@zgeoff/atc 2.15.2 → 2.17.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 (60) hide show
  1. package/package.json +2 -2
  2. package/src/agents/build-hook-settings.ts +12 -3
  3. package/src/agents/print-codex-hook-file.ts +1 -1
  4. package/src/agents/print-grok-hook-file.ts +1 -1
  5. package/src/cli.ts +39 -5
  6. package/src/client/collect-target-picks.ts +53 -0
  7. package/src/client/dirs.ts +1 -1
  8. package/src/client/spawn-picker.ts +132 -6
  9. package/src/client/ui.ts +6 -1
  10. package/src/daemon/broker-authority-error.ts +41 -0
  11. package/src/daemon/collect-secret-rule-mismatches.ts +83 -0
  12. package/src/daemon/daemon-connection.ts +129 -0
  13. package/src/daemon/daemon-context.ts +23 -0
  14. package/src/daemon/daemon.ts +46 -1
  15. package/src/daemon/imp-client-port.ts +75 -3
  16. package/src/daemon/imp-port.ts +60 -0
  17. package/src/daemon/is-imp-name-allowed.ts +38 -0
  18. package/src/daemon/is-own-hook-event.ts +19 -0
  19. package/src/daemon/materialize-workspace.ts +42 -18
  20. package/src/daemon/parse-hook-line.ts +3 -0
  21. package/src/daemon/require-git-transports.ts +23 -0
  22. package/src/daemon/session-runtime.ts +6 -0
  23. package/src/daemon/verify-broker-authority.ts +52 -0
  24. package/src/daemon/verify-cleanup-authority.ts +44 -0
  25. package/src/daemon/verify-token-imp-authority.ts +74 -0
  26. package/src/hook-report.ts +6 -4
  27. package/src/mcp/require-daemon-features.ts +2 -0
  28. package/src/protocol/daemon-features.ts +8 -0
  29. package/src/protocol/hook-event.ts +6 -2
  30. package/src/protocol/protocol.ts +2 -0
  31. package/src/protocol/request-param-schemas.ts +67 -15
  32. package/src/shared/collect-workspaces-config.ts +105 -0
  33. package/src/shared/config.ts +14 -0
  34. package/src/shared/default-git-transports.ts +5 -0
  35. package/src/shared/is-git-url.ts +11 -0
  36. package/src/sources/build-sources.ts +40 -0
  37. package/src/sources/collect-builtin-sources.ts +41 -0
  38. package/src/sources/dirs/build-dirs-source.ts +70 -0
  39. package/src/sources/git/build-git-source.ts +25 -0
  40. package/src/sources/github/build-github-source.ts +113 -0
  41. package/src/sources/github/collect-github-repos.ts +165 -0
  42. package/src/sources/github/find-github-alternate-url.ts +20 -0
  43. package/src/sources/github/read-git-protocol.ts +11 -0
  44. package/src/sources/github/run-gh.ts +66 -0
  45. package/src/sources/types.ts +70 -0
  46. package/src/statusline.ts +8 -6
  47. package/src/workspace/check-git-transport.ts +61 -0
  48. package/src/workspace/check-repository-access.ts +108 -0
  49. package/src/workspace/collect-remote-refs.ts +154 -0
  50. package/src/workspace/create-git-askpass.ts +84 -0
  51. package/src/workspace/create-workspace-clone.ts +29 -76
  52. package/src/workspace/expand-git-shorthand.ts +13 -0
  53. package/src/workspace/find-remote-ref.ts +34 -0
  54. package/src/workspace/normalize-git-url.ts +2 -7
  55. package/src/workspace/resolve-git-url.ts +46 -0
  56. package/src/workspace/resolve-path-source.ts +11 -4
  57. package/src/workspace/run-git.ts +52 -4
  58. package/src/workspace/workspace-source.ts +10 -2
  59. /package/src/{client → shared}/collect-root-dirs.ts +0 -0
  60. /package/src/{client → shared}/collect-zoxide-dirs.ts +0 -0
@@ -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
 
@@ -23,8 +23,11 @@ export function parseHookLine(line: string): HookEvent | null {
23
23
  return null;
24
24
  }
25
25
 
26
+ const agent = parsed['agent'];
27
+
26
28
  return {
27
29
  atcId: toSessionID(parsed['atcId']),
30
+ ...(typeof agent === 'string' && agent !== '' ? { agent } : {}),
28
31
  event: parsed['event'],
29
32
  payload: parsed['payload'],
30
33
  };
@@ -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
+ }
@@ -46,6 +46,11 @@ export class SessionRuntime {
46
46
  // later dropped may be restarting, so it keeps the inbox open.
47
47
  tapAttached = false;
48
48
 
49
+ // Whether a hook line carrying the session's agent arrived since the
50
+ // terminal last booted. Once one has, the session's own hooks are known
51
+ // to carry it, and a line without one comes from another harness.
52
+ hasAgentHookLines = false;
53
+
49
54
  // Returns the boot-scoped state to how a fresh terminal starts, at the
50
55
  // dims it boots with: no SessionStart yet and no tap since. Every path
51
56
  // that boots a new terminal for an existing session runs it, so a revived
@@ -54,6 +59,7 @@ export class SessionRuntime {
54
59
  this.dims = dims;
55
60
  this.startedAt = null;
56
61
  this.tapAttached = false;
62
+ this.hasAgentHookLines = false;
57
63
  }
58
64
 
59
65
  dispose(): void {
@@ -0,0 +1,52 @@
1
+ import { BrokerAuthorityError } from './broker-authority-error';
2
+ import type { ImpPort } from './imp-port';
3
+ import { verifyTokenImpAuthority } from './verify-token-imp-authority';
4
+
5
+ /**
6
+ * What a provisioning call is about to touch: the imps under their actual
7
+ * names, as the target's runtime namespace builds them, and every secret
8
+ * the binding grants them.
9
+ */
10
+ export interface BrokerActivation {
11
+ readonly impNames: readonly string[];
12
+ readonly secrets: readonly string[];
13
+ }
14
+
15
+ /**
16
+ * The gate before atc grants or relies on a brokered credential. It reads
17
+ * impd's features, then the token's identity, and writes nothing, so a
18
+ * refusal leaves impd as it was. It rejects unless impd has both grantable
19
+ * tokens and secret rebinds, the token may manage each imp and reaches no
20
+ * imp outside the namespace whose imp names start with the prefix, and the
21
+ * token may grant every bound secret.
22
+ * Whether each secret's rules match the binding is a separate comparison.
23
+ */
24
+ export async function verifyBrokerAuthority(
25
+ port: Pick<ImpPort, 'readFeatures' | 'readIdentity'>,
26
+ activation: BrokerActivation,
27
+ impPrefix: string,
28
+ ): Promise<void> {
29
+ const features = await port.readFeatures();
30
+
31
+ if (!features.grantableTokens || !features.secretRebind) {
32
+ throw new BrokerAuthorityError(
33
+ 'auth_impd_too_old',
34
+ 'impd lacks grantable tokens or secret rebinds; it must be 0.27.0 or later',
35
+ { grantableTokens: features.grantableTokens, secretRebind: features.secretRebind },
36
+ );
37
+ }
38
+
39
+ const identity = await port.readIdentity();
40
+
41
+ verifyTokenImpAuthority(identity, activation.impNames, impPrefix);
42
+
43
+ const missing = activation.secrets.filter((secret) => !identity.grantable.includes(secret));
44
+
45
+ if (missing.length > 0) {
46
+ throw new BrokerAuthorityError(
47
+ 'auth_secret_not_grantable',
48
+ `impd token ${identity.name} cannot grant ${missing.join(', ')}`,
49
+ { token: identity.name, grantable: identity.grantable, missing },
50
+ );
51
+ }
52
+ }
@@ -0,0 +1,44 @@
1
+ import { BrokerAuthorityError } from './broker-authority-error';
2
+ import type { ImpPort, ImpView } from './imp-port';
3
+ import { verifyTokenImpAuthority } from './verify-token-imp-authority';
4
+
5
+ // The imp a binding recorded when atc made it.
6
+ export interface RecordedImp {
7
+ readonly name: string;
8
+ readonly id: string;
9
+ }
10
+
11
+ /**
12
+ * The check before atc destroys a session's imp or revokes its grants.
13
+ * It asks that the token may manage the recorded imp, under imp patterns
14
+ * that stay inside the namespace whose imp names start with the prefix,
15
+ * and that the imp under that
16
+ * name is still the one recorded. It never asks that a secret's rules
17
+ * still match or that every grant is still in place, so a rotated, rebound
18
+ * or deleted secret never stops atc removing access. Resolves to the imp,
19
+ * or to null when impd confirms no imp holds the name, which needs no
20
+ * cleanup; an imp with another id rejects.
21
+ */
22
+ export async function verifyCleanupAuthority(
23
+ port: Pick<ImpPort, 'readIdentity' | 'readImp'>,
24
+ recorded: RecordedImp,
25
+ impPrefix: string,
26
+ ): Promise<ImpView | null> {
27
+ const identity = await port.readIdentity();
28
+
29
+ // Checked first, so impd's answer for the name comes from a token that
30
+ // can see the imp, and a missing imp means it is gone.
31
+ verifyTokenImpAuthority(identity, [recorded.name], impPrefix);
32
+
33
+ const imp = await port.readImp(recorded.name);
34
+
35
+ if (imp !== null && imp.id !== recorded.id) {
36
+ throw new BrokerAuthorityError(
37
+ 'auth_runtime_mismatch',
38
+ `imp ${recorded.name} is no longer the imp atc made`,
39
+ { imp: recorded.name, recordedID: recorded.id, actualID: imp.id },
40
+ );
41
+ }
42
+
43
+ return imp;
44
+ }
@@ -0,0 +1,74 @@
1
+ import { BrokerAuthorityError } from './broker-authority-error';
2
+ import type { ImpIdentity } from './imp-port';
3
+ import { isImpNameAllowed } from './is-imp-name-allowed';
4
+
5
+ /**
6
+ * Rejects unless the token may manage every named imp and reaches no imp
7
+ * outside atc's namespace: scope `manage`, imp patterns rather than none,
8
+ * and each pattern contained in the namespace prefix, so the literal text
9
+ * before its first `*`, or the whole pattern when it has none, starts with
10
+ * the prefix. A token with any pattern beyond the prefix is refused
11
+ * outright, however few names that pattern reaches, never used in place of
12
+ * a contained one. Each name must then match a pattern. The prefix is the
13
+ * literal start of every imp name the target's runtime namespace builds,
14
+ * and the names are the imps the call touches under those built names.
15
+ */
16
+ export function verifyTokenImpAuthority(
17
+ identity: ImpIdentity,
18
+ impNames: readonly string[],
19
+ impPrefix: string,
20
+ ): void {
21
+ assertImpPrefix(impPrefix);
22
+
23
+ if (identity.scope !== 'manage') {
24
+ throw new BrokerAuthorityError(
25
+ 'auth_token_scope',
26
+ `impd token ${identity.name} cannot manage imps`,
27
+ {
28
+ token: identity.name,
29
+ scope: identity.scope,
30
+ },
31
+ );
32
+ }
33
+
34
+ const patterns = identity.imps;
35
+
36
+ if (patterns === null || !patterns.every((pattern) => isPatternContained(pattern, impPrefix))) {
37
+ throw new BrokerAuthorityError(
38
+ 'auth_token_too_broad',
39
+ `impd token ${identity.name} can reach imps outside atc's namespace, whose imp names start with ${impPrefix}`,
40
+ {
41
+ token: identity.name,
42
+ imps: patterns,
43
+ offending:
44
+ patterns === null
45
+ ? null
46
+ : patterns.filter((pattern) => !isPatternContained(pattern, impPrefix)),
47
+ },
48
+ );
49
+ }
50
+
51
+ const outside = impNames.filter((name) => !isImpNameAllowed(patterns, name));
52
+
53
+ if (outside.length > 0) {
54
+ throw new BrokerAuthorityError(
55
+ 'auth_imp_out_of_scope',
56
+ `impd token ${identity.name} cannot manage ${outside.join(', ')}`,
57
+ { token: identity.name, imps: patterns, outside },
58
+ );
59
+ }
60
+ }
61
+
62
+ // An empty prefix would contain every pattern, `*` included.
63
+ function assertImpPrefix(impPrefix: string): void {
64
+ if (impPrefix === '') {
65
+ throw new Error('the imp name prefix of a runtime namespace must not be empty');
66
+ }
67
+ }
68
+
69
+ // Every name a pattern matches starts with the text before its first `*`.
70
+ function isPatternContained(pattern: string, impPrefix: string): boolean {
71
+ const [literal = ''] = pattern.split('*');
72
+
73
+ return literal.startsWith(impPrefix);
74
+ }
@@ -4,10 +4,12 @@ import { isRecord, sendReport } from './shared/report';
4
4
  /**
5
5
  * Runs as a hook inside wrangled sessions. Reads the hook event from stdin
6
6
  * (Claude snake_case keys or Grok camelCase keys) and forwards a PascalCase
7
- * event name to the atc unix socket. Always exits 0 so it never blocks the
8
- * session it reports on.
7
+ * event name to the atc unix socket, with the agent id the hook command
8
+ * gave it, so the daemon can tell a nested harness's report from the
9
+ * session's own. Always exits 0 so it never blocks the session it reports
10
+ * on.
9
11
  */
10
- export async function runHookReport(): Promise<void> {
12
+ export async function runHookReport(agent: string): Promise<void> {
11
13
  const sock = process.env['ATC_SOCKET'];
12
14
  const atcId = process.env['ATC_SESSION_ID'];
13
15
 
@@ -26,7 +28,7 @@ export async function runHookReport(): Promise<void> {
26
28
 
27
29
  const rawName = payload['hook_event_name'] ?? payload['hookEventName'];
28
30
  const event = typeof rawName === 'string' ? normalizeHookEventName(rawName) : rawName;
29
- const line = `${JSON.stringify({ atcId, event, payload })}\n`;
31
+ const line = `${JSON.stringify({ atcId, ...(agent === '' ? {} : { agent }), event, payload })}\n`;
30
32
 
31
33
  await sendReport(sock, line, 2000);
32
34
  }
@@ -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];
@@ -1,11 +1,15 @@
1
+ import type { AgentID } from '../shared/agent-id';
1
2
  import type { SessionID } from '../shared/session-id';
2
3
 
3
4
  /**
4
- * One line a hook reporter sends: the atc session it reports on, the agent's
5
- * hook event name, and the hook's payload as the agent gave it.
5
+ * One line a hook reporter sends: the atc session it reports on, the agent
6
+ * whose hook command sent it, the agent's hook event name, and the hook's
7
+ * payload as the agent gave it. The agent is absent on a line from a hook
8
+ * command that carries none.
6
9
  */
7
10
  export interface HookEvent {
8
11
  atcId: SessionID;
12
+ agent?: AgentID;
9
13
  event: string;
10
14
  payload: Record<string, unknown>;
11
15
  }
@@ -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),