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