@peakflo/agent-harness 0.0.0-stage → 0.2.2-rc.1

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 (85) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +68 -2
  3. package/dist/adapters/acp-adapter.d.ts +177 -0
  4. package/dist/adapters/acp-adapter.js +1732 -0
  5. package/dist/adapters/claude-code-adapter.d.ts +171 -0
  6. package/dist/adapters/claude-code-adapter.js +1918 -0
  7. package/dist/adapters/codex-adapter.d.ts +67 -0
  8. package/dist/adapters/codex-adapter.js +681 -0
  9. package/dist/adapters/codex-app-server-adapter.d.ts +105 -0
  10. package/dist/adapters/codex-app-server-adapter.js +1309 -0
  11. package/dist/adapters/coding-agent-adapter.d.ts +289 -0
  12. package/dist/adapters/coding-agent-adapter.js +31 -0
  13. package/dist/adapters/index.d.ts +17 -0
  14. package/dist/adapters/index.js +35 -0
  15. package/dist/adapters/model-gateway.d.ts +84 -0
  16. package/dist/adapters/model-gateway.js +270 -0
  17. package/dist/adapters/opencode-adapter.d.ts +403 -0
  18. package/dist/adapters/opencode-adapter.js +2710 -0
  19. package/dist/adapters/opencode-config.d.ts +41 -0
  20. package/dist/adapters/opencode-config.js +147 -0
  21. package/dist/adapters/opencode-server-policy.d.ts +82 -0
  22. package/dist/adapters/opencode-server-policy.js +148 -0
  23. package/dist/adapters/pi-adapter.d.ts +168 -0
  24. package/dist/adapters/pi-adapter.js +1468 -0
  25. package/dist/capabilities.d.ts +58 -0
  26. package/dist/capabilities.js +161 -0
  27. package/dist/cli.d.ts +2 -0
  28. package/dist/cli.js +231 -0
  29. package/dist/contract.d.ts +273 -0
  30. package/dist/contract.js +357 -0
  31. package/dist/control-plane.d.ts +178 -0
  32. package/dist/control-plane.js +431 -0
  33. package/dist/credential-store.d.ts +110 -0
  34. package/dist/credential-store.js +181 -0
  35. package/dist/enrol/browser-enrol.d.ts +85 -0
  36. package/dist/enrol/browser-enrol.js +387 -0
  37. package/dist/github-command-guards.d.ts +20 -0
  38. package/dist/github-command-guards.js +330 -0
  39. package/dist/github-proxy-certificates.d.ts +25 -0
  40. package/dist/github-proxy-certificates.js +145 -0
  41. package/dist/github-proxy.d.ts +37 -0
  42. package/dist/github-proxy.js +476 -0
  43. package/dist/github-workspace.d.ts +55 -0
  44. package/dist/github-workspace.js +537 -0
  45. package/dist/harness.d.ts +333 -0
  46. package/dist/harness.js +1414 -0
  47. package/dist/health.d.ts +45 -0
  48. package/dist/health.js +116 -0
  49. package/dist/index.d.ts +53 -0
  50. package/dist/index.js +123 -0
  51. package/dist/model-probe.d.ts +96 -0
  52. package/dist/model-probe.js +195 -0
  53. package/dist/options.d.ts +15 -0
  54. package/dist/options.js +212 -0
  55. package/dist/ports-default.d.ts +212 -0
  56. package/dist/ports-default.js +333 -0
  57. package/dist/ports.d.ts +436 -0
  58. package/dist/ports.js +27 -0
  59. package/dist/runtime/attachments.d.ts +92 -0
  60. package/dist/runtime/attachments.js +377 -0
  61. package/dist/runtime/child-stream-guards.d.ts +45 -0
  62. package/dist/runtime/child-stream-guards.js +111 -0
  63. package/dist/runtime/conversation.d.ts +87 -0
  64. package/dist/runtime/conversation.js +156 -0
  65. package/dist/runtime/lease-renewal.d.ts +169 -0
  66. package/dist/runtime/lease-renewal.js +433 -0
  67. package/dist/runtime/mcp-auth-proxy.d.ts +82 -0
  68. package/dist/runtime/mcp-auth-proxy.js +300 -0
  69. package/dist/runtime/permission-mode.d.ts +116 -0
  70. package/dist/runtime/permission-mode.js +146 -0
  71. package/dist/runtime/projection.d.ts +71 -0
  72. package/dist/runtime/projection.js +211 -0
  73. package/dist/runtime/session-runner.d.ts +520 -0
  74. package/dist/runtime/session-runner.js +2122 -0
  75. package/dist/shared/reasoning-effort.d.ts +11 -0
  76. package/dist/shared/reasoning-effort.js +20 -0
  77. package/dist/store/conversation-store.d.ts +100 -0
  78. package/dist/store/conversation-store.js +305 -0
  79. package/dist/store/sqlite-store.d.ts +120 -0
  80. package/dist/store/sqlite-store.js +346 -0
  81. package/dist/types.d.ts +952 -0
  82. package/dist/types.js +55 -0
  83. package/dist/version.d.ts +32 -0
  84. package/dist/version.js +77 -0
  85. package/package.json +53 -4
@@ -0,0 +1,330 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.MERGE_REFUSAL = void 0;
4
+ exports.installGhGuard = installGhGuard;
5
+ exports.acquireGhGuardPath = acquireGhGuardPath;
6
+ exports.installPushGuard = installPushGuard;
7
+ const node_fs_1 = require("node:fs");
8
+ const promises_1 = require("node:fs/promises");
9
+ const node_path_1 = require("node:path");
10
+ exports.MERGE_REFUSAL = 'GitHub merge refused: agents may open pull requests but must not merge them.';
11
+ let guardPathState;
12
+ let guardPathQueue = Promise.resolve();
13
+ /**
14
+ * Install friendly command-line refusals before backend processes start.
15
+ * These launchers are UX only. The TLS proxy is the security boundary and
16
+ * applies the same policy even when an agent calls the real binary directly.
17
+ */
18
+ async function installGhGuard(root) {
19
+ const bin = (0, node_path_1.join)(root, '.github-bin');
20
+ await (0, promises_1.mkdir)(bin, { recursive: true, mode: 0o700 });
21
+ let executable = null;
22
+ let gitExecutable = null;
23
+ for (const directory of (process.env.PATH ?? '').split(node_path_1.delimiter)) {
24
+ if (!directory || directory === bin)
25
+ continue;
26
+ const candidate = (0, node_path_1.join)(directory, 'gh');
27
+ try {
28
+ await (0, promises_1.access)(candidate, node_fs_1.constants.X_OK);
29
+ executable = candidate;
30
+ break;
31
+ }
32
+ catch {
33
+ /* Try next PATH entry. */
34
+ }
35
+ }
36
+ for (const directory of (process.env.PATH ?? '').split(node_path_1.delimiter)) {
37
+ if (!directory || directory === bin)
38
+ continue;
39
+ const candidate = (0, node_path_1.join)(directory, 'git');
40
+ try {
41
+ await (0, promises_1.access)(candidate, node_fs_1.constants.X_OK);
42
+ gitExecutable = candidate;
43
+ break;
44
+ }
45
+ catch {
46
+ /* Next entry. */
47
+ }
48
+ }
49
+ await (0, promises_1.writeFile)((0, node_path_1.join)(bin, 'git'), `#!${process.execPath}
50
+ const { spawnSync } = require('node:child_process');
51
+ const { readFileSync } = require('node:fs');
52
+ const { resolve, join } = require('node:path');
53
+ const executable = ${JSON.stringify(gitExecutable)};
54
+ if (!executable) { console.error('git is not installed'); process.exit(127); }
55
+ const args = process.argv.slice(2);
56
+ const gitEnv = { ...process.env };
57
+ for (const key of Object.keys(gitEnv)) {
58
+ if (key.startsWith('GIT_')) delete gitEnv[key];
59
+ if (key.startsWith('GH_') && !['GH_TOKEN', 'GH_CONFIG_DIR'].includes(key)) delete gitEnv[key];
60
+ }
61
+ gitEnv.GIT_TERMINAL_PROMPT = '0';
62
+ // The wrapper deliberately removes caller-controlled GIT_* overrides, but
63
+ // Git's libcurl requires GIT_SSL_CAINFO to trust the harness TLS proxy. Pin it
64
+ // to the harness-owned bundle instead of accepting the caller's value.
65
+ gitEnv.GIT_SSL_CAINFO = ${JSON.stringify((0, node_path_1.join)(root, '.github-proxy-ca.pem'))};
66
+ for (const key of ['GIT_AUTHOR_NAME', 'GIT_AUTHOR_EMAIL', 'GIT_AUTHOR_DATE', 'GIT_COMMITTER_NAME', 'GIT_COMMITTER_EMAIL', 'GIT_COMMITTER_DATE']) {
67
+ if (process.env[key]) gitEnv[key] = process.env[key];
68
+ }
69
+ let command = 0;
70
+ let cwd = process.cwd();
71
+ let refusedGlobalOption = null;
72
+ const valueOptions = new Set(['-C', '-c', '--git-dir', '--work-tree', '--namespace', '--super-prefix', '--config-env', '--list-cmds', '--attr-source']);
73
+ const flagOptions = new Set(['-p', '-P', '--bare', '--no-pager', '--paginate', '--exec-path', '--html-path', '--man-path', '--info-path', '--version', '--help', '--literal-pathspecs', '--glob-pathspecs', '--noglob-pathspecs', '--icase-pathspecs', '--no-optional-locks', '--no-replace-objects', '--no-lazy-fetch', '--no-advice']);
74
+ const redirectingOptions = new Set(['--git-dir', '--work-tree', '--namespace', '--super-prefix', '--config-env', '--bare', '--exec-path']);
75
+ while (command < args.length) {
76
+ const arg = args[command];
77
+ if (valueOptions.has(arg) && args[command + 1]) {
78
+ if (arg === '-C') cwd = resolve(cwd, args[command + 1]);
79
+ if (redirectingOptions.has(arg)) refusedGlobalOption = arg;
80
+ if (arg === '-c') refusedGlobalOption = arg + ' ' + args[command + 1].split('=', 1)[0];
81
+ command += 2;
82
+ continue;
83
+ }
84
+ if (/^-c.+/.test(arg)) {
85
+ refusedGlobalOption = '-c ' + arg.slice(2).split('=', 1)[0];
86
+ command += 1;
87
+ continue;
88
+ }
89
+ if (/^-C.+/.test(arg)) {
90
+ cwd = resolve(cwd, arg.slice(2));
91
+ command += 1;
92
+ continue;
93
+ }
94
+ const assignedOption = arg.match(/^(--(?:git-dir|work-tree|namespace|super-prefix|config-env|exec-path|list-cmds|attr-source))=/)?.[1];
95
+ if (flagOptions.has(arg) || assignedOption) {
96
+ if (redirectingOptions.has(arg) || (assignedOption && redirectingOptions.has(assignedOption))) refusedGlobalOption = assignedOption ?? arg;
97
+ command += 1;
98
+ continue;
99
+ }
100
+ break;
101
+ }
102
+ if (refusedGlobalOption && args[command] !== 'push') {
103
+ console.error('Git command refused: global option ' + refusedGlobalOption + ' can redirect repository policy or command execution.');
104
+ process.exit(1);
105
+ }
106
+ if (args[command] === 'config') {
107
+ const configArgs = args.slice(command + 1);
108
+ const readActions = new Set(['--get', '--get-all', '--get-regexp', '--get-urlmatch', '--list', '-l', '--get-color', '--get-colorbool']);
109
+ const writeActions = new Set(['--add', '--replace-all', '--unset', '--unset-all', '--rename-section', '--remove-section', '-e', '--edit']);
110
+ const positional = configArgs.filter(arg => !arg.startsWith('-'));
111
+ const mutates = configArgs.some(arg => writeActions.has(arg)) || (!configArgs.some(arg => readActions.has(arg)) && positional.length > 1);
112
+ if (mutates) {
113
+ console.error('Git config refused: agents may read Git configuration but may not change it.');
114
+ process.exit(1);
115
+ }
116
+ }
117
+ if (args[command] === 'push') {
118
+ const refuse = () => { console.error('Git push refused: use an explicit agent/<session-id> destination; default and other branches are forbidden.'); process.exit(1); };
119
+ if (refusedGlobalOption) {
120
+ console.error('Git push refused: global option ' + refusedGlobalOption + ' can redirect the repository, hooks, refs, or push URL.');
121
+ process.exit(1);
122
+ }
123
+ let policy;
124
+ const hooks = spawnSync(executable, ['-C', cwd, 'config', '--get', 'core.hooksPath'], { encoding: 'utf8', env: gitEnv });
125
+ try { policy = JSON.parse(readFileSync(join(hooks.stdout.trim(), 'push-policy.json'), 'utf8')); } catch { refuse(); }
126
+ const positional = [];
127
+ for (const arg of args.slice(command + 1)) {
128
+ if (['-u', '--set-upstream', '-f', '--force', '--force-with-lease', '--dry-run', '-n', '--porcelain', '-v', '--verbose'].includes(arg)) continue;
129
+ if (arg.startsWith('-')) refuse();
130
+ positional.push(arg);
131
+ }
132
+ if (positional.length < 2) refuse();
133
+ if (positional[0] !== 'origin') refuse();
134
+ const remote = spawnSync(executable, ['-C', cwd, 'remote', 'get-url', '--push', positional[0]], { encoding: 'utf8', env: gitEnv });
135
+ if (remote.status !== 0 || remote.stdout.trim() !== policy.remoteUrl) refuse();
136
+ for (const ref of positional.slice(1)) {
137
+ const destination = ref.replace(/^\\+/, '').split(':').pop().replace(/^refs\\/heads\\//, '');
138
+ if (destination === policy.defaultBranch || destination !== policy.branch) refuse();
139
+ }
140
+ }
141
+ const result = spawnSync(executable, args, { stdio: 'inherit', env: gitEnv });
142
+ process.exit(result.status ?? 1);
143
+ `, { mode: 0o700 });
144
+ await (0, promises_1.writeFile)((0, node_path_1.join)(bin, 'gh'), `#!${process.execPath}
145
+ const { spawnSync } = require('node:child_process');
146
+ const { readFileSync } = require('node:fs');
147
+ const { resolve } = require('node:path');
148
+ const args = process.argv.slice(2);
149
+ const cleanEnv = { ...process.env };
150
+ for (const key of Object.keys(cleanEnv)) {
151
+ if (key.startsWith('GIT_')) delete cleanEnv[key];
152
+ if (key.startsWith('GH_') && !['GH_TOKEN', 'GH_CONFIG_DIR'].includes(key)) delete cleanEnv[key];
153
+ }
154
+ cleanEnv.GIT_TERMINAL_PROMPT = '0';
155
+ // Inspect all args, including endpoint=... and flags before the subcommand.
156
+ const decodedArgs = args.map(arg => {
157
+ try { return decodeURIComponent(arg); } catch { return arg; }
158
+ });
159
+ const ghGlobalValueOptions = new Set(['-R', '--repo', '--hostname']);
160
+ const ghGlobalFlagOptions = new Set(['--help', '--version', '--prompt', '--no-pager']);
161
+ let command = 0;
162
+ while (command < decodedArgs.length) {
163
+ const arg = decodedArgs[command];
164
+ if (ghGlobalValueOptions.has(arg) && decodedArgs[command + 1]) {
165
+ command += 2;
166
+ continue;
167
+ }
168
+ if (/^-R.+/.test(arg) || /^--(?:repo|hostname)=/.test(arg) || ghGlobalFlagOptions.has(arg)) {
169
+ command += 1;
170
+ continue;
171
+ }
172
+ break;
173
+ }
174
+ const commandName = decodedArgs[command];
175
+ const api = commandName === 'api' ? command : -1;
176
+ const apiValueOptions = new Set(['-X', '--method', '-H', '--header', '-F', '--field', '-f', '--raw-field', '--input', '--hostname', '--cache']);
177
+ let endpoint = null;
178
+ let inputBuffer = null;
179
+ const body = [...decodedArgs];
180
+ if (api >= 0) {
181
+ for (let index = api + 1; index < decodedArgs.length; index += 1) {
182
+ const arg = decodedArgs[index];
183
+ if (apiValueOptions.has(arg)) {
184
+ const value = decodedArgs[index + 1];
185
+ if (arg === '--input' && value) {
186
+ try {
187
+ inputBuffer = value === '-' ? readFileSync(0) : readFileSync(resolve(value));
188
+ body.push(inputBuffer.toString('utf8'));
189
+ } catch { /* Let gh report an unreadable input after policy checks. */ }
190
+ }
191
+ index += 1;
192
+ continue;
193
+ }
194
+ const assigned = arg.match(/^(?:-X|--method|-H|--header|-F|--field|-f|--raw-field|--input|--hostname|--cache)=(.*)$/)?.[1];
195
+ if (assigned !== undefined) {
196
+ if (/^--input=/.test(arg)) {
197
+ try {
198
+ inputBuffer = assigned === '-' ? readFileSync(0) : readFileSync(resolve(assigned));
199
+ body.push(inputBuffer.toString('utf8'));
200
+ } catch { /* Let gh report an unreadable input after policy checks. */ }
201
+ }
202
+ continue;
203
+ }
204
+ if (arg.startsWith('-')) continue;
205
+ if (endpoint === null) endpoint = arg;
206
+ }
207
+ }
208
+ const normaliseEndpoint = value => {
209
+ if (!value) return '';
210
+ try {
211
+ const parsed = new URL(value, 'https://api.github.com');
212
+ return parsed.pathname.replace(/\\/+$/, '') || '/';
213
+ } catch {
214
+ return value.split(/[?#]/, 1)[0].replace(/\\/+$/, '');
215
+ }
216
+ };
217
+ const mergeOperation = api >= 0 && body.some(value => /\\b(?:mergePullRequest|enablePullRequestAutoMerge)\\b/i.test(value));
218
+ const mergePath = api >= 0 && /^\\/repos\\/[^/]+\\/[^/]+\\/(?:pulls\\/[^/]+\\/merge|merges)$/i.test(normaliseEndpoint(endpoint));
219
+ const containsPrMerge = value => /(?:^|\\s)pr(?:\\s+--?\\S+(?:\\s+\\S+)?)*\\s+merge(?:\\s|$)/i.test(value);
220
+ let prCommand = command + 1;
221
+ if (commandName === 'pr') {
222
+ while (prCommand < decodedArgs.length) {
223
+ const arg = decodedArgs[prCommand];
224
+ if (ghGlobalValueOptions.has(arg) && decodedArgs[prCommand + 1]) {
225
+ prCommand += 2;
226
+ continue;
227
+ }
228
+ if (/^-R.+/.test(arg) || /^--(?:repo|hostname)=/.test(arg) || ghGlobalFlagOptions.has(arg)) {
229
+ prCommand += 1;
230
+ continue;
231
+ }
232
+ break;
233
+ }
234
+ }
235
+ const mergeCommand = commandName === 'pr' && decodedArgs[prCommand] === 'merge';
236
+ const commandTail = decodedArgs.slice(command + 1);
237
+ const administrativeCommand =
238
+ (commandName === 'repo' && commandTail.includes('delete')) ||
239
+ (commandName === 'secret' && commandTail.includes('set')) ||
240
+ (commandName === 'workflow' && commandTail.includes('run'));
241
+ const dangerousAliasText = value => /\\b(?:mergePullRequest|enablePullRequestAutoMerge)\\b|\\/merges?(?:[\\s/?#]|$)/i.test(value) || containsPrMerge(value);
242
+ const executable = ${JSON.stringify(executable)};
243
+ let dangerousAlias = commandName === 'alias' && decodedArgs.slice(command).some(dangerousAliasText);
244
+ if (!dangerousAlias && executable && commandName) {
245
+ const alias = spawnSync(executable, ['alias', 'get', commandName], { encoding: 'utf8', env: cleanEnv });
246
+ dangerousAlias = alias.status === 0 && dangerousAliasText(alias.stdout);
247
+ }
248
+ if (mergeOperation || mergePath || mergeCommand || dangerousAlias) {
249
+ console.error(${JSON.stringify(exports.MERGE_REFUSAL)});
250
+ process.exit(1);
251
+ }
252
+ if (administrativeCommand) {
253
+ console.error('GitHub request refused: this administrative operation is not permitted.');
254
+ process.exit(1);
255
+ }
256
+ if (!executable) { console.error('gh is not installed; use the workspace GitHub API link to open a PR.'); process.exit(127); }
257
+ const result = inputBuffer
258
+ ? spawnSync(executable, args, { input: inputBuffer, stdio: ['pipe', 'inherit', 'inherit'], env: cleanEnv })
259
+ : spawnSync(executable, args, { stdio: 'inherit', env: cleanEnv });
260
+ process.exit(result.status ?? 1);
261
+ `, { mode: 0o700 });
262
+ return bin;
263
+ }
264
+ /**
265
+ * Lease one session-neutral guard directory for the whole process. Generated
266
+ * launchers read policy from the target worktree, so sharing the launcher is
267
+ * safe; stacking per-Harness directories in global PATH is not.
268
+ */
269
+ async function acquireGhGuardPath(root) {
270
+ let unlock;
271
+ const previous = guardPathQueue;
272
+ guardPathQueue = new Promise((resolve) => {
273
+ unlock = resolve;
274
+ });
275
+ await previous;
276
+ try {
277
+ if (!guardPathState) {
278
+ const bin = await installGhGuard(root);
279
+ const path = (process.env.PATH ?? '')
280
+ .split(node_path_1.delimiter)
281
+ .filter((entry) => entry && entry !== bin);
282
+ process.env.PATH = [bin, ...path].join(node_path_1.delimiter);
283
+ guardPathState = { bin, references: 0 };
284
+ }
285
+ const state = guardPathState;
286
+ state.references += 1;
287
+ let released = false;
288
+ return {
289
+ bin: state.bin,
290
+ release() {
291
+ if (released)
292
+ return;
293
+ released = true;
294
+ state.references -= 1;
295
+ if (state.references === 0 && guardPathState === state) {
296
+ process.env.PATH = (process.env.PATH ?? '')
297
+ .split(node_path_1.delimiter)
298
+ .filter((entry) => entry && entry !== state.bin)
299
+ .join(node_path_1.delimiter);
300
+ guardPathState = undefined;
301
+ }
302
+ }
303
+ };
304
+ }
305
+ finally {
306
+ unlock();
307
+ }
308
+ }
309
+ /** Git supplies resolved destination refs, covering force, wildcard and implicit pushes. */
310
+ async function installPushGuard(directory, sessionId, defaultBranch, remoteUrl) {
311
+ await (0, promises_1.mkdir)(directory, { recursive: true, mode: 0o700 });
312
+ await (0, promises_1.writeFile)((0, node_path_1.join)(directory, 'push-policy.json'), JSON.stringify({ branch: `agent/${sessionId}`, defaultBranch, remoteUrl }), { mode: 0o600 });
313
+ await (0, promises_1.writeFile)((0, node_path_1.join)(directory, 'pre-push'), `#!${process.execPath}
314
+ const { readFileSync } = require('node:fs');
315
+ const allowed = ${JSON.stringify(`refs/heads/agent/${sessionId}`)};
316
+ const defaultRef = ${JSON.stringify(`refs/heads/${defaultBranch}`)};
317
+ const allowedRemote = ${JSON.stringify(remoteUrl)};
318
+ if (process.argv[2] !== 'origin' || process.argv[3] !== allowedRemote) {
319
+ console.error('Git push refused: remote ' + process.argv[2] + ' is not this worktree origin.');
320
+ process.exit(1);
321
+ }
322
+ for (const line of readFileSync(0, 'utf8').trim().split('\\n').filter(Boolean)) {
323
+ const remoteRef = line.split(/\\s+/)[2];
324
+ if (remoteRef === defaultRef || remoteRef !== allowed) {
325
+ console.error('Git push refused: destination ' + remoteRef + '; this session may push only ' + allowed + '.');
326
+ process.exit(1);
327
+ }
328
+ }
329
+ `, { mode: 0o700 });
330
+ }
@@ -0,0 +1,25 @@
1
+ import 'reflect-metadata';
2
+ export interface GitHubProxyCertificateOptions {
3
+ /** Override only for deterministic tests. Production preserves process.env. */
4
+ environment?: NodeJS.ProcessEnv;
5
+ }
6
+ export interface GitHubProxyCertificates {
7
+ caCertificate: Buffer;
8
+ caCertificatePath: string;
9
+ leaf(hostname: string): Promise<{
10
+ certificate: Buffer;
11
+ privateKey: Buffer;
12
+ }>;
13
+ }
14
+ /**
15
+ * Create one short-lived CA for this harness process. WebCrypto creates and
16
+ * signs every key in process. Private key material exists only in memory. Only
17
+ * the public CA certificate is written, as the command-line trust anchor.
18
+ *
19
+ * SSL_CERT_FILE and CURL_CA_BUNDLE replace (rather than extend) the caller's
20
+ * normal trust store. The file therefore also carries Node's public roots. If
21
+ * it contained only our proxy CA, direct HTTPS calls such as the model gateway
22
+ * would fail with `unable to verify the first certificate` even though their
23
+ * public certificate chains are valid.
24
+ */
25
+ export declare function createGitHubProxyCertificates(stateRoot: string, options?: GitHubProxyCertificateOptions): Promise<GitHubProxyCertificates>;
@@ -0,0 +1,145 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.createGitHubProxyCertificates = createGitHubProxyCertificates;
4
+ require("reflect-metadata");
5
+ const node_crypto_1 = require("node:crypto");
6
+ const promises_1 = require("node:fs/promises");
7
+ const node_path_1 = require("node:path");
8
+ const node_tls_1 = require("node:tls");
9
+ const x509_1 = require("@peculiar/x509");
10
+ const CA_CERTIFICATE_FILE = '.github-proxy-ca.pem';
11
+ const CERTIFICATE_LIFETIME_MS = 2 * 24 * 60 * 60 * 1000;
12
+ const SIGNING_ALGORITHM = {
13
+ name: 'RSASSA-PKCS1-v1_5',
14
+ hash: 'SHA-256',
15
+ publicExponent: new Uint8Array([1, 0, 1]),
16
+ modulusLength: 2048
17
+ };
18
+ const AMBIENT_CA_ENVIRONMENT_KEYS = [
19
+ 'SSL_CERT_FILE',
20
+ 'CURL_CA_BUNDLE',
21
+ 'NODE_EXTRA_CA_CERTS'
22
+ ];
23
+ async function ambientCaCertificates(environment, outputPath) {
24
+ const paths = new Set();
25
+ for (const key of AMBIENT_CA_ENVIRONMENT_KEYS) {
26
+ const value = environment[key]?.trim();
27
+ if (!value)
28
+ continue;
29
+ const path = (0, node_path_1.resolve)(value);
30
+ // A reused process must never append yesterday's generated bundle to the
31
+ // new one and make the file grow on every proxy restart.
32
+ if (path !== outputPath)
33
+ paths.add(path);
34
+ }
35
+ const certificates = [];
36
+ for (const path of paths) {
37
+ try {
38
+ const contents = await (0, promises_1.readFile)(path);
39
+ if (contents.length > 0) {
40
+ certificates.push(contents.at(-1) === 0x0a
41
+ ? contents
42
+ : Buffer.concat([contents, Buffer.from('\n')]));
43
+ }
44
+ }
45
+ catch (error) {
46
+ const code = error.code;
47
+ // Node itself warns and ignores absent extra-CA files. Preserve that
48
+ // compatibility while refusing to silently discard a CA file that
49
+ // exists but cannot be read.
50
+ if (code === 'ENOENT' || code === 'ENOTDIR')
51
+ continue;
52
+ throw error;
53
+ }
54
+ }
55
+ return certificates;
56
+ }
57
+ function validity() {
58
+ const now = Date.now();
59
+ return {
60
+ // Permit a small clock difference between the harness and the client.
61
+ notBefore: new Date(now - 60_000),
62
+ notAfter: new Date(now + CERTIFICATE_LIFETIME_MS)
63
+ };
64
+ }
65
+ function serialNumber() {
66
+ return (0, node_crypto_1.randomBytes)(16).toString('hex');
67
+ }
68
+ async function privateKeyPem(key) {
69
+ const pkcs8 = await node_crypto_1.webcrypto.subtle.exportKey('pkcs8', key);
70
+ return Buffer.from(x509_1.PemConverter.encode(pkcs8, x509_1.PemConverter.PrivateKeyTag));
71
+ }
72
+ /**
73
+ * Create one short-lived CA for this harness process. WebCrypto creates and
74
+ * signs every key in process. Private key material exists only in memory. Only
75
+ * the public CA certificate is written, as the command-line trust anchor.
76
+ *
77
+ * SSL_CERT_FILE and CURL_CA_BUNDLE replace (rather than extend) the caller's
78
+ * normal trust store. The file therefore also carries Node's public roots. If
79
+ * it contained only our proxy CA, direct HTTPS calls such as the model gateway
80
+ * would fail with `unable to verify the first certificate` even though their
81
+ * public certificate chains are valid.
82
+ */
83
+ async function createGitHubProxyCertificates(stateRoot, options = {}) {
84
+ x509_1.cryptoProvider.set(node_crypto_1.webcrypto);
85
+ const root = (0, node_path_1.resolve)(stateRoot);
86
+ await (0, promises_1.mkdir)(root, { recursive: true, mode: 0o700 });
87
+ const caKeys = (await node_crypto_1.webcrypto.subtle.generateKey(SIGNING_ALGORITHM, false, [
88
+ 'sign',
89
+ 'verify'
90
+ ]));
91
+ const ca = await x509_1.X509CertificateGenerator.createSelfSigned({
92
+ serialNumber: serialNumber(),
93
+ name: 'CN=Peakflo GitHub Sandbox Proxy CA',
94
+ ...validity(),
95
+ signingAlgorithm: SIGNING_ALGORITHM,
96
+ keys: caKeys,
97
+ extensions: [
98
+ new x509_1.BasicConstraintsExtension(true, undefined, true),
99
+ new x509_1.KeyUsagesExtension(x509_1.KeyUsageFlags.keyCertSign | x509_1.KeyUsageFlags.cRLSign, true),
100
+ await x509_1.SubjectKeyIdentifierExtension.create(caKeys.publicKey)
101
+ ]
102
+ });
103
+ const caCertificate = Buffer.from(ca.toString('pem'));
104
+ const caCertificatePath = (0, node_path_1.join)(root, CA_CERTIFICATE_FILE);
105
+ const ambientRoots = await ambientCaCertificates(options.environment ?? process.env, caCertificatePath);
106
+ const publicRoots = `${node_tls_1.rootCertificates.join('\n')}\n`;
107
+ await (0, promises_1.writeFile)(caCertificatePath, Buffer.concat([...ambientRoots, Buffer.from(publicRoots), caCertificate]), { mode: 0o644 });
108
+ await (0, promises_1.chmod)(caCertificatePath, 0o644);
109
+ const leaves = new Map();
110
+ const makeLeaf = async (hostname) => {
111
+ const leafKeys = (await node_crypto_1.webcrypto.subtle.generateKey(SIGNING_ALGORITHM, true, ['sign', 'verify']));
112
+ const certificate = await x509_1.X509CertificateGenerator.create({
113
+ serialNumber: serialNumber(),
114
+ subject: `CN=${hostname}`,
115
+ issuer: ca.subject,
116
+ ...validity(),
117
+ signingAlgorithm: SIGNING_ALGORITHM,
118
+ publicKey: leafKeys.publicKey,
119
+ signingKey: caKeys.privateKey,
120
+ extensions: [
121
+ new x509_1.BasicConstraintsExtension(false, undefined, true),
122
+ new x509_1.KeyUsagesExtension(x509_1.KeyUsageFlags.digitalSignature | x509_1.KeyUsageFlags.keyEncipherment, true),
123
+ new x509_1.ExtendedKeyUsageExtension([x509_1.ExtendedKeyUsage.serverAuth]),
124
+ new x509_1.SubjectAlternativeNameExtension([{ type: 'dns', value: hostname }]),
125
+ await x509_1.SubjectKeyIdentifierExtension.create(leafKeys.publicKey)
126
+ ]
127
+ });
128
+ return {
129
+ certificate: Buffer.from(certificate.toString('pem')),
130
+ privateKey: await privateKeyPem(leafKeys.privateKey)
131
+ };
132
+ };
133
+ return {
134
+ caCertificate,
135
+ caCertificatePath,
136
+ leaf(hostname) {
137
+ let leaf = leaves.get(hostname);
138
+ if (!leaf) {
139
+ leaf = makeLeaf(hostname);
140
+ leaves.set(hostname, leaf);
141
+ }
142
+ return leaf;
143
+ }
144
+ };
145
+ }
@@ -0,0 +1,37 @@
1
+ import { type Server } from 'node:http';
2
+ import { type RequestOptions as HttpsRequestOptions } from 'node:https';
3
+ import { type Socket } from 'node:net';
4
+ import type { GitHubProxyCertificates } from './github-proxy-certificates';
5
+ import type { LeasedGitHubCredential } from './types';
6
+ export interface GitHubProxySession {
7
+ sessionId: string;
8
+ sentinel: string;
9
+ credential: LeasedGitHubCredential;
10
+ }
11
+ export interface GitHubProxyOptions {
12
+ certificates: GitHubProxyCertificates;
13
+ findSession(sentinel: string): GitHubProxySession | undefined;
14
+ isDirectHostAllowed?(hostname: string): boolean;
15
+ logger?: Pick<Console, 'warn'>;
16
+ connectDirect?: (hostname: string, port: number) => Socket;
17
+ resolveUpstream?: (hostname: string) => Partial<HttpsRequestOptions>;
18
+ }
19
+ export interface ReceivePackCommand {
20
+ oldOid: string;
21
+ newOid: string;
22
+ ref: string;
23
+ }
24
+ export declare function parseReceivePackCommands(body: Buffer): ReceivePackCommand[];
25
+ export declare function receivePackRefusal(body: Buffer, sessionId: string): string | null;
26
+ export declare class GitHubTlsProxy {
27
+ private readonly listener;
28
+ private readonly options;
29
+ private readonly tunneled;
30
+ constructor(listener: Server, options: GitHubProxyOptions);
31
+ private refused;
32
+ private logRefusal;
33
+ private connect;
34
+ private connectDirect;
35
+ private handle;
36
+ private forward;
37
+ }