@zgeoff/atc 3.4.1 → 3.5.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 (86) hide show
  1. package/package.json +1 -1
  2. package/src/agents/agent-adapter.ts +4 -0
  3. package/src/agents/build-claude-query-options.ts +1 -0
  4. package/src/agents/build-headless-env.ts +4 -0
  5. package/src/agents/claude-adapter.ts +4 -3
  6. package/src/agents/gateway-adapter.ts +2 -2
  7. package/src/agents/plan-claude-line-input.ts +44 -0
  8. package/src/client/daemon-client.ts +6 -1
  9. package/src/collect-restart-env.ts +6 -3
  10. package/src/daemon/build-scoped-context.ts +2 -0
  11. package/src/daemon/check-session-scope.ts +235 -0
  12. package/src/daemon/daemon-connection.ts +74 -0
  13. package/src/daemon/daemon-context.ts +21 -0
  14. package/src/daemon/daemon.ts +21 -0
  15. package/src/daemon/find-host-branch.ts +28 -0
  16. package/src/daemon/materialize-workspace.ts +6 -1
  17. package/src/daemon/merge-recorded-scope.ts +50 -0
  18. package/src/daemon/published-records.ts +269 -0
  19. package/src/daemon/request-access-classes.ts +1 -0
  20. package/src/daemon/run-host-git.ts +52 -0
  21. package/src/daemon/sessions.ts +185 -53
  22. package/src/daemon/start-headless-turn.ts +3 -0
  23. package/src/federation/id-rules.ts +11 -2
  24. package/src/mcp/mcp-tools.ts +51 -1
  25. package/src/mcp/require-daemon-features.ts +1 -0
  26. package/src/mcp/run-tool.ts +14 -0
  27. package/src/protocol/daemon-features.ts +5 -0
  28. package/src/protocol/parse-declared-scope.ts +158 -0
  29. package/src/protocol/protocol.ts +1 -0
  30. package/src/protocol/published-record.ts +62 -0
  31. package/src/protocol/request-param-schemas.ts +16 -0
  32. package/src/store/parse-published-record.ts +59 -0
  33. package/src/store/run-migrations.ts +23 -0
  34. package/src/store/state-store.ts +32 -0
  35. package/src/test-utils/build-mock-published-record.ts +39 -0
  36. package/src/test-utils/build-stub-brokered-agent-adapter.ts +3 -1
  37. package/src/test-utils/build-stub-daemon-context.ts +2 -0
  38. package/src/test-utils/build-stub-daemon-requests.ts +3 -3
  39. package/src/test-utils/build-stub-harness-relay.ts +2 -2
  40. package/src/test-utils/build-stub-held-gh.ts +5 -7
  41. package/src/test-utils/build-stub-held-inbox-source.ts +2 -2
  42. package/src/test-utils/build-stub-held-zoxide.ts +4 -6
  43. package/src/test-utils/build-stub-host-hold.ts +7 -6
  44. package/src/test-utils/build-stub-launch-admission.ts +20 -0
  45. package/src/test-utils/build-stub-pregranted-broker-host.ts +22 -0
  46. package/src/test-utils/build-stub-terminal.ts +3 -3
  47. package/src/test-utils/create-git-fixture.ts +2 -2
  48. package/src/test-utils/create-stub-claude.ts +3 -1
  49. package/src/test-utils/create-stub-imp-port.ts +46 -37
  50. package/src/test-utils/create-stub-systemd.ts +6 -6
  51. package/src/test-utils/isolate-home.ts +8 -5
  52. package/src/test-utils/read-query-plan.ts +10 -6
  53. package/src/test-utils/register-test-cleanup.ts +10 -10
  54. package/src/test-utils/{run-legacy-daemon.ts → run-stub-legacy-daemon.ts} +1 -1
  55. package/src/test-utils/setup-mcp-home.ts +7 -6
  56. package/src/test-utils/setup-mcp-http.ts +6 -6
  57. package/src/test-utils/setup-temp-dir.ts +4 -10
  58. package/src/test-utils/start-cut-proxy.ts +2 -4
  59. package/src/test-utils/start-daemon-process.ts +13 -13
  60. package/src/test-utils/start-git-http-server.ts +2 -4
  61. package/src/test-utils/start-mcp-stdio.ts +5 -5
  62. package/src/test-utils/start-stub-answering-listener.ts +3 -3
  63. package/src/test-utils/start-stub-closing-listener.ts +3 -3
  64. package/src/test-utils/start-stub-dropping-daemon.ts +2 -3
  65. package/src/test-utils/start-stub-impd-info.ts +3 -7
  66. package/src/test-utils/start-stub-impd.ts +3 -5
  67. package/src/test-utils/start-stub-legacy-daemon.ts +2 -3
  68. package/src/test-utils/start-stub-mcp-server.ts +3 -5
  69. package/src/test-utils/start-stub-recording-listener.ts +3 -3
  70. package/src/test-utils/start-stub-refusing-listener.ts +3 -3
  71. package/src/test-utils/start-stub-reporter-socket.ts +3 -9
  72. package/src/test-utils/start-stub-restore-daemon.ts +2 -7
  73. package/src/test-utils/start-stub-session-bridge.ts +3 -9
  74. package/src/test-utils/start-stub-silent-server.ts +3 -6
  75. package/src/test-utils/start-stub-source-daemon.ts +3 -3
  76. package/src/test-utils/start-stub-stalled-client.ts +3 -3
  77. package/src/test-utils/start-stub-stalled-listener.ts +3 -3
  78. package/src/test-utils/start-stub-tcp-daemon.ts +3 -3
  79. package/src/test-utils/start-stub-telemetry-collector.ts +2 -2
  80. package/src/test-utils/start-stub-unanswering-listener.ts +3 -3
  81. package/src/test-utils/start-stub-unanswering-unix-listener.ts +3 -3
  82. package/src/test-utils/start-test-daemon.ts +62 -18
  83. package/src/test-utils/start-tui-harness.ts +17 -10
  84. package/src/test-utils/subscribe-to-socket-lines.ts +5 -9
  85. package/src/test-utils/{can-bind-addresses.ts → try-bind-addresses.ts} +4 -3
  86. /package/src/test-utils/{run-source-daemon.ts → run-stub-source-daemon.ts} +0 -0
@@ -0,0 +1,158 @@
1
+ import { z } from 'zod';
2
+
3
+ /**
4
+ * The scope a caller declares for a session, before atc checks any entry
5
+ * against the session's host.
6
+ */
7
+ export interface DeclaredScope {
8
+ readonly worktrees: readonly { readonly path: string }[];
9
+ readonly branches: readonly { readonly name: string; readonly repo?: string }[];
10
+ readonly pullRequests: readonly { readonly number: number; readonly repo?: string }[];
11
+ }
12
+
13
+ export type ParsedDeclaredScope =
14
+ | { readonly ok: true; readonly scope: DeclaredScope }
15
+ | { readonly ok: false; readonly entry: string; readonly message: string };
16
+
17
+ // An absolute path without `.` or `..` segments or control characters, so
18
+ // the host resolves it to the directory it holds as written.
19
+ const ABSOLUTE_PATH = z
20
+ .string({ error: 'a path must be a string' })
21
+ .startsWith('/', 'a path must be absolute')
22
+ .max(4096, 'a path must be at most 4096 characters')
23
+ .refine(
24
+ (path) => !path.split('/').some((segment) => segment === '.' || segment === '..'),
25
+ 'a path must not hold . or .. segments',
26
+ )
27
+ .refine((path) => !hasControlCharacter(path), 'a path must not hold control characters');
28
+
29
+ const BRANCH_NAME = z
30
+ .string({ error: 'a branch name must be a string' })
31
+ .min(1, 'a branch name must not be empty')
32
+ .max(255, 'a branch name must be at most 255 characters')
33
+ .refine((name) => !name.startsWith('-'), 'a branch name must not start with -')
34
+ .refine((name) => !hasControlCharacter(name), 'a branch name must not hold control characters');
35
+
36
+ // A GitHub repository as `owner/name`.
37
+ const GITHUB_REPO = z
38
+ .string({ error: 'a pull request repo must be a string' })
39
+ .regex(/^[\w.-]+\/[\w.-]+$/u, 'a pull request repo is a GitHub repository as owner/name');
40
+
41
+ // How many entries one list of a declared scope holds at most.
42
+ const MAX_ENTRIES = 64;
43
+
44
+ const WORKTREE_ENTRY = z.strictObject(
45
+ { path: ABSOLUTE_PATH },
46
+ { error: buildEntryError('a worktree holds only path') },
47
+ );
48
+
49
+ const BRANCH_ENTRY = z.strictObject(
50
+ { name: BRANCH_NAME, repo: ABSOLUTE_PATH.optional() },
51
+ { error: buildEntryError('a branch holds only name and repo') },
52
+ );
53
+
54
+ const PULL_REQUEST_NUMBER = z
55
+ .number({ error: 'a pull request number must be a number' })
56
+ .int('a pull request number must be a whole number')
57
+ .positive('a pull request number must be positive');
58
+
59
+ const PULL_REQUEST_ENTRY = z.strictObject(
60
+ { number: PULL_REQUEST_NUMBER, repo: GITHUB_REPO.optional() },
61
+ { error: buildEntryError('a pull request holds only number and repo') },
62
+ );
63
+
64
+ const SCOPE = z.strictObject(
65
+ {
66
+ worktrees: z
67
+ .array(WORKTREE_ENTRY, { error: 'worktrees must be a list' })
68
+ .max(MAX_ENTRIES, `worktrees holds at most ${MAX_ENTRIES} entries`)
69
+ .optional(),
70
+ branches: z
71
+ .array(BRANCH_ENTRY, { error: 'branches must be a list' })
72
+ .max(MAX_ENTRIES, `branches holds at most ${MAX_ENTRIES} entries`)
73
+ .optional(),
74
+ pullRequests: z
75
+ .array(PULL_REQUEST_ENTRY, { error: 'pullRequests must be a list' })
76
+ .max(MAX_ENTRIES, `pullRequests holds at most ${MAX_ENTRIES} entries`)
77
+ .optional(),
78
+ },
79
+ {
80
+ error: (issue) =>
81
+ issue.code === 'unrecognized_keys'
82
+ ? 'a scope holds only worktrees, branches, and pullRequests'
83
+ : 'scope must be an object',
84
+ },
85
+ );
86
+
87
+ /**
88
+ * Reads a declared scope off the wire. Every object is closed, so a key the
89
+ * format does not define refuses the scope, and a refusal holds the entry
90
+ * at fault, such as `scope.branches[1]`.
91
+ */
92
+ export function parseDeclaredScope(raw: unknown): ParsedDeclaredScope {
93
+ const result = SCOPE.safeParse(raw);
94
+
95
+ if (result.success) {
96
+ return {
97
+ ok: true,
98
+ scope: {
99
+ worktrees: result.data.worktrees ?? [],
100
+ branches: (result.data.branches ?? []).map((branch) =>
101
+ branch.repo === undefined
102
+ ? { name: branch.name }
103
+ : { name: branch.name, repo: branch.repo },
104
+ ),
105
+ pullRequests: (result.data.pullRequests ?? []).map((pr) =>
106
+ pr.repo === undefined ? { number: pr.number } : { number: pr.number, repo: pr.repo },
107
+ ),
108
+ },
109
+ };
110
+ }
111
+
112
+ const [issue] = result.error.issues;
113
+
114
+ if (issue === undefined) {
115
+ return { ok: false, entry: 'scope', message: 'scope is invalid' };
116
+ }
117
+
118
+ const entry = formatEntry({
119
+ path: issue.path,
120
+ unrecognized: issue.code === 'unrecognized_keys' ? (issue.keys[0] ?? '') : undefined,
121
+ });
122
+
123
+ return { ok: false, entry, message: `${entry} is invalid: ${issue.message}` };
124
+ }
125
+
126
+ function hasControlCharacter(text: string): boolean {
127
+ return /\p{Cc}/u.test(text);
128
+ }
129
+
130
+ // Where an issue lies: its path, and the first key a closed object refused
131
+ // when it refused one.
132
+ interface IssueLocation {
133
+ readonly path: readonly PropertyKey[];
134
+ readonly unrecognized?: string | undefined;
135
+ }
136
+
137
+ // The entry an issue lies in: the list and index it sits under, or the key
138
+ // a closed object refused.
139
+ function formatEntry(issue: IssueLocation): string {
140
+ const [list, index] = issue.path;
141
+
142
+ if (issue.unrecognized !== undefined && list === undefined) {
143
+ return `scope.${issue.unrecognized}`;
144
+ }
145
+
146
+ if (typeof list !== 'string') {
147
+ return 'scope';
148
+ }
149
+
150
+ return typeof index === 'number' ? `scope.${list}[${index}]` : `scope.${list}`;
151
+ }
152
+
153
+ // The message for an entry that is not an object, or that holds a key its
154
+ // kind does not define.
155
+ function buildEntryError(unrecognized: string): (issue: Readonly<{ code?: string }>) => string {
156
+ return (issue) =>
157
+ issue.code === 'unrecognized_keys' ? unrecognized : 'an entry must be an object';
158
+ }
@@ -46,6 +46,7 @@ const ERROR_CODES = [
46
46
  'workspace_overlap',
47
47
  'transfer_failed',
48
48
  'workspace_mismatch',
49
+ 'scope_invalid',
49
50
  'github_unavailable',
50
51
  'host_unavailable',
51
52
  'auth_not_configured',
@@ -0,0 +1,62 @@
1
+ /**
2
+ * The record atc publishes for one session: what atc vouches for about it,
3
+ * in the format the session record doc defines as atc's contract. A reader
4
+ * ignores the keys it does not know, so a field added within a version
5
+ * breaks none.
6
+ */
7
+ export interface PublishedRecord {
8
+ readonly format: 'atc.session-record';
9
+ readonly version: 1;
10
+ readonly session: string;
11
+ readonly daemonID: string;
12
+ readonly target: string;
13
+
14
+ // Starts at 1 and grows by one with each change to the record.
15
+ readonly revision: number;
16
+
17
+ // When atc last changed the record, as an ISO 8601 UTC time.
18
+ readonly updatedAt: string;
19
+ readonly scope: RecordedScope;
20
+ }
21
+
22
+ export interface RecordedScope {
23
+ readonly workspace: RecordedWorkspace;
24
+ readonly worktrees: readonly RecordedWorktree[];
25
+ readonly branches: readonly RecordedBranch[];
26
+ readonly pullRequests: readonly RecordedPullRequest[];
27
+ }
28
+
29
+ /**
30
+ * The directory the session runs in and its branch, null for a detached
31
+ * checkout or a directory outside git. The repository URL and the commit
32
+ * are those of a materialized workspace, null for a directory that runs as
33
+ * it stands.
34
+ */
35
+ interface RecordedWorkspace {
36
+ readonly path: string;
37
+ readonly branch: string | null;
38
+ readonly repoURL: string | null;
39
+ readonly sha: string | null;
40
+ }
41
+
42
+ export interface RecordedWorktree {
43
+ readonly path: string;
44
+ readonly branch: string | null;
45
+ }
46
+
47
+ export interface RecordedBranch {
48
+ readonly name: string;
49
+
50
+ // The repository directory on the session's host the branch exists in.
51
+ readonly repo: string;
52
+ }
53
+
54
+ export interface RecordedPullRequest {
55
+ // The GitHub repository the pull request belongs to, as `owner/name`.
56
+ readonly repo: string;
57
+ readonly number: number;
58
+ readonly url: string;
59
+
60
+ // The pull request's head branch.
61
+ readonly branch: string;
62
+ }
@@ -112,6 +112,13 @@ export const REQUEST_PARAM_SCHEMAS = {
112
112
  .string({ error: 'daemon.hello principal must be a non-empty string' })
113
113
  .min(1, 'daemon.hello principal must be a non-empty string')
114
114
  .optional(),
115
+
116
+ // The atc session the client runs inside, which a client started in a
117
+ // session's environment sends; absent for a client outside any session.
118
+ session: z.preprocess(
119
+ (v) => (typeof v === 'string' && v !== '' ? v : undefined),
120
+ z.string().transform(toSessionID).optional(),
121
+ ),
115
122
  }),
116
123
  'daemon.ping': z.object({}),
117
124
  'daemon.quit': z.object({}),
@@ -196,6 +203,11 @@ export const REQUEST_PARAM_SCHEMAS = {
196
203
  .boolean({ error: 'session.spawn trustClonedWorkspace must be a boolean' })
197
204
  .optional(),
198
205
 
206
+ // The worktrees, branches, and pull requests the session may touch
207
+ // beyond its own workspace, which the daemon checks entry by entry and
208
+ // refuses as scope_invalid.
209
+ scope: z.unknown().optional(),
210
+
199
211
  // The session the new one is a sub-session of; absent or empty spawns a
200
212
  // top-level session.
201
213
  parent: z.preprocess(
@@ -225,6 +237,10 @@ export const REQUEST_PARAM_SCHEMAS = {
225
237
  // session's host, or bind that host to its agent's current selection.
226
238
  'session.auth.revoke': SESSION_DEFAULTED,
227
239
  'session.auth.rebind': SESSION_DEFAULTED,
240
+
241
+ // Adds checked entries to a session's published record, refused for the
242
+ // session itself and for any session it is a sub-session of.
243
+ 'session.scope.add': SESSION_DEFAULTED.extend({ scope: z.unknown() }),
228
244
  'session.update': SESSION_DEFAULTED.extend({
229
245
  name: buildOptionalString(),
230
246
  pinned: buildOptionalBoolean(),
@@ -0,0 +1,59 @@
1
+ import { z } from 'zod';
2
+ import type { PublishedRecord } from '../protocol/published-record';
3
+
4
+ const NULLABLE_STRING = z.string().nullable();
5
+
6
+ const WORKSPACE_SCHEMA = z.object({
7
+ path: z.string(),
8
+ branch: NULLABLE_STRING,
9
+ repoURL: NULLABLE_STRING,
10
+ sha: NULLABLE_STRING,
11
+ });
12
+
13
+ const WORKTREE_SCHEMA = z.object({ path: z.string(), branch: NULLABLE_STRING });
14
+ const BRANCH_SCHEMA = z.object({ name: z.string(), repo: z.string() });
15
+
16
+ const PULL_REQUEST_SCHEMA = z.object({
17
+ repo: z.string(),
18
+ number: z.number(),
19
+ url: z.string(),
20
+ branch: z.string(),
21
+ });
22
+
23
+ const SCOPE_SCHEMA = z.object({
24
+ workspace: WORKSPACE_SCHEMA,
25
+ worktrees: z.array(WORKTREE_SCHEMA),
26
+ branches: z.array(BRANCH_SCHEMA),
27
+ pullRequests: z.array(PULL_REQUEST_SCHEMA),
28
+ });
29
+
30
+ const REVISION = z.number().int().positive();
31
+
32
+ const PUBLISHED_RECORD_SCHEMA = z.object({
33
+ format: z.literal('atc.session-record'),
34
+ version: z.literal(1),
35
+ session: z.string(),
36
+ daemonID: z.string(),
37
+ target: z.string(),
38
+ revision: REVISION,
39
+ updatedAt: z.string(),
40
+ scope: SCOPE_SCHEMA,
41
+ });
42
+
43
+ /**
44
+ * Reads a published record back from the JSON the state store holds; null
45
+ * for text that is not a version 1 record.
46
+ */
47
+ export function parsePublishedRecord(text: string): PublishedRecord | null {
48
+ let raw: unknown;
49
+
50
+ try {
51
+ raw = JSON.parse(text);
52
+ } catch {
53
+ return null;
54
+ }
55
+
56
+ const parsed = PUBLISHED_RECORD_SCHEMA.safeParse(raw);
57
+
58
+ return parsed.success ? parsed.data : null;
59
+ }
@@ -154,6 +154,16 @@ interface RuntimeAuthGrantTable {
154
154
  updated_at: number;
155
155
  }
156
156
 
157
+ // One session's published record, keyed by the session.
158
+ interface PublishedRecordTable {
159
+ session_id: string;
160
+
161
+ // The record as JSON, in the format its version defines.
162
+ record: string;
163
+ revision: number;
164
+ updated_at: number;
165
+ }
166
+
157
167
  interface MessagesTable {
158
168
  id: string;
159
169
  atc_id: string;
@@ -186,6 +196,7 @@ export interface StateStoreSchema {
186
196
  workspace_materialization: WorkspaceMaterializationTable;
187
197
  runtime_auth_binding: RuntimeAuthBindingTable;
188
198
  runtime_auth_grant: RuntimeAuthGrantTable;
199
+ published_record: PublishedRecordTable;
189
200
  }
190
201
 
191
202
  // Every shape the fleet table has shipped with: the oldest carries only
@@ -557,6 +568,18 @@ const MIGRATIONS: Record<string, Migration> = {
557
568
  .execute();
558
569
  },
559
570
  },
571
+ '027_create_published_record': {
572
+ async up(db: Kysely<StateStoreSchema>) {
573
+ await db.schema
574
+ .createTable('published_record')
575
+ .ifNotExists()
576
+ .addColumn('session_id', 'text', (c) => c.primaryKey())
577
+ .addColumn('record', 'text', (c) => c.notNull())
578
+ .addColumn('revision', 'integer', (c) => c.notNull())
579
+ .addColumn('updated_at', 'integer', (c) => c.notNull())
580
+ .execute();
581
+ },
582
+ },
560
583
  };
561
584
 
562
585
  const PROVIDER: MigrationProvider = {
@@ -13,6 +13,7 @@ import type {
13
13
  import type { AdapterEvent } from '../protocol/adapter-event';
14
14
  import { DaemonError } from '../protocol/daemon-error';
15
15
  import type { HookEvent } from '../protocol/hook-event';
16
+ import type { PublishedRecord } from '../protocol/published-record';
16
17
  import type { AgentID } from '../shared/agent-id';
17
18
  import type { AgentSessionID } from '../shared/agent-session-id';
18
19
  import { BunSqliteDriver } from '../shared/bun-sqlite-driver';
@@ -29,6 +30,7 @@ import type { FleetEntry, FleetEntryUpdate, LegacyFleetEntry } from './fleet-ent
29
30
  import type { EffectTarget, IdempotencyClaim, IdempotencyRecord } from './idempotency-record';
30
31
  import type { MessageOwner } from './message-owner';
31
32
  import type { MessageRecord } from './message-record';
33
+ import { parsePublishedRecord } from './parse-published-record';
32
34
  import { runMigrations } from './run-migrations';
33
35
  import type { StateStoreSchema } from './run-migrations';
34
36
  import type {
@@ -1003,6 +1005,36 @@ export class StateStore {
1003
1005
  .execute();
1004
1006
  }
1005
1007
 
1008
+ // A row whose JSON no longer reads as a record counts as no record, so
1009
+ // the next delivery records the session afresh.
1010
+ async findPublishedRecord(sessionID: SessionID): Promise<PublishedRecord | null> {
1011
+ const row = await this.db
1012
+ .selectFrom('published_record')
1013
+ .select('record')
1014
+ .where('session_id', '=', sessionID)
1015
+ .executeTakeFirst();
1016
+
1017
+ return row === undefined ? null : parsePublishedRecord(row.record);
1018
+ }
1019
+
1020
+ async writePublishedRecord(record: PublishedRecord): Promise<void> {
1021
+ const values = {
1022
+ record: JSON.stringify(record),
1023
+ revision: record.revision,
1024
+ updated_at: Date.parse(record.updatedAt),
1025
+ };
1026
+
1027
+ await this.db
1028
+ .insertInto('published_record')
1029
+ .values({ session_id: record.session, ...values })
1030
+ .onConflict((oc) => oc.column('session_id').doUpdateSet(values))
1031
+ .execute();
1032
+ }
1033
+
1034
+ async removePublishedRecord(sessionID: SessionID): Promise<void> {
1035
+ await this.db.deleteFrom('published_record').where('session_id', '=', sessionID).execute();
1036
+ }
1037
+
1006
1038
  // Records a host's binding as its first attempt starts provisioning it,
1007
1039
  // at the first revision, before the imp exists.
1008
1040
  async createAuthBinding(
@@ -0,0 +1,39 @@
1
+ import { faker } from '@faker-js/faker';
2
+ import type { PublishedRecord } from '../protocol/published-record';
3
+ import { mergeDeep } from './merge-deep';
4
+ import type { MockOverrides } from './mock-overrides';
5
+
6
+ type DefaultedKey = keyof PublishedRecord;
7
+
8
+ /**
9
+ * A first-revision version 1 record of a fresh session on the local target,
10
+ * whose workspace is an arbitrary directory on a branch, with no declared
11
+ * scope. Overrides merge into fresh defaults at every depth.
12
+ */
13
+ export function buildMockPublishedRecord(
14
+ overrides: MockOverrides<PublishedRecord, DefaultedKey> = {},
15
+ ): PublishedRecord {
16
+ return mergeDeep<PublishedRecord, DefaultedKey>(
17
+ {
18
+ format: 'atc.session-record',
19
+ version: 1,
20
+ session: faker.string.uuid(),
21
+ daemonID: faker.string.uuid(),
22
+ target: 'local',
23
+ revision: 1,
24
+ updatedAt: faker.date.recent().toISOString(),
25
+ scope: {
26
+ workspace: {
27
+ path: faker.system.directoryPath(),
28
+ branch: faker.git.branch(),
29
+ repoURL: null,
30
+ sha: null,
31
+ },
32
+ worktrees: [],
33
+ branches: [],
34
+ pullRequests: [],
35
+ },
36
+ },
37
+ overrides,
38
+ );
39
+ }
@@ -23,7 +23,9 @@ interface StubBrokeredAgentAdapterConfig {
23
23
  * prints `revision <n>` for the binding's revision and then sleeps, with
24
24
  * the binding's variables and `CLAUDE_CONFIG_DIR` set to `claude-config`
25
25
  * in the guest folder; a guest spawn without one, and a local spawn, sleep.
26
- * Every other member is the plain agent adapter's.
26
+ * Its profile is a `GLM` agent of the claude kind run through `sh`, which
27
+ * offers no model or effort choice. Every other member is the plain agent
28
+ * adapter's.
27
29
  */
28
30
  export function buildStubBrokeredAgentAdapter(
29
31
  config: StubBrokeredAgentAdapterConfig,
@@ -45,6 +45,8 @@ export function buildStubDaemonContext(overrides: Partial<DaemonContext> = {}):
45
45
  revokeSessionAuth: makeUnreached('revokeSessionAuth'),
46
46
  updateSessionAuth: makeUnreached('updateSessionAuth'),
47
47
  updateSession: makeUnreached('updateSession'),
48
+ updateSessionScope: makeUnreached('updateSessionScope'),
49
+ isCallerTree: makeUnreached('isCallerTree'),
48
50
  quitDaemon: makeUnreached('quitDaemon'),
49
51
  ackSession: makeUnreached('ackSession'),
50
52
  buildResumeCommand: makeUnreached('buildResumeCommand'),
@@ -51,7 +51,7 @@ export function buildStubDaemonRequests(options: StubDaemonRequestsOptions) {
51
51
 
52
52
  const countAll = () => sent.length + options.countReactions();
53
53
 
54
- const resolveAndWait = async (request: Readonly<StubRequest>, value: DaemonAnswer) => {
54
+ const answerRequest = async (request: Readonly<StubRequest>, value: DaemonAnswer) => {
55
55
  const before = countAll();
56
56
 
57
57
  answered.add(request);
@@ -81,7 +81,7 @@ export function buildStubDaemonRequests(options: StubDaemonRequestsOptions) {
81
81
  return latest;
82
82
  }, waitOptions);
83
83
 
84
- await resolveAndWait(request, value);
84
+ await answerRequest(request, value);
85
85
  },
86
86
  async answerOldest(m: string, value: DaemonAnswer): Promise<void> {
87
87
  const request = await waitFor(() => {
@@ -94,7 +94,7 @@ export function buildStubDaemonRequests(options: StubDaemonRequestsOptions) {
94
94
  return oldest;
95
95
  }, waitOptions);
96
96
 
97
- await resolveAndWait(request, value);
97
+ await answerRequest(request, value);
98
98
  },
99
99
  collectSent(m: string): DaemonAnswer[] {
100
100
  return sent.filter((request) => request.m === m).map((request) => request.p);
@@ -5,7 +5,7 @@ import type { HarnessRelay } from '../daemon/execution-provider';
5
5
  * a guest's connection. `sendLine` delivers a value as one JSON line from
6
6
  * the guest to every line listener, `written` holds every line written back
7
7
  * to the guest, parsed, in order, and `isClosed` turns true once the relay
8
- * is closed. A write settles at once, as a relay with room does. `hangUp`
8
+ * is closed. A write settles at once, as a relay with room does. `emitClose`
9
9
  * runs every close listener, as a guest that hangs up does; closing the
10
10
  * relay from the daemon's end runs none.
11
11
  */
@@ -41,7 +41,7 @@ export function buildStubHarnessRelay() {
41
41
  listener(JSON.stringify(value));
42
42
  }
43
43
  },
44
- hangUp: () => {
44
+ emitClose: () => {
45
45
  for (const listener of closeListeners) {
46
46
  listener();
47
47
  }
@@ -1,18 +1,16 @@
1
1
  /**
2
2
  * The script of a stand-in `gh` whose repository listing waits for the
3
- * test. `gh config …` prints `https`. Any other command touches
4
- * `$HOME/gh-held` and waits while `$HOME/gh-hold` exists, then prints one
5
- * repository, `me/dots`, in the JSON shape `gh repo list` prints.
3
+ * test. `gh config …` prints `https`. Any other command waits while
4
+ * `$HOME/gh-hold` exists, touching `$HOME/gh-held` on each pass of the
5
+ * wait, so the marker appears only once the stand-in is held, then prints
6
+ * one repository, `me/dots`, in the JSON shape `gh repo list` prints.
6
7
  */
7
8
  export function buildStubHeldGH(): string {
8
9
  return `#!/bin/sh
9
10
  case "$1" in
10
11
  config) echo https ;;
11
12
  *)
12
- if [ -f "$HOME/gh-hold" ]; then
13
- touch "$HOME/gh-held"
14
- while [ -f "$HOME/gh-hold" ]; do sleep 0.05; done
15
- fi
13
+ while [ -f "$HOME/gh-hold" ]; do touch "$HOME/gh-held"; sleep 0.05; done
16
14
  echo '[{"nameWithOwner":"me/dots","description":"dotfiles","isPrivate":false,"url":"https://github.com/me/dots","sshUrl":"git@github.com:me/dots.git"}]' ;;
17
15
  esac
18
16
  `;
@@ -9,7 +9,7 @@ import type { MessageRecord } from '../store/message-record';
9
9
  * An inbox source over a real, empty tap registry, standing in for the
10
10
  * daemon's store. It holds every session under the given agent session id,
11
11
  * and its pending-message read waits until the test answers it: `reading`
12
- * settles once a read starts, `read` answers the read with the given
12
+ * settles once a read starts, `answer` answers the read with the given
13
13
  * messages whatever owner it asked for.
14
14
  */
15
15
  export function buildStubHeldInboxSource(agentSessionID: AgentSessionID) {
@@ -29,7 +29,7 @@ export function buildStubHeldInboxSource(agentSessionID: AgentSessionID) {
29
29
  return {
30
30
  source,
31
31
  reading: reading.promise,
32
- read: (records: readonly MessageRecord[]) => {
32
+ answer: (records: readonly MessageRecord[]) => {
33
33
  pending.resolve([...records]);
34
34
  },
35
35
  };
@@ -1,13 +1,11 @@
1
1
  /**
2
2
  * The script of a stand-in `zoxide` that lists no directories, and waits
3
- * for the test first: while `$HOME/zoxide-hold` exists, it touches
4
- * `$HOME/zoxide-held` and waits for the file to go.
3
+ * for the test first: while `$HOME/zoxide-hold` exists, it waits for the
4
+ * file to go, touching `$HOME/zoxide-held` on each pass of the wait, so the
5
+ * marker appears only once the stand-in is held.
5
6
  */
6
7
  export function buildStubHeldZoxide(): string {
7
8
  return `#!/bin/sh
8
- if [ -f "$HOME/zoxide-hold" ]; then
9
- touch "$HOME/zoxide-held"
10
- while [ -f "$HOME/zoxide-hold" ]; do sleep 0.05; done
11
- fi
9
+ while [ -f "$HOME/zoxide-hold" ]; do touch "$HOME/zoxide-held"; sleep 0.05; done
12
10
  `;
13
11
  }
@@ -1,9 +1,10 @@
1
1
  /**
2
2
  * A host operation a test can hold open, for a host preparation or sleep
3
- * that must stay in flight while the test changes something. Unarmed, the
4
- * operation resolves at once, so the arrange steps before the hold pass
5
- * through. Once armed, the next call resolves `entered` and every call
6
- * waits until `release`; after the release, calls resolve at once again.
3
+ * that must stay in flight while the test changes something: a test passes
4
+ * `waitForRelease` as the operation. Before `startHold`, the operation
5
+ * resolves at once, so the arrange steps before the hold pass through.
6
+ * After it, the next call resolves `entered` and every call waits until
7
+ * `release`; after the release, calls resolve at once again.
7
8
  */
8
9
  export function buildStubHostHold() {
9
10
  const entered = Promise.withResolvers<void>();
@@ -11,7 +12,7 @@ export function buildStubHostHold() {
11
12
  let armed = false;
12
13
 
13
14
  return {
14
- hold: (): Promise<void> => {
15
+ waitForRelease: (): Promise<void> => {
15
16
  if (!armed) {
16
17
  return Promise.resolve();
17
18
  }
@@ -20,7 +21,7 @@ export function buildStubHostHold() {
20
21
 
21
22
  return released.promise;
22
23
  },
23
- arm: () => {
24
+ startHold: () => {
24
25
  armed = true;
25
26
  },
26
27
  entered: entered.promise,
@@ -0,0 +1,20 @@
1
+ import type { LaunchTicket } from '../daemon/execution-provider';
2
+ import type { DaemonError } from '../protocol/daemon-error';
3
+
4
+ /**
5
+ * A runtime auth binder's launch admission for a harness behind the
6
+ * broker. `admit` admits every start and attach: it hands `send` a ticket
7
+ * at once, before the admission settles, as the binder does under its host
8
+ * lock, and then resolves. The ticket's check runs the given check, which
9
+ * returns the refusal that stops the request unsent or null to let it go,
10
+ * and may throw as a broken binder would. Its release does nothing.
11
+ */
12
+ export function buildStubLaunchAdmission(check: () => DaemonError | null) {
13
+ return {
14
+ admit: (_kind: 'start' | 'attach', send: (ticket: LaunchTicket) => void): Promise<void> => {
15
+ send({ check, release: () => {} });
16
+
17
+ return Promise.resolve();
18
+ },
19
+ };
20
+ }
@@ -0,0 +1,22 @@
1
+ import type { BrokerAuthHost } from '../daemon/broker-auth-host';
2
+
3
+ /**
4
+ * A broker host whose new imps already hold a grant of `secret` when its
5
+ * create returns, as on an impd where another principal grants every imp
6
+ * the moment it appears. Every other member is the given host's own.
7
+ */
8
+ export function buildStubPregrantedBrokerHost(
9
+ host: BrokerAuthHost,
10
+ secret: string,
11
+ ): BrokerAuthHost {
12
+ return {
13
+ ...host,
14
+ createImp: async (hostKey) => {
15
+ const imp = await host.createImp(hostKey);
16
+
17
+ await host.port.createGrant(imp.name, secret);
18
+
19
+ return imp;
20
+ },
21
+ };
22
+ }