@zgeoff/atc 2.16.0 → 2.19.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 (46) hide show
  1. package/package.json +1 -1
  2. package/src/cli.ts +24 -1
  3. package/src/client/build-workspace-destination.ts +38 -0
  4. package/src/client/collect-target-picks.ts +53 -0
  5. package/src/client/dirs.ts +1 -1
  6. package/src/client/pick-refusal-step.ts +29 -0
  7. package/src/client/resolve-workspace-root.ts +49 -0
  8. package/src/client/spawn-picker.ts +1683 -84
  9. package/src/client/ui.ts +6 -1
  10. package/src/daemon/daemon-connection.ts +129 -0
  11. package/src/daemon/daemon-context.ts +23 -0
  12. package/src/daemon/daemon.ts +29 -0
  13. package/src/daemon/materialize-workspace.ts +42 -18
  14. package/src/daemon/require-git-transports.ts +23 -0
  15. package/src/mcp/require-daemon-features.ts +2 -0
  16. package/src/protocol/daemon-features.ts +8 -0
  17. package/src/protocol/protocol.ts +2 -0
  18. package/src/protocol/request-param-schemas.ts +67 -15
  19. package/src/shared/collect-workspaces-config.ts +120 -0
  20. package/src/shared/config.ts +30 -1
  21. package/src/shared/default-git-transports.ts +5 -0
  22. package/src/shared/is-git-url.ts +11 -0
  23. package/src/sources/build-sources.ts +40 -0
  24. package/src/sources/collect-builtin-sources.ts +41 -0
  25. package/src/sources/dirs/build-dirs-source.ts +70 -0
  26. package/src/sources/git/build-git-source.ts +25 -0
  27. package/src/sources/github/build-github-source.ts +113 -0
  28. package/src/sources/github/collect-github-repos.ts +165 -0
  29. package/src/sources/github/find-github-alternate-url.ts +20 -0
  30. package/src/sources/github/read-git-protocol.ts +11 -0
  31. package/src/sources/github/run-gh.ts +66 -0
  32. package/src/sources/types.ts +70 -0
  33. package/src/workspace/check-git-transport.ts +61 -0
  34. package/src/workspace/check-repository-access.ts +108 -0
  35. package/src/workspace/collect-remote-refs.ts +154 -0
  36. package/src/workspace/create-git-askpass.ts +84 -0
  37. package/src/workspace/create-workspace-clone.ts +29 -76
  38. package/src/workspace/expand-git-shorthand.ts +13 -0
  39. package/src/workspace/find-remote-ref.ts +34 -0
  40. package/src/workspace/normalize-git-url.ts +2 -7
  41. package/src/workspace/resolve-git-url.ts +46 -0
  42. package/src/workspace/resolve-path-source.ts +11 -4
  43. package/src/workspace/run-git.ts +52 -4
  44. package/src/workspace/workspace-source.ts +10 -2
  45. /package/src/{client → shared}/collect-root-dirs.ts +0 -0
  46. /package/src/{client → shared}/collect-zoxide-dirs.ts +0 -0
package/src/client/ui.ts CHANGED
@@ -387,6 +387,9 @@ export interface PickerView {
387
387
  input: string;
388
388
  placeholder?: string;
389
389
  hint: string;
390
+
391
+ // The indexes of items drawn dim: listed, but not a fit for the flow.
392
+ dimmed?: ReadonlySet<number>;
390
393
  }
391
394
 
392
395
  export function drawPicker(view: PickerView) {
@@ -397,7 +400,9 @@ export function drawPicker(view: PickerView) {
397
400
  for (const [i, item] of shown.entries()) {
398
401
  const sel = i === view.selected;
399
402
  const t = truncate(item, width - 4);
400
- const styled = sel ? `${ESC}[7m${t.padEnd(width - 4)}${ESC}[0m` : t;
403
+ const dim = view.dimmed?.has(i) === true;
404
+ const inverse = sel ? `${ESC}[7m${t.padEnd(width - 4)}${ESC}[0m` : t;
405
+ const styled = dim ? `${ESC}[90m${inverse}${ESC}[0m` : inverse;
401
406
  const plainLen = sel ? width - 4 : t.length;
402
407
 
403
408
  rowsList.push(boxRow(width, styled, plainLen));
@@ -19,6 +19,7 @@ import type { AgentID } from '../shared/agent-id';
19
19
  import { isRecord } from '../shared/report';
20
20
  import type { SessionID } from '../shared/session-id';
21
21
  import { toSessionID } from '../shared/to-session-id';
22
+ import type { SourceProvider, SourceRequest } from '../sources/types';
22
23
  import { buildPayloadHash } from './build-payload-hash';
23
24
  import { buildScopedContext } from './build-scoped-context';
24
25
  import { buildTargetForbiddenError } from './build-target-forbidden-error';
@@ -43,6 +44,18 @@ interface ViewUpdate {
43
44
  readonly withdrawn: readonly SessionID[];
44
45
  }
45
46
 
47
+ // What a source request holds: the source's id and the target it is for.
48
+ interface SourceParams {
49
+ readonly source: string;
50
+ readonly target?: string | undefined;
51
+ }
52
+
53
+ // The source a request asks for, and the request the source serves.
54
+ interface ResolvedSourceRequest {
55
+ readonly source: SourceProvider;
56
+ readonly request: SourceRequest;
57
+ }
58
+
46
59
  interface PeerSocket extends SocketWriter {
47
60
  readonly end: () => void;
48
61
  }
@@ -409,6 +422,21 @@ export class DaemonConnection {
409
422
 
410
423
  return;
411
424
  }
425
+ case 'sources.list': {
426
+ await this.applySourcesList(req, ctx);
427
+
428
+ return;
429
+ }
430
+ case 'sources.interpret': {
431
+ await this.applySourcesInterpret(req, ctx);
432
+
433
+ return;
434
+ }
435
+ case 'git.probe': {
436
+ await this.applyGitProbe(req, ctx);
437
+
438
+ return;
439
+ }
412
440
  case 'session.spawn': {
413
441
  await this.applySpawn(req, ctx);
414
442
 
@@ -794,6 +822,107 @@ export class DaemonConnection {
794
822
  this.sendOk(req.id, spawned);
795
823
  }
796
824
 
825
+ // Lists one source's candidates for a spawn to the request's target.
826
+ private async applySourcesList(req: RequestMsg, ctx: DaemonContext): Promise<void> {
827
+ const parsed = parseRequestParams('sources.list', req.p);
828
+
829
+ if (!parsed.ok) {
830
+ this.sendErr(req.id, 'bad_args', parsed.message);
831
+
832
+ return;
833
+ }
834
+
835
+ const resolved = this.resolveSourceRequest(parsed.data, ctx);
836
+ const source = resolved.source;
837
+
838
+ const listed = await source.list(
839
+ {
840
+ ...(parsed.data.scope === undefined ? {} : { scope: parsed.data.scope }),
841
+ ...(parsed.data.text === undefined ? {} : { text: parsed.data.text }),
842
+ },
843
+ resolved.request,
844
+ );
845
+
846
+ this.sendOk(req.id, { source: source.id, scope: listed.scope, candidates: listed.candidates });
847
+ }
848
+
849
+ // Reads typed input through one source for a spawn to the request's
850
+ // target.
851
+ private async applySourcesInterpret(req: RequestMsg, ctx: DaemonContext): Promise<void> {
852
+ const parsed = parseRequestParams('sources.interpret', req.p);
853
+
854
+ if (!parsed.ok) {
855
+ this.sendErr(req.id, 'bad_args', parsed.message);
856
+
857
+ return;
858
+ }
859
+
860
+ const resolved = this.resolveSourceRequest(parsed.data, ctx);
861
+
862
+ const interpretation = await resolved.source.interpret(parsed.data.input, resolved.request);
863
+
864
+ this.sendOk(req.id, { ...interpretation });
865
+ }
866
+
867
+ // The source a request asks for and the request it serves, so a principal
868
+ // reaches a source only for a target it may spawn on, and a git source
869
+ // only for a target it may spawn a workspace on.
870
+ private resolveSourceRequest(params: SourceParams, ctx: DaemonContext): ResolvedSourceRequest {
871
+ const source = ctx.findSource(params.source);
872
+
873
+ if (source === null) {
874
+ throw new DaemonError('unsupported', `this daemon offers no source '${params.source}'`);
875
+ }
876
+
877
+ const target = ctx.resolveSpawnTarget(params.target);
878
+
879
+ if (source.kind === 'git') {
880
+ this.ctx.requireWorkspaceTarget(target);
881
+ }
882
+
883
+ // The spawn history is read through the request's own view, so a
884
+ // principal lists only directories spawned on targets it may use.
885
+ return { source, request: { target, collectSpawnDirs: () => ctx.collectSpawnDirs(null) } };
886
+ }
887
+
888
+ // Probes a git workspace source for a spawn to the request's target, so
889
+ // a principal probes only for a target it may spawn a workspace on. A
890
+ // refusal holds the repository's other URLs that the offered sources know.
891
+ private async applyGitProbe(req: RequestMsg, ctx: DaemonContext): Promise<void> {
892
+ const parsed = parseRequestParams('git.probe', req.p);
893
+
894
+ if (!parsed.ok) {
895
+ this.sendErr(req.id, 'bad_args', parsed.message);
896
+
897
+ return;
898
+ }
899
+
900
+ const data = parsed.data;
901
+
902
+ this.ctx.requireWorkspaceTarget(ctx.resolveSpawnTarget(data.target));
903
+
904
+ const access = await ctx.checkRepositoryAccess({
905
+ url: data.url,
906
+ ref: data.ref,
907
+ sha: data.sha,
908
+ credential: data.credentialRef,
909
+ });
910
+
911
+ if (!access.ok) {
912
+ const alternates = ctx.collectAlternateGitURLs(data.url);
913
+ const detail = alternates.length === 0 ? undefined : { alternates };
914
+
915
+ throw new DaemonError(access.code, access.message, detail);
916
+ }
917
+
918
+ this.sendOk(req.id, {
919
+ url: access.url,
920
+ head: access.head,
921
+ refs: access.refs,
922
+ resolved: access.resolved,
923
+ });
924
+ }
925
+
797
926
  private applyAttach(req: RequestMsg, ctx: DaemonContext): void {
798
927
  const parsed = parseRequestParams('session.attach', req.p);
799
928
 
@@ -6,9 +6,11 @@ import type { TargetConfigError } from '../shared/collect-targets';
6
6
  import type { DaemonID } from '../shared/daemon-id';
7
7
  import type { MessageID } from '../shared/message-id';
8
8
  import type { SessionID } from '../shared/session-id';
9
+ import type { SourceKind, SourceProvider } from '../sources/types';
9
10
  import type { FleetEntry } from '../store/fleet-entry';
10
11
  import type { MessageRecord } from '../store/message-record';
11
12
  import type { TurnSibling } from '../store/state-store';
13
+ import type { checkRepositoryAccess } from '../workspace/check-repository-access';
12
14
  import type { Dims } from './attach-registry';
13
15
  import type { AgentEntry } from './build-agent-list';
14
16
  import type { FleetEvent } from './build-fleet-events';
@@ -79,6 +81,13 @@ interface AgentList {
79
81
  readonly spawnDefaults: { readonly agent: AgentID; readonly target: string | null };
80
82
  readonly configRevision: string;
81
83
  readonly targetErrors: readonly TargetConfigError[];
84
+
85
+ // The sources the spawn picker offers, in order.
86
+ readonly sources: readonly {
87
+ readonly id: string;
88
+ readonly label: string;
89
+ readonly kind: SourceKind;
90
+ }[];
82
91
  }
83
92
 
84
93
  // The slice of a connection the attach bookkeeping needs: identity plus the
@@ -163,6 +172,20 @@ export interface DaemonContext {
163
172
  // one whose provider cannot both transfer an archive and run a command.
164
173
  readonly requireWorkspaceTarget: (target: string) => void;
165
174
 
175
+ // The source the daemon offers the spawn picker under an id, or null when
176
+ // it offers none under it.
177
+ readonly findSource: (id: string) => SourceProvider | null;
178
+
179
+ // The other URLs the offered sources know for a git URL the daemon's host
180
+ // could not read, each once.
181
+ readonly collectAlternateGitURLs: (url: string) => readonly string[];
182
+
183
+ // Checks that the daemon's host can read a git workspace source, and
184
+ // resolves its ref, the way a workspace spawn from it would.
185
+ readonly checkRepositoryAccess: (
186
+ request: Omit<Parameters<typeof checkRepositoryAccess>[0], 'transports'>,
187
+ ) => ReturnType<typeof checkRepositoryAccess>;
188
+
166
189
  // Runs the plan, which throws the refusal for a spawn it refuses, then
167
190
  // spawns. Answers with the `session.spawn` ok payload, which a keyed
168
191
  // retry replays as the first spawn answered it while the access, when
@@ -13,12 +13,15 @@ import type { SpawnWorkspaceSource } from '../protocol/request-param-schemas';
13
13
  import type { SessionState } from '../protocol/session-state';
14
14
  import type { HooksConfig } from '../shared/collect-hooks';
15
15
  import type { TargetConfigError } from '../shared/collect-targets';
16
+ import type { InvalidGitTransports } from '../shared/collect-workspaces-config';
17
+ import { DEFAULT_GIT_TRANSPORTS } from '../shared/default-git-transports';
16
18
  import { findDaemonRecord } from '../shared/find-daemon-record';
17
19
  import type { MessageID } from '../shared/message-id';
18
20
  import { isRecord } from '../shared/report';
19
21
  import type { SessionID } from '../shared/session-id';
20
22
  import { toMessageID } from '../shared/to-message-id';
21
23
  import { truncateToBytes } from '../shared/truncate-to-bytes';
24
+ import type { SourceProvider } from '../sources/types';
22
25
  import type { IdempotencyRecord } from '../store/idempotency-record';
23
26
  import type { MessageOwner } from '../store/message-owner';
24
27
  import type { MessageRecord, MessageStatus } from '../store/message-record';
@@ -26,6 +29,7 @@ import { StateStore } from '../store/state-store';
26
29
  import type { EventScope } from '../store/state-store';
27
30
  import type { TrailEntry } from '../store/trail-entry';
28
31
  import type { SessionWorkspace } from '../store/workspace-materialization';
32
+ import { checkRepositoryAccess } from '../workspace/check-repository-access';
29
33
  import { ANSWER_BYTE_CAP } from './answer-byte-cap';
30
34
  import { AttachRegistry } from './attach-registry';
31
35
  import { buildAgentList } from './build-agent-list';
@@ -68,6 +72,7 @@ import { mintMessageID } from './mint-message-id';
68
72
  import { mintSessionID } from './mint-session-id';
69
73
  import { parseReport } from './parse-report';
70
74
  import { PermissionRegistry } from './permission-registry';
75
+ import { requireGitTransports } from './require-git-transports';
71
76
  import { restoreFleet } from './restore-fleet';
72
77
  import { runEjectHandoff } from './run-eject-handoff';
73
78
  import { ScreenModel } from './screen-model';
@@ -153,6 +158,15 @@ export interface DaemonOptions {
153
158
  // Where background failures are reported, one line at a time; stderr
154
159
  // when unset.
155
160
  readonly log?: (line: string) => void;
161
+
162
+ // The sources the spawn picker offers, in order, each built with the
163
+ // services it uses; none when unset.
164
+ readonly sources?: readonly SourceProvider[];
165
+
166
+ // The transports a git workspace source may use and git may fetch over,
167
+ // or the invalid list the config holds, which refuses every git
168
+ // operation; https and ssh when unset.
169
+ readonly gitTransports?: readonly string[] | InvalidGitTransports;
156
170
  }
157
171
 
158
172
  export interface DaemonHandle {
@@ -236,6 +250,7 @@ export async function startDaemon(opts: DaemonOptions): Promise<DaemonHandle> {
236
250
 
237
251
  const targetErrors = opts.targetErrors ?? [];
238
252
  const principals = opts.principals ?? null;
253
+ const gitTransports = opts.gitTransports ?? DEFAULT_GIT_TRANSPORTS;
239
254
 
240
255
  const targetsByID = new Map(targets.map((target) => [target.id, target]));
241
256
 
@@ -911,6 +926,7 @@ export async function startDaemon(opts: DaemonOptions): Promise<DaemonHandle> {
911
926
  mgr.log(line);
912
927
  },
913
928
  stagingRoot: tmpdir(),
929
+ gitTransports,
914
930
  },
915
931
  );
916
932
  };
@@ -1192,6 +1208,8 @@ export async function startDaemon(opts: DaemonOptions): Promise<DaemonHandle> {
1192
1208
  }
1193
1209
  };
1194
1210
 
1211
+ const sources = opts.sources ?? [];
1212
+
1195
1213
  const ctx: DaemonContext = {
1196
1214
  build: opts.build,
1197
1215
  daemonID: store.daemonID,
@@ -1215,6 +1233,11 @@ export async function startDaemon(opts: DaemonOptions): Promise<DaemonHandle> {
1215
1233
  spawnDefaults: { agent: 'claude', target: defaultTarget },
1216
1234
  configRevision,
1217
1235
  targetErrors,
1236
+ sources: sources.map((source) => ({
1237
+ id: source.id,
1238
+ label: source.label,
1239
+ kind: source.kind,
1240
+ })),
1218
1241
  }),
1219
1242
  collectFleet: () => store.loadFleet(),
1220
1243
  loadLastUsedAgent: () => store.loadLastUsedAgent(),
@@ -1256,6 +1279,12 @@ export async function startDaemon(opts: DaemonOptions): Promise<DaemonHandle> {
1256
1279
  mgr.requireExecution({ target, targetIdentity: null }, 'transfer');
1257
1280
  mgr.requireExecution({ target, targetIdentity: null }, 'run');
1258
1281
  },
1282
+ findSource: (id) => sources.find((source) => source.id === id) ?? null,
1283
+ collectAlternateGitURLs: (url) => [
1284
+ ...new Set(sources.flatMap((source) => source.findAlternateURLs?.(url) ?? [])),
1285
+ ],
1286
+ checkRepositoryAccess: (request) =>
1287
+ checkRepositoryAccess({ ...request, transports: requireGitTransports(gitTransports) }),
1259
1288
  spawnSession: (plan, keyed, access) => {
1260
1289
  // Under an access, a spawn under a parent whose tree leaves the access
1261
1290
  // before the harness starts is refused as a spawn under an unknown
@@ -3,18 +3,22 @@ import { dirname, join, resolve } from 'node:path';
3
3
  import { DaemonError } from '../protocol/daemon-error';
4
4
  import type { ErrorCode } from '../protocol/protocol';
5
5
  import type { SpawnWorkspaceSource } from '../protocol/request-param-schemas';
6
+ import type { InvalidGitTransports } from '../shared/collect-workspaces-config';
6
7
  import type { SessionID } from '../shared/session-id';
7
8
  import type { StateStore } from '../store/state-store';
8
9
  import type { MaterializationPhase, SessionWorkspace } from '../store/workspace-materialization';
9
10
  import { checkURLCredentials } from '../workspace/check-url-credentials';
10
11
  import { createWorkspaceClone } from '../workspace/create-workspace-clone';
12
+ import { expandGitShorthand } from '../workspace/expand-git-shorthand';
11
13
  import { normalizeGitURL } from '../workspace/normalize-git-url';
12
14
  import { readWorkspaceTar } from '../workspace/read-workspace-tar';
13
15
  import { REPOSITORY_ENV_VARS } from '../workspace/repository-env-vars';
16
+ import { resolveGitURL } from '../workspace/resolve-git-url';
14
17
  import { resolvePathSource } from '../workspace/resolve-path-source';
15
18
  import { runGit } from '../workspace/run-git';
16
19
  import { sanitizeWorkspaceClone } from '../workspace/sanitize-workspace-clone';
17
20
  import type { ExecutionProvider } from './execution-provider';
21
+ import { requireGitTransports } from './require-git-transports';
18
22
 
19
23
  interface MaterializeRequest {
20
24
  readonly sessionID: SessionID;
@@ -39,6 +43,10 @@ interface MaterializeDeps {
39
43
  // The directory on the daemon's host that holds each clone's staging
40
44
  // directory while the workspace is built.
41
45
  readonly stagingRoot: string;
46
+
47
+ // The transports a source may use and git may fetch over, or the invalid
48
+ // list the config holds, which refuses every source that needs git.
49
+ readonly gitTransports: readonly string[] | InvalidGitTransports;
42
50
  }
43
51
 
44
52
  type MaterializedWorkspace = { readonly kind: 'in_place' } | ReadyWorkspace;
@@ -203,7 +211,9 @@ async function runMaterialization(
203
211
  updateProgress: ProgressTracker,
204
212
  secret: string | null,
205
213
  ): Promise<Omit<ReadyWorkspace, 'withheldEnv'>> {
206
- const pinned = await resolveSource(request.source, staging);
214
+ const transports = requireGitTransports(deps.gitTransports, { phase: 'resolving' });
215
+
216
+ const pinned = await resolveSource(request.source, staging, transports);
207
217
 
208
218
  // What is recorded and returned is scrubbed of the credential, even
209
219
  // where a caller's own ref happens to spell it.
@@ -213,7 +223,7 @@ async function runMaterialization(
213
223
  await claimTargetDir(request, deps, updateProgress);
214
224
  await recordPhase(request, deps, updateProgress, 'cloning', { repoURL, ref });
215
225
 
216
- const clone = await createCleanClone(pinned, join(staging, 'clone'));
226
+ const clone = await createCleanClone(pinned, join(staging, 'clone'), transports);
217
227
 
218
228
  // The archive is in memory, so the clone leaves the daemon's host before
219
229
  // the target is touched.
@@ -251,8 +261,10 @@ interface PinnedSource {
251
261
  readonly cloneURL: string;
252
262
  readonly repoURL: string;
253
263
 
254
- // The ref or commit the clone checks out, and the branch or tag recorded.
264
+ // The ref or commit the clone checks out, the commit it is pinned to when
265
+ // the ref only names its branch, and the branch or tag recorded.
255
266
  readonly checkout: string;
267
+ readonly sha?: string | undefined;
256
268
  readonly ref: string | null;
257
269
  readonly credential: { readonly kind: 'env'; readonly name: string } | undefined;
258
270
  readonly warnings: readonly string[];
@@ -263,7 +275,11 @@ interface PinnedSource {
263
275
  * not a pushed, complete, credential-free repository. A path source pins
264
276
  * its pushed HEAD; a git source keeps its ref, which the clone pins.
265
277
  */
266
- async function resolveSource(source: SpawnWorkspaceSource, staging: string): Promise<PinnedSource> {
278
+ async function resolveSource(
279
+ source: SpawnWorkspaceSource,
280
+ staging: string,
281
+ transports: readonly string[],
282
+ ): Promise<PinnedSource> {
267
283
  if (source.kind === 'path') {
268
284
  // The origin is checked as configured, before resolving it applies any
269
285
  // rewrite: a rewrite the checkout's own config holds expands it too.
@@ -271,6 +287,7 @@ async function resolveSource(source: SpawnWorkspaceSource, staging: string): Pro
271
287
 
272
288
  const resolved = await resolvePathSource(source.path, {
273
289
  allowDirty: source.allowDirty ?? 'refuse',
290
+ transports,
274
291
  });
275
292
 
276
293
  if (!resolved.ok) {
@@ -289,22 +306,19 @@ async function resolveSource(source: SpawnWorkspaceSource, staging: string): Pro
289
306
  };
290
307
  }
291
308
 
292
- await requireNoURLCredentials(source.url, staging);
309
+ // The clone fetches the URL it records, so the spawn API's `owner/repo`
310
+ // shorthand reaches the repository it expands to.
311
+ const resolved = await resolveGitURL(expandGitShorthand(source.url), staging, transports);
293
312
 
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' });
313
+ if (!resolved.ok) {
314
+ throw new DaemonError(resolved.code, resolved.message, { phase: 'resolving' });
300
315
  }
301
316
 
302
- await requireNoURLCredentials(normalized.url, staging);
303
-
304
317
  return {
305
- cloneURL: normalized.url,
306
- repoURL: normalized.url,
307
- checkout: source.sha ?? source.ref ?? '',
318
+ cloneURL: resolved.url,
319
+ repoURL: resolved.url,
320
+ checkout: source.ref ?? source.sha ?? '',
321
+ sha: source.ref === undefined ? undefined : source.sha,
308
322
  ref: source.ref ?? null,
309
323
  credential: source.credentialRef,
310
324
  warnings: [],
@@ -400,10 +414,20 @@ interface CleanClone {
400
414
  * Clones the pinned source into a staging directory on the daemon's host,
401
415
  * sanitizes it, and reads it back as a tar archive.
402
416
  */
403
- async function createCleanClone(pinned: PinnedSource, dir: string): Promise<CleanClone> {
417
+ async function createCleanClone(
418
+ pinned: PinnedSource,
419
+ dir: string,
420
+ transports: readonly string[],
421
+ ): Promise<CleanClone> {
404
422
  const clone = await createWorkspaceClone({
405
- source: { kind: 'git', url: pinned.cloneURL, ref: pinned.checkout },
423
+ source: {
424
+ kind: 'git',
425
+ url: pinned.cloneURL,
426
+ ref: pinned.checkout,
427
+ ...(pinned.sha === undefined ? {} : { sha: pinned.sha }),
428
+ },
406
429
  dir,
430
+ transports,
407
431
  ...(pinned.credential === undefined ? {} : { credential: pinned.credential }),
408
432
  });
409
433
 
@@ -0,0 +1,23 @@
1
+ import { DaemonError } from '../protocol/daemon-error';
2
+ import type { InvalidGitTransports } from '../shared/collect-workspaces-config';
3
+
4
+ /**
5
+ * The transports git may fetch over, or the refusal of a git operation
6
+ * when the configured list is invalid: the daemon then runs no git at all,
7
+ * and the error quotes the config errors it printed at startup, with
8
+ * `data` as its detail.
9
+ */
10
+ export function requireGitTransports(
11
+ policy: readonly string[] | InvalidGitTransports,
12
+ data?: Readonly<Record<string, unknown>>,
13
+ ): readonly string[] {
14
+ if ('invalid' in policy) {
15
+ throw new DaemonError(
16
+ 'git_transports_invalid',
17
+ `workspaces.gitTransports in config.json is invalid, so the daemon runs no git: ${policy.invalid}`,
18
+ data,
19
+ );
20
+ }
21
+
22
+ return policy;
23
+ }
@@ -19,6 +19,8 @@ const FEATURE_USES: Readonly<Record<DaemonFeature, string>> = {
19
19
  'report.get': 'atc_report_get',
20
20
  'request.principal': 'the target limits of a remote MCP client',
21
21
  'spawn.workspace': "atc_session_spawn's workspace",
22
+ sources: 'sources.list and sources.interpret',
23
+ 'git.probe': 'git.probe',
22
24
  };
23
25
 
24
26
  /**
@@ -56,6 +56,14 @@ export const DAEMON_FEATURES = [
56
56
 
57
57
  // `report.get` exists.
58
58
  'report.get',
59
+
60
+ // `sources.list` and `sources.interpret` exist, and `agents.list` returns
61
+ // `sources`.
62
+ 'sources',
63
+
64
+ // `git.probe` exists, and `session.spawn` takes a git workspace with both
65
+ // `ref` and `sha`.
66
+ 'git.probe',
59
67
  ] as const;
60
68
 
61
69
  export type DaemonFeature = (typeof DAEMON_FEATURES)[number];
@@ -34,6 +34,7 @@ const ERROR_CODES = [
34
34
  'workspace_dirty',
35
35
  'no_origin',
36
36
  'invalid_git_url',
37
+ 'git_transports_invalid',
37
38
  'unpushed_head',
38
39
  'credential_in_url',
39
40
  'credential_missing',
@@ -44,6 +45,7 @@ const ERROR_CODES = [
44
45
  'workspace_exists',
45
46
  'transfer_failed',
46
47
  'workspace_mismatch',
48
+ 'github_unavailable',
47
49
  'host_unavailable',
48
50
  'auth_not_configured',
49
51
  'host_leased',
@@ -32,15 +32,30 @@ const CREDENTIAL_REF = z.strictObject({
32
32
  name: z.string().regex(/^[A-Za-z_]\w*$/u, 'a credentialRef names an environment variable'),
33
33
  });
34
34
 
35
+ // A git workspace's repository URL, which git must never read as an option.
36
+ const GIT_URL = z
37
+ .string({ error: 'a git workspace requires a url' })
38
+ .min(1, 'a git workspace requires a url')
39
+ .refine((url) => !url.startsWith('-'), 'a git workspace url must not start with -');
40
+
41
+ // A git workspace's branch or tag, which git must never read as an option.
42
+ const GIT_REF = z
43
+ .string()
44
+ .min(1, 'a git workspace ref must not be empty')
45
+ .refine((ref) => !ref.startsWith('-'), 'a git workspace ref must not start with -');
46
+
47
+ const GIT_SHA = z.string().regex(COMMIT_ID, 'a git workspace sha is a full commit id');
48
+
35
49
  /**
36
50
  * Where a spawn's working directory comes from, materialized as a clean
37
51
  * checkout into the spawn's `cwd` on its execution target. A `path` source
38
52
  * is a directory on the daemon's host, resolved to its origin URL and
39
53
  * pushed HEAD; `allowDirty: 'warn'` resolves a tree with uncommitted
40
54
  * changes to HEAD and leaves the changes behind with a warning. A `git`
41
- * source is a repository URL with exactly one of a branch or tag `ref` or
42
- * a full commit `sha`, and an optional `credentialRef` naming the daemon
43
- * environment variable that holds its token.
55
+ * source is a repository URL with a branch or tag `ref`, a full commit
56
+ * `sha`, or both, and an optional `credentialRef` naming the daemon
57
+ * environment variable that holds its token. With both, the sha is the
58
+ * commit checked out and the ref is recorded as what it was resolved from.
44
59
  */
45
60
  const WORKSPACE_SOURCE = z.discriminatedUnion('kind', [
46
61
  z.strictObject({
@@ -53,23 +68,33 @@ const WORKSPACE_SOURCE = z.discriminatedUnion('kind', [
53
68
  z
54
69
  .strictObject({
55
70
  kind: z.literal('git'),
56
- url: z
57
- .string({ error: 'a git workspace requires a url' })
58
- .min(1, 'a git workspace requires a url')
59
- .refine((url) => !url.startsWith('-'), 'a git workspace url must not start with -'),
60
- ref: z
61
- .string()
62
- .min(1, 'a git workspace ref must not be empty')
63
- .refine((ref) => !ref.startsWith('-'), 'a git workspace ref must not start with -')
64
- .optional(),
65
- sha: z.string().regex(COMMIT_ID, 'a git workspace sha is a full commit id').optional(),
71
+ url: GIT_URL,
72
+ ref: GIT_REF.optional(),
73
+ sha: GIT_SHA.optional(),
66
74
  credentialRef: CREDENTIAL_REF.optional(),
67
75
  })
68
- .refine((source) => (source.ref === undefined) !== (source.sha === undefined), {
69
- message: 'a git workspace takes exactly one of ref or sha',
76
+ .refine((source) => source.ref !== undefined || source.sha !== undefined, {
77
+ message: 'a git workspace takes a ref, a sha, or both',
70
78
  }),
71
79
  ]);
72
80
 
81
+ // The execution target a request acts for; absent is the default target.
82
+ const TARGET = z
83
+ .string({ error: 'target must be a non-empty target id' })
84
+ .min(1, 'target must be a non-empty target id')
85
+ .optional();
86
+
87
+ // The id of a source the daemon offers the spawn picker.
88
+ const SOURCE_ID = z
89
+ .string({ error: 'source must be a source id' })
90
+ .min(1, 'source must be a source id')
91
+ .max(64, 'source must be a source id');
92
+
93
+ // Text a source reads, at most 4096 characters.
94
+ const SOURCE_TEXT = z
95
+ .string({ error: 'source text must be a string' })
96
+ .max(4096, 'source text must be at most 4096 characters');
97
+
73
98
  export type SpawnWorkspaceSource = z.infer<typeof WORKSPACE_SOURCE>;
74
99
 
75
100
  // The refusal of a terminal size outside the range a terminal takes.
@@ -88,6 +113,33 @@ export const REQUEST_PARAM_SCHEMAS = {
88
113
  'session.list': z.object({}),
89
114
  'dirs.list': z.object({}),
90
115
  'agents.list': z.object({}),
116
+
117
+ // One source's candidates for a spawn to the target, under the scope the
118
+ // source defines and filtered by text when it filters.
119
+ 'sources.list': z.object({
120
+ source: SOURCE_ID,
121
+ target: TARGET,
122
+ scope: SOURCE_TEXT.min(1, 'scope must be non-empty').optional(),
123
+ text: SOURCE_TEXT.optional(),
124
+ }),
125
+
126
+ // What one source reads typed input as, for a spawn to the target.
127
+ 'sources.interpret': z.object({ source: SOURCE_ID, input: SOURCE_TEXT, target: TARGET }),
128
+
129
+ // Whether the daemon's host can read a git workspace source the way a
130
+ // spawn to the target would, with its refs and the commit a ref or sha
131
+ // selects; at most one of the two.
132
+ 'git.probe': z
133
+ .object({
134
+ url: GIT_URL,
135
+ ref: GIT_REF.optional(),
136
+ sha: GIT_SHA.optional(),
137
+ credentialRef: CREDENTIAL_REF.optional(),
138
+ target: TARGET,
139
+ })
140
+ .refine((probe) => probe.ref === undefined || probe.sha === undefined, {
141
+ message: 'git.probe takes at most one of ref or sha',
142
+ }),
91
143
  'fleet.list': z.object({}),
92
144
  'fleet.restore': z.object({
93
145
  cols: buildTerminalSize(80),