@peakflo/agent-harness 0.2.18 → 0.2.19-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.
- package/dist/adapters/claude-code-adapter.d.ts +3 -1
- package/dist/adapters/claude-code-adapter.js +59 -0
- package/dist/adapters/coding-agent-adapter.d.ts +1 -0
- package/dist/adapters/coding-agent-adapter.js +1 -0
- package/dist/github-command-guards.d.ts +4 -9
- package/dist/github-command-guards.js +5 -210
- package/dist/github-proxy.d.ts +2 -8
- package/dist/github-proxy.js +21 -266
- package/dist/github-workspace.d.ts +1 -0
- package/dist/github-workspace.js +11 -7
- package/dist/harness.js +2 -1
- package/package.json +1 -1
|
@@ -23,11 +23,13 @@ export declare enum ClaudeSystemSubtype {
|
|
|
23
23
|
* status source and as a transcript-suppression case. */
|
|
24
24
|
BACKGROUND_TASKS_CHANGED = "background_tasks_changed",
|
|
25
25
|
STATUS = "status",
|
|
26
|
-
THINKING_TOKENS = "thinking_tokens"
|
|
26
|
+
THINKING_TOKENS = "thinking_tokens",
|
|
27
|
+
API_RETRY = "api_retry"
|
|
27
28
|
}
|
|
28
29
|
export declare class ClaudeCodeAdapter implements CodingAgentAdapter {
|
|
29
30
|
private readonly executableOverride?;
|
|
30
31
|
private sessions;
|
|
32
|
+
private apiRetryBursts;
|
|
31
33
|
private sdkLoading;
|
|
32
34
|
private claudeExecutablePath;
|
|
33
35
|
private verifiedClaudeExecutablePath;
|
|
@@ -54,6 +54,7 @@ exports.verifyClaudeIsolatedConfigVersion = verifyClaudeIsolatedConfigVersion;
|
|
|
54
54
|
* `./optional-agent-sdks.d.ts` so `tsc` succeeds when it is not installed.
|
|
55
55
|
* Source formatting (no semicolons) preserved.
|
|
56
56
|
*/
|
|
57
|
+
const node_crypto_1 = require("node:crypto");
|
|
57
58
|
const node_os_1 = require("node:os");
|
|
58
59
|
const node_path_1 = require("node:path");
|
|
59
60
|
const coding_agent_adapter_1 = require("./coding-agent-adapter");
|
|
@@ -103,7 +104,12 @@ var ClaudeSystemSubtype;
|
|
|
103
104
|
ClaudeSystemSubtype["BACKGROUND_TASKS_CHANGED"] = "background_tasks_changed";
|
|
104
105
|
ClaudeSystemSubtype["STATUS"] = "status";
|
|
105
106
|
ClaudeSystemSubtype["THINKING_TOKENS"] = "thinking_tokens";
|
|
107
|
+
ClaudeSystemSubtype["API_RETRY"] = "api_retry";
|
|
106
108
|
})(ClaudeSystemSubtype || (exports.ClaudeSystemSubtype = ClaudeSystemSubtype = {}));
|
|
109
|
+
function retryBurstLabel(burst, ended = false) {
|
|
110
|
+
const state = ended ? 'retry attempts ended' : 'retrying';
|
|
111
|
+
return `Claude API ${burst.cause} — ${state} (${burst.attempt}${ended ? '' : burst.delay})`;
|
|
112
|
+
}
|
|
107
113
|
/**
|
|
108
114
|
* Terminal states for a Claude Code background task. Once a task reports one of
|
|
109
115
|
* these (via `task_updated.patch.status` or `task_notification.status`) it is no
|
|
@@ -121,6 +127,9 @@ const MAX_BACKGROUND_TASK_AGE_MS = 60 * 60 * 1000; // 60 minutes
|
|
|
121
127
|
class ClaudeCodeAdapter {
|
|
122
128
|
executableOverride;
|
|
123
129
|
sessions = new Map();
|
|
130
|
+
// Each polling/replay context owns its retry burst. Weak keys let closed
|
|
131
|
+
// readers be collected without keeping state in the content-length map.
|
|
132
|
+
apiRetryBursts = new WeakMap();
|
|
124
133
|
sdkLoading = null;
|
|
125
134
|
claudeExecutablePath = null;
|
|
126
135
|
verifiedClaudeExecutablePath = null;
|
|
@@ -1484,6 +1493,24 @@ class ClaudeCodeAdapter {
|
|
|
1484
1493
|
if (msgWithProps.parent_tool_use_id) {
|
|
1485
1494
|
return parts;
|
|
1486
1495
|
}
|
|
1496
|
+
// A retry burst is one durable status row. Keep the first event's UUID so
|
|
1497
|
+
// later turns cannot overwrite an earlier turn's retry history.
|
|
1498
|
+
if (msgWithProps.type === 'assistant' || msgWithProps.type === 'assistant_message' ||
|
|
1499
|
+
msgWithProps.type === 'result' || msgWithProps.type === 'user') {
|
|
1500
|
+
const burst = this.apiRetryBursts.get(partContentLengths);
|
|
1501
|
+
if (burst) {
|
|
1502
|
+
const content = retryBurstLabel(burst, true);
|
|
1503
|
+
partContentLengths.set(burst.id, String(content.length));
|
|
1504
|
+
parts.push({
|
|
1505
|
+
id: burst.id,
|
|
1506
|
+
type: coding_agent_adapter_1.MessagePartType.RETRY,
|
|
1507
|
+
role: 'system',
|
|
1508
|
+
content,
|
|
1509
|
+
update: true,
|
|
1510
|
+
});
|
|
1511
|
+
}
|
|
1512
|
+
this.apiRetryBursts.delete(partContentLengths);
|
|
1513
|
+
}
|
|
1487
1514
|
// Handle assistant_message type
|
|
1488
1515
|
if (msgWithProps.type === 'assistant' || msgWithProps.type === 'assistant_message') {
|
|
1489
1516
|
// Content is nested inside message.content for Claude Code SDK format
|
|
@@ -1838,6 +1865,38 @@ class ClaudeCodeAdapter {
|
|
|
1838
1865
|
}
|
|
1839
1866
|
}
|
|
1840
1867
|
else if (msgWithProps.type === 'system') {
|
|
1868
|
+
if (msgWithProps.subtype === ClaudeSystemSubtype.API_RETRY) {
|
|
1869
|
+
const previous = this.apiRetryBursts.get(partContentLengths);
|
|
1870
|
+
const partId = previous?.id || `claude-api-retry-${msgWithProps.uuid || (0, node_crypto_1.randomUUID)()}`;
|
|
1871
|
+
const positive = (value) => typeof value === 'number' && Number.isFinite(value) && value > 0;
|
|
1872
|
+
const attempt = positive(msgWithProps.attempt) && positive(msgWithProps.max_retries)
|
|
1873
|
+
? `attempt ${Math.floor(msgWithProps.attempt)}/${Math.floor(msgWithProps.max_retries)}`
|
|
1874
|
+
: 'next attempt';
|
|
1875
|
+
const delay = typeof msgWithProps.retry_delay_ms === 'number' &&
|
|
1876
|
+
Number.isFinite(msgWithProps.retry_delay_ms) && msgWithProps.retry_delay_ms >= 0
|
|
1877
|
+
? `, in ${Math.ceil(msgWithProps.retry_delay_ms / 1000)} s`
|
|
1878
|
+
: '';
|
|
1879
|
+
const status = msgWithProps.error_status;
|
|
1880
|
+
const cause = status === 429 ? 'rate limit reached'
|
|
1881
|
+
: status === 529 ? 'busy'
|
|
1882
|
+
: status === null ? 'connection failed'
|
|
1883
|
+
: typeof status === 'number' && status >= 500 ? `server error ${status}`
|
|
1884
|
+
: 'request failed';
|
|
1885
|
+
const burst = { id: partId, cause, attempt, delay };
|
|
1886
|
+
const content = retryBurstLabel(burst);
|
|
1887
|
+
// Never forward the raw error, which can contain request credentials.
|
|
1888
|
+
this.apiRetryBursts.set(partContentLengths, burst);
|
|
1889
|
+
partContentLengths.set(partId, String(content.length));
|
|
1890
|
+
seenPartIds.add(partId);
|
|
1891
|
+
parts.push({
|
|
1892
|
+
id: partId,
|
|
1893
|
+
type: coding_agent_adapter_1.MessagePartType.RETRY,
|
|
1894
|
+
role: 'system',
|
|
1895
|
+
content,
|
|
1896
|
+
update: previous !== undefined,
|
|
1897
|
+
});
|
|
1898
|
+
return parts;
|
|
1899
|
+
}
|
|
1841
1900
|
// Skip system init messages - they're internal session setup
|
|
1842
1901
|
if (msgWithProps.subtype === ClaudeSystemSubtype.INIT) {
|
|
1843
1902
|
return parts;
|
|
@@ -22,6 +22,7 @@ var MessageRole;
|
|
|
22
22
|
var MessagePartType;
|
|
23
23
|
(function (MessagePartType) {
|
|
24
24
|
MessagePartType["TEXT"] = "text";
|
|
25
|
+
MessagePartType["RETRY"] = "retry";
|
|
25
26
|
MessagePartType["REASONING"] = "reasoning";
|
|
26
27
|
MessagePartType["TOOL"] = "tool";
|
|
27
28
|
MessagePartType["QUESTION"] = "question";
|
|
@@ -1,20 +1,15 @@
|
|
|
1
|
-
export declare const MERGE_REFUSAL = "GitHub merge refused: agents may open pull requests but must not merge them.";
|
|
2
1
|
interface GuardPathLease {
|
|
3
2
|
bin: string;
|
|
4
3
|
release(): void;
|
|
5
4
|
}
|
|
6
5
|
/**
|
|
7
|
-
* Install
|
|
8
|
-
*
|
|
9
|
-
* applies the same policy even when an agent calls the real binary directly.
|
|
6
|
+
* Install credential-isolating launchers before backend processes start.
|
|
7
|
+
* Commands and arguments pass through. GitHub enforces operation policy.
|
|
10
8
|
*/
|
|
11
9
|
export declare function installGhGuard(root: string): Promise<string>;
|
|
12
10
|
/**
|
|
13
|
-
* Lease one session-neutral guard directory for the whole process.
|
|
14
|
-
* launchers
|
|
15
|
-
* safe; stacking per-Harness directories in global PATH is not.
|
|
11
|
+
* Lease one session-neutral guard directory for the whole process. The
|
|
12
|
+
* launchers contain no session policy, so sharing the launcher is safe; stacking per-Harness directories in global PATH is not.
|
|
16
13
|
*/
|
|
17
14
|
export declare function acquireGhGuardPath(root: string): Promise<GuardPathLease>;
|
|
18
|
-
/** Git supplies resolved destination refs, covering force, wildcard and implicit pushes. */
|
|
19
|
-
export declare function installPushGuard(directory: string, sessionId: string, defaultBranch: string, remoteUrl: string): Promise<void>;
|
|
20
15
|
export {};
|
|
@@ -1,20 +1,16 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
-
exports.MERGE_REFUSAL = void 0;
|
|
4
3
|
exports.installGhGuard = installGhGuard;
|
|
5
4
|
exports.acquireGhGuardPath = acquireGhGuardPath;
|
|
6
|
-
exports.installPushGuard = installPushGuard;
|
|
7
5
|
const node_fs_1 = require("node:fs");
|
|
8
6
|
const promises_1 = require("node:fs/promises");
|
|
9
7
|
const node_path_1 = require("node:path");
|
|
10
8
|
const vendor_environment_1 = require("./adapters/vendor-environment");
|
|
11
|
-
exports.MERGE_REFUSAL = 'GitHub merge refused: agents may open pull requests but must not merge them.';
|
|
12
9
|
let guardPathState;
|
|
13
10
|
let guardPathQueue = Promise.resolve();
|
|
14
11
|
/**
|
|
15
|
-
* Install
|
|
16
|
-
*
|
|
17
|
-
* applies the same policy even when an agent calls the real binary directly.
|
|
12
|
+
* Install credential-isolating launchers before backend processes start.
|
|
13
|
+
* Commands and arguments pass through. GitHub enforces operation policy.
|
|
18
14
|
*/
|
|
19
15
|
async function installGhGuard(root) {
|
|
20
16
|
const bin = (0, node_path_1.join)(root, '.github-bin');
|
|
@@ -49,8 +45,6 @@ async function installGhGuard(root) {
|
|
|
49
45
|
}
|
|
50
46
|
await (0, promises_1.writeFile)((0, node_path_1.join)(bin, 'git'), `#!${process.execPath}
|
|
51
47
|
const { spawnSync } = require('node:child_process');
|
|
52
|
-
const { readFileSync } = require('node:fs');
|
|
53
|
-
const { resolve, join } = require('node:path');
|
|
54
48
|
const executable = ${JSON.stringify(gitExecutable)};
|
|
55
49
|
if (!executable) { console.error('git is not installed'); process.exit(127); }
|
|
56
50
|
const args = process.argv.slice(2);
|
|
@@ -70,85 +64,11 @@ gitEnv.GIT_SSL_CAINFO = ${JSON.stringify((0, node_path_1.join)(root, '.github-pr
|
|
|
70
64
|
for (const key of ['GIT_AUTHOR_NAME', 'GIT_AUTHOR_EMAIL', 'GIT_AUTHOR_DATE', 'GIT_COMMITTER_NAME', 'GIT_COMMITTER_EMAIL', 'GIT_COMMITTER_DATE']) {
|
|
71
65
|
if (process.env[key]) gitEnv[key] = process.env[key];
|
|
72
66
|
}
|
|
73
|
-
let command = 0;
|
|
74
|
-
let cwd = process.cwd();
|
|
75
|
-
let refusedGlobalOption = null;
|
|
76
|
-
const valueOptions = new Set(['-C', '-c', '--git-dir', '--work-tree', '--namespace', '--super-prefix', '--config-env', '--list-cmds', '--attr-source']);
|
|
77
|
-
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']);
|
|
78
|
-
const redirectingOptions = new Set(['--git-dir', '--work-tree', '--namespace', '--super-prefix', '--config-env', '--bare', '--exec-path']);
|
|
79
|
-
while (command < args.length) {
|
|
80
|
-
const arg = args[command];
|
|
81
|
-
if (valueOptions.has(arg) && args[command + 1]) {
|
|
82
|
-
if (arg === '-C') cwd = resolve(cwd, args[command + 1]);
|
|
83
|
-
if (redirectingOptions.has(arg)) refusedGlobalOption = arg;
|
|
84
|
-
if (arg === '-c') refusedGlobalOption = arg + ' ' + args[command + 1].split('=', 1)[0];
|
|
85
|
-
command += 2;
|
|
86
|
-
continue;
|
|
87
|
-
}
|
|
88
|
-
if (/^-c.+/.test(arg)) {
|
|
89
|
-
refusedGlobalOption = '-c ' + arg.slice(2).split('=', 1)[0];
|
|
90
|
-
command += 1;
|
|
91
|
-
continue;
|
|
92
|
-
}
|
|
93
|
-
if (/^-C.+/.test(arg)) {
|
|
94
|
-
cwd = resolve(cwd, arg.slice(2));
|
|
95
|
-
command += 1;
|
|
96
|
-
continue;
|
|
97
|
-
}
|
|
98
|
-
const assignedOption = arg.match(/^(--(?:git-dir|work-tree|namespace|super-prefix|config-env|exec-path|list-cmds|attr-source))=/)?.[1];
|
|
99
|
-
if (flagOptions.has(arg) || assignedOption) {
|
|
100
|
-
if (redirectingOptions.has(arg) || (assignedOption && redirectingOptions.has(assignedOption))) refusedGlobalOption = assignedOption ?? arg;
|
|
101
|
-
command += 1;
|
|
102
|
-
continue;
|
|
103
|
-
}
|
|
104
|
-
break;
|
|
105
|
-
}
|
|
106
|
-
if (refusedGlobalOption && args[command] !== 'push') {
|
|
107
|
-
console.error('Git command refused: global option ' + refusedGlobalOption + ' can redirect repository policy or command execution.');
|
|
108
|
-
process.exit(1);
|
|
109
|
-
}
|
|
110
|
-
if (args[command] === 'config') {
|
|
111
|
-
const configArgs = args.slice(command + 1);
|
|
112
|
-
const readActions = new Set(['--get', '--get-all', '--get-regexp', '--get-urlmatch', '--list', '-l', '--get-color', '--get-colorbool']);
|
|
113
|
-
const writeActions = new Set(['--add', '--replace-all', '--unset', '--unset-all', '--rename-section', '--remove-section', '-e', '--edit']);
|
|
114
|
-
const positional = configArgs.filter(arg => !arg.startsWith('-'));
|
|
115
|
-
const mutates = configArgs.some(arg => writeActions.has(arg)) || (!configArgs.some(arg => readActions.has(arg)) && positional.length > 1);
|
|
116
|
-
if (mutates) {
|
|
117
|
-
console.error('Git config refused: agents may read Git configuration but may not change it.');
|
|
118
|
-
process.exit(1);
|
|
119
|
-
}
|
|
120
|
-
}
|
|
121
|
-
if (args[command] === 'push') {
|
|
122
|
-
const refuse = () => { console.error('Git push refused: use an explicit agent/<session-id> destination; default and other branches are forbidden.'); process.exit(1); };
|
|
123
|
-
if (refusedGlobalOption) {
|
|
124
|
-
console.error('Git push refused: global option ' + refusedGlobalOption + ' can redirect the repository, hooks, refs, or push URL.');
|
|
125
|
-
process.exit(1);
|
|
126
|
-
}
|
|
127
|
-
let policy;
|
|
128
|
-
const hooks = spawnSync(executable, ['-C', cwd, 'config', '--get', 'core.hooksPath'], { encoding: 'utf8', env: gitEnv });
|
|
129
|
-
try { policy = JSON.parse(readFileSync(join(hooks.stdout.trim(), 'push-policy.json'), 'utf8')); } catch { refuse(); }
|
|
130
|
-
const positional = [];
|
|
131
|
-
for (const arg of args.slice(command + 1)) {
|
|
132
|
-
if (['-u', '--set-upstream', '-f', '--force', '--force-with-lease', '--dry-run', '-n', '--porcelain', '-v', '--verbose'].includes(arg)) continue;
|
|
133
|
-
if (arg.startsWith('-')) refuse();
|
|
134
|
-
positional.push(arg);
|
|
135
|
-
}
|
|
136
|
-
if (positional.length < 2) refuse();
|
|
137
|
-
if (positional[0] !== 'origin') refuse();
|
|
138
|
-
const remote = spawnSync(executable, ['-C', cwd, 'remote', 'get-url', '--push', positional[0]], { encoding: 'utf8', env: gitEnv });
|
|
139
|
-
if (remote.status !== 0 || remote.stdout.trim() !== policy.remoteUrl) refuse();
|
|
140
|
-
for (const ref of positional.slice(1)) {
|
|
141
|
-
const destination = ref.replace(/^\\+/, '').split(':').pop().replace(/^refs\\/heads\\//, '');
|
|
142
|
-
if (destination === policy.defaultBranch || destination !== policy.branch) refuse();
|
|
143
|
-
}
|
|
144
|
-
}
|
|
145
67
|
const result = spawnSync(executable, args, { stdio: 'inherit', env: gitEnv });
|
|
146
68
|
process.exit(result.status ?? 1);
|
|
147
69
|
`, { mode: 0o700 });
|
|
148
70
|
await (0, promises_1.writeFile)((0, node_path_1.join)(bin, 'gh'), `#!${process.execPath}
|
|
149
71
|
const { spawnSync } = require('node:child_process');
|
|
150
|
-
const { readFileSync } = require('node:fs');
|
|
151
|
-
const { resolve } = require('node:path');
|
|
152
72
|
const args = process.argv.slice(2);
|
|
153
73
|
const cleanEnv = { ...process.env };
|
|
154
74
|
for (const key of Object.keys(cleanEnv)) {
|
|
@@ -157,119 +77,16 @@ for (const key of Object.keys(cleanEnv)) {
|
|
|
157
77
|
}
|
|
158
78
|
cleanEnv.GIT_TERMINAL_PROMPT = '0';
|
|
159
79
|
cleanEnv.GIT_SSH_COMMAND = ${JSON.stringify(vendor_environment_1.DISABLED_GIT_SSH_COMMAND)};
|
|
160
|
-
// Inspect all args, including endpoint=... and flags before the subcommand.
|
|
161
|
-
const decodedArgs = args.map(arg => {
|
|
162
|
-
try { return decodeURIComponent(arg); } catch { return arg; }
|
|
163
|
-
});
|
|
164
|
-
const ghGlobalValueOptions = new Set(['-R', '--repo', '--hostname']);
|
|
165
|
-
const ghGlobalFlagOptions = new Set(['--help', '--version', '--prompt', '--no-pager']);
|
|
166
|
-
let command = 0;
|
|
167
|
-
while (command < decodedArgs.length) {
|
|
168
|
-
const arg = decodedArgs[command];
|
|
169
|
-
if (ghGlobalValueOptions.has(arg) && decodedArgs[command + 1]) {
|
|
170
|
-
command += 2;
|
|
171
|
-
continue;
|
|
172
|
-
}
|
|
173
|
-
if (/^-R.+/.test(arg) || /^--(?:repo|hostname)=/.test(arg) || ghGlobalFlagOptions.has(arg)) {
|
|
174
|
-
command += 1;
|
|
175
|
-
continue;
|
|
176
|
-
}
|
|
177
|
-
break;
|
|
178
|
-
}
|
|
179
|
-
const commandName = decodedArgs[command];
|
|
180
|
-
const api = commandName === 'api' ? command : -1;
|
|
181
|
-
const apiValueOptions = new Set(['-X', '--method', '-H', '--header', '-F', '--field', '-f', '--raw-field', '--input', '--hostname', '--cache']);
|
|
182
|
-
let endpoint = null;
|
|
183
|
-
let inputBuffer = null;
|
|
184
|
-
const body = [...decodedArgs];
|
|
185
|
-
if (api >= 0) {
|
|
186
|
-
for (let index = api + 1; index < decodedArgs.length; index += 1) {
|
|
187
|
-
const arg = decodedArgs[index];
|
|
188
|
-
if (apiValueOptions.has(arg)) {
|
|
189
|
-
const value = decodedArgs[index + 1];
|
|
190
|
-
if (arg === '--input' && value) {
|
|
191
|
-
try {
|
|
192
|
-
inputBuffer = value === '-' ? readFileSync(0) : readFileSync(resolve(value));
|
|
193
|
-
body.push(inputBuffer.toString('utf8'));
|
|
194
|
-
} catch { /* Let gh report an unreadable input after policy checks. */ }
|
|
195
|
-
}
|
|
196
|
-
index += 1;
|
|
197
|
-
continue;
|
|
198
|
-
}
|
|
199
|
-
const assigned = arg.match(/^(?:-X|--method|-H|--header|-F|--field|-f|--raw-field|--input|--hostname|--cache)=(.*)$/)?.[1];
|
|
200
|
-
if (assigned !== undefined) {
|
|
201
|
-
if (/^--input=/.test(arg)) {
|
|
202
|
-
try {
|
|
203
|
-
inputBuffer = assigned === '-' ? readFileSync(0) : readFileSync(resolve(assigned));
|
|
204
|
-
body.push(inputBuffer.toString('utf8'));
|
|
205
|
-
} catch { /* Let gh report an unreadable input after policy checks. */ }
|
|
206
|
-
}
|
|
207
|
-
continue;
|
|
208
|
-
}
|
|
209
|
-
if (arg.startsWith('-')) continue;
|
|
210
|
-
if (endpoint === null) endpoint = arg;
|
|
211
|
-
}
|
|
212
|
-
}
|
|
213
|
-
const normaliseEndpoint = value => {
|
|
214
|
-
if (!value) return '';
|
|
215
|
-
try {
|
|
216
|
-
const parsed = new URL(value, 'https://api.github.com');
|
|
217
|
-
return parsed.pathname.replace(/\\/+$/, '') || '/';
|
|
218
|
-
} catch {
|
|
219
|
-
return value.split(/[?#]/, 1)[0].replace(/\\/+$/, '');
|
|
220
|
-
}
|
|
221
|
-
};
|
|
222
|
-
const mergeOperation = api >= 0 && body.some(value => /\\b(?:mergePullRequest|enablePullRequestAutoMerge)\\b/i.test(value));
|
|
223
|
-
const mergePath = api >= 0 && /^\\/repos\\/[^/]+\\/[^/]+\\/(?:pulls\\/[^/]+\\/merge|merges)$/i.test(normaliseEndpoint(endpoint));
|
|
224
|
-
const containsPrMerge = value => /(?:^|\\s)pr(?:\\s+--?\\S+(?:\\s+\\S+)?)*\\s+merge(?:\\s|$)/i.test(value);
|
|
225
|
-
let prCommand = command + 1;
|
|
226
|
-
if (commandName === 'pr') {
|
|
227
|
-
while (prCommand < decodedArgs.length) {
|
|
228
|
-
const arg = decodedArgs[prCommand];
|
|
229
|
-
if (ghGlobalValueOptions.has(arg) && decodedArgs[prCommand + 1]) {
|
|
230
|
-
prCommand += 2;
|
|
231
|
-
continue;
|
|
232
|
-
}
|
|
233
|
-
if (/^-R.+/.test(arg) || /^--(?:repo|hostname)=/.test(arg) || ghGlobalFlagOptions.has(arg)) {
|
|
234
|
-
prCommand += 1;
|
|
235
|
-
continue;
|
|
236
|
-
}
|
|
237
|
-
break;
|
|
238
|
-
}
|
|
239
|
-
}
|
|
240
|
-
const mergeCommand = commandName === 'pr' && decodedArgs[prCommand] === 'merge';
|
|
241
|
-
const commandTail = decodedArgs.slice(command + 1);
|
|
242
|
-
const administrativeCommand =
|
|
243
|
-
(commandName === 'repo' && commandTail.includes('delete')) ||
|
|
244
|
-
(commandName === 'secret' && commandTail.includes('set')) ||
|
|
245
|
-
(commandName === 'workflow' && commandTail.includes('run'));
|
|
246
|
-
const dangerousAliasText = value => /\\b(?:mergePullRequest|enablePullRequestAutoMerge)\\b|\\/merges?(?:[\\s/?#]|$)/i.test(value) || containsPrMerge(value);
|
|
247
80
|
const executable = ${JSON.stringify(executable)};
|
|
248
|
-
let dangerousAlias = commandName === 'alias' && decodedArgs.slice(command).some(dangerousAliasText);
|
|
249
|
-
if (!dangerousAlias && executable && commandName) {
|
|
250
|
-
const alias = spawnSync(executable, ['alias', 'get', commandName], { encoding: 'utf8', env: cleanEnv });
|
|
251
|
-
dangerousAlias = alias.status === 0 && dangerousAliasText(alias.stdout);
|
|
252
|
-
}
|
|
253
|
-
if (mergeOperation || mergePath || mergeCommand || dangerousAlias) {
|
|
254
|
-
console.error(${JSON.stringify(exports.MERGE_REFUSAL)});
|
|
255
|
-
process.exit(1);
|
|
256
|
-
}
|
|
257
|
-
if (administrativeCommand) {
|
|
258
|
-
console.error('GitHub request refused: this administrative operation is not permitted.');
|
|
259
|
-
process.exit(1);
|
|
260
|
-
}
|
|
261
81
|
if (!executable) { console.error('gh is not installed; use the workspace GitHub API link to open a PR.'); process.exit(127); }
|
|
262
|
-
const result =
|
|
263
|
-
? spawnSync(executable, args, { input: inputBuffer, stdio: ['pipe', 'inherit', 'inherit'], env: cleanEnv })
|
|
264
|
-
: spawnSync(executable, args, { stdio: 'inherit', env: cleanEnv });
|
|
82
|
+
const result = spawnSync(executable, args, { stdio: 'inherit', env: cleanEnv });
|
|
265
83
|
process.exit(result.status ?? 1);
|
|
266
84
|
`, { mode: 0o700 });
|
|
267
85
|
return bin;
|
|
268
86
|
}
|
|
269
87
|
/**
|
|
270
|
-
* Lease one session-neutral guard directory for the whole process.
|
|
271
|
-
* launchers
|
|
272
|
-
* safe; stacking per-Harness directories in global PATH is not.
|
|
88
|
+
* Lease one session-neutral guard directory for the whole process. The
|
|
89
|
+
* launchers contain no session policy, so sharing the launcher is safe; stacking per-Harness directories in global PATH is not.
|
|
273
90
|
*/
|
|
274
91
|
async function acquireGhGuardPath(root) {
|
|
275
92
|
let unlock;
|
|
@@ -311,25 +128,3 @@ async function acquireGhGuardPath(root) {
|
|
|
311
128
|
unlock();
|
|
312
129
|
}
|
|
313
130
|
}
|
|
314
|
-
/** Git supplies resolved destination refs, covering force, wildcard and implicit pushes. */
|
|
315
|
-
async function installPushGuard(directory, sessionId, defaultBranch, remoteUrl) {
|
|
316
|
-
await (0, promises_1.mkdir)(directory, { recursive: true, mode: 0o700 });
|
|
317
|
-
await (0, promises_1.writeFile)((0, node_path_1.join)(directory, 'push-policy.json'), JSON.stringify({ branch: `agent/${sessionId}`, defaultBranch, remoteUrl }), { mode: 0o600 });
|
|
318
|
-
await (0, promises_1.writeFile)((0, node_path_1.join)(directory, 'pre-push'), `#!${process.execPath}
|
|
319
|
-
const { readFileSync } = require('node:fs');
|
|
320
|
-
const allowed = ${JSON.stringify(`refs/heads/agent/${sessionId}`)};
|
|
321
|
-
const defaultRef = ${JSON.stringify(`refs/heads/${defaultBranch}`)};
|
|
322
|
-
const allowedRemote = ${JSON.stringify(remoteUrl)};
|
|
323
|
-
if (process.argv[2] !== 'origin' || process.argv[3] !== allowedRemote) {
|
|
324
|
-
console.error('Git push refused: remote ' + process.argv[2] + ' is not this worktree origin.');
|
|
325
|
-
process.exit(1);
|
|
326
|
-
}
|
|
327
|
-
for (const line of readFileSync(0, 'utf8').trim().split('\\n').filter(Boolean)) {
|
|
328
|
-
const remoteRef = line.split(/\\s+/)[2];
|
|
329
|
-
if (remoteRef === defaultRef || remoteRef !== allowed) {
|
|
330
|
-
console.error('Git push refused: destination ' + remoteRef + '; this session may push only ' + allowed + '.');
|
|
331
|
-
process.exit(1);
|
|
332
|
-
}
|
|
333
|
-
}
|
|
334
|
-
`, { mode: 0o700 });
|
|
335
|
-
}
|
package/dist/github-proxy.d.ts
CHANGED
|
@@ -13,6 +13,8 @@ export interface GitHubProxyOptions {
|
|
|
13
13
|
certificates: GitHubProxyCertificates;
|
|
14
14
|
findSession(sentinel: string): GitHubProxySession | undefined;
|
|
15
15
|
isDirectHostAllowed?(hostname: string): boolean;
|
|
16
|
+
/** Local runners use normal HTTPS egress for non-GitHub hosts. */
|
|
17
|
+
allowDirectHttps?: boolean;
|
|
16
18
|
logger?: Pick<Console, 'warn'>;
|
|
17
19
|
connectDirect?: (hostname: string, port: number) => Socket;
|
|
18
20
|
resolveUpstream?: (hostname: string) => Partial<HttpsRequestOptions>;
|
|
@@ -23,13 +25,6 @@ export interface GitHubProxyOptions {
|
|
|
23
25
|
*/
|
|
24
26
|
peerUid?: PeerUidResolver;
|
|
25
27
|
}
|
|
26
|
-
export interface ReceivePackCommand {
|
|
27
|
-
oldOid: string;
|
|
28
|
-
newOid: string;
|
|
29
|
-
ref: string;
|
|
30
|
-
}
|
|
31
|
-
export declare function parseReceivePackCommands(body: Buffer): ReceivePackCommand[];
|
|
32
|
-
export declare function receivePackRefusal(body: Buffer, sessionId: string): string | null;
|
|
33
28
|
export declare class GitHubTlsProxy {
|
|
34
29
|
private readonly listener;
|
|
35
30
|
private readonly options;
|
|
@@ -47,7 +42,6 @@ export declare class GitHubTlsProxy {
|
|
|
47
42
|
* true; otherwise returns false and the caller answers as before.
|
|
48
43
|
*/
|
|
49
44
|
refuseBrowserPeer(request: IncomingMessage, response: ServerResponse): boolean;
|
|
50
|
-
private refused;
|
|
51
45
|
private logRefusal;
|
|
52
46
|
private connect;
|
|
53
47
|
private connectDirect;
|
package/dist/github-proxy.js
CHANGED
|
@@ -1,8 +1,6 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
3
|
exports.GitHubTlsProxy = void 0;
|
|
4
|
-
exports.parseReceivePackCommands = parseReceivePackCommands;
|
|
5
|
-
exports.receivePackRefusal = receivePackRefusal;
|
|
6
4
|
const node_crypto_1 = require("node:crypto");
|
|
7
5
|
const node_http_1 = require("node:http");
|
|
8
6
|
const node_https_1 = require("node:https");
|
|
@@ -16,88 +14,11 @@ const PASS_THROUGH_HOSTS = new Set([
|
|
|
16
14
|
'codeload.github.com'
|
|
17
15
|
]);
|
|
18
16
|
const ALLOWED_HOSTS = new Set([...INTERCEPTED_HOSTS, ...PASS_THROUGH_HOSTS]);
|
|
19
|
-
const GRAPHQL_REFUSED_MUTATIONS = [
|
|
20
|
-
'mergePullRequest',
|
|
21
|
-
'enablePullRequestAutoMerge',
|
|
22
|
-
'deleteRepository',
|
|
23
|
-
'updateBranchProtectionRule',
|
|
24
|
-
'createBranchProtectionRule'
|
|
25
|
-
];
|
|
26
|
-
const ZERO_OID = /^0+$/;
|
|
27
|
-
const MAX_INSPECTED_BODY_BYTES = 16 * 1024 * 1024;
|
|
28
|
-
const MAX_RECEIVE_PACK_COMMAND_BYTES = 64 * 1024;
|
|
29
|
-
function receivePackCommandLength(body) {
|
|
30
|
-
let offset = 0;
|
|
31
|
-
while (offset + 4 <= body.length) {
|
|
32
|
-
const lengthText = body.subarray(offset, offset + 4).toString('ascii');
|
|
33
|
-
if (!/^[0-9a-f]{4}$/i.test(lengthText)) {
|
|
34
|
-
throw new Error('Invalid git receive-pack pkt-line length.');
|
|
35
|
-
}
|
|
36
|
-
const length = Number.parseInt(lengthText, 16);
|
|
37
|
-
if (length === 0)
|
|
38
|
-
return offset + 4;
|
|
39
|
-
if (length < 4)
|
|
40
|
-
throw new Error('Invalid git receive-pack pkt-line length.');
|
|
41
|
-
if (offset + length > body.length)
|
|
42
|
-
return null;
|
|
43
|
-
offset += length;
|
|
44
|
-
}
|
|
45
|
-
return null;
|
|
46
|
-
}
|
|
47
17
|
function safeEquals(left, right) {
|
|
48
18
|
const a = Buffer.from(left);
|
|
49
19
|
const b = Buffer.from(right);
|
|
50
20
|
return a.length === b.length && (0, node_crypto_1.timingSafeEqual)(a, b);
|
|
51
21
|
}
|
|
52
|
-
function parseReceivePackCommands(body) {
|
|
53
|
-
const commands = [];
|
|
54
|
-
let offset = 0;
|
|
55
|
-
while (offset + 4 <= body.length) {
|
|
56
|
-
const lengthText = body.subarray(offset, offset + 4).toString('ascii');
|
|
57
|
-
if (!/^[0-9a-f]{4}$/i.test(lengthText)) {
|
|
58
|
-
throw new Error('Invalid git receive-pack pkt-line length.');
|
|
59
|
-
}
|
|
60
|
-
const length = Number.parseInt(lengthText, 16);
|
|
61
|
-
if (length === 0)
|
|
62
|
-
return commands;
|
|
63
|
-
if (length < 4 || offset + length > body.length) {
|
|
64
|
-
throw new Error('Incomplete git receive-pack pkt-line.');
|
|
65
|
-
}
|
|
66
|
-
const payload = body
|
|
67
|
-
.subarray(offset + 4, offset + length)
|
|
68
|
-
.toString('utf8')
|
|
69
|
-
.replace(/\n$/, '');
|
|
70
|
-
const command = payload.split('\0', 1)[0];
|
|
71
|
-
const match = command.match(/^([0-9a-f]+) ([0-9a-f]+) ([^\s]+)$/i);
|
|
72
|
-
if (!match)
|
|
73
|
-
throw new Error('Invalid git receive-pack ref update.');
|
|
74
|
-
commands.push({ oldOid: match[1], newOid: match[2], ref: match[3] });
|
|
75
|
-
offset += length;
|
|
76
|
-
}
|
|
77
|
-
throw new Error('Incomplete git receive-pack command list.');
|
|
78
|
-
}
|
|
79
|
-
function receivePackRefusal(body, sessionId) {
|
|
80
|
-
let commands;
|
|
81
|
-
try {
|
|
82
|
-
commands = parseReceivePackCommands(body);
|
|
83
|
-
}
|
|
84
|
-
catch {
|
|
85
|
-
return 'Git push refused: the receive-pack command list is malformed.';
|
|
86
|
-
}
|
|
87
|
-
if (commands.length === 0) {
|
|
88
|
-
return 'Git push refused: the receive-pack command list is empty.';
|
|
89
|
-
}
|
|
90
|
-
const allowed = `refs/heads/agent/${sessionId}`;
|
|
91
|
-
for (const command of commands) {
|
|
92
|
-
if (ZERO_OID.test(command.newOid)) {
|
|
93
|
-
return `Git push refused: deleting ${command.ref} is not permitted.`;
|
|
94
|
-
}
|
|
95
|
-
if (command.ref !== allowed) {
|
|
96
|
-
return `Git push refused: this session may update only ${allowed}.`;
|
|
97
|
-
}
|
|
98
|
-
}
|
|
99
|
-
return null;
|
|
100
|
-
}
|
|
101
22
|
function decodedPath(request) {
|
|
102
23
|
try {
|
|
103
24
|
return decodeURIComponent(new URL(request.url ?? '/', 'https://x').pathname);
|
|
@@ -106,34 +27,6 @@ function decodedPath(request) {
|
|
|
106
27
|
return null;
|
|
107
28
|
}
|
|
108
29
|
}
|
|
109
|
-
function repositoryFromPath(hostname, path) {
|
|
110
|
-
const match = hostname === 'api.github.com'
|
|
111
|
-
? path.match(/^\/repos\/([^/]+)\/([^/]+)(?:\/|$)/)
|
|
112
|
-
: path.match(/^\/([^/]+)\/([^/]+?)\.git(?:\/|$)/);
|
|
113
|
-
return match ? `${match[1]}/${match[2]}` : null;
|
|
114
|
-
}
|
|
115
|
-
function restRefusal(method, path) {
|
|
116
|
-
if (/^\/repos\/[^/]+\/[^/]+\/pulls\/[^/]+\/merge\/?$/i.test(path))
|
|
117
|
-
return 'GitHub request refused: pull requests must be merged by a human.';
|
|
118
|
-
if (/^\/repos\/[^/]+\/[^/]+\/merges\/?$/i.test(path))
|
|
119
|
-
return 'GitHub request refused: merge commits are not permitted.';
|
|
120
|
-
if (method === 'DELETE' && /^\/repos\/[^/]+\/[^/]+\/?$/i.test(path))
|
|
121
|
-
return 'GitHub request refused: repository deletion is not permitted.';
|
|
122
|
-
const forbidden = [
|
|
123
|
-
/\/actions\/secrets(?:\/|$)/i,
|
|
124
|
-
/\/actions\/workflows\/[^/]+\/dispatches\/?$/i,
|
|
125
|
-
/\/rulesets(?:\/|$)/i,
|
|
126
|
-
/\/branches\/[^/]+\/protection(?:\/|$)/i,
|
|
127
|
-
/\/hooks(?:\/|$)/i,
|
|
128
|
-
/\/keys(?:\/|$)/i,
|
|
129
|
-
/\/collaborators(?:\/|$)/i,
|
|
130
|
-
/\/deployments(?:\/|$)/i,
|
|
131
|
-
/\/codespaces(?:\/|$)/i
|
|
132
|
-
];
|
|
133
|
-
return forbidden.some((pattern) => pattern.test(path))
|
|
134
|
-
? 'GitHub request refused: this administrative operation is not permitted.'
|
|
135
|
-
: null;
|
|
136
|
-
}
|
|
137
30
|
function bearerSentinel(header) {
|
|
138
31
|
const value = Array.isArray(header) ? header[0] : header;
|
|
139
32
|
return (value?.match(/^\s*(?:token|Bearer)\s+(pfgh_[A-Za-z0-9_-]+)\s*$/i)?.[1] ??
|
|
@@ -155,23 +48,6 @@ function basicSentinel(header) {
|
|
|
155
48
|
return null;
|
|
156
49
|
}
|
|
157
50
|
}
|
|
158
|
-
function graphQLPolicyText(body) {
|
|
159
|
-
const raw = body.toString('utf8');
|
|
160
|
-
const forms = [raw];
|
|
161
|
-
try {
|
|
162
|
-
forms.push(JSON.stringify(JSON.parse(raw)));
|
|
163
|
-
}
|
|
164
|
-
catch {
|
|
165
|
-
/* gh uses JSON; retain the raw form for other valid GitHub clients. */
|
|
166
|
-
}
|
|
167
|
-
try {
|
|
168
|
-
forms.push(decodeURIComponent(raw.replace(/\+/g, ' ')));
|
|
169
|
-
}
|
|
170
|
-
catch {
|
|
171
|
-
/* A malformed percent escape remains visible in the raw form. */
|
|
172
|
-
}
|
|
173
|
-
return forms.join('\n');
|
|
174
|
-
}
|
|
175
51
|
function forwardedHeaders(headers) {
|
|
176
52
|
const copy = { ...headers };
|
|
177
53
|
for (const name of [
|
|
@@ -187,74 +63,10 @@ function forwardedHeaders(headers) {
|
|
|
187
63
|
}
|
|
188
64
|
return copy;
|
|
189
65
|
}
|
|
190
|
-
|
|
191
|
-
const chunks = [];
|
|
192
|
-
let length = 0;
|
|
193
|
-
for await (const chunk of request) {
|
|
194
|
-
const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
|
|
195
|
-
length += buffer.length;
|
|
196
|
-
if (length > MAX_INSPECTED_BODY_BYTES) {
|
|
197
|
-
throw new Error('GitHub request body is too large for policy inspection.');
|
|
198
|
-
}
|
|
199
|
-
chunks.push(buffer);
|
|
200
|
-
}
|
|
201
|
-
return Buffer.concat(chunks);
|
|
202
|
-
}
|
|
203
|
-
async function readReceivePackPrefix(request) {
|
|
204
|
-
return new Promise((resolvePromise, reject) => {
|
|
205
|
-
let buffered = Buffer.alloc(0);
|
|
206
|
-
function cleanup() {
|
|
207
|
-
// eslint-disable-next-line @typescript-eslint/no-use-before-define
|
|
208
|
-
request.off('data', onData);
|
|
209
|
-
// eslint-disable-next-line @typescript-eslint/no-use-before-define
|
|
210
|
-
request.off('end', onEnd);
|
|
211
|
-
// eslint-disable-next-line @typescript-eslint/no-use-before-define
|
|
212
|
-
request.off('error', onError);
|
|
213
|
-
}
|
|
214
|
-
function onError(error) {
|
|
215
|
-
cleanup();
|
|
216
|
-
reject(error);
|
|
217
|
-
}
|
|
218
|
-
function onEnd() {
|
|
219
|
-
cleanup();
|
|
220
|
-
reject(new Error('Incomplete git receive-pack command list.'));
|
|
221
|
-
}
|
|
222
|
-
function onData(chunk) {
|
|
223
|
-
request.pause();
|
|
224
|
-
buffered = Buffer.concat([buffered, Buffer.from(chunk)]);
|
|
225
|
-
if (buffered.length > MAX_RECEIVE_PACK_COMMAND_BYTES) {
|
|
226
|
-
cleanup();
|
|
227
|
-
reject(new Error('Git receive-pack command list is too large.'));
|
|
228
|
-
return;
|
|
229
|
-
}
|
|
230
|
-
try {
|
|
231
|
-
const length = receivePackCommandLength(buffered);
|
|
232
|
-
if (length === null) {
|
|
233
|
-
request.resume();
|
|
234
|
-
return;
|
|
235
|
-
}
|
|
236
|
-
cleanup();
|
|
237
|
-
const remainder = buffered.subarray(length);
|
|
238
|
-
if (remainder.length > 0)
|
|
239
|
-
request.unshift(remainder);
|
|
240
|
-
resolvePromise(buffered.subarray(0, length));
|
|
241
|
-
}
|
|
242
|
-
catch (error) {
|
|
243
|
-
cleanup();
|
|
244
|
-
reject(error);
|
|
245
|
-
}
|
|
246
|
-
}
|
|
247
|
-
request.on('data', onData);
|
|
248
|
-
request.once('end', onEnd);
|
|
249
|
-
request.once('error', onError);
|
|
250
|
-
request.resume();
|
|
251
|
-
});
|
|
252
|
-
}
|
|
253
|
-
function reply(response, status, message, basicChallenge = false) {
|
|
66
|
+
function reply(response, status, message) {
|
|
254
67
|
response.writeHead(status, {
|
|
255
68
|
'content-type': 'text/plain; charset=utf-8',
|
|
256
|
-
'cache-control': 'no-store'
|
|
257
|
-
...(basicChallenge ? { 'www-authenticate': 'Basic realm="GitHub"' } : {})
|
|
69
|
+
'cache-control': 'no-store'
|
|
258
70
|
});
|
|
259
71
|
response.end(`${message}\n`);
|
|
260
72
|
}
|
|
@@ -298,10 +110,6 @@ class GitHubTlsProxy {
|
|
|
298
110
|
refuseBrowserPeer(request, response) {
|
|
299
111
|
return this.peerGuard.refuse(request.socket, response);
|
|
300
112
|
}
|
|
301
|
-
refused(sessionId, kind, path, response, reason) {
|
|
302
|
-
this.logRefusal(sessionId, kind, path);
|
|
303
|
-
reply(response, 403, reason);
|
|
304
|
-
}
|
|
305
113
|
logRefusal(sessionId, kind, path) {
|
|
306
114
|
(this.options.logger ?? console).warn(`[coding-agents] github-proxy-refused sessionId=${sessionId} kind=${kind} path=${JSON.stringify(path)}`);
|
|
307
115
|
}
|
|
@@ -309,7 +117,10 @@ class GitHubTlsProxy {
|
|
|
309
117
|
const target = request.url ?? '';
|
|
310
118
|
const match = target.match(/^([^:]+):(\d+)$/);
|
|
311
119
|
const hostname = match?.[1]?.toLowerCase() ?? '';
|
|
312
|
-
if (match?.[2] === '443' &&
|
|
120
|
+
if (match?.[2] === '443' &&
|
|
121
|
+
!ALLOWED_HOSTS.has(hostname) &&
|
|
122
|
+
(this.options.allowDirectHttps ||
|
|
123
|
+
this.options.isDirectHostAllowed?.(hostname))) {
|
|
313
124
|
this.connectDirect(hostname, 443, socket, head);
|
|
314
125
|
return;
|
|
315
126
|
}
|
|
@@ -340,9 +151,8 @@ class GitHubTlsProxy {
|
|
|
340
151
|
}
|
|
341
152
|
}
|
|
342
153
|
connectDirect(hostname, port, socket, head) {
|
|
343
|
-
//
|
|
344
|
-
//
|
|
345
|
-
// end-to-end, while GitHub traffic continues through policy inspection.
|
|
154
|
+
// Keep non-GitHub TLS end to end. The local runner's own network policy
|
|
155
|
+
// controls this tunnel. Sandbox callers still need an approved direct host.
|
|
346
156
|
const upstream = (this.options.connectDirect ??
|
|
347
157
|
((host, targetPort) => (0, node_net_1.connect)(targetPort, host)))(hostname, port);
|
|
348
158
|
(0, child_stream_guards_1.guardStream)(upstream, 'GitHubProxy/direct-upstream');
|
|
@@ -380,17 +190,24 @@ class GitHubTlsProxy {
|
|
|
380
190
|
return;
|
|
381
191
|
}
|
|
382
192
|
if (PASS_THROUGH_HOSTS.has(hostname)) {
|
|
383
|
-
this.forward(hostname, request, response,
|
|
193
|
+
this.forward(hostname, request, response, undefined);
|
|
384
194
|
return;
|
|
385
195
|
}
|
|
386
196
|
const gitRequest = hostname === 'github.com' &&
|
|
387
197
|
/\.git\/(?:info\/refs|git-upload-pack|git-receive-pack)$/i.test(path);
|
|
198
|
+
// Public GitHub pages need no installation token. Do not inject it into
|
|
199
|
+
// web traffic; only Git smart HTTP and the API use token exchange.
|
|
200
|
+
if (hostname === 'github.com' && !gitRequest) {
|
|
201
|
+
this.forward(hostname, request, response, undefined);
|
|
202
|
+
return;
|
|
203
|
+
}
|
|
388
204
|
const sentinel = gitRequest
|
|
389
205
|
? basicSentinel(request.headers.authorization)
|
|
390
206
|
: bearerSentinel(request.headers.authorization);
|
|
391
207
|
if (!sentinel) {
|
|
392
|
-
|
|
393
|
-
|
|
208
|
+
// GitHub handles anonymous requests and sends its own auth challenge.
|
|
209
|
+
// forwardedHeaders strips caller authorization before this reaches it.
|
|
210
|
+
this.forward(hostname, request, response, undefined);
|
|
394
211
|
return;
|
|
395
212
|
}
|
|
396
213
|
const session = this.options.findSession(sentinel);
|
|
@@ -399,71 +216,16 @@ class GitHubTlsProxy {
|
|
|
399
216
|
reply(response, 403, 'GitHub session sentinel is invalid.');
|
|
400
217
|
return;
|
|
401
218
|
}
|
|
402
|
-
if (hostname === 'github.com' && !gitRequest) {
|
|
403
|
-
this.refused(session.sessionId, 'github-path', path, response, 'GitHub request refused: github.com is limited to Git smart HTTP.');
|
|
404
|
-
return;
|
|
405
|
-
}
|
|
406
|
-
const repository = repositoryFromPath(hostname, path);
|
|
407
|
-
if (repository &&
|
|
408
|
-
!session.credential.repositories.some((item) => item.fullName.toLowerCase() === repository.toLowerCase())) {
|
|
409
|
-
this.refused(session.sessionId, 'repository-grant', path, response, 'Repository is outside this agent grant.');
|
|
410
|
-
return;
|
|
411
|
-
}
|
|
412
|
-
if (hostname === 'api.github.com' && path !== '/graphql' && !repository) {
|
|
413
|
-
this.refused(session.sessionId, 'repository-grant', path, response, 'GitHub API request is not scoped to a granted repository.');
|
|
414
|
-
return;
|
|
415
|
-
}
|
|
416
|
-
const restReason = hostname === 'api.github.com'
|
|
417
|
-
? restRefusal(request.method ?? 'GET', path)
|
|
418
|
-
: null;
|
|
419
|
-
if (restReason) {
|
|
420
|
-
this.refused(session.sessionId, 'rest-policy', path, response, restReason);
|
|
421
|
-
return;
|
|
422
|
-
}
|
|
423
|
-
const receivePack = /\.git\/git-receive-pack$/i.test(path);
|
|
424
|
-
let body;
|
|
425
|
-
try {
|
|
426
|
-
body = receivePack
|
|
427
|
-
? await readReceivePackPrefix(request)
|
|
428
|
-
: await readBody(request);
|
|
429
|
-
}
|
|
430
|
-
catch (error) {
|
|
431
|
-
reply(response, 413, error instanceof Error ? error.message : String(error));
|
|
432
|
-
return;
|
|
433
|
-
}
|
|
434
|
-
if (path === '/graphql') {
|
|
435
|
-
if (request.method !== 'POST' ||
|
|
436
|
-
![undefined, 'identity'].includes(request.headers['content-encoding'])) {
|
|
437
|
-
this.refused(session.sessionId, 'graphql-policy', path, response, 'GitHub GraphQL request refused: the body cannot be inspected.');
|
|
438
|
-
return;
|
|
439
|
-
}
|
|
440
|
-
const text = graphQLPolicyText(body);
|
|
441
|
-
const mutation = GRAPHQL_REFUSED_MUTATIONS.find((name) => new RegExp(`\\b${name}\\b`).test(text));
|
|
442
|
-
if (mutation) {
|
|
443
|
-
this.refused(session.sessionId, 'graphql-policy', path, response, `GitHub GraphQL mutation refused: ${mutation} is not permitted.`);
|
|
444
|
-
return;
|
|
445
|
-
}
|
|
446
|
-
}
|
|
447
|
-
if (receivePack) {
|
|
448
|
-
const reason = receivePackRefusal(body, session.sessionId);
|
|
449
|
-
if (reason) {
|
|
450
|
-
this.refused(session.sessionId, 'git-receive-pack', path, response, reason);
|
|
451
|
-
return;
|
|
452
|
-
}
|
|
453
|
-
}
|
|
454
219
|
const authorization = gitRequest
|
|
455
220
|
? `Basic ${Buffer.from(`x-access-token:${session.credential.token}`).toString('base64')}`
|
|
456
221
|
: `Bearer ${session.credential.token}`;
|
|
457
|
-
this.forward(hostname, request, response,
|
|
222
|
+
this.forward(hostname, request, response, authorization);
|
|
458
223
|
}
|
|
459
|
-
forward(hostname, request, response,
|
|
224
|
+
forward(hostname, request, response, authorization) {
|
|
460
225
|
const headers = forwardedHeaders(request.headers);
|
|
461
226
|
headers.host = hostname;
|
|
462
227
|
if (authorization)
|
|
463
228
|
headers.authorization = authorization;
|
|
464
|
-
if (body.length > 0 && !pipeRemainder) {
|
|
465
|
-
headers['content-length'] = String(body.length);
|
|
466
|
-
}
|
|
467
229
|
const upstream = (0, node_https_1.request)({
|
|
468
230
|
hostname,
|
|
469
231
|
port: 443,
|
|
@@ -482,14 +244,7 @@ class GitHubTlsProxy {
|
|
|
482
244
|
response.writeHead(502);
|
|
483
245
|
response.end(error.message);
|
|
484
246
|
});
|
|
485
|
-
if (
|
|
486
|
-
if (body.length > 0)
|
|
487
|
-
upstream.write(body);
|
|
488
|
-
request.pipe(upstream);
|
|
489
|
-
}
|
|
490
|
-
else if (body.length > 0)
|
|
491
|
-
upstream.end(body);
|
|
492
|
-
else if (request.readableEnded)
|
|
247
|
+
if (request.readableEnded)
|
|
493
248
|
upstream.end();
|
|
494
249
|
else
|
|
495
250
|
request.pipe(upstream);
|
|
@@ -10,6 +10,7 @@ export interface GitHubWorkspaceOptions {
|
|
|
10
10
|
createServer?: (handler: RequestListener) => Server;
|
|
11
11
|
logger?: Pick<Console, 'warn'>;
|
|
12
12
|
directUrls?: string[];
|
|
13
|
+
allowDirectHttps?: boolean;
|
|
13
14
|
/** Injected in tests: the proxy's peer-uid lookup. See `GitHubTlsProxy`. */
|
|
14
15
|
peerUid?: PeerUidResolver;
|
|
15
16
|
}
|
package/dist/github-workspace.js
CHANGED
|
@@ -11,7 +11,6 @@ const node_http_1 = require("node:http");
|
|
|
11
11
|
const node_path_1 = require("node:path");
|
|
12
12
|
const node_util_1 = require("node:util");
|
|
13
13
|
const vendor_environment_1 = require("./adapters/vendor-environment");
|
|
14
|
-
const github_command_guards_1 = require("./github-command-guards");
|
|
15
14
|
const github_proxy_1 = require("./github-proxy");
|
|
16
15
|
const github_proxy_certificates_1 = require("./github-proxy-certificates");
|
|
17
16
|
const execFileAsync = (0, node_util_1.promisify)(node_child_process_1.execFile);
|
|
@@ -446,6 +445,7 @@ class GitHubWorkspaceProvider {
|
|
|
446
445
|
tlsProxy = new github_proxy_1.GitHubTlsProxy(server, {
|
|
447
446
|
certificates,
|
|
448
447
|
peerUid: this.options.peerUid,
|
|
448
|
+
allowDirectHttps: this.options.allowDirectHttps,
|
|
449
449
|
logger: this.options.logger,
|
|
450
450
|
isDirectHostAllowed: (hostname) => this.directHosts.has(hostname),
|
|
451
451
|
findSession: (sentinel) => [...this.sessions.values()].find((entry) => entry.sentinel === sentinel)
|
|
@@ -598,10 +598,14 @@ class GitHubWorkspaceProvider {
|
|
|
598
598
|
});
|
|
599
599
|
await this.runGit(['config', '--worktree', 'remote.origin.url', transportUrl], { cwd: worktree });
|
|
600
600
|
const hooks = (0, node_path_1.join)(sessionRoot, '.github-hooks', name);
|
|
601
|
-
|
|
602
|
-
await
|
|
603
|
-
|
|
604
|
-
|
|
601
|
+
// Retire our old hook on reused checkouts. GitHub decides push policy.
|
|
602
|
+
await (0, promises_1.rm)(hooks, { recursive: true, force: true });
|
|
603
|
+
const configuredHooks = await this.runGit(['config', '--worktree', '--get', 'core.hooksPath'], { cwd: worktree }).catch(() => ({ stdout: '' }));
|
|
604
|
+
if (configuredHooks.stdout.trim() === hooks) {
|
|
605
|
+
await this.runGit(['config', '--worktree', '--unset', 'core.hooksPath'], {
|
|
606
|
+
cwd: worktree
|
|
607
|
+
});
|
|
608
|
+
}
|
|
605
609
|
// A direct /usr/bin/git call also uses this worktree configuration.
|
|
606
610
|
// Rewrite GitHub SSH addresses before transport selection and disable
|
|
607
611
|
// owner SSH keys for any address that is not rewritten.
|
|
@@ -645,8 +649,8 @@ class GitHubWorkspaceProvider {
|
|
|
645
649
|
const instructions = [
|
|
646
650
|
'# GitHub repository access',
|
|
647
651
|
'',
|
|
648
|
-
'Each repository is a child directory.
|
|
649
|
-
'GitHub access uses the session proxy. Use gh and git only in this session worktree. GH_TOKEN is a session sentinel, not a GitHub token.
|
|
652
|
+
'Each repository is a child directory. New checkouts start on agent/<session-id>.',
|
|
653
|
+
'GitHub access uses the session proxy. Use gh and git only in this session worktree. GH_TOKEN is a session sentinel, not a GitHub token. GitHub permissions and repository rules decide which pushes and merges are allowed.',
|
|
650
654
|
''
|
|
651
655
|
].join('\n');
|
|
652
656
|
await (0, promises_1.writeFile)((0, node_path_1.join)(sessionRoot, 'AGENTS.md'), instructions, {
|
package/dist/harness.js
CHANGED
|
@@ -328,7 +328,8 @@ class Harness {
|
|
|
328
328
|
new github_workspace_1.GitHubWorkspaceProvider({
|
|
329
329
|
root: options.workspaceRoot,
|
|
330
330
|
port: options.githubProxyPort,
|
|
331
|
-
directUrls: [options.controlPlaneUrl]
|
|
331
|
+
directUrls: [options.controlPlaneUrl],
|
|
332
|
+
allowDirectHttps: options.runtime === 'desktop' || options.runtime === 'self-hosted'
|
|
332
333
|
});
|
|
333
334
|
this.secrets = deps.secrets ?? new ports_default_1.NoSecretProvider();
|
|
334
335
|
this.events = deps.events ?? new ports_default_1.NullEventSink();
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@peakflo/agent-harness",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.19-rc.1",
|
|
4
4
|
"description": "Headless Peakflo coding-agent harness. One codebase, three hosting modes: a Daytona cloud sandbox, a customer VM, and the 20x desktop.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "commonjs",
|