@zgeoff/atc 2.15.2 → 2.17.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (60) hide show
  1. package/package.json +2 -2
  2. package/src/agents/build-hook-settings.ts +12 -3
  3. package/src/agents/print-codex-hook-file.ts +1 -1
  4. package/src/agents/print-grok-hook-file.ts +1 -1
  5. package/src/cli.ts +39 -5
  6. package/src/client/collect-target-picks.ts +53 -0
  7. package/src/client/dirs.ts +1 -1
  8. package/src/client/spawn-picker.ts +132 -6
  9. package/src/client/ui.ts +6 -1
  10. package/src/daemon/broker-authority-error.ts +41 -0
  11. package/src/daemon/collect-secret-rule-mismatches.ts +83 -0
  12. package/src/daemon/daemon-connection.ts +129 -0
  13. package/src/daemon/daemon-context.ts +23 -0
  14. package/src/daemon/daemon.ts +46 -1
  15. package/src/daemon/imp-client-port.ts +75 -3
  16. package/src/daemon/imp-port.ts +60 -0
  17. package/src/daemon/is-imp-name-allowed.ts +38 -0
  18. package/src/daemon/is-own-hook-event.ts +19 -0
  19. package/src/daemon/materialize-workspace.ts +42 -18
  20. package/src/daemon/parse-hook-line.ts +3 -0
  21. package/src/daemon/require-git-transports.ts +23 -0
  22. package/src/daemon/session-runtime.ts +6 -0
  23. package/src/daemon/verify-broker-authority.ts +52 -0
  24. package/src/daemon/verify-cleanup-authority.ts +44 -0
  25. package/src/daemon/verify-token-imp-authority.ts +74 -0
  26. package/src/hook-report.ts +6 -4
  27. package/src/mcp/require-daemon-features.ts +2 -0
  28. package/src/protocol/daemon-features.ts +8 -0
  29. package/src/protocol/hook-event.ts +6 -2
  30. package/src/protocol/protocol.ts +2 -0
  31. package/src/protocol/request-param-schemas.ts +67 -15
  32. package/src/shared/collect-workspaces-config.ts +105 -0
  33. package/src/shared/config.ts +14 -0
  34. package/src/shared/default-git-transports.ts +5 -0
  35. package/src/shared/is-git-url.ts +11 -0
  36. package/src/sources/build-sources.ts +40 -0
  37. package/src/sources/collect-builtin-sources.ts +41 -0
  38. package/src/sources/dirs/build-dirs-source.ts +70 -0
  39. package/src/sources/git/build-git-source.ts +25 -0
  40. package/src/sources/github/build-github-source.ts +113 -0
  41. package/src/sources/github/collect-github-repos.ts +165 -0
  42. package/src/sources/github/find-github-alternate-url.ts +20 -0
  43. package/src/sources/github/read-git-protocol.ts +11 -0
  44. package/src/sources/github/run-gh.ts +66 -0
  45. package/src/sources/types.ts +70 -0
  46. package/src/statusline.ts +8 -6
  47. package/src/workspace/check-git-transport.ts +61 -0
  48. package/src/workspace/check-repository-access.ts +108 -0
  49. package/src/workspace/collect-remote-refs.ts +154 -0
  50. package/src/workspace/create-git-askpass.ts +84 -0
  51. package/src/workspace/create-workspace-clone.ts +29 -76
  52. package/src/workspace/expand-git-shorthand.ts +13 -0
  53. package/src/workspace/find-remote-ref.ts +34 -0
  54. package/src/workspace/normalize-git-url.ts +2 -7
  55. package/src/workspace/resolve-git-url.ts +46 -0
  56. package/src/workspace/resolve-path-source.ts +11 -4
  57. package/src/workspace/run-git.ts +52 -4
  58. package/src/workspace/workspace-source.ts +10 -2
  59. /package/src/{client → shared}/collect-root-dirs.ts +0 -0
  60. /package/src/{client → shared}/collect-zoxide-dirs.ts +0 -0
@@ -0,0 +1,11 @@
1
+ import { runGH } from './run-gh';
2
+
3
+ /**
4
+ * The clone protocol the gh config on this host prefers: `ssh` when it says
5
+ * so, else `https`, which a gh that fails or takes too long also gives.
6
+ */
7
+ export async function readGitProtocol(bin: string, timeoutMs: number): Promise<'https' | 'ssh'> {
8
+ const run = await runGH(bin, timeoutMs, ['config', 'get', 'git_protocol']);
9
+
10
+ return run.exitCode === 0 && run.stdout.trim() === 'ssh' ? 'ssh' : 'https';
11
+ }
@@ -0,0 +1,66 @@
1
+ export interface GHRun {
2
+ readonly exitCode: number;
3
+ readonly stdout: string;
4
+ readonly stderr: string;
5
+ readonly timedOut: boolean;
6
+ }
7
+
8
+ /**
9
+ * Runs gh with arguments and a time limit, killing its whole process group
10
+ * once the limit passes, so a wrapper or extension leaves no process
11
+ * behind. gh reads its own config and the host's environment for its
12
+ * token, and is kept from prompting, colouring, or checking for updates.
13
+ */
14
+ export async function runGH(
15
+ bin: string,
16
+ timeoutMs: number,
17
+ args: readonly string[],
18
+ ): Promise<GHRun> {
19
+ const proc = Bun.spawn([bin, ...args], {
20
+ env: {
21
+ ...process.env,
22
+ GH_PROMPT_DISABLED: '1',
23
+ GH_NO_UPDATE_NOTIFIER: '1',
24
+ GH_SPINNER_DISABLED: '1',
25
+ NO_COLOR: '1',
26
+ LC_ALL: 'C',
27
+ },
28
+ stdin: 'ignore',
29
+ stdout: 'pipe',
30
+ stderr: 'pipe',
31
+
32
+ // gh leads its own process group, so stopping it stops what it
33
+ // started too.
34
+ detached: true,
35
+ });
36
+
37
+ const finished = Promise.all([
38
+ new Response(proc.stdout).text(),
39
+ new Response(proc.stderr).text(),
40
+ proc.exited,
41
+ ]);
42
+
43
+ const limit = Promise.withResolvers<null>();
44
+
45
+ const timer = setTimeout(() => {
46
+ limit.resolve(null);
47
+ }, timeoutMs);
48
+
49
+ const settled = await Promise.race([finished, limit.promise]);
50
+
51
+ clearTimeout(timer);
52
+
53
+ if (settled === null) {
54
+ try {
55
+ process.kill(-proc.pid, 'SIGKILL');
56
+ } catch {
57
+ // The group already exited.
58
+ }
59
+
60
+ return { exitCode: -1, stdout: '', stderr: '', timedOut: true };
61
+ }
62
+
63
+ const [stdout, stderr, exitCode] = settled;
64
+
65
+ return { exitCode, stdout, stderr, timedOut: false };
66
+ }
@@ -0,0 +1,70 @@
1
+ /**
2
+ * What a candidate or a typed input resolves to: a directory on the
3
+ * daemon's host, or a git repository URL that a probe pins to a commit
4
+ * before any spawn uses it.
5
+ */
6
+ type SourcePick =
7
+ | { readonly kind: 'path'; readonly dir: string }
8
+ | { readonly kind: 'git'; readonly url: string };
9
+
10
+ // The kind of pick every candidate of one source resolves to.
11
+ export type SourceKind = SourcePick['kind'];
12
+
13
+ // One entry a source lists for the spawn picker.
14
+ interface SourceCandidate {
15
+ readonly label: string;
16
+ readonly detail?: string;
17
+ readonly pick: SourcePick;
18
+ }
19
+
20
+ // One listing: the candidates, and the scope they were listed under, or
21
+ // null when the source lists without one.
22
+ interface SourceListing {
23
+ readonly candidates: readonly SourceCandidate[];
24
+ readonly scope: string | null;
25
+ }
26
+
27
+ // What a listing asks for: a scope the source defines, such as one account,
28
+ // and filter text for a source that filters on the daemon.
29
+ interface SourceQuery {
30
+ readonly scope?: string;
31
+ readonly text?: string;
32
+ }
33
+
34
+ /**
35
+ * What a source reads typed input as: a scope to list, a pick, or nothing
36
+ * it recognizes.
37
+ */
38
+ export type SourceInterpretation =
39
+ | { readonly kind: 'browse'; readonly scope: string }
40
+ | SourcePick
41
+ | { readonly kind: 'none' };
42
+
43
+ /**
44
+ * The request a source serves: the execution target the spawn will use,
45
+ * and the daemon's spawn history as the requesting principal may read it.
46
+ */
47
+ export interface SourceRequest {
48
+ readonly target: string;
49
+ readonly collectSpawnDirs: () => Promise<string[]>;
50
+ }
51
+
52
+ /**
53
+ * A discovery source for the spawn picker. It lists candidates and reads
54
+ * typed input, and every candidate resolves to a pick of its kind. A source
55
+ * runs on the daemon, the host that materializes the workspace, and gets
56
+ * the services it uses when it is built. A failure throws the error the
57
+ * source defines for it.
58
+ */
59
+ export interface SourceProvider {
60
+ readonly id: string;
61
+ readonly label: string;
62
+ readonly kind: SourceKind;
63
+ readonly list: (query: SourceQuery, request: SourceRequest) => Promise<SourceListing>;
64
+ readonly interpret: (input: string, request: SourceRequest) => Promise<SourceInterpretation>;
65
+
66
+ // The other URLs of the repository at a git URL the daemon's host could
67
+ // not read, for a source that knows its host's URL forms; none when it
68
+ // does not recognize the URL.
69
+ readonly findAlternateURLs?: (url: string) => readonly string[];
70
+ }
package/src/statusline.ts CHANGED
@@ -8,10 +8,11 @@ import { isRecord, sendReport } from './shared/report';
8
8
  /**
9
9
  * Runs as the statusLine command injected into wrangled sessions. Chains the
10
10
  * user's own statusline (from ~/.claude/settings.json), appends the atc fleet
11
- * segment, and heartbeats the session id back to the atc socket. Always
12
- * exits 0 so it never breaks the session it renders for.
11
+ * segment, and heartbeats the session id back to the atc socket, with the
12
+ * agent id the command gave it. Always exits 0 so it never breaks the
13
+ * session it renders for.
13
14
  */
14
- export async function runStatusline(): Promise<void> {
15
+ export async function runStatusline(agent: string): Promise<void> {
15
16
  const raw = await new Response(Bun.stdin.stream()).text();
16
17
 
17
18
  const sock = process.env['ATC_SOCKET'];
@@ -28,7 +29,7 @@ export async function runStatusline(): Promise<void> {
28
29
  }
29
30
  } catch {}
30
31
 
31
- const line = `${JSON.stringify({ atcId, event: 'Statusline', payload })}\n`;
32
+ const line = `${JSON.stringify({ atcId, ...(agent === '' ? {} : { agent }), event: 'Statusline', payload })}\n`;
32
33
 
33
34
  await sendReport(sock, line, 500);
34
35
  }
@@ -128,7 +129,8 @@ async function readOwnSegment(sock: string): Promise<string> {
128
129
  }
129
130
 
130
131
  // A user statusline that is atc's own injected command would chain into
131
- // itself; the injected command always ends with the bare subcommand.
132
+ // itself; the injected command ends with the bare subcommand, or with the
133
+ // subcommand and its agent flag.
132
134
  function isSelfCommand(cmd: string): boolean {
133
- return cmd.includes('statusline.ts') || cmd.trimEnd().endsWith(' statusline');
135
+ return cmd.includes('statusline.ts') || /\sstatusline(?:\s+--agent\s.*)?$/u.test(cmd);
134
136
  }
@@ -0,0 +1,61 @@
1
+ type TransportCheck =
2
+ | { readonly ok: true }
3
+ | { readonly ok: false; readonly code: 'invalid_git_url'; readonly message: string };
4
+
5
+ // `<transport>::<address>`, which runs git's remote helper for the
6
+ // transport, such as `ext::` or `fd::`.
7
+ const HELPER_PATTERN = /^(?<name>[A-Za-z][\w+.-]*)::/u;
8
+
9
+ // `<scheme>://…`.
10
+ const SCHEME_PATTERN = /^(?<name>[A-Za-z][\w+.-]*):\/\//u;
11
+
12
+ // The scp-style `user@host:path` or `host:path`, which git reads as ssh.
13
+ const SCP_PATTERN = /^(?:[^@/:\s]+@)?[^@/:\s]+:/u;
14
+
15
+ // Schemes git treats as ssh.
16
+ const SSH_SCHEMES: ReadonlySet<string> = new Set(['ssh', 'git+ssh', 'ssh+git']);
17
+
18
+ /**
19
+ * Checks that a repository URL uses one of the allowed git transports,
20
+ * without running git. The scp-style form is ssh, a remote-helper URL is
21
+ * its helper's transport, and a path is the `file` transport. A URL that
22
+ * git could read as an option is refused whatever the transports.
23
+ */
24
+ export function checkGitTransport(url: string, transports: readonly string[]): TransportCheck {
25
+ if (url.startsWith('-')) {
26
+ return { ok: false, code: 'invalid_git_url', message: 'a git URL must not start with -' };
27
+ }
28
+
29
+ const transport = findTransport(url);
30
+
31
+ if (transports.includes(transport)) {
32
+ return { ok: true };
33
+ }
34
+
35
+ const allowed =
36
+ transports.length === 0
37
+ ? 'workspaces.gitTransports allows no transports'
38
+ : `the daemon fetches over ${transports.join(' and ')}`;
39
+
40
+ return {
41
+ ok: false,
42
+ code: 'invalid_git_url',
43
+ message: `git transport '${transport}' is not allowed; ${allowed}`,
44
+ };
45
+ }
46
+
47
+ function findTransport(url: string): string {
48
+ const helper = HELPER_PATTERN.exec(url)?.groups?.['name'];
49
+
50
+ if (helper !== undefined) {
51
+ return helper.toLowerCase();
52
+ }
53
+
54
+ const scheme = SCHEME_PATTERN.exec(url)?.groups?.['name']?.toLowerCase();
55
+
56
+ if (scheme !== undefined) {
57
+ return SSH_SCHEMES.has(scheme) ? 'ssh' : scheme;
58
+ }
59
+
60
+ return SCP_PATTERN.test(url) ? 'ssh' : 'file';
61
+ }
@@ -0,0 +1,108 @@
1
+ import { mkdtemp, rm } from 'node:fs/promises';
2
+ import { tmpdir } from 'node:os';
3
+ import { join } from 'node:path';
4
+ import { collectRemoteRefs } from './collect-remote-refs';
5
+ import type { RemoteRef } from './collect-remote-refs';
6
+ import type { GitCredential } from './create-git-askpass';
7
+ import { findRemoteRef } from './find-remote-ref';
8
+ import { resolveGitURL } from './resolve-git-url';
9
+
10
+ interface AccessRequest {
11
+ readonly url: string;
12
+
13
+ // At most one: a branch or tag to resolve to its commit, or a full
14
+ // commit id taken as it is.
15
+ readonly ref?: string | undefined;
16
+ readonly sha?: string | undefined;
17
+ readonly credential?: GitCredential | undefined;
18
+
19
+ // How long the listing may take; 20 s when unset.
20
+ readonly timeoutMs?: number | undefined;
21
+
22
+ // The transports the URL may use and git may fetch over.
23
+ readonly transports: readonly string[];
24
+ }
25
+
26
+ interface RepositoryAccess {
27
+ readonly ok: true;
28
+
29
+ // The URL a workspace spawn from this source clones.
30
+ readonly url: string;
31
+ readonly head: string | null;
32
+ readonly refs: readonly RemoteRef[];
33
+
34
+ // The commit the requested ref or sha selects, and the branch it is on;
35
+ // null when the request holds neither.
36
+ readonly resolved: {
37
+ readonly sha: string;
38
+ readonly branch: string | null;
39
+ } | null;
40
+ }
41
+
42
+ interface AccessRefusal {
43
+ readonly ok: false;
44
+ readonly code:
45
+ | 'clone_failed'
46
+ | 'credential_in_url'
47
+ | 'credential_missing'
48
+ | 'invalid_git_url'
49
+ | 'ref_not_found';
50
+ readonly message: string;
51
+ }
52
+
53
+ /**
54
+ * Checks that the daemon's host can read a git workspace source the way a
55
+ * workspace spawn from it would, without cloning: the URL resolves exactly
56
+ * as the materializer resolves it, against the git config of a fresh empty
57
+ * directory, and one `git ls-remote` authenticates exactly as the clone
58
+ * does. A readable upstream answers with its branches, tags, and default
59
+ * branch, and with the commit the requested ref points at now. A full
60
+ * commit id is taken as it is: a listing holds refs, not objects, so only
61
+ * the clone can tell that the upstream lacks it.
62
+ */
63
+ export async function checkRepositoryAccess(
64
+ request: AccessRequest,
65
+ ): Promise<AccessRefusal | RepositoryAccess> {
66
+ const cwd = await mkdtemp(join(tmpdir(), 'atc-repo-access-'));
67
+
68
+ try {
69
+ const resolved = await resolveGitURL(request.url, cwd, request.transports);
70
+
71
+ if (!resolved.ok) {
72
+ return resolved;
73
+ }
74
+
75
+ const listing = await collectRemoteRefs(
76
+ resolved.url,
77
+ request.credential,
78
+ request.transports,
79
+ request.timeoutMs,
80
+ );
81
+
82
+ if (!listing.ok) {
83
+ return listing;
84
+ }
85
+
86
+ const access = { ok: true, url: resolved.url, head: listing.head, refs: listing.refs } as const;
87
+
88
+ if (request.sha !== undefined) {
89
+ return { ...access, resolved: { sha: request.sha, branch: null } };
90
+ }
91
+
92
+ if (request.ref === undefined) {
93
+ return { ...access, resolved: null };
94
+ }
95
+
96
+ const match = findRemoteRef(listing.byName, request.ref);
97
+
98
+ if (match === null) {
99
+ const name = request.ref.replace(/^refs\/(?:heads|tags)\//u, '');
100
+
101
+ return { ok: false, code: 'ref_not_found', message: `origin has no branch or tag '${name}'` };
102
+ }
103
+
104
+ return { ...access, resolved: match };
105
+ } finally {
106
+ await rm(cwd, { recursive: true, force: true });
107
+ }
108
+ }
@@ -0,0 +1,154 @@
1
+ import { createGitAskpass } from './create-git-askpass';
2
+ import type { GitCredential } from './create-git-askpass';
3
+ import { runGit } from './run-git';
4
+
5
+ export interface RemoteRef {
6
+ // The short name: `main`, `feat/x`, `v1.0`.
7
+ readonly name: string;
8
+ readonly kind: 'branch' | 'tag';
9
+
10
+ // The commit the ref points at; an annotated tag's is the commit it
11
+ // peels to.
12
+ readonly sha: string;
13
+ }
14
+
15
+ interface RemoteRefListing {
16
+ readonly ok: true;
17
+
18
+ // The branch the upstream's HEAD points at, or null when it reports none.
19
+ readonly head: string | null;
20
+ readonly refs: readonly RemoteRef[];
21
+
22
+ // Every listed ref by full name, each annotated tag's peeled `^{}` entry
23
+ // beside it, for resolving one ref the way a clone does.
24
+ readonly byName: ReadonlyMap<string, string>;
25
+ }
26
+
27
+ interface RemoteRefRefusal {
28
+ readonly ok: false;
29
+ readonly code: 'clone_failed' | 'credential_missing';
30
+ readonly message: string;
31
+ }
32
+
33
+ // How long a listing may take: a host that never answers holds a client
34
+ // waiting on it no longer than this.
35
+ const REMOTE_TIMEOUT_MS = 20_000;
36
+
37
+ /**
38
+ * Lists an upstream's branches and tags and the branch its HEAD points at,
39
+ * through one `git ls-remote` that authenticates exactly as a clone of the
40
+ * same URL does: through the host's git config, or through a private
41
+ * askpass helper for an env credential. git never prompts. A listing git
42
+ * cannot read is refused as `clone_failed` with git's own message, and so
43
+ * is one that takes longer than the time limit, 20 s unless given. git
44
+ * fetches only over `transports`.
45
+ */
46
+ export async function collectRemoteRefs(
47
+ url: string,
48
+ credential: GitCredential | undefined,
49
+ transports: readonly string[],
50
+ timeoutMs: number = REMOTE_TIMEOUT_MS,
51
+ ): Promise<RemoteRefListing | RemoteRefRefusal> {
52
+ const askpass = await createGitAskpass(credential);
53
+
54
+ if (!askpass.ok) {
55
+ return askpass;
56
+ }
57
+
58
+ let listed: Awaited<ReturnType<typeof runGit>>;
59
+
60
+ try {
61
+ const sshEnv = await buildSSHTimeoutEnv(timeoutMs);
62
+
63
+ listed = await runGit(
64
+ [
65
+ ...askpass.args,
66
+ ...buildHTTPTimeoutArgs(timeoutMs),
67
+ 'ls-remote',
68
+ '--symref',
69
+ '--',
70
+ url,
71
+ 'HEAD',
72
+ 'refs/heads/*',
73
+ 'refs/tags/*',
74
+ ],
75
+ { env: { ...askpass.env, ...sshEnv }, timeoutMs, transports },
76
+ );
77
+ } finally {
78
+ await askpass[Symbol.asyncDispose]();
79
+ }
80
+
81
+ if (listed.timedOut) {
82
+ return {
83
+ ok: false,
84
+ code: 'clone_failed',
85
+ message: `git ls-remote did not answer within ${timeoutMs / 1000} s`,
86
+ };
87
+ }
88
+
89
+ if (listed.exitCode !== 0) {
90
+ return { ok: false, code: 'clone_failed', message: listed.stderr.trim() };
91
+ }
92
+
93
+ return parseListing(listed.stdout);
94
+ }
95
+
96
+ // An ssh connection that takes longer than the limit is given up by ssh.
97
+ // A host that chose its own ssh command keeps it.
98
+ async function buildSSHTimeoutEnv(timeoutMs: number): Promise<Readonly<Record<string, string>>> {
99
+ if (process.env['GIT_SSH_COMMAND'] !== undefined || process.env['GIT_SSH'] !== undefined) {
100
+ return {};
101
+ }
102
+
103
+ const configured = await runGit(['config', '--get', 'core.sshCommand']);
104
+
105
+ if (configured.exitCode === 0 && configured.stdout.trim() !== '') {
106
+ return {};
107
+ }
108
+
109
+ const seconds = String(Math.max(1, Math.ceil(timeoutMs / 1000)));
110
+
111
+ return { GIT_SSH_COMMAND: `ssh -o ConnectTimeout=${seconds}` };
112
+ }
113
+
114
+ // An HTTP transfer that moves under a byte a second for the whole limit is
115
+ // given up by git itself.
116
+ function buildHTTPTimeoutArgs(timeoutMs: number): string[] {
117
+ const seconds = String(Math.max(1, Math.ceil(timeoutMs / 1000)));
118
+
119
+ return ['-c', 'http.lowSpeedLimit=1', '-c', `http.lowSpeedTime=${seconds}`];
120
+ }
121
+
122
+ const SYMREF_PREFIX = 'ref: refs/heads/';
123
+
124
+ function parseListing(stdout: string): RemoteRefListing {
125
+ const byName = new Map<string, string>();
126
+
127
+ let head: string | null = null;
128
+
129
+ for (const line of stdout.split('\n')) {
130
+ const [left = '', name = ''] = line.split('\t');
131
+
132
+ if (name === 'HEAD' && left.startsWith(SYMREF_PREFIX)) {
133
+ head = left.slice(SYMREF_PREFIX.length);
134
+ } else if (name.startsWith('refs/heads/') || name.startsWith('refs/tags/')) {
135
+ byName.set(name, left);
136
+ }
137
+ }
138
+
139
+ const refs: RemoteRef[] = [];
140
+
141
+ for (const [name, sha] of byName) {
142
+ if (name.startsWith('refs/heads/')) {
143
+ refs.push({ name: name.slice('refs/heads/'.length), kind: 'branch', sha });
144
+ } else if (!name.endsWith('^{}')) {
145
+ refs.push({
146
+ name: name.slice('refs/tags/'.length),
147
+ kind: 'tag',
148
+ sha: byName.get(`${name}^{}`) ?? sha,
149
+ });
150
+ }
151
+ }
152
+
153
+ return { ok: true, head, refs, byName };
154
+ }
@@ -0,0 +1,84 @@
1
+ import { mkdtemp, rm, writeFile } from 'node:fs/promises';
2
+ import { tmpdir } from 'node:os';
3
+ import { join } from 'node:path';
4
+
5
+ export interface GitCredential {
6
+ readonly kind: 'env';
7
+ readonly name: string;
8
+ }
9
+
10
+ interface GitAskpass {
11
+ readonly ok: true;
12
+
13
+ // What each network git command takes: the variables that point it at
14
+ // the helper, and the arguments that switch the host's credential
15
+ // helpers off.
16
+ readonly env: Readonly<Record<string, string>>;
17
+ readonly args: readonly string[];
18
+ [Symbol.asyncDispose]: () => Promise<void>;
19
+ }
20
+
21
+ interface MissingCredential {
22
+ readonly ok: false;
23
+ readonly code: 'credential_missing';
24
+ readonly message: string;
25
+ }
26
+
27
+ const ASKPASS_SECRET_VAR = 'ATC_GIT_ASKPASS_SECRET';
28
+
29
+ // Username prompts get a fixed name that GitHub and GitLab both accept
30
+ // alongside a token; password prompts get the token.
31
+ const ASKPASS_SCRIPT = `#!/bin/sh
32
+ case "$1" in
33
+ Username*) printf '%s\\n' x-access-token ;;
34
+ *) printf '%s\\n' "$${ASKPASS_SECRET_VAR}" ;;
35
+ esac
36
+ `;
37
+
38
+ /**
39
+ * Selects how network git commands authenticate. Without a credential they
40
+ * authenticate through the host's own git config, and the result adds
41
+ * nothing. An env credential is read from the named variable and handed to
42
+ * git only through a private askpass helper, never through the command line
43
+ * or the URL, with the host's credential helpers switched off so none of
44
+ * them stores the token. Disposing the result deletes the helper.
45
+ */
46
+ export async function createGitAskpass(
47
+ credential: GitCredential | undefined,
48
+ ): Promise<GitAskpass | MissingCredential> {
49
+ if (credential === undefined) {
50
+ return { ok: true, env: {}, args: [], [Symbol.asyncDispose]: async () => {} };
51
+ }
52
+
53
+ const secret = process.env[credential.name];
54
+
55
+ if (secret === undefined || secret === '') {
56
+ return {
57
+ ok: false,
58
+ code: 'credential_missing',
59
+ message: 'the credential environment variable is unset or empty',
60
+ };
61
+ }
62
+
63
+ const dir = await mkdtemp(join(tmpdir(), 'atc-askpass-'));
64
+
65
+ const helper = join(dir, 'askpass');
66
+
67
+ // A helper that cannot be written leaves no directory behind.
68
+ try {
69
+ await writeFile(helper, ASKPASS_SCRIPT, { mode: 0o700 });
70
+ } catch (error) {
71
+ await rm(dir, { recursive: true, force: true });
72
+
73
+ throw error;
74
+ }
75
+
76
+ return {
77
+ ok: true,
78
+ env: { GIT_ASKPASS: helper, [ASKPASS_SECRET_VAR]: secret },
79
+ args: ['-c', 'credential.helper='],
80
+ [Symbol.asyncDispose]: async () => {
81
+ await rm(dir, { recursive: true, force: true });
82
+ },
83
+ };
84
+ }