@zgeoff/atc 2.11.0 → 2.13.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +10 -10
- package/package.json +2 -1
- package/src/agents/agent-adapter.ts +98 -10
- package/src/agents/build-args-without-flags.ts +25 -0
- package/src/agents/build-atc-bridge-files.ts +13 -0
- package/src/agents/build-claude-override-args.ts +26 -0
- package/src/agents/build-claude-query-options.ts +72 -0
- package/src/agents/build-cli-command.ts +7 -5
- package/src/{daemon → agents}/build-headless-env.ts +12 -6
- package/src/agents/build-hook-settings.ts +8 -2
- package/src/agents/build-restore-mode-args.ts +23 -0
- package/src/agents/claude-adapter.ts +112 -18
- package/src/agents/claude-effort-levels.ts +5 -0
- package/src/agents/codex-adapter.ts +48 -2
- package/src/agents/find-claude-permission-mode.ts +24 -0
- package/src/agents/find-flag-value.ts +27 -0
- package/src/agents/gateway-adapter.ts +83 -13
- package/src/agents/grok-adapter.ts +24 -1
- package/src/agents/make-claude-headless-runner.ts +46 -0
- package/src/agents/plan-pasted-line-input.ts +22 -0
- package/src/agents/plan-typed-line-input.ts +8 -0
- package/src/agents/resolve-claude-permission-mode.ts +13 -0
- package/src/{daemon/start-headless-run.ts → agents/start-claude-headless-run.ts} +6 -42
- package/src/agents/write-atc-bridge.ts +2 -6
- package/src/cli.ts +23 -9
- package/src/client/daemon-client.ts +14 -2
- package/src/client/index.ts +50 -1
- package/src/client/spawn-picker.ts +27 -4
- package/src/client/ui.ts +8 -0
- package/src/daemon/build-agent-list.ts +37 -1
- package/src/daemon/build-config-revision.ts +27 -0
- package/src/daemon/build-execution-targets.ts +73 -0
- package/src/daemon/build-fleet-events.ts +2 -1
- package/src/daemon/build-imp-provider.ts +68 -0
- package/src/daemon/build-payload-hash.ts +31 -0
- package/src/daemon/build-report-trail-entry.ts +4 -2
- package/src/daemon/build-scoped-context.ts +235 -0
- package/src/daemon/build-session-lifecycle.ts +52 -0
- package/src/daemon/build-tar-archive.ts +85 -0
- package/src/daemon/build-target-access.ts +33 -0
- package/src/daemon/build-target-forbidden-error.ts +13 -0
- package/src/daemon/build-target-identity.ts +22 -0
- package/src/daemon/build-target-list.ts +50 -0
- package/src/daemon/daemon-connection.ts +425 -96
- package/src/daemon/daemon.ts +730 -96
- package/src/daemon/effect-remains-error.ts +13 -0
- package/src/daemon/execution-provider.ts +208 -0
- package/src/daemon/find-execution-refusal.ts +104 -0
- package/src/daemon/hooks.ts +5 -17
- package/src/daemon/idempotency-ledger.ts +164 -0
- package/src/daemon/imp-client-port.ts +343 -0
- package/src/daemon/imp-harness.ts +618 -0
- package/src/daemon/imp-port-error.ts +18 -0
- package/src/daemon/imp-port.ts +246 -0
- package/src/daemon/imp-provider.ts +601 -0
- package/src/daemon/is-binding-current.ts +43 -0
- package/src/daemon/local-pty-provider.ts +142 -0
- package/src/daemon/materialize-workspace.ts +571 -0
- package/src/daemon/mint-session-id.ts +5 -5
- package/src/daemon/parse-hook-line.ts +31 -0
- package/src/daemon/parse-spawn-overrides.ts +94 -0
- package/src/daemon/permission-registry.ts +14 -4
- package/src/daemon/pick-session-state.ts +15 -0
- package/src/daemon/restore-fleet.ts +103 -38
- package/src/daemon/screen-model.ts +7 -0
- package/src/daemon/session-runtime.ts +10 -0
- package/src/daemon/sessions.ts +835 -105
- package/src/daemon/start-headless-turn.ts +12 -3
- package/src/daemon/start-session-bridge.ts +294 -0
- package/src/daemon/target-access.ts +36 -0
- package/src/mcp/answer-mcp-request.ts +12 -4
- package/src/mcp/answer-rpc-request.ts +28 -1
- package/src/mcp/build-principal-caller.ts +15 -0
- package/src/mcp/build-spawn-descriptions.ts +45 -0
- package/src/mcp/build-tool-list.ts +32 -4
- package/src/mcp/mcp-tools.ts +107 -10
- package/src/mcp/parse-idempotency-key.ts +29 -0
- package/src/mcp/reconnecting-caller.ts +39 -7
- package/src/mcp/require-daemon-features.ts +10 -0
- package/src/mcp/run-tool.ts +87 -16
- package/src/mcp/types.ts +2 -0
- package/src/protocol/daemon-error.ts +5 -1
- package/src/protocol/daemon-features.ts +35 -0
- package/src/protocol/protocol.ts +67 -11
- package/src/protocol/request-param-schemas.ts +126 -10
- package/src/report.ts +37 -2
- package/src/run-bridge-tap.ts +241 -0
- package/src/shared/agent-session-id.ts +1 -1
- package/src/shared/collect-clean-env.ts +9 -1
- package/src/shared/collect-principals.ts +51 -0
- package/src/shared/collect-targets.ts +144 -0
- package/src/shared/config.ts +139 -14
- package/src/shared/daemon-id.ts +8 -0
- package/src/shared/format-json-kind.ts +21 -0
- package/src/shared/open-bridge-socket.ts +86 -0
- package/src/shared/send-bridge-request.ts +47 -0
- package/src/shared/sort-json-keys.ts +19 -0
- package/src/shared/to-daemon-id.ts +11 -0
- package/src/statusline.ts +34 -3
- package/src/store/fleet-entry.ts +61 -9
- package/src/store/idempotency-record.ts +48 -0
- package/src/store/message-owner.ts +1 -1
- package/src/store/run-migrations.ts +330 -5
- package/src/store/state-store.ts +607 -44
- package/src/store/trail-entry.ts +4 -0
- package/src/store/workspace-materialization.ts +70 -0
- package/src/tap.ts +11 -0
- package/src/workspace/check-url-credentials.ts +59 -0
- package/src/workspace/check-workspace-completeness.ts +90 -0
- package/src/workspace/create-workspace-clone.ts +251 -0
- package/src/workspace/normalize-git-url.ts +59 -0
- package/src/workspace/read-workspace-tar.ts +38 -0
- package/src/workspace/repository-env-vars.ts +22 -0
- package/src/workspace/resolve-path-source.ts +170 -0
- package/src/workspace/run-git.ts +87 -0
- package/src/workspace/sanitize-workspace-clone.ts +146 -0
- package/src/workspace/workspace-provenance.ts +11 -0
- package/src/workspace/workspace-source.ts +8 -0
- /package/src/{daemon → agents}/resolve-headless-executable.ts +0 -0
package/src/store/trail-entry.ts
CHANGED
|
@@ -18,6 +18,10 @@ interface MessageTrailEntry extends TrailEntryBase {
|
|
|
18
18
|
interface ReportTrailEntry extends TrailEntryBase {
|
|
19
19
|
readonly kind: 'report';
|
|
20
20
|
readonly label: string;
|
|
21
|
+
|
|
22
|
+
// The id a remote session's reporter gave the report, so a resent
|
|
23
|
+
// report is stored once; absent for a report that carries none.
|
|
24
|
+
readonly reportID?: string;
|
|
21
25
|
}
|
|
22
26
|
|
|
23
27
|
/**
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import type { SessionID } from '../shared/session-id';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Where a spawn's workspace stands on its way to the execution target:
|
|
5
|
+
* resolving the source to a commit, cloning it on the daemon's host,
|
|
6
|
+
* transferring the archive to the target, and verifying the target's
|
|
7
|
+
* checkout, then `ready` or `failed`. A row a stopped daemon left in any
|
|
8
|
+
* other phase is reconciled to `failed` before the next daemon serves a
|
|
9
|
+
* request, so no workspace ever counts as ready without its verification.
|
|
10
|
+
*/
|
|
11
|
+
export type MaterializationPhase =
|
|
12
|
+
| 'resolving'
|
|
13
|
+
| 'cloning'
|
|
14
|
+
| 'transferring'
|
|
15
|
+
| 'verifying'
|
|
16
|
+
| 'ready'
|
|
17
|
+
| 'failed';
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* One spawn's workspace materialization, keyed by the session it is for.
|
|
21
|
+
* The repository URL is the token-free form, and the row never holds a
|
|
22
|
+
* credential or the reference to one.
|
|
23
|
+
*/
|
|
24
|
+
export interface WorkspaceMaterialization {
|
|
25
|
+
readonly sessionID: SessionID;
|
|
26
|
+
readonly target: string;
|
|
27
|
+
|
|
28
|
+
// The directory on the target the workspace is materialized into.
|
|
29
|
+
readonly dir: string;
|
|
30
|
+
readonly sourceKind: 'path' | 'git';
|
|
31
|
+
readonly phase: MaterializationPhase;
|
|
32
|
+
readonly repoURL: string | null;
|
|
33
|
+
readonly sha: string | null;
|
|
34
|
+
|
|
35
|
+
// The branch or tag the commit was resolved from; null for a pinned
|
|
36
|
+
// commit or a detached checkout.
|
|
37
|
+
readonly ref: string | null;
|
|
38
|
+
|
|
39
|
+
// The refusal that failed the materialization; null unless failed.
|
|
40
|
+
readonly errorCode: string | null;
|
|
41
|
+
readonly startedAt: number;
|
|
42
|
+
readonly updatedAt: number;
|
|
43
|
+
readonly materializedAt: number | null;
|
|
44
|
+
|
|
45
|
+
// The environment variable names every harness the session starts goes
|
|
46
|
+
// without: the workspace credential's variable and the askpass context.
|
|
47
|
+
readonly withheldEnv: readonly string[];
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// The fields a materialization rewrites as it moves through its phases.
|
|
51
|
+
export interface MaterializationUpdate {
|
|
52
|
+
readonly phase: MaterializationPhase;
|
|
53
|
+
readonly repoURL?: string;
|
|
54
|
+
readonly sha?: string;
|
|
55
|
+
readonly ref?: string | null;
|
|
56
|
+
readonly errorCode?: string;
|
|
57
|
+
readonly materializedAt?: number;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* What a session's ready workspace was built from: the token-free
|
|
62
|
+
* repository URL, the commit checked out, the branch or tag it came from,
|
|
63
|
+
* and when it became ready.
|
|
64
|
+
*/
|
|
65
|
+
export interface SessionWorkspace {
|
|
66
|
+
readonly repoURL: string;
|
|
67
|
+
readonly sha: string;
|
|
68
|
+
readonly ref?: string;
|
|
69
|
+
readonly materializedAt: number;
|
|
70
|
+
}
|
package/src/tap.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { z } from 'zod';
|
|
2
2
|
import { DaemonClient } from './client/daemon-client';
|
|
3
3
|
import { DaemonError } from './protocol/daemon-error';
|
|
4
|
+
import { runBridgeTap } from './run-bridge-tap';
|
|
4
5
|
import { daemonSocketPath } from './shared/config';
|
|
5
6
|
import { getBuild } from './shared/get-build';
|
|
6
7
|
|
|
@@ -28,6 +29,16 @@ interface InboxMessage {
|
|
|
28
29
|
* another tap replaced it or the session was removed.
|
|
29
30
|
*/
|
|
30
31
|
export async function runTap(session: string): Promise<void> {
|
|
32
|
+
const bridge = process.env['ATC_SOCKET'];
|
|
33
|
+
|
|
34
|
+
// Inside a remote host the daemon's own socket is out of reach, and the
|
|
35
|
+
// session bridge serves the tap instead.
|
|
36
|
+
if (process.env['ATC_BRIDGE'] === '1' && bridge !== undefined && bridge !== '') {
|
|
37
|
+
await runBridgeTap(bridge, process.env['ATC_OUTBOX'] ?? '');
|
|
38
|
+
|
|
39
|
+
return;
|
|
40
|
+
}
|
|
41
|
+
|
|
31
42
|
let client: DaemonClient;
|
|
32
43
|
|
|
33
44
|
try {
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { runGit } from './run-git';
|
|
2
|
+
|
|
3
|
+
type URLCredentialFinding =
|
|
4
|
+
| { readonly ok: true }
|
|
5
|
+
| { readonly ok: false; readonly code: 'credential_in_url'; readonly message: string };
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Checks that a repository URL reaches git without a credential in it, so
|
|
9
|
+
* a token can never surface in git's own messages or in the clone. The URL
|
|
10
|
+
* itself must carry none, and no `url.<base>.insteadOf` rewrite in the git
|
|
11
|
+
* config read from `cwd` may expand it into one that does. git expands the
|
|
12
|
+
* URL without touching the network. An http(s) URL carries a credential in
|
|
13
|
+
* its userinfo, query, or fragment; any other scheme URL only in a
|
|
14
|
+
* password, since its user is a login name such as `git`.
|
|
15
|
+
*/
|
|
16
|
+
export async function checkURLCredentials(url: string, cwd: string): Promise<URLCredentialFinding> {
|
|
17
|
+
// git prints the URL as it would fetch it, with every rewrite applied.
|
|
18
|
+
const expanded = await runGit(['ls-remote', '--get-url', url], { cwd });
|
|
19
|
+
|
|
20
|
+
const fetched = expanded.exitCode === 0 ? expanded.stdout.trim() : url;
|
|
21
|
+
|
|
22
|
+
if (!hasURLCredential(fetched)) {
|
|
23
|
+
return { ok: true };
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
return {
|
|
27
|
+
ok: false,
|
|
28
|
+
code: 'credential_in_url',
|
|
29
|
+
message: hasURLCredential(url)
|
|
30
|
+
? 'the repository URL carries a credential; pass it as a credentialRef instead'
|
|
31
|
+
: 'a url.<base>.insteadOf rewrite in the host git config puts a credential into the repository URL; remove the rewrite or pass the credential as a credentialRef',
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const SCHEME_PATTERN = /^[a-z][a-z\d+.-]*:\/\//iu;
|
|
36
|
+
|
|
37
|
+
const HTTP_PROTOCOLS: ReadonlySet<string> = new Set(['http:', 'https:']);
|
|
38
|
+
|
|
39
|
+
function hasURLCredential(url: string): boolean {
|
|
40
|
+
if (!SCHEME_PATTERN.test(url)) {
|
|
41
|
+
return false;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
let parsed: URL;
|
|
45
|
+
|
|
46
|
+
try {
|
|
47
|
+
parsed = new URL(url);
|
|
48
|
+
} catch {
|
|
49
|
+
return false;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
if (HTTP_PROTOCOLS.has(parsed.protocol)) {
|
|
53
|
+
return (
|
|
54
|
+
parsed.username !== '' || parsed.password !== '' || parsed.search !== '' || parsed.hash !== ''
|
|
55
|
+
);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
return parsed.password !== '';
|
|
59
|
+
}
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import { runGit } from './run-git';
|
|
2
|
+
|
|
3
|
+
type CompletenessFinding =
|
|
4
|
+
| { readonly ok: true }
|
|
5
|
+
| {
|
|
6
|
+
readonly ok: false;
|
|
7
|
+
readonly code: 'has_submodules' | 'unreadable_tree';
|
|
8
|
+
readonly message: string;
|
|
9
|
+
}
|
|
10
|
+
| {
|
|
11
|
+
readonly ok: false;
|
|
12
|
+
readonly code: 'lfs_unsupported';
|
|
13
|
+
readonly message: string;
|
|
14
|
+
readonly count: number;
|
|
15
|
+
readonly paths: readonly string[];
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
const LFS_PATHS_SHOWN = 5;
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Checks that a checked-out commit is the whole workspace, so a checkout is
|
|
22
|
+
* never handed on with holes in it. The commit's own tree must hold no
|
|
23
|
+
* gitlink and no `.gitmodules`, since submodules are never initialized, and
|
|
24
|
+
* no tracked path may carry the `lfs` filter, since its content would be a
|
|
25
|
+
* pointer file rather than the object it points at.
|
|
26
|
+
*/
|
|
27
|
+
export async function checkWorkspaceCompleteness(
|
|
28
|
+
dir: string,
|
|
29
|
+
sha: string,
|
|
30
|
+
): Promise<CompletenessFinding> {
|
|
31
|
+
const tree = await runGit(['ls-tree', '-r', '--full-tree', sha], { cwd: dir, isolated: true });
|
|
32
|
+
|
|
33
|
+
if (tree.exitCode !== 0) {
|
|
34
|
+
return {
|
|
35
|
+
ok: false,
|
|
36
|
+
code: 'unreadable_tree',
|
|
37
|
+
message: `cannot list the tree of ${sha}: ${tree.stderr.trim()}`,
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const entries = tree.stdout.split('\n');
|
|
42
|
+
|
|
43
|
+
if (entries.some((line) => line.startsWith('160000 ') || line.endsWith('\t.gitmodules'))) {
|
|
44
|
+
return { ok: false, code: 'has_submodules', message: `${sha} uses submodules` };
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const tracked = await runGit(['ls-files', '-z'], { cwd: dir, isolated: true });
|
|
48
|
+
|
|
49
|
+
const attributes = await runGit(['check-attr', '--stdin', '-z', 'filter'], {
|
|
50
|
+
cwd: dir,
|
|
51
|
+
input: tracked.stdout,
|
|
52
|
+
isolated: true,
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
if (tracked.exitCode !== 0 || attributes.exitCode !== 0) {
|
|
56
|
+
return {
|
|
57
|
+
ok: false,
|
|
58
|
+
code: 'unreadable_tree',
|
|
59
|
+
message: `cannot read the attributes of ${sha}: ${tracked.stderr.trim()}${attributes.stderr.trim()}`,
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const lfsPaths = collectLFSPaths(attributes.stdout);
|
|
64
|
+
|
|
65
|
+
if (lfsPaths.length > 0) {
|
|
66
|
+
return {
|
|
67
|
+
ok: false,
|
|
68
|
+
code: 'lfs_unsupported',
|
|
69
|
+
message: `${sha} tracks ${lfsPaths.length} path(s) through Git LFS`,
|
|
70
|
+
count: lfsPaths.length,
|
|
71
|
+
paths: lfsPaths.slice(0, LFS_PATHS_SHOWN),
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
return { ok: true };
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// `check-attr -z` writes one path, attribute, value triple per path.
|
|
79
|
+
function collectLFSPaths(output: string): string[] {
|
|
80
|
+
const fields = output.split('\0');
|
|
81
|
+
const paths: string[] = [];
|
|
82
|
+
|
|
83
|
+
for (let index = 0; index + 2 < fields.length; index += 3) {
|
|
84
|
+
if (fields[index + 2] === 'lfs') {
|
|
85
|
+
paths.push(fields[index] ?? '');
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
return paths;
|
|
90
|
+
}
|
|
@@ -0,0 +1,251 @@
|
|
|
1
|
+
import { mkdtemp, rm, writeFile } from 'node:fs/promises';
|
|
2
|
+
import { tmpdir } from 'node:os';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
import { checkWorkspaceCompleteness } from './check-workspace-completeness';
|
|
5
|
+
import { runGit } from './run-git';
|
|
6
|
+
import type { WorkspaceSource } from './workspace-source';
|
|
7
|
+
|
|
8
|
+
interface GitCredential {
|
|
9
|
+
readonly kind: 'env';
|
|
10
|
+
readonly name: string;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
interface CloneRequest {
|
|
14
|
+
readonly source: Extract<WorkspaceSource, { readonly kind: 'git' }>;
|
|
15
|
+
readonly dir: string;
|
|
16
|
+
readonly credential?: GitCredential;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
interface CreatedClone {
|
|
20
|
+
readonly ok: true;
|
|
21
|
+
readonly sha: string;
|
|
22
|
+
readonly branch: string | null;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
interface CloneRefusal {
|
|
26
|
+
readonly ok: false;
|
|
27
|
+
readonly code: 'credential_missing' | 'ref_not_found' | 'clone_failed';
|
|
28
|
+
readonly message: string;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
type IncompleteCheckout = Exclude<
|
|
32
|
+
Awaited<ReturnType<typeof checkWorkspaceCompleteness>>,
|
|
33
|
+
{ readonly ok: true }
|
|
34
|
+
>;
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Clones a repository into an empty directory and checks out the commit its
|
|
38
|
+
* ref points at that moment. A branch ref is checked out as that branch; a tag
|
|
39
|
+
* or a full commit id leaves HEAD detached at the commit. The clone copies
|
|
40
|
+
* objects rather than hard-linking them, takes no hook templates, and runs no
|
|
41
|
+
* hooks on checkout.
|
|
42
|
+
*
|
|
43
|
+
* A ref, or a full commit id, that the upstream does not have is refused as
|
|
44
|
+
* `ref_not_found`, and the directory is removed.
|
|
45
|
+
*
|
|
46
|
+
* The checked-out commit must be the whole workspace: one that uses
|
|
47
|
+
* submodules or tracks paths through Git LFS is refused and the directory is
|
|
48
|
+
* removed. The clone fetches without checking out, and the checkout then
|
|
49
|
+
* runs isolated from the host's system and global git config, so no filter
|
|
50
|
+
* the host configured, LFS or otherwise, runs or reaches the network.
|
|
51
|
+
*
|
|
52
|
+
* Without a credential, git authenticates through the host's own git
|
|
53
|
+
* config. An env credential is read from the named variable and handed to git
|
|
54
|
+
* only through a private askpass helper for the network commands, never
|
|
55
|
+
* through the command line or the URL, and the helper is deleted before this
|
|
56
|
+
* returns. The host's credential helpers are switched off for those commands
|
|
57
|
+
* so none of them stores the token.
|
|
58
|
+
*/
|
|
59
|
+
export async function createWorkspaceClone(
|
|
60
|
+
request: CloneRequest,
|
|
61
|
+
): Promise<CloneRefusal | CreatedClone | IncompleteCheckout> {
|
|
62
|
+
const askpass = await createAskpass(request.credential);
|
|
63
|
+
|
|
64
|
+
if (!askpass.ok) {
|
|
65
|
+
return askpass;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
try {
|
|
69
|
+
return await createCloneAtRef(request, askpass.env, askpass.args);
|
|
70
|
+
} finally {
|
|
71
|
+
await askpass[Symbol.asyncDispose]();
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
interface Askpass {
|
|
76
|
+
readonly ok: true;
|
|
77
|
+
readonly env: Readonly<Record<string, string>>;
|
|
78
|
+
readonly args: readonly string[];
|
|
79
|
+
[Symbol.asyncDispose]: () => Promise<void>;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const ASKPASS_SECRET_VAR = 'ATC_GIT_ASKPASS_SECRET';
|
|
83
|
+
|
|
84
|
+
// Username prompts get a fixed name that GitHub and GitLab both accept
|
|
85
|
+
// alongside a token; password prompts get the token.
|
|
86
|
+
const ASKPASS_SCRIPT = `#!/bin/sh
|
|
87
|
+
case "$1" in
|
|
88
|
+
Username*) printf '%s\\n' x-access-token ;;
|
|
89
|
+
*) printf '%s\\n' "$${ASKPASS_SECRET_VAR}" ;;
|
|
90
|
+
esac
|
|
91
|
+
`;
|
|
92
|
+
|
|
93
|
+
async function createAskpass(
|
|
94
|
+
credential: GitCredential | undefined,
|
|
95
|
+
): Promise<Askpass | CloneRefusal> {
|
|
96
|
+
if (credential === undefined) {
|
|
97
|
+
return { ok: true, env: {}, args: [], [Symbol.asyncDispose]: async () => {} };
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const secret = process.env[credential.name];
|
|
101
|
+
|
|
102
|
+
if (secret === undefined || secret === '') {
|
|
103
|
+
return {
|
|
104
|
+
ok: false,
|
|
105
|
+
code: 'credential_missing',
|
|
106
|
+
message: 'the credential environment variable is unset or empty',
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
const dir = await mkdtemp(join(tmpdir(), 'atc-askpass-'));
|
|
111
|
+
|
|
112
|
+
const helper = join(dir, 'askpass');
|
|
113
|
+
|
|
114
|
+
await writeFile(helper, ASKPASS_SCRIPT, { mode: 0o700 });
|
|
115
|
+
|
|
116
|
+
return {
|
|
117
|
+
ok: true,
|
|
118
|
+
env: { GIT_ASKPASS: helper, [ASKPASS_SECRET_VAR]: secret },
|
|
119
|
+
args: ['-c', 'credential.helper='],
|
|
120
|
+
[Symbol.asyncDispose]: async () => {
|
|
121
|
+
await rm(dir, { recursive: true, force: true });
|
|
122
|
+
},
|
|
123
|
+
};
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
async function createCloneAtRef(
|
|
127
|
+
request: CloneRequest,
|
|
128
|
+
env: Readonly<Record<string, string>>,
|
|
129
|
+
args: readonly string[],
|
|
130
|
+
): Promise<CloneRefusal | CreatedClone | IncompleteCheckout> {
|
|
131
|
+
const target = await resolveRef(request.source, env, args);
|
|
132
|
+
|
|
133
|
+
if (!target.ok) {
|
|
134
|
+
return target;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
const clone = await runGit(
|
|
138
|
+
[
|
|
139
|
+
...args,
|
|
140
|
+
'clone',
|
|
141
|
+
'--quiet',
|
|
142
|
+
'--no-local',
|
|
143
|
+
'--no-hardlinks',
|
|
144
|
+
'--no-checkout',
|
|
145
|
+
'--template=',
|
|
146
|
+
'--',
|
|
147
|
+
request.source.url,
|
|
148
|
+
request.dir,
|
|
149
|
+
],
|
|
150
|
+
{ env },
|
|
151
|
+
);
|
|
152
|
+
|
|
153
|
+
if (clone.exitCode !== 0) {
|
|
154
|
+
return { ok: false, code: 'clone_failed', message: clone.stderr.trim() };
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
const checkout = await runGit(
|
|
158
|
+
[
|
|
159
|
+
'-c',
|
|
160
|
+
'core.hooksPath=/dev/null',
|
|
161
|
+
'checkout',
|
|
162
|
+
'--quiet',
|
|
163
|
+
...(target.branch === null ? ['--detach', target.sha] : ['-B', target.branch, target.sha]),
|
|
164
|
+
],
|
|
165
|
+
{ cwd: request.dir, isolated: true },
|
|
166
|
+
);
|
|
167
|
+
|
|
168
|
+
if (checkout.exitCode !== 0) {
|
|
169
|
+
// A pinned commit that no upstream ref reaches never arrives in the
|
|
170
|
+
// clone, so its checkout fails on a commit the upstream does not have.
|
|
171
|
+
const present = await runGit(['cat-file', '-e', `${target.sha}^{commit}`], {
|
|
172
|
+
cwd: request.dir,
|
|
173
|
+
isolated: true,
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
await rm(request.dir, { recursive: true, force: true });
|
|
177
|
+
|
|
178
|
+
return target.branch === null && present.exitCode !== 0
|
|
179
|
+
? { ok: false, code: 'ref_not_found', message: `origin has no commit ${target.sha}` }
|
|
180
|
+
: { ok: false, code: 'clone_failed', message: checkout.stderr.trim() };
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
const complete = await checkWorkspaceCompleteness(request.dir, target.sha);
|
|
184
|
+
|
|
185
|
+
if (!complete.ok) {
|
|
186
|
+
await rm(request.dir, { recursive: true, force: true });
|
|
187
|
+
|
|
188
|
+
return complete;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
return { ok: true, sha: target.sha, branch: target.branch };
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
interface ResolvedRef {
|
|
195
|
+
readonly ok: true;
|
|
196
|
+
readonly sha: string;
|
|
197
|
+
readonly branch: string | null;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
const SHA_PATTERN = /^(?:[\da-f]{40}|[\da-f]{64})$/u;
|
|
201
|
+
|
|
202
|
+
/**
|
|
203
|
+
* Pins a ref to a commit before cloning, so the checkout is exactly the
|
|
204
|
+
* commit the ref pointed at when asked, whatever lands on the branch meanwhile.
|
|
205
|
+
* A branch wins over a same-named tag, and an annotated tag resolves to the
|
|
206
|
+
* commit it points at.
|
|
207
|
+
*/
|
|
208
|
+
async function resolveRef(
|
|
209
|
+
source: Extract<WorkspaceSource, { readonly kind: 'git' }>,
|
|
210
|
+
env: Readonly<Record<string, string>>,
|
|
211
|
+
args: readonly string[],
|
|
212
|
+
): Promise<CloneRefusal | ResolvedRef> {
|
|
213
|
+
if (SHA_PATTERN.test(source.ref)) {
|
|
214
|
+
return { ok: true, sha: source.ref, branch: null };
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
const listed = await runGit(
|
|
218
|
+
[...args, 'ls-remote', '--', source.url, source.ref, `${source.ref}^{}`],
|
|
219
|
+
{ env },
|
|
220
|
+
);
|
|
221
|
+
|
|
222
|
+
if (listed.exitCode !== 0) {
|
|
223
|
+
return { ok: false, code: 'clone_failed', message: listed.stderr.trim() };
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
const refs = new Map(
|
|
227
|
+
listed.stdout
|
|
228
|
+
.split('\n')
|
|
229
|
+
.filter((line) => line !== '')
|
|
230
|
+
.map((line) => {
|
|
231
|
+
const [sha = '', name = ''] = line.split('\t');
|
|
232
|
+
|
|
233
|
+
return [name, sha] as const;
|
|
234
|
+
}),
|
|
235
|
+
);
|
|
236
|
+
|
|
237
|
+
const name = source.ref.replace(/^refs\/(?:heads|tags)\//u, '');
|
|
238
|
+
const branchSHA = refs.get(`refs/heads/${name}`);
|
|
239
|
+
|
|
240
|
+
if (branchSHA !== undefined && !source.ref.startsWith('refs/tags/')) {
|
|
241
|
+
return { ok: true, sha: branchSHA, branch: name };
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
const tagSHA = refs.get(`refs/tags/${name}^{}`) ?? refs.get(`refs/tags/${name}`);
|
|
245
|
+
|
|
246
|
+
if (tagSHA !== undefined && !source.ref.startsWith('refs/heads/')) {
|
|
247
|
+
return { ok: true, sha: tagSHA, branch: null };
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
return { ok: false, code: 'ref_not_found', message: `origin has no branch or tag '${name}'` };
|
|
251
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
type NormalizedGitURL =
|
|
2
|
+
| { readonly ok: true; readonly url: string }
|
|
3
|
+
| { readonly ok: false; readonly code: 'invalid_git_url'; readonly message: string };
|
|
4
|
+
|
|
5
|
+
const SCHEME_PATTERN = /^[a-z][a-z\d+.-]*:\/\//iu;
|
|
6
|
+
const SCP_PATTERN = /^(?:[^@/:\s]+@)?[^@/:\s]+:[^@\s]+$/u;
|
|
7
|
+
const SHORTHAND_PATTERN = /^[\w.-]+\/[\w.-]+$/u;
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Reduces a repository URL to a form that carries no credential, so it can be
|
|
11
|
+
* recorded, shown, and handed to another host. An http(s) URL loses its
|
|
12
|
+
* userinfo, query, and fragment, where tokens ride. Any other scheme URL
|
|
13
|
+
* loses its password but keeps its user, which is a login name such as
|
|
14
|
+
* `git`. An scp-style `git@host:owner/repo` and a local path pass through,
|
|
15
|
+
* and a bare `owner/repo` expands to its GitHub https URL.
|
|
16
|
+
*/
|
|
17
|
+
export function normalizeGitURL(raw: string): NormalizedGitURL {
|
|
18
|
+
const trimmed = raw.trim();
|
|
19
|
+
|
|
20
|
+
if (SCHEME_PATTERN.test(trimmed)) {
|
|
21
|
+
return normalizeSchemeURL(trimmed);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
if (trimmed.startsWith('/')) {
|
|
25
|
+
return { ok: true, url: trimmed };
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
if (SHORTHAND_PATTERN.test(trimmed) && !trimmed.startsWith('.')) {
|
|
29
|
+
return { ok: true, url: `https://github.com/${trimmed.replace(/\.git$/u, '')}.git` };
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
if (SCP_PATTERN.test(trimmed)) {
|
|
33
|
+
return { ok: true, url: trimmed };
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
return { ok: false, code: 'invalid_git_url', message: 'not a git repository URL' };
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const HTTP_PROTOCOLS: ReadonlySet<string> = new Set(['http:', 'https:']);
|
|
40
|
+
|
|
41
|
+
function normalizeSchemeURL(raw: string): NormalizedGitURL {
|
|
42
|
+
let url: URL;
|
|
43
|
+
|
|
44
|
+
try {
|
|
45
|
+
url = new URL(raw);
|
|
46
|
+
} catch {
|
|
47
|
+
return { ok: false, code: 'invalid_git_url', message: 'not a parseable repository URL' };
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
url.password = '';
|
|
51
|
+
|
|
52
|
+
if (HTTP_PROTOCOLS.has(url.protocol)) {
|
|
53
|
+
url.username = '';
|
|
54
|
+
url.search = '';
|
|
55
|
+
url.hash = '';
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
return { ok: true, url: url.toString() };
|
|
59
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
type TarOutcome =
|
|
2
|
+
| { readonly ok: true }
|
|
3
|
+
| { readonly ok: false; readonly code: 'tar_failed'; readonly message: string };
|
|
4
|
+
|
|
5
|
+
interface WorkspaceTar {
|
|
6
|
+
readonly stream: ReadableStream<Uint8Array>;
|
|
7
|
+
readonly done: Promise<TarOutcome>;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Streams an uncompressed tar of a directory, `.git` included, with entries
|
|
12
|
+
* relative to the directory. The archive is read as it is produced and never
|
|
13
|
+
* written to disk. A failure partway through still ends the stream, so a
|
|
14
|
+
* consumer drains it and then awaits the outcome before trusting what it
|
|
15
|
+
* received. Options the host passes to GNU tar through `TAR_OPTIONS` are
|
|
16
|
+
* ignored, since one could follow symlinks out of the directory or leave
|
|
17
|
+
* files out.
|
|
18
|
+
*/
|
|
19
|
+
export function readWorkspaceTar(dir: string): WorkspaceTar {
|
|
20
|
+
const { TAR_OPTIONS: _ignored, ...env } = process.env;
|
|
21
|
+
|
|
22
|
+
const proc = Bun.spawn(['tar', '-c', '-f', '-', '-C', dir, '.'], {
|
|
23
|
+
env,
|
|
24
|
+
stdin: 'ignore',
|
|
25
|
+
stdout: 'pipe',
|
|
26
|
+
stderr: 'pipe',
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
const done = (async (): Promise<TarOutcome> => {
|
|
30
|
+
const [stderr, exitCode] = await Promise.all([new Response(proc.stderr).text(), proc.exited]);
|
|
31
|
+
|
|
32
|
+
return exitCode === 0
|
|
33
|
+
? { ok: true }
|
|
34
|
+
: { ok: false, code: 'tar_failed', message: stderr.trim() };
|
|
35
|
+
})();
|
|
36
|
+
|
|
37
|
+
return { stream: proc.stdout, done };
|
|
38
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The variables that pin git to a repository other than the one in its
|
|
3
|
+
* working directory, such as the `GIT_DIR` a git hook exports. A command
|
|
4
|
+
* that must act on its own directory runs without every one of them.
|
|
5
|
+
*/
|
|
6
|
+
export const REPOSITORY_ENV_VARS: ReadonlySet<string> = new Set([
|
|
7
|
+
'GIT_ALTERNATE_OBJECT_DIRECTORIES',
|
|
8
|
+
'GIT_COMMON_DIR',
|
|
9
|
+
'GIT_CONFIG',
|
|
10
|
+
'GIT_CONFIG_COUNT',
|
|
11
|
+
'GIT_CONFIG_PARAMETERS',
|
|
12
|
+
'GIT_DIR',
|
|
13
|
+
'GIT_GRAFT_FILE',
|
|
14
|
+
'GIT_IMPLICIT_WORK_TREE',
|
|
15
|
+
'GIT_INDEX_FILE',
|
|
16
|
+
'GIT_NO_REPLACE_OBJECTS',
|
|
17
|
+
'GIT_OBJECT_DIRECTORY',
|
|
18
|
+
'GIT_PREFIX',
|
|
19
|
+
'GIT_REPLACE_REF_BASE',
|
|
20
|
+
'GIT_SHALLOW_FILE',
|
|
21
|
+
'GIT_WORK_TREE',
|
|
22
|
+
]);
|