Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 6 additions & 1 deletion docs/notion_backlog.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <op>` round-trips
through the relay to host `ntn`, with read/write classification + write gating.
- `packages/integrations/` package: `types.ts` (IntegrationOp, IntegrationConnector),
Expand Down Expand Up @@ -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.<svc>.<op>` dispatch wired into both
`server.ts` (docker) and `host-actions.ts` (cloud), and `agentbox-ctl
integration` command tree. PR pending.
1 change: 1 addition & 0 deletions packages/ctl/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@
},
"dependencies": {
"@agentbox/core": "workspace:*",
"@agentbox/integrations": "workspace:*",
"@agentbox/relay": "workspace:*",
"commander": "^12.1.0",
"yaml": "^2.6.1"
Expand Down
2 changes: 2 additions & 0 deletions packages/ctl/src/bin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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);
Expand Down
60 changes: 60 additions & 0 deletions packages/ctl/src/commands/integration.ts
Original file line number Diff line number Diff line change
@@ -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.<service>.<op>`),
* 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 <path>',
'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;
}
33 changes: 33 additions & 0 deletions packages/integrations/package.json
Original file line number Diff line number Diff line change
@@ -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"
}
}
108 changes: 108 additions & 0 deletions packages/integrations/src/connectors/notion.ts
Original file line number Diff line number Diff line change
@@ -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}'`,
);
}
8 changes: 8 additions & 0 deletions packages/integrations/src/index.ts
Original file line number Diff line number Diff line change
@@ -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';
18 changes: 18 additions & 0 deletions packages/integrations/src/registry.ts
Original file line number Diff line number Diff line change
@@ -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.<service>.<op>` 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;
}
66 changes: 66 additions & 0 deletions packages/integrations/src/types.ts
Original file line number Diff line number Diff line change
@@ -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<Record<string, string>>;
/** Allowlist of proxied ops; anything not listed is denied at the relay. */
ops: Readonly<Record<string, IntegrationOp>>;
}
Loading
Loading