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
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
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
157 changes: 153 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,134 @@ 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.
*
* Auth env handling: we deliberately do NOT force `NOTION_KEYRING=0` on the
* host probe. The Notion connector forces it inside the box because the box
* has no keychain; on the host the user's authed state IS the keychain
* entry, and forcing the file-auth path would make a keychain-authed user
* read as "not logged in" against a non-existent `~/.config/notion/auth.json`.
*/
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);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Auth probe lacks timeout

Medium Severity

When Notion is enabled, checkOneIntegration runs probeIntegrationBin for authArgs (ntn api v1/users/me) with no execa timeout, so a slow or stuck network call can leave agentbox doctor hanging unlike relay integration RPCs, which cap host CLI time.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 404a23f. Configure here.

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 +534,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 +566,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 +588,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 +612,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
127 changes: 127 additions & 0 deletions apps/cli/test/doctor-integrations.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
/**
* Unit tests for the `integrations:` group in `agentbox doctor`.
*
* The real `ntn` lives only on the host (this box can't install it), so the
* test stages a tiny shell script named `ntn` on a private PATH and asserts
* the four meaningful transitions: disabled → info, enabled+missing → warn,
* enabled+present-but-unauthed → warn (with the login hint), enabled+ok → ok.
*
* Config is injected via the `IntegrationsConfigLoader` parameter rather than
* touched on disk — same pattern `refuseIfIntegrationDisabled` uses in the
* relay, so the test stays pure (no `~/.agentbox` touch).
*/

import { chmod, mkdtemp, rm, writeFile } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import {
integrationsChecks,
type IntegrationsConfigLoader,
} from '../src/lib/doctor-checks.js';

const NTN_SCRIPT = `#!/usr/bin/env bash
case "$1" in
--version)
echo "ntn version 0.42.0"
exit 0 ;;
api)
if [ "$NTN_TEST_AUTH" = "ok" ]; then
echo '{"object":"user","id":"stub"}'
exit 0
fi
echo "Error: not logged in. Run 'ntn login' to authenticate." >&2
exit 1 ;;
*)
echo "stub: unknown subcommand $1" >&2
exit 2 ;;
esac
`;

const enabled: IntegrationsConfigLoader = () =>
Promise.resolve({ effective: { integrations: { notion: { enabled: true } } } });
const disabled: IntegrationsConfigLoader = () => Promise.resolve({ effective: {} });

describe('doctor — integrations group', () => {
let stubDir: string;
let originalPath: string | undefined;
let originalAuth: string | undefined;

beforeEach(async () => {
stubDir = await mkdtemp(join(tmpdir(), 'agentbox-doctor-int-'));
originalPath = process.env.PATH;
originalAuth = process.env.NTN_TEST_AUTH;
});

afterEach(async () => {
if (originalPath === undefined) delete process.env.PATH;
else process.env.PATH = originalPath;
if (originalAuth === undefined) delete process.env.NTN_TEST_AUTH;
else process.env.NTN_TEST_AUTH = originalAuth;
await rm(stubDir, { recursive: true, force: true });
});

async function stageStub(): Promise<void> {
const ntn = join(stubDir, 'ntn');
await writeFile(ntn, NTN_SCRIPT, 'utf8');
await chmod(ntn, 0o755);
// Prepend the stub dir so our fake `ntn` wins over any real one, but
// keep the original PATH so the script's `#!/usr/bin/env bash` shebang
// can still resolve `bash` (env in /usr/bin uses the child's PATH).
process.env.PATH = `${stubDir}:${originalPath ?? ''}`;
}

function emptyPath(): void {
// Only the empty stub dir — execa(`ntn`) gets ENOENT directly (no
// shebang interpretation needed for a missing binary).
process.env.PATH = stubDir;
}

it('renders info / "disabled" when the flag is off (default)', async () => {
emptyPath();
const results = await integrationsChecks(disabled);
expect(results).toHaveLength(1);
const row = results[0]!;
expect(row.label).toBe('notion');
expect(row.status).toBe('info');
expect(row.detail).toBe('disabled');
expect(row.hint).toContain('integrations.notion.enabled true');
});

it('renders warn / "not installed" when enabled but ntn is missing', async () => {
emptyPath();
const results = await integrationsChecks(enabled);
const row = results[0]!;
expect(row.status).toBe('warn');
expect(row.detail).toMatch(/not installed/);
expect(row.hint).toMatch(/install ntn/);
});

it('renders warn / "not logged in" when ntn is present but unauthed', async () => {
await stageStub();
delete process.env.NTN_TEST_AUTH;
const results = await integrationsChecks(enabled);
const row = results[0]!;
expect(row.status).toBe('warn');
expect(row.detail).toBe('not logged in');
expect(row.hint).toBe('ntn login');
});

it('renders ok with the version line when ntn is present and authed', async () => {
await stageStub();
process.env.NTN_TEST_AUTH = 'ok';
const results = await integrationsChecks(enabled);
const row = results[0]!;
expect(row.status).toBe('ok');
expect(row.detail).toContain('ntn version 0.42.0');
expect(row.detail).toContain('authed');
});

it('fails closed (no throw) when the config loader rejects', async () => {
emptyPath();
const broken: IntegrationsConfigLoader = () =>
Promise.reject(new Error('malformed yaml'));
const results = await integrationsChecks(broken);
expect(results[0]?.status).toBe('info');
});
});
2 changes: 1 addition & 1 deletion apps/web/content/docs/cli.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -259,7 +259,7 @@ agentbox prepare -p hetzner
agentbox prepare -p docker --build
```

`install` is the first-run setup wizard (system check, pick a provider, log in, prepare its base image, install the host skill). `install cmux` pins a live `agentbox list` panel (all your boxes) to the [cmux](https://cmux.com) sidebar dock — see [cmux integration](/docs/integrations-cmux#the-agentbox-dock-right-sidebar). `doctor` diagnoses system and provider readiness. `prepare` builds base images or snapshots — omit `--provider` for status only.
`install` is the first-run setup wizard (system check, pick a provider, log in, prepare its base image, install the host skill). `install cmux` pins a live `agentbox list` panel (all your boxes) to the [cmux](https://cmux.com) sidebar dock — see [cmux integration](/docs/integrations-cmux#the-agentbox-dock-right-sidebar). `doctor` diagnoses system and provider readiness — and reports each [service integration](/docs/integrations-notion) (host CLI installed? authed? enabled per project?). `prepare` builds base images or snapshots — omit `--provider` for status only.

<Callout title="TIP">`agentbox config get <key> --all` shows which layer each value comes from. See the full key reference in [Configuration](/docs/configuration).</Callout>

Expand Down
Loading
Loading