@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.
- package/package.json +2 -2
- package/src/agents/build-hook-settings.ts +12 -3
- package/src/agents/print-codex-hook-file.ts +1 -1
- package/src/agents/print-grok-hook-file.ts +1 -1
- package/src/cli.ts +39 -5
- package/src/client/collect-target-picks.ts +53 -0
- package/src/client/dirs.ts +1 -1
- package/src/client/spawn-picker.ts +132 -6
- package/src/client/ui.ts +6 -1
- package/src/daemon/broker-authority-error.ts +41 -0
- package/src/daemon/collect-secret-rule-mismatches.ts +83 -0
- package/src/daemon/daemon-connection.ts +129 -0
- package/src/daemon/daemon-context.ts +23 -0
- package/src/daemon/daemon.ts +46 -1
- package/src/daemon/imp-client-port.ts +75 -3
- package/src/daemon/imp-port.ts +60 -0
- package/src/daemon/is-imp-name-allowed.ts +38 -0
- package/src/daemon/is-own-hook-event.ts +19 -0
- package/src/daemon/materialize-workspace.ts +42 -18
- package/src/daemon/parse-hook-line.ts +3 -0
- package/src/daemon/require-git-transports.ts +23 -0
- package/src/daemon/session-runtime.ts +6 -0
- package/src/daemon/verify-broker-authority.ts +52 -0
- package/src/daemon/verify-cleanup-authority.ts +44 -0
- package/src/daemon/verify-token-imp-authority.ts +74 -0
- package/src/hook-report.ts +6 -4
- package/src/mcp/require-daemon-features.ts +2 -0
- package/src/protocol/daemon-features.ts +8 -0
- package/src/protocol/hook-event.ts +6 -2
- package/src/protocol/protocol.ts +2 -0
- package/src/protocol/request-param-schemas.ts +67 -15
- package/src/shared/collect-workspaces-config.ts +105 -0
- package/src/shared/config.ts +14 -0
- 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/statusline.ts +8 -6
- 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
|
@@ -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
|
|
|
@@ -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
|
+
}
|
package/src/hook-report.ts
CHANGED
|
@@ -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
|
|
8
|
-
*
|
|
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
|
|
5
|
-
* hook
|
|
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
|
}
|
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),
|