From 2b345a638c6b9fcb1d000487a3f3b0e39f7c2aa2 Mon Sep 17 00:00:00 2001 From: Marco D'Alia Date: Sat, 6 Jun 2026 13:44:51 +0000 Subject: [PATCH] feat(integrations): shared foundation + Notion core plumbing (T1) Generalize the gh relay pattern (in-box ctl -> host relay -> host-authed CLI with read/write classification + write gating) into a reusable connector model so future ticketing integrations (Notion, Linear, Trello, ClickUp) all share one spine. Box never holds a service token; host runs the real CLI. - New package @agentbox/integrations: IntegrationConnector / IntegrationOp descriptor model, registry (getConnector / ALL_CONNECTORS), and a Notion descriptor wrapping ntn. Conservative starter allowlist: api (GET passthrough, refuseCall mirrors gh.api's -X/-f detection so the read classification stays honest) plus page.create / page.update / comment.add (write-gated). NOTION_KEYRING=0 forced via the connector env so ntn reads file-based auth on Linux boxes; harmless on macOS. - packages/relay/src/integrations.ts: runHostIntegration + assertIntegrationReady + parseIntegrationMethod, plus refuseIntegrationCall and a mergeConnectorEnv namespace guard that prevents a descriptor from setting env vars outside _* (returns a typed exit-78 envelope when violated). - Generic integration.. dispatch wired into BOTH packages/relay/src/server.ts (POST /rpc) AND packages/relay/src/host-actions.ts (cloud path) so docker, daytona, hetzner, vercel, and e2b all get it for free. - packages/ctl/src/commands/integration.ts: builds one commander subtree per connector from its descriptor, each op calling postRpcAndExit through the existing relay-rpc transport. Registered next to ghCommand. Tests (pure vitest, no docker/network): allowlist denies unknown ops; api refuseCall blocks POST/PATCH/DELETE/-f field flags/--input on docker AND cloud paths; reads bypass the prompt; writes enqueue an askPrompt and a denied prompt yields exit 10; descriptor env outside its SERVICE_ namespace surfaces as exit 78; cloud and docker paths emit identical envelopes for unknown shape / unknown service / op not on allowlist. Out of scope (T2-T4): in-box notion shim, Dockerfile/stage-runtime staging, hetzner/cloud install-script mirror, config flags, doctor detection, docs site, nested-box e2e. --- docs/notion_backlog.md | 7 +- packages/ctl/package.json | 1 + packages/ctl/src/bin.ts | 2 + packages/ctl/src/commands/integration.ts | 60 +++ packages/integrations/package.json | 33 ++ .../integrations/src/connectors/notion.ts | 108 +++++ packages/integrations/src/index.ts | 8 + packages/integrations/src/registry.ts | 18 + packages/integrations/src/types.ts | 66 ++++ packages/integrations/test/registry.test.ts | 116 ++++++ packages/integrations/tsconfig.json | 7 + packages/integrations/tsup.config.ts | 10 + packages/relay/package.json | 1 + packages/relay/src/host-actions.ts | 93 +++++ packages/relay/src/index.ts | 10 + packages/relay/src/integrations.ts | 275 +++++++++++++ packages/relay/src/server.ts | 127 ++++++ packages/relay/test/host-actions.test.ts | 35 ++ packages/relay/test/integrations.test.ts | 374 ++++++++++++++++++ pnpm-lock.yaml | 21 + 20 files changed, 1371 insertions(+), 1 deletion(-) create mode 100644 packages/ctl/src/commands/integration.ts create mode 100644 packages/integrations/package.json create mode 100644 packages/integrations/src/connectors/notion.ts create mode 100644 packages/integrations/src/index.ts create mode 100644 packages/integrations/src/registry.ts create mode 100644 packages/integrations/src/types.ts create mode 100644 packages/integrations/test/registry.test.ts create mode 100644 packages/integrations/tsconfig.json create mode 100644 packages/integrations/tsup.config.ts create mode 100644 packages/relay/src/integrations.ts create mode 100644 packages/relay/test/integrations.test.ts diff --git a/docs/notion_backlog.md b/docs/notion_backlog.md index 5d1977a2..bbd2f340 100644 --- a/docs/notion_backlog.md +++ b/docs/notion_backlog.md @@ -34,7 +34,7 @@ Reference implementations to copy: `packages/relay/src/gh.ts`, ## Tasks -### T1 — Shared foundation + Notion core plumbing ⬜ not started +### T1 — Shared foundation + Notion core plumbing ✅ done The working vertical slice: `agentbox-ctl integration notion ` round-trips through the relay to host `ntn`, with read/write classification + write gating. - `packages/integrations/` package: `types.ts` (IntegrationOp, IntegrationConnector), @@ -82,3 +82,8 @@ Make a box agent able to type `notion …`. ## Status log - 2026-06-06: Backlog created; host-side carry for `ntn` file-auth added to `agentbox.yaml`. Top-level box testing uses the host's keychain-authed `ntn`. +- 2026-06-06: T1 shipped — `@agentbox/integrations` package with Notion + descriptor, `packages/relay/src/integrations.ts` (host exec + readiness + probe), generic `integration..` dispatch wired into both + `server.ts` (docker) and `host-actions.ts` (cloud), and `agentbox-ctl + integration` command tree. PR pending. diff --git a/packages/ctl/package.json b/packages/ctl/package.json index 7ed8482e..40a13a5d 100644 --- a/packages/ctl/package.json +++ b/packages/ctl/package.json @@ -31,6 +31,7 @@ }, "dependencies": { "@agentbox/core": "workspace:*", + "@agentbox/integrations": "workspace:*", "@agentbox/relay": "workspace:*", "commander": "^12.1.0", "yaml": "^2.6.1" diff --git a/packages/ctl/src/bin.ts b/packages/ctl/src/bin.ts index 6da36853..eb24641e 100644 --- a/packages/ctl/src/bin.ts +++ b/packages/ctl/src/bin.ts @@ -9,6 +9,7 @@ import { downloadCommand } from './commands/download.js'; import { checkpointCommand } from './commands/checkpoint.js'; import { ghCommand } from './commands/gh.js'; import { gitCommand } from './commands/git.js'; +import { integrationCommand } from './commands/integration.js'; import { notifyCommand } from './commands/notify.js'; import { openCommand } from './commands/open.js'; import { statusCommand } from './commands/status.js'; @@ -46,6 +47,7 @@ program.addCommand(waitReadyCommand); program.addCommand(runTaskCommand); program.addCommand(gitCommand); program.addCommand(ghCommand); +program.addCommand(integrationCommand); program.addCommand(checkpointCommand); program.addCommand(cpCommand); program.addCommand(downloadCommand); diff --git a/packages/ctl/src/commands/integration.ts b/packages/ctl/src/commands/integration.ts new file mode 100644 index 00000000..7f487688 --- /dev/null +++ b/packages/ctl/src/commands/integration.ts @@ -0,0 +1,60 @@ +import { Command } from 'commander'; +import { ALL_CONNECTORS, type IntegrationConnector } from '@agentbox/integrations'; +import { postRpcAndExit } from '../relay-rpc.js'; + +interface IntegrationRpcParams { + path: string; + args?: string[]; +} + +/** + * In-box surface for the integrations foundation: one commander subtree + * per connector descriptor in `@agentbox/integrations`. Each op's action + * forwards verbatim argv to the relay (`integration..`), + * where the host-side dispatcher classifies read/write and gates writes + * via askPrompt before shelling out to the connector's host CLI. + * + * Mirrors `commands/gh.ts` exactly — descriptor-driven so a new + * connector is one file in `@agentbox/integrations` and no surgery here. + */ +export const integrationCommand = new Command('integration').description( + 'Ticketing/knowledge CLIs routed through the host relay (host runs the real CLI with host creds; box never sees a token)', +); + +for (const connector of ALL_CONNECTORS) { + integrationCommand.addCommand(buildConnectorCommand(connector)); +} + +function buildConnectorCommand(connector: IntegrationConnector): Command { + const cmd = new Command(connector.service).description( + `${connector.service} CLI operations via the host \`${connector.hostBin}\` (requires \`${connector.hostBin}\` installed and authenticated on the host)`, + ); + for (const [opName, op] of Object.entries(connector.ops)) { + const description = op.write + ? `Run \`${connector.hostBin} ${opName}\` on the host (prompted; write op).` + : `Run \`${connector.hostBin} ${opName}\` on the host (read-only; no prompt).`; + const errorPrefix = `agentbox-ctl integration ${connector.service} ${opName}`; + const method = `integration.${connector.service}.${opName}`; + cmd.addCommand( + new Command(opName) + .description(description) + .option( + '--cwd ', + 'container path identifying which registered worktree to use (default: cwd)', + ) + .allowExcessArguments(true) + .allowUnknownOption(true) + .argument( + '[args...]', + `extra args forwarded to \`${connector.hostBin} ${opName}\` verbatim`, + ) + .action(async (args: string[], opts: { cwd?: string }) => { + const params: IntegrationRpcParams = { path: opts.cwd ?? process.cwd() }; + if (args.length > 0) params.args = args; + const code = await postRpcAndExit(method, params, { errorPrefix }); + process.exit(code); + }), + ); + } + return cmd; +} diff --git a/packages/integrations/package.json b/packages/integrations/package.json new file mode 100644 index 00000000..dfb40470 --- /dev/null +++ b/packages/integrations/package.json @@ -0,0 +1,33 @@ +{ + "name": "@agentbox/integrations", + "version": "0.0.0", + "private": true, + "description": "Connector descriptors (Notion, …) for AgentBox's host-side relay-gated integrations. Pure data + helpers; consumed by @agentbox/relay (host exec + write gating) and @agentbox/ctl (in-box command surface).", + "license": "MIT", + "type": "module", + "main": "./dist/index.js", + "types": "./dist/index.d.ts", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js" + } + }, + "files": [ + "dist" + ], + "scripts": { + "build": "tsup", + "dev": "tsup --watch", + "lint": "eslint src test", + "test": "vitest run", + "typecheck": "tsc --noEmit", + "clean": "rm -rf dist .turbo" + }, + "devDependencies": { + "@types/node": "^22.10.1", + "tsup": "^8.3.5", + "typescript": "^5.7.2", + "vitest": "^2.1.8" + } +} diff --git a/packages/integrations/src/connectors/notion.ts b/packages/integrations/src/connectors/notion.ts new file mode 100644 index 00000000..94d2a506 --- /dev/null +++ b/packages/integrations/src/connectors/notion.ts @@ -0,0 +1,108 @@ +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. + * + * `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 + * — keychain mode is unaffected by the value, only its presence. On + * Linux (in-box) the carried auth file IS the credential, and the var + * is required for `ntn` to find it. See `agentbox.yaml` carry block. + */ +export const notionConnector: IntegrationConnector = { + service: 'notion', + hostBin: 'ntn', + detect: { + versionArgs: ['--version'], + authArgs: ['api', 'v1/users/me'], + }, + env: { NOTION_KEYRING: '0' }, + ops: { + api: { + write: false, + buildArgv: (args) => ['api', ...args], + refuseCall: refuseApiNonGet, + }, + 'page.create': { + write: true, + buildArgv: (args) => ['page', 'create', ...args], + }, + 'page.update': { + write: true, + buildArgv: (args) => ['page', 'update', ...args], + }, + 'comment.add': { + write: true, + buildArgv: (args) => ['comment', 'add', ...args], + }, + }, +}; + +/** + * Reject any `ntn api` call whose argv would issue a non-GET HTTP method. + * + * `ntn api`'s flag surface mirrors `gh api`'s (Go pflag-style): an + * explicit method via `-X`/`--method` (with separate, glued, or `=`-joined + * values), or any field flag (`-f`/`-F`/`--field`/`--raw-field`) which + * implicitly switches the request to POST. We refuse all of those. + * `--input` (stdin/file body) can't traverse the relay anyway. + * + * Kept here (next to the op declaration) — not exported — because the + * test surface is "does notion.api refuse a DELETE", not the parser + * shape. If a second connector needs the same check, lift it. + */ +function refuseApiNonGet(args: readonly string[]): IntegrationOpRefusal | null { + const refuse = (reason: string): IntegrationOpRefusal => ({ + exitCode: 65, + stderr: `notion api: ${reason}\n`, + }); + let explicitMethod: string | null = null; + let hasFieldFlag = false; + for (let i = 0; i < args.length; i++) { + const arg = args[i] ?? ''; + if (arg === '-X' || arg === '--method') { + explicitMethod = args[i + 1] ?? ''; + i++; + continue; + } + if (arg.startsWith('--method=')) { + explicitMethod = arg.slice('--method='.length); + continue; + } + if (arg.startsWith('-X') && arg.length > 2) { + explicitMethod = arg.slice(2).replace(/^=/, ''); + continue; + } + if (arg === '--input' || arg.startsWith('--input=')) { + return refuse("'--input' (stdin/file body) isn't supported through the relay"); + } + // Field flags auto-POST in gh; ntn follows the same convention. Consume + // the spaced value so a method-looking token bound to the field (e.g. + // `-f -X=GET`) can't downgrade the detected method on the next loop. + if (arg === '-f' || arg === '-F' || arg === '--field' || arg === '--raw-field') { + hasFieldFlag = true; + i++; + continue; + } + if ( + arg.startsWith('-f') || + arg.startsWith('-F') || + arg.startsWith('--field=') || + arg.startsWith('--raw-field=') + ) { + hasFieldFlag = true; + } + } + 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}'`, + ); +} diff --git a/packages/integrations/src/index.ts b/packages/integrations/src/index.ts new file mode 100644 index 00000000..858be645 --- /dev/null +++ b/packages/integrations/src/index.ts @@ -0,0 +1,8 @@ +export type { + IntegrationConnector, + IntegrationOp, + IntegrationOpRefusal, + IntegrationService, +} from './types.js'; +export { ALL_CONNECTORS, getConnector } from './registry.js'; +export { notionConnector } from './connectors/notion.js'; diff --git a/packages/integrations/src/registry.ts b/packages/integrations/src/registry.ts new file mode 100644 index 00000000..e133402e --- /dev/null +++ b/packages/integrations/src/registry.ts @@ -0,0 +1,18 @@ +import { notionConnector } from './connectors/notion.js'; +import type { IntegrationConnector } from './types.js'; + +/** + * All integration connectors known to AgentBox. The relay's dispatcher + * walks this list to validate `integration..` calls — anything + * not present is denied. Mirrors `packages/core/src/provider.ts`'s + * registry pattern for the provider abstraction. + */ +export const ALL_CONNECTORS: readonly IntegrationConnector[] = [notionConnector]; + +/** Lookup by `IntegrationConnector.service`. Returns `null` for unknown. */ +export function getConnector(service: string): IntegrationConnector | null { + for (const c of ALL_CONNECTORS) { + if (c.service === service) return c; + } + return null; +} diff --git a/packages/integrations/src/types.ts b/packages/integrations/src/types.ts new file mode 100644 index 00000000..8bdf7c30 --- /dev/null +++ b/packages/integrations/src/types.ts @@ -0,0 +1,66 @@ +/** + * Connector descriptor shape for the AgentBox `integrations` foundation — + * one entry per ticketing/knowledge service the host relay can proxy on + * behalf of an in-box agent. The descriptors are pure data; the relay + * (`@agentbox/relay/src/integrations.ts`) does the host-side spawn + write + * gating, and the ctl (`@agentbox/ctl/src/commands/integration.ts`) builds + * the in-box command surface from the same descriptors. + * + * The same shape mirrors `packages/relay/src/gh.ts`: an allowlist of ops + * each tagged read/write; reads pass through without prompting, writes go + * through `askPrompt` before the host CLI is invoked. Anything not on the + * allowlist is denied by the relay (mirrors `gh api`'s endpoint refusal). + */ + +export type IntegrationService = 'notion'; + +export interface IntegrationOp { + /** Reads bypass the host confirm prompt; writes always gate via askPrompt. */ + write: boolean; + /** + * Optional argv shaper: the ctl forwards user argv verbatim in `args`; + * `buildArgv` shapes them into the host CLI's argv (e.g. + * `['page','create', ...args]` for `ntn page create …`). When omitted, + * the args are forwarded verbatim — useful only for the rare case where + * the host CLI's command name matches the wire op exactly. + */ + buildArgv?: (args: readonly string[]) => string[]; + /** + * Optional inline pre-flight: returned non-null short-circuits the dispatch + * with the given exit/stderr — used to enforce a stricter contract than + * `write` alone, e.g. `notion.api` (a `write:false` passthrough) refuses + * any non-GET HTTP method by parsing `-X`/`--method`/`-f`/`-F` so the + * "read" classification isn't a hole. Mirrors `refuseGhApiCall` in + * `packages/relay/src/gh.ts`. + */ + refuseCall?: (args: readonly string[]) => IntegrationOpRefusal | null; +} + +/** Ready-to-send refusal returned by `IntegrationOp.refuseCall`. */ +export interface IntegrationOpRefusal { + /** Conventional CLI exit code (65 = bad usage, etc.); surfaces to the agent. */ + exitCode: number; + /** One-line `\n`-terminated reason; rendered to the agent's stderr. */ + stderr: string; +} + +export interface IntegrationConnector { + service: IntegrationService; + /** Host binary the relay execs (resolved on PATH). */ + hostBin: string; + /** + * How `agentbox doctor` (T3) detects host presence + auth. T1 only + * reads `versionArgs` — for the relay's "binary present?" probe. + * `authArgs` is reserved for the doctor's auth check. + */ + detect: { versionArgs: readonly string[]; authArgs?: readonly string[] }; + /** + * Extra env vars the relay forces when spawning the host CLI. For Notion + * this is `NOTION_KEYRING=0` so `ntn` reads file-based auth on Linux + * boxes; on the macOS host that env var is harmless (keychain mode is + * the default and the var only suppresses an alternative path). + */ + env?: Readonly>; + /** Allowlist of proxied ops; anything not listed is denied at the relay. */ + ops: Readonly>; +} diff --git a/packages/integrations/test/registry.test.ts b/packages/integrations/test/registry.test.ts new file mode 100644 index 00000000..2cafe18e --- /dev/null +++ b/packages/integrations/test/registry.test.ts @@ -0,0 +1,116 @@ +import { describe, expect, it } from 'vitest'; +import { ALL_CONNECTORS, getConnector } from '../src/registry.js'; +import { notionConnector } from '../src/connectors/notion.js'; + +describe('integration registry', () => { + it('exposes the Notion connector exactly once', () => { + expect(ALL_CONNECTORS).toContain(notionConnector); + expect(ALL_CONNECTORS.filter((c) => c.service === 'notion')).toHaveLength(1); + }); + + it('looks up by service name', () => { + expect(getConnector('notion')).toBe(notionConnector); + }); + + it('returns null for unknown services (allowlist)', () => { + expect(getConnector('linear')).toBeNull(); + expect(getConnector('clickup')).toBeNull(); + expect(getConnector('')).toBeNull(); + expect(getConnector('NOTION')).toBeNull(); // case-sensitive — matches wire shape + }); +}); + +describe('notion connector', () => { + it('targets the official ntn binary with file-auth env', () => { + expect(notionConnector.hostBin).toBe('ntn'); + // The macOS host's keychain mode is the default; this var only matters + // for Linux boxes where the carried auth.json is the credential. + expect(notionConnector.env).toMatchObject({ NOTION_KEYRING: '0' }); + }); + + it('classifies api as read and the page/comment ops as write', () => { + 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.api?.buildArgv?.(['v1/users/me'])).toEqual([ + 'api', + 'v1/users/me', + ]); + expect(notionConnector.ops['page.create']?.buildArgv?.(['--parent', 'db_id'])).toEqual([ + 'page', + 'create', + '--parent', + 'db_id', + ]); + expect(notionConnector.ops['page.update']?.buildArgv?.(['page_id', '--archive'])).toEqual([ + 'page', + 'update', + '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(), + ); + }); +}); + +describe('notion api refuseCall — keeps write:false honest', () => { + const refuse = notionConnector.ops.api!.refuseCall!; + + it('allows plain GETs (default and explicit method)', () => { + expect(refuse(['v1/users/me'])).toBeNull(); + expect(refuse(['-X', 'GET', 'v1/users/me'])).toBeNull(); + expect(refuse(['--method=GET', 'v1/users/me'])).toBeNull(); + expect(refuse(['-XGET', 'v1/users/me'])).toBeNull(); + }); + + it('refuses any non-GET method (the write surface)', () => { + for (const argv of [ + ['-X', 'POST', 'v1/pages'], + ['-X', 'DELETE', 'v1/blocks/abc'], + ['-X', 'PATCH', 'v1/pages/abc'], + ['--method=PUT', 'v1/pages'], + ['-XDELETE', 'v1/blocks/abc'], + ]) { + const r = refuse(argv); + expect(r).not.toBeNull(); + expect(r!.exitCode).toBe(65); + expect(r!.stderr).toMatch(/notion api/); + } + }); + + it('refuses implicit POST via field flags (gh-pflag style)', () => { + // -f / -F / --field / --raw-field auto-switch to POST per gh's convention. + expect(refuse(['v1/pages', '-f', 'title=hi'])?.exitCode).toBe(65); + expect(refuse(['v1/pages', '-fbody=hi'])?.exitCode).toBe(65); + expect(refuse(['v1/pages', '--field=body=hi'])?.exitCode).toBe(65); + expect(refuse(['v1/pages', '-F', 'count=5'])?.exitCode).toBe(65); + }); + + it('refuses --input (stdin/file body cannot cross the relay)', () => { + expect(refuse(['--input', '-', 'v1/pages'])?.exitCode).toBe(65); + expect(refuse(['--input=/tmp/x', 'v1/pages'])?.exitCode).toBe(65); + expect(refuse(['--input=/tmp/x'])?.stderr).toMatch(/--input/); + }); + + it("doesn't downgrade a POST when a field's value looks like -X=GET", () => { + // pflag binds `-X=GET` as `-f`'s value (so the request still POSTs); + // refuse must consume the field value and not re-read the next token + // as an explicit method. + expect(refuse(['v1/pages', '-f', '-X=GET'])?.exitCode).toBe(65); + }); +}); diff --git a/packages/integrations/tsconfig.json b/packages/integrations/tsconfig.json new file mode 100644 index 00000000..f24546c2 --- /dev/null +++ b/packages/integrations/tsconfig.json @@ -0,0 +1,7 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "outDir": "dist" + }, + "include": ["src/**/*", "test/**/*"] +} diff --git a/packages/integrations/tsup.config.ts b/packages/integrations/tsup.config.ts new file mode 100644 index 00000000..17d0a4d5 --- /dev/null +++ b/packages/integrations/tsup.config.ts @@ -0,0 +1,10 @@ +import { defineConfig } from 'tsup'; + +export default defineConfig({ + entry: ['src/index.ts'], + format: ['esm'], + target: 'node20', + clean: true, + dts: true, + sourcemap: true, +}); diff --git a/packages/relay/package.json b/packages/relay/package.json index 8d951c6a..bcb42270 100644 --- a/packages/relay/package.json +++ b/packages/relay/package.json @@ -31,6 +31,7 @@ "dependencies": { "@agentbox/config": "workspace:*", "@agentbox/core": "workspace:*", + "@agentbox/integrations": "workspace:*", "@agentbox/sandbox-core": "workspace:*", "commander": "^12.1.0", "execa": "^9.5.2" diff --git a/packages/relay/src/host-actions.ts b/packages/relay/src/host-actions.ts index 88ed1314..c510f7c3 100644 --- a/packages/relay/src/host-actions.ts +++ b/packages/relay/src/host-actions.ts @@ -41,7 +41,16 @@ import { type GhRunRpcParams, } from './gh.js'; import { hashRpcParams, type HostInitiatedTokens } from './host-initiated.js'; +import { + assertIntegrationReady, + makeIntegrationOpRefusal, + parseIntegrationMethod, + refuseIntegrationCall, + runHostIntegration, + type IntegrationRpcParams, +} from './integrations.js'; import { askPrompt, type PendingPrompts, type PromptSubscribers } from './prompts.js'; +import { getConnector } from '@agentbox/integrations'; import type { CheckpointRpcParams, CpRpcParams, @@ -213,6 +222,9 @@ export async function executeCloudAction( if (action.method === 'gh.api') { return runGhApiRpc(action, deps); } + if (action.method.startsWith('integration.')) { + return runIntegrationRpc(action, deps); + } if (action.method === 'git.clone' || action.method === 'gh.repo.clone') { return { exitCode: 64, @@ -460,6 +472,87 @@ async function runGhApiRpc( return runHostGh(['api', endpoint, ...args], lookup.workspacePath); } +/** + * Cloud `integration..` executor. Mirrors the docker handler + * exactly — same descriptor lookup, same read/write gating, same host + * binary invocation. Reuses the gh-pr `cloudWriteConfirm` helper because + * the no-subscriber fallback (`AGENTBOX_GH_NO_SUB` env knob) covers every + * gated host action by design. + */ +async function runIntegrationRpc( + action: HostAction, + deps: CloudActionExecutorDeps, +): Promise { + const parsed = parseIntegrationMethod(action.method); + if (!parsed) { + return { + exitCode: 64, + stdout: '', + stderr: `unknown integration method shape: ${action.method}\n`, + }; + } + const connector = getConnector(parsed.service); + if (!connector) { + return { + exitCode: 64, + stdout: '', + stderr: `unknown integration service: ${parsed.service}\n`, + }; + } + const opDesc = connector.ops[parsed.op]; + if (!opDesc) { + return makeIntegrationOpRefusal( + parsed.service, + parsed.op, + connector.hostBin, + Object.keys(connector.ops), + ); + } + const params = (action.params ?? {}) as IntegrationRpcParams; + const args = Array.isArray(params.args) + ? params.args.filter((a): a is string => typeof a === 'string') + : []; + + const callRefusal = refuseIntegrationCall(opDesc, args); + if (callRefusal) return callRefusal; + + const ready = await assertIntegrationReady(connector); + if (ready) return ready; + + if (opDesc.write) { + const tokenClaimed = typeof params.hostInitiated === 'string'; + const incomingHash = hashRpcParams(params); + const tokenOk = + tokenClaimed && + (deps.hostInitiatedTokens?.consume( + params.hostInitiated, + deps.boxId, + action.method, + incomingHash, + ) ?? false); + if (tokenClaimed && !tokenOk) { + return { + exitCode: 10, + stdout: '', + stderr: + 'host-initiated token rejected: invalid, expired, or bound to different params\n', + }; + } + if (!tokenOk) { + const denied = await cloudWriteConfirm( + deps, + `integration ${parsed.service} ${parsed.op}`, + params.path, + args, + ); + if (denied) return denied; + } + } + + 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/index.ts b/packages/relay/src/index.ts index 8121c2c7..0bc9bac1 100644 --- a/packages/relay/src/index.ts +++ b/packages/relay/src/index.ts @@ -47,6 +47,16 @@ export { } from './prompts.js'; export { BoxNotices } from './notices.js'; export { hashRpcParams, HostInitiatedTokens } from './host-initiated.js'; +export { + _resetIntegrationReadyCacheForTests, + assertIntegrationReady, + makeIntegrationOpRefusal, + parseIntegrationMethod, + refuseIntegrationCall, + runHostIntegration, + type IntegrationRpcParams, + type ParsedIntegrationMethod, +} from './integrations.js'; export { assertGhReady, checkoutGuards, diff --git a/packages/relay/src/integrations.ts b/packages/relay/src/integrations.ts new file mode 100644 index 00000000..022d107a --- /dev/null +++ b/packages/relay/src/integrations.ts @@ -0,0 +1,275 @@ +/** + * Generic host-side machinery for the `integration..` RPCs — + * the relay-side spine that turns a descriptor in `@agentbox/integrations` + * into a host-CLI invocation with read/write classification and write + * gating. Companion to `gh.ts`: same spawn/probe/cache shape, but driven + * by a descriptor (so each service is one small file in + * `@agentbox/integrations/connectors/`, not a new pair of files here). + * + * Lives in its own file so both `server.ts` (docker `POST /rpc`) and + * `host-actions.ts` (cloud path) share the same helpers — same cycle- + * avoidance reasoning as `gh.ts`. + */ + +import { spawn } from 'node:child_process'; +import type { IntegrationConnector, IntegrationOp } from '@agentbox/integrations'; +import type { GitRpcResult } from './types.js'; + +/** Wire params for every `integration..` method. Mirrors GhPrRpcParams. */ +export interface IntegrationRpcParams { + /** Container path the ctl ran in; used to pick the registered worktree. */ + path?: string; + /** Pass-through argv forwarded to the host CLI (after `op.buildArgv`). */ + args?: string[]; + /** + * One-time token minted by the host CLI via `/admin/host-initiated/mint` + * before invoking `agentbox-ctl integration `. Validated against + * the relay's in-memory store, scoped to `(boxId, method=integration..)` + * and the params-hash; consumed on match and the confirm prompt is + * skipped. Boxes cannot mint tokens (admin endpoint is loopback-only). + * Reserved for T1's host-CLI surface (T3+) — agent-initiated ctl calls + * never pass it; the `askPrompt` gate applies. + */ + hostInitiated?: string; +} + +const INTEGRATION_RPC_TIMEOUT_MS = 120_000; +const INTEGRATION_READY_CACHE_TTL_MS = 60_000; + +/** + * `integration..` wire shape: + * - service: lowercase ASCII, matches IntegrationConnector.service. + * - op: lowercase ASCII + digits + dots; first char a letter + * (excludes leading `.` shapes like `integration.notion..api`). + * + * Dots are allowed in the op portion so descriptor ops can use a + * dotted-namespace form (e.g. `page.create`) without colliding with the + * `integration..` delimiter — the parser splits on the FIRST two + * dots and keeps everything after as the op (so e.g. + * `integration.notion.page.create` parses to `{service:'notion', op:'page.create'}`). + */ +const INTEGRATION_METHOD_RE = /^integration\.([a-z][a-z0-9]*)\.([a-z][a-z0-9.]*)$/; + +export interface ParsedIntegrationMethod { + service: string; + op: string; +} + +/** Parse `integration..`; returns null on shape miss. */ +export function parseIntegrationMethod(method: string): ParsedIntegrationMethod | null { + const m = INTEGRATION_METHOD_RE.exec(method); + if (!m) return null; + const service = m[1]!; + const op = m[2]!; + // Disallow a trailing dot (`integration.notion.api.`) or consecutive dots + // (`integration.notion.page..create`) — the regex's `[a-z0-9.]*` is + // permissive on purpose; we reject the degenerate shapes here. + if (op.endsWith('.') || op.includes('..')) return null; + return { service, op }; +} + +interface IntegrationReadyCacheEntry { + /** null on success; ready-to-send error envelope when the binary isn't usable. */ + result: GitRpcResult | null; + expiresAt: number; +} +const integrationReadyCache = new Map(); + +/** + * Returns `null` when the host has the connector's binary on PATH; + * otherwise a ready-to-send `{ exitCode, stdout, stderr }` envelope + * describing what's missing. Cached per `connector.hostBin` for ~60s so a + * burst of integration ops doesn't reprobe on every call (same TTL as + * `assertGhReady`). + * + * - binary missing → exit 127 (matches Bash's "command not found"). + * - binary present but `--version` non-zero → propagate that exit. + * + * Auth-status is intentionally NOT probed here — `ntn` exits non-zero with + * a clear "not logged in" message on every call when unauthed, which + * surfaces directly through the relay's stdout/stderr passthrough. A + * dedicated `auth` probe is the `agentbox doctor` flow (T3), not the + * per-call hot path. + */ +export async function assertIntegrationReady( + connector: IntegrationConnector, +): Promise { + const now = Date.now(); + const cached = integrationReadyCache.get(connector.hostBin); + if (cached && cached.expiresAt > now) return cached.result; + const result = await probeIntegration(connector); + integrationReadyCache.set(connector.hostBin, { + result, + expiresAt: now + INTEGRATION_READY_CACHE_TTL_MS, + }); + return result; +} + +/** Test-only: clear the readiness cache between cases. */ +export function _resetIntegrationReadyCacheForTests(): void { + integrationReadyCache.clear(); +} + +async function probeIntegration( + connector: IntegrationConnector, +): Promise { + const version = await runHostBinary( + connector, + [...connector.detect.versionArgs], + process.cwd(), + 10_000, + ); + if (version.exitCode === 127 || /ENOENT/.test(version.stderr)) { + return { + exitCode: 127, + stdout: '', + stderr: `${connector.hostBin} not installed on host (install the ${connector.service} CLI on the host)\n`, + }; + } + if (version.exitCode !== 0) { + return { + exitCode: version.exitCode, + stdout: '', + stderr: + `${connector.hostBin} ${connector.detect.versionArgs.join(' ')} failed: ` + + (version.stderr || version.stdout).trimEnd() + + '\n', + }; + } + return null; +} + +/** + * Spawn the connector's host binary with the given op + user args inside + * `cwd`. Returns the standard `{ exitCode, stdout, stderr }` envelope. + * `op.buildArgv` (when supplied) shapes the host CLI's subcommand path; + * absent, the user args are forwarded verbatim. Connector env vars + * (e.g. `NOTION_KEYRING=0`) are merged onto `process.env` via + * `mergeConnectorEnv` — a descriptor that tries to set an env var + * outside its `_*` namespace yields a typed exit-78 envelope + * (sysexits EX_CONFIG) rather than throwing, so the docker /rpc and + * cloud paths both surface the misconfiguration as a normal envelope. + * + * Self-contained (no import dependency on the rest of the relay), same + * cycle-avoidance reasoning as `runHostGh` in `gh.ts`. + */ +export function runHostIntegration( + connector: IntegrationConnector, + op: IntegrationOp, + args: readonly string[], + cwd: string, + timeoutMs: number = INTEGRATION_RPC_TIMEOUT_MS, +): Promise { + const argv = op.buildArgv ? op.buildArgv(args) : [...args]; + return runHostBinary(connector, argv, cwd, timeoutMs); +} + +/** + * Merge the relay's `process.env` with the connector's declared overrides, + * but only let the connector set env vars whose names are in its + * `_…` namespace (or other deliberately-shared names) — never + * relay-controlled prefixes like `AGENTBOX_*`, `PATH`, `HOME`, etc. A + * careless future descriptor cannot disable the relay's prompt gate or + * rewrite PATH by setting `env: { AGENTBOX_PROMPT: 'off' }`. + */ +function mergeConnectorEnv(connector: IntegrationConnector): NodeJS.ProcessEnv { + if (!connector.env) return process.env; + const allowedPrefix = `${connector.service.toUpperCase()}_`; + const env: NodeJS.ProcessEnv = { ...process.env }; + for (const [key, value] of Object.entries(connector.env)) { + if (!key.startsWith(allowedPrefix)) { + throw new Error( + `integration ${connector.service}: env key '${key}' not in '${allowedPrefix}*' namespace; descriptor cannot set it`, + ); + } + env[key] = value; + } + return env; +} + +function runHostBinary( + connector: IntegrationConnector, + argv: readonly string[], + cwd: string, + timeoutMs: number, +): Promise { + let env: NodeJS.ProcessEnv; + try { + env = mergeConnectorEnv(connector); + } catch (err) { + // Bad descriptor — return a typed envelope so the in-box ctl prints + // the actual cause instead of an opaque relay "internal error" 500. + return Promise.resolve({ + exitCode: 78, + stdout: '', + stderr: `${connector.hostBin}: ${err instanceof Error ? err.message : String(err)}\n`, + }); + } + return new Promise((resolve) => { + const child = spawn(connector.hostBin, [...argv], { + cwd, + env, + stdio: ['ignore', 'pipe', 'pipe'], + }); + let stdout = ''; + let stderr = ''; + let settled = false; + const finish = (exitCode: number): void => { + if (settled) return; + settled = true; + resolve({ exitCode, stdout, stderr }); + }; + const timer = setTimeout(() => { + child.kill('SIGTERM'); + stderr += `\nrelay: ${connector.hostBin} command timed out after ${String(timeoutMs)}ms\n`; + finish(124); + }, timeoutMs); + child.stdout?.on('data', (chunk: Buffer) => { + stdout += chunk.toString('utf8'); + }); + child.stderr?.on('data', (chunk: Buffer) => { + stderr += chunk.toString('utf8'); + }); + child.on('error', (err) => { + clearTimeout(timer); + // ENOENT (binary missing) lands here too; surface as exit 127. + const code = (err as NodeJS.ErrnoException).code; + stderr += String(err.message ?? err); + finish(code === 'ENOENT' ? 127 : 1); + }); + child.on('close', (code) => { + clearTimeout(timer); + finish(code ?? -1); + }); + }); +} + +/** Ready-to-send refusal for an op not on the connector's allowlist. */ +export function makeIntegrationOpRefusal( + service: string, + op: string, + hostBin: string, + knownOps: readonly string[], +): GitRpcResult { + return { + exitCode: 65, + stdout: '', + stderr: + `integration ${service}: op '${op}' not on allowlist for ${hostBin}. ` + + `Available: ${knownOps.join(', ')}\n`, + }; +} + +/** + * Run the op's `refuseCall` pre-flight (e.g. `notion.api`'s GET-only check) + * and lift its `{exitCode, stderr}` shape into the relay's full + * `GitRpcResult`. Returns null when the call may proceed. + */ +export function refuseIntegrationCall( + op: IntegrationOp, + args: readonly string[], +): GitRpcResult | null { + const refusal = op.refuseCall?.(args); + if (!refusal) return null; + return { exitCode: refusal.exitCode, stdout: '', stderr: refusal.stderr }; +} diff --git a/packages/relay/src/server.ts b/packages/relay/src/server.ts index 1c06a7f0..9a109a97 100644 --- a/packages/relay/src/server.ts +++ b/packages/relay/src/server.ts @@ -4,6 +4,7 @@ import { executeCloudAction, refreshCloudPreviewUrl } from './host-actions.js'; import { HostActionQueue } from './host-action-queue.js'; import { BoxNotices } from './notices.js'; import { hostOpenCommand } from '@agentbox/sandbox-core'; +import { getConnector } from '@agentbox/integrations'; import { assertGhReady, checkoutGuards, @@ -27,6 +28,14 @@ import { type GhRunRpcParams, } from './gh.js'; import { hashRpcParams, HostInitiatedTokens } from './host-initiated.js'; +import { + assertIntegrationReady, + makeIntegrationOpRefusal, + parseIntegrationMethod, + refuseIntegrationCall, + runHostIntegration, + type IntegrationRpcParams, +} from './integrations.js'; import { askPrompt, isPromptAnswerBody, PendingPrompts, PromptSubscribers } from './prompts.js'; import { BoxRegistry, EventBuffer } from './registry.js'; import { BoxStatusStore, isValidBoxStatus } from './status-store.js'; @@ -539,6 +548,19 @@ export function createRelayServer(opts: RelayServerOptions): RelayServerHandle { send(res, status, result); return; } + if (body.method.startsWith('integration.')) { + const result = await handleIntegrationRpc( + body.method, + reg, + body.params as IntegrationRpcParams | undefined, + prompts, + subscribers, + hostInitiatedTokens, + ); + const status = result.exitCode === 0 ? 200 : 500; + send(res, status, result); + return; + } if (body.method === 'git.clone' || body.method === 'gh.repo.clone') { // Clone bundle-ship-back machinery is deferred to a follow-up PR // (see docs/plans/gh-and-git-shims-host-only.md → Deferred follow-ups). @@ -1324,6 +1346,111 @@ async function handleGhApiRpc( return runHostGh(['api', endpoint, ...args], worktree.hostMainRepo); } +/** + * `integration..`: generic dispatch for any connector + * registered in `@agentbox/integrations`. Mirrors the `gh.pr.` flow + * (worktree resolve → `assertReady` → host-initiated token / askPrompt for + * writes → shell out). Reads bypass the prompt; writes are always gated. + * Op-level `refuseCall` (e.g. `notion.api`'s GET-only check) runs after + * worktree resolve but before any host process is touched. + * + * All failures return the same `{exitCode, stdout, stderr}` envelope as + * `handleGhPrRpc` — including unknown-method/service shapes (exit 64) — + * so the cloud and docker paths emit identical wire shapes per the + * "fix across all providers" rule. + */ +async function handleIntegrationRpc( + method: string, + reg: BoxRegistration, + params: IntegrationRpcParams | undefined, + prompts: PendingPrompts, + subscribers: PromptSubscribers, + hostInitiatedTokens: HostInitiatedTokens, +): Promise { + const parsed = parseIntegrationMethod(method); + if (!parsed) { + return { + exitCode: 64, + stdout: '', + stderr: `unknown integration method shape: ${method}\n`, + }; + } + const connector = getConnector(parsed.service); + if (!connector) { + return { + exitCode: 64, + stdout: '', + stderr: `unknown integration service: ${parsed.service}\n`, + }; + } + const opDesc = connector.ops[parsed.op]; + if (!opDesc) { + return makeIntegrationOpRefusal( + parsed.service, + parsed.op, + connector.hostBin, + Object.keys(connector.ops), + ); + } + const containerPath = params?.path ?? '/workspace'; + const worktree = resolveWorktree(reg, containerPath); + if (!worktree) { + return { + exitCode: 64, + stdout: '', + stderr: `no worktree registered for box ${reg.boxId} matching ${containerPath}`, + }; + } + const args = Array.isArray(params?.args) + ? params.args.filter((a): a is string => typeof a === 'string') + : []; + + const callRefusal = refuseIntegrationCall(opDesc, args); + if (callRefusal) return callRefusal; + + const ready = await assertIntegrationReady(connector); + if (ready) return ready; + + // Host-initiated calls (from a host CLI mint) skip the prompt — but only + // with a valid scope-matched, params-hash-bound one-time token. Hard + // reject a *present-but-invalid* token (attack signal). Only fall through + // to the prompt when no token was claimed. Reads never need a token. + if (opDesc.write) { + const tokenClaimed = typeof params?.hostInitiated === 'string'; + const incomingHash = hashRpcParams(params); + const tokenOk = + tokenClaimed && + hostInitiatedTokens.consume(params?.hostInitiated, reg.boxId, method, incomingHash); + if (tokenClaimed && !tokenOk) { + return { + exitCode: 10, + stdout: '', + stderr: + 'host-initiated token rejected: invalid, expired, or bound to different params\n', + }; + } + if (!tokenOk) { + const detail = args.join(' ').slice(0, 200); + const verdict = await askPrompt(prompts, subscribers, reg.boxId, { + kind: 'confirm', + message: `Allow ${parsed.service} ${parsed.op} from box ${reg.name}?`, + detail, + defaultAnswer: 'n', + context: { + command: `integration ${parsed.service} ${parsed.op}`, + cwd: containerPath, + argv: args, + }, + }); + if (verdict.answer !== 'y') { + return { exitCode: 10, stdout: '', stderr: 'denied by user\n' }; + } + } + } + + return runHostIntegration(connector, opDesc, args, worktree.hostMainRepo); +} + /** * cp.toHost / cp.fromHost: copy a file/dir between box and host. Shells * out to the installed agentbox CLI's `cp` subcommand — that command diff --git a/packages/relay/test/host-actions.test.ts b/packages/relay/test/host-actions.test.ts index ab188ce9..d1cd1d83 100644 --- a/packages/relay/test/host-actions.test.ts +++ b/packages/relay/test/host-actions.test.ts @@ -139,4 +139,39 @@ describe('executeCloudAction routing', () => { expect(result.exitCode).toBe(65); expect(result.stderr).toMatch(/DELETE|not proxied/); }); + + // Integration.* routing: same shape parity with docker so an agent's + // misnamed call yields the same envelope on either provider. + it('integration.notion. (malformed shape) returns exit 64', async () => { + const result = await executeCloudAction(action('integration.notion.'), makeDeps()); + expect(result.exitCode).toBe(64); + expect(result.stderr).toContain('unknown integration method shape'); + }); + + it('integration.linear.api (unknown service, allowlist-default) returns exit 64', async () => { + const result = await executeCloudAction( + action('integration.linear.api', { args: ['v1/issues'] }), + makeDeps(), + ); + expect(result.exitCode).toBe(64); + expect(result.stderr).toContain('unknown integration service'); + }); + + it('integration.notion.bogus (op not on allowlist) returns exit 65', async () => { + const result = await executeCloudAction( + action('integration.notion.bogus', { args: [] }), + makeDeps(), + ); + expect(result.exitCode).toBe(65); + expect(result.stderr).toContain('not on allowlist'); + }); + + it('integration.notion.api with -X DELETE refused (read classification stays honest)', async () => { + const result = await executeCloudAction( + action('integration.notion.api', { args: ['-X', 'DELETE', 'v1/blocks/abc'] }), + makeDeps(), + ); + expect(result.exitCode).toBe(65); + expect(result.stderr).toMatch(/notion api/); + }); }); diff --git a/packages/relay/test/integrations.test.ts b/packages/relay/test/integrations.test.ts new file mode 100644 index 00000000..e6a97994 --- /dev/null +++ b/packages/relay/test/integrations.test.ts @@ -0,0 +1,374 @@ +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import type { AddressInfo } from 'node:net'; +import { mkdtemp, readFile, rm, writeFile, chmod } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { startRelayServer, type RelayServerHandle } from '../src/server.js'; +import { + parseIntegrationMethod, + refuseIntegrationCall, + runHostIntegration, +} from '../src/integrations.js'; +import type { IntegrationConnector } from '@agentbox/integrations'; + +interface FetchResult { + status: number; + body: unknown; + text: string; +} + +async function fetchJson( + handle: RelayServerHandle, + method: string, + path: string, + init: { token?: string; body?: unknown } = {}, +): Promise { + const port = (handle.server.address() as AddressInfo).port; + const headers: Record = { 'Content-Type': 'application/json' }; + if (init.token) headers.Authorization = `Bearer ${init.token}`; + const res = await fetch(`http://127.0.0.1:${String(port)}${path}`, { + method, + headers, + body: init.body !== undefined ? JSON.stringify(init.body) : undefined, + }); + const text = await res.text(); + let body: unknown = null; + if (text.length > 0) { + try { + body = JSON.parse(text); + } catch { + body = text; + } + } + return { status: res.status, body, text }; +} + +describe('refuseIntegrationCall', () => { + it('returns null when the op has no refuseCall hook', () => { + expect(refuseIntegrationCall({ write: true }, ['anything'])).toBeNull(); + }); + + it('lifts the descriptor refusal into a full GitRpcResult', () => { + const op = { + write: false, + refuseCall: () => ({ exitCode: 65, stderr: 'no\n' }), + }; + expect(refuseIntegrationCall(op, [])).toEqual({ + exitCode: 65, + stdout: '', + stderr: 'no\n', + }); + }); +}); + +describe('connector.env namespace guard', () => { + // A future descriptor that tries to shadow a relay-controlled env var + // (AGENTBOX_PROMPT, PATH, etc.) must be rejected so a careless contributor + // can't disable the prompt gate from a descriptor. The runtime path + // returns a typed exit-78 envelope (sysexits EX_CONFIG) instead of + // throwing, so the in-box ctl prints the actual cause rather than an + // opaque relay 'internal error' 500. + it('returns exit 78 when a descriptor sets an env key outside its SERVICE_ namespace', async () => { + const bogus: IntegrationConnector = { + service: 'notion', + hostBin: 'ntn', + detect: { versionArgs: ['--version'] }, + env: { AGENTBOX_PROMPT: 'off' }, + ops: { ping: { write: false, buildArgv: () => ['--version'] } }, + }; + const r = await runHostIntegration(bogus, bogus.ops.ping!, [], process.cwd(), 5_000); + expect(r.exitCode).toBe(78); + expect(r.stderr).toMatch(/not in 'NOTION_\*' namespace/); + }); + + it('accepts an env key in the SERVICE_ namespace', async () => { + const ok: IntegrationConnector = { + service: 'notion', + hostBin: '/bin/true', + detect: { versionArgs: ['--version'] }, + env: { NOTION_KEYRING: '0' }, + ops: { ping: { write: false, buildArgv: () => [] } }, + }; + const r = await runHostIntegration(ok, ok.ops.ping!, [], process.cwd(), 5_000); + expect(r.exitCode).toBe(0); + }); +}); + +describe('parseIntegrationMethod', () => { + it('parses well-formed integration methods', () => { + expect(parseIntegrationMethod('integration.notion.api')).toEqual({ + service: 'notion', + op: 'api', + }); + // Dotted op names (page.create) split on the FIRST two dots and keep + // the rest as the op. + expect(parseIntegrationMethod('integration.notion.page.create')).toEqual({ + service: 'notion', + op: 'page.create', + }); + }); + + it('rejects degenerate shapes', () => { + expect(parseIntegrationMethod('integration.notion.')).toBeNull(); + expect(parseIntegrationMethod('integration..api')).toBeNull(); + expect(parseIntegrationMethod('integration.notion.page..create')).toBeNull(); + expect(parseIntegrationMethod('integration.notion.api.')).toBeNull(); + expect(parseIntegrationMethod('integration.NOTION.api')).toBeNull(); + expect(parseIntegrationMethod('gh.pr.create')).toBeNull(); + expect(parseIntegrationMethod('')).toBeNull(); + }); +}); + +/** + * End-to-end relay /rpc dispatch through `handleIntegrationRpc`. We stub + * `ntn` via a tempdir on PATH (same pattern as `relay /rpc gh.pr.* flow` + * in server.test.ts) so the tests are deterministic on machines without + * the real CLI. The stub records its argv + the value of NOTION_KEYRING + * into side files so we can assert what was invoked. + */ +describe('relay /rpc integration.* flow', () => { + let handle: RelayServerHandle; + let stubDir: string; + let stubLog: string; + let stubEnvLog: string; + let prevPath: string | undefined; + let prevPrompt: string | undefined; + + beforeEach(async () => { + stubDir = await mkdtemp(join(tmpdir(), 'ntn-stub-')); + stubLog = join(stubDir, 'invocations.log'); + stubEnvLog = join(stubDir, 'env.log'); + // The stub records argv + the NOTION_KEYRING env value. `--version` + // satisfies the readiness probe; `api …` and `page create …` etc. + // echo their argv and exit 0 so the relay's runHostIntegration + // produces a stable, asserted stdout. + const script = `#!/usr/bin/env bash +echo "$@" >> ${JSON.stringify(stubLog)} +echo "NOTION_KEYRING=\${NOTION_KEYRING-unset}" >> ${JSON.stringify(stubEnvLog)} +case "$1" in + --version) echo "ntn stub 0.0.0"; exit 0 ;; + *) echo "stub: ntn $*"; exit 0 ;; +esac +`; + const stubPath = join(stubDir, 'ntn'); + await writeFile(stubPath, script, 'utf8'); + await chmod(stubPath, 0o755); + prevPath = process.env.PATH; + process.env.PATH = `${stubDir}:${prevPath ?? ''}`; + prevPrompt = process.env.AGENTBOX_PROMPT; + delete process.env.AGENTBOX_PROMPT; + const integ = await import('../src/integrations.js'); + integ._resetIntegrationReadyCacheForTests(); + handle = await startRelayServer({ port: 0, host: '127.0.0.1' }); + }); + + afterEach(async () => { + await handle.close(); + await rm(stubDir, { recursive: true, force: true }); + if (prevPath === undefined) delete process.env.PATH; + else process.env.PATH = prevPath; + if (prevPrompt === undefined) delete process.env.AGENTBOX_PROMPT; + else process.env.AGENTBOX_PROMPT = prevPrompt; + const integ = await import('../src/integrations.js'); + integ._resetIntegrationReadyCacheForTests(); + }); + + async function registerBox(): Promise { + // hostMainRepo doesn't need to exist on disk: handleIntegrationRpc only + // uses it as a cwd for the spawn, and the stub doesn't look at cwd. + const r = await fetchJson(handle, 'POST', '/admin/register-box', { + body: { + boxId: 'b1', + token: 't1', + name: 'box-one', + worktrees: [ + { containerPath: '/workspace', hostMainRepo: stubDir, branch: 'agentbox/box-one' }, + ], + }, + }); + expect(r.status).toBe(204); + } + + it('reads (api) bypass the prompt and propagate stub stdout', async () => { + await registerBox(); + 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(200); + const body = r.body as { exitCode: number; stdout: string }; + expect(body.exitCode).toBe(0); + expect(body.stdout).toContain('stub: ntn api v1/users/me'); + expect(handle.prompts.size()).toBe(0); + // NOTION_KEYRING=0 forced into the spawned env, so `ntn` reads + // file-based auth on Linux boxes. Lines: --version probe + the call. + const envSeen = await readFile(stubEnvLog, 'utf8'); + expect(envSeen).toMatch(/NOTION_KEYRING=0/); + expect(envSeen).not.toMatch(/NOTION_KEYRING=unset/); + }); + + it('write op enqueues an askPrompt; denial returns exit 10', async () => { + await registerBox(); + const rpcPromise = fetchJson(handle, 'POST', '/rpc', { + token: 't1', + body: { + method: 'integration.notion.page.create', + params: { path: '/workspace', args: ['--parent', 'db_id', '--title', 'T'] }, + }, + }); + let pendingId: string | null = null; + for (let i = 0; i < 50 && pendingId === null; i++) { + const list = handle.prompts.forBox('b1'); + if (list.length > 0) { + pendingId = list[0]!.id; + // The wire-format prompt context surfaces the method to the wrapper. + expect(list[0]!.context?.command).toBe('integration notion page.create'); + } else { + await new Promise((r) => setTimeout(r, 10)); + } + } + expect(pendingId).not.toBeNull(); + + const answer = await fetchJson(handle, 'POST', '/admin/prompts/answer', { + body: { id: pendingId, answer: 'n' }, + }); + expect(answer.status).toBe(204); + + const rpc = await rpcPromise; + expect(rpc.status).toBe(500); + const body = rpc.body as { exitCode: number; stderr: string }; + expect(body.exitCode).toBe(10); + expect(body.stderr).toMatch(/denied by user/); + }); + + it('write op approved runs the host CLI', async () => { + await registerBox(); + process.env.AGENTBOX_PROMPT = 'off'; + const r = await fetchJson(handle, 'POST', '/rpc', { + token: 't1', + body: { + method: 'integration.notion.page.create', + params: { path: '/workspace', args: ['--parent', 'db_id'] }, + }, + }); + expect(r.status).toBe(200); + const body = r.body as { exitCode: number; stdout: string }; + expect(body.exitCode).toBe(0); + expect(body.stdout).toContain('stub: ntn page create --parent db_id'); + }); + + it('op not on allowlist refuses with exit 65', async () => { + await registerBox(); + process.env.AGENTBOX_PROMPT = 'off'; + const r = await fetchJson(handle, 'POST', '/rpc', { + token: 't1', + body: { + method: 'integration.notion.bogus', + params: { path: '/workspace', args: [] }, + }, + }); + expect(r.status).toBe(500); + const body = r.body as { exitCode: number; stderr: string }; + expect(body.exitCode).toBe(65); + expect(body.stderr).toMatch(/not on allowlist/); + // The stub must NOT have been invoked for a disallowed op. + let invoked = false; + try { + const log = await readFile(stubLog, 'utf8'); + // Only `--version` from the readiness probe may appear. + invoked = log.split('\n').some((l) => l.trim().length > 0 && l.trim() !== '--version'); + } catch { + invoked = false; + } + expect(invoked).toBe(false); + }); + + it('unknown service surfaces exit 64 (allowlist-default; same envelope as cloud)', async () => { + await registerBox(); + const r = await fetchJson(handle, 'POST', '/rpc', { + token: 't1', + body: { + method: 'integration.linear.api', + params: { path: '/workspace', args: ['v1/issues'] }, + }, + }); + expect(r.status).toBe(500); + const body = r.body as { exitCode: number; stderr: string }; + expect(body.exitCode).toBe(64); + expect(body.stderr).toMatch(/unknown integration service/); + }); + + it('malformed method shape surfaces exit 64', async () => { + await registerBox(); + const r = await fetchJson(handle, 'POST', '/rpc', { + token: 't1', + body: { method: 'integration.notion.' }, + }); + expect(r.status).toBe(500); + const body = r.body as { exitCode: number; stderr: string }; + expect(body.exitCode).toBe(64); + expect(body.stderr).toMatch(/unknown integration method shape/); + }); + + it('refuseCall blocks an `api -X DELETE` before the host CLI is touched', async () => { + await registerBox(); + process.env.AGENTBOX_PROMPT = 'off'; + const r = await fetchJson(handle, 'POST', '/rpc', { + token: 't1', + body: { + method: 'integration.notion.api', + params: { path: '/workspace', args: ['-X', 'DELETE', 'v1/blocks/abc'] }, + }, + }); + expect(r.status).toBe(500); + const body = r.body as { exitCode: number; stderr: string }; + expect(body.exitCode).toBe(65); + expect(body.stderr).toMatch(/notion api/); + // The stub must NOT have been spawned for the rejected api call. + const log = await readFile(stubLog, 'utf8').catch(() => ''); + expect(log.split('\n').some((l) => l.trim() === '-X DELETE v1/blocks/abc')).toBe(false); + }); + + it('no worktree registered surfaces exit 64', async () => { + // Register without worktrees so resolveWorktree returns null. + const r0 = await fetchJson(handle, 'POST', '/admin/register-box', { + body: { boxId: 'b1', token: 't1', name: 'box-one' }, + }); + expect(r0.status).toBe(204); + 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(64); + expect(body.stderr).toMatch(/no worktree registered/); + }); + + it('reports ntn-not-installed when the binary is missing from PATH', async () => { + await registerBox(); + process.env.PATH = '/nonexistent-bin-dir'; + const integ = await import('../src/integrations.js'); + integ._resetIntegrationReadyCacheForTests(); + 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(127); + expect(body.stderr).toMatch(/ntn not installed/); + }); +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b5b1a365..ae624cd2 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -207,6 +207,9 @@ importers: '@agentbox/core': specifier: workspace:* version: link:../core + '@agentbox/integrations': + specifier: workspace:* + version: link:../integrations '@agentbox/relay': specifier: workspace:* version: link:../relay @@ -233,6 +236,21 @@ importers: specifier: ^2.1.8 version: 2.1.9(@types/node@22.19.19)(lightningcss@1.32.0) + packages/integrations: + devDependencies: + '@types/node': + specifier: ^22.10.1 + version: 22.19.19 + tsup: + specifier: ^8.3.5 + version: 8.5.1(jiti@2.7.0)(postcss@8.5.14)(tsx@4.22.3)(typescript@5.9.3)(yaml@2.9.0) + typescript: + specifier: ^5.7.2 + version: 5.9.3 + vitest: + specifier: ^2.1.8 + version: 2.1.9(@types/node@22.19.19)(lightningcss@1.32.0) + packages/relay: dependencies: '@agentbox/config': @@ -241,6 +259,9 @@ importers: '@agentbox/core': specifier: workspace:* version: link:../core + '@agentbox/integrations': + specifier: workspace:* + version: link:../integrations '@agentbox/sandbox-core': specifier: workspace:* version: link:../sandbox-core