@zgeoff/atc 2.19.0 → 2.22.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 +1 -1
- package/src/agents/agent-adapter.ts +5 -0
- package/src/agents/gateway-adapter.ts +25 -2
- package/src/cli.ts +187 -113
- package/src/client/collect-agent-picks.ts +5 -2
- package/src/client/daemon-client.ts +29 -18
- package/src/daemon/build-agent-list.ts +8 -6
- package/src/daemon/build-auth-binding.ts +58 -0
- package/src/daemon/build-imp-name.ts +11 -0
- package/src/daemon/build-imp-provider.ts +25 -2
- package/src/daemon/build-payload-hash.ts +4 -3
- package/src/daemon/daemon-connection.ts +277 -20
- package/src/daemon/daemon-context.ts +8 -0
- package/src/daemon/daemon.ts +186 -32
- package/src/daemon/find-token-fingerprint.ts +23 -0
- package/src/daemon/handshake-throttle.ts +47 -0
- package/src/daemon/idempotency-ledger.ts +20 -2
- package/src/daemon/imp-port.ts +4 -2
- package/src/daemon/imp-provider.ts +24 -19
- package/src/daemon/is-allowed-listen-host.ts +63 -0
- package/src/daemon/load-listener-tokens.ts +46 -0
- package/src/daemon/parse-listen-address.ts +33 -0
- package/src/daemon/restore-fleet.ts +2 -1
- package/src/daemon/sessions.ts +6 -0
- package/src/daemon/start-tcp-listener.ts +165 -0
- package/src/federation/build-binding-payload-hash.ts +34 -0
- package/src/federation/build-daemon-outdated-error.ts +14 -0
- package/src/federation/build-events-filter-hash.ts +16 -0
- package/src/federation/build-gateway-error.ts +45 -0
- package/src/federation/build-gateway-id.ts +14 -0
- package/src/federation/build-gateway-result.ts +31 -0
- package/src/federation/build-ruled-value.ts +53 -0
- package/src/federation/collect-unruled-id-paths.ts +46 -0
- package/src/federation/daemon-caller.ts +473 -0
- package/src/federation/daemon-pool.ts +56 -0
- package/src/federation/decode-gateway-cursor.ts +73 -0
- package/src/federation/encode-gateway-cursor.ts +15 -0
- package/src/federation/gateway-error.ts +25 -0
- package/src/federation/gateway-store.ts +253 -0
- package/src/federation/id-rules.ts +85 -0
- package/src/federation/load-gateway-registry.ts +29 -0
- package/src/federation/max-events-cursor-bytes.ts +4 -0
- package/src/federation/max-registry-daemons.ts +26 -0
- package/src/federation/merge-event-pages.ts +228 -0
- package/src/federation/open-gateway-caller.ts +55 -0
- package/src/federation/parse-gateway-id.ts +28 -0
- package/src/federation/parse-gateway-registry.ts +123 -0
- package/src/federation/pick-daemon-state.ts +46 -0
- package/src/federation/plan-event-reads.ts +54 -0
- package/src/federation/read-fleet-events.ts +279 -0
- package/src/federation/require-serving-daemon.ts +27 -0
- package/src/federation/resolve-daemon-request.ts +59 -0
- package/src/federation/routing-caller.ts +450 -0
- package/src/federation/types.ts +33 -0
- package/src/federation/wait-for-outcome.ts +38 -0
- package/src/mcp/answer-rpc-request.ts +14 -1
- package/src/mcp/build-tool-list.ts +6 -5
- package/src/mcp/mcp-tools.ts +41 -8
- package/src/mcp/require-daemon-features.ts +2 -0
- package/src/mcp/run-tool.ts +15 -1
- package/src/mcp/start-mcp-http-server.ts +53 -9
- package/src/mcp/types.ts +8 -1
- package/src/protocol/daemon-features.ts +9 -0
- package/src/protocol/protocol.ts +2 -0
- package/src/protocol/request-param-schemas.ts +6 -0
- package/src/run-daemon-id.ts +52 -0
- package/src/shared/collect-auth-profiles.ts +122 -0
- package/src/shared/collect-gateways.ts +223 -6
- package/src/shared/config.ts +32 -4
- package/src/shared/find-daemon-record.ts +8 -3
- package/src/shared/resolve-auth-profiles.ts +186 -0
- package/src/store/run-migrations.ts +77 -0
- package/src/store/runtime-auth-binding.ts +110 -0
- package/src/store/state-store.ts +202 -0
package/src/mcp/mcp-tools.ts
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
import { z } from 'zod';
|
|
2
|
-
import type { DaemonFeature } from '../protocol/daemon-features';
|
|
3
2
|
import { REQUEST_PARAM_SCHEMAS } from '../protocol/request-param-schemas';
|
|
4
3
|
import type { GrantScope } from '../shared/grant-scope';
|
|
5
4
|
import { buildSpawnDescriptions } from './build-spawn-descriptions';
|
|
6
5
|
import { IDEMPOTENCY_KEY_FIELD } from './parse-idempotency-key';
|
|
6
|
+
import type { FleetFeature } from './types';
|
|
7
7
|
|
|
8
8
|
const NO_INPUT: Readonly<Record<string, unknown>> = z.toJSONSchema(z.strictObject({}));
|
|
9
9
|
|
|
@@ -27,8 +27,23 @@ const { $schema: _, ...IDEMPOTENCY_KEY_INPUT } = z.toJSONSchema(IDEMPOTENCY_KEY_
|
|
|
27
27
|
io: 'input',
|
|
28
28
|
});
|
|
29
29
|
|
|
30
|
+
// The daemon a call goes to, offered only by a caller that routes across
|
|
31
|
+
// named daemons.
|
|
32
|
+
const DAEMON_FIELD = z
|
|
33
|
+
.string()
|
|
34
|
+
.optional()
|
|
35
|
+
.describe(
|
|
36
|
+
'The atc daemon to run on, one of the names atc_daemons_list returns. Omit it to use the default daemon. A daemon that is down answers daemon_unavailable; atc never runs the call on another daemon instead.',
|
|
37
|
+
);
|
|
38
|
+
|
|
39
|
+
const DIRS_INPUT: Readonly<Record<string, unknown>> = z.toJSONSchema(
|
|
40
|
+
z.strictObject({ daemon: DAEMON_FIELD }),
|
|
41
|
+
{ io: 'input' },
|
|
42
|
+
);
|
|
43
|
+
|
|
30
44
|
const SPAWN_INPUT: Readonly<Record<string, unknown>> = z.toJSONSchema(
|
|
31
45
|
z.strictObject({
|
|
46
|
+
daemon: DAEMON_FIELD,
|
|
32
47
|
cwd: SPAWN_SCHEMA.shape.cwd.describe('Absolute path of the working directory'),
|
|
33
48
|
name: SPAWN_SCHEMA.shape.name.describe('Session name; defaults to the directory basename'),
|
|
34
49
|
prompt: SPAWN_SCHEMA.shape.prompt.describe('First message for the session'),
|
|
@@ -117,7 +132,11 @@ const MESSAGE_GET_INPUT: Readonly<Record<string, unknown>> = z.toJSONSchema(
|
|
|
117
132
|
|
|
118
133
|
const REPORT_GET_INPUT: Readonly<Record<string, unknown>> = z.toJSONSchema(
|
|
119
134
|
z.strictObject({
|
|
120
|
-
report: z
|
|
135
|
+
report: z
|
|
136
|
+
.string()
|
|
137
|
+
.describe(
|
|
138
|
+
"The report handle of the report's event from atc_events_read, or the event's cursor when it carries no report handle",
|
|
139
|
+
),
|
|
121
140
|
}),
|
|
122
141
|
{ io: 'input' },
|
|
123
142
|
);
|
|
@@ -316,10 +335,13 @@ interface MCPToolDefinition {
|
|
|
316
335
|
// What the connected daemon has to announce for the tool to be listed at
|
|
317
336
|
// all, for its output schema to be declared, and for each listed input
|
|
318
337
|
// property to be offered. An older daemon gets the tool without them.
|
|
338
|
+
// `outputUnless` leaves the output schema out when the caller announces
|
|
339
|
+
// that feature, for a tool whose result takes another shape there.
|
|
319
340
|
readonly requires?: {
|
|
320
|
-
readonly tool?:
|
|
321
|
-
readonly output?:
|
|
322
|
-
readonly
|
|
341
|
+
readonly tool?: FleetFeature;
|
|
342
|
+
readonly output?: FleetFeature;
|
|
343
|
+
readonly outputUnless?: FleetFeature;
|
|
344
|
+
readonly inputs?: Readonly<Record<string, FleetFeature>>;
|
|
323
345
|
};
|
|
324
346
|
readonly scope: GrantScope;
|
|
325
347
|
}
|
|
@@ -373,6 +395,7 @@ export const MCP_TOOLS: readonly MCPToolDefinition[] = [
|
|
|
373
395
|
inputSchema: SPAWN_INPUT,
|
|
374
396
|
requires: {
|
|
375
397
|
inputs: {
|
|
398
|
+
daemon: 'fleet.daemons',
|
|
376
399
|
model: 'spawn.options',
|
|
377
400
|
effort: 'spawn.options',
|
|
378
401
|
idempotencyKey: 'spawn.idempotency',
|
|
@@ -452,7 +475,17 @@ export const MCP_TOOLS: readonly MCPToolDefinition[] = [
|
|
|
452
475
|
annotations: READ_ONLY,
|
|
453
476
|
scope: 'read',
|
|
454
477
|
description: 'List directories sessions were previously spawned from, most recent first.',
|
|
478
|
+
inputSchema: DIRS_INPUT,
|
|
479
|
+
requires: { inputs: { daemon: 'fleet.daemons' } },
|
|
480
|
+
},
|
|
481
|
+
{
|
|
482
|
+
name: 'atc_daemons_list',
|
|
483
|
+
annotations: READ_ONLY,
|
|
484
|
+
scope: 'read',
|
|
485
|
+
description:
|
|
486
|
+
"List the atc daemons this server routes to: each one's name, state (up, down, unauthorized, changed, or outdated), build, daemonID, and features, plus defaultDaemon, the daemon a spawn or directory listing without daemon goes to. Session and message ids start with the name of the daemon that holds them.",
|
|
455
487
|
inputSchema: NO_INPUT,
|
|
488
|
+
requires: { tool: 'fleet.daemons' },
|
|
456
489
|
},
|
|
457
490
|
{
|
|
458
491
|
name: 'atc_agents_list',
|
|
@@ -462,7 +495,7 @@ export const MCP_TOOLS: readonly MCPToolDefinition[] = [
|
|
|
462
495
|
"List the agents this atc host can run sessions under, plus the host itself (daemon: hostname, platform, arch, build). Each agent has its id (pass it as atc_session_spawn's agent), label, kind (the agent CLI family it runs), installed (whether its binary resolves on this host; a registered agent that is not installed cannot spawn), capabilities (spawn, readTranscript, message, attach, screen, input), models (the model names the config sets for it, or null), and spawnOptions when the daemon supports spawn options. spawnOptions holds model and effort, each with supported (whether atc passes it to the agent CLI), available (whether a spawn on this host can pass it now), values (the accepted set, or null for any alias or model name), examples (each with the provider model it resolves to, when the config maps one), default (the configured value, or null for the CLI's own), backendEffect (applied, or unverified when the backend may ignore it), and a note. atc_session_spawn accepts exactly the available options. When the daemon supports targets, it also returns targets (each with its id, provider kind, identity, available, default, and capabilities), spawnDefaults (the agent and target a spawn without either runs with; a null target means such a spawn is refused), configRevision (a digest that changes whenever the target config does), and targetErrors (config problems that leave a target, or every target, unusable; a config file that exists but cannot be read or parsed is scope config, problem config_malformed or config_unreadable, with its path and detail, and refuses every spawn, local included). It never includes credentials, environment values, or endpoints, and holds nothing about which plans or subscriptions an agent's account has.",
|
|
463
496
|
inputSchema: NO_INPUT,
|
|
464
497
|
outputSchema: AGENTS_OUTPUT,
|
|
465
|
-
requires: { tool: 'agents.list', output: 'spawn.options' },
|
|
498
|
+
requires: { tool: 'agents.list', output: 'spawn.options', outputUnless: 'fleet.daemons' },
|
|
466
499
|
},
|
|
467
500
|
{
|
|
468
501
|
name: 'atc_session_get',
|
|
@@ -485,7 +518,7 @@ export const MCP_TOOLS: readonly MCPToolDefinition[] = [
|
|
|
485
518
|
annotations: READ_ONLY,
|
|
486
519
|
scope: 'read',
|
|
487
520
|
description:
|
|
488
|
-
'Catch up on the fleet: session events (started, prompt-submitted, needs-input, turn-done, ended), message events (message-accepted, message-delivered, message-answered), and reports (report) since a cursor, oldest first, each with the session id and name. A message event carries the message id; read the full message with atc_message_get. A report event carries its label and a preview of its text; read the full text with atc_report_get, passing the
|
|
521
|
+
'Catch up on the fleet: session events (started, prompt-submitted, needs-input, turn-done, ended), message events (message-accepted, message-delivered, message-answered), and reports (report) since a cursor, oldest first, each with the session id and name. A message event carries the message id; read the full message with atc_message_get. A report event carries its label and a preview of its text; read the full text with atc_report_get, passing the report handle of that event when it carries one, else its cursor. Without a cursor it returns the most recent events. Pass the returned cursor next time; more is true when the page stopped before the newest event, so read again at once. session limits the read to one session. waitMs holds the call open until an event arrives; pass it instead of polling in a tight loop.',
|
|
489
522
|
inputSchema: EVENTS_READ_INPUT,
|
|
490
523
|
outputSchema: EVENTS_OUTPUT,
|
|
491
524
|
requires: { output: 'events.more', inputs: { session: 'events.session' } },
|
|
@@ -495,7 +528,7 @@ export const MCP_TOOLS: readonly MCPToolDefinition[] = [
|
|
|
495
528
|
annotations: READ_ONLY,
|
|
496
529
|
scope: 'read',
|
|
497
530
|
description:
|
|
498
|
-
"Read one report's full text without messaging the session that sent it. Pass the
|
|
531
|
+
"Read one report's full text without messaging the session that sent it. Pass the report handle of the report's event from atc_events_read, or the event's cursor when it carries none. Returns the report cursor, at, the session id and name, the label, the text (up to 64 KiB, as the session sent it), and complete, which is false for a report recorded before atc kept full texts: its text is then only the preview the event held. A cursor of an event that is not a report answers as an unknown report.",
|
|
499
532
|
inputSchema: REPORT_GET_INPUT,
|
|
500
533
|
outputSchema: REPORT_OUTPUT,
|
|
501
534
|
requires: { tool: 'report.get' },
|
|
@@ -21,6 +21,8 @@ const FEATURE_USES: Readonly<Record<DaemonFeature, string>> = {
|
|
|
21
21
|
'spawn.workspace': "atc_session_spawn's workspace",
|
|
22
22
|
sources: 'sources.list and sources.interpret',
|
|
23
23
|
'git.probe': 'git.probe',
|
|
24
|
+
'transport.tcp': 'a TCP connection to the daemon',
|
|
25
|
+
'idempotency.replayOnly': 'a resend that only replays a held idempotency key',
|
|
24
26
|
};
|
|
25
27
|
|
|
26
28
|
/**
|
package/src/mcp/run-tool.ts
CHANGED
|
@@ -25,6 +25,12 @@ export function runTool(
|
|
|
25
25
|
.with('atc_session_list', async () => {
|
|
26
26
|
const ok = await caller.sendRequest('session.list');
|
|
27
27
|
|
|
28
|
+
// A caller that routes across named daemons adds each daemon's state,
|
|
29
|
+
// so a daemon that is down never reads as one with no sessions.
|
|
30
|
+
if (ok['daemons'] !== undefined) {
|
|
31
|
+
return buildObjectResult({ sessions: ok['sessions'], daemons: ok['daemons'] });
|
|
32
|
+
}
|
|
33
|
+
|
|
28
34
|
// The text stays the bare list older clients read; structured content
|
|
29
35
|
// has to be an object.
|
|
30
36
|
return {
|
|
@@ -38,6 +44,7 @@ export function runTool(
|
|
|
38
44
|
const key = parseIdempotencyKey(args['idempotencyKey']);
|
|
39
45
|
|
|
40
46
|
const params = {
|
|
47
|
+
...(typeof args['daemon'] === 'string' ? { daemon: args['daemon'] } : {}),
|
|
41
48
|
cwd: args['cwd'],
|
|
42
49
|
...(typeof args['name'] === 'string' ? { name: args['name'] } : {}),
|
|
43
50
|
...(typeof args['prompt'] === 'string' ? { prompt: args['prompt'] } : {}),
|
|
@@ -132,10 +139,17 @@ export function runTool(
|
|
|
132
139
|
};
|
|
133
140
|
})
|
|
134
141
|
.with('atc_dirs_list', async () => {
|
|
135
|
-
const
|
|
142
|
+
const params = typeof args['daemon'] === 'string' ? { daemon: args['daemon'] } : {};
|
|
143
|
+
|
|
144
|
+
const ok = await caller.sendRequest('dirs.list', params);
|
|
136
145
|
|
|
137
146
|
return { text: JSON.stringify(ok['dirs'], null, 2), structured: { dirs: ok['dirs'] } };
|
|
138
147
|
})
|
|
148
|
+
.with('atc_daemons_list', async () => {
|
|
149
|
+
const ok = await caller.sendRequest('daemons.list');
|
|
150
|
+
|
|
151
|
+
return buildObjectResult(ok);
|
|
152
|
+
})
|
|
139
153
|
.with('atc_agents_list', async () => {
|
|
140
154
|
const ok = await caller.sendRequest('agents.list', {}, ['agents.list']);
|
|
141
155
|
|
|
@@ -35,6 +35,9 @@ interface MCPHTTPServerOptions {
|
|
|
35
35
|
|
|
36
36
|
// How long a rotated refresh token still answers with its successor.
|
|
37
37
|
readonly refreshReuseSeconds?: number;
|
|
38
|
+
|
|
39
|
+
// Serves `/healthz` and `/readyz` for an orchestrator's probes.
|
|
40
|
+
readonly probes?: boolean;
|
|
38
41
|
}
|
|
39
42
|
|
|
40
43
|
/**
|
|
@@ -60,7 +63,11 @@ export interface MCPHTTPServer {
|
|
|
60
63
|
* method, path, JSON-RPC method and tool, status, duration, and MCP protocol
|
|
61
64
|
* version, and never a body, query, credential, or address. A request whose Host header is not the server's own is
|
|
62
65
|
* refused, so a DNS rebinding page cannot reach it through a browser, and a
|
|
63
|
-
* browser form post from any other origin is refused too.
|
|
66
|
+
* browser form post from any other origin is refused too. With `probes`,
|
|
67
|
+
* `/healthz` answers 200 while the server serves and `/readyz` answers 200
|
|
68
|
+
* once the authorization server's database is open, else 503; both pass
|
|
69
|
+
* the Host check first and answer with an empty body, so they disclose
|
|
70
|
+
* nothing about the fleet.
|
|
64
71
|
*/
|
|
65
72
|
export async function startMCPHTTPServer(options: MCPHTTPServerOptions): Promise<MCPHTTPServer> {
|
|
66
73
|
// Normalized before binding, so an invalid public URL throws with no port
|
|
@@ -79,7 +86,10 @@ export async function startMCPHTTPServer(options: MCPHTTPServerOptions): Promise
|
|
|
79
86
|
);
|
|
80
87
|
}
|
|
81
88
|
|
|
82
|
-
const holder: { ready: ServerState | null } = {
|
|
89
|
+
const holder: { ready: ServerState | null; hosts: ReadonlySet<string> } = {
|
|
90
|
+
ready: null,
|
|
91
|
+
hosts: new Set(),
|
|
92
|
+
};
|
|
83
93
|
|
|
84
94
|
const server = Bun.serve({
|
|
85
95
|
hostname: options.host,
|
|
@@ -94,7 +104,11 @@ export async function startMCPHTTPServer(options: MCPHTTPServerOptions): Promise
|
|
|
94
104
|
let rpc: RPCLabel | null = null;
|
|
95
105
|
let response: Response;
|
|
96
106
|
|
|
97
|
-
|
|
107
|
+
const path = new URL(request.url).pathname;
|
|
108
|
+
|
|
109
|
+
if (options.probes === true && PROBE_PATHS.has(path)) {
|
|
110
|
+
response = answerProbeRequest(request, path, holder.hosts, state !== null);
|
|
111
|
+
} else if (state === null) {
|
|
98
112
|
response = new Response(null, { status: 503 });
|
|
99
113
|
} else {
|
|
100
114
|
try {
|
|
@@ -122,6 +136,16 @@ export async function startMCPHTTPServer(options: MCPHTTPServerOptions): Promise
|
|
|
122
136
|
const port = server.port ?? options.port;
|
|
123
137
|
const local = `http://127.0.0.1:${port}`;
|
|
124
138
|
const origin = publicOrigin ?? local;
|
|
139
|
+
|
|
140
|
+
const hosts = new Set([
|
|
141
|
+
new URL(origin).host,
|
|
142
|
+
`127.0.0.1:${port}`,
|
|
143
|
+
`localhost:${port}`,
|
|
144
|
+
...options.allowedHosts,
|
|
145
|
+
]);
|
|
146
|
+
|
|
147
|
+
holder.hosts = hosts;
|
|
148
|
+
|
|
125
149
|
let store: HTTPServerContext['store'];
|
|
126
150
|
|
|
127
151
|
try {
|
|
@@ -139,12 +163,7 @@ export async function startMCPHTTPServer(options: MCPHTTPServerOptions): Promise
|
|
|
139
163
|
}
|
|
140
164
|
|
|
141
165
|
holder.ready = {
|
|
142
|
-
hosts
|
|
143
|
-
new URL(origin).host,
|
|
144
|
-
`127.0.0.1:${port}`,
|
|
145
|
-
`localhost:${port}`,
|
|
146
|
-
...options.allowedHosts,
|
|
147
|
-
]),
|
|
166
|
+
hosts,
|
|
148
167
|
origins: new Set([origin, local, `http://localhost:${port}`]),
|
|
149
168
|
ctx: {
|
|
150
169
|
caller: options.caller,
|
|
@@ -168,6 +187,31 @@ export async function startMCPHTTPServer(options: MCPHTTPServerOptions): Promise
|
|
|
168
187
|
};
|
|
169
188
|
}
|
|
170
189
|
|
|
190
|
+
const PROBE_PATHS: ReadonlySet<string> = new Set(['/healthz', '/readyz']);
|
|
191
|
+
|
|
192
|
+
// A probe from a Host other than the server's own is refused like any other
|
|
193
|
+
// request; `ready` is whether the authorization server's database is open.
|
|
194
|
+
function answerProbeRequest(
|
|
195
|
+
request: Request,
|
|
196
|
+
path: string,
|
|
197
|
+
hosts: ReadonlySet<string>,
|
|
198
|
+
ready: boolean,
|
|
199
|
+
): Response {
|
|
200
|
+
const host = request.headers.get('host');
|
|
201
|
+
|
|
202
|
+
if (host === null || !hosts.has(host)) {
|
|
203
|
+
return new Response(null, { status: 403 });
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
if (request.method !== 'GET' && request.method !== 'HEAD') {
|
|
207
|
+
return new Response(null, { status: 405, headers: { allow: 'GET, HEAD' } });
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
const isReady = path === '/healthz' || ready;
|
|
211
|
+
|
|
212
|
+
return new Response(null, { status: isReady ? 200 : 503 });
|
|
213
|
+
}
|
|
214
|
+
|
|
171
215
|
// An IPv6 address takes the brackets a URL puts around it.
|
|
172
216
|
function formatBindURL(host: string, port: number): string {
|
|
173
217
|
const bracketed = host.includes(':') && !host.startsWith('[') ? `[${host}]` : host;
|
package/src/mcp/types.ts
CHANGED
|
@@ -2,6 +2,13 @@ import type { DaemonFeature } from '../protocol/daemon-features';
|
|
|
2
2
|
import type { ApprovalState } from './approval-state';
|
|
3
3
|
import type { openMCPAuth } from './open-mcp-auth';
|
|
4
4
|
|
|
5
|
+
/**
|
|
6
|
+
* A feature the tool list can depend on: one a daemon announces, or
|
|
7
|
+
* `fleet.daemons`, which only a caller that routes across named daemons
|
|
8
|
+
* announces, for the tools and inputs that pick a daemon.
|
|
9
|
+
*/
|
|
10
|
+
export type FleetFeature = DaemonFeature | 'fleet.daemons';
|
|
11
|
+
|
|
5
12
|
// The slice of the daemon client the tool handlers need: requests, and the
|
|
6
13
|
// features the connected daemon announced at its handshake. A request that
|
|
7
14
|
// lists required features is checked against the connection it is about to
|
|
@@ -14,7 +21,7 @@ export interface FleetCaller {
|
|
|
14
21
|
required?: readonly DaemonFeature[],
|
|
15
22
|
principal?: string,
|
|
16
23
|
) => Promise<Readonly<Record<string, unknown>>>;
|
|
17
|
-
readonly readFeatures: () => Promise<ReadonlySet<
|
|
24
|
+
readonly readFeatures: () => Promise<ReadonlySet<FleetFeature>>;
|
|
18
25
|
}
|
|
19
26
|
|
|
20
27
|
export interface ToolContext {
|
|
@@ -64,6 +64,15 @@ export const DAEMON_FEATURES = [
|
|
|
64
64
|
// `git.probe` exists, and `session.spawn` takes a git workspace with both
|
|
65
65
|
// `ref` and `sha`.
|
|
66
66
|
'git.probe',
|
|
67
|
+
|
|
68
|
+
// The daemon can serve the client protocol on a TCP listener whose
|
|
69
|
+
// handshake takes a bearer token.
|
|
70
|
+
'transport.tcp',
|
|
71
|
+
|
|
72
|
+
// A keyed `session.spawn` or `session.message` takes `replayOnly`, which
|
|
73
|
+
// replays a key the daemon holds and refuses one it does not hold with
|
|
74
|
+
// `idempotency_key_unknown`, running nothing.
|
|
75
|
+
'idempotency.replayOnly',
|
|
67
76
|
] as const;
|
|
68
77
|
|
|
69
78
|
export type DaemonFeature = (typeof DAEMON_FEATURES)[number];
|
package/src/protocol/protocol.ts
CHANGED
|
@@ -48,6 +48,7 @@ const ERROR_CODES = [
|
|
|
48
48
|
'github_unavailable',
|
|
49
49
|
'host_unavailable',
|
|
50
50
|
'auth_not_configured',
|
|
51
|
+
'auth_target_unsupported',
|
|
51
52
|
'host_leased',
|
|
52
53
|
'confirmation_required',
|
|
53
54
|
'confirm_token_invalid',
|
|
@@ -56,6 +57,7 @@ const ERROR_CODES = [
|
|
|
56
57
|
'stale_epoch',
|
|
57
58
|
'idempotency_conflict',
|
|
58
59
|
'outcome_unknown',
|
|
60
|
+
'idempotency_key_unknown',
|
|
59
61
|
'internal',
|
|
60
62
|
] as const;
|
|
61
63
|
|
|
@@ -23,6 +23,10 @@ const IDEMPOTENCY_KEY = z
|
|
|
23
23
|
.max(200, 'idempotencyKey must be at most 200 characters')
|
|
24
24
|
.optional();
|
|
25
25
|
|
|
26
|
+
// With a key, only replays what the key already holds: a key the daemon
|
|
27
|
+
// does not hold is refused and nothing runs.
|
|
28
|
+
const REPLAY_ONLY = z.boolean({ error: 'replayOnly must be a boolean' }).optional();
|
|
29
|
+
|
|
26
30
|
// A full commit id, SHA-1 or SHA-256.
|
|
27
31
|
const COMMIT_ID = /^(?:[\da-f]{40}|[\da-f]{64})$/u;
|
|
28
32
|
|
|
@@ -168,6 +172,7 @@ export const REQUEST_PARAM_SCHEMAS = {
|
|
|
168
172
|
// A retry carrying the same key replays the first spawn's answer instead
|
|
169
173
|
// of spawning again.
|
|
170
174
|
idempotencyKey: IDEMPOTENCY_KEY,
|
|
175
|
+
replayOnly: REPLAY_ONLY,
|
|
171
176
|
|
|
172
177
|
// The execution target the session runs on; absent runs on the default
|
|
173
178
|
// target, and a target the daemon cannot use refuses the spawn.
|
|
@@ -260,6 +265,7 @@ export const REQUEST_PARAM_SCHEMAS = {
|
|
|
260
265
|
// A retry carrying the same key replays the first send's message
|
|
261
266
|
// instead of sending another.
|
|
262
267
|
idempotencyKey: IDEMPOTENCY_KEY,
|
|
268
|
+
replayOnly: REPLAY_ONLY,
|
|
263
269
|
}).refine((v) => v.text !== '', { message: 'session.message requires text' }),
|
|
264
270
|
'session.tap': SESSION_DEFAULTED,
|
|
265
271
|
'message.get': z
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { DaemonClient } from './client/daemon-client';
|
|
2
|
+
import { daemonRecordFile, daemonSocketPath } from './shared/config';
|
|
3
|
+
import { findDaemonRecord } from './shared/find-daemon-record';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Prints the running daemon's `daemonID`, the identity a gateway registry
|
|
7
|
+
* pins, read from the handshake over the owner's local socket: the one this
|
|
8
|
+
* environment computes, else the one the daemon recorded in the state
|
|
9
|
+
* directory. When no daemon answers, prints a hint to stderr and exits
|
|
10
|
+
* nonzero instead of booting one.
|
|
11
|
+
*/
|
|
12
|
+
export async function runDaemonID(build: string): Promise<void> {
|
|
13
|
+
const record = findDaemonRecord(daemonRecordFile);
|
|
14
|
+
const paths = [daemonSocketPath, ...(record === null ? [] : [record.socketPath])];
|
|
15
|
+
|
|
16
|
+
for (const path of new Set(paths)) {
|
|
17
|
+
const daemonID = await tryReadDaemonID(path, build);
|
|
18
|
+
|
|
19
|
+
if (daemonID !== null) {
|
|
20
|
+
console.log(daemonID);
|
|
21
|
+
|
|
22
|
+
return;
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
console.error(`atc daemon id: no daemon at ${daemonSocketPath} — start one first`);
|
|
27
|
+
process.exit(1);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// The daemonID the daemon at the socket returns, or null when nothing
|
|
31
|
+
// answers there or the answer holds none.
|
|
32
|
+
async function tryReadDaemonID(socketPath: string, build: string): Promise<string | null> {
|
|
33
|
+
let client: DaemonClient;
|
|
34
|
+
|
|
35
|
+
try {
|
|
36
|
+
client = await DaemonClient.open(socketPath);
|
|
37
|
+
} catch {
|
|
38
|
+
return null;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
try {
|
|
42
|
+
const hello = await client.sendHello(build);
|
|
43
|
+
|
|
44
|
+
const daemonID = hello['daemonID'];
|
|
45
|
+
|
|
46
|
+
return typeof daemonID === 'string' ? daemonID : null;
|
|
47
|
+
} catch {
|
|
48
|
+
return null;
|
|
49
|
+
} finally {
|
|
50
|
+
client.stop();
|
|
51
|
+
}
|
|
52
|
+
}
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
import { isRecord } from './report';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* A named reference to a credential impd holds: the secret's name, never
|
|
5
|
+
* its value, and the rule impd applies when a request reaches the host,
|
|
6
|
+
* plus the profiles a session selecting this one needs beside it. Only
|
|
7
|
+
* the `custom` kind and the `bearer` scheme are bound.
|
|
8
|
+
*/
|
|
9
|
+
export interface AuthProfile {
|
|
10
|
+
readonly name: string;
|
|
11
|
+
readonly secret: string;
|
|
12
|
+
readonly kind: 'custom';
|
|
13
|
+
readonly host: string;
|
|
14
|
+
readonly header: string;
|
|
15
|
+
readonly scheme: 'bearer';
|
|
16
|
+
readonly dependencies: readonly string[];
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
interface AuthProfiles {
|
|
20
|
+
readonly profiles: ReadonlyMap<string, AuthProfile>;
|
|
21
|
+
readonly errors: readonly string[];
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Reads the `authProfiles` map under impd's own rules for secret names,
|
|
26
|
+
* broker hosts and header names. A profile that breaks a rule is left out
|
|
27
|
+
* with an error, so a gateway that selects it is refused rather than bound
|
|
28
|
+
* to a rule impd would reject or apply differently. Dependencies are
|
|
29
|
+
* names only here; whether they resolve is a property of each selection.
|
|
30
|
+
*/
|
|
31
|
+
export function collectAuthProfiles(raw: unknown): AuthProfiles {
|
|
32
|
+
if (raw === undefined) {
|
|
33
|
+
return { profiles: new Map(), errors: [] };
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
if (!isRecord(raw) || Array.isArray(raw)) {
|
|
37
|
+
return { profiles: new Map(), errors: ['authProfiles must be an object of named profiles'] };
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const profiles = new Map<string, AuthProfile>();
|
|
41
|
+
|
|
42
|
+
const errors: string[] = [];
|
|
43
|
+
|
|
44
|
+
for (const [name, entry] of Object.entries(raw)) {
|
|
45
|
+
const parsed = parseAuthProfile(name, entry);
|
|
46
|
+
|
|
47
|
+
if (typeof parsed === 'string') {
|
|
48
|
+
errors.push(`authProfiles.${name}: ${parsed}`);
|
|
49
|
+
} else {
|
|
50
|
+
profiles.set(name, parsed);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
return { profiles, errors };
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// impd's secret name rule: an imp name's form, so a secret name is never a
|
|
58
|
+
// path or a flag.
|
|
59
|
+
const SECRET_NAME = /^[a-z][a-z0-9-]{0,30}$/;
|
|
60
|
+
|
|
61
|
+
// impd's broker host rule: a lowercase hostname as a CONNECT carries it, with
|
|
62
|
+
// no port and no wildcard, whose last label starts with a letter so an IP
|
|
63
|
+
// address never matches.
|
|
64
|
+
const BROKER_HOST = /^(?:[a-z0-9][a-z0-9-]{0,62}\.)+[a-z][a-z0-9-]{0,62}$/;
|
|
65
|
+
const BROKER_HOST_MAX = 253;
|
|
66
|
+
|
|
67
|
+
// impd's header name rule.
|
|
68
|
+
const HEADER_NAME = /^[a-z0-9-]{1,64}$/;
|
|
69
|
+
|
|
70
|
+
// The profile an entry holds, or the first rule it breaks.
|
|
71
|
+
function parseAuthProfile(name: string, entry: unknown): AuthProfile | string {
|
|
72
|
+
if (!isRecord(entry) || Array.isArray(entry)) {
|
|
73
|
+
return 'a profile must be an object';
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const secret = entry['secret'];
|
|
77
|
+
const host = entry['host'];
|
|
78
|
+
const header = entry['header'];
|
|
79
|
+
const scheme = entry['scheme'];
|
|
80
|
+
const dependencies = entry['dependencies'];
|
|
81
|
+
|
|
82
|
+
if (entry['kind'] !== undefined && entry['kind'] !== 'custom') {
|
|
83
|
+
return 'kind must be custom, the one kind atc binds';
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
if (typeof secret !== 'string' || !SECRET_NAME.test(secret)) {
|
|
87
|
+
return 'secret must be a lowercase letter followed by up to 30 lowercase letters, digits or hyphens';
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
if (typeof host !== 'string' || host.length > BROKER_HOST_MAX || !BROKER_HOST.test(host)) {
|
|
91
|
+
return 'host must be a lowercase hostname such as api.example.com';
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
if (typeof header !== 'string' || !HEADER_NAME.test(header)) {
|
|
95
|
+
return 'header must be a lowercase header name such as authorization';
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
if (scheme !== 'bearer') {
|
|
99
|
+
return 'scheme must be bearer, the one scheme atc binds';
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
if (entry['user'] !== undefined) {
|
|
103
|
+
return 'user pairs only with the basic scheme, which atc does not bind';
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
if (
|
|
107
|
+
dependencies !== undefined &&
|
|
108
|
+
(!Array.isArray(dependencies) || !dependencies.every((dep) => typeof dep === 'string'))
|
|
109
|
+
) {
|
|
110
|
+
return 'dependencies must be an array of profile names';
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
return {
|
|
114
|
+
name,
|
|
115
|
+
secret,
|
|
116
|
+
kind: 'custom',
|
|
117
|
+
host,
|
|
118
|
+
header,
|
|
119
|
+
scheme,
|
|
120
|
+
dependencies: dependencies ?? [],
|
|
121
|
+
};
|
|
122
|
+
}
|