@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
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
import { statSync } from 'node:fs';
|
|
2
|
+
import { normalizeGitURL } from './normalize-git-url';
|
|
3
|
+
import { runGit } from './run-git';
|
|
4
|
+
|
|
5
|
+
interface PathSourceOptions {
|
|
6
|
+
readonly allowDirty?: 'refuse' | 'warn';
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
interface ResolvedPathSource {
|
|
10
|
+
readonly ok: true;
|
|
11
|
+
readonly url: string;
|
|
12
|
+
readonly sha: string;
|
|
13
|
+
readonly branch: string | null;
|
|
14
|
+
readonly dirty: boolean;
|
|
15
|
+
readonly warnings: readonly string[];
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
type PathSourceRefusalCode =
|
|
19
|
+
| 'not_a_git_repo'
|
|
20
|
+
| 'no_commits'
|
|
21
|
+
| 'unreadable_tree'
|
|
22
|
+
| 'has_submodules'
|
|
23
|
+
| 'workspace_dirty'
|
|
24
|
+
| 'no_origin'
|
|
25
|
+
| 'invalid_git_url'
|
|
26
|
+
| 'unpushed_head';
|
|
27
|
+
|
|
28
|
+
interface PathSourceRefusal {
|
|
29
|
+
readonly ok: false;
|
|
30
|
+
readonly code: PathSourceRefusalCode;
|
|
31
|
+
readonly message: string;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Turns a local checkout into the repository URL and commit another host
|
|
36
|
+
* can clone, so a workspace never carries files that exist only here. The
|
|
37
|
+
* commit is HEAD, and it must already be on origin: a remote-tracking ref
|
|
38
|
+
* under origin contains it, or origin advertises it as a ref tip. A tree
|
|
39
|
+
* with uncommitted or untracked changes is refused, or with `allowDirty:
|
|
40
|
+
* 'warn'` resolves to HEAD and leaves those changes behind with a warning.
|
|
41
|
+
* Submodules are refused. The URL is origin's, with any credential stripped.
|
|
42
|
+
*/
|
|
43
|
+
export async function resolvePathSource(
|
|
44
|
+
path: string,
|
|
45
|
+
options: PathSourceOptions = {},
|
|
46
|
+
): Promise<PathSourceRefusal | ResolvedPathSource> {
|
|
47
|
+
if (statSync(path, { throwIfNoEntry: false })?.isDirectory() !== true) {
|
|
48
|
+
return { ok: false, code: 'not_a_git_repo', message: `${path} is not a directory` };
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const toplevel = await runGit(['rev-parse', '--show-toplevel'], { cwd: path });
|
|
52
|
+
|
|
53
|
+
if (toplevel.exitCode !== 0) {
|
|
54
|
+
return { ok: false, code: 'not_a_git_repo', message: `${path} is not inside a git work tree` };
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const root = toplevel.stdout.trim();
|
|
58
|
+
|
|
59
|
+
const head = await runGit(['rev-parse', '--verify', '--quiet', 'HEAD^{commit}'], { cwd: root });
|
|
60
|
+
|
|
61
|
+
if (head.exitCode !== 0) {
|
|
62
|
+
return { ok: false, code: 'no_commits', message: `${root} has no commit to check out` };
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const sha = head.stdout.trim();
|
|
66
|
+
|
|
67
|
+
const tree = await runGit(['ls-tree', '-r', '--full-tree', sha], { cwd: root });
|
|
68
|
+
|
|
69
|
+
if (tree.exitCode !== 0) {
|
|
70
|
+
return {
|
|
71
|
+
ok: false,
|
|
72
|
+
code: 'unreadable_tree',
|
|
73
|
+
message: `cannot list the tree of ${sha} in ${root}: ${tree.stderr.trim()}`,
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
if (hasSubmodules(tree.stdout)) {
|
|
78
|
+
return { ok: false, code: 'has_submodules', message: `${root} uses submodules` };
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const status = await runGit(['status', '--porcelain', '--untracked-files=normal'], { cwd: root });
|
|
82
|
+
|
|
83
|
+
if (status.exitCode !== 0) {
|
|
84
|
+
return {
|
|
85
|
+
ok: false,
|
|
86
|
+
code: 'unreadable_tree',
|
|
87
|
+
message: `cannot read the status of ${root}: ${status.stderr.trim()}`,
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const dirty = status.stdout.trim() !== '';
|
|
92
|
+
const warnings: string[] = [];
|
|
93
|
+
|
|
94
|
+
if (dirty && options.allowDirty !== 'warn') {
|
|
95
|
+
return {
|
|
96
|
+
ok: false,
|
|
97
|
+
code: 'workspace_dirty',
|
|
98
|
+
message: `${root} has uncommitted or untracked changes`,
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
if (dirty) {
|
|
103
|
+
warnings.push(`uncommitted and untracked changes in ${root} stay behind; using ${sha}`);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
const origin = await runGit(['ls-remote', '--get-url', 'origin'], { cwd: root });
|
|
107
|
+
|
|
108
|
+
const originURL = origin.stdout.trim();
|
|
109
|
+
|
|
110
|
+
// With no origin configured, git echoes the remote name back.
|
|
111
|
+
if (origin.exitCode !== 0 || originURL === '' || originURL === 'origin') {
|
|
112
|
+
return { ok: false, code: 'no_origin', message: `${root} has no origin remote` };
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
const url = normalizeGitURL(originURL);
|
|
116
|
+
|
|
117
|
+
if (!url.ok) {
|
|
118
|
+
return { ok: false, code: url.code, message: `origin of ${root}: ${url.message}` };
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
const pushed = await isOnOrigin(root, sha);
|
|
122
|
+
|
|
123
|
+
if (!pushed) {
|
|
124
|
+
return { ok: false, code: 'unpushed_head', message: `${sha} is not on origin; push it first` };
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
const branch = await runGit(['symbolic-ref', '--quiet', '--short', 'HEAD'], { cwd: root });
|
|
128
|
+
|
|
129
|
+
return {
|
|
130
|
+
ok: true,
|
|
131
|
+
url: url.url,
|
|
132
|
+
sha,
|
|
133
|
+
branch: branch.exitCode === 0 ? branch.stdout.trim() : null,
|
|
134
|
+
dirty,
|
|
135
|
+
warnings,
|
|
136
|
+
};
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* Whether a recursive tree listing holds a gitlink or a `.gitmodules` file at
|
|
141
|
+
* its root. A workspace clone never initializes submodules, so either would
|
|
142
|
+
* ship an incomplete tree.
|
|
143
|
+
*/
|
|
144
|
+
function hasSubmodules(listing: string): boolean {
|
|
145
|
+
return listing
|
|
146
|
+
.split('\n')
|
|
147
|
+
.some((line) => line.startsWith('160000 ') || line.endsWith('\t.gitmodules'));
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* Whether origin already has the commit. A local remote-tracking ref proves
|
|
152
|
+
* it without network access; failing that, origin's advertised ref tips are
|
|
153
|
+
* checked, which catches a push whose tracking ref was never fetched.
|
|
154
|
+
*/
|
|
155
|
+
async function isOnOrigin(root: string, sha: string): Promise<boolean> {
|
|
156
|
+
const tracking = await runGit(['branch', '-r', '--contains', sha, '--list', 'origin/*'], {
|
|
157
|
+
cwd: root,
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
if (tracking.exitCode === 0 && tracking.stdout.trim() !== '') {
|
|
161
|
+
return true;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
const advertised = await runGit(['ls-remote', 'origin'], { cwd: root });
|
|
165
|
+
|
|
166
|
+
return (
|
|
167
|
+
advertised.exitCode === 0 &&
|
|
168
|
+
advertised.stdout.split('\n').some((line) => line.split('\t')[0] === sha)
|
|
169
|
+
);
|
|
170
|
+
}
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import { REPOSITORY_ENV_VARS } from './repository-env-vars';
|
|
2
|
+
|
|
3
|
+
interface GitRunOptions {
|
|
4
|
+
readonly cwd?: string;
|
|
5
|
+
readonly env?: Readonly<Record<string, string>>;
|
|
6
|
+
readonly input?: string;
|
|
7
|
+
readonly isolated?: boolean;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
interface GitRun {
|
|
11
|
+
readonly exitCode: number;
|
|
12
|
+
readonly stdout: string;
|
|
13
|
+
readonly stderr: string;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Runs one git command to completion, feeding it any given input, and
|
|
18
|
+
* returns its exit code and output.
|
|
19
|
+
* git never prompts on a terminal here, since the daemon has none to answer
|
|
20
|
+
* with, and its messages stay in the C locale so callers can read them.
|
|
21
|
+
* Variables that pin git to some other repository, such as the `GIT_DIR` a
|
|
22
|
+
* git hook exports, are dropped so the command acts on its own directory.
|
|
23
|
+
*
|
|
24
|
+
* An isolated command reads only the repository's own config and
|
|
25
|
+
* attributes: the host's system and global config and its system and global
|
|
26
|
+
* attributes files are ignored, and every LFS filter is switched off. A
|
|
27
|
+
* checkout run this way runs no filter the host configured, so it can
|
|
28
|
+
* neither execute host code nor reach the network with the host's
|
|
29
|
+
* credentials.
|
|
30
|
+
*/
|
|
31
|
+
const ISOLATED_ARGS = [
|
|
32
|
+
'-c',
|
|
33
|
+
'core.attributesFile=/dev/null',
|
|
34
|
+
'-c',
|
|
35
|
+
'filter.lfs.smudge=',
|
|
36
|
+
'-c',
|
|
37
|
+
'filter.lfs.process=',
|
|
38
|
+
'-c',
|
|
39
|
+
'filter.lfs.required=false',
|
|
40
|
+
];
|
|
41
|
+
|
|
42
|
+
const ISOLATED_ENV = {
|
|
43
|
+
GIT_CONFIG_NOSYSTEM: '1',
|
|
44
|
+
GIT_ATTR_NOSYSTEM: '1',
|
|
45
|
+
GIT_CONFIG_GLOBAL: '/dev/null',
|
|
46
|
+
GIT_LFS_SKIP_SMUDGE: '1',
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
export async function runGit(
|
|
50
|
+
args: readonly string[],
|
|
51
|
+
options: GitRunOptions = {},
|
|
52
|
+
): Promise<GitRun> {
|
|
53
|
+
const isolated = options.isolated === true;
|
|
54
|
+
|
|
55
|
+
const proc = Bun.spawn(['git', ...(isolated ? ISOLATED_ARGS : []), ...args], {
|
|
56
|
+
...(options.cwd === undefined ? {} : { cwd: options.cwd }),
|
|
57
|
+
env: {
|
|
58
|
+
...collectHostEnv(),
|
|
59
|
+
...options.env,
|
|
60
|
+
...(isolated ? ISOLATED_ENV : {}),
|
|
61
|
+
GIT_TERMINAL_PROMPT: '0',
|
|
62
|
+
LC_ALL: 'C',
|
|
63
|
+
},
|
|
64
|
+
stdin: options.input === undefined ? 'ignore' : Buffer.from(options.input),
|
|
65
|
+
stdout: 'pipe',
|
|
66
|
+
stderr: 'pipe',
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
const [stdout, stderr, exitCode] = await Promise.all([
|
|
70
|
+
new Response(proc.stdout).text(),
|
|
71
|
+
new Response(proc.stderr).text(),
|
|
72
|
+
proc.exited,
|
|
73
|
+
]);
|
|
74
|
+
|
|
75
|
+
return { exitCode, stdout, stderr };
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function collectHostEnv(): Record<string, string | undefined> {
|
|
79
|
+
return Object.fromEntries(
|
|
80
|
+
Object.entries(process.env).filter(
|
|
81
|
+
([name]) =>
|
|
82
|
+
!REPOSITORY_ENV_VARS.has(name) &&
|
|
83
|
+
!name.startsWith('GIT_CONFIG_KEY_') &&
|
|
84
|
+
!name.startsWith('GIT_CONFIG_VALUE_'),
|
|
85
|
+
),
|
|
86
|
+
);
|
|
87
|
+
}
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
import { mkdir, rm } from 'node:fs/promises';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import { normalizeGitURL } from './normalize-git-url';
|
|
4
|
+
import { runGit } from './run-git';
|
|
5
|
+
import type { WorkspaceProvenance } from './workspace-provenance';
|
|
6
|
+
|
|
7
|
+
interface SanitizedClone {
|
|
8
|
+
readonly ok: true;
|
|
9
|
+
readonly provenance: WorkspaceProvenance;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
interface SanitizeRefusal {
|
|
13
|
+
readonly ok: false;
|
|
14
|
+
readonly code: 'invalid_git_url' | 'sanitize_failed';
|
|
15
|
+
readonly message: string;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Strips a fresh clone of everything that could authenticate as, or run code
|
|
20
|
+
* for, the host it was cloned on, before the clone leaves that host. Objects,
|
|
21
|
+
* refs, HEAD, and the checked-out branch stay. The origin URL is reset to its
|
|
22
|
+
* token-free form; every credential setting, every http extra header, every
|
|
23
|
+
* remote URL carrying a credential, and every URL rewrite whose either side holds
|
|
24
|
+
* userinfo are removed from the repository config; hooks, reflogs (which
|
|
25
|
+
* record the clone URL), and any `.git-credentials` or `.netrc` file are
|
|
26
|
+
* deleted. The result is then checked: the history still reads and the
|
|
27
|
+
* config holds no credential.
|
|
28
|
+
*/
|
|
29
|
+
export async function sanitizeWorkspaceClone(
|
|
30
|
+
dir: string,
|
|
31
|
+
url: string,
|
|
32
|
+
): Promise<SanitizeRefusal | SanitizedClone> {
|
|
33
|
+
const normalized = normalizeGitURL(url);
|
|
34
|
+
|
|
35
|
+
if (!normalized.ok) {
|
|
36
|
+
return normalized;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const gitDir = join(dir, '.git');
|
|
40
|
+
const configFile = join(gitDir, 'config');
|
|
41
|
+
|
|
42
|
+
for (const key of await collectCredentialKeys(configFile)) {
|
|
43
|
+
await runGit(['config', '--file', configFile, '--unset-all', key]);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const setURL = await runGit([
|
|
47
|
+
'config',
|
|
48
|
+
'--file',
|
|
49
|
+
configFile,
|
|
50
|
+
'remote.origin.url',
|
|
51
|
+
normalized.url,
|
|
52
|
+
]);
|
|
53
|
+
|
|
54
|
+
if (setURL.exitCode !== 0) {
|
|
55
|
+
return { ok: false, code: 'sanitize_failed', message: setURL.stderr.trim() };
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
await rm(join(gitDir, 'hooks'), { recursive: true, force: true });
|
|
59
|
+
await mkdir(join(gitDir, 'hooks'));
|
|
60
|
+
await rm(join(gitDir, 'logs'), { recursive: true, force: true });
|
|
61
|
+
|
|
62
|
+
for (const file of ['.git-credentials', '.netrc']) {
|
|
63
|
+
await rm(join(dir, file), { force: true });
|
|
64
|
+
await rm(join(gitDir, file), { force: true });
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
return verifyClone(dir, configFile, normalized.url);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Every config key whose setting can carry a secret: credential helpers and
|
|
72
|
+
* usernames, http extra headers (where an Authorization header rides),
|
|
73
|
+
* remote URLs that lose something when stripped of credentials, and URL
|
|
74
|
+
* rewrites whose either side holds userinfo.
|
|
75
|
+
*/
|
|
76
|
+
async function collectCredentialKeys(configFile: string): Promise<string[]> {
|
|
77
|
+
const listed = await runGit(['config', '--file', configFile, '--list', '-z']);
|
|
78
|
+
|
|
79
|
+
const keys = new Set<string>();
|
|
80
|
+
|
|
81
|
+
for (const entry of listed.stdout.split('\0')) {
|
|
82
|
+
const newline = entry.indexOf('\n');
|
|
83
|
+
const key = newline === -1 ? entry : entry.slice(0, newline);
|
|
84
|
+
const value = newline === -1 ? '' : entry.slice(newline + 1);
|
|
85
|
+
|
|
86
|
+
if (isCredentialEntry(key.toLowerCase(), key, value)) {
|
|
87
|
+
keys.add(key);
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
return [...keys];
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const URL_WITH_USERINFO = /^[a-z][a-z\d+.-]*:\/\/[^/]*@/iu;
|
|
95
|
+
|
|
96
|
+
function isCredentialEntry(lowered: string, key: string, value: string): boolean {
|
|
97
|
+
if (lowered.startsWith('credential.')) {
|
|
98
|
+
return true;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
if (lowered.startsWith('http.') && lowered.endsWith('.extraheader')) {
|
|
102
|
+
return true;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
if (lowered.startsWith('remote.') && /\.(?:url|pushurl)$/u.test(lowered)) {
|
|
106
|
+
const normalized = normalizeGitURL(value);
|
|
107
|
+
|
|
108
|
+
return !normalized.ok || normalized.url !== value;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
const rewrite = /^url\.(?<base>.+)\.(?:insteadof|pushinsteadof)$/iu.exec(key);
|
|
112
|
+
|
|
113
|
+
if (rewrite !== null) {
|
|
114
|
+
return URL_WITH_USERINFO.test(rewrite.groups?.['base'] ?? '') || URL_WITH_USERINFO.test(value);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
return false;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
async function verifyClone(
|
|
121
|
+
dir: string,
|
|
122
|
+
configFile: string,
|
|
123
|
+
url: string,
|
|
124
|
+
): Promise<SanitizeRefusal | SanitizedClone> {
|
|
125
|
+
const log = await runGit(['log', '-1', '--format=%H'], { cwd: dir });
|
|
126
|
+
|
|
127
|
+
if (log.exitCode !== 0) {
|
|
128
|
+
return {
|
|
129
|
+
ok: false,
|
|
130
|
+
code: 'sanitize_failed',
|
|
131
|
+
message: `history unreadable: ${log.stderr.trim()}`,
|
|
132
|
+
};
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
const remaining = await collectCredentialKeys(configFile);
|
|
136
|
+
|
|
137
|
+
if (remaining.length > 0) {
|
|
138
|
+
return {
|
|
139
|
+
ok: false,
|
|
140
|
+
code: 'sanitize_failed',
|
|
141
|
+
message: `config still holds ${remaining.length} credential entries`,
|
|
142
|
+
};
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
return { ok: true, provenance: { repoURL: url, sha: log.stdout.trim() } };
|
|
146
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What a materialized workspace was built from: the token-free repository
|
|
3
|
+
* URL and the exact commit checked out, plus the image and harness version
|
|
4
|
+
* when the execution target reports them.
|
|
5
|
+
*/
|
|
6
|
+
export interface WorkspaceProvenance {
|
|
7
|
+
readonly repoURL: string;
|
|
8
|
+
readonly sha: string;
|
|
9
|
+
readonly image?: string;
|
|
10
|
+
readonly harnessVersion?: string;
|
|
11
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Where a session's workspace comes from: a directory on a host, which is
|
|
3
|
+
* resolved to a pushed commit before anything leaves that host, or a git
|
|
4
|
+
* repository and ref that the control side clones itself.
|
|
5
|
+
*/
|
|
6
|
+
export type WorkspaceSource =
|
|
7
|
+
| { readonly kind: 'path'; readonly host: string; readonly path: string }
|
|
8
|
+
| { readonly kind: 'git'; readonly url: string; readonly ref: string };
|
|
File without changes
|