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
36 changes: 36 additions & 0 deletions agentbox.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,42 @@ carry:
dest: ~/.local/share/opencode/auth.json
mode: 0o600
optional: true
# Notion CLI (`ntn`) file-based auth — INTERNAL AGENTBOX DEV ONLY, so a box
# that creates NESTED boxes can act as their relay host and shell out to `ntn`.
# Normal boxes never need this: they reach `ntn` through the host relay and
# carry no token. macOS `ntn` keeps its token in the keychain (uncarryable);
# the box is Linux, so for the nested path bootstrap the host once with
# `NOTION_KEYRING=0 ntn login` (writes ~/.config/notion/auth.json) and see
# docs/development.md for how the in-box `ntn` reads it (the connector no
# longer forces NOTION_KEYRING=0). All optional: a host without the file-auth
# still gets a working box (top-level boxes test through the host's own `ntn`).
- src: ~/.config/notion/auth.json
dest: ~/.config/notion/auth.json
mode: 0o600
optional: true
- src: ~/.config/notion/config.json
dest: ~/.config/notion/config.json
mode: 0o600
optional: true
- src: ~/.config/notion/workspaces.json
dest: ~/.config/notion/workspaces.json
mode: 0o600
optional: true
# Linear CLI (`@schpet/linear-cli`, the `linear` binary) auth, so a box that
# creates NESTED boxes can act as their relay host and shell out to `linear`
# for the integration's e2e test. The CLI stores a plaintext API token at
# ~/.config/linear/credentials.toml by default (keyring migration is opt-in
# and not used here), so the file carries directly — no keyring env toggle
# needed (unlike `ntn`). All optional: a host without the file still gets a
# working box (top-level boxes test through the host's own authed `linear`).
- src: ~/.config/linear/credentials.toml
dest: ~/.config/linear/credentials.toml
mode: 0o600
optional: true
- src: ~/.config/linear/linear.toml
dest: ~/.config/linear/linear.toml
mode: 0o600
optional: true
# Per-provider base-snapshot pointers. With these, `agentbox prepare`
# inside the box can skip-fast (existing snapshot detected) and just
# exercise the post-prepare config write + migration — no bake needed.
Expand Down
1 change: 1 addition & 0 deletions apps/cli/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,7 @@
"@agentbox/config": "workspace:*",
"@agentbox/core": "workspace:*",
"@agentbox/ctl": "workspace:*",
"@agentbox/integrations": "workspace:*",
"@agentbox/relay": "workspace:*",
"@agentbox/sandbox-cloud": "workspace:*",
"@agentbox/sandbox-core": "workspace:*",
Expand Down
10 changes: 10 additions & 0 deletions apps/cli/scripts/stage-runtime.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,8 @@ 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/linear-shim',
'packages/sandbox-docker/scripts/chromium-resolver',
]);
const contextFiles = [
Expand All @@ -54,6 +56,8 @@ 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/linear-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 +102,8 @@ 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-docker/scripts/linear-shim', 'linear-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 +140,8 @@ 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-docker/scripts/linear-shim', 'linear-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 +167,8 @@ 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-docker/scripts/linear-shim', 'linear-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
23 changes: 14 additions & 9 deletions apps/cli/src/commands/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -69,22 +69,27 @@ function fail(message: string): never {
process.exit(1);
}

// Walk every dot-segment, not just the first one — `integrations.notion.enabled`
// must reach the deepest leaf, not be split as `integrations` + `notion.enabled`.
// Same shape as `readLeaf` in packages/config/src/load.ts.
function walkKey(obj: Record<string, unknown> | undefined, key: string): unknown {
let cur: unknown = obj;
for (const seg of key.split('.')) {
if (cur === undefined || cur === null || typeof cur !== 'object') return undefined;
cur = (cur as Record<string, unknown>)[seg];
}
return cur;
}

function leafValue(loaded: LoadedConfig, key: string): unknown {
const idx = key.indexOf('.');
const branch = key.slice(0, idx);
const leaf = key.slice(idx + 1);
return (loaded.effective as unknown as Record<string, Record<string, unknown>>)[branch]?.[leaf];
return walkKey(loaded.effective as unknown as Record<string, unknown>, key);
}

function rawLeafFromValues(
values: Record<string, unknown> | undefined,
key: string,
): unknown {
if (!values) return undefined;
const idx = key.indexOf('.');
const b = (values as Record<string, unknown>)[key.slice(0, idx)];
if (!b || typeof b !== 'object') return undefined;
return (b as Record<string, unknown>)[key.slice(idx + 1)];
return walkKey(values, key);
}

function describeSource(source: ConfigSource, loaded: LoadedConfig): string {
Expand Down
16 changes: 14 additions & 2 deletions apps/cli/src/commands/doctor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
import { Command } from 'commander';
import {
formatDetailed,
integrationsChecks,
runAllChecks,
runProviderChecks,
runSystemChecks,
Expand Down Expand Up @@ -42,9 +43,20 @@ export const doctorCommand = new Command('doctor')
);
process.exit(1);
}
// Integrations are host-side (not provider-side), but a user running
// `doctor -p hetzner` still wants to know whether their Notion is
// installed/authed/enabled — otherwise the only way to see the
// integrations group is the unscoped doctor, which is a discoverability
// gap. Include it alongside system + the scoped provider.
const [sys, prov, integrations] = await Promise.all([
runSystemChecks(),
runProviderChecks(name as ProviderName),
integrationsChecks(),
]);
groups = [
{ title: 'system', results: await runSystemChecks() },
await runProviderChecks(name as ProviderName),
{ title: 'system', results: sys },
prov,
{ title: 'integrations', results: integrations },
];
} else {
groups = await runAllChecks();
Expand Down
156 changes: 152 additions & 4 deletions apps/cli/src/lib/doctor-checks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,16 @@ import { accessSync, constants as fsConstants, mkdirSync } from 'node:fs';
import { homedir } from 'node:os';
import { join } from 'node:path';
import { execa } from 'execa';
import { loadEffectiveConfig } from '@agentbox/config';
import { ALL_CONNECTORS, type IntegrationConnector } from '@agentbox/integrations';

export type CheckStatus = 'ok' | 'warn' | 'fail';
/**
* `info` is for rows that are intentionally inert (e.g. an integration the
* user hasn't enabled). It surfaces as a distinct glyph but rolls up like
* `ok` so it never pushes the overall doctor status to "warn" — disabling
* Notion is a setting, not a problem.
*/
export type CheckStatus = 'ok' | 'info' | 'warn' | 'fail';

export interface CheckResult {
label: string;
Expand Down Expand Up @@ -373,6 +381,133 @@ async function e2bChecks(): Promise<CheckResult[]> {
}
}

/**
* Probe a binary, treating ENOENT (missing on PATH) as a distinct outcome
* from a non-zero exit. `execa({reject:false})` returns a result envelope
* even on spawn failure — `{ failed: true, code: 'ENOENT', exitCode: undefined }`
* — rather than throwing. We map that to `missing: true` so the integration
* check has a single, easy-to-read branch. Wrapped in try/catch in case a
* future execa release reverts to throwing on spawn errors.
*/
async function probeIntegrationBin(
bin: string,
args: readonly string[],
): Promise<{ exitCode: number; stdout: string; stderr: string; missing: boolean }> {
try {
const r = await execa(bin, [...args], { reject: false });
const code = (r as { code?: string }).code;
if (code === 'ENOENT') {
return { exitCode: 127, stdout: '', stderr: r.stderr ?? '', missing: true };
}
return {
exitCode: r.exitCode ?? 1,
stdout: typeof r.stdout === 'string' ? r.stdout : '',
stderr: typeof r.stderr === 'string' ? r.stderr : '',
missing: false,
};
} catch (err) {
const code = (err as NodeJS.ErrnoException).code;
return {
exitCode: code === 'ENOENT' ? 127 : 1,
stdout: '',
stderr: errSummary(err),
missing: code === 'ENOENT',
};
}
}

/** Shape `loadEffectiveConfig` returns; only the integrations slice matters here. */
type IntegrationsConfigSlice = {
effective: { integrations?: Record<string, { enabled?: boolean } | undefined> };
};

export type IntegrationsConfigLoader = (cwd: string) => Promise<IntegrationsConfigSlice>;

/**
* Per-connector host-side detection: is each `integrations.<svc>.enabled`
* flipped on, is the host CLI installed, and is the user logged in. Driven
* off `ALL_CONNECTORS` so Linear/Trello light up here automatically when
* they ship — no doctor change needed.
*
* `loader` is injectable for unit tests (mirrors `refuseIfIntegrationDisabled`'s
* approach). The default reads layered config from `cwd`, so toggling the
* flag via `agentbox config set` takes effect on the next doctor run with
* no caching.
*
* The auth probe runs each connector's CLI with no forced env, exactly as the
* relay does — so a host's real authed state (e.g. the macOS keychain after
* `ntn login`) is what's reported, and doctor can't show "authed" for a path
* the relay wouldn't actually use.
*/
export async function integrationsChecks(
loader: IntegrationsConfigLoader = loadEffectiveConfig,
): Promise<CheckResult[]> {
let cfg: IntegrationsConfigSlice;
try {
cfg = await loader(process.cwd());
} catch {
cfg = { effective: {} };
}
// Parallel: each connector's two probes (version + auth) are independent
// across connectors. With Linear / Trello / ClickUp queued, the serial
// walk would scale linearly; Promise.all keeps doctor latency flat.
return Promise.all(
ALL_CONNECTORS.map((connector) => checkOneIntegration(connector, cfg.effective.integrations)),
);
}

async function checkOneIntegration(
connector: IntegrationConnector,
integrations: Record<string, { enabled?: boolean } | undefined> | undefined,
): Promise<CheckResult> {
const svc = connector.service;
const enabled = integrations?.[svc]?.enabled === true;
if (!enabled) {
return {
label: svc,
status: 'info',
detail: 'disabled',
hint: `enable with \`agentbox config set --project integrations.${svc}.enabled true\``,
};
}

const version = await probeIntegrationBin(connector.hostBin, connector.detect.versionArgs);
if (version.missing || version.exitCode === 127) {
return {
label: svc,
status: 'warn',
detail: `${connector.hostBin} not installed`,
hint:
connector.detect.installHint ??
`install the ${svc} CLI (\`${connector.hostBin}\`) on the host`,
};
}
if (version.exitCode !== 0) {
const tail = firstLine((version.stderr || version.stdout).trim());
return {
label: svc,
status: 'warn',
detail: `${connector.hostBin} ${connector.detect.versionArgs.join(' ')} failed${tail ? `: ${tail}` : ''}`,
};
}
const versionLine = firstLine((version.stdout || version.stderr).trim()) || connector.hostBin;

if (!connector.detect.authArgs || connector.detect.authArgs.length === 0) {
return { label: svc, status: 'ok', detail: versionLine };
}

const auth = await probeIntegrationBin(connector.hostBin, connector.detect.authArgs);
if (auth.exitCode !== 0) {
return {
label: svc,
status: 'warn',
detail: 'not logged in',
hint: connector.detect.loginHint ?? `run \`${connector.hostBin} login\``,
};
}
return { label: svc, status: 'ok', detail: `${versionLine} · authed` };
}

export async function runProviderChecks(name: ProviderName): Promise<CheckGroup> {
let results: CheckResult[];
switch (name) {
Expand All @@ -398,14 +533,17 @@ export async function runProviderChecks(name: ProviderName): Promise<CheckGroup>
export async function runAllChecks(): Promise<CheckGroup[]> {
const sys: CheckGroup = { title: 'system', results: await runSystemChecks() };
const providerGroups = await Promise.all(ALL_PROVIDERS.map((n) => runProviderChecks(n)));
return [sys, ...providerGroups];
const integrations: CheckGroup = { title: 'integrations', results: await integrationsChecks() };
return [sys, ...providerGroups, integrations];
}

function worstInResults(results: CheckResult[]): CheckStatus {
let worst: CheckStatus = 'ok';
for (const r of results) {
if (r.status === 'fail') return 'fail';
if (r.status === 'warn') worst = 'warn';
// `info` rolls up like `ok` — intentionally inert rows shouldn't flip
// the overall doctor status.
}
return worst;
}
Expand All @@ -427,6 +565,14 @@ function summaryToken(group: CheckGroup): string {
if (worst === 'warn') return 'system warn';
return 'system ok';
}
if (group.title === 'integrations') {
if (worst === 'fail') return 'integrations FAIL';
if (worst === 'warn') return 'integrations check';
// All rows ok or info (disabled) — render as "off" when every row is
// info, else "ready" when at least one is enabled and green.
const anyEnabled = group.results.some((r) => r.status === 'ok');
return anyEnabled ? 'integrations ready' : 'integrations off';
}
if (worst === 'fail') return `${group.title} FAIL`;
if (worst === 'warn') {
// Distinguish "not configured" (warn on credentials) from other warns.
Expand All @@ -441,13 +587,14 @@ function summaryToken(group: CheckGroup): string {
const C_GREEN = '\x1b[32m';
const C_YELLOW = '\x1b[33m';
const C_RED = '\x1b[31m';
const C_DIM = '\x1b[2m';
const C_RESET = '\x1b[0m';
const COLOR = !process.env.NO_COLOR; // install requires a TTY anyway; honor NO_COLOR for piped output

function statusMarker(s: CheckStatus): string {
const glyph = s === 'ok' ? '✓' : s === 'warn' ? '⚠' : '✗';
const glyph = s === 'ok' ? '✓' : s === 'info' ? '·' : s === 'warn' ? '⚠' : '✗';
if (!COLOR) return glyph;
const color = s === 'ok' ? C_GREEN : s === 'warn' ? C_YELLOW : C_RED;
const color = s === 'ok' ? C_GREEN : s === 'info' ? C_DIM : s === 'warn' ? C_YELLOW : C_RED;
return `${color}${glyph}${C_RESET}`;
}

Expand All @@ -464,6 +611,7 @@ function pad(s: string, width: number): string {

function statusBadge(s: CheckStatus): string {
if (s === 'ok') return '[ ok ]';
if (s === 'info') return '[info]';
if (s === 'warn') return '[warn]';
return '[FAIL]';
}
Expand Down
Loading
Loading