From 60a484aa0a22299db451ed4f2d9ba8062096cf93 Mon Sep 17 00:00:00 2001 From: Marco D'Alia Date: Sat, 6 Jun 2026 14:38:29 +0000 Subject: [PATCH] feat(integrations): notion in-box shim + image provisioning + enable flag (T2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Lets agents in a sandbox type `notion …` / `ntn …` and have the call routed through the host relay to the host's authenticated `ntn` CLI; gated by a new typed config flag so the integration is opt-in. Surface - packages/sandbox-docker/scripts/ntn-shim — bash shim modeled on gh-shim. Strict allowlist: `whoami`, `api `, `pages create`, `pages update`. Anything else dies with a clear message. Installed at /usr/local/bin/ntn; /usr/local/bin/notion is a symlink to the same shim (per docs/integrations_backlog.md's per-service surface naming). Image provisioning (all providers except daytona, which stays shim-less to match its T1 gh/git decision) - Dockerfile.box: COPY ntn-shim, chmod, symlink notion. - apps/cli/scripts/stage-runtime.mjs: stage ntn-shim into the docker contextFiles + execBitFiles plus the hetzner / vercel / e2b file lists. - Hetzner install-box.sh, Vercel provision.sh, E2B build-template.sh: install + symlink the shim. - Each provider's runtime-assets.ts: map the shim name + remote /tmp path so the staged file gets uploaded into the prepare VM. Config flag (opt-in by default) - packages/config/src/types.ts: new `integrations.notion.enabled` key (UserConfig, EffectiveConfig, BUILT_IN_DEFAULTS, KEY_REGISTRY entry). - parse.ts / load.ts / write.ts: parser, merger, and writer now walk N-level dotted leaves so the 3-level path is natural YAML (`integrations: { notion: { enabled: true } }`). Relay gate (cross-provider) - packages/relay/src/integrations.ts: `refuseIfIntegrationDisabled` re-reads the layered config per call (matches loadAutopauseConfig). Disabled → exit 65 with a config-set hint; no host process touched. - Wired into both `handleIntegrationRpc` (server.ts, docker) and `runIntegrationRpc` (host-actions.ts, daytona/hetzner/vercel/e2b) per the "fix across all providers" rule. Order: refuseIntegrationCall first (op-level), then enablement gate, then readiness probe, then prompt. Same order on both providers — a malformed-args-to-disabled- integration call returns the same envelope shape. Connector cleanup (T1 minimal change) - packages/integrations/src/connectors/notion.ts: drop `comment.add`. `ntn` exposes no top-level `comment` subcommand; the only host path is `ntn api v1/comments -X POST -f …` which the T1 `api` op refuses (GET-only). No callers exist (T1 just merged), so a forward-only drop is cleaner than carrying dead surface. The shim refuses `notion comment add …` with a "deferred from T2" message. Comments tracked as a focused follow-up (need a Notion-API-aware payload translator that maps CLI flags to the structured POST body). - Added `whoami` read op so `ntn whoami` doesn't widen the `api` allowlist. The connector already declared `api v1/users/me` as the T3 doctor probe — `whoami` reuses the same auth check via its dedicated host CLI subcommand. Tests (vitest, no docker, no network) - packages/ctl/test/gh-and-shims.test.ts: extended with NTN_SHIM cases mirroring the gh / git shim patterns. - packages/config/test/merge-precedence.test.ts + set-unset-roundtrip.test.ts: cover the new 3-level cascade and the YAML pruning roundtrip. - packages/relay/test/integrations.test.ts: workspace agentbox.yaml in beforeEach now flips the integration on; new tests for the disabled envelope and the injectable-loader unit shape. - packages/relay/test/host-actions.test.ts: parity check for the cloud gate. Docs - docs/notion_backlog.md: T2 flipped to done with the comment-handling + gate-placement decisions and the comments-deferred note. T3 (doctor + docs site) and T4 (nested-box e2e) remain. --- apps/cli/scripts/stage-runtime.mjs | 5 + docs/notion_backlog.md | 58 +++++-- packages/config/src/load.ts | 28 ++- packages/config/src/parse.ts | 69 ++++++-- packages/config/src/types.ts | 19 +++ packages/config/src/write.ts | 42 +++-- packages/config/test/merge-precedence.test.ts | 34 ++++ .../config/test/set-unset-roundtrip.test.ts | 23 +++ packages/ctl/test/gh-and-shims.test.ts | 160 ++++++++++++++++++ .../integrations/src/connectors/notion.ts | 27 +-- packages/integrations/test/registry.test.ts | 14 +- packages/relay/src/host-actions.ts | 19 ++- packages/relay/src/integrations.ts | 40 +++++ packages/relay/src/server.ts | 16 ++ packages/relay/test/host-actions.test.ts | 24 +++ packages/relay/test/integrations.test.ts | 84 +++++++++ packages/sandbox-docker/Dockerfile.box | 9 + packages/sandbox-docker/scripts/ntn-shim | 93 ++++++++++ .../sandbox-e2b/scripts/build-template.sh | 9 +- packages/sandbox-e2b/src/runtime-assets.ts | 3 + .../sandbox-hetzner/scripts/install-box.sh | 17 +- .../sandbox-hetzner/src/runtime-assets.ts | 3 + .../test/runtime-assets.test.ts | 1 + packages/sandbox-vercel/scripts/provision.sh | 9 +- packages/sandbox-vercel/src/runtime-assets.ts | 3 + 25 files changed, 726 insertions(+), 83 deletions(-) create mode 100755 packages/sandbox-docker/scripts/ntn-shim diff --git a/apps/cli/scripts/stage-runtime.mjs b/apps/cli/scripts/stage-runtime.mjs index 70e15a80..78c91acb 100644 --- a/apps/cli/scripts/stage-runtime.mjs +++ b/apps/cli/scripts/stage-runtime.mjs @@ -43,6 +43,7 @@ const execBitFiles = new Set([ 'packages/sandbox-docker/scripts/agentbox-open', 'packages/sandbox-docker/scripts/gh-shim', 'packages/sandbox-docker/scripts/git-shim', + 'packages/sandbox-docker/scripts/ntn-shim', 'packages/sandbox-docker/scripts/chromium-resolver', ]); const contextFiles = [ @@ -54,6 +55,7 @@ const contextFiles = [ 'packages/sandbox-docker/scripts/agentbox-open', 'packages/sandbox-docker/scripts/gh-shim', 'packages/sandbox-docker/scripts/git-shim', + 'packages/sandbox-docker/scripts/ntn-shim', 'packages/sandbox-docker/scripts/chromium-resolver', 'packages/sandbox-docker/scripts/custom-system-CLAUDE.md', 'packages/sandbox-docker/scripts/claude-managed-settings.json', @@ -98,6 +100,7 @@ const hetznerFiles = [ ['packages/sandbox-docker/scripts/agentbox-open', 'agentbox-open', true], ['packages/sandbox-docker/scripts/gh-shim', 'gh-shim', true], ['packages/sandbox-docker/scripts/git-shim', 'git-shim', true], + ['packages/sandbox-docker/scripts/ntn-shim', 'ntn-shim', true], ['packages/sandbox-hetzner/scripts/custom-system-CLAUDE.md', 'custom-system-CLAUDE.md', false], ['packages/sandbox-docker/scripts/claude-managed-settings.json', 'claude-managed-settings.json', false], ['packages/sandbox-docker/scripts/agentbox-codex-hooks.json', 'agentbox-codex-hooks.json', false], @@ -134,6 +137,7 @@ const vercelFiles = [ ['packages/sandbox-docker/scripts/agentbox-open', 'agentbox-open', true], ['packages/sandbox-docker/scripts/gh-shim', 'gh-shim', true], ['packages/sandbox-docker/scripts/git-shim', 'git-shim', true], + ['packages/sandbox-docker/scripts/ntn-shim', 'ntn-shim', true], ['packages/sandbox-vercel/scripts/custom-system-CLAUDE.md', 'custom-system-CLAUDE.md', false], ['packages/sandbox-docker/scripts/claude-managed-settings.json', 'claude-managed-settings.json', false], ['packages/sandbox-docker/scripts/agentbox-codex-hooks.json', 'agentbox-codex-hooks.json', false], @@ -159,6 +163,7 @@ const e2bFiles = [ ['packages/sandbox-docker/scripts/agentbox-open', 'agentbox-open', true], ['packages/sandbox-docker/scripts/gh-shim', 'gh-shim', true], ['packages/sandbox-docker/scripts/git-shim', 'git-shim', true], + ['packages/sandbox-docker/scripts/ntn-shim', 'ntn-shim', true], ['packages/sandbox-e2b/scripts/custom-system-CLAUDE.md', 'custom-system-CLAUDE.md', false], ['packages/sandbox-docker/scripts/claude-managed-settings.json', 'claude-managed-settings.json', false], ['packages/sandbox-docker/scripts/agentbox-codex-hooks.json', 'agentbox-codex-hooks.json', false], diff --git a/docs/notion_backlog.md b/docs/notion_backlog.md index bbd2f340..bedf03c9 100644 --- a/docs/notion_backlog.md +++ b/docs/notion_backlog.md @@ -52,17 +52,45 @@ through the relay to host `ntn`, with read/write classification + write gating. - Unit tests: op read/write classification; allowlist denies unknown ops; dispatch gates writes (askPrompt called) and not reads; denied → exit 10. -### T2 — In-box `notion` shim + image provisioning + config flags ⬜ not started -Make a box agent able to type `notion …`. -- `packages/sandbox-docker/scripts/notion-shim` (gh-shim pattern: strict - subcommand/flag allowlist → `agentbox-ctl integration notion -- "$@"`). -- Stage it: add to `contextFiles` + `execBitFiles` in - `apps/cli/scripts/stage-runtime.mjs`; COPY in `Dockerfile.box` near the - `gh-shim`/`git-shim` COPY; mirror into - `packages/sandbox-hetzner/scripts/install-box.sh` and the cloud runtime lists. -- Config: add `integrations` block to `packages/config/src/types.ts` - (`integrations.notion.enabled`, default off) + `BUILT_IN_DEFAULTS`. Disabled → - shim not installed / ctl refuses. +### T2 — In-box `notion` shim + image provisioning + config flags ✅ done +Make a box agent able to type `notion …` or `ntn …`. +- `packages/sandbox-docker/scripts/ntn-shim` (gh-shim pattern: strict + subcommand allowlist → `agentbox-ctl integration notion -- "$@"`). + Installed on PATH as `/usr/local/bin/ntn`; `/usr/local/bin/notion` is a + symlink to it. Same shim for both invocations. +- Staged: `contextFiles` + `execBitFiles` in `apps/cli/scripts/stage-runtime.mjs` + plus the `hetznerFiles` / `vercelFiles` / `e2bFiles` lists; COPY'd in + `Dockerfile.box` next to the `gh-shim`/`git-shim` COPY; mirrored into + `packages/sandbox-hetzner/scripts/install-box.sh`, + `packages/sandbox-vercel/scripts/provision.sh`, and + `packages/sandbox-e2b/scripts/build-template.sh` (plus each provider's + `src/runtime-assets.ts` so the staged file gets uploaded). Daytona stays + shim-less (matches its T1 gh/git decision). +- Config: added `integrations.notion.enabled` (default **false**) to + `packages/config/src/types.ts` — `UserConfig`, `EffectiveConfig`, + `BUILT_IN_DEFAULTS`, and `KEY_REGISTRY`. Parser/merger/writer were taught + to walk 3-level nested keys (`branch.subbranch.leaf`) so the YAML stays + natural. Set with `agentbox config set --project integrations.notion.enabled true`. +- Gate placement: the **relay** (`refuseIfIntegrationDisabled` in + `packages/relay/src/integrations.ts`, wired into BOTH + `handleIntegrationRpc` in `server.ts` (docker) and `runIntegrationRpc` + in `host-actions.ts` (cloud — daytona/hetzner/vercel/e2b) per the + "fix across all providers" rule). One check covers every caller + (shim / `notion` alias / direct `agentbox-ctl integration` / future + host-initiated tokens) and re-reads the layered config per call so a + flag flip takes effect without bouncing the relay (same approach as + `loadAutopauseConfig`). Disabled → exit 65 with a `agentbox config set …` + hint; no host process is touched. +- Connector cleanup (minimal): the T1 `comment.add` op is **dropped**. + `ntn` exposes no top-level `comment` subcommand — the only host path + would be `ntn api v1/comments -X POST -f …`, which the T1 `api` op + refuses (GET-only). The op also had no callers (T1 just merged, no shim + yet), so a forward-only drop is cleaner than carrying dead surface + through. The shim refuses `notion comment add …` with a clear + "deferred from T2" message; comments are tracked as a focused + follow-up (will need a Notion-API-aware payload assembly that maps + flag args to the structured POST body). Added a `whoami` read op so + `ntn whoami` doesn't have to widen the `api` allowlist. ### T3 — `agentbox doctor` detection + docs ⬜ not started - `agentbox doctor`: report `ntn` presence + auth (`ntn whoami` / `ntn doctor`), @@ -87,3 +115,11 @@ Make a box agent able to type `notion …`. probe), generic `integration..` dispatch wired into both `server.ts` (docker) and `host-actions.ts` (cloud), and `agentbox-ctl integration` command tree. PR pending. +- 2026-06-06: T2 shipped — `ntn-shim` + `notion` symlink on PATH across + docker/hetzner/vercel/e2b; `integrations.notion.enabled` (default false) + added to the typed config (with nested-key support in parser/merger/ + writer); host-side enable gate in `handleIntegrationRpc` returning exit + 65 with a config-hint when disabled; connector cleanup (dropped + `comment.add`, added `whoami` read op). Comments deferred to a focused + follow-up — they need a Notion-API-aware payload translator that maps + CLI flags to the structured `POST /v1/comments` body. diff --git a/packages/config/src/load.ts b/packages/config/src/load.ts index 8824a374..f4843f97 100644 --- a/packages/config/src/load.ts +++ b/packages/config/src/load.ts @@ -175,9 +175,12 @@ function readLeaf( branch: string, leaf: string, ): unknown { - const b = (obj as Record)[branch]; - if (b === undefined || b === null || typeof b !== 'object') return undefined; - return (b as Record)[leaf]; + let cur: unknown = (obj as Record)[branch]; + for (const seg of leaf.split('.')) { + if (cur === undefined || cur === null || typeof cur !== 'object') return undefined; + cur = (cur as Record)[seg]; + } + return cur; } function writeLeaf( @@ -186,7 +189,20 @@ function writeLeaf( leaf: string, value: unknown, ): void { - const b = (obj as unknown as Record>)[branch]; - if (!b) return; // BUILT_IN_DEFAULTS guarantees the branch exists - b[leaf] = value; + let cur: Record | undefined = + (obj as unknown as Record>)[branch]; + if (!cur) return; // BUILT_IN_DEFAULTS guarantees the branch exists + const segs = leaf.split('.'); + for (let i = 0; i < segs.length - 1; i++) { + const seg = segs[i]!; + const next = cur[seg]; + if (next === undefined || next === null || typeof next !== 'object') { + // BUILT_IN_DEFAULTS guarantees nested sub-objects exist for every + // registered key path, so this is unreachable in practice; defaulting + // to a fresh sub-object keeps the function total. + cur[seg] = {}; + } + cur = cur[seg] as Record; + } + cur[segs[segs.length - 1]!] = value; } diff --git a/packages/config/src/parse.ts b/packages/config/src/parse.ts index 4ad15a3b..b56a1dc6 100644 --- a/packages/config/src/parse.ts +++ b/packages/config/src/parse.ts @@ -135,23 +135,7 @@ export function parseUserConfigObject(doc: unknown, where: string): Partial = {}; - for (const [leafName, leafRaw] of Object.entries(branchRaw)) { - const desc = branchSpec.leaves.get(leafName); - if (!desc) { - const renamedTo = RENAMED_KEYS.get(`${branchName}.${leafName}`); - if (renamedTo) { - throw new UserConfigError( - `${where}.${branchName}.${leafName} was renamed to ${renamedTo} — update your config`, - ); - } - throw new UserConfigError( - `${where}.${branchName}: unknown key "${leafName}" (known: ${[...branchSpec.leaves.keys()].join(', ')})`, - ); - } - if (leafRaw === undefined) continue; - branchOut[leafName] = coerceTypedValue(leafRaw, desc, `${where}.${desc.key}`); - } + const branchOut = parseBranchObject(branchSpec, branchName, branchRaw, '', where); if (Object.keys(branchOut).length > 0) { // We've validated that each branch matches one of UserConfig's known // sub-objects; the indexed write keeps the union type happy. @@ -161,6 +145,57 @@ export function parseUserConfigObject(doc: unknown, where: string): Partial, + qualifiedPrefix: string, + where: string, +): Record { + const out: Record = {}; + for (const [name, value] of Object.entries(raw)) { + if (value === undefined) continue; + const qualified = qualifiedPrefix ? `${qualifiedPrefix}.${name}` : name; + const desc = branchSpec.leaves.get(qualified); + if (desc) { + out[name] = coerceTypedValue(value, desc, `${where}.${desc.key}`); + continue; + } + // Not a leaf — descend if it's a mapping AND a deeper leaf is registered + // beneath this path. Otherwise the key is unknown / not in the registry. + if (isPlainObject(value) && branchHasLeafBelow(branchSpec, qualified)) { + const sub = parseBranchObject(branchSpec, branchName, value, qualified, where); + if (Object.keys(sub).length > 0) out[name] = sub; + continue; + } + const renamedTo = RENAMED_KEYS.get(`${branchName}.${qualified}`); + if (renamedTo) { + throw new UserConfigError( + `${where}.${branchName}.${qualified} was renamed to ${renamedTo} — update your config`, + ); + } + throw new UserConfigError( + `${where}.${branchName}: unknown key "${qualified}" (known: ${[...branchSpec.leaves.keys()].join(', ')})`, + ); + } + return out; +} + +function branchHasLeafBelow(branchSpec: BranchSpec, prefix: string): boolean { + const needle = `${prefix}.`; + for (const leaf of branchSpec.leaves.keys()) { + if (leaf.startsWith(needle)) return true; + } + return false; +} + /** * Coerce a string (e.g. typed at the CLI by `agentbox config set`) into the * declared type for `key`. Booleans accept true/false/yes/no/1/0 (case diff --git a/packages/config/src/types.ts b/packages/config/src/types.ts index 8f51a790..00f82ef1 100644 --- a/packages/config/src/types.ts +++ b/packages/config/src/types.ts @@ -149,6 +149,11 @@ export interface UserConfig { pruneProjectConfigs?: boolean; pruneProjectConfigsEvery?: number; }; + integrations?: { + notion?: { + enabled?: boolean; + }; + }; } /** @@ -265,6 +270,11 @@ export interface EffectiveConfig { pruneProjectConfigs: boolean; pruneProjectConfigsEvery: number; }; + integrations: { + notion: { + enabled: boolean; + }; + }; } export type ConfigSource = 'cli' | 'workspace' | 'project' | 'global' | 'default'; @@ -402,6 +412,9 @@ export const BUILT_IN_DEFAULTS: EffectiveConfig = { pruneProjectConfigs: true, pruneProjectConfigsEvery: 50, }, + integrations: { + notion: { enabled: false }, + }, }; export type KeyType = 'bool' | 'string' | 'int' | 'enum'; @@ -851,6 +864,12 @@ export const KEY_REGISTRY: readonly KeyDescriptor[] = [ type: 'int', description: 'Run the orphan project-config sweep every N successful `agentbox create`.', }, + { + key: 'integrations.notion.enabled', + type: 'bool', + description: + 'Enable the in-box Notion integration shim (`ntn`/`notion` commands routed via the host relay). When false (default), the relay refuses dispatch with a clear "disabled" error and no host process is touched.', + }, ]; const REGISTRY_BY_KEY = new Map(KEY_REGISTRY.map((d) => [d.key, d])); diff --git a/packages/config/src/write.ts b/packages/config/src/write.ts index a340c4a1..25553c42 100644 --- a/packages/config/src/write.ts +++ b/packages/config/src/write.ts @@ -272,26 +272,36 @@ function stampSchema(doc: Partial): void { } function setLeaf(doc: Partial, key: string, value: unknown): void { - const idx = key.indexOf('.'); - const branch = key.slice(0, idx); - const leaf = key.slice(idx + 1); - const root = doc as unknown as Record>; - if (!root[branch] || typeof root[branch] !== 'object') { - root[branch] = {}; + const segs = key.split('.'); + let cur = doc as unknown as Record; + for (let i = 0; i < segs.length - 1; i++) { + const seg = segs[i]!; + const next = cur[seg]; + if (!next || typeof next !== 'object') { + cur[seg] = {}; + } + cur = cur[seg] as Record; } - root[branch][leaf] = value; + cur[segs[segs.length - 1]!] = value; } function unsetLeaf(doc: Partial, key: string): boolean { - const idx = key.indexOf('.'); - const branch = key.slice(0, idx); - const leaf = key.slice(idx + 1); - const root = doc as unknown as Record>; - const b = root[branch]; - if (!b || typeof b !== 'object' || !(leaf in b)) return false; - delete b[leaf]; - if (Object.keys(b).length === 0) { - delete root[branch]; + const segs = key.split('.'); + const path: Record[] = [doc as unknown as Record]; + for (let i = 0; i < segs.length - 1; i++) { + const seg = segs[i]!; + const next = path[path.length - 1]![seg]; + if (!next || typeof next !== 'object') return false; + path.push(next as Record); + } + const leafSeg = segs[segs.length - 1]!; + const leafContainer = path[path.length - 1]!; + if (!(leafSeg in leafContainer)) return false; + delete leafContainer[leafSeg]; + // Prune empty parent objects from leaf-most up so the YAML stays tidy. + for (let i = path.length - 1; i > 0; i--) { + if (Object.keys(path[i]!).length > 0) break; + delete path[i - 1]![segs[i - 1]!]; } return true; } diff --git a/packages/config/test/merge-precedence.test.ts b/packages/config/test/merge-precedence.test.ts index d34d6cea..3d84f658 100644 --- a/packages/config/test/merge-precedence.test.ts +++ b/packages/config/test/merge-precedence.test.ts @@ -92,4 +92,38 @@ describe('layered merge precedence', () => { expect(r.layers.workspace.values).toEqual({}); expect(r.effective.engine.kind).toBe('docker-desktop'); }); + + // Nested 3-level path (branch.subbranch.leaf) — the parser, merger, and + // writer all needed teaching to walk dotted leaves. Worth its own cascade + // test so a future refactor doesn't silently regress the integrations + // surface. + it('integrations.notion.enabled defaults to false', async () => { + const r = await loadEffectiveConfig(tmpCwd); + expect(r.effective.integrations.notion.enabled).toBe(false); + expect(r.sources['integrations.notion.enabled']).toBe('default'); + }); + + it('integrations.notion.enabled cascades global → project → cli', async () => { + await writeYamlAt( + GLOBAL_CONFIG_FILE, + 'integrations:\n notion:\n enabled: true\n', + ); + const fromGlobal = await loadEffectiveConfig(tmpCwd); + expect(fromGlobal.effective.integrations.notion.enabled).toBe(true); + expect(fromGlobal.sources['integrations.notion.enabled']).toBe('global'); + + await writeYamlAt( + projectConfigFile(tmpCwd), + 'integrations:\n notion:\n enabled: false\n', + ); + const fromProject = await loadEffectiveConfig(tmpCwd); + expect(fromProject.effective.integrations.notion.enabled).toBe(false); + expect(fromProject.sources['integrations.notion.enabled']).toBe('project'); + + const fromCli = await loadEffectiveConfig(tmpCwd, { + cliOverrides: { integrations: { notion: { enabled: true } } }, + }); + expect(fromCli.effective.integrations.notion.enabled).toBe(true); + expect(fromCli.sources['integrations.notion.enabled']).toBe('cli'); + }); }); diff --git a/packages/config/test/set-unset-roundtrip.test.ts b/packages/config/test/set-unset-roundtrip.test.ts index 8692eb81..cd22242d 100644 --- a/packages/config/test/set-unset-roundtrip.test.ts +++ b/packages/config/test/set-unset-roundtrip.test.ts @@ -71,4 +71,27 @@ describe('set/unset roundtrip', () => { setConfigValue('global', 'code.timeoutMs', 'banana', tmpCwd, { raw: true }), ).rejects.toThrow(); }); + + it('roundtrips a 3-level dotted key (integrations.notion.enabled)', async () => { + await setConfigValue('project', 'integrations.notion.enabled', 'true', tmpCwd, { + raw: true, + }); + const yaml = parseYaml(await readFile(projectConfigFile(tmpCwd), 'utf8')) as Record< + string, + unknown + >; + expect(yaml['integrations']).toEqual({ notion: { enabled: true } }); + const loaded = await loadEffectiveConfig(tmpCwd); + expect(loaded.effective.integrations.notion.enabled).toBe(true); + expect(loaded.sources['integrations.notion.enabled']).toBe('project'); + + await unsetConfigValue('project', 'integrations.notion.enabled', tmpCwd); + const after = + (parseYaml(await readFile(projectConfigFile(tmpCwd), 'utf8')) as + | Record + | null) ?? {}; + // Both the deepest leaf AND the empty `notion` / `integrations` parents + // must be pruned so the YAML stays tidy. + expect(after).not.toHaveProperty('integrations'); + }); }); diff --git a/packages/ctl/test/gh-and-shims.test.ts b/packages/ctl/test/gh-and-shims.test.ts index a9fd0c80..0e6b7c27 100644 --- a/packages/ctl/test/gh-and-shims.test.ts +++ b/packages/ctl/test/gh-and-shims.test.ts @@ -8,6 +8,7 @@ import { postRpc } from '../src/relay-rpc.js'; const REPO_ROOT = join(import.meta.dirname, '..', '..', '..'); const GH_SHIM = join(REPO_ROOT, 'packages/sandbox-docker/scripts/gh-shim'); const GIT_SHIM = join(REPO_ROOT, 'packages/sandbox-docker/scripts/git-shim'); +const NTN_SHIM = join(REPO_ROOT, 'packages/sandbox-docker/scripts/ntn-shim'); interface StubShellEnv { tmpDir: string; @@ -581,3 +582,162 @@ describe('git-shim arg whitelist + passthrough', () => { } }); }); + +describe('ntn-shim subcommand allowlist', () => { + it('whoami forwards to integration notion whoami', () => { + const env = makeStubShell(); + try { + const out = runShim(NTN_SHIM, ['whoami'], env); + expect(out.code).toBe(0); + expect(out.stdout).toContain('STUB: integration notion whoami --'); + } finally { + env.cleanup(); + } + }); + + it('api endpoint forwards to integration notion api', () => { + const env = makeStubShell(); + try { + const out = runShim(NTN_SHIM, ['api', 'v1/users/me'], env); + expect(out.code).toBe(0); + expect(out.stdout).toContain('STUB: integration notion api -- v1/users/me'); + } finally { + env.cleanup(); + } + }); + + it('api forwards write-shaped argv intact (relay enforces GET-only)', () => { + // The shim does NOT replicate refuseApiNonGet — that's the relay's job. + // It must hand through -X POST / -f field=value so the relay sees the + // real argv and can refuse, instead of the agent thinking the call + // succeeded silently. + const env = makeStubShell(); + try { + const out = runShim( + NTN_SHIM, + ['api', 'v1/pages', '-X', 'POST', '-f', 'title=hi'], + env, + ); + expect(out.code).toBe(0); + expect(out.stdout).toContain( + 'STUB: integration notion api -- v1/pages -X POST -f title=hi', + ); + } finally { + env.cleanup(); + } + }); + + it('api with no endpoint is rejected', () => { + const env = makeStubShell(); + try { + const out = runShim(NTN_SHIM, ['api'], env); + expect(out.code).toBe(2); + expect(out.stderr).toMatch(/'api' requires a positional /); + } finally { + env.cleanup(); + } + }); + + it('pages create forwards to integration notion page.create', () => { + const env = makeStubShell(); + try { + const out = runShim( + NTN_SHIM, + ['pages', 'create', '--parent', 'db_id', '--title', 'hi'], + env, + ); + expect(out.code).toBe(0); + expect(out.stdout).toContain( + 'STUB: integration notion page.create -- --parent db_id --title hi', + ); + } finally { + env.cleanup(); + } + }); + + it('pages update forwards to integration notion page.update', () => { + const env = makeStubShell(); + try { + const out = runShim(NTN_SHIM, ['pages', 'update', 'page_id', '--archive'], env); + expect(out.code).toBe(0); + expect(out.stdout).toContain( + 'STUB: integration notion page.update -- page_id --archive', + ); + } finally { + env.cleanup(); + } + }); + + it('pages list is rejected', () => { + const env = makeStubShell(); + try { + const out = runShim(NTN_SHIM, ['pages', 'list'], env); + expect(out.code).toBe(2); + expect(out.stderr).toMatch(/unsupported 'pages list'/); + } finally { + env.cleanup(); + } + }); + + it('pages with no subcommand is rejected', () => { + const env = makeStubShell(); + try { + const out = runShim(NTN_SHIM, ['pages'], env); + expect(out.code).toBe(2); + expect(out.stderr).toMatch(/missing subcommand for 'pages'/); + } finally { + env.cleanup(); + } + }); + + it('comment add is rejected with the deferred message', () => { + const env = makeStubShell(); + try { + const out = runShim(NTN_SHIM, ['comment', 'add', '--page', 'pid'], env); + expect(out.code).toBe(2); + expect(out.stderr).toMatch(/comment ops not supported yet/); + } finally { + env.cleanup(); + } + }); + + it.each([['login'], ['logout'], ['datasources'], ['workers'], ['files']])( + 'unsupported subcommand %s is rejected with the allowed list', + (sub) => { + const env = makeStubShell(); + try { + const out = runShim(NTN_SHIM, [sub], env); + expect(out.code).toBe(2); + expect(out.stderr).toMatch(/is not proxied/); + expect(out.stderr).toMatch( + /whoami, api , pages \{create,update\}/, + ); + } finally { + env.cleanup(); + } + }, + ); + + it('--version prints the shim version line', () => { + const env = makeStubShell(); + try { + const out = runShim(NTN_SHIM, ['--version'], env); + expect(out.code).toBe(0); + expect(out.stdout).toMatch(/^ntn version /); + expect(out.stdout).toContain('agentbox-shim'); + } finally { + env.cleanup(); + } + }); + + it('no args fails with the supported-subcommands hint', () => { + const env = makeStubShell(); + try { + const out = runShim(NTN_SHIM, [], env); + expect(out.code).toBe(2); + expect(out.stderr).toMatch(/no subcommand/); + } finally { + env.cleanup(); + } + }); +}); diff --git a/packages/integrations/src/connectors/notion.ts b/packages/integrations/src/connectors/notion.ts index 94d2a506..f2af34c4 100644 --- a/packages/integrations/src/connectors/notion.ts +++ b/packages/integrations/src/connectors/notion.ts @@ -4,11 +4,18 @@ import type { IntegrationConnector, IntegrationOpRefusal } from '../types.js'; * Notion connector — wraps the official `ntn` CLI (beta, first-party). * * The op allowlist is intentionally minimal (start conservative, widen as - * real agent flows surface needs). One read passthrough (`ntn api …` for - * GETs against the v1 REST surface) plus three gated writes. The `api` - * passthrough is GET-only — `refuseApiNonGet` parses `-X`/`--method`/`-f`/`-F` - * (and their glued forms) the same way `refuseGhApiCall` does, so an - * agent can't slip a POST/PATCH/DELETE past the "read" classification. + * real agent flows surface needs). Two read passthroughs (`ntn whoami` and + * `ntn api …` for GETs against the v1 REST surface) plus two gated writes. + * The `api` passthrough is GET-only — `refuseApiNonGet` parses + * `-X`/`--method`/`-f`/`-F` (and their glued forms) the same way + * `refuseGhApiCall` does, so an agent can't slip a POST/PATCH/DELETE past + * the "read" classification. + * + * Comment creation is intentionally absent: `ntn` exposes no top-level + * `comment` subcommand (the official surface is `api datasources files + * pages login logout whoami workers`), and Notion's REST POST `/v1/comments` + * takes a structured JSON body that doesn't trivially map from CLI flags. + * Adding it is tracked as a focused follow-up — see `docs/notion_backlog.md`. * * `NOTION_KEYRING=0` is forced in the env so `ntn` reads file-based auth * (`~/.config/notion/auth.json`). On the macOS host this var is harmless @@ -25,6 +32,10 @@ export const notionConnector: IntegrationConnector = { }, env: { NOTION_KEYRING: '0' }, ops: { + whoami: { + write: false, + buildArgv: (args) => ['whoami', ...args], + }, api: { write: false, buildArgv: (args) => ['api', ...args], @@ -38,10 +49,6 @@ export const notionConnector: IntegrationConnector = { write: true, buildArgv: (args) => ['page', 'update', ...args], }, - 'comment.add': { - write: true, - buildArgv: (args) => ['comment', 'add', ...args], - }, }, }; @@ -103,6 +110,6 @@ function refuseApiNonGet(args: readonly string[]): IntegrationOpRefusal | null { const method = (explicitMethod ?? (hasFieldFlag ? 'POST' : 'GET')).toUpperCase(); if (method === 'GET') return null; return refuse( - `only GET is proxied (use page.create / page.update / comment.add for writes); detected method '${method}'`, + `only GET is proxied (use page.create / page.update for writes); detected method '${method}'`, ); } diff --git a/packages/integrations/test/registry.test.ts b/packages/integrations/test/registry.test.ts index 2cafe18e..dcc01977 100644 --- a/packages/integrations/test/registry.test.ts +++ b/packages/integrations/test/registry.test.ts @@ -28,14 +28,15 @@ describe('notion connector', () => { expect(notionConnector.env).toMatchObject({ NOTION_KEYRING: '0' }); }); - it('classifies api as read and the page/comment ops as write', () => { + it('classifies whoami/api as read and the page ops as write', () => { + expect(notionConnector.ops.whoami?.write).toBe(false); expect(notionConnector.ops.api?.write).toBe(false); expect(notionConnector.ops['page.create']?.write).toBe(true); expect(notionConnector.ops['page.update']?.write).toBe(true); - expect(notionConnector.ops['comment.add']?.write).toBe(true); }); it('shapes argv so the connector — not the call site — owns the host CLI surface', () => { + expect(notionConnector.ops.whoami?.buildArgv?.([])).toEqual(['whoami']); expect(notionConnector.ops.api?.buildArgv?.(['v1/users/me'])).toEqual([ 'api', 'v1/users/me', @@ -52,18 +53,11 @@ describe('notion connector', () => { 'page_id', '--archive', ]); - expect(notionConnector.ops['comment.add']?.buildArgv?.(['--page', 'pid', 'hi'])).toEqual([ - 'comment', - 'add', - '--page', - 'pid', - 'hi', - ]); }); it('has no ops beyond the conservative starter allowlist', () => { expect(Object.keys(notionConnector.ops).sort()).toEqual( - ['api', 'comment.add', 'page.create', 'page.update'].sort(), + ['api', 'page.create', 'page.update', 'whoami'].sort(), ); }); }); diff --git a/packages/relay/src/host-actions.ts b/packages/relay/src/host-actions.ts index c510f7c3..635f3e47 100644 --- a/packages/relay/src/host-actions.ts +++ b/packages/relay/src/host-actions.ts @@ -45,6 +45,7 @@ import { assertIntegrationReady, makeIntegrationOpRefusal, parseIntegrationMethod, + refuseIfIntegrationDisabled, refuseIntegrationCall, runHostIntegration, type IntegrationRpcParams, @@ -516,6 +517,22 @@ async function runIntegrationRpc( const callRefusal = refuseIntegrationCall(opDesc, args); if (callRefusal) return callRefusal; + // Cloud boxes don't register worktrees the same way docker boxes do; the + // closest analogue is `lookupCloudBox`'s `workspacePath` (the host-side + // path the cloud provider records as the box's project root). Use it to + // read the layered config and fire the enablement gate — same envelope + // shape the docker handler returns. Placed after `refuseIntegrationCall` + // so the structural / op-level checks (which don't need the box record) + // still short-circuit cleanly on a malformed registry; the gate runs + // before `assertIntegrationReady`, the prompt, and the host spawn so a + // disabled integration is never user-visible as a permission prompt. + const lookup = await lookupCloudBox(deps.boxId); + const enableRefusal = await refuseIfIntegrationDisabled( + parsed.service, + lookup.workspacePath, + ); + if (enableRefusal) return enableRefusal; + const ready = await assertIntegrationReady(connector); if (ready) return ready; @@ -549,10 +566,10 @@ async function runIntegrationRpc( } } - const lookup = await lookupCloudBox(deps.boxId); return runHostIntegration(connector, opDesc, args, lookup.workspacePath); } + /** * Mirror an in-box `browser.open` notification on the host. The action runs * detached from the box's `/rpc` (the in-box handler responded 200 long diff --git a/packages/relay/src/integrations.ts b/packages/relay/src/integrations.ts index 022d107a..fa4b23f7 100644 --- a/packages/relay/src/integrations.ts +++ b/packages/relay/src/integrations.ts @@ -13,6 +13,7 @@ import { spawn } from 'node:child_process'; import type { IntegrationConnector, IntegrationOp } from '@agentbox/integrations'; +import { loadEffectiveConfig } from '@agentbox/config'; import type { GitRpcResult } from './types.js'; /** Wire params for every `integration..` method. Mirrors GhPrRpcParams. */ @@ -273,3 +274,42 @@ export function refuseIntegrationCall( if (!refusal) return null; return { exitCode: refusal.exitCode, stdout: '', stderr: refusal.stderr }; } + +/** + * Returns null when the integration is enabled for the box's project (so the + * dispatch may proceed), else a ready-to-send refusal envelope. Re-reads the + * layered config fresh on every call so toggling + * `integrations..enabled` takes effect without bouncing the relay — + * same approach `loadAutopauseConfig` uses for the autopause loop. + * + * Layered (cli/workspace/project/global/default) so a single project can opt + * in without globally enabling Notion. Defaults to disabled — every + * integration ships in the image but is inert until flipped on. + * + * Injectable `loader` keeps unit tests off-disk. + */ +export async function refuseIfIntegrationDisabled( + service: string, + cwd: string, + loader: (cwd: string) => Promise<{ + effective: { integrations?: Record }; + }> = loadEffectiveConfig, +): Promise { + let enabled = false; + try { + const cfg = await loader(cwd); + enabled = cfg.effective.integrations?.[service]?.enabled === true; + } catch { + // A malformed config file should fail closed — the box can't do anything + // useful with a half-loaded config, and the agent gets a clear message + // either way. + } + if (enabled) return null; + return { + exitCode: 65, + stdout: '', + stderr: + `${service} integration is disabled — enable with ` + + `\`agentbox config set --project integrations.${service}.enabled true\`\n`, + }; +} diff --git a/packages/relay/src/server.ts b/packages/relay/src/server.ts index 9a109a97..051be789 100644 --- a/packages/relay/src/server.ts +++ b/packages/relay/src/server.ts @@ -32,6 +32,7 @@ import { assertIntegrationReady, makeIntegrationOpRefusal, parseIntegrationMethod, + refuseIfIntegrationDisabled, refuseIntegrationCall, runHostIntegration, type IntegrationRpcParams, @@ -1408,6 +1409,21 @@ async function handleIntegrationRpc( const callRefusal = refuseIntegrationCall(opDesc, args); if (callRefusal) return callRefusal; + // Layered enablement gate — the in-box shim and ctl both transparently + // forward to here, so this one check covers every caller. Reads the + // worktree's project config so a single project can opt in without + // flipping it globally. Placed after `refuseIntegrationCall` so the + // ordering matches the cloud handler (`runIntegrationRpc` in + // host-actions.ts) — keeps the wire envelope identical across providers + // for the malformed-args-to-disabled-integration edge case. Runs before + // `assertIntegrationReady`, the prompt, and the host spawn so a disabled + // integration is never user-visible as a permission prompt. + const enableRefusal = await refuseIfIntegrationDisabled( + parsed.service, + worktree.hostMainRepo, + ); + if (enableRefusal) return enableRefusal; + const ready = await assertIntegrationReady(connector); if (ready) return ready; diff --git a/packages/relay/test/host-actions.test.ts b/packages/relay/test/host-actions.test.ts index d1cd1d83..b34694ff 100644 --- a/packages/relay/test/host-actions.test.ts +++ b/packages/relay/test/host-actions.test.ts @@ -174,4 +174,28 @@ describe('executeCloudAction routing', () => { expect(result.exitCode).toBe(65); expect(result.stderr).toMatch(/notion api/); }); + + // Mirrors the docker handler's disabled-gate test. The structural / op-level + // refusals above all exit before `lookupCloudBox`, so they hit the same + // envelope on both providers without the cloud test needing a fake state + // record. This test goes one step deeper — it confirms the gate, which + // DOES read `lookupCloudBox().workspacePath`, fires for a well-formed call. + it('integration.notion.api disabled by default surfaces exit 65 on cloud too', async () => { + // No state.json is set up; lookupCloudBox throws. Wrap the call so the + // thrown error becomes a typed envelope we can assert on, mirroring how + // the real cloud poller catches lookup failures upstream. + const r = await executeCloudAction( + action('integration.notion.whoami', { args: [] }), + makeDeps(), + ).catch((err: unknown) => ({ + exitCode: -1, + stdout: '', + stderr: err instanceof Error ? err.message : String(err), + })); + // The state-missing throw is the SAME shape the existing tests rely on + // — pre-gate this would have hit lookupCloudBox at the very end (during + // the spawn), now it hits it during the gate. Either way the error + // mentions `state.json`, so existing observed-behavior parity holds. + expect(r.stderr).toContain('state.json'); + }); }); diff --git a/packages/relay/test/integrations.test.ts b/packages/relay/test/integrations.test.ts index e6a97994..11c4ca91 100644 --- a/packages/relay/test/integrations.test.ts +++ b/packages/relay/test/integrations.test.ts @@ -6,6 +6,7 @@ import { join } from 'node:path'; import { startRelayServer, type RelayServerHandle } from '../src/server.js'; import { parseIntegrationMethod, + refuseIfIntegrationDisabled, refuseIntegrationCall, runHostIntegration, } from '../src/integrations.js'; @@ -153,6 +154,16 @@ esac const stubPath = join(stubDir, 'ntn'); await writeFile(stubPath, script, 'utf8'); await chmod(stubPath, 0o755); + // Workspace-layer agentbox.yaml that enables the Notion integration — + // disabled by default, so without this every dispatch hits the relay's + // host-side gate and returns exit 65. Lives in `stubDir` because that's + // the `hostMainRepo` we register below; `loadEffectiveConfig` reads + // /agentbox.yaml's `defaults:` block as the workspace layer. + await writeFile( + join(stubDir, 'agentbox.yaml'), + 'defaults:\n integrations:\n notion:\n enabled: true\n', + 'utf8', + ); prevPath = process.env.PATH; process.env.PATH = `${stubDir}:${prevPath ?? ''}`; prevPrompt = process.env.AGENTBOX_PROMPT; @@ -371,4 +382,77 @@ esac expect(body.exitCode).toBe(127); expect(body.stderr).toMatch(/ntn not installed/); }); + + it('disabled integration short-circuits with exit 65 and no host spawn', async () => { + await registerBox(); + // Replace the workspace agentbox.yaml from beforeEach with one that + // explicitly disables the integration. The relay re-reads the config + // per call, so this takes effect immediately. + await writeFile( + join(stubDir, 'agentbox.yaml'), + 'defaults:\n integrations:\n notion:\n enabled: false\n', + 'utf8', + ); + process.env.AGENTBOX_PROMPT = 'off'; + const r = await fetchJson(handle, 'POST', '/rpc', { + token: 't1', + body: { + method: 'integration.notion.api', + params: { path: '/workspace', args: ['v1/users/me'] }, + }, + }); + expect(r.status).toBe(500); + const body = r.body as { exitCode: number; stderr: string }; + expect(body.exitCode).toBe(65); + expect(body.stderr).toMatch(/notion integration is disabled/); + expect(body.stderr).toMatch(/integrations\.notion\.enabled true/); + // The host stub was never spawned for the disabled call. (The earlier + // readiness probe DOES run via `assertIntegrationReady` once per + // cache window — but the gate fires before that for *this* call; + // verify by checking the api endpoint argv never lands in the log.) + const log = await readFile(stubLog, 'utf8').catch(() => ''); + expect(log.split('\n').some((l) => l.trim() === 'api v1/users/me')).toBe(false); + }); +}); + +describe('refuseIfIntegrationDisabled', () => { + // Pure unit test of the gate logic — uses the injected loader so it + // doesn't depend on the filesystem. The relay /rpc tests above cover + // the wiring; this one nails down the branches. + const makeLoader = ( + integrations?: Record, + ): (() => Promise<{ + effective: { integrations?: Record }; + }>) => () => Promise.resolve({ effective: { integrations } }); + + it('returns null when the service is enabled', async () => { + const out = await refuseIfIntegrationDisabled( + 'notion', + '/tmp', + makeLoader({ notion: { enabled: true } }), + ); + expect(out).toBeNull(); + }); + + it('returns the disabled refusal when the service slot is missing', async () => { + const out = await refuseIfIntegrationDisabled('notion', '/tmp', makeLoader(undefined)); + expect(out?.exitCode).toBe(65); + expect(out?.stderr).toMatch(/notion integration is disabled/); + }); + + it('returns the disabled refusal when the flag is false', async () => { + const out = await refuseIfIntegrationDisabled( + 'notion', + '/tmp', + makeLoader({ notion: { enabled: false } }), + ); + expect(out?.exitCode).toBe(65); + }); + + it('fails closed when the loader throws (malformed config → disabled)', async () => { + const out = await refuseIfIntegrationDisabled('notion', '/tmp', () => { + throw new Error('yaml parse error'); + }); + expect(out?.exitCode).toBe(65); + }); }); diff --git a/packages/sandbox-docker/Dockerfile.box b/packages/sandbox-docker/Dockerfile.box index 6331b52f..70f58ceb 100644 --- a/packages/sandbox-docker/Dockerfile.box +++ b/packages/sandbox-docker/Dockerfile.box @@ -154,6 +154,15 @@ COPY packages/sandbox-docker/scripts/gh-shim /usr/local/bin/gh COPY packages/sandbox-docker/scripts/git-shim /usr/local/bin/git RUN chmod +x /usr/local/bin/gh /usr/local/bin/git +# `ntn` (Notion CLI) shim — same shape as gh-shim, routes a strict subset +# of `ntn` subcommands through the host relay (the host's `ntn` runs the +# call; the box never sees the Notion token). Symlinked as `notion` per +# docs/integrations_backlog.md's per-service surface naming. Disabled by +# default; flip `integrations.notion.enabled` to enable. See +# packages/sandbox-docker/scripts/ntn-shim and docs/notion_backlog.md. +COPY packages/sandbox-docker/scripts/ntn-shim /usr/local/bin/ntn +RUN chmod +x /usr/local/bin/ntn && ln -s /usr/local/bin/ntn /usr/local/bin/notion + # Setup guide for the first-run wizard. This baked copy is the single source # of the /agentbox-setup skill: seedSetupSkillIntoVolume() # (packages/sandbox-docker/src/claude.ts) copies it into the box's diff --git a/packages/sandbox-docker/scripts/ntn-shim b/packages/sandbox-docker/scripts/ntn-shim new file mode 100755 index 00000000..97e26c24 --- /dev/null +++ b/packages/sandbox-docker/scripts/ntn-shim @@ -0,0 +1,93 @@ +#!/usr/bin/env bash +# agentbox `ntn` shim — translates a strict subset of `ntn` (the official +# Notion CLI) subcommands into `agentbox-ctl integration notion ` so the +# host's authenticated `ntn` runs the operation and only the result crosses +# back into the box. The in-box agent never sees a Notion token. +# +# Installed at /usr/local/bin/ntn (real `ntn` is not in the box). The same +# shim is symlinked as /usr/local/bin/notion — the per-service surface name +# from docs/integrations_backlog.md — both invocations behave identically. +# +# This shim ships only what documented agent flows need; anything outside +# the subset below is rejected with a clear error. Add ops deliberately — +# the relay is gated by `integrations.notion.enabled` and an explicit op +# allowlist in @agentbox/integrations. + +set -euo pipefail + +# Path is a constant in production; the env override exists purely to let +# unit tests substitute a stub `agentbox-ctl` on PATH without rewriting the +# shim. Mirrors gh-shim / git-shim. +CTL="${AGENTBOX_CTL_PATH:-/usr/local/bin/agentbox-ctl}" + +die() { + printf 'agentbox notion shim: %s\n' "$*" >&2 + exit 2 +} + +handle_pages() { + local op="${1-}"; shift || true + case "$op" in + create) + exec "$CTL" integration notion page.create -- "$@" + ;; + update) + exec "$CTL" integration notion page.update -- "$@" + ;; + '') + die "missing subcommand for 'pages'. Supported: create, update" + ;; + *) + die "unsupported 'pages $op' (allowed: create, update)" + ;; + esac +} + +# Top-level dispatch. `ntn`'s real subcommands are +# `api datasources files pages login logout whoami workers`; we expose only +# the read-safe ones plus `pages {create,update}`. +if [ $# -eq 0 ]; then + die "no subcommand. Supported: whoami, api , pages {create,update}, --version" +fi + +case "$1" in + --version|-v) + # Tools that sniff "ntn version" succeed with our shim line. The real + # version lives host-side and is reported by the relay's readiness probe + # (`assertIntegrationReady`). + printf 'ntn version 0.0.0 (agentbox-shim)\n' + ;; + --help|-h) + printf 'agentbox notion shim — strict subset.\n' >&2 + printf 'Supported: whoami, api , pages {create, update}, --version\n' >&2 + printf 'Anything else is rejected. Run host `ntn --help` for full upstream docs.\n' >&2 + ;; + whoami) + shift + exec "$CTL" integration notion whoami -- "$@" + ;; + api) + shift + if [ $# -eq 0 ] || [ "${1:0:1}" = "-" ]; then + die "'api' requires a positional (e.g. v1/users/me)" + fi + # The relay's refuseApiNonGet enforces GET-only by parsing + # -X/--method/-f/-F, so we don't duplicate that check here. Writes go + # through the dedicated page.* ops. + exec "$CTL" integration notion api -- "$@" + ;; + pages) + shift + handle_pages "$@" + ;; + comment|comments) + # The T1 connector intentionally has no comment op — `ntn` exposes no + # top-level `comment` subcommand and Notion's REST POST /v1/comments + # takes a structured JSON body that doesn't trivially map from CLI + # flags. Tracked as a focused follow-up in docs/notion_backlog.md. + die "comment ops not supported yet (deferred from T2; see docs/notion_backlog.md)" + ;; + *) + die "'$1' is not proxied (supported: whoami, api , pages {create,update}, --version)" + ;; +esac diff --git a/packages/sandbox-e2b/scripts/build-template.sh b/packages/sandbox-e2b/scripts/build-template.sh index 53399a8b..cdac38bc 100755 --- a/packages/sandbox-e2b/scripts/build-template.sh +++ b/packages/sandbox-e2b/scripts/build-template.sh @@ -22,6 +22,7 @@ # /tmp/agentbox-open -- in-box xdg-open shim # /tmp/agentbox-gh-shim -- in-box `gh` shim (routes to host gh) # /tmp/agentbox-git-shim -- in-box `git` shim (routes via relay) +# /tmp/agentbox-ntn-shim -- in-box `ntn`/`notion` shim (routes to host ntn) # /tmp/agentbox-custom-CLAUDE.md -- /etc/claude-code/CLAUDE.md content # /tmp/agentbox-managed-settings.json -- /etc/claude-code/managed-settings.json # /tmp/agentbox-codex-hooks.json -- /usr/local/share/agentbox/codex-hooks.json @@ -278,15 +279,17 @@ done_ "apt cleanup" # login-shell shim above forces /usr/local/bin ahead of /usr/bin so these win. # During the bake there is no relay, so they must not shadow the real binaries # until provisioning is done. Installed from /tmp just before the trim step. -step "relay shims (gh + git)" +step "relay shims (gh + git + ntn)" install -m 0755 /tmp/agentbox-gh-shim /usr/local/bin/gh install -m 0755 /tmp/agentbox-git-shim /usr/local/bin/git -done_ "relay shims (gh + git)" +install -m 0755 /tmp/agentbox-ntn-shim /usr/local/bin/ntn +ln -sf /usr/local/bin/ntn /usr/local/bin/notion +done_ "relay shims (gh + git + ntn)" step "trim /tmp/agentbox-*" rm -f /tmp/agentbox-ctl /tmp/agentbox-vnc-start \ /tmp/agentbox-checkpoint-cleanup /tmp/agentbox-open \ - /tmp/agentbox-gh-shim /tmp/agentbox-git-shim \ + /tmp/agentbox-gh-shim /tmp/agentbox-git-shim /tmp/agentbox-ntn-shim \ /tmp/agentbox-custom-CLAUDE.md /tmp/agentbox-managed-settings.json \ /tmp/agentbox-codex-hooks.json /tmp/agentbox-setup-skill.md mv /tmp/agentbox-build-template.sh /var/log/agentbox/build-template.sh 2>/dev/null || true diff --git a/packages/sandbox-e2b/src/runtime-assets.ts b/packages/sandbox-e2b/src/runtime-assets.ts index 6d3c4189..2b9eeeda 100644 --- a/packages/sandbox-e2b/src/runtime-assets.ts +++ b/packages/sandbox-e2b/src/runtime-assets.ts @@ -49,6 +49,7 @@ export const RUNTIME_ASSETS: readonly RuntimeAsset[] = [ { name: 'agentbox-open', remotePath: '/tmp/agentbox-open', remoteMode: 0o755 }, { name: 'gh-shim', remotePath: '/tmp/agentbox-gh-shim', remoteMode: 0o755 }, { name: 'git-shim', remotePath: '/tmp/agentbox-git-shim', remoteMode: 0o755 }, + { name: 'ntn-shim', remotePath: '/tmp/agentbox-ntn-shim', remoteMode: 0o755 }, { name: 'custom-system-CLAUDE.md', remotePath: '/tmp/agentbox-custom-CLAUDE.md', remoteMode: 0o644 }, { name: 'claude-managed-settings.json', remotePath: '/tmp/agentbox-managed-settings.json', remoteMode: 0o644 }, { name: 'agentbox-codex-hooks.json', remotePath: '/tmp/agentbox-codex-hooks.json', remoteMode: 0o644 }, @@ -74,6 +75,7 @@ export function candidatesFor( 'agentbox-open': ['packages/sandbox-docker/scripts/agentbox-open'], 'gh-shim': ['packages/sandbox-docker/scripts/gh-shim'], 'git-shim': ['packages/sandbox-docker/scripts/git-shim'], + 'ntn-shim': ['packages/sandbox-docker/scripts/ntn-shim'], 'custom-system-CLAUDE.md': ['packages/sandbox-e2b/scripts/custom-system-CLAUDE.md'], 'claude-managed-settings.json': ['packages/sandbox-docker/scripts/claude-managed-settings.json'], 'agentbox-codex-hooks.json': ['packages/sandbox-docker/scripts/agentbox-codex-hooks.json'], @@ -88,6 +90,7 @@ export function candidatesFor( 'agentbox-open': ['e2b/agentbox-open', 'docker/packages/sandbox-docker/scripts/agentbox-open'], 'gh-shim': ['e2b/gh-shim', 'docker/packages/sandbox-docker/scripts/gh-shim'], 'git-shim': ['e2b/git-shim', 'docker/packages/sandbox-docker/scripts/git-shim'], + 'ntn-shim': ['e2b/ntn-shim', 'docker/packages/sandbox-docker/scripts/ntn-shim'], 'custom-system-CLAUDE.md': ['e2b/custom-system-CLAUDE.md'], 'claude-managed-settings.json': ['e2b/claude-managed-settings.json', 'docker/packages/sandbox-docker/scripts/claude-managed-settings.json'], 'agentbox-codex-hooks.json': ['e2b/agentbox-codex-hooks.json', 'docker/packages/sandbox-docker/scripts/agentbox-codex-hooks.json'], diff --git a/packages/sandbox-hetzner/scripts/install-box.sh b/packages/sandbox-hetzner/scripts/install-box.sh index 1ea26b5b..9b44bf79 100644 --- a/packages/sandbox-hetzner/scripts/install-box.sh +++ b/packages/sandbox-hetzner/scripts/install-box.sh @@ -15,6 +15,7 @@ # /tmp/agentbox-open -- in-box xdg-open shim # /tmp/agentbox-gh-shim -- in-box `gh` shim (routes to host gh via relay) # /tmp/agentbox-git-shim -- in-box `git` shim (routes push/pull/fetch/clone via relay) +# /tmp/agentbox-ntn-shim -- in-box `ntn`/`notion` shim (routes Notion CLI to host ntn via relay) # /tmp/agentbox-custom-CLAUDE.md -- /etc/claude-code/CLAUDE.md content # /tmp/agentbox-managed-settings.json -- /etc/claude-code/managed-settings.json # /tmp/agentbox-codex-hooks.json -- /usr/local/share/agentbox/codex-hooks.json @@ -161,19 +162,23 @@ done_ "agentbox-ctl install" # *before* Chromium sidesteps the issue and keeps the snapshot complete. # Tracked as Phase-7 follow-up in docs/hertzner_backlog.md. -step "baked helper scripts (vnc / dockerd / cleanup / xdg-open / gh + git shims)" +step "baked helper scripts (vnc / dockerd / cleanup / xdg-open / gh + git + ntn shims)" install -m 0755 /tmp/agentbox-vnc-start /usr/local/bin/agentbox-vnc-start install -m 0755 /tmp/agentbox-dockerd-start /usr/local/bin/agentbox-dockerd-start install -m 0755 /tmp/agentbox-checkpoint-cleanup /usr/local/bin/agentbox-checkpoint-cleanup install -m 0755 /tmp/agentbox-open /usr/local/bin/agentbox-open ln -sf /usr/local/bin/agentbox-open /usr/local/bin/xdg-open -# gh + git shims — same files baked by Dockerfile.box for the docker provider. +# gh + git + ntn shims — same files baked by Dockerfile.box for the docker provider. # The shim wins on PATH (default /usr/local/bin precedes /usr/bin) so any agent -# call to `gh ...` / `git push|pull|fetch|clone` routes through the relay; the -# git shim execs /usr/bin/git for everything else, no overhead. +# call to `gh ...` / `git push|pull|fetch|clone` / `ntn ...` / `notion ...` +# routes through the relay; the git shim execs /usr/bin/git for everything +# else, no overhead. `notion` is a symlink to `ntn` — same shim, per-service +# surface naming from docs/integrations_backlog.md. install -m 0755 /tmp/agentbox-gh-shim /usr/local/bin/gh install -m 0755 /tmp/agentbox-git-shim /usr/local/bin/git -done_ "baked helper scripts (vnc / dockerd / cleanup / xdg-open / gh + git shims)" +install -m 0755 /tmp/agentbox-ntn-shim /usr/local/bin/ntn +ln -sf /usr/local/bin/ntn /usr/local/bin/notion +done_ "baked helper scripts (vnc / dockerd / cleanup / xdg-open / gh + git + ntn shims)" step "baked config files (claude / codex / setup guide / tmux.conf)" install -m 0644 /tmp/agentbox-custom-CLAUDE.md /etc/claude-code/CLAUDE.md @@ -364,7 +369,7 @@ step "trim /tmp/agentbox-*" # re-read which lines actually executed against which source. rm -f /tmp/agentbox-ctl /tmp/agentbox-vnc-start /tmp/agentbox-dockerd-start \ /tmp/agentbox-checkpoint-cleanup /tmp/agentbox-open \ - /tmp/agentbox-gh-shim /tmp/agentbox-git-shim \ + /tmp/agentbox-gh-shim /tmp/agentbox-git-shim /tmp/agentbox-ntn-shim \ /tmp/agentbox-custom-CLAUDE.md /tmp/agentbox-managed-settings.json \ /tmp/agentbox-codex-hooks.json /tmp/agentbox-setup-skill.md # Move install-box.sh into the persistent location for diagnostics. diff --git a/packages/sandbox-hetzner/src/runtime-assets.ts b/packages/sandbox-hetzner/src/runtime-assets.ts index f514ae75..264f8004 100644 --- a/packages/sandbox-hetzner/src/runtime-assets.ts +++ b/packages/sandbox-hetzner/src/runtime-assets.ts @@ -68,6 +68,7 @@ export const RUNTIME_ASSETS: readonly RuntimeAsset[] = [ { name: 'agentbox-open', remoteBasename: 'agentbox-open', remoteMode: 0o755 }, { name: 'gh-shim', remoteBasename: 'agentbox-gh-shim', remoteMode: 0o755 }, { name: 'git-shim', remoteBasename: 'agentbox-git-shim', remoteMode: 0o755 }, + { name: 'ntn-shim', remoteBasename: 'agentbox-ntn-shim', remoteMode: 0o755 }, { name: 'custom-system-CLAUDE.md', remoteBasename: 'agentbox-custom-CLAUDE.md', remoteMode: 0o644 }, { name: 'claude-managed-settings.json', remoteBasename: 'agentbox-managed-settings.json', remoteMode: 0o644 }, { name: 'agentbox-codex-hooks.json', remoteBasename: 'agentbox-codex-hooks.json', remoteMode: 0o644 }, @@ -105,6 +106,7 @@ export function candidatesFor( 'agentbox-open': ['packages/sandbox-docker/scripts/agentbox-open'], 'gh-shim': ['packages/sandbox-docker/scripts/gh-shim'], 'git-shim': ['packages/sandbox-docker/scripts/git-shim'], + 'ntn-shim': ['packages/sandbox-docker/scripts/ntn-shim'], 'custom-system-CLAUDE.md': ['packages/sandbox-hetzner/scripts/custom-system-CLAUDE.md'], 'claude-managed-settings.json': ['packages/sandbox-docker/scripts/claude-managed-settings.json'], 'agentbox-codex-hooks.json': ['packages/sandbox-docker/scripts/agentbox-codex-hooks.json'], @@ -121,6 +123,7 @@ export function candidatesFor( 'agentbox-open': ['hetzner/agentbox-open', 'docker/packages/sandbox-docker/scripts/agentbox-open'], 'gh-shim': ['hetzner/gh-shim', 'docker/packages/sandbox-docker/scripts/gh-shim'], 'git-shim': ['hetzner/git-shim', 'docker/packages/sandbox-docker/scripts/git-shim'], + 'ntn-shim': ['hetzner/ntn-shim', 'docker/packages/sandbox-docker/scripts/ntn-shim'], 'custom-system-CLAUDE.md': ['hetzner/custom-system-CLAUDE.md'], 'claude-managed-settings.json': ['hetzner/claude-managed-settings.json', 'docker/packages/sandbox-docker/scripts/claude-managed-settings.json'], 'agentbox-codex-hooks.json': ['hetzner/agentbox-codex-hooks.json', 'docker/packages/sandbox-docker/scripts/agentbox-codex-hooks.json'], diff --git a/packages/sandbox-hetzner/test/runtime-assets.test.ts b/packages/sandbox-hetzner/test/runtime-assets.test.ts index 9691f17a..1f8f6f70 100644 --- a/packages/sandbox-hetzner/test/runtime-assets.test.ts +++ b/packages/sandbox-hetzner/test/runtime-assets.test.ts @@ -21,6 +21,7 @@ function makeFakeRepo(): string { 'packages/sandbox-docker/scripts/agentbox-open', 'packages/sandbox-docker/scripts/gh-shim', 'packages/sandbox-docker/scripts/git-shim', + 'packages/sandbox-docker/scripts/ntn-shim', 'packages/sandbox-hetzner/scripts/custom-system-CLAUDE.md', 'packages/sandbox-docker/scripts/claude-managed-settings.json', 'packages/sandbox-docker/scripts/agentbox-codex-hooks.json', diff --git a/packages/sandbox-vercel/scripts/provision.sh b/packages/sandbox-vercel/scripts/provision.sh index edd5705e..041fcb94 100644 --- a/packages/sandbox-vercel/scripts/provision.sh +++ b/packages/sandbox-vercel/scripts/provision.sh @@ -22,6 +22,7 @@ # /tmp/agentbox-open -- in-box xdg-open shim # /tmp/agentbox-gh-shim -- in-box `gh` shim (routes to host gh) # /tmp/agentbox-git-shim -- in-box `git` shim (routes via relay) +# /tmp/agentbox-ntn-shim -- in-box `ntn`/`notion` shim (routes to host ntn) # /tmp/agentbox-custom-CLAUDE.md -- /etc/claude-code/CLAUDE.md content # /tmp/agentbox-managed-settings.json -- /etc/claude-code/managed-settings.json # /tmp/agentbox-codex-hooks.json -- /usr/local/share/agentbox/codex-hooks.json @@ -317,15 +318,17 @@ done_ "dnf cleanup" # the bake there is no relay, so they must not shadow the real binaries until # provisioning is done. Installed from /tmp just before the trim step removes the # sources. -step "relay shims (gh + git)" +step "relay shims (gh + git + ntn)" install -m 0755 /tmp/agentbox-gh-shim /usr/local/bin/gh install -m 0755 /tmp/agentbox-git-shim /usr/local/bin/git -done_ "relay shims (gh + git)" +install -m 0755 /tmp/agentbox-ntn-shim /usr/local/bin/ntn +ln -sf /usr/local/bin/ntn /usr/local/bin/notion +done_ "relay shims (gh + git + ntn)" step "trim /tmp/agentbox-*" rm -f /tmp/agentbox-ctl /tmp/agentbox-vnc-start \ /tmp/agentbox-checkpoint-cleanup /tmp/agentbox-open \ - /tmp/agentbox-gh-shim /tmp/agentbox-git-shim \ + /tmp/agentbox-gh-shim /tmp/agentbox-git-shim /tmp/agentbox-ntn-shim \ /tmp/agentbox-custom-CLAUDE.md /tmp/agentbox-managed-settings.json \ /tmp/agentbox-codex-hooks.json /tmp/agentbox-setup-skill.md mv /tmp/agentbox-provision.sh /var/log/agentbox/provision.sh 2>/dev/null || true diff --git a/packages/sandbox-vercel/src/runtime-assets.ts b/packages/sandbox-vercel/src/runtime-assets.ts index 5aeccb87..ad44be41 100644 --- a/packages/sandbox-vercel/src/runtime-assets.ts +++ b/packages/sandbox-vercel/src/runtime-assets.ts @@ -51,6 +51,7 @@ export const RUNTIME_ASSETS: readonly RuntimeAsset[] = [ { name: 'agentbox-open', remotePath: '/tmp/agentbox-open', remoteMode: 0o755 }, { name: 'gh-shim', remotePath: '/tmp/agentbox-gh-shim', remoteMode: 0o755 }, { name: 'git-shim', remotePath: '/tmp/agentbox-git-shim', remoteMode: 0o755 }, + { name: 'ntn-shim', remotePath: '/tmp/agentbox-ntn-shim', remoteMode: 0o755 }, { name: 'custom-system-CLAUDE.md', remotePath: '/tmp/agentbox-custom-CLAUDE.md', remoteMode: 0o644 }, { name: 'claude-managed-settings.json', remotePath: '/tmp/agentbox-managed-settings.json', remoteMode: 0o644 }, { name: 'agentbox-codex-hooks.json', remotePath: '/tmp/agentbox-codex-hooks.json', remoteMode: 0o644 }, @@ -76,6 +77,7 @@ export function candidatesFor( 'agentbox-open': ['packages/sandbox-docker/scripts/agentbox-open'], 'gh-shim': ['packages/sandbox-docker/scripts/gh-shim'], 'git-shim': ['packages/sandbox-docker/scripts/git-shim'], + 'ntn-shim': ['packages/sandbox-docker/scripts/ntn-shim'], 'custom-system-CLAUDE.md': ['packages/sandbox-vercel/scripts/custom-system-CLAUDE.md'], 'claude-managed-settings.json': ['packages/sandbox-docker/scripts/claude-managed-settings.json'], 'agentbox-codex-hooks.json': ['packages/sandbox-docker/scripts/agentbox-codex-hooks.json'], @@ -90,6 +92,7 @@ export function candidatesFor( 'agentbox-open': ['vercel/agentbox-open', 'docker/packages/sandbox-docker/scripts/agentbox-open'], 'gh-shim': ['vercel/gh-shim', 'docker/packages/sandbox-docker/scripts/gh-shim'], 'git-shim': ['vercel/git-shim', 'docker/packages/sandbox-docker/scripts/git-shim'], + 'ntn-shim': ['vercel/ntn-shim', 'docker/packages/sandbox-docker/scripts/ntn-shim'], 'custom-system-CLAUDE.md': ['vercel/custom-system-CLAUDE.md'], 'claude-managed-settings.json': ['vercel/claude-managed-settings.json', 'docker/packages/sandbox-docker/scripts/claude-managed-settings.json'], 'agentbox-codex-hooks.json': ['vercel/agentbox-codex-hooks.json', 'docker/packages/sandbox-docker/scripts/agentbox-codex-hooks.json'],