@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.
Files changed (74) hide show
  1. package/package.json +1 -1
  2. package/src/agents/agent-adapter.ts +5 -0
  3. package/src/agents/gateway-adapter.ts +25 -2
  4. package/src/cli.ts +187 -113
  5. package/src/client/collect-agent-picks.ts +5 -2
  6. package/src/client/daemon-client.ts +29 -18
  7. package/src/daemon/build-agent-list.ts +8 -6
  8. package/src/daemon/build-auth-binding.ts +58 -0
  9. package/src/daemon/build-imp-name.ts +11 -0
  10. package/src/daemon/build-imp-provider.ts +25 -2
  11. package/src/daemon/build-payload-hash.ts +4 -3
  12. package/src/daemon/daemon-connection.ts +277 -20
  13. package/src/daemon/daemon-context.ts +8 -0
  14. package/src/daemon/daemon.ts +186 -32
  15. package/src/daemon/find-token-fingerprint.ts +23 -0
  16. package/src/daemon/handshake-throttle.ts +47 -0
  17. package/src/daemon/idempotency-ledger.ts +20 -2
  18. package/src/daemon/imp-port.ts +4 -2
  19. package/src/daemon/imp-provider.ts +24 -19
  20. package/src/daemon/is-allowed-listen-host.ts +63 -0
  21. package/src/daemon/load-listener-tokens.ts +46 -0
  22. package/src/daemon/parse-listen-address.ts +33 -0
  23. package/src/daemon/restore-fleet.ts +2 -1
  24. package/src/daemon/sessions.ts +6 -0
  25. package/src/daemon/start-tcp-listener.ts +165 -0
  26. package/src/federation/build-binding-payload-hash.ts +34 -0
  27. package/src/federation/build-daemon-outdated-error.ts +14 -0
  28. package/src/federation/build-events-filter-hash.ts +16 -0
  29. package/src/federation/build-gateway-error.ts +45 -0
  30. package/src/federation/build-gateway-id.ts +14 -0
  31. package/src/federation/build-gateway-result.ts +31 -0
  32. package/src/federation/build-ruled-value.ts +53 -0
  33. package/src/federation/collect-unruled-id-paths.ts +46 -0
  34. package/src/federation/daemon-caller.ts +473 -0
  35. package/src/federation/daemon-pool.ts +56 -0
  36. package/src/federation/decode-gateway-cursor.ts +73 -0
  37. package/src/federation/encode-gateway-cursor.ts +15 -0
  38. package/src/federation/gateway-error.ts +25 -0
  39. package/src/federation/gateway-store.ts +253 -0
  40. package/src/federation/id-rules.ts +85 -0
  41. package/src/federation/load-gateway-registry.ts +29 -0
  42. package/src/federation/max-events-cursor-bytes.ts +4 -0
  43. package/src/federation/max-registry-daemons.ts +26 -0
  44. package/src/federation/merge-event-pages.ts +228 -0
  45. package/src/federation/open-gateway-caller.ts +55 -0
  46. package/src/federation/parse-gateway-id.ts +28 -0
  47. package/src/federation/parse-gateway-registry.ts +123 -0
  48. package/src/federation/pick-daemon-state.ts +46 -0
  49. package/src/federation/plan-event-reads.ts +54 -0
  50. package/src/federation/read-fleet-events.ts +279 -0
  51. package/src/federation/require-serving-daemon.ts +27 -0
  52. package/src/federation/resolve-daemon-request.ts +59 -0
  53. package/src/federation/routing-caller.ts +450 -0
  54. package/src/federation/types.ts +33 -0
  55. package/src/federation/wait-for-outcome.ts +38 -0
  56. package/src/mcp/answer-rpc-request.ts +14 -1
  57. package/src/mcp/build-tool-list.ts +6 -5
  58. package/src/mcp/mcp-tools.ts +41 -8
  59. package/src/mcp/require-daemon-features.ts +2 -0
  60. package/src/mcp/run-tool.ts +15 -1
  61. package/src/mcp/start-mcp-http-server.ts +53 -9
  62. package/src/mcp/types.ts +8 -1
  63. package/src/protocol/daemon-features.ts +9 -0
  64. package/src/protocol/protocol.ts +2 -0
  65. package/src/protocol/request-param-schemas.ts +6 -0
  66. package/src/run-daemon-id.ts +52 -0
  67. package/src/shared/collect-auth-profiles.ts +122 -0
  68. package/src/shared/collect-gateways.ts +223 -6
  69. package/src/shared/config.ts +32 -4
  70. package/src/shared/find-daemon-record.ts +8 -3
  71. package/src/shared/resolve-auth-profiles.ts +186 -0
  72. package/src/store/run-migrations.ts +77 -0
  73. package/src/store/runtime-auth-binding.ts +110 -0
  74. package/src/store/state-store.ts +202 -0
@@ -3,11 +3,12 @@ import { isRecord } from '../shared/report';
3
3
 
4
4
  /**
5
5
  * The SHA-256 of a request's params as canonical JSON: object keys sorted
6
- * at every depth, with the idempotency key itself left out. Two requests
7
- * that differ only in key order or in their idempotency key hash the same.
6
+ * at every depth, with the idempotency key and the replay-only flag left
7
+ * out. Two requests that differ only in key order, in their idempotency
8
+ * key, or in whether they only replay hash the same.
8
9
  */
9
10
  export function buildPayloadHash(params: Readonly<Record<string, unknown>>): string {
10
- const { idempotencyKey: _ignored, ...payload } = params;
11
+ const { idempotencyKey: _ignored, replayOnly: _replayOnly, ...payload } = params;
11
12
 
12
13
  return createHash('sha256')
13
14
  .update(JSON.stringify(sortKeys(payload)))
@@ -26,7 +26,7 @@ import { buildTargetForbiddenError } from './build-target-forbidden-error';
26
26
  import type { DaemonContext, SpawnParams } from './daemon-context';
27
27
  import type { TranscriptPosition } from './load-transcript-page';
28
28
  import { parseSpawnOverrides } from './parse-spawn-overrides';
29
- import type { TargetAccess } from './target-access';
29
+ import { TargetAccess } from './target-access';
30
30
 
31
31
  // The targets a request may use and the namespace of its idempotency keys.
32
32
  interface RequestScope {
@@ -60,6 +60,19 @@ interface PeerSocket extends SocketWriter {
60
60
  readonly end: () => void;
61
61
  }
62
62
 
63
+ /**
64
+ * What a connection accepted over TCP needs from its listener: the check of
65
+ * the bearer token its handshake presents, which answers with the matched
66
+ * token's fingerprint, or null for a missing or wrong token.
67
+ */
68
+ export interface TCPPeer {
69
+ readonly verifyHandshake: (presented: string | null) => Promise<string | null>;
70
+
71
+ // Counts a line other than a handshake, sent before one passed, as a
72
+ // failed handshake from the peer's address.
73
+ readonly recordFailure: () => void;
74
+ }
75
+
63
76
  export class DaemonConnection {
64
77
  private readonly peer: PeerSocket;
65
78
 
@@ -95,11 +108,37 @@ export class DaemonConnection {
95
108
 
96
109
  private readonly shownRequests = new Set<string>();
97
110
 
98
- constructor(peer: PeerSocket, ctx: DaemonContext) {
111
+ // The listener of a connection accepted over TCP, null on the local
112
+ // socket.
113
+ private readonly tcp: TCPPeer | null;
114
+
115
+ // The fingerprint of the token a TCP handshake presented, set once the
116
+ // handshake passes; null before then and on the local socket.
117
+ private fingerprint: string | null = null;
118
+
119
+ // Whether this TCP connection has sent its one handshake.
120
+ private tcpHelloSent = false;
121
+
122
+ constructor(peer: PeerSocket, ctx: DaemonContext, tcp: TCPPeer | null = null) {
99
123
  this.peer = peer;
100
124
  this.ctx = ctx;
125
+ this.tcp = tcp;
101
126
 
102
127
  this.queue = new OutboundQueue(peer, ctx.queueBytes);
128
+
129
+ // A TCP connection acts only as the principals its requests give, so
130
+ // the events pushed to it are limited from the first byte.
131
+ if (tcp !== null) {
132
+ this.access = new TargetAccess([]);
133
+ }
134
+ }
135
+
136
+ /**
137
+ * The fingerprint of the token this connection's TCP handshake presented,
138
+ * or null for a connection that has not passed one.
139
+ */
140
+ get tokenFingerprint(): string | null {
141
+ return this.fingerprint;
103
142
  }
104
143
 
105
144
  /**
@@ -110,7 +149,7 @@ export class DaemonConnection {
110
149
  }
111
150
 
112
151
  sendEvent(event: EventMsg): void {
113
- if (!this.helloed) {
152
+ if (!this.hasPassedHello()) {
114
153
  return;
115
154
  }
116
155
 
@@ -142,7 +181,7 @@ export class DaemonConnection {
142
181
  // intermediate chunk is never dropped without that resync, because a byte
143
182
  // stream cut mid-escape corrupts the client's terminal state.
144
183
  sendOutput(sessionID: SessionID, event: EventMsg, byteLength: number): void {
145
- if (!this.helloed || !this.canSeeOutput(sessionID)) {
184
+ if (!this.hasPassedHello() || !this.canSeeOutput(sessionID)) {
146
185
  return;
147
186
  }
148
187
 
@@ -159,6 +198,11 @@ export class DaemonConnection {
159
198
  }
160
199
  }
161
200
 
201
+ // Whether the handshake has passed: on TCP, only once its token checked out.
202
+ private hasPassedHello(): boolean {
203
+ return this.helloed && (this.tcp === null || this.fingerprint !== null);
204
+ }
205
+
162
206
  // Decodes with state kept across reads, so a multi-byte character split
163
207
  // between two reads decodes whole.
164
208
  // oxlint-disable-next-line prefer-readonly-parameter-types -- a socket read buffer has no readonly form
@@ -167,7 +211,16 @@ export class DaemonConnection {
167
211
  }
168
212
 
169
213
  applyChunk(chunk: string): void {
170
- if (this.lines.pendingLength + chunk.length > MAX_LINE) {
214
+ const oversized = this.lines.pendingLength + chunk.length > MAX_LINE;
215
+
216
+ if (oversized && this.isUnauthenticatedTCP()) {
217
+ this.tcp?.recordFailure();
218
+ this.peer.end();
219
+
220
+ return;
221
+ }
222
+
223
+ if (oversized) {
171
224
  this.sendErr(0, 'bad_args', `line exceeds ${MAX_LINE} bytes`);
172
225
  this.peer.end();
173
226
 
@@ -209,6 +262,14 @@ export class DaemonConnection {
209
262
 
210
263
  // false: the connection is beyond recovery and gets closed.
211
264
  private applyLine(line: string): boolean {
265
+ if (this.tcp !== null) {
266
+ const refused = this.findTCPLineRefusal(line);
267
+
268
+ if (refused === 'close') {
269
+ return false;
270
+ }
271
+ }
272
+
212
273
  const decoded = decodeMessage(line);
213
274
 
214
275
  if (decoded.kind === 'malformed') {
@@ -226,7 +287,7 @@ export class DaemonConnection {
226
287
  const req = decoded.msg;
227
288
 
228
289
  if (req.m === 'daemon.hello') {
229
- return this.applyHello(req);
290
+ return this.tcp === null ? this.applyHello(req) : this.applyTCPHello(req, this.tcp);
230
291
  }
231
292
 
232
293
  if (!this.helloed) {
@@ -235,6 +296,12 @@ export class DaemonConnection {
235
296
  return false;
236
297
  }
237
298
 
299
+ if (this.tcp !== null) {
300
+ this.answerAsync(req.id, () => this.applyTCPRequest(req));
301
+
302
+ return true;
303
+ }
304
+
238
305
  const scope = this.findRequestScope(req);
239
306
 
240
307
  if (scope !== null && OWNER_METHODS.has(req.m)) {
@@ -251,6 +318,82 @@ export class DaemonConnection {
251
318
  return true;
252
319
  }
253
320
 
321
+ // Whether a line ends this TCP connection before any answer to it. Until
322
+ // its token checks out, a connection gets nothing but the answer to one
323
+ // handshake: any other line, or a line while that handshake is still
324
+ // checked, counts as a failed handshake and closes it without a reply,
325
+ // so a peer learns nothing and floods nothing past the delay. Once in,
326
+ // a second handshake, which could change the principal under kept
327
+ // attachments, gets `unauthorized` and closes it.
328
+ private findTCPLineRefusal(line: string): 'close' | null {
329
+ if (this.isUnauthenticatedTCP()) {
330
+ const decoded = decodeMessage(line);
331
+ const isHello = decoded.kind === 'request' && decoded.msg.m === 'daemon.hello';
332
+
333
+ if (this.tcpHelloSent || !isHello) {
334
+ this.tcp?.recordFailure();
335
+
336
+ return 'close';
337
+ }
338
+
339
+ this.tcpHelloSent = true;
340
+
341
+ return null;
342
+ }
343
+
344
+ const decoded = decodeMessage(line);
345
+
346
+ if (decoded.kind === 'request' && decoded.msg.m === 'daemon.hello') {
347
+ this.sendErr(decoded.msg.id, 'unauthorized', 'a TCP connection takes one handshake');
348
+
349
+ return 'close';
350
+ }
351
+
352
+ return null;
353
+ }
354
+
355
+ // Whether this is a TCP connection whose handshake has not passed.
356
+ private isUnauthenticatedTCP(): boolean {
357
+ return this.tcp !== null && this.fingerprint === null;
358
+ }
359
+
360
+ // A request over TCP runs only once the handshake has passed, only as a
361
+ // principal the config lists, and never as the daemon's owner, so it
362
+ // always runs on the context scoped to that principal.
363
+ private async applyTCPRequest(req: RequestMsg): Promise<void> {
364
+ await this.helloAnswered;
365
+
366
+ if (this.fingerprint === null) {
367
+ return;
368
+ }
369
+
370
+ if (OWNER_METHODS.has(req.m)) {
371
+ this.sendErr(req.id, 'unauthorized', `${req.m} is open to the daemon's owner only`);
372
+
373
+ return;
374
+ }
375
+
376
+ if (req.as === undefined) {
377
+ this.sendErr(req.id, 'unauthorized', 'a request over TCP must act as a principal with as');
378
+
379
+ return;
380
+ }
381
+
382
+ if (!this.ctx.hasListedPrincipal(req.as)) {
383
+ this.sendErr(req.id, 'unauthorized', `principal '${req.as}' is not listed in principals`);
384
+
385
+ return;
386
+ }
387
+
388
+ const scope = this.findRequestScope(req);
389
+
390
+ if (scope === null) {
391
+ throw new DaemonError('internal', 'a request over TCP resolved to the owner scope');
392
+ }
393
+
394
+ await this.applyRequest(req, buildScopedContext(this.ctx, scope.access, scope.keyNamespace));
395
+ }
396
+
254
397
  // The targets a request may use and the namespace its idempotency keys
255
398
  // live in: the request's own principal, limited to what the connection's
256
399
  // may use, else the connection's. Null is the daemon's owner. The owner
@@ -751,6 +894,13 @@ export class DaemonConnection {
751
894
  );
752
895
  }
753
896
 
897
+ // Checked before any workspace is materialized for the spawn.
898
+ const refusal = adapter.findSpawnRefusal?.() ?? null;
899
+
900
+ if (refusal !== null) {
901
+ throw refusal;
902
+ }
903
+
754
904
  const overrides = parseSpawnOverrides(entry, { model: data.model, effort: data.effort });
755
905
 
756
906
  if (!overrides.ok) {
@@ -797,10 +947,20 @@ export class DaemonConnection {
797
947
  };
798
948
  };
799
949
 
950
+ if (data.replayOnly === true && data.idempotencyKey === undefined) {
951
+ this.sendErr(req.id, 'bad_args', 'replayOnly needs an idempotencyKey');
952
+
953
+ return;
954
+ }
955
+
800
956
  const keyed =
801
957
  data.idempotencyKey === undefined
802
958
  ? null
803
- : { key: data.idempotencyKey, payloadHash: buildPayloadHash(data) };
959
+ : {
960
+ key: data.idempotencyKey,
961
+ payloadHash: buildPayloadHash(data),
962
+ replayOnly: data.replayOnly === true,
963
+ };
804
964
 
805
965
  const spawned = await ctx.spawnSession(plan, keyed, null);
806
966
 
@@ -1217,10 +1377,20 @@ export class DaemonConnection {
1217
1377
 
1218
1378
  const sessionID = parsed.data.session;
1219
1379
 
1380
+ if (parsed.data.replayOnly === true && parsed.data.idempotencyKey === undefined) {
1381
+ this.sendErr(req.id, 'bad_args', 'replayOnly needs an idempotencyKey');
1382
+
1383
+ return;
1384
+ }
1385
+
1220
1386
  const keyed =
1221
1387
  parsed.data.idempotencyKey === undefined
1222
1388
  ? null
1223
- : { key: parsed.data.idempotencyKey, payloadHash: buildPayloadHash(parsed.data) };
1389
+ : {
1390
+ key: parsed.data.idempotencyKey,
1391
+ payloadHash: buildPayloadHash(parsed.data),
1392
+ replayOnly: parsed.data.replayOnly === true,
1393
+ };
1224
1394
 
1225
1395
  const result = await ctx.writeSessionMessage(
1226
1396
  sessionID,
@@ -1398,18 +1568,7 @@ export class DaemonConnection {
1398
1568
  const principal = parsedHello.ok ? (parsedHello.data.principal ?? null) : null;
1399
1569
 
1400
1570
  if (principal !== null) {
1401
- const access = this.ctx.buildTargetAccess(principal);
1402
-
1403
- this.principal = principal;
1404
- this.access = access;
1405
-
1406
- for (const session of this.ctx.collectSessions()) {
1407
- const view = this.ctx.canSeeSession(session.id, access)
1408
- ? this.shownSessions
1409
- : this.withheldSessions;
1410
-
1411
- view.add(session.id);
1412
- }
1571
+ this.setPrincipal(principal);
1413
1572
  }
1414
1573
 
1415
1574
  if (req.v !== PROTOCOL_V) {
@@ -1430,12 +1589,98 @@ export class DaemonConnection {
1430
1589
  return true;
1431
1590
  }
1432
1591
 
1592
+ // A TCP handshake must carry a bearer token the listener holds, checked
1593
+ // before anything else in it, so a peer without one learns nothing about
1594
+ // the daemon. A wrong or missing token, or a principal the config does
1595
+ // not list, gets `unauthorized` and the connection closes. Requests that
1596
+ // arrive while the check runs wait behind it.
1597
+ private applyTCPHello(req: RequestMsg, tcp: TCPPeer): boolean {
1598
+ this.helloed = true;
1599
+
1600
+ this.helloAnswered = (async () => {
1601
+ const fingerprint = await tcp.verifyHandshake(findBearerToken(req.p));
1602
+
1603
+ if (fingerprint === null) {
1604
+ this.sendErr(req.id, 'unauthorized', 'daemon.hello over TCP needs a valid bearer token');
1605
+ this.peer.end();
1606
+
1607
+ return;
1608
+ }
1609
+
1610
+ const parsedHello = parseRequestParams('daemon.hello', req.p);
1611
+
1612
+ if (!parsedHello.ok) {
1613
+ this.sendErr(req.id, 'bad_args', parsedHello.message);
1614
+ this.peer.end();
1615
+
1616
+ return;
1617
+ }
1618
+
1619
+ const principal = parsedHello.data.principal ?? null;
1620
+
1621
+ if (principal !== null && !this.ctx.hasListedPrincipal(principal)) {
1622
+ this.sendErr(
1623
+ req.id,
1624
+ 'unauthorized',
1625
+ `principal '${principal}' is not listed in principals`,
1626
+ );
1627
+
1628
+ this.peer.end();
1629
+
1630
+ return;
1631
+ }
1632
+
1633
+ if (req.v !== PROTOCOL_V) {
1634
+ this.sendErr(
1635
+ req.id,
1636
+ 'protocol_mismatch',
1637
+ `${parsedHello.data.client} speaks protocol v${req.v}, daemon ${this.ctx.build} speaks v${PROTOCOL_V}; restart the daemon so both run the same build`,
1638
+ );
1639
+
1640
+ this.peer.end();
1641
+
1642
+ return;
1643
+ }
1644
+
1645
+ if (principal !== null) {
1646
+ this.setPrincipal(principal);
1647
+ }
1648
+
1649
+ this.fingerprint = fingerprint;
1650
+
1651
+ await this.sendHelloOk(req.id);
1652
+ })();
1653
+
1654
+ this.answerAsync(req.id, () => this.helloAnswered);
1655
+
1656
+ return true;
1657
+ }
1658
+
1659
+ // Makes the connection act as the principal, its view starting as the
1660
+ // sessions the principal may see now, so one that leaves its reach later
1661
+ // is pushed as removed.
1662
+ private setPrincipal(principal: string): void {
1663
+ const access = this.ctx.buildTargetAccess(principal);
1664
+
1665
+ this.principal = principal;
1666
+ this.access = access;
1667
+
1668
+ for (const session of this.ctx.collectSessions()) {
1669
+ const view = this.ctx.canSeeSession(session.id, access)
1670
+ ? this.shownSessions
1671
+ : this.withheldSessions;
1672
+
1673
+ view.add(session.id);
1674
+ }
1675
+ }
1676
+
1433
1677
  private async sendHelloOk(id: number): Promise<void> {
1434
1678
  this.sendOk(id, {
1435
1679
  daemon: this.ctx.build,
1436
1680
  daemonID: this.ctx.daemonID,
1437
1681
  limits: { maxLine: MAX_LINE, maxChunk: MAX_CHUNK },
1438
1682
  features: DAEMON_FEATURES,
1683
+ idempotency: { completedRetentionMs: this.ctx.idempotencyRetentionMs },
1439
1684
  lastUsedAgent: await this.ctx.loadLastUsedAgent(),
1440
1685
  });
1441
1686
  }
@@ -1460,6 +1705,18 @@ export class DaemonConnection {
1460
1705
  }
1461
1706
  }
1462
1707
 
1708
+ // The token of a handshake's `auth: { scheme: "bearer", token }`, or null
1709
+ // when its params hold none.
1710
+ function findBearerToken(p: Readonly<Record<string, unknown>> | undefined): string | null {
1711
+ const auth = p?.['auth'];
1712
+
1713
+ if (!isRecord(auth) || auth['scheme'] !== 'bearer' || typeof auth['token'] !== 'string') {
1714
+ return null;
1715
+ }
1716
+
1717
+ return auth['token'];
1718
+ }
1719
+
1463
1720
  // The session an event belongs to, from its session id or the session it
1464
1721
  // adds, or null for an event of no session.
1465
1722
  function findEventSession(event: EventMsg): SessionID | null {
@@ -122,6 +122,10 @@ export interface TapClient {
122
122
  export interface DaemonContext {
123
123
  readonly build: string;
124
124
  readonly daemonID: DaemonID;
125
+
126
+ // How long the daemon keeps a completed idempotency key, which the
127
+ // handshake announces so a caller knows how long a retry stays deduplicated.
128
+ readonly idempotencyRetentionMs: number;
125
129
  readonly collectSessions: () => SessionDescriptor[];
126
130
 
127
131
  // The directories spawns ran in, most recent first, leaving out each one
@@ -135,6 +139,10 @@ export interface DaemonContext {
135
139
  // The targets a principal may use.
136
140
  readonly buildTargetAccess: (principal: string) => TargetAccess;
137
141
 
142
+ // Whether the config's principals key lists the principal, which a
143
+ // request over TCP must act as.
144
+ readonly hasListedPrincipal: (principal: string) => boolean;
145
+
138
146
  // The target and identity a session is bound to, or null when no
139
147
  // session holds the id.
140
148
  readonly findSessionGrant: (id: SessionID) => TargetGrant | null;