@zgeoff/atc 2.12.0 → 2.14.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 (75) hide show
  1. package/README.md +12 -11
  2. package/package.json +2 -1
  3. package/src/agents/agent-adapter.ts +48 -11
  4. package/src/agents/build-atc-bridge-files.ts +13 -0
  5. package/src/agents/build-claude-query-options.ts +72 -0
  6. package/src/agents/build-cli-command.ts +7 -5
  7. package/src/{daemon → agents}/build-headless-env.ts +12 -6
  8. package/src/agents/build-hook-settings.ts +8 -2
  9. package/src/agents/build-restore-mode-args.ts +23 -0
  10. package/src/agents/claude-adapter.ts +64 -17
  11. package/src/agents/codex-adapter.ts +5 -0
  12. package/src/agents/find-claude-permission-mode.ts +24 -0
  13. package/src/agents/gateway-adapter.ts +38 -11
  14. package/src/agents/grok-adapter.ts +5 -0
  15. package/src/agents/make-claude-headless-runner.ts +46 -0
  16. package/src/agents/plan-pasted-line-input.ts +22 -0
  17. package/src/agents/plan-typed-line-input.ts +8 -0
  18. package/src/agents/resolve-claude-permission-mode.ts +13 -0
  19. package/src/{daemon/start-headless-run.ts → agents/start-claude-headless-run.ts} +6 -53
  20. package/src/agents/write-atc-bridge.ts +2 -6
  21. package/src/cli.ts +8 -12
  22. package/src/client/boot-daemon.ts +47 -21
  23. package/src/client/format-protocol-mismatch.ts +37 -0
  24. package/src/client/index.ts +104 -2
  25. package/src/client/ui.ts +8 -0
  26. package/src/daemon/build-execution-targets.ts +49 -10
  27. package/src/daemon/build-imp-provider.ts +68 -0
  28. package/src/daemon/build-report-trail-entry.ts +6 -2
  29. package/src/daemon/build-report-view.ts +41 -0
  30. package/src/daemon/build-scoped-context.ts +9 -1
  31. package/src/daemon/build-session-lifecycle.ts +52 -0
  32. package/src/daemon/build-tar-archive.ts +85 -0
  33. package/src/daemon/daemon-connection.ts +134 -4
  34. package/src/daemon/daemon.ts +306 -42
  35. package/src/daemon/execution-provider.ts +109 -4
  36. package/src/daemon/hooks.ts +5 -17
  37. package/src/daemon/imp-client-port.ts +343 -0
  38. package/src/daemon/imp-harness.ts +618 -0
  39. package/src/daemon/imp-port-error.ts +18 -0
  40. package/src/daemon/imp-port.ts +246 -0
  41. package/src/daemon/imp-provider.ts +601 -0
  42. package/src/daemon/is-binding-current.ts +43 -0
  43. package/src/daemon/local-pty-provider.ts +62 -3
  44. package/src/daemon/materialize-workspace.ts +574 -0
  45. package/src/daemon/parse-hook-line.ts +31 -0
  46. package/src/daemon/pick-session-state.ts +15 -0
  47. package/src/daemon/restore-fleet.ts +41 -23
  48. package/src/daemon/screen-model.ts +7 -0
  49. package/src/daemon/sessions.ts +574 -61
  50. package/src/daemon/start-headless-turn.ts +1 -1
  51. package/src/daemon/start-session-bridge.ts +294 -0
  52. package/src/mcp/mcp-tools.ts +41 -3
  53. package/src/mcp/reconnecting-caller.ts +1 -0
  54. package/src/mcp/require-daemon-features.ts +4 -0
  55. package/src/mcp/run-tool.ts +32 -8
  56. package/src/protocol/daemon-features.ts +14 -0
  57. package/src/protocol/protocol.ts +23 -0
  58. package/src/protocol/request-param-schemas.ts +72 -0
  59. package/src/report.ts +37 -2
  60. package/src/run-bridge-tap.ts +241 -0
  61. package/src/shared/collect-clean-env.ts +9 -1
  62. package/src/shared/open-bridge-socket.ts +86 -0
  63. package/src/shared/send-bridge-request.ts +47 -0
  64. package/src/statusline.ts +34 -3
  65. package/src/store/fleet-entry.ts +19 -0
  66. package/src/store/run-migrations.ts +85 -0
  67. package/src/store/state-store.ts +226 -21
  68. package/src/store/trail-entry.ts +7 -0
  69. package/src/store/workspace-materialization.ts +70 -0
  70. package/src/tap.ts +11 -0
  71. package/src/workspace/check-url-credentials.ts +59 -0
  72. package/src/workspace/create-workspace-clone.ts +13 -1
  73. package/src/workspace/repository-env-vars.ts +22 -0
  74. package/src/workspace/run-git.ts +2 -18
  75. /package/src/{daemon → agents}/resolve-headless-executable.ts +0 -0
@@ -1,5 +1,6 @@
1
1
  import { mkdir } from 'node:fs/promises';
2
2
  import { spawn } from 'bun-pty';
3
+ import { collectCleanEnv } from '../shared/collect-clean-env';
3
4
  import type {
4
5
  CommandResult,
5
6
  CommandSpec,
@@ -18,6 +19,8 @@ import type {
18
19
  export class LocalPTYProvider implements ExecutionProvider {
19
20
  readonly kind = 'local-pty';
20
21
 
22
+ readonly remote = false;
23
+
21
24
  readonly capabilities: ExecutionCapabilities = {
22
25
  spawn: true,
23
26
  attach: true,
@@ -31,23 +34,69 @@ export class LocalPTYProvider implements ExecutionProvider {
31
34
  destroy: false,
32
35
  };
33
36
 
34
- readonly spawnHarness = (spec: HarnessSpec): HarnessHandle =>
35
- spawn(spec.bin, [...spec.args], {
37
+ // The daemon's own machine is always ready.
38
+ readonly prepareHost = (): Promise<void> => Promise.resolve();
39
+
40
+ // The daemon's machine keeps no process the daemon lets go of, so a
41
+ // detach ends the harness as a kill does, after dropping every listener.
42
+ readonly spawnHarness = (spec: HarnessSpec): HarnessHandle => {
43
+ const pty = spawn(spec.bin, [...spec.args], {
36
44
  name: 'xterm-256color',
37
45
  cols: spec.cols,
38
46
  rows: spec.rows,
39
47
  cwd: spec.cwd,
40
- env: { ...spec.env },
48
+ env: collectCleanEnv(spec.env, spec.withheldEnv),
41
49
  });
42
50
 
51
+ const subscriptions = new Set<{ readonly dispose: () => void }>();
52
+
53
+ return {
54
+ onData: (listener) => {
55
+ const subscription = pty.onData(listener);
56
+
57
+ subscriptions.add(subscription);
58
+
59
+ return subscription;
60
+ },
61
+ onExit: (listener) => {
62
+ const subscription = pty.onExit(listener);
63
+
64
+ subscriptions.add(subscription);
65
+
66
+ return subscription;
67
+ },
68
+ write: (data) => {
69
+ pty.write(data);
70
+ },
71
+ resize: (cols, rows) => {
72
+ pty.resize(cols, rows);
73
+ },
74
+ kill: () => {
75
+ pty.kill();
76
+ },
77
+ detach: () => {
78
+ for (const subscription of subscriptions) {
79
+ subscription.dispose();
80
+ }
81
+
82
+ pty.kill();
83
+ },
84
+ };
85
+ };
86
+
87
+ // Options the host passes to GNU tar through `TAR_OPTIONS` are ignored,
88
+ // since one could leave tracked files out of the unpacked checkout.
43
89
  // oxlint-disable-next-line prefer-readonly-parameter-types -- archive bytes have no readonly form
44
90
  readonly transferArchive = async (archive: Uint8Array, dir: string): Promise<void> => {
45
91
  await mkdir(dir, { recursive: true });
46
92
 
93
+ const { TAR_OPTIONS: _ignored, ...env } = process.env;
94
+
47
95
  const result = await this.runCommandWithInput(
48
96
  ['tar', '-x', '-f', '-', '-C', dir],
49
97
  dir,
50
98
  archive,
99
+ env,
51
100
  );
52
101
 
53
102
  if (result.exitCode !== 0) {
@@ -58,15 +107,25 @@ export class LocalPTYProvider implements ExecutionProvider {
58
107
  readonly runCommand = (spec: CommandSpec): Promise<CommandResult> =>
59
108
  this.runCommandWithInput(spec.argv, spec.cwd, null);
60
109
 
110
+ readonly suspendHost = (host: string): Promise<void> =>
111
+ Promise.reject(new Error(`the local-pty provider cannot suspend host ${host}`));
112
+
113
+ readonly destroyHost = (host: string): Promise<void> =>
114
+ Promise.reject(new Error(`the local-pty provider cannot destroy host ${host}`));
115
+
116
+ readonly dispose = (): void => {};
117
+
61
118
  private async runCommandWithInput(
62
119
  argv: readonly string[],
63
120
  cwd: string,
64
121
 
65
122
  // oxlint-disable-next-line prefer-readonly-parameter-types -- input bytes have no readonly form
66
123
  input: Uint8Array | null,
124
+ env?: Readonly<Record<string, string | undefined>>,
67
125
  ): Promise<CommandResult> {
68
126
  const proc = Bun.spawn([...argv], {
69
127
  cwd,
128
+ ...(env === undefined ? {} : { env: { ...env } }),
70
129
  stdin: input ?? 'ignore',
71
130
  stdout: 'pipe',
72
131
  stderr: 'pipe',
@@ -0,0 +1,574 @@
1
+ import { mkdtemp, rm } from 'node:fs/promises';
2
+ import { dirname, join, resolve } from 'node:path';
3
+ import { DaemonError } from '../protocol/daemon-error';
4
+ import type { ErrorCode } from '../protocol/protocol';
5
+ import type { SpawnWorkspaceSource } from '../protocol/request-param-schemas';
6
+ import type { SessionID } from '../shared/session-id';
7
+ import type { StateStore } from '../store/state-store';
8
+ import type { MaterializationPhase, SessionWorkspace } from '../store/workspace-materialization';
9
+ import { checkURLCredentials } from '../workspace/check-url-credentials';
10
+ import { createWorkspaceClone } from '../workspace/create-workspace-clone';
11
+ import { normalizeGitURL } from '../workspace/normalize-git-url';
12
+ import { readWorkspaceTar } from '../workspace/read-workspace-tar';
13
+ import { REPOSITORY_ENV_VARS } from '../workspace/repository-env-vars';
14
+ import { resolvePathSource } from '../workspace/resolve-path-source';
15
+ import { runGit } from '../workspace/run-git';
16
+ import { sanitizeWorkspaceClone } from '../workspace/sanitize-workspace-clone';
17
+ import type { ExecutionProvider } from './execution-provider';
18
+
19
+ interface MaterializeRequest {
20
+ readonly sessionID: SessionID;
21
+ readonly target: string;
22
+
23
+ // The directory on the target the workspace lands in; it must not exist.
24
+ readonly dir: string;
25
+ readonly source: SpawnWorkspaceSource;
26
+
27
+ // Whether the target is the daemon's own host, where a directory outside
28
+ // any git work tree runs in place instead of being materialized.
29
+ readonly inPlace: boolean;
30
+ }
31
+
32
+ interface MaterializeDeps {
33
+ // The target's provider for one operation, after the execution gate
34
+ // passes it; throws the gate's refusal otherwise.
35
+ readonly requireProvider: (capability: 'run' | 'transfer') => ExecutionProvider;
36
+ readonly store: Pick<StateStore, 'createMaterialization' | 'updateMaterialization'>;
37
+ readonly log: (line: string) => void;
38
+
39
+ // The directory on the daemon's host that holds each clone's staging
40
+ // directory while the workspace is built.
41
+ readonly stagingRoot: string;
42
+ }
43
+
44
+ type MaterializedWorkspace = { readonly kind: 'in_place' } | ReadyWorkspace;
45
+
46
+ interface ReadyWorkspace {
47
+ readonly kind: 'ready';
48
+ readonly workspace: SessionWorkspace;
49
+ readonly warnings: readonly string[];
50
+
51
+ // The variables every harness the session starts goes without.
52
+ readonly withheldEnv: readonly string[];
53
+ }
54
+
55
+ interface MaterializationProgress {
56
+ phase: MaterializationPhase;
57
+
58
+ // Whether this call created the target directory, so a failure removes
59
+ // only a directory it made.
60
+ claimed: boolean;
61
+ }
62
+
63
+ // Moves a materialization's progress along as each step completes.
64
+ type ProgressTracker = (update: Readonly<Partial<MaterializationProgress>>) => void;
65
+
66
+ /**
67
+ * Builds a spawn's working directory on its execution target as a clean
68
+ * checkout of a pushed commit, through the target provider's generic
69
+ * operations alone. The source resolves to a repository URL and a commit,
70
+ * the target directory is claimed with a `mkdir` that fails if it exists,
71
+ * the daemon clones and sanitizes the commit on its own host and tars it,
72
+ * the provider unpacks the archive into the directory, and a `git
73
+ * rev-parse HEAD` the provider runs there must print the pinned commit.
74
+ * Each provider call passes the execution gate first.
75
+ *
76
+ * Every phase is recorded before it starts, so a daemon that stops partway
77
+ * leaves a row the next start fails as interrupted. A refusal fails the
78
+ * row, removes a directory this call claimed, and throws a `DaemonError`
79
+ * whose message and data never hold the credential.
80
+ *
81
+ * A path source on the daemon's own host that lies outside any git work
82
+ * tree is not materialized: the session runs in it as it stands, and the
83
+ * spawn's directory must be that path.
84
+ */
85
+ export async function materializeWorkspace(
86
+ request: MaterializeRequest,
87
+ deps: MaterializeDeps,
88
+ ): Promise<MaterializedWorkspace> {
89
+ const source = request.source;
90
+
91
+ const outside =
92
+ source.kind === 'path' && request.inPlace ? await isOutsideWorkTree(source.path) : false;
93
+
94
+ if (source.kind === 'path' && outside) {
95
+ if (resolve(source.path) !== resolve(request.dir)) {
96
+ throw new DaemonError(
97
+ 'bad_args',
98
+ `${source.path} is outside any git work tree, so the session runs in it as it stands; spawn with it as cwd`,
99
+ { phase: 'resolving' },
100
+ );
101
+ }
102
+
103
+ return { kind: 'in_place' };
104
+ }
105
+
106
+ const secret = findCredentialSecret(source);
107
+ const withheldEnv = buildWithheldEnv(source);
108
+ const progress: MaterializationProgress = { phase: 'resolving', claimed: false };
109
+
110
+ const updateProgress = (update: Readonly<Partial<MaterializationProgress>>) => {
111
+ Object.assign(progress, update);
112
+ };
113
+
114
+ await deps.store.createMaterialization(
115
+ {
116
+ sessionID: request.sessionID,
117
+ target: request.target,
118
+ dir: request.dir,
119
+ sourceKind: source.kind,
120
+ withheldEnv,
121
+ },
122
+ Date.now(),
123
+ );
124
+
125
+ // The staging directory exists only once the row does, and only inside
126
+ // the block that removes it, so neither can outlive a failure of the other.
127
+ try {
128
+ const staging = await mkdtemp(join(deps.stagingRoot, 'atc-workspace-'));
129
+
130
+ try {
131
+ const ready = await runMaterialization(request, deps, staging, updateProgress, secret);
132
+
133
+ return { ...ready, withheldEnv };
134
+ } finally {
135
+ await rm(staging, { recursive: true, force: true });
136
+ }
137
+ } catch (error) {
138
+ const refusal = toScrubbedRefusal(error, progress.phase, secret);
139
+
140
+ await tryRemoveClaimedDir(request, deps, progress, secret);
141
+ await tryUpdateFailed(request, deps, refusal.code);
142
+
143
+ deps.log(
144
+ `atc: workspace for session ${request.sessionID} failed while ${progress.phase}: ${refusal.code}: ${refusal.message}`,
145
+ );
146
+
147
+ throw refusal;
148
+ }
149
+ }
150
+
151
+ // The variables only the clone's git commands may see: a git source's
152
+ // credential variable, and the askpass helper and secret variables the clone
153
+ // hands its network commands. A harness inherits the daemon's environment,
154
+ // so the session withholds these from every harness it starts.
155
+ const ASKPASS_ENV = ['GIT_ASKPASS', 'ATC_GIT_ASKPASS_SECRET'];
156
+
157
+ function buildWithheldEnv(source: SpawnWorkspaceSource): string[] {
158
+ return source.kind === 'git' && source.credentialRef !== undefined
159
+ ? [source.credentialRef.name, ...ASKPASS_ENV]
160
+ : [...ASKPASS_ENV];
161
+ }
162
+
163
+ // Whether a path is a directory that no git work tree holds. Only git's own
164
+ // answer that no repository holds it proves that: any other failure to
165
+ // inspect the path, such as a broken config or a repository git does not
166
+ // trust, refuses the spawn rather than run it in place unchecked.
167
+ async function isOutsideWorkTree(path: string): Promise<boolean> {
168
+ const inside = await runGit(['rev-parse', '--is-inside-work-tree'], { cwd: path }).catch(
169
+ () => null,
170
+ );
171
+
172
+ if (inside === null || inside.exitCode === 0) {
173
+ return false;
174
+ }
175
+
176
+ if (inside.stderr.startsWith('fatal: not a git repository')) {
177
+ return true;
178
+ }
179
+
180
+ throw new DaemonError(
181
+ 'unreadable_tree',
182
+ `git cannot inspect ${path}: ${inside.stderr.trim().split('\n')[0] ?? ''}`,
183
+ { phase: 'resolving' },
184
+ );
185
+ }
186
+
187
+ // The credential's value, which every message leaving this module is
188
+ // scrubbed of; null for a source without one.
189
+ function findCredentialSecret(source: SpawnWorkspaceSource): string | null {
190
+ if (source.kind !== 'git' || source.credentialRef === undefined) {
191
+ return null;
192
+ }
193
+
194
+ const value = process.env[source.credentialRef.name];
195
+
196
+ return value === undefined || value === '' ? null : value;
197
+ }
198
+
199
+ async function runMaterialization(
200
+ request: MaterializeRequest,
201
+ deps: MaterializeDeps,
202
+ staging: string,
203
+ updateProgress: ProgressTracker,
204
+ secret: string | null,
205
+ ): Promise<Omit<ReadyWorkspace, 'withheldEnv'>> {
206
+ const pinned = await resolveSource(request.source, staging);
207
+
208
+ // What is recorded and returned is scrubbed of the credential, even
209
+ // where a caller's own ref happens to spell it.
210
+ const repoURL = secret === null ? pinned.repoURL : toRedacted(pinned.repoURL, secret);
211
+ const ref = secret === null || pinned.ref === null ? pinned.ref : toRedacted(pinned.ref, secret);
212
+
213
+ await claimTargetDir(request, deps, updateProgress);
214
+ await recordPhase(request, deps, updateProgress, 'cloning', { repoURL, ref });
215
+
216
+ const clone = await createCleanClone(pinned, join(staging, 'clone'));
217
+
218
+ // The archive is in memory, so the clone leaves the daemon's host before
219
+ // the target is touched.
220
+ await rm(staging, { recursive: true, force: true });
221
+ await recordPhase(request, deps, updateProgress, 'transferring', { sha: clone.sha });
222
+
223
+ try {
224
+ await deps.requireProvider('transfer').transferArchive(clone.archive, request.dir);
225
+ } catch (error) {
226
+ throw toDaemonError(error, 'transfer_failed', 'transferring');
227
+ }
228
+
229
+ await recordPhase(request, deps, updateProgress, 'verifying', {});
230
+ await verifyTargetHead(request, deps, clone.sha);
231
+
232
+ const materializedAt = Date.now();
233
+
234
+ await recordPhase(request, deps, updateProgress, 'ready', { materializedAt });
235
+
236
+ return {
237
+ kind: 'ready',
238
+ workspace: {
239
+ repoURL,
240
+ sha: clone.sha,
241
+ ...(ref === null ? {} : { ref }),
242
+ materializedAt,
243
+ },
244
+ warnings: pinned.warnings,
245
+ };
246
+ }
247
+
248
+ interface PinnedSource {
249
+ // The URL the clone fetches from and the token-free form it is recorded
250
+ // under.
251
+ readonly cloneURL: string;
252
+ readonly repoURL: string;
253
+
254
+ // The ref or commit the clone checks out, and the branch or tag recorded.
255
+ readonly checkout: string;
256
+ readonly ref: string | null;
257
+ readonly credential: { readonly kind: 'env'; readonly name: string } | undefined;
258
+ readonly warnings: readonly string[];
259
+ }
260
+
261
+ /**
262
+ * Turns a source into what the clone needs, refusing a source that is
263
+ * not a pushed, complete, credential-free repository. A path source pins
264
+ * its pushed HEAD; a git source keeps its ref, which the clone pins.
265
+ */
266
+ async function resolveSource(source: SpawnWorkspaceSource, staging: string): Promise<PinnedSource> {
267
+ if (source.kind === 'path') {
268
+ // The origin is checked as configured, before resolving it applies any
269
+ // rewrite: a rewrite the checkout's own config holds expands it too.
270
+ await requireNoOriginRewriteCredentials(source.path);
271
+
272
+ const resolved = await resolvePathSource(source.path, {
273
+ allowDirty: source.allowDirty ?? 'refuse',
274
+ });
275
+
276
+ if (!resolved.ok) {
277
+ throw new DaemonError(resolved.code, resolved.message, { phase: 'resolving' });
278
+ }
279
+
280
+ await requireNoURLCredentials(resolved.url, staging);
281
+
282
+ return {
283
+ cloneURL: resolved.url,
284
+ repoURL: resolved.url,
285
+ checkout: resolved.sha,
286
+ ref: resolved.branch,
287
+ credential: undefined,
288
+ warnings: resolved.warnings,
289
+ };
290
+ }
291
+
292
+ await requireNoURLCredentials(source.url, staging);
293
+
294
+ // The clone fetches the URL it records, so an `owner/repo` shorthand
295
+ // reaches the repository it expands to.
296
+ const normalized = normalizeGitURL(source.url);
297
+
298
+ if (!normalized.ok) {
299
+ throw new DaemonError(normalized.code, normalized.message, { phase: 'resolving' });
300
+ }
301
+
302
+ await requireNoURLCredentials(normalized.url, staging);
303
+
304
+ return {
305
+ cloneURL: normalized.url,
306
+ repoURL: normalized.url,
307
+ checkout: source.sha ?? source.ref ?? '',
308
+ ref: source.ref ?? null,
309
+ credential: source.credentialRef,
310
+ warnings: [],
311
+ };
312
+ }
313
+
314
+ /**
315
+ * Refuses a checkout whose configured origin a rewrite expands into a URL
316
+ * with a credential. A credential the origin itself holds is stripped when
317
+ * the source resolves, so only the rewrite is checked here. A checkout with
318
+ * no readable origin is left to resolution to refuse.
319
+ */
320
+ async function requireNoOriginRewriteCredentials(path: string): Promise<void> {
321
+ const origin = await runGit(['config', '--get', 'remote.origin.url'], { cwd: path }).catch(
322
+ () => null,
323
+ );
324
+
325
+ const normalized = origin?.exitCode === 0 ? normalizeGitURL(origin.stdout) : null;
326
+
327
+ if (normalized?.ok === true) {
328
+ await requireNoURLCredentials(normalized.url, path);
329
+ }
330
+ }
331
+
332
+ async function requireNoURLCredentials(url: string, cwd: string): Promise<void> {
333
+ const finding = await checkURLCredentials(url, cwd);
334
+
335
+ if (!finding.ok) {
336
+ throw new DaemonError(finding.code, finding.message, { phase: 'resolving' });
337
+ }
338
+ }
339
+
340
+ /**
341
+ * Creates the target directory as the claim on it: `mkdir` without `-p`
342
+ * fails when the directory exists, so a materialization never unpacks over
343
+ * files it did not put there. The parent is created first.
344
+ */
345
+ async function claimTargetDir(
346
+ request: MaterializeRequest,
347
+ deps: MaterializeDeps,
348
+ updateProgress: ProgressTracker,
349
+ ): Promise<void> {
350
+ const parent = await deps
351
+ .requireProvider('run')
352
+ .runCommand({ argv: ['mkdir', '-p', '--', dirname(request.dir)], cwd: '/' });
353
+
354
+ if (parent.exitCode !== 0) {
355
+ throw new DaemonError(
356
+ 'transfer_failed',
357
+ `cannot create ${dirname(request.dir)} on target '${request.target}': ${parent.stderr.trim()}`,
358
+ { phase: 'resolving', dir: request.dir },
359
+ );
360
+ }
361
+
362
+ const claim = await deps
363
+ .requireProvider('run')
364
+ .runCommand({ argv: ['mkdir', '--', request.dir], cwd: '/' });
365
+
366
+ if (claim.exitCode !== 0) {
367
+ throw new DaemonError(
368
+ 'workspace_exists',
369
+ `${request.dir} already exists on target '${request.target}'; a workspace is materialized only into a directory that does not exist`,
370
+ { phase: 'resolving', dir: request.dir },
371
+ );
372
+ }
373
+
374
+ updateProgress({ claimed: true });
375
+ }
376
+
377
+ async function recordPhase(
378
+ request: MaterializeRequest,
379
+ deps: MaterializeDeps,
380
+ updateProgress: ProgressTracker,
381
+ phase: MaterializationPhase,
382
+ fields: Readonly<{
383
+ repoURL?: string;
384
+ sha?: string;
385
+ ref?: string | null;
386
+ materializedAt?: number;
387
+ }>,
388
+ ): Promise<void> {
389
+ await deps.store.updateMaterialization(request.sessionID, { phase, ...fields }, Date.now());
390
+
391
+ updateProgress({ phase });
392
+ }
393
+
394
+ interface CleanClone {
395
+ readonly sha: string;
396
+ readonly archive: Uint8Array;
397
+ }
398
+
399
+ /**
400
+ * Clones the pinned source into a staging directory on the daemon's host,
401
+ * sanitizes it, and reads it back as a tar archive.
402
+ */
403
+ async function createCleanClone(pinned: PinnedSource, dir: string): Promise<CleanClone> {
404
+ const clone = await createWorkspaceClone({
405
+ source: { kind: 'git', url: pinned.cloneURL, ref: pinned.checkout },
406
+ dir,
407
+ ...(pinned.credential === undefined ? {} : { credential: pinned.credential }),
408
+ });
409
+
410
+ if (!clone.ok) {
411
+ const { ok: _ok, code, message, ...detail } = clone;
412
+
413
+ throw new DaemonError(code, message, { phase: 'cloning', ...detail });
414
+ }
415
+
416
+ const sanitized = await sanitizeWorkspaceClone(dir, pinned.cloneURL);
417
+
418
+ if (!sanitized.ok) {
419
+ throw new DaemonError(sanitized.code, sanitized.message, { phase: 'cloning' });
420
+ }
421
+
422
+ const tar = readWorkspaceTar(dir);
423
+
424
+ const bytes = await new Response(tar.stream).arrayBuffer();
425
+
426
+ const archive = new Uint8Array(bytes);
427
+
428
+ const outcome = await tar.done;
429
+
430
+ if (!outcome.ok) {
431
+ throw new DaemonError(outcome.code, outcome.message, { phase: 'cloning' });
432
+ }
433
+
434
+ return { sha: clone.sha, archive };
435
+ }
436
+
437
+ function toDaemonError(error: unknown, code: ErrorCode, phase: MaterializationPhase): DaemonError {
438
+ if (error instanceof DaemonError) {
439
+ return error;
440
+ }
441
+
442
+ const reason = error instanceof Error ? error.message : String(error);
443
+
444
+ return new DaemonError(code, reason, { phase });
445
+ }
446
+
447
+ // The provider runs commands in its own environment, so the verify unsets
448
+ // every variable that could point git at another repository first.
449
+ const VERIFY_ENV = ['env', ...[...REPOSITORY_ENV_VARS].flatMap((name) => ['-u', name])];
450
+ const VERIFY_ARGV = ['git', 'rev-parse', '--verify', 'HEAD^{commit}'];
451
+
452
+ // Lists every tracked file whose content differs from HEAD, so a file the
453
+ // unpack left out or changed refuses the checkout whatever tar did there.
454
+ const STATUS_ARGV = [
455
+ 'git',
456
+ '-c',
457
+ 'core.fsmonitor=false',
458
+ 'status',
459
+ '--porcelain',
460
+ '--untracked-files=no',
461
+ ];
462
+
463
+ async function verifyTargetHead(
464
+ request: MaterializeRequest,
465
+ deps: MaterializeDeps,
466
+ sha: string,
467
+ ): Promise<void> {
468
+ const head = await deps
469
+ .requireProvider('run')
470
+ .runCommand({ argv: [...VERIFY_ENV, ...VERIFY_ARGV], cwd: request.dir });
471
+
472
+ const actual = head.exitCode === 0 ? head.stdout.trim() : null;
473
+
474
+ if (actual !== sha) {
475
+ throw new DaemonError(
476
+ 'workspace_mismatch',
477
+ `the checkout on target '${request.target}' is at ${actual ?? 'no commit'}, not ${sha}`,
478
+ { phase: 'verifying', expected: sha, actual },
479
+ );
480
+ }
481
+
482
+ const status = await deps
483
+ .requireProvider('run')
484
+ .runCommand({ argv: [...VERIFY_ENV, ...STATUS_ARGV], cwd: request.dir });
485
+
486
+ const changed = status.stdout.split('\n').filter((line) => line !== '');
487
+
488
+ if (status.exitCode !== 0 || changed.length > 0) {
489
+ throw new DaemonError(
490
+ 'workspace_mismatch',
491
+ `the checkout on target '${request.target}' does not match ${sha} in its tracked files: ${changed.slice(0, 5).join('; ') || status.stderr.trim()}`,
492
+ { phase: 'verifying', expected: sha, actual },
493
+ );
494
+ }
495
+ }
496
+
497
+ // The refusal a failure becomes, with the credential's value scrubbed from
498
+ // its message and every string in its data.
499
+ function toScrubbedRefusal(
500
+ error: unknown,
501
+ phase: MaterializationPhase,
502
+ secret: string | null,
503
+ ): DaemonError {
504
+ const refusal = toDaemonError(error, 'internal', phase);
505
+
506
+ if (secret === null) {
507
+ return refusal;
508
+ }
509
+
510
+ const data =
511
+ refusal.data === undefined
512
+ ? undefined
513
+ : Object.fromEntries(
514
+ Object.entries(refusal.data).map(([key, value]) => [
515
+ key,
516
+ typeof value === 'string' ? toRedacted(value, secret) : value,
517
+ ]),
518
+ );
519
+
520
+ return new DaemonError(refusal.code, toRedacted(refusal.message, secret), data);
521
+ }
522
+
523
+ function toRedacted(text: string, secret: string): string {
524
+ return text.replaceAll(secret, '[credential]');
525
+ }
526
+
527
+ /**
528
+ * Removes the target directory after a failure, when this call created it,
529
+ * so a failed materialization leaves no partial checkout behind. A removal
530
+ * that fails is logged; the directory then stays and blocks the next
531
+ * materialization into it with `workspace_exists`.
532
+ */
533
+ async function tryRemoveClaimedDir(
534
+ request: MaterializeRequest,
535
+ deps: MaterializeDeps,
536
+ progress: Readonly<MaterializationProgress>,
537
+ secret: string | null,
538
+ ): Promise<void> {
539
+ if (!progress.claimed) {
540
+ return;
541
+ }
542
+
543
+ try {
544
+ const removed = await deps
545
+ .requireProvider('run')
546
+ .runCommand({ argv: ['rm', '-rf', '--', request.dir], cwd: '/' });
547
+
548
+ if (removed.exitCode !== 0) {
549
+ throw new Error(removed.stderr.trim());
550
+ }
551
+ } catch (error) {
552
+ const reason = toScrubbedRefusal(error, progress.phase, secret).message;
553
+
554
+ deps.log(
555
+ `atc: cannot remove ${request.dir} after its workspace for session ${request.sessionID} failed: ${reason}`,
556
+ );
557
+ }
558
+ }
559
+
560
+ async function tryUpdateFailed(
561
+ request: MaterializeRequest,
562
+ deps: MaterializeDeps,
563
+ code: ErrorCode,
564
+ ): Promise<void> {
565
+ try {
566
+ await deps.store.updateMaterialization(
567
+ request.sessionID,
568
+ { phase: 'failed', errorCode: code },
569
+ Date.now(),
570
+ );
571
+ } catch {
572
+ // A row left short of failed is failed as interrupted at the next start.
573
+ }
574
+ }