@zgeoff/atc 2.15.1 → 2.16.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.
@@ -0,0 +1,52 @@
1
+ import { BrokerAuthorityError } from './broker-authority-error';
2
+ import type { ImpPort } from './imp-port';
3
+ import { verifyTokenImpAuthority } from './verify-token-imp-authority';
4
+
5
+ /**
6
+ * What a provisioning call is about to touch: the imps under their actual
7
+ * names, as the target's runtime namespace builds them, and every secret
8
+ * the binding grants them.
9
+ */
10
+ export interface BrokerActivation {
11
+ readonly impNames: readonly string[];
12
+ readonly secrets: readonly string[];
13
+ }
14
+
15
+ /**
16
+ * The gate before atc grants or relies on a brokered credential. It reads
17
+ * impd's features, then the token's identity, and writes nothing, so a
18
+ * refusal leaves impd as it was. It rejects unless impd has both grantable
19
+ * tokens and secret rebinds, the token may manage each imp and reaches no
20
+ * imp outside the namespace whose imp names start with the prefix, and the
21
+ * token may grant every bound secret.
22
+ * Whether each secret's rules match the binding is a separate comparison.
23
+ */
24
+ export async function verifyBrokerAuthority(
25
+ port: Pick<ImpPort, 'readFeatures' | 'readIdentity'>,
26
+ activation: BrokerActivation,
27
+ impPrefix: string,
28
+ ): Promise<void> {
29
+ const features = await port.readFeatures();
30
+
31
+ if (!features.grantableTokens || !features.secretRebind) {
32
+ throw new BrokerAuthorityError(
33
+ 'auth_impd_too_old',
34
+ 'impd lacks grantable tokens or secret rebinds; it must be 0.27.0 or later',
35
+ { grantableTokens: features.grantableTokens, secretRebind: features.secretRebind },
36
+ );
37
+ }
38
+
39
+ const identity = await port.readIdentity();
40
+
41
+ verifyTokenImpAuthority(identity, activation.impNames, impPrefix);
42
+
43
+ const missing = activation.secrets.filter((secret) => !identity.grantable.includes(secret));
44
+
45
+ if (missing.length > 0) {
46
+ throw new BrokerAuthorityError(
47
+ 'auth_secret_not_grantable',
48
+ `impd token ${identity.name} cannot grant ${missing.join(', ')}`,
49
+ { token: identity.name, grantable: identity.grantable, missing },
50
+ );
51
+ }
52
+ }
@@ -0,0 +1,44 @@
1
+ import { BrokerAuthorityError } from './broker-authority-error';
2
+ import type { ImpPort, ImpView } from './imp-port';
3
+ import { verifyTokenImpAuthority } from './verify-token-imp-authority';
4
+
5
+ // The imp a binding recorded when atc made it.
6
+ export interface RecordedImp {
7
+ readonly name: string;
8
+ readonly id: string;
9
+ }
10
+
11
+ /**
12
+ * The check before atc destroys a session's imp or revokes its grants.
13
+ * It asks that the token may manage the recorded imp, under imp patterns
14
+ * that stay inside the namespace whose imp names start with the prefix,
15
+ * and that the imp under that
16
+ * name is still the one recorded. It never asks that a secret's rules
17
+ * still match or that every grant is still in place, so a rotated, rebound
18
+ * or deleted secret never stops atc removing access. Resolves to the imp,
19
+ * or to null when impd confirms no imp holds the name, which needs no
20
+ * cleanup; an imp with another id rejects.
21
+ */
22
+ export async function verifyCleanupAuthority(
23
+ port: Pick<ImpPort, 'readIdentity' | 'readImp'>,
24
+ recorded: RecordedImp,
25
+ impPrefix: string,
26
+ ): Promise<ImpView | null> {
27
+ const identity = await port.readIdentity();
28
+
29
+ // Checked first, so impd's answer for the name comes from a token that
30
+ // can see the imp, and a missing imp means it is gone.
31
+ verifyTokenImpAuthority(identity, [recorded.name], impPrefix);
32
+
33
+ const imp = await port.readImp(recorded.name);
34
+
35
+ if (imp !== null && imp.id !== recorded.id) {
36
+ throw new BrokerAuthorityError(
37
+ 'auth_runtime_mismatch',
38
+ `imp ${recorded.name} is no longer the imp atc made`,
39
+ { imp: recorded.name, recordedID: recorded.id, actualID: imp.id },
40
+ );
41
+ }
42
+
43
+ return imp;
44
+ }
@@ -0,0 +1,74 @@
1
+ import { BrokerAuthorityError } from './broker-authority-error';
2
+ import type { ImpIdentity } from './imp-port';
3
+ import { isImpNameAllowed } from './is-imp-name-allowed';
4
+
5
+ /**
6
+ * Rejects unless the token may manage every named imp and reaches no imp
7
+ * outside atc's namespace: scope `manage`, imp patterns rather than none,
8
+ * and each pattern contained in the namespace prefix, so the literal text
9
+ * before its first `*`, or the whole pattern when it has none, starts with
10
+ * the prefix. A token with any pattern beyond the prefix is refused
11
+ * outright, however few names that pattern reaches, never used in place of
12
+ * a contained one. Each name must then match a pattern. The prefix is the
13
+ * literal start of every imp name the target's runtime namespace builds,
14
+ * and the names are the imps the call touches under those built names.
15
+ */
16
+ export function verifyTokenImpAuthority(
17
+ identity: ImpIdentity,
18
+ impNames: readonly string[],
19
+ impPrefix: string,
20
+ ): void {
21
+ assertImpPrefix(impPrefix);
22
+
23
+ if (identity.scope !== 'manage') {
24
+ throw new BrokerAuthorityError(
25
+ 'auth_token_scope',
26
+ `impd token ${identity.name} cannot manage imps`,
27
+ {
28
+ token: identity.name,
29
+ scope: identity.scope,
30
+ },
31
+ );
32
+ }
33
+
34
+ const patterns = identity.imps;
35
+
36
+ if (patterns === null || !patterns.every((pattern) => isPatternContained(pattern, impPrefix))) {
37
+ throw new BrokerAuthorityError(
38
+ 'auth_token_too_broad',
39
+ `impd token ${identity.name} can reach imps outside atc's namespace, whose imp names start with ${impPrefix}`,
40
+ {
41
+ token: identity.name,
42
+ imps: patterns,
43
+ offending:
44
+ patterns === null
45
+ ? null
46
+ : patterns.filter((pattern) => !isPatternContained(pattern, impPrefix)),
47
+ },
48
+ );
49
+ }
50
+
51
+ const outside = impNames.filter((name) => !isImpNameAllowed(patterns, name));
52
+
53
+ if (outside.length > 0) {
54
+ throw new BrokerAuthorityError(
55
+ 'auth_imp_out_of_scope',
56
+ `impd token ${identity.name} cannot manage ${outside.join(', ')}`,
57
+ { token: identity.name, imps: patterns, outside },
58
+ );
59
+ }
60
+ }
61
+
62
+ // An empty prefix would contain every pattern, `*` included.
63
+ function assertImpPrefix(impPrefix: string): void {
64
+ if (impPrefix === '') {
65
+ throw new Error('the imp name prefix of a runtime namespace must not be empty');
66
+ }
67
+ }
68
+
69
+ // Every name a pattern matches starts with the text before its first `*`.
70
+ function isPatternContained(pattern: string, impPrefix: string): boolean {
71
+ const [literal = ''] = pattern.split('*');
72
+
73
+ return literal.startsWith(impPrefix);
74
+ }
@@ -4,10 +4,12 @@ import { isRecord, sendReport } from './shared/report';
4
4
  /**
5
5
  * Runs as a hook inside wrangled sessions. Reads the hook event from stdin
6
6
  * (Claude snake_case keys or Grok camelCase keys) and forwards a PascalCase
7
- * event name to the atc unix socket. Always exits 0 so it never blocks the
8
- * session it reports on.
7
+ * event name to the atc unix socket, with the agent id the hook command
8
+ * gave it, so the daemon can tell a nested harness's report from the
9
+ * session's own. Always exits 0 so it never blocks the session it reports
10
+ * on.
9
11
  */
10
- export async function runHookReport(): Promise<void> {
12
+ export async function runHookReport(agent: string): Promise<void> {
11
13
  const sock = process.env['ATC_SOCKET'];
12
14
  const atcId = process.env['ATC_SESSION_ID'];
13
15
 
@@ -26,7 +28,7 @@ export async function runHookReport(): Promise<void> {
26
28
 
27
29
  const rawName = payload['hook_event_name'] ?? payload['hookEventName'];
28
30
  const event = typeof rawName === 'string' ? normalizeHookEventName(rawName) : rawName;
29
- const line = `${JSON.stringify({ atcId, event, payload })}\n`;
31
+ const line = `${JSON.stringify({ atcId, ...(agent === '' ? {} : { agent }), event, payload })}\n`;
30
32
 
31
33
  await sendReport(sock, line, 2000);
32
34
  }
@@ -1,11 +1,15 @@
1
+ import type { AgentID } from '../shared/agent-id';
1
2
  import type { SessionID } from '../shared/session-id';
2
3
 
3
4
  /**
4
- * One line a hook reporter sends: the atc session it reports on, the agent's
5
- * hook event name, and the hook's payload as the agent gave it.
5
+ * One line a hook reporter sends: the atc session it reports on, the agent
6
+ * whose hook command sent it, the agent's hook event name, and the hook's
7
+ * payload as the agent gave it. The agent is absent on a line from a hook
8
+ * command that carries none.
6
9
  */
7
10
  export interface HookEvent {
8
11
  atcId: SessionID;
12
+ agent?: AgentID;
9
13
  event: string;
10
14
  payload: Record<string, unknown>;
11
15
  }
package/src/statusline.ts CHANGED
@@ -8,10 +8,11 @@ import { isRecord, sendReport } from './shared/report';
8
8
  /**
9
9
  * Runs as the statusLine command injected into wrangled sessions. Chains the
10
10
  * user's own statusline (from ~/.claude/settings.json), appends the atc fleet
11
- * segment, and heartbeats the session id back to the atc socket. Always
12
- * exits 0 so it never breaks the session it renders for.
11
+ * segment, and heartbeats the session id back to the atc socket, with the
12
+ * agent id the command gave it. Always exits 0 so it never breaks the
13
+ * session it renders for.
13
14
  */
14
- export async function runStatusline(): Promise<void> {
15
+ export async function runStatusline(agent: string): Promise<void> {
15
16
  const raw = await new Response(Bun.stdin.stream()).text();
16
17
 
17
18
  const sock = process.env['ATC_SOCKET'];
@@ -28,7 +29,7 @@ export async function runStatusline(): Promise<void> {
28
29
  }
29
30
  } catch {}
30
31
 
31
- const line = `${JSON.stringify({ atcId, event: 'Statusline', payload })}\n`;
32
+ const line = `${JSON.stringify({ atcId, ...(agent === '' ? {} : { agent }), event: 'Statusline', payload })}\n`;
32
33
 
33
34
  await sendReport(sock, line, 500);
34
35
  }
@@ -128,7 +129,8 @@ async function readOwnSegment(sock: string): Promise<string> {
128
129
  }
129
130
 
130
131
  // A user statusline that is atc's own injected command would chain into
131
- // itself; the injected command always ends with the bare subcommand.
132
+ // itself; the injected command ends with the bare subcommand, or with the
133
+ // subcommand and its agent flag.
132
134
  function isSelfCommand(cmd: string): boolean {
133
- return cmd.includes('statusline.ts') || cmd.trimEnd().endsWith(' statusline');
135
+ return cmd.includes('statusline.ts') || /\sstatusline(?:\s+--agent\s.*)?$/u.test(cmd);
134
136
  }
@@ -56,6 +56,8 @@ interface EventsTable {
56
56
 
57
57
  interface SpawnHistoryTable {
58
58
  cwd: string;
59
+ target: string;
60
+ target_identity: string;
59
61
  last_spawn: number;
60
62
  }
61
63
 
@@ -152,8 +154,9 @@ export interface StateStoreSchema {
152
154
  // agent_session_id under its Claude-era name plus name and cwd, and each
153
155
  // later step adds one column the daemon grew to depend on, until the
154
156
  // rebuild that keys the table by the atc session id. events later gains
155
- // columns of its own, as does messages, while spawn_history and prefs have
156
- // carried one shape since they were added.
157
+ // columns of its own, as does messages, spawn_history is rebuilt to key each
158
+ // directory by the target it was spawned on, and prefs has carried one shape
159
+ // since it was added.
157
160
  const MIGRATIONS: Record<string, Migration> = {
158
161
  '001_create_initial_schema': {
159
162
  async up(db: Kysely<StateStoreSchema>) {
@@ -446,6 +449,29 @@ const MIGRATIONS: Record<string, Migration> = {
446
449
  await db.schema.alterTable('events').addColumn('report_text', 'text').execute();
447
450
  },
448
451
  },
452
+ '024_rebuild_spawn_history_keyed_by_target': {
453
+ async up(db: Kysely<StateStoreSchema>) {
454
+ // The ledger records this step only after it returns, so a crash in
455
+ // between leaves a rebuilt table behind a ledger that still lacks it.
456
+ const columns = await sql<ColumnInfoRow>`PRAGMA table_info(spawn_history)`.execute(db);
457
+
458
+ if (columns.rows.some((column) => column.name === 'target')) {
459
+ return;
460
+ }
461
+
462
+ await sql`BEGIN IMMEDIATE`.execute(db);
463
+
464
+ try {
465
+ await updateSpawnHistoryKeyToTarget(db);
466
+
467
+ await sql`COMMIT`.execute(db);
468
+ } catch (error) {
469
+ await sql`ROLLBACK`.execute(db);
470
+
471
+ throw error;
472
+ }
473
+ },
474
+ },
449
475
  };
450
476
 
451
477
  const PROVIDER: MigrationProvider = {
@@ -457,8 +483,9 @@ const PROVIDER: MigrationProvider = {
457
483
  * `Migrator`, one additive step at a time. A database from before the
458
484
  * ladder existed is recognized at whichever shape it stopped at by a
459
485
  * baselining pass that records the steps its columns already satisfy, so
460
- * only what is genuinely missing runs. One step rebuilds the fleet table
461
- * under a new key and carries every row across; no step drops a row.
486
+ * only what is genuinely missing runs. Two steps rebuild a table under a
487
+ * new key, the fleet and the spawn history, and carry every row across; no
488
+ * step drops a row.
462
489
  */
463
490
  export async function runMigrations(db: Kysely<StateStoreSchema>): Promise<void> {
464
491
  // A baselined step can land out of order relative to a step that turned
@@ -652,6 +679,30 @@ interface ColumnInfoRow {
652
679
  type: string;
653
680
  }
654
681
 
682
+ // The identity of the local target with no options. Every spawn from before
683
+ // spawns recorded their target ran there.
684
+ const LEGACY_SPAWN_TARGET_IDENTITY = 'local-pty:44136fa355b3678a';
685
+
686
+ async function updateSpawnHistoryKeyToTarget(db: Kysely<StateStoreSchema>): Promise<void> {
687
+ await sql`
688
+ CREATE TABLE spawn_history_rebuilt (
689
+ cwd TEXT NOT NULL,
690
+ target TEXT NOT NULL,
691
+ target_identity TEXT NOT NULL,
692
+ last_spawn INTEGER NOT NULL,
693
+ PRIMARY KEY (cwd, target, target_identity)
694
+ )
695
+ `.execute(db);
696
+
697
+ await sql`
698
+ INSERT INTO spawn_history_rebuilt (cwd, target, target_identity, last_spawn)
699
+ SELECT cwd, 'local', ${LEGACY_SPAWN_TARGET_IDENTITY}, last_spawn FROM spawn_history
700
+ `.execute(db);
701
+
702
+ await sql`DROP TABLE spawn_history`.execute(db);
703
+ await sql`ALTER TABLE spawn_history_rebuilt RENAME TO spawn_history`.execute(db);
704
+ }
705
+
655
706
  async function collectFleetColumns(db: Kysely<StateStoreSchema>): Promise<ReadonlySet<string>> {
656
707
  const result = await sql<ColumnInfoRow>`PRAGMA table_info(fleet)`.execute(db);
657
708
 
@@ -64,6 +64,27 @@ export interface StoredEvent {
64
64
  readonly label?: string;
65
65
  }
66
66
 
67
+ // The target a spawn ran on: its name and the identity the spawned session
68
+ // was bound to there.
69
+ interface SpawnTarget {
70
+ readonly target: string;
71
+ readonly targetIdentity: string;
72
+ }
73
+
74
+ // A directory a spawn ran in and the target it ran on.
75
+ export interface SpawnDir {
76
+ readonly cwd: string;
77
+ readonly grant: SpawnTarget;
78
+ }
79
+
80
+ /**
81
+ * Another message one turn answered, and the atc id it was sent to.
82
+ */
83
+ export interface TurnSibling {
84
+ readonly id: MessageID;
85
+ readonly atcID: SessionID;
86
+ }
87
+
67
88
  /**
68
89
  * One report as the trail holds it. A report recorded before the trail kept
69
90
  * whole texts has only its preview, so its text is that preview and
@@ -513,24 +534,38 @@ export class StateStore {
513
534
  return row?.ts === null || row?.ts === undefined ? null : Date.parse(row.ts);
514
535
  }
515
536
 
516
- async recordSpawnDir(cwd: string): Promise<void> {
537
+ async recordSpawnDir(cwd: string, grant: SpawnTarget): Promise<void> {
517
538
  await this.db
518
539
  .insertInto('spawn_history')
519
- .values({ cwd, last_spawn: Date.now() })
540
+ .values({
541
+ cwd,
542
+ target: grant.target,
543
+ target_identity: grant.targetIdentity,
544
+ last_spawn: Date.now(),
545
+ })
520
546
  .onConflict((oc) =>
521
- oc.column('cwd').doUpdateSet((eb) => ({ last_spawn: eb.ref('excluded.last_spawn') })),
547
+ oc
548
+ .columns(['cwd', 'target', 'target_identity'])
549
+ .doUpdateSet((eb) => ({ last_spawn: eb.ref('excluded.last_spawn') })),
522
550
  )
523
551
  .execute();
524
552
  }
525
553
 
526
- async collectSpawnDirs(): Promise<string[]> {
554
+ /**
555
+ * Each directory a spawn ran in, with the target it ran on, most recent
556
+ * first. A directory spawned on several targets has one entry for each.
557
+ */
558
+ async collectSpawnDirs(): Promise<SpawnDir[]> {
527
559
  const rows = await this.db
528
560
  .selectFrom('spawn_history')
529
- .select('cwd')
561
+ .select(['cwd', 'target', 'target_identity'])
530
562
  .orderBy('last_spawn', 'desc')
531
563
  .execute();
532
564
 
533
- return rows.map((row) => row.cwd);
565
+ return rows.map((row) => ({
566
+ cwd: row.cwd,
567
+ grant: { target: row.target, targetIdentity: row.target_identity },
568
+ }));
534
569
  }
535
570
 
536
571
  async loadLastUsedAgent(): Promise<AgentID> {
@@ -607,7 +642,7 @@ export class StateStore {
607
642
 
608
643
  // The other messages of the same session the given message's turn
609
644
  // answered, oldest first; none when the message has no turn.
610
- async collectTurnSiblings(record: MessageRecord): Promise<MessageID[]> {
645
+ async collectTurnSiblings(record: MessageRecord): Promise<TurnSibling[]> {
611
646
  if (record.turn === undefined) {
612
647
  return [];
613
648
  }
@@ -619,7 +654,7 @@ export class StateStore {
619
654
 
620
655
  const rows = await this.db
621
656
  .selectFrom('messages')
622
- .select('id')
657
+ .select(['id', 'atc_id'])
623
658
  .where('turn_id', '=', record.turn)
624
659
  .where('id', '!=', record.id)
625
660
  .where((eb) => buildOwnerFilter(eb, owner))
@@ -627,7 +662,7 @@ export class StateStore {
627
662
  .orderBy(sql`rowid`, 'asc')
628
663
  .execute();
629
664
 
630
- return rows.map((row) => toMessageID(row.id));
665
+ return rows.map((row) => ({ id: toMessageID(row.id), atcID: toSessionID(row.atc_id) }));
631
666
  }
632
667
 
633
668
  async updateMessageDelivered(