@zgeoff/atc 3.4.2 → 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 (78) 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/client/daemon-client.ts +6 -1
  6. package/src/collect-restart-env.ts +6 -3
  7. package/src/daemon/build-scoped-context.ts +2 -0
  8. package/src/daemon/check-session-scope.ts +235 -0
  9. package/src/daemon/daemon-connection.ts +74 -0
  10. package/src/daemon/daemon-context.ts +21 -0
  11. package/src/daemon/daemon.ts +21 -0
  12. package/src/daemon/find-host-branch.ts +28 -0
  13. package/src/daemon/materialize-workspace.ts +6 -1
  14. package/src/daemon/merge-recorded-scope.ts +50 -0
  15. package/src/daemon/published-records.ts +269 -0
  16. package/src/daemon/request-access-classes.ts +1 -0
  17. package/src/daemon/run-host-git.ts +52 -0
  18. package/src/daemon/sessions.ts +185 -53
  19. package/src/daemon/start-headless-turn.ts +3 -0
  20. package/src/federation/id-rules.ts +11 -2
  21. package/src/mcp/mcp-tools.ts +50 -0
  22. package/src/mcp/require-daemon-features.ts +1 -0
  23. package/src/mcp/run-tool.ts +14 -0
  24. package/src/protocol/daemon-features.ts +5 -0
  25. package/src/protocol/parse-declared-scope.ts +158 -0
  26. package/src/protocol/protocol.ts +1 -0
  27. package/src/protocol/published-record.ts +62 -0
  28. package/src/protocol/request-param-schemas.ts +16 -0
  29. package/src/store/parse-published-record.ts +59 -0
  30. package/src/store/run-migrations.ts +23 -0
  31. package/src/store/state-store.ts +32 -0
  32. package/src/test-utils/build-mock-published-record.ts +39 -0
  33. package/src/test-utils/build-stub-brokered-agent-adapter.ts +3 -1
  34. package/src/test-utils/build-stub-daemon-context.ts +2 -0
  35. package/src/test-utils/build-stub-daemon-requests.ts +3 -3
  36. package/src/test-utils/build-stub-harness-relay.ts +2 -2
  37. package/src/test-utils/build-stub-held-inbox-source.ts +2 -2
  38. package/src/test-utils/build-stub-host-hold.ts +7 -6
  39. package/src/test-utils/build-stub-launch-admission.ts +20 -0
  40. package/src/test-utils/build-stub-pregranted-broker-host.ts +22 -0
  41. package/src/test-utils/build-stub-terminal.ts +3 -3
  42. package/src/test-utils/create-git-fixture.ts +2 -2
  43. package/src/test-utils/create-stub-imp-port.ts +37 -37
  44. package/src/test-utils/create-stub-systemd.ts +6 -6
  45. package/src/test-utils/isolate-home.ts +8 -5
  46. package/src/test-utils/read-query-plan.ts +10 -6
  47. package/src/test-utils/register-test-cleanup.ts +10 -10
  48. package/src/test-utils/setup-mcp-home.ts +7 -6
  49. package/src/test-utils/setup-mcp-http.ts +6 -6
  50. package/src/test-utils/setup-temp-dir.ts +4 -10
  51. package/src/test-utils/start-cut-proxy.ts +2 -4
  52. package/src/test-utils/start-daemon-process.ts +13 -13
  53. package/src/test-utils/start-git-http-server.ts +2 -4
  54. package/src/test-utils/start-mcp-stdio.ts +5 -5
  55. package/src/test-utils/start-stub-answering-listener.ts +3 -3
  56. package/src/test-utils/start-stub-closing-listener.ts +3 -3
  57. package/src/test-utils/start-stub-dropping-daemon.ts +2 -3
  58. package/src/test-utils/start-stub-impd-info.ts +3 -7
  59. package/src/test-utils/start-stub-impd.ts +3 -5
  60. package/src/test-utils/start-stub-legacy-daemon.ts +2 -3
  61. package/src/test-utils/start-stub-mcp-server.ts +3 -5
  62. package/src/test-utils/start-stub-recording-listener.ts +3 -3
  63. package/src/test-utils/start-stub-refusing-listener.ts +3 -3
  64. package/src/test-utils/start-stub-reporter-socket.ts +3 -9
  65. package/src/test-utils/start-stub-restore-daemon.ts +2 -7
  66. package/src/test-utils/start-stub-session-bridge.ts +3 -9
  67. package/src/test-utils/start-stub-silent-server.ts +3 -6
  68. package/src/test-utils/start-stub-source-daemon.ts +2 -2
  69. package/src/test-utils/start-stub-stalled-client.ts +3 -3
  70. package/src/test-utils/start-stub-stalled-listener.ts +3 -3
  71. package/src/test-utils/start-stub-tcp-daemon.ts +3 -3
  72. package/src/test-utils/start-stub-telemetry-collector.ts +2 -2
  73. package/src/test-utils/start-stub-unanswering-listener.ts +3 -3
  74. package/src/test-utils/start-stub-unanswering-unix-listener.ts +3 -3
  75. package/src/test-utils/start-test-daemon.ts +62 -18
  76. package/src/test-utils/start-tui-harness.ts +17 -10
  77. package/src/test-utils/subscribe-to-socket-lines.ts +5 -9
  78. package/src/test-utils/{can-bind-addresses.ts → try-bind-addresses.ts} +4 -3
@@ -0,0 +1,50 @@
1
+ import type { RecordedScope } from '../protocol/published-record';
2
+ import type { CheckedScope } from './check-session-scope';
3
+
4
+ /**
5
+ * Adds each checked entry the recorded scope lacks, keeping the order the
6
+ * entries arrived in. A worktree matches by path, a branch by name and
7
+ * repository, and a pull request by repository and number. Returns the
8
+ * recorded scope itself when every entry is already there.
9
+ */
10
+ export function mergeRecordedScope(recorded: RecordedScope, added: CheckedScope): RecordedScope {
11
+ const worktrees = mergeEntries(recorded.worktrees, added.worktrees, (w) => w.path);
12
+ const branches = mergeEntries(recorded.branches, added.branches, (b) => `${b.repo}\n${b.name}`);
13
+
14
+ const pullRequests = mergeEntries(
15
+ recorded.pullRequests,
16
+ added.pullRequests,
17
+ (pr) => `${pr.repo.toLowerCase()}#${pr.number}`,
18
+ );
19
+
20
+ if (
21
+ worktrees === recorded.worktrees &&
22
+ branches === recorded.branches &&
23
+ pullRequests === recorded.pullRequests
24
+ ) {
25
+ return recorded;
26
+ }
27
+
28
+ return { workspace: recorded.workspace, worktrees, branches, pullRequests };
29
+ }
30
+
31
+ function mergeEntries<T>(
32
+ recorded: readonly T[],
33
+ added: readonly T[],
34
+ toKey: (entry: T) => string,
35
+ ): readonly T[] {
36
+ const seen = new Set(recorded.map((entry) => toKey(entry)));
37
+
38
+ const fresh: T[] = [];
39
+
40
+ for (const entry of added) {
41
+ const key = toKey(entry);
42
+
43
+ if (!seen.has(key)) {
44
+ seen.add(key);
45
+ fresh.push(entry);
46
+ }
47
+ }
48
+
49
+ return fresh.length === 0 ? recorded : [...recorded, ...fresh];
50
+ }
@@ -0,0 +1,269 @@
1
+ import { randomUUID } from 'node:crypto';
2
+ import { chmod, mkdir, rename, rm, writeFile } from 'node:fs/promises';
3
+ import { join } from 'node:path';
4
+ import type { DeclaredScope } from '../protocol/parse-declared-scope';
5
+ import type { PublishedRecord, RecordedScope } from '../protocol/published-record';
6
+ import type { SessionID } from '../shared/session-id';
7
+ import type { SessionWorkspace } from '../store/workspace-materialization';
8
+ import { checkSessionScope } from './check-session-scope';
9
+ import type { CheckedScope } from './check-session-scope';
10
+ import type { ExecutionProvider } from './execution-provider';
11
+ import { findHostBranch } from './find-host-branch';
12
+ import { mergeRecordedScope } from './merge-recorded-scope';
13
+
14
+ /**
15
+ * Where the state store keeps each session's published record.
16
+ */
17
+ interface PublishedRecordStore {
18
+ readonly findPublishedRecord: (sessionID: SessionID) => Promise<PublishedRecord | null>;
19
+ readonly writePublishedRecord: (record: PublishedRecord) => Promise<void>;
20
+ readonly removePublishedRecord: (sessionID: SessionID) => Promise<void>;
21
+ }
22
+
23
+ /**
24
+ * One session as its record sees it: the host it runs on, the directory
25
+ * it runs in, and what its workspace was materialized from.
26
+ */
27
+ export interface RecordSubject {
28
+ readonly session: SessionID;
29
+ readonly target: string;
30
+ readonly provider: ExecutionProvider;
31
+ readonly host: SessionID;
32
+ readonly dir: string;
33
+ readonly workspace: SessionWorkspace | null;
34
+
35
+ // The branch atc checked the workspace out on, when atc materialized it;
36
+ // absent leaves the host to show the directory's branch.
37
+ readonly branch?: string | null;
38
+ }
39
+
40
+ interface PublishedRecordsOptions {
41
+ readonly store: PublishedRecordStore;
42
+ readonly daemonID: string;
43
+
44
+ // The directory on the daemon's machine each local copy lands in.
45
+ readonly localDir: string;
46
+
47
+ // The gh executable that checks a declared pull request.
48
+ readonly ghBin: string;
49
+ readonly now: () => number;
50
+ }
51
+
52
+ const EMPTY_SCOPE: CheckedScope = { worktrees: [], branches: [], pullRequests: [] };
53
+
54
+ // Writes a record copy into a directory on a remote host: the content to a
55
+ // staged file, made read-only, then renamed over the copy.
56
+ const PLACE_COPY =
57
+ 'umask 022 && mkdir -p "$1" && printf "%s" "$4" > "$1/$3" && chmod 0444 "$1/$3" && mv -f "$1/$3" "$1/$2"';
58
+
59
+ /**
60
+ * Publishes each session's record: the state store holds it, and the
61
+ * session's provider places a read-only copy where the session reads it.
62
+ * Only the daemon writes either, from what it checked itself.
63
+ */
64
+ export class PublishedRecords {
65
+ private readonly options: PublishedRecordsOptions;
66
+
67
+ // The change under way for each session, which the next change to the
68
+ // same record waits on, so two additions never write over each other.
69
+ private readonly changes = new Map<SessionID, Promise<void>>();
70
+
71
+ constructor(options: PublishedRecordsOptions) {
72
+ this.options = options;
73
+ }
74
+
75
+ // Checks a spawn's declared scope on its readied host, records it with
76
+ // the workspace the session runs in, and places the copy. Returns the
77
+ // copy's path on the host.
78
+ async createRecord(subject: RecordSubject, declared: DeclaredScope | null): Promise<string> {
79
+ const checked = declared === null ? EMPTY_SCOPE : await this.checkScope(subject, declared);
80
+
81
+ const workspace = await this.buildWorkspace(subject);
82
+
83
+ const record = this.buildRecord(subject, { workspace, ...checked }, 1);
84
+
85
+ await this.options.store.writePublishedRecord(record);
86
+
87
+ return this.writeCopy(subject, record);
88
+ }
89
+
90
+ // Places the stored copy again before a harness starts on a revived or
91
+ // restored session, recording a fresh one for a session without one.
92
+ async restoreCopy(subject: RecordSubject): Promise<string> {
93
+ const stored = await this.options.store.findPublishedRecord(subject.session);
94
+
95
+ if (stored === null) {
96
+ return this.createRecord(subject, null);
97
+ }
98
+
99
+ return this.writeCopy(subject, stored);
100
+ }
101
+
102
+ // Checks an added scope on the session's host, adds each entry the record
103
+ // lacks, and places the changed copy. A scope that adds nothing leaves
104
+ // the record and its revision as they were.
105
+ async updateScope(subject: RecordSubject, declared: DeclaredScope): Promise<PublishedRecord> {
106
+ const before = this.changes.get(subject.session);
107
+ const turn = Promise.withResolvers<void>();
108
+
109
+ this.changes.set(subject.session, turn.promise);
110
+
111
+ try {
112
+ await before;
113
+
114
+ return await this.writeScope(subject, declared);
115
+ } finally {
116
+ turn.resolve();
117
+
118
+ if (this.changes.get(subject.session) === turn.promise) {
119
+ this.changes.delete(subject.session);
120
+ }
121
+ }
122
+ }
123
+
124
+ findRecord(sessionID: SessionID): Promise<PublishedRecord | null> {
125
+ return this.options.store.findPublishedRecord(sessionID);
126
+ }
127
+
128
+ // Where the copy of a session on the daemon's own machine lies, the only
129
+ // machine a headless turn runs on.
130
+ getLocalPath(sessionID: SessionID): string {
131
+ return join(this.options.localDir, `${sessionID}.json`);
132
+ }
133
+
134
+ // Drops a session's record and its copy on the daemon's machine.
135
+ async remove(sessionID: SessionID): Promise<void> {
136
+ await this.options.store.removePublishedRecord(sessionID);
137
+
138
+ await rm(this.getLocalPath(sessionID), { force: true });
139
+ }
140
+
141
+ private async writeScope(
142
+ subject: RecordSubject,
143
+ declared: DeclaredScope,
144
+ ): Promise<PublishedRecord> {
145
+ const stored = await this.options.store.findPublishedRecord(subject.session);
146
+
147
+ let current = stored;
148
+
149
+ current ??= await this.buildFirstRecord(subject);
150
+
151
+ const checked = await this.checkScope(subject, declared);
152
+
153
+ const merged = mergeRecordedScope(current.scope, checked);
154
+
155
+ // A change that adds nothing still places the copy, so a retry after a
156
+ // failed copy write brings the copy level with the store.
157
+ if (stored !== null && merged === current.scope) {
158
+ await this.writeCopy(subject, stored);
159
+
160
+ return stored;
161
+ }
162
+
163
+ const record = this.buildRecord(subject, merged, current.revision + 1);
164
+
165
+ await this.options.store.writePublishedRecord(record);
166
+ await this.writeCopy(subject, record);
167
+
168
+ return record;
169
+ }
170
+
171
+ // The record a session without one starts from, at revision 0 so its
172
+ // first change records revision 1.
173
+ private async buildFirstRecord(subject: RecordSubject): Promise<PublishedRecord> {
174
+ const workspace = await this.buildWorkspace(subject);
175
+
176
+ return this.buildRecord(subject, { workspace, ...EMPTY_SCOPE }, 0);
177
+ }
178
+
179
+ private checkScope(subject: RecordSubject, declared: DeclaredScope): Promise<CheckedScope> {
180
+ return checkSessionScope(
181
+ {
182
+ provider: subject.provider,
183
+ host: subject.host,
184
+ dir: subject.dir,
185
+ repoURL: subject.workspace?.repoURL ?? null,
186
+ declared,
187
+ },
188
+ this.options.ghBin,
189
+ );
190
+ }
191
+
192
+ private async buildWorkspace(subject: RecordSubject): Promise<RecordedScope['workspace']> {
193
+ const branch = await this.findBranch(subject);
194
+
195
+ return {
196
+ path: subject.dir,
197
+ branch,
198
+ repoURL: subject.workspace?.repoURL ?? null,
199
+ sha: subject.workspace?.sha ?? null,
200
+ };
201
+ }
202
+
203
+ // The branch atc checked a workspace out on, else the branch of a
204
+ // directory on the daemon's machine. A remote directory atc did not
205
+ // materialize records none, so a spawn there runs no command on the host
206
+ // before its harness starts.
207
+ private findBranch(subject: RecordSubject): Promise<string | null> {
208
+ if (subject.branch !== undefined) {
209
+ return Promise.resolve(subject.branch);
210
+ }
211
+
212
+ return subject.provider.remote
213
+ ? Promise.resolve(null)
214
+ : findHostBranch(subject.provider, subject.host, subject.dir);
215
+ }
216
+
217
+ private buildRecord(
218
+ subject: RecordSubject,
219
+ scope: RecordedScope,
220
+ revision: number,
221
+ ): PublishedRecord {
222
+ return {
223
+ format: 'atc.session-record',
224
+ version: 1,
225
+ session: subject.session,
226
+ daemonID: this.options.daemonID,
227
+ target: subject.target,
228
+ revision,
229
+ updatedAt: new Date(this.options.now()).toISOString(),
230
+ scope,
231
+ };
232
+ }
233
+
234
+ // Replaces the copy through a rename, so a reader never sees half of it.
235
+ private async writeCopy(subject: RecordSubject, record: PublishedRecord): Promise<string> {
236
+ const content = `${JSON.stringify(record, null, 2)}\n`;
237
+ const name = `${subject.session}.json`;
238
+
239
+ if (!subject.provider.remote) {
240
+ const dir = this.options.localDir;
241
+ const path = this.getLocalPath(subject.session);
242
+ const staged = join(dir, `.${name}.${randomUUID()}`);
243
+
244
+ await mkdir(dir, { recursive: true, mode: 0o700 });
245
+ await chmod(dir, 0o700);
246
+ await writeFile(staged, content, { mode: 0o444, flag: 'wx' });
247
+ await rename(staged, path);
248
+
249
+ return path;
250
+ }
251
+
252
+ // One command writes the copy beside its place and renames it there.
253
+ // A remote host runs it as the user its provider runs commands as,
254
+ // root on an imp, so the copy and its directory belong to that user.
255
+ const dir = `${subject.provider.guest?.dir ?? '/tmp/atc'}/records`;
256
+
257
+ const placed = await subject.provider.runCommand({
258
+ argv: ['sh', '-c', PLACE_COPY, 'sh', dir, name, `.${name}.${randomUUID()}`, content],
259
+ cwd: '/',
260
+ host: subject.host,
261
+ });
262
+
263
+ if (placed.exitCode !== 0) {
264
+ throw new Error(`placing the session record in ${dir} failed: ${placed.stderr.trim()}`);
265
+ }
266
+
267
+ return `${dir}/${name}`;
268
+ }
269
+ }
@@ -34,6 +34,7 @@ export const REQUEST_ACCESS_CLASSES: Readonly<Record<RequestMethod, RequestAcces
34
34
  'session.auth.revoke': 'owner',
35
35
  'session.auth.rebind': 'owner',
36
36
  'session.update': 'principal',
37
+ 'session.scope.add': 'principal',
37
38
  'session.attach': 'principal',
38
39
  'session.detach': 'principal',
39
40
  'session.input': 'principal',
@@ -0,0 +1,52 @@
1
+ import { REPOSITORY_ENV_VARS } from '../workspace/repository-env-vars';
2
+ import type { CommandResult, ExecutionProvider } from './execution-provider';
3
+
4
+ // The git options every check runs with: every directory counts as safe,
5
+ // since a host's files may belong to another user than the one the command
6
+ // runs as, and no fsmonitor starts.
7
+ const GIT_OPTIONS = ['-c', 'safe.directory=*', '-c', 'core.fsmonitor=false'];
8
+
9
+ // A remote host runs git without the variables that would point it at
10
+ // another repository than the directory it is given.
11
+ const CLEAN_ENV = ['env', ...[...REPOSITORY_ENV_VARS].flatMap((name) => ['-u', name])];
12
+
13
+ /**
14
+ * Runs git against a directory on a session's host and returns what it
15
+ * printed. A provider on the daemon's own machine shares the daemon's
16
+ * filesystem, so the daemon runs git there itself; a remote host runs it
17
+ * through the provider.
18
+ */
19
+ export async function runHostGit(
20
+ provider: ExecutionProvider,
21
+ host: string,
22
+ dir: string,
23
+ args: readonly string[],
24
+ ): Promise<CommandResult> {
25
+ if (provider.remote) {
26
+ return provider.runCommand({
27
+ argv: [...CLEAN_ENV, 'git', ...GIT_OPTIONS, '-C', dir, ...args],
28
+ cwd: '/',
29
+ host,
30
+ });
31
+ }
32
+
33
+ const env = Object.fromEntries(
34
+ Object.entries(process.env).filter(([name]) => !REPOSITORY_ENV_VARS.has(name)),
35
+ );
36
+
37
+ const proc = Bun.spawn(['git', ...GIT_OPTIONS, '-C', dir, ...args], {
38
+ cwd: '/',
39
+ env,
40
+ stdin: 'ignore',
41
+ stdout: 'pipe',
42
+ stderr: 'pipe',
43
+ });
44
+
45
+ const [stdout, stderr, exitCode] = await Promise.all([
46
+ new Response(proc.stdout).text(),
47
+ new Response(proc.stderr).text(),
48
+ proc.exited,
49
+ ]);
50
+
51
+ return { exitCode, stdout, stderr };
52
+ }