@zgeoff/atc 3.3.0 → 3.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +4 -2
- package/src/agents/agent-adapter.ts +10 -0
- package/src/agents/build-cli-argv.ts +2 -4
- package/src/agents/claude-adapter.ts +13 -3
- package/src/agents/codex-adapter.ts +11 -2
- package/src/agents/gateway-adapter.ts +20 -8
- package/src/agents/grok-adapter.ts +16 -13
- package/src/agents/load-claude-config-bundle.ts +6 -6
- package/src/agents/print-codex-hook-file.ts +7 -3
- package/src/agents/print-grok-hook-file.ts +8 -3
- package/src/agents/resolve-agent-home.ts +7 -3
- package/src/agents/resolve-claude-global-config-path.ts +4 -4
- package/src/agents/update-claude-project-trust.ts +41 -30
- package/src/agents/with-claude-config-lock.ts +13 -1
- package/src/agents/write-atc-bridge.ts +10 -7
- package/src/agents/write-hook-settings.ts +13 -7
- package/src/client/boot-daemon.ts +48 -15
- package/src/client/dirs.ts +4 -2
- package/src/client/index.ts +17 -1
- package/src/client/log-client-event.ts +20 -0
- package/src/client/spawn-picker.ts +200 -121
- package/src/client/ui.ts +7 -7
- package/src/clients.ts +41 -11
- package/src/daemon/daemon.ts +143 -33
- package/src/daemon/event-signal.ts +19 -3
- package/src/daemon/make-hook-runner.ts +82 -22
- package/src/daemon/permission-registry.ts +11 -6
- package/src/daemon/restore-fleet.ts +62 -17
- package/src/daemon/run-eject-handoff.ts +20 -2
- package/src/daemon/sessions.ts +6 -0
- package/src/daemon/start-tcp-listener.ts +12 -10
- package/src/federation/daemon-caller.ts +25 -4
- package/src/federation/daemon-pool.ts +3 -1
- package/src/federation/open-gateway-caller.ts +6 -1
- package/src/grants.ts +36 -11
- package/src/hook-report.ts +17 -3
- package/src/mcp/read-report-texts.ts +11 -7
- package/src/mcp/start-mcp-http-server.ts +5 -1
- package/src/mcp-http-server.ts +85 -32
- package/src/report.ts +21 -3
- package/src/run-bridge-tap.ts +36 -7
- package/src/run-config-migrate.ts +35 -11
- package/src/run-gateway.ts +4 -2
- package/src/shared/build-atc-command.ts +7 -3
- package/src/shared/bun-sqlite-driver.ts +10 -0
- package/src/shared/claim-daemon-lock.ts +10 -4
- package/src/shared/collect-dir-roots.ts +6 -6
- package/src/shared/collect-hooks.ts +7 -6
- package/src/shared/config.ts +19 -9
- package/src/shared/system-clock.ts +24 -0
- package/src/sources/github/collect-github-repos.ts +1 -1
- package/src/sources/github/read-git-protocol.ts +1 -1
- package/src/sources/github/run-gh.ts +15 -9
- package/src/store/state-store.ts +57 -7
- package/src/tap.ts +44 -13
- package/src/test-utils/build-mock-agent-adapter.ts +25 -0
- package/src/test-utils/build-mock-agent-entry.ts +32 -0
- package/src/test-utils/build-mock-auth-binding.ts +28 -0
- package/src/test-utils/build-mock-auth-profile.ts +62 -0
- package/src/test-utils/build-mock-daemon-record.ts +26 -0
- package/src/test-utils/build-mock-fleet-entry.ts +33 -0
- package/src/test-utils/build-mock-gateway-config.ts +32 -0
- package/src/test-utils/build-mock-imp-identity.ts +18 -0
- package/src/test-utils/build-mock-key-binding-claim.ts +34 -0
- package/src/test-utils/build-mock-message-record.ts +22 -0
- package/src/test-utils/build-mock-mirror-session.ts +42 -0
- package/src/test-utils/build-mock-registry-daemon.ts +28 -0
- package/src/test-utils/build-mock-session-descriptor.ts +66 -0
- package/src/test-utils/build-mock-session.ts +43 -0
- package/src/test-utils/build-mock-sortable-session-view.ts +20 -0
- package/src/test-utils/build-stub-attention-adapter.ts +22 -0
- package/src/test-utils/build-stub-bridge-context.ts +20 -0
- package/src/test-utils/build-stub-brokered-agent-adapter.ts +96 -0
- package/src/test-utils/build-stub-channel-opener.ts +45 -0
- package/src/test-utils/build-stub-claude-headless-run.ts +13 -0
- package/src/test-utils/build-stub-client-screens.ts +33 -0
- package/src/test-utils/build-stub-clock.ts +67 -0
- package/src/test-utils/build-stub-daemon-context.ts +81 -0
- package/src/test-utils/build-stub-daemon-requests.ts +103 -0
- package/src/test-utils/{fixture-dir-provider.ts → build-stub-dir-provider.ts} +20 -12
- package/src/test-utils/build-stub-execution-provider.ts +112 -0
- package/src/test-utils/build-stub-fleet-caller.ts +60 -0
- package/src/test-utils/build-stub-forking-gh.ts +10 -0
- package/src/test-utils/build-stub-gh.ts +50 -0
- package/src/test-utils/build-stub-handoff-daemon.ts +11 -0
- package/src/test-utils/build-stub-harness-relay.ts +50 -0
- package/src/test-utils/build-stub-headless-runner.ts +60 -0
- package/src/test-utils/build-stub-held-gh.ts +19 -0
- package/src/test-utils/build-stub-held-inbox-source.ts +36 -0
- package/src/test-utils/build-stub-held-provider.ts +58 -0
- package/src/test-utils/build-stub-held-zoxide.ts +13 -0
- package/src/test-utils/build-stub-host-hold.ts +31 -0
- package/src/test-utils/build-stub-host-provider.ts +26 -0
- package/src/test-utils/{fixture-imp-port.ts → build-stub-imp-port.ts} +119 -63
- package/src/test-utils/build-stub-log.ts +15 -0
- package/src/test-utils/build-stub-mcp-claude.ts +26 -0
- package/src/test-utils/build-stub-mcp-grok.ts +19 -0
- package/src/test-utils/build-stub-peer-socket.ts +81 -0
- package/src/test-utils/build-stub-picker-host.ts +36 -0
- package/src/test-utils/build-stub-proxied-agent-adapter.ts +29 -0
- package/src/test-utils/build-stub-pty-provider.ts +53 -0
- package/src/test-utils/build-stub-recording-atc.ts +10 -0
- package/src/test-utils/build-stub-signed-in-gh.ts +18 -0
- package/src/test-utils/build-stub-signed-out-gh.ts +8 -0
- package/src/test-utils/build-stub-soft-kill-provider.ts +29 -0
- package/src/test-utils/build-stub-targets.ts +58 -0
- package/src/test-utils/build-stub-terminal.ts +19 -0
- package/src/test-utils/build-stub-timeout-scheduler.ts +48 -0
- package/src/test-utils/build-stub-tui-claude.ts +47 -0
- package/src/test-utils/build-stub-tui-grok.ts +53 -0
- package/src/test-utils/build-stub-wait-clock.ts +23 -0
- package/src/test-utils/build-target-options-from-config.ts +37 -0
- package/src/test-utils/collect-process-tree.ts +75 -0
- package/src/test-utils/create-git-fixture.ts +120 -0
- package/src/test-utils/create-migrated-state-db.ts +12 -10
- package/src/test-utils/create-stub-bin.ts +19 -0
- package/src/test-utils/create-stub-claude.ts +75 -0
- package/src/test-utils/create-stub-codex.ts +31 -0
- package/src/test-utils/create-stub-composer.ts +65 -0
- package/src/test-utils/create-stub-failing-agent-adapter.ts +111 -0
- package/src/test-utils/create-stub-grok.ts +44 -0
- package/src/test-utils/create-stub-guest-clis.ts +55 -0
- package/src/test-utils/create-stub-harness-guest.ts +35 -0
- package/src/test-utils/create-stub-recording-claude.ts +20 -0
- package/src/test-utils/{setup-fake-systemd.ts → create-stub-systemd.ts} +5 -5
- package/src/test-utils/get-gateway-config.ts +3 -3
- package/src/test-utils/get-only-effect-ref.ts +21 -0
- package/src/test-utils/get-only-imp-name.ts +19 -0
- package/src/test-utils/get-records.ts +20 -0
- package/src/test-utils/get-string.ts +14 -0
- package/src/test-utils/keys.ts +26 -0
- package/src/test-utils/merge-deep.ts +36 -0
- package/src/test-utils/mock-overrides.ts +13 -0
- package/src/test-utils/open-repo-step.ts +24 -0
- package/src/test-utils/read-query-plan.ts +22 -0
- package/src/test-utils/resolve-atc-command.ts +13 -0
- package/src/test-utils/resolve-gateway-command.ts +13 -0
- package/src/test-utils/run-atc.ts +56 -0
- package/src/test-utils/run-legacy-daemon.ts +27 -5
- package/src/test-utils/run-listener-daemon.ts +5 -10
- package/src/test-utils/run-source-daemon.ts +2 -2
- package/src/test-utils/seed-faker.ts +7 -0
- package/src/test-utils/send-lines-before-handshake.ts +35 -0
- package/src/test-utils/send-mcp-request.ts +25 -0
- package/src/test-utils/setup-mcp-home.ts +99 -0
- package/src/test-utils/setup-mcp-http.ts +19 -31
- package/src/test-utils/spawn-claude-session.ts +34 -0
- package/src/test-utils/spawn-grok-session.ts +39 -0
- package/src/test-utils/start-cut-proxy.ts +25 -8
- package/src/test-utils/start-daemon-process.ts +192 -0
- package/src/test-utils/start-git-http-server.ts +21 -3
- package/src/test-utils/start-mcp-stdio.ts +195 -0
- package/src/test-utils/start-output-capture.ts +20 -0
- package/src/test-utils/start-stub-dropping-daemon.ts +103 -0
- package/src/test-utils/start-stub-impd-info.ts +32 -0
- package/src/test-utils/start-stub-impd.ts +138 -0
- package/src/test-utils/start-stub-legacy-daemon.ts +166 -0
- package/src/test-utils/start-stub-mcp-server.ts +50 -0
- package/src/test-utils/start-stub-refusing-listener.ts +38 -0
- package/src/test-utils/start-stub-reporter-socket.ts +77 -0
- package/src/test-utils/start-stub-restore-daemon.ts +58 -0
- package/src/test-utils/start-stub-session-bridge.ts +98 -0
- package/src/test-utils/start-stub-silent-server.ts +33 -0
- package/src/test-utils/start-stub-source-daemon.ts +39 -0
- package/src/test-utils/start-stub-stalled-listener.ts +40 -0
- package/src/test-utils/start-stub-tap.ts +38 -0
- package/src/test-utils/start-stub-tcp-daemon.ts +70 -0
- package/src/test-utils/start-stub-telemetry-collector.ts +26 -0
- package/src/test-utils/start-test-daemon.ts +215 -0
- package/src/test-utils/start-tui-harness.ts +284 -0
- package/src/test-utils/subscribe-to-socket-lines.ts +59 -12
- package/src/test-utils/try-send-request.ts +29 -0
- package/src/test-utils/wait-for-event.ts +41 -0
- package/src/test-utils/wait-for.ts +15 -3
- package/src/verify-restored-fleet.ts +62 -13
- package/src/workspace/check-repository-access.ts +4 -0
- package/src/workspace/collect-remote-refs.ts +4 -2
- package/src/workspace/run-git.ts +6 -0
- package/src/test-utils/start-legacy-daemon.ts +0 -111
package/src/grants.ts
CHANGED
|
@@ -1,33 +1,58 @@
|
|
|
1
1
|
import { mkdirSync } from 'node:fs';
|
|
2
|
+
import { dirname } from 'node:path';
|
|
2
3
|
import { collectGrants } from './mcp/collect-grants';
|
|
3
4
|
import { openMCPAuth } from './mcp/open-mcp-auth';
|
|
4
5
|
import { revokeGrant } from './mcp/revoke-grant';
|
|
5
|
-
import { mcpAuthDBFile
|
|
6
|
+
import { mcpAuthDBFile } from './shared/config';
|
|
7
|
+
|
|
8
|
+
// Where the command's lines and its exit code go: the console and the
|
|
9
|
+
// process by default.
|
|
10
|
+
interface GrantsIO {
|
|
11
|
+
readonly print: (line: string) => void;
|
|
12
|
+
readonly printError: (line: string) => void;
|
|
13
|
+
readonly setExitCode: (code: number) => void;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const PROCESS_IO: GrantsIO = {
|
|
17
|
+
print: (line) => {
|
|
18
|
+
console.log(line);
|
|
19
|
+
},
|
|
20
|
+
printError: (line) => {
|
|
21
|
+
console.error(line);
|
|
22
|
+
},
|
|
23
|
+
setExitCode: (code) => {
|
|
24
|
+
process.exitCode = code;
|
|
25
|
+
},
|
|
26
|
+
};
|
|
6
27
|
|
|
7
28
|
/**
|
|
8
29
|
* Runs `atc grants`: lists the grants clients of `atc mcp --http` hold, or
|
|
9
30
|
* revokes one. A revoked grant's tokens stop working at once, and its client
|
|
10
31
|
* has to go through approval again. It opens the authorization server's
|
|
11
|
-
* database directly, so it works whether or not the server is
|
|
32
|
+
* database at `dbPath` directly, so it works whether or not the server is
|
|
33
|
+
* running. Revoking a grant that does not exist sets exit code 1.
|
|
12
34
|
*/
|
|
13
|
-
export async function runGrants(
|
|
14
|
-
|
|
35
|
+
export async function runGrants(
|
|
36
|
+
revoke: string | null,
|
|
37
|
+
dbPath: string = mcpAuthDBFile,
|
|
38
|
+
io: GrantsIO = PROCESS_IO,
|
|
39
|
+
): Promise<void> {
|
|
40
|
+
mkdirSync(dirname(dbPath), { recursive: true });
|
|
15
41
|
|
|
16
|
-
const store = await openMCPAuth({ dbPath
|
|
42
|
+
const store = await openMCPAuth({ dbPath, origin: null });
|
|
17
43
|
|
|
18
44
|
try {
|
|
19
45
|
if (revoke !== null) {
|
|
20
46
|
const revoked = await revokeGrant(store.db, revoke);
|
|
21
47
|
|
|
22
48
|
if (!revoked) {
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
process.exitCode = 1;
|
|
49
|
+
io.printError(`atc grants: no grant has the ID '${revoke}'`);
|
|
50
|
+
io.setExitCode(1);
|
|
26
51
|
|
|
27
52
|
return;
|
|
28
53
|
}
|
|
29
54
|
|
|
30
|
-
|
|
55
|
+
io.print(`Revoked grant ${revoke}`);
|
|
31
56
|
|
|
32
57
|
return;
|
|
33
58
|
}
|
|
@@ -35,13 +60,13 @@ export async function runGrants(revoke: string | null): Promise<void> {
|
|
|
35
60
|
const grants = await collectGrants(store.db);
|
|
36
61
|
|
|
37
62
|
if (grants.length === 0) {
|
|
38
|
-
|
|
63
|
+
io.print('No grants.');
|
|
39
64
|
|
|
40
65
|
return;
|
|
41
66
|
}
|
|
42
67
|
|
|
43
68
|
for (const grant of grants) {
|
|
44
|
-
|
|
69
|
+
io.print(
|
|
45
70
|
`${grant.grantID} ${grant.clientName} ${grant.scopes.join(',')} last used ${grant.lastUsedAt ?? 'never'}`,
|
|
46
71
|
);
|
|
47
72
|
}
|
package/src/hook-report.ts
CHANGED
|
@@ -1,6 +1,20 @@
|
|
|
1
1
|
import { normalizeHookEventName } from './agents/normalize-hook-event';
|
|
2
2
|
import { isRecord, sendReport } from './shared/report';
|
|
3
3
|
|
|
4
|
+
// Where the reporter reads its event and how it exits: stdin and the
|
|
5
|
+
// process by default.
|
|
6
|
+
interface HookReportIO {
|
|
7
|
+
readonly readStdin: () => Promise<string>;
|
|
8
|
+
readonly exit: (code: number) => void;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
const PROCESS_IO: HookReportIO = {
|
|
12
|
+
readStdin: () => new Response(Bun.stdin.stream()).text(),
|
|
13
|
+
exit: (code) => {
|
|
14
|
+
process.exit(code);
|
|
15
|
+
},
|
|
16
|
+
};
|
|
17
|
+
|
|
4
18
|
/**
|
|
5
19
|
* Runs as a hook inside wrangled sessions. Reads the hook event from stdin
|
|
6
20
|
* (Claude snake_case keys or Grok camelCase keys) and forwards a PascalCase
|
|
@@ -9,12 +23,12 @@ import { isRecord, sendReport } from './shared/report';
|
|
|
9
23
|
* session's own. Always exits 0 so it never blocks the session it reports
|
|
10
24
|
* on.
|
|
11
25
|
*/
|
|
12
|
-
export async function runHookReport(agent: string): Promise<void> {
|
|
26
|
+
export async function runHookReport(agent: string, io: HookReportIO = PROCESS_IO): Promise<void> {
|
|
13
27
|
const sock = process.env['ATC_SOCKET'];
|
|
14
28
|
const atcId = process.env['ATC_SESSION_ID'];
|
|
15
29
|
|
|
16
30
|
if (sock !== undefined && sock !== '' && atcId !== undefined && atcId !== '') {
|
|
17
|
-
const raw = await
|
|
31
|
+
const raw = await io.readStdin();
|
|
18
32
|
|
|
19
33
|
let payload: Record<string, unknown> = {};
|
|
20
34
|
|
|
@@ -33,5 +47,5 @@ export async function runHookReport(agent: string): Promise<void> {
|
|
|
33
47
|
await sendReport(sock, line, 2000);
|
|
34
48
|
}
|
|
35
49
|
|
|
36
|
-
|
|
50
|
+
io.exit(0);
|
|
37
51
|
}
|
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
import { isRecord } from '../shared/report';
|
|
2
|
+
import { systemClock } from '../shared/system-clock';
|
|
3
|
+
import type { Clock } from '../shared/system-clock';
|
|
2
4
|
import type { FleetCaller } from './types';
|
|
3
5
|
|
|
4
6
|
// The most report text one events page carries, in UTF-8 bytes: as much as
|
|
@@ -26,33 +28,34 @@ type ReportRead = Readonly<Record<string, unknown>>;
|
|
|
26
28
|
* deadline: it then holds the cursor of the last event it keeps and `more`
|
|
27
29
|
* true, so the next read starts at that report. Every other field of the
|
|
28
30
|
* page, such as a gateway's `unavailable` and `truncated`, passes through
|
|
29
|
-
* unchanged.
|
|
31
|
+
* unchanged. The deadline runs on `clock`, the wall clock by default.
|
|
30
32
|
*/
|
|
31
33
|
export async function readReportTexts(
|
|
32
34
|
caller: FleetCaller,
|
|
33
35
|
page: Readonly<Record<string, unknown>>,
|
|
34
36
|
deadlineMs: number = REPORT_READ_DEADLINE_MS,
|
|
37
|
+
clock: Clock = systemClock,
|
|
35
38
|
): Promise<Readonly<Record<string, unknown>>> {
|
|
36
39
|
const raw: unknown = page['events'];
|
|
37
40
|
const events = Array.isArray(raw) ? raw.filter((event) => isRecord(event)) : [];
|
|
38
|
-
const endsAt =
|
|
41
|
+
const endsAt = clock.now() + deadlineMs;
|
|
39
42
|
const timeout = Promise.withResolvers<ReportRead>();
|
|
40
43
|
|
|
41
|
-
const
|
|
44
|
+
const cancel = clock.schedule(() => {
|
|
42
45
|
timeout.resolve({
|
|
43
46
|
textError: `timeout: the report text did not arrive within ${deadlineMs} ms`,
|
|
44
47
|
});
|
|
45
48
|
}, deadlineMs);
|
|
46
49
|
|
|
47
|
-
const read = await readPageReports(caller, page, events, endsAt, timeout.promise);
|
|
50
|
+
const read = await readPageReports(caller, page, events, endsAt, timeout.promise, clock);
|
|
48
51
|
|
|
49
|
-
|
|
52
|
+
cancel();
|
|
50
53
|
|
|
51
54
|
return read;
|
|
52
55
|
}
|
|
53
56
|
|
|
54
57
|
// The page with its reports read, one at a time, until the budget or the
|
|
55
|
-
// deadline at `endsAt` stops it. A read still out when `timeout` settles
|
|
58
|
+
// deadline at `endsAt` on `clock` stops it. A read still out when `timeout` settles
|
|
56
59
|
// gives way to the timeout it settles with.
|
|
57
60
|
async function readPageReports(
|
|
58
61
|
caller: FleetCaller,
|
|
@@ -60,6 +63,7 @@ async function readPageReports(
|
|
|
60
63
|
events: readonly Readonly<Record<string, unknown>>[],
|
|
61
64
|
endsAt: number,
|
|
62
65
|
timeout: Readonly<Promise<ReportRead>>,
|
|
66
|
+
clock: Clock,
|
|
63
67
|
): Promise<Readonly<Record<string, unknown>>> {
|
|
64
68
|
const kept: Readonly<Record<string, unknown>>[] = [];
|
|
65
69
|
let used = 0;
|
|
@@ -72,7 +76,7 @@ async function readPageReports(
|
|
|
72
76
|
|
|
73
77
|
const last = kept.at(-1);
|
|
74
78
|
|
|
75
|
-
if (last !== undefined &&
|
|
79
|
+
if (last !== undefined && clock.now() >= endsAt) {
|
|
76
80
|
return { ...page, events: kept, cursor: last['cursor'], more: true };
|
|
77
81
|
}
|
|
78
82
|
|
|
@@ -38,6 +38,10 @@ interface MCPHTTPServerOptions {
|
|
|
38
38
|
|
|
39
39
|
// Serves `/healthz` and `/readyz` for an orchestrator's probes.
|
|
40
40
|
readonly probes?: boolean;
|
|
41
|
+
|
|
42
|
+
// The time pending approvals and the limit on approval starts run on, in
|
|
43
|
+
// epoch milliseconds; the wall clock when unset.
|
|
44
|
+
readonly now?: () => number;
|
|
41
45
|
}
|
|
42
46
|
|
|
43
47
|
/**
|
|
@@ -171,7 +175,7 @@ export async function startMCPHTTPServer(options: MCPHTTPServerOptions): Promise
|
|
|
171
175
|
origin,
|
|
172
176
|
resource: `${origin}/mcp`,
|
|
173
177
|
store,
|
|
174
|
-
approvals: new ApprovalState(600_000, () => Date.now()),
|
|
178
|
+
approvals: new ApprovalState(600_000, options.now ?? (() => Date.now())),
|
|
175
179
|
printApproval: options.printApproval,
|
|
176
180
|
},
|
|
177
181
|
};
|
package/src/mcp-http-server.ts
CHANGED
|
@@ -1,11 +1,14 @@
|
|
|
1
1
|
import { mkdirSync } from 'node:fs';
|
|
2
|
+
import { dirname } from 'node:path';
|
|
2
3
|
import { bootDaemonClient } from './client/boot-daemon';
|
|
4
|
+
import type { DaemonBoot, DaemonBootOptions } from './client/boot-daemon';
|
|
3
5
|
import { DaemonClient } from './client/daemon-client';
|
|
4
6
|
import { collectClients } from './mcp/collect-clients';
|
|
5
7
|
import { openMCPAuth } from './mcp/open-mcp-auth';
|
|
6
8
|
import { ReconnectingCaller } from './mcp/reconnecting-caller';
|
|
7
9
|
import { startMCPHTTPServer } from './mcp/start-mcp-http-server';
|
|
8
|
-
import {
|
|
10
|
+
import type { MCPHTTPConfig } from './shared/collect-mcp-http-config';
|
|
11
|
+
import { mcpAuthDBFile } from './shared/config';
|
|
9
12
|
import { loadMCPHTTPConfig } from './shared/load-mcp-http-config';
|
|
10
13
|
|
|
11
14
|
interface MCPHTTPFlags {
|
|
@@ -15,6 +18,41 @@ interface MCPHTTPFlags {
|
|
|
15
18
|
readonly waitForDaemon: boolean;
|
|
16
19
|
}
|
|
17
20
|
|
|
21
|
+
// What the server reaches outside itself, the process's own by default: its
|
|
22
|
+
// config, the authorization database, the daemon it boots or waits for, the
|
|
23
|
+
// console, the exit, and the signal listeners that stop it.
|
|
24
|
+
interface MCPHTTPServerIO {
|
|
25
|
+
readonly loadConfig: () => MCPHTTPConfig;
|
|
26
|
+
readonly dbPath: string;
|
|
27
|
+
readonly bootDaemon: (
|
|
28
|
+
options: DaemonBootOptions,
|
|
29
|
+
) => Promise<Pick<DaemonBoot, 'client' | 'socketPath'>>;
|
|
30
|
+
readonly print: (line: string) => void;
|
|
31
|
+
readonly printError: (line: string) => void;
|
|
32
|
+
readonly exit: (code: number) => void;
|
|
33
|
+
readonly registerSignal: (signal: 'SIGINT' | 'SIGTERM', listener: () => Promise<void>) => void;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const PROCESS_IO: MCPHTTPServerIO = {
|
|
37
|
+
loadConfig: loadMCPHTTPConfig,
|
|
38
|
+
dbPath: mcpAuthDBFile,
|
|
39
|
+
bootDaemon: bootDaemonClient,
|
|
40
|
+
print: (line) => {
|
|
41
|
+
console.log(line);
|
|
42
|
+
},
|
|
43
|
+
printError: (line) => {
|
|
44
|
+
console.error(line);
|
|
45
|
+
},
|
|
46
|
+
exit: (code) => {
|
|
47
|
+
process.exit(code);
|
|
48
|
+
},
|
|
49
|
+
registerSignal: (signal, listener) => {
|
|
50
|
+
process.on(signal, () => {
|
|
51
|
+
void listener();
|
|
52
|
+
});
|
|
53
|
+
},
|
|
54
|
+
};
|
|
55
|
+
|
|
18
56
|
// How long `--wait-for-daemon` waits before it exits. A service manager's
|
|
19
57
|
// restart covers a daemon that takes longer.
|
|
20
58
|
const DAEMON_WAIT_MS = 30_000;
|
|
@@ -25,19 +63,38 @@ const DAEMON_WAIT_MS = 30_000;
|
|
|
25
63
|
* serves MCP over HTTP until Ctrl-C. Approval codes print here, so the
|
|
26
64
|
* terminal running it is where the operator approves a client.
|
|
27
65
|
*/
|
|
28
|
-
export async function runMCPHTTPServer(
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
66
|
+
export async function runMCPHTTPServer(
|
|
67
|
+
build: string,
|
|
68
|
+
flags: MCPHTTPFlags,
|
|
69
|
+
io: MCPHTTPServerIO = PROCESS_IO,
|
|
70
|
+
): Promise<void> {
|
|
71
|
+
const config = io.loadConfig();
|
|
72
|
+
|
|
73
|
+
const bootOptions = flags.waitForDaemon
|
|
74
|
+
? {
|
|
75
|
+
waitForDaemonMs: DAEMON_WAIT_MS,
|
|
76
|
+
onWaitForDaemon: () => {
|
|
77
|
+
io.printError(
|
|
78
|
+
`atc mcp --http: no daemon answers yet; waiting up to ${DAEMON_WAIT_MS / 1000}s for one, without starting it`,
|
|
79
|
+
);
|
|
80
|
+
},
|
|
81
|
+
}
|
|
82
|
+
: {};
|
|
83
|
+
|
|
84
|
+
let boot: Pick<DaemonBoot, 'client' | 'socketPath'>;
|
|
85
|
+
|
|
86
|
+
try {
|
|
87
|
+
boot = await io.bootDaemon(bootOptions);
|
|
88
|
+
} catch (error) {
|
|
89
|
+
io.printError(`atc mcp --http: ${error instanceof Error ? error.message : String(error)}`);
|
|
90
|
+
io.exit(1);
|
|
91
|
+
|
|
92
|
+
return;
|
|
93
|
+
}
|
|
37
94
|
|
|
38
95
|
boot.client.stop();
|
|
39
96
|
|
|
40
|
-
mkdirSync(
|
|
97
|
+
mkdirSync(dirname(io.dbPath), { recursive: true });
|
|
41
98
|
|
|
42
99
|
const caller = new ReconnectingCaller(boot.socketPath, build, (path) => DaemonClient.open(path));
|
|
43
100
|
|
|
@@ -48,45 +105,41 @@ export async function runMCPHTTPServer(build: string, flags: MCPHTTPFlags): Prom
|
|
|
48
105
|
port: flags.port ?? config.port,
|
|
49
106
|
publicURL: flags.publicURL ?? config.publicURL,
|
|
50
107
|
allowedHosts: config.allowedHosts,
|
|
51
|
-
dbPath:
|
|
108
|
+
dbPath: io.dbPath,
|
|
52
109
|
|
|
53
110
|
// The line carries a client's name, so control and format characters
|
|
54
111
|
// are dropped before it reaches the operator's terminal.
|
|
55
112
|
printApproval: (line) => {
|
|
56
|
-
|
|
113
|
+
io.print(line.replaceAll(/[\p{Cc}\p{Cf}]/gu, ''));
|
|
57
114
|
},
|
|
58
115
|
|
|
59
116
|
// Request lines go to stderr, so stdout keeps the approval lines alone.
|
|
60
117
|
printRequest: (line) => {
|
|
61
|
-
|
|
118
|
+
io.printError(line);
|
|
62
119
|
},
|
|
63
120
|
});
|
|
64
121
|
|
|
65
|
-
|
|
122
|
+
// The handlers go in before the serving line, so a signal sent once the
|
|
123
|
+
// line appears always finds them.
|
|
124
|
+
const stopServing = async () => {
|
|
125
|
+
await server.stop();
|
|
126
|
+
await caller.stop();
|
|
127
|
+
|
|
128
|
+
io.exit(0);
|
|
129
|
+
};
|
|
66
130
|
|
|
67
|
-
|
|
131
|
+
io.registerSignal('SIGINT', stopServing);
|
|
132
|
+
io.registerSignal('SIGTERM', stopServing);
|
|
133
|
+
io.print(`atc mcp --http: serving ${server.origin}/mcp, listening on ${server.listening}`);
|
|
134
|
+
|
|
135
|
+
const admin = await openMCPAuth({ dbPath: io.dbPath, origin: null });
|
|
68
136
|
const clients = await collectClients(admin.db);
|
|
69
137
|
|
|
70
138
|
await admin.close();
|
|
71
139
|
|
|
72
140
|
if (clients.length === 0) {
|
|
73
|
-
|
|
141
|
+
io.print(
|
|
74
142
|
'No clients can connect yet. Add one with: atc clients add <name> --redirect-uri <uri>',
|
|
75
143
|
);
|
|
76
144
|
}
|
|
77
|
-
|
|
78
|
-
const stopServing = async () => {
|
|
79
|
-
await server.stop();
|
|
80
|
-
await caller.stop();
|
|
81
|
-
|
|
82
|
-
process.exit(0);
|
|
83
|
-
};
|
|
84
|
-
|
|
85
|
-
process.on('SIGINT', () => {
|
|
86
|
-
void stopServing();
|
|
87
|
-
});
|
|
88
|
-
|
|
89
|
-
process.on('SIGTERM', () => {
|
|
90
|
-
void stopServing();
|
|
91
|
-
});
|
|
92
145
|
}
|
package/src/report.ts
CHANGED
|
@@ -16,6 +16,20 @@ interface ReportOptions {
|
|
|
16
16
|
readonly turn: string;
|
|
17
17
|
}
|
|
18
18
|
|
|
19
|
+
// Where the reporter reads its text and how it exits: stdin and the process
|
|
20
|
+
// by default.
|
|
21
|
+
interface ReportIO {
|
|
22
|
+
readonly readStdin: () => Promise<string>;
|
|
23
|
+
readonly exit: (code: number) => void;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const PROCESS_IO: ReportIO = {
|
|
27
|
+
readStdin: () => new Response(Bun.stdin.stream()).text(),
|
|
28
|
+
exit: (code) => {
|
|
29
|
+
process.exit(code);
|
|
30
|
+
},
|
|
31
|
+
};
|
|
32
|
+
|
|
19
33
|
/**
|
|
20
34
|
* Runs inside wrangled sessions: reads stdin verbatim and forwards it to the
|
|
21
35
|
* atc socket as a Report envelope of the given kind. An `answered` report
|
|
@@ -26,7 +40,11 @@ interface ReportOptions {
|
|
|
26
40
|
* given. Inside a remote host it goes to the session bridge instead. Always
|
|
27
41
|
* exits 0 so it never blocks the session it reports on.
|
|
28
42
|
*/
|
|
29
|
-
export async function runReport(
|
|
43
|
+
export async function runReport(
|
|
44
|
+
kind: string,
|
|
45
|
+
options: ReportOptions,
|
|
46
|
+
io: ReportIO = PROCESS_IO,
|
|
47
|
+
): Promise<void> {
|
|
30
48
|
try {
|
|
31
49
|
const sock = process.env['ATC_SOCKET'];
|
|
32
50
|
const atcId = process.env['ATC_SESSION_ID'];
|
|
@@ -38,7 +56,7 @@ export async function runReport(kind: string, options: ReportOptions): Promise<v
|
|
|
38
56
|
atcId !== '' &&
|
|
39
57
|
REPORT_KINDS.some((known) => known === kind)
|
|
40
58
|
) {
|
|
41
|
-
const stdin = await
|
|
59
|
+
const stdin = await io.readStdin();
|
|
42
60
|
|
|
43
61
|
const payload = buildReportPayload(kind, options, stdin);
|
|
44
62
|
|
|
@@ -52,7 +70,7 @@ export async function runReport(kind: string, options: ReportOptions): Promise<v
|
|
|
52
70
|
}
|
|
53
71
|
} catch {}
|
|
54
72
|
|
|
55
|
-
|
|
73
|
+
io.exit(0);
|
|
56
74
|
}
|
|
57
75
|
|
|
58
76
|
// Inside a remote host, a report goes to the session bridge under an id of
|
package/src/run-bridge-tap.ts
CHANGED
|
@@ -29,6 +29,24 @@ const SEEN_LIMIT = 10_000;
|
|
|
29
29
|
// opened the tap.
|
|
30
30
|
type TapEnd = 'closed' | 'refused' | 'lost' | 'unreachable';
|
|
31
31
|
|
|
32
|
+
// Where the tap prints and how it exits: stdout, stderr, and the process by
|
|
33
|
+
// default.
|
|
34
|
+
interface BridgeTapIO {
|
|
35
|
+
readonly writeStdout: (text: string) => Promise<unknown>;
|
|
36
|
+
readonly printError: (line: string) => void;
|
|
37
|
+
readonly exit: (code: number) => void;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const PROCESS_IO: BridgeTapIO = {
|
|
41
|
+
writeStdout: (text) => Bun.write(Bun.stdout, text),
|
|
42
|
+
printError: (line) => {
|
|
43
|
+
console.error(line);
|
|
44
|
+
},
|
|
45
|
+
exit: (code) => {
|
|
46
|
+
process.exit(code);
|
|
47
|
+
},
|
|
48
|
+
};
|
|
49
|
+
|
|
32
50
|
/**
|
|
33
51
|
* Streams the session's inbox to stdout from inside a remote host, through
|
|
34
52
|
* the session bridge at the given socket. It prints each message once as
|
|
@@ -40,21 +58,29 @@ type TapEnd = 'closed' | 'refused' | 'lost' | 'unreachable';
|
|
|
40
58
|
* outbox still holds. Exits 0 once the inbox closes and 1 when the bridge
|
|
41
59
|
* refuses the tap.
|
|
42
60
|
*/
|
|
43
|
-
export async function runBridgeTap(
|
|
61
|
+
export async function runBridgeTap(
|
|
62
|
+
socketPath: string,
|
|
63
|
+
outbox: string,
|
|
64
|
+
io: BridgeTapIO = PROCESS_IO,
|
|
65
|
+
): Promise<void> {
|
|
44
66
|
const seen = new Set<string>();
|
|
45
67
|
|
|
46
68
|
let wait = FIRST_RETRY_MS;
|
|
47
69
|
|
|
48
70
|
for (;;) {
|
|
49
|
-
const end = await runTapConnection(socketPath, outbox, seen);
|
|
71
|
+
const end = await runTapConnection(socketPath, outbox, seen, io);
|
|
50
72
|
|
|
51
73
|
if (end === 'closed') {
|
|
52
|
-
|
|
74
|
+
io.exit(0);
|
|
75
|
+
|
|
76
|
+
return;
|
|
53
77
|
}
|
|
54
78
|
|
|
55
79
|
if (end === 'refused') {
|
|
56
|
-
|
|
57
|
-
|
|
80
|
+
io.printError('atc tap: the session bridge refused the tap');
|
|
81
|
+
io.exit(1);
|
|
82
|
+
|
|
83
|
+
return;
|
|
58
84
|
}
|
|
59
85
|
|
|
60
86
|
if (end === 'lost') {
|
|
@@ -73,6 +99,7 @@ async function runTapConnection(
|
|
|
73
99
|
|
|
74
100
|
// oxlint-disable-next-line prefer-readonly-parameter-types -- the ids the tap printed, which every connection adds to
|
|
75
101
|
seen: Set<string>,
|
|
102
|
+
io: BridgeTapIO,
|
|
76
103
|
): Promise<TapEnd> {
|
|
77
104
|
const ended = Promise.withResolvers<TapEnd>();
|
|
78
105
|
let opened = false;
|
|
@@ -139,9 +166,11 @@ async function runTapConnection(
|
|
|
139
166
|
const printed = `${JSON.stringify({ id: message.message, from: message.from, text: message.text, sentAt: message.sentAt })}\n`;
|
|
140
167
|
|
|
141
168
|
try {
|
|
142
|
-
await
|
|
169
|
+
await io.writeStdout(printed);
|
|
143
170
|
} catch {
|
|
144
|
-
|
|
171
|
+
io.exit(1);
|
|
172
|
+
|
|
173
|
+
return;
|
|
145
174
|
}
|
|
146
175
|
|
|
147
176
|
updateSeenIDs(seen, message.message);
|
|
@@ -1,19 +1,43 @@
|
|
|
1
1
|
import { copyFileSync, readFileSync, writeFileSync } from 'node:fs';
|
|
2
2
|
import { buildMigratedConfig } from './build-migrated-config';
|
|
3
3
|
|
|
4
|
+
// Where the command's output goes, the console by default: text printed as
|
|
5
|
+
// it is, and lines printed to stdout or stderr.
|
|
6
|
+
interface ConfigMigrateIO {
|
|
7
|
+
readonly printText: (text: string) => void;
|
|
8
|
+
readonly print: (line: string) => void;
|
|
9
|
+
readonly printError: (line: string) => void;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
const CONSOLE_IO: ConfigMigrateIO = {
|
|
13
|
+
printText: (text) => {
|
|
14
|
+
process.stdout.write(text);
|
|
15
|
+
},
|
|
16
|
+
print: (line) => {
|
|
17
|
+
console.log(line);
|
|
18
|
+
},
|
|
19
|
+
printError: (line) => {
|
|
20
|
+
console.error(line);
|
|
21
|
+
},
|
|
22
|
+
};
|
|
23
|
+
|
|
4
24
|
/**
|
|
5
25
|
* Runs `atc config migrate`: prints the config.json rewritten around
|
|
6
26
|
* `agents`, or with `write` copies the file to a timestamped backup beside it
|
|
7
27
|
* and rewrites it in place. Returns the exit code. Notes and problems go to
|
|
8
28
|
* stderr and name keys and paths, never values.
|
|
9
29
|
*/
|
|
10
|
-
export function runConfigMigrate(
|
|
30
|
+
export function runConfigMigrate(
|
|
31
|
+
file: string,
|
|
32
|
+
write: boolean,
|
|
33
|
+
io: ConfigMigrateIO = CONSOLE_IO,
|
|
34
|
+
): number {
|
|
11
35
|
let text: string;
|
|
12
36
|
|
|
13
37
|
try {
|
|
14
38
|
text = readFileSync(file, 'utf8');
|
|
15
39
|
} catch {
|
|
16
|
-
|
|
40
|
+
io.printError(`atc config migrate: ${file} cannot be read`);
|
|
17
41
|
|
|
18
42
|
return 1;
|
|
19
43
|
}
|
|
@@ -23,7 +47,7 @@ export function runConfigMigrate(file: string, write: boolean): number {
|
|
|
23
47
|
try {
|
|
24
48
|
raw = JSON.parse(text);
|
|
25
49
|
} catch {
|
|
26
|
-
|
|
50
|
+
io.printError(`atc config migrate: ${file} is not valid JSON`);
|
|
27
51
|
|
|
28
52
|
return 1;
|
|
29
53
|
}
|
|
@@ -31,23 +55,23 @@ export function runConfigMigrate(file: string, write: boolean): number {
|
|
|
31
55
|
const result = buildMigratedConfig(raw);
|
|
32
56
|
|
|
33
57
|
if (result.kind === 'unusable') {
|
|
34
|
-
|
|
58
|
+
io.printError(`atc config migrate: ${file}: ${result.detail}`);
|
|
35
59
|
|
|
36
60
|
return 1;
|
|
37
61
|
}
|
|
38
62
|
|
|
39
63
|
if (result.kind === 'current') {
|
|
40
|
-
|
|
64
|
+
io.print('config.json already uses agents; nothing to migrate');
|
|
41
65
|
|
|
42
66
|
return 0;
|
|
43
67
|
}
|
|
44
68
|
|
|
45
69
|
for (const note of result.notes) {
|
|
46
|
-
|
|
70
|
+
io.printError(note);
|
|
47
71
|
}
|
|
48
72
|
|
|
49
73
|
if (!write) {
|
|
50
|
-
|
|
74
|
+
io.printText(result.text);
|
|
51
75
|
|
|
52
76
|
return 0;
|
|
53
77
|
}
|
|
@@ -57,7 +81,7 @@ export function runConfigMigrate(file: string, write: boolean): number {
|
|
|
57
81
|
try {
|
|
58
82
|
copyFileSync(file, backup);
|
|
59
83
|
} catch {
|
|
60
|
-
|
|
84
|
+
io.printError(`atc config migrate: ${backup} cannot be written; ${file} is unchanged`);
|
|
61
85
|
|
|
62
86
|
return 1;
|
|
63
87
|
}
|
|
@@ -65,13 +89,13 @@ export function runConfigMigrate(file: string, write: boolean): number {
|
|
|
65
89
|
try {
|
|
66
90
|
writeFileSync(file, result.text);
|
|
67
91
|
} catch {
|
|
68
|
-
|
|
92
|
+
io.printError(`atc config migrate: ${file} cannot be written; the backup is at ${backup}`);
|
|
69
93
|
|
|
70
94
|
return 1;
|
|
71
95
|
}
|
|
72
96
|
|
|
73
|
-
|
|
74
|
-
|
|
97
|
+
io.print(`backup: ${backup}`);
|
|
98
|
+
io.print(`wrote: ${file}`);
|
|
75
99
|
|
|
76
100
|
return 0;
|
|
77
101
|
}
|
package/src/run-gateway.ts
CHANGED
|
@@ -74,8 +74,8 @@ export async function runGateway(build: string, flags: GatewayFlags): Promise<vo
|
|
|
74
74
|
process.exit(1);
|
|
75
75
|
}
|
|
76
76
|
|
|
77
|
-
|
|
78
|
-
|
|
77
|
+
// The handlers go in before the serving line, so a signal sent once the
|
|
78
|
+
// line appears always finds them.
|
|
79
79
|
const stopServing = async () => {
|
|
80
80
|
await server.stop();
|
|
81
81
|
await gateway.stop();
|
|
@@ -90,4 +90,6 @@ export async function runGateway(build: string, flags: GatewayFlags): Promise<vo
|
|
|
90
90
|
process.on('SIGTERM', () => {
|
|
91
91
|
void stopServing();
|
|
92
92
|
});
|
|
93
|
+
|
|
94
|
+
console.log(`atc-gateway: serving ${server.origin}/mcp, listening on ${server.listening}`);
|
|
93
95
|
}
|
|
@@ -4,10 +4,14 @@ import { isCompiledBinary } from './is-compiled-binary';
|
|
|
4
4
|
/**
|
|
5
5
|
* The argv that runs this build of atc with `args`: the compiled binary is
|
|
6
6
|
* its own entry, and a source run is bun with the CLI entrypoint ahead of
|
|
7
|
-
* the arguments.
|
|
7
|
+
* the arguments. `compiled` tells the two apart, and defaults to how this
|
|
8
|
+
* process runs.
|
|
8
9
|
*/
|
|
9
|
-
export function buildATCCommand(
|
|
10
|
-
|
|
10
|
+
export function buildATCCommand(
|
|
11
|
+
args: readonly string[],
|
|
12
|
+
compiled: boolean = isCompiledBinary(),
|
|
13
|
+
): string[] {
|
|
14
|
+
return compiled
|
|
11
15
|
? [process.execPath, ...args]
|
|
12
16
|
: [process.execPath, join(import.meta.dir, '..', 'cli.ts'), ...args];
|
|
13
17
|
}
|
|
@@ -16,6 +16,12 @@ export class BunSqliteDriver implements Driver {
|
|
|
16
16
|
|
|
17
17
|
private releaseTurn: () => void = () => {};
|
|
18
18
|
|
|
19
|
+
/**
|
|
20
|
+
* How many acquires are waiting for an earlier caller to release the
|
|
21
|
+
* connection.
|
|
22
|
+
*/
|
|
23
|
+
waiting = 0;
|
|
24
|
+
|
|
19
25
|
constructor(sqlite: Database) {
|
|
20
26
|
this.connection = buildConnection(sqlite);
|
|
21
27
|
}
|
|
@@ -32,8 +38,12 @@ export class BunSqliteDriver implements Driver {
|
|
|
32
38
|
release = resolve;
|
|
33
39
|
});
|
|
34
40
|
|
|
41
|
+
this.waiting += 1;
|
|
42
|
+
|
|
35
43
|
await previous;
|
|
36
44
|
|
|
45
|
+
this.waiting -= 1;
|
|
46
|
+
|
|
37
47
|
if (release !== undefined) {
|
|
38
48
|
this.releaseTurn = release;
|
|
39
49
|
}
|