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
5 changes: 5 additions & 0 deletions apps/cli/scripts/stage-runtime.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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 = [
Expand All @@ -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',
Expand Down Expand Up @@ -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],
Expand Down Expand Up @@ -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],
Expand All @@ -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],
Expand Down
58 changes: 47 additions & 11 deletions docs/notion_backlog.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <op> -- "$@"`).
- 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 <op> -- "$@"`).
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`),
Expand All @@ -87,3 +115,11 @@ Make a box agent able to type `notion …`.
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.
- 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.
28 changes: 22 additions & 6 deletions packages/config/src/load.ts
Original file line number Diff line number Diff line change
Expand Up @@ -175,9 +175,12 @@ function readLeaf(
branch: string,
leaf: string,
): unknown {
const b = (obj as Record<string, unknown>)[branch];
if (b === undefined || b === null || typeof b !== 'object') return undefined;
return (b as Record<string, unknown>)[leaf];
let cur: unknown = (obj as Record<string, unknown>)[branch];
for (const seg of leaf.split('.')) {
if (cur === undefined || cur === null || typeof cur !== 'object') return undefined;
cur = (cur as Record<string, unknown>)[seg];
}
return cur;
}

function writeLeaf(
Expand All @@ -186,7 +189,20 @@ function writeLeaf(
leaf: string,
value: unknown,
): void {
const b = (obj as unknown as Record<string, Record<string, unknown>>)[branch];
if (!b) return; // BUILT_IN_DEFAULTS guarantees the branch exists
b[leaf] = value;
let cur: Record<string, unknown> | undefined =
(obj as unknown as Record<string, Record<string, unknown>>)[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<string, unknown>;
}
cur[segs[segs.length - 1]!] = value;
}
69 changes: 52 additions & 17 deletions packages/config/src/parse.ts
Original file line number Diff line number Diff line change
Expand Up @@ -135,23 +135,7 @@ export function parseUserConfigObject(doc: unknown, where: string): Partial<User
if (!isPlainObject(branchRaw)) {
throw new UserConfigError(`${where}.${branchName}: must be a mapping`);
}
const branchOut: Record<string, unknown> = {};
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.
Expand All @@ -161,6 +145,57 @@ export function parseUserConfigObject(doc: unknown, where: string): Partial<User
return out;
}

/**
* Validate a YAML branch sub-tree against `branchSpec`'s registered leaf paths.
* Handles nested keys like `integrations.notion.enabled` — the branch is
* `integrations`, the leaf path is `notion.enabled`, so the YAML can be
* written as a nested mapping. `qualifiedPrefix` is the dotted path walked so
* far within the branch (empty at top level).
*/
function parseBranchObject(
branchSpec: BranchSpec,
branchName: string,
raw: Record<string, unknown>,
qualifiedPrefix: string,
where: string,
): Record<string, unknown> {
const out: Record<string, unknown> = {};
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
Expand Down
19 changes: 19 additions & 0 deletions packages/config/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -149,6 +149,11 @@ export interface UserConfig {
pruneProjectConfigs?: boolean;
pruneProjectConfigsEvery?: number;
};
integrations?: {
notion?: {
enabled?: boolean;
};
};
}

/**
Expand Down Expand Up @@ -265,6 +270,11 @@ export interface EffectiveConfig {
pruneProjectConfigs: boolean;
pruneProjectConfigsEvery: number;
};
integrations: {
notion: {
enabled: boolean;
};
};
}

export type ConfigSource = 'cli' | 'workspace' | 'project' | 'global' | 'default';
Expand Down Expand Up @@ -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';
Expand Down Expand Up @@ -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<string, KeyDescriptor>(KEY_REGISTRY.map((d) => [d.key, d]));
Expand Down
42 changes: 26 additions & 16 deletions packages/config/src/write.ts
Original file line number Diff line number Diff line change
Expand Up @@ -272,26 +272,36 @@ function stampSchema(doc: Partial<UserConfig>): void {
}

function setLeaf(doc: Partial<UserConfig>, 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<string, Record<string, unknown>>;
if (!root[branch] || typeof root[branch] !== 'object') {
root[branch] = {};
const segs = key.split('.');
let cur = doc as unknown as Record<string, unknown>;
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<string, unknown>;
}
root[branch][leaf] = value;
cur[segs[segs.length - 1]!] = value;
}

function unsetLeaf(doc: Partial<UserConfig>, 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<string, Record<string, unknown>>;
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<string, unknown>[] = [doc as unknown as Record<string, unknown>];
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<string, unknown>);
}
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;
}
Expand Down
34 changes: 34 additions & 0 deletions packages/config/test/merge-precedence.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');
});
});
23 changes: 23 additions & 0 deletions packages/config/test/set-unset-roundtrip.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, unknown>
| null) ?? {};
// Both the deepest leaf AND the empty `notion` / `integrations` parents
// must be pruned so the YAML stays tidy.
expect(after).not.toHaveProperty('integrations');
});
});
Loading
Loading