@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.
- package/package.json +1 -1
- package/src/cli.ts +24 -1
- package/src/client/build-workspace-destination.ts +38 -0
- package/src/client/collect-target-picks.ts +53 -0
- package/src/client/dirs.ts +1 -1
- package/src/client/pick-refusal-step.ts +29 -0
- package/src/client/resolve-workspace-root.ts +49 -0
- package/src/client/spawn-picker.ts +1683 -84
- package/src/client/ui.ts +6 -1
- package/src/daemon/daemon-connection.ts +129 -0
- package/src/daemon/daemon-context.ts +23 -0
- package/src/daemon/daemon.ts +29 -0
- package/src/daemon/materialize-workspace.ts +42 -18
- package/src/daemon/require-git-transports.ts +23 -0
- package/src/mcp/require-daemon-features.ts +2 -0
- package/src/protocol/daemon-features.ts +8 -0
- package/src/protocol/protocol.ts +2 -0
- package/src/protocol/request-param-schemas.ts +67 -15
- package/src/shared/collect-workspaces-config.ts +120 -0
- package/src/shared/config.ts +30 -1
- package/src/shared/default-git-transports.ts +5 -0
- package/src/shared/is-git-url.ts +11 -0
- package/src/sources/build-sources.ts +40 -0
- package/src/sources/collect-builtin-sources.ts +41 -0
- package/src/sources/dirs/build-dirs-source.ts +70 -0
- package/src/sources/git/build-git-source.ts +25 -0
- package/src/sources/github/build-github-source.ts +113 -0
- package/src/sources/github/collect-github-repos.ts +165 -0
- package/src/sources/github/find-github-alternate-url.ts +20 -0
- package/src/sources/github/read-git-protocol.ts +11 -0
- package/src/sources/github/run-gh.ts +66 -0
- package/src/sources/types.ts +70 -0
- package/src/workspace/check-git-transport.ts +61 -0
- package/src/workspace/check-repository-access.ts +108 -0
- package/src/workspace/collect-remote-refs.ts +154 -0
- package/src/workspace/create-git-askpass.ts +84 -0
- package/src/workspace/create-workspace-clone.ts +29 -76
- package/src/workspace/expand-git-shorthand.ts +13 -0
- package/src/workspace/find-remote-ref.ts +34 -0
- package/src/workspace/normalize-git-url.ts +2 -7
- package/src/workspace/resolve-git-url.ts +46 -0
- package/src/workspace/resolve-path-source.ts +11 -4
- package/src/workspace/run-git.ts +52 -4
- package/src/workspace/workspace-source.ts +10 -2
- /package/src/{client → shared}/collect-root-dirs.ts +0 -0
- /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
|
|
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
|
package/src/daemon/daemon.ts
CHANGED
|
@@ -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
|
|
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,
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
295
|
-
|
|
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:
|
|
306
|
-
repoURL:
|
|
307
|
-
checkout: source.
|
|
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(
|
|
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: {
|
|
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];
|
package/src/protocol/protocol.ts
CHANGED
|
@@ -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
|
|
42
|
-
*
|
|
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:
|
|
57
|
-
|
|
58
|
-
|
|
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) =>
|
|
69
|
-
message: 'a git workspace takes
|
|
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),
|