@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.
- package/LICENSE +21 -0
- package/README.md +68 -2
- package/dist/adapters/acp-adapter.d.ts +177 -0
- package/dist/adapters/acp-adapter.js +1732 -0
- package/dist/adapters/claude-code-adapter.d.ts +171 -0
- package/dist/adapters/claude-code-adapter.js +1918 -0
- package/dist/adapters/codex-adapter.d.ts +67 -0
- package/dist/adapters/codex-adapter.js +681 -0
- package/dist/adapters/codex-app-server-adapter.d.ts +105 -0
- package/dist/adapters/codex-app-server-adapter.js +1309 -0
- package/dist/adapters/coding-agent-adapter.d.ts +289 -0
- package/dist/adapters/coding-agent-adapter.js +31 -0
- package/dist/adapters/index.d.ts +17 -0
- package/dist/adapters/index.js +35 -0
- package/dist/adapters/model-gateway.d.ts +84 -0
- package/dist/adapters/model-gateway.js +270 -0
- package/dist/adapters/opencode-adapter.d.ts +403 -0
- package/dist/adapters/opencode-adapter.js +2710 -0
- package/dist/adapters/opencode-config.d.ts +41 -0
- package/dist/adapters/opencode-config.js +147 -0
- package/dist/adapters/opencode-server-policy.d.ts +82 -0
- package/dist/adapters/opencode-server-policy.js +148 -0
- package/dist/adapters/pi-adapter.d.ts +168 -0
- package/dist/adapters/pi-adapter.js +1468 -0
- package/dist/capabilities.d.ts +58 -0
- package/dist/capabilities.js +161 -0
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +231 -0
- package/dist/contract.d.ts +273 -0
- package/dist/contract.js +357 -0
- package/dist/control-plane.d.ts +178 -0
- package/dist/control-plane.js +431 -0
- package/dist/credential-store.d.ts +110 -0
- package/dist/credential-store.js +181 -0
- package/dist/enrol/browser-enrol.d.ts +85 -0
- package/dist/enrol/browser-enrol.js +387 -0
- package/dist/github-command-guards.d.ts +20 -0
- package/dist/github-command-guards.js +330 -0
- package/dist/github-proxy-certificates.d.ts +25 -0
- package/dist/github-proxy-certificates.js +145 -0
- package/dist/github-proxy.d.ts +37 -0
- package/dist/github-proxy.js +476 -0
- package/dist/github-workspace.d.ts +55 -0
- package/dist/github-workspace.js +537 -0
- package/dist/harness.d.ts +333 -0
- package/dist/harness.js +1414 -0
- package/dist/health.d.ts +45 -0
- package/dist/health.js +116 -0
- package/dist/index.d.ts +53 -0
- package/dist/index.js +123 -0
- package/dist/model-probe.d.ts +96 -0
- package/dist/model-probe.js +195 -0
- package/dist/options.d.ts +15 -0
- package/dist/options.js +212 -0
- package/dist/ports-default.d.ts +212 -0
- package/dist/ports-default.js +333 -0
- package/dist/ports.d.ts +436 -0
- package/dist/ports.js +27 -0
- package/dist/runtime/attachments.d.ts +92 -0
- package/dist/runtime/attachments.js +377 -0
- package/dist/runtime/child-stream-guards.d.ts +45 -0
- package/dist/runtime/child-stream-guards.js +111 -0
- package/dist/runtime/conversation.d.ts +87 -0
- package/dist/runtime/conversation.js +156 -0
- package/dist/runtime/lease-renewal.d.ts +169 -0
- package/dist/runtime/lease-renewal.js +433 -0
- package/dist/runtime/mcp-auth-proxy.d.ts +82 -0
- package/dist/runtime/mcp-auth-proxy.js +300 -0
- package/dist/runtime/permission-mode.d.ts +116 -0
- package/dist/runtime/permission-mode.js +146 -0
- package/dist/runtime/projection.d.ts +71 -0
- package/dist/runtime/projection.js +211 -0
- package/dist/runtime/session-runner.d.ts +520 -0
- package/dist/runtime/session-runner.js +2122 -0
- package/dist/shared/reasoning-effort.d.ts +11 -0
- package/dist/shared/reasoning-effort.js +20 -0
- package/dist/store/conversation-store.d.ts +100 -0
- package/dist/store/conversation-store.js +305 -0
- package/dist/store/sqlite-store.d.ts +120 -0
- package/dist/store/sqlite-store.js +346 -0
- package/dist/types.d.ts +952 -0
- package/dist/types.js +55 -0
- package/dist/version.d.ts +32 -0
- package/dist/version.js +77 -0
- package/package.json +53 -4
|
@@ -0,0 +1,476 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.GitHubTlsProxy = void 0;
|
|
4
|
+
exports.parseReceivePackCommands = parseReceivePackCommands;
|
|
5
|
+
exports.receivePackRefusal = receivePackRefusal;
|
|
6
|
+
const node_crypto_1 = require("node:crypto");
|
|
7
|
+
const node_http_1 = require("node:http");
|
|
8
|
+
const node_https_1 = require("node:https");
|
|
9
|
+
const node_net_1 = require("node:net");
|
|
10
|
+
const node_tls_1 = require("node:tls");
|
|
11
|
+
const child_stream_guards_1 = require("./runtime/child-stream-guards");
|
|
12
|
+
const INTERCEPTED_HOSTS = new Set(['github.com', 'api.github.com']);
|
|
13
|
+
const PASS_THROUGH_HOSTS = new Set([
|
|
14
|
+
'objects.githubusercontent.com',
|
|
15
|
+
'codeload.github.com'
|
|
16
|
+
]);
|
|
17
|
+
const ALLOWED_HOSTS = new Set([...INTERCEPTED_HOSTS, ...PASS_THROUGH_HOSTS]);
|
|
18
|
+
const GRAPHQL_REFUSED_MUTATIONS = [
|
|
19
|
+
'mergePullRequest',
|
|
20
|
+
'enablePullRequestAutoMerge',
|
|
21
|
+
'deleteRepository',
|
|
22
|
+
'updateBranchProtectionRule',
|
|
23
|
+
'createBranchProtectionRule'
|
|
24
|
+
];
|
|
25
|
+
const ZERO_OID = /^0+$/;
|
|
26
|
+
const MAX_INSPECTED_BODY_BYTES = 16 * 1024 * 1024;
|
|
27
|
+
const MAX_RECEIVE_PACK_COMMAND_BYTES = 64 * 1024;
|
|
28
|
+
function receivePackCommandLength(body) {
|
|
29
|
+
let offset = 0;
|
|
30
|
+
while (offset + 4 <= body.length) {
|
|
31
|
+
const lengthText = body.subarray(offset, offset + 4).toString('ascii');
|
|
32
|
+
if (!/^[0-9a-f]{4}$/i.test(lengthText)) {
|
|
33
|
+
throw new Error('Invalid git receive-pack pkt-line length.');
|
|
34
|
+
}
|
|
35
|
+
const length = Number.parseInt(lengthText, 16);
|
|
36
|
+
if (length === 0)
|
|
37
|
+
return offset + 4;
|
|
38
|
+
if (length < 4)
|
|
39
|
+
throw new Error('Invalid git receive-pack pkt-line length.');
|
|
40
|
+
if (offset + length > body.length)
|
|
41
|
+
return null;
|
|
42
|
+
offset += length;
|
|
43
|
+
}
|
|
44
|
+
return null;
|
|
45
|
+
}
|
|
46
|
+
function safeEquals(left, right) {
|
|
47
|
+
const a = Buffer.from(left);
|
|
48
|
+
const b = Buffer.from(right);
|
|
49
|
+
return a.length === b.length && (0, node_crypto_1.timingSafeEqual)(a, b);
|
|
50
|
+
}
|
|
51
|
+
function parseReceivePackCommands(body) {
|
|
52
|
+
const commands = [];
|
|
53
|
+
let offset = 0;
|
|
54
|
+
while (offset + 4 <= body.length) {
|
|
55
|
+
const lengthText = body.subarray(offset, offset + 4).toString('ascii');
|
|
56
|
+
if (!/^[0-9a-f]{4}$/i.test(lengthText)) {
|
|
57
|
+
throw new Error('Invalid git receive-pack pkt-line length.');
|
|
58
|
+
}
|
|
59
|
+
const length = Number.parseInt(lengthText, 16);
|
|
60
|
+
if (length === 0)
|
|
61
|
+
return commands;
|
|
62
|
+
if (length < 4 || offset + length > body.length) {
|
|
63
|
+
throw new Error('Incomplete git receive-pack pkt-line.');
|
|
64
|
+
}
|
|
65
|
+
const payload = body
|
|
66
|
+
.subarray(offset + 4, offset + length)
|
|
67
|
+
.toString('utf8')
|
|
68
|
+
.replace(/\n$/, '');
|
|
69
|
+
const command = payload.split('\0', 1)[0];
|
|
70
|
+
const match = command.match(/^([0-9a-f]+) ([0-9a-f]+) ([^\s]+)$/i);
|
|
71
|
+
if (!match)
|
|
72
|
+
throw new Error('Invalid git receive-pack ref update.');
|
|
73
|
+
commands.push({ oldOid: match[1], newOid: match[2], ref: match[3] });
|
|
74
|
+
offset += length;
|
|
75
|
+
}
|
|
76
|
+
throw new Error('Incomplete git receive-pack command list.');
|
|
77
|
+
}
|
|
78
|
+
function receivePackRefusal(body, sessionId) {
|
|
79
|
+
let commands;
|
|
80
|
+
try {
|
|
81
|
+
commands = parseReceivePackCommands(body);
|
|
82
|
+
}
|
|
83
|
+
catch {
|
|
84
|
+
return 'Git push refused: the receive-pack command list is malformed.';
|
|
85
|
+
}
|
|
86
|
+
if (commands.length === 0) {
|
|
87
|
+
return 'Git push refused: the receive-pack command list is empty.';
|
|
88
|
+
}
|
|
89
|
+
const allowed = `refs/heads/agent/${sessionId}`;
|
|
90
|
+
for (const command of commands) {
|
|
91
|
+
if (ZERO_OID.test(command.newOid)) {
|
|
92
|
+
return `Git push refused: deleting ${command.ref} is not permitted.`;
|
|
93
|
+
}
|
|
94
|
+
if (command.ref !== allowed) {
|
|
95
|
+
return `Git push refused: this session may update only ${allowed}.`;
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
return null;
|
|
99
|
+
}
|
|
100
|
+
function decodedPath(request) {
|
|
101
|
+
try {
|
|
102
|
+
return decodeURIComponent(new URL(request.url ?? '/', 'https://x').pathname);
|
|
103
|
+
}
|
|
104
|
+
catch {
|
|
105
|
+
return null;
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
function repositoryFromPath(hostname, path) {
|
|
109
|
+
const match = hostname === 'api.github.com'
|
|
110
|
+
? path.match(/^\/repos\/([^/]+)\/([^/]+)(?:\/|$)/)
|
|
111
|
+
: path.match(/^\/([^/]+)\/([^/]+?)\.git(?:\/|$)/);
|
|
112
|
+
return match ? `${match[1]}/${match[2]}` : null;
|
|
113
|
+
}
|
|
114
|
+
function restRefusal(method, path) {
|
|
115
|
+
if (/^\/repos\/[^/]+\/[^/]+\/pulls\/[^/]+\/merge\/?$/i.test(path))
|
|
116
|
+
return 'GitHub request refused: pull requests must be merged by a human.';
|
|
117
|
+
if (/^\/repos\/[^/]+\/[^/]+\/merges\/?$/i.test(path))
|
|
118
|
+
return 'GitHub request refused: merge commits are not permitted.';
|
|
119
|
+
if (method === 'DELETE' && /^\/repos\/[^/]+\/[^/]+\/?$/i.test(path))
|
|
120
|
+
return 'GitHub request refused: repository deletion is not permitted.';
|
|
121
|
+
const forbidden = [
|
|
122
|
+
/\/actions\/secrets(?:\/|$)/i,
|
|
123
|
+
/\/actions\/workflows\/[^/]+\/dispatches\/?$/i,
|
|
124
|
+
/\/rulesets(?:\/|$)/i,
|
|
125
|
+
/\/branches\/[^/]+\/protection(?:\/|$)/i,
|
|
126
|
+
/\/hooks(?:\/|$)/i,
|
|
127
|
+
/\/keys(?:\/|$)/i,
|
|
128
|
+
/\/collaborators(?:\/|$)/i,
|
|
129
|
+
/\/deployments(?:\/|$)/i,
|
|
130
|
+
/\/codespaces(?:\/|$)/i
|
|
131
|
+
];
|
|
132
|
+
return forbidden.some((pattern) => pattern.test(path))
|
|
133
|
+
? 'GitHub request refused: this administrative operation is not permitted.'
|
|
134
|
+
: null;
|
|
135
|
+
}
|
|
136
|
+
function bearerSentinel(header) {
|
|
137
|
+
const value = Array.isArray(header) ? header[0] : header;
|
|
138
|
+
return (value?.match(/^\s*(?:token|Bearer)\s+(pfgh_[A-Za-z0-9_-]+)\s*$/i)?.[1] ??
|
|
139
|
+
null);
|
|
140
|
+
}
|
|
141
|
+
function basicSentinel(header) {
|
|
142
|
+
const value = Array.isArray(header) ? header[0] : header;
|
|
143
|
+
const encoded = value?.match(/^\s*Basic\s+([^\s]+)\s*$/i)?.[1];
|
|
144
|
+
if (!encoded)
|
|
145
|
+
return null;
|
|
146
|
+
try {
|
|
147
|
+
const decoded = Buffer.from(encoded, 'base64').toString('utf8');
|
|
148
|
+
const separator = decoded.indexOf(':');
|
|
149
|
+
return decoded.slice(0, separator) === 'x-access-token'
|
|
150
|
+
? decoded.slice(separator + 1)
|
|
151
|
+
: null;
|
|
152
|
+
}
|
|
153
|
+
catch {
|
|
154
|
+
return null;
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
function graphQLPolicyText(body) {
|
|
158
|
+
const raw = body.toString('utf8');
|
|
159
|
+
const forms = [raw];
|
|
160
|
+
try {
|
|
161
|
+
forms.push(JSON.stringify(JSON.parse(raw)));
|
|
162
|
+
}
|
|
163
|
+
catch {
|
|
164
|
+
/* gh uses JSON; retain the raw form for other valid GitHub clients. */
|
|
165
|
+
}
|
|
166
|
+
try {
|
|
167
|
+
forms.push(decodeURIComponent(raw.replace(/\+/g, ' ')));
|
|
168
|
+
}
|
|
169
|
+
catch {
|
|
170
|
+
/* A malformed percent escape remains visible in the raw form. */
|
|
171
|
+
}
|
|
172
|
+
return forms.join('\n');
|
|
173
|
+
}
|
|
174
|
+
function forwardedHeaders(headers) {
|
|
175
|
+
const copy = { ...headers };
|
|
176
|
+
for (const name of [
|
|
177
|
+
'authorization',
|
|
178
|
+
'proxy-authorization',
|
|
179
|
+
'proxy-connection',
|
|
180
|
+
'connection',
|
|
181
|
+
'keep-alive',
|
|
182
|
+
'transfer-encoding',
|
|
183
|
+
'upgrade'
|
|
184
|
+
]) {
|
|
185
|
+
delete copy[name];
|
|
186
|
+
}
|
|
187
|
+
return copy;
|
|
188
|
+
}
|
|
189
|
+
async function readBody(request) {
|
|
190
|
+
const chunks = [];
|
|
191
|
+
let length = 0;
|
|
192
|
+
for await (const chunk of request) {
|
|
193
|
+
const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
|
|
194
|
+
length += buffer.length;
|
|
195
|
+
if (length > MAX_INSPECTED_BODY_BYTES) {
|
|
196
|
+
throw new Error('GitHub request body is too large for policy inspection.');
|
|
197
|
+
}
|
|
198
|
+
chunks.push(buffer);
|
|
199
|
+
}
|
|
200
|
+
return Buffer.concat(chunks);
|
|
201
|
+
}
|
|
202
|
+
async function readReceivePackPrefix(request) {
|
|
203
|
+
return new Promise((resolvePromise, reject) => {
|
|
204
|
+
let buffered = Buffer.alloc(0);
|
|
205
|
+
function cleanup() {
|
|
206
|
+
// eslint-disable-next-line @typescript-eslint/no-use-before-define
|
|
207
|
+
request.off('data', onData);
|
|
208
|
+
// eslint-disable-next-line @typescript-eslint/no-use-before-define
|
|
209
|
+
request.off('end', onEnd);
|
|
210
|
+
// eslint-disable-next-line @typescript-eslint/no-use-before-define
|
|
211
|
+
request.off('error', onError);
|
|
212
|
+
}
|
|
213
|
+
function onError(error) {
|
|
214
|
+
cleanup();
|
|
215
|
+
reject(error);
|
|
216
|
+
}
|
|
217
|
+
function onEnd() {
|
|
218
|
+
cleanup();
|
|
219
|
+
reject(new Error('Incomplete git receive-pack command list.'));
|
|
220
|
+
}
|
|
221
|
+
function onData(chunk) {
|
|
222
|
+
request.pause();
|
|
223
|
+
buffered = Buffer.concat([buffered, Buffer.from(chunk)]);
|
|
224
|
+
if (buffered.length > MAX_RECEIVE_PACK_COMMAND_BYTES) {
|
|
225
|
+
cleanup();
|
|
226
|
+
reject(new Error('Git receive-pack command list is too large.'));
|
|
227
|
+
return;
|
|
228
|
+
}
|
|
229
|
+
try {
|
|
230
|
+
const length = receivePackCommandLength(buffered);
|
|
231
|
+
if (length === null) {
|
|
232
|
+
request.resume();
|
|
233
|
+
return;
|
|
234
|
+
}
|
|
235
|
+
cleanup();
|
|
236
|
+
const remainder = buffered.subarray(length);
|
|
237
|
+
if (remainder.length > 0)
|
|
238
|
+
request.unshift(remainder);
|
|
239
|
+
resolvePromise(buffered.subarray(0, length));
|
|
240
|
+
}
|
|
241
|
+
catch (error) {
|
|
242
|
+
cleanup();
|
|
243
|
+
reject(error);
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
request.on('data', onData);
|
|
247
|
+
request.once('end', onEnd);
|
|
248
|
+
request.once('error', onError);
|
|
249
|
+
request.resume();
|
|
250
|
+
});
|
|
251
|
+
}
|
|
252
|
+
function reply(response, status, message, basicChallenge = false) {
|
|
253
|
+
response.writeHead(status, {
|
|
254
|
+
'content-type': 'text/plain; charset=utf-8',
|
|
255
|
+
'cache-control': 'no-store',
|
|
256
|
+
...(basicChallenge ? { 'www-authenticate': 'Basic realm="GitHub"' } : {})
|
|
257
|
+
});
|
|
258
|
+
response.end(`${message}\n`);
|
|
259
|
+
}
|
|
260
|
+
class GitHubTlsProxy {
|
|
261
|
+
listener;
|
|
262
|
+
options;
|
|
263
|
+
tunneled = (0, node_http_1.createServer)((request, response) => {
|
|
264
|
+
void this.handle(request, response);
|
|
265
|
+
});
|
|
266
|
+
constructor(listener, options) {
|
|
267
|
+
this.listener = listener;
|
|
268
|
+
this.options = options;
|
|
269
|
+
listener.on('connect', (request, socket, head) => {
|
|
270
|
+
// A refused CONNECT commonly makes the client reset the TCP connection
|
|
271
|
+
// after it receives our 403. Without an error listener Node promotes that
|
|
272
|
+
// routine ECONNRESET into an uncaught exception and kills the whole
|
|
273
|
+
// harness. Install the guard before the first await/write in connect(),
|
|
274
|
+
// including for targets outside the GitHub allowlist.
|
|
275
|
+
(0, child_stream_guards_1.guardStream)(socket, 'GitHubProxy/connect');
|
|
276
|
+
void this.connect(request, socket, head);
|
|
277
|
+
});
|
|
278
|
+
}
|
|
279
|
+
refused(sessionId, kind, path, response, reason) {
|
|
280
|
+
this.logRefusal(sessionId, kind, path);
|
|
281
|
+
reply(response, 403, reason);
|
|
282
|
+
}
|
|
283
|
+
logRefusal(sessionId, kind, path) {
|
|
284
|
+
(this.options.logger ?? console).warn(`[coding-agents] github-proxy-refused sessionId=${sessionId} kind=${kind} path=${JSON.stringify(path)}`);
|
|
285
|
+
}
|
|
286
|
+
async connect(request, socket, head) {
|
|
287
|
+
const target = request.url ?? '';
|
|
288
|
+
const match = target.match(/^([^:]+):(\d+)$/);
|
|
289
|
+
const hostname = match?.[1]?.toLowerCase() ?? '';
|
|
290
|
+
if (match?.[2] === '443' && this.options.isDirectHostAllowed?.(hostname)) {
|
|
291
|
+
this.connectDirect(hostname, 443, socket, head);
|
|
292
|
+
return;
|
|
293
|
+
}
|
|
294
|
+
if (!match || match[2] !== '443' || !ALLOWED_HOSTS.has(hostname)) {
|
|
295
|
+
this.logRefusal('unknown', 'connect-target', target);
|
|
296
|
+
socket.end('HTTP/1.1 403 Forbidden\r\nConnection: close\r\n\r\n');
|
|
297
|
+
return;
|
|
298
|
+
}
|
|
299
|
+
try {
|
|
300
|
+
const leaf = await this.options.certificates.leaf(hostname);
|
|
301
|
+
const tlsServer = (0, node_tls_1.createServer)({
|
|
302
|
+
key: leaf.privateKey,
|
|
303
|
+
cert: leaf.certificate
|
|
304
|
+
});
|
|
305
|
+
tlsServer.once('secureConnection', (tlsSocket) => {
|
|
306
|
+
tlsSocket.pfGithubHost =
|
|
307
|
+
hostname;
|
|
308
|
+
this.tunneled.emit('connection', tlsSocket);
|
|
309
|
+
});
|
|
310
|
+
tlsServer.once('tlsClientError', () => socket.destroy());
|
|
311
|
+
socket.write('HTTP/1.1 200 Connection Established\r\n\r\n');
|
|
312
|
+
if (head.length > 0)
|
|
313
|
+
socket.unshift(head);
|
|
314
|
+
tlsServer.emit('connection', socket);
|
|
315
|
+
}
|
|
316
|
+
catch {
|
|
317
|
+
socket.end('HTTP/1.1 502 Bad Gateway\r\nConnection: close\r\n\r\n');
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
connectDirect(hostname, port, socket, head) {
|
|
321
|
+
// Some clients do not consistently honor NO_PROXY. Tunnel only the exact
|
|
322
|
+
// server-configured direct hosts so their public TLS certificate remains
|
|
323
|
+
// end-to-end, while GitHub traffic continues through policy inspection.
|
|
324
|
+
const upstream = (this.options.connectDirect ??
|
|
325
|
+
((host, targetPort) => (0, node_net_1.connect)(targetPort, host)))(hostname, port);
|
|
326
|
+
(0, child_stream_guards_1.guardStream)(upstream, 'GitHubProxy/direct-upstream');
|
|
327
|
+
let connected = false;
|
|
328
|
+
upstream.once('connect', () => {
|
|
329
|
+
connected = true;
|
|
330
|
+
socket.write('HTTP/1.1 200 Connection Established\r\n\r\n');
|
|
331
|
+
if (head.length > 0)
|
|
332
|
+
upstream.write(head);
|
|
333
|
+
socket.pipe(upstream);
|
|
334
|
+
upstream.pipe(socket);
|
|
335
|
+
});
|
|
336
|
+
upstream.once('error', () => {
|
|
337
|
+
if (!connected) {
|
|
338
|
+
socket.end('HTTP/1.1 502 Bad Gateway\r\nConnection: close\r\n\r\n');
|
|
339
|
+
}
|
|
340
|
+
else {
|
|
341
|
+
socket.destroy();
|
|
342
|
+
}
|
|
343
|
+
});
|
|
344
|
+
socket.once('close', () => upstream.destroy());
|
|
345
|
+
upstream.once('close', () => socket.destroy());
|
|
346
|
+
}
|
|
347
|
+
async handle(request, response) {
|
|
348
|
+
const hostname = request.socket
|
|
349
|
+
.pfGithubHost;
|
|
350
|
+
if (!hostname || !ALLOWED_HOSTS.has(hostname)) {
|
|
351
|
+
this.logRefusal('unknown', 'tunnel-target', hostname ?? 'unknown');
|
|
352
|
+
reply(response, 403, 'GitHub proxy target is not permitted.');
|
|
353
|
+
return;
|
|
354
|
+
}
|
|
355
|
+
const path = decodedPath(request);
|
|
356
|
+
if (!path) {
|
|
357
|
+
reply(response, 400, 'Malformed GitHub request path.');
|
|
358
|
+
return;
|
|
359
|
+
}
|
|
360
|
+
if (PASS_THROUGH_HOSTS.has(hostname)) {
|
|
361
|
+
this.forward(hostname, request, response, Buffer.alloc(0), undefined);
|
|
362
|
+
return;
|
|
363
|
+
}
|
|
364
|
+
const gitRequest = hostname === 'github.com' &&
|
|
365
|
+
/\.git\/(?:info\/refs|git-upload-pack|git-receive-pack)$/i.test(path);
|
|
366
|
+
const sentinel = gitRequest
|
|
367
|
+
? basicSentinel(request.headers.authorization)
|
|
368
|
+
: bearerSentinel(request.headers.authorization);
|
|
369
|
+
if (!sentinel) {
|
|
370
|
+
this.logRefusal('unknown', 'missing-sentinel', path);
|
|
371
|
+
reply(response, 401, 'GitHub session sentinel is required.', gitRequest);
|
|
372
|
+
return;
|
|
373
|
+
}
|
|
374
|
+
const session = this.options.findSession(sentinel);
|
|
375
|
+
if (!session || !safeEquals(session.sentinel, sentinel)) {
|
|
376
|
+
this.logRefusal('unknown', 'invalid-sentinel', path);
|
|
377
|
+
reply(response, 403, 'GitHub session sentinel is invalid.');
|
|
378
|
+
return;
|
|
379
|
+
}
|
|
380
|
+
if (hostname === 'github.com' && !gitRequest) {
|
|
381
|
+
this.refused(session.sessionId, 'github-path', path, response, 'GitHub request refused: github.com is limited to Git smart HTTP.');
|
|
382
|
+
return;
|
|
383
|
+
}
|
|
384
|
+
const repository = repositoryFromPath(hostname, path);
|
|
385
|
+
if (repository &&
|
|
386
|
+
!session.credential.repositories.some((item) => item.fullName.toLowerCase() === repository.toLowerCase())) {
|
|
387
|
+
this.refused(session.sessionId, 'repository-grant', path, response, 'Repository is outside this agent grant.');
|
|
388
|
+
return;
|
|
389
|
+
}
|
|
390
|
+
if (hostname === 'api.github.com' && path !== '/graphql' && !repository) {
|
|
391
|
+
this.refused(session.sessionId, 'repository-grant', path, response, 'GitHub API request is not scoped to a granted repository.');
|
|
392
|
+
return;
|
|
393
|
+
}
|
|
394
|
+
const restReason = hostname === 'api.github.com'
|
|
395
|
+
? restRefusal(request.method ?? 'GET', path)
|
|
396
|
+
: null;
|
|
397
|
+
if (restReason) {
|
|
398
|
+
this.refused(session.sessionId, 'rest-policy', path, response, restReason);
|
|
399
|
+
return;
|
|
400
|
+
}
|
|
401
|
+
const receivePack = /\.git\/git-receive-pack$/i.test(path);
|
|
402
|
+
let body;
|
|
403
|
+
try {
|
|
404
|
+
body = receivePack
|
|
405
|
+
? await readReceivePackPrefix(request)
|
|
406
|
+
: await readBody(request);
|
|
407
|
+
}
|
|
408
|
+
catch (error) {
|
|
409
|
+
reply(response, 413, error instanceof Error ? error.message : String(error));
|
|
410
|
+
return;
|
|
411
|
+
}
|
|
412
|
+
if (path === '/graphql') {
|
|
413
|
+
if (request.method !== 'POST' ||
|
|
414
|
+
![undefined, 'identity'].includes(request.headers['content-encoding'])) {
|
|
415
|
+
this.refused(session.sessionId, 'graphql-policy', path, response, 'GitHub GraphQL request refused: the body cannot be inspected.');
|
|
416
|
+
return;
|
|
417
|
+
}
|
|
418
|
+
const text = graphQLPolicyText(body);
|
|
419
|
+
const mutation = GRAPHQL_REFUSED_MUTATIONS.find((name) => new RegExp(`\\b${name}\\b`).test(text));
|
|
420
|
+
if (mutation) {
|
|
421
|
+
this.refused(session.sessionId, 'graphql-policy', path, response, `GitHub GraphQL mutation refused: ${mutation} is not permitted.`);
|
|
422
|
+
return;
|
|
423
|
+
}
|
|
424
|
+
}
|
|
425
|
+
if (receivePack) {
|
|
426
|
+
const reason = receivePackRefusal(body, session.sessionId);
|
|
427
|
+
if (reason) {
|
|
428
|
+
this.refused(session.sessionId, 'git-receive-pack', path, response, reason);
|
|
429
|
+
return;
|
|
430
|
+
}
|
|
431
|
+
}
|
|
432
|
+
const authorization = gitRequest
|
|
433
|
+
? `Basic ${Buffer.from(`x-access-token:${session.credential.token}`).toString('base64')}`
|
|
434
|
+
: `Bearer ${session.credential.token}`;
|
|
435
|
+
this.forward(hostname, request, response, body, authorization, receivePack);
|
|
436
|
+
}
|
|
437
|
+
forward(hostname, request, response, body, authorization, pipeRemainder = false) {
|
|
438
|
+
const headers = forwardedHeaders(request.headers);
|
|
439
|
+
headers.host = hostname;
|
|
440
|
+
if (authorization)
|
|
441
|
+
headers.authorization = authorization;
|
|
442
|
+
if (body.length > 0 && !pipeRemainder) {
|
|
443
|
+
headers['content-length'] = String(body.length);
|
|
444
|
+
}
|
|
445
|
+
const upstream = (0, node_https_1.request)({
|
|
446
|
+
hostname,
|
|
447
|
+
port: 443,
|
|
448
|
+
method: request.method,
|
|
449
|
+
path: request.url,
|
|
450
|
+
headers,
|
|
451
|
+
...this.options.resolveUpstream?.(hostname)
|
|
452
|
+
}, (upstreamResponse) => {
|
|
453
|
+
const responseHeaders = forwardedHeaders(upstreamResponse.headers);
|
|
454
|
+
responseHeaders['cache-control'] = 'no-store';
|
|
455
|
+
response.writeHead(upstreamResponse.statusCode ?? 502, responseHeaders);
|
|
456
|
+
upstreamResponse.pipe(response);
|
|
457
|
+
});
|
|
458
|
+
upstream.once('error', (error) => {
|
|
459
|
+
if (!response.headersSent)
|
|
460
|
+
response.writeHead(502);
|
|
461
|
+
response.end(error.message);
|
|
462
|
+
});
|
|
463
|
+
if (pipeRemainder) {
|
|
464
|
+
if (body.length > 0)
|
|
465
|
+
upstream.write(body);
|
|
466
|
+
request.pipe(upstream);
|
|
467
|
+
}
|
|
468
|
+
else if (body.length > 0)
|
|
469
|
+
upstream.end(body);
|
|
470
|
+
else if (request.readableEnded)
|
|
471
|
+
upstream.end();
|
|
472
|
+
else
|
|
473
|
+
request.pipe(upstream);
|
|
474
|
+
}
|
|
475
|
+
}
|
|
476
|
+
exports.GitHubTlsProxy = GitHubTlsProxy;
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { type RequestListener, type Server } from 'node:http';
|
|
2
|
+
import type { WorkspaceProvider, WorkspaceRequest } from './ports';
|
|
3
|
+
import type { LeasedGitHubCredential } from './types';
|
|
4
|
+
export interface GitHubWorkspaceOptions {
|
|
5
|
+
root: string;
|
|
6
|
+
port?: number;
|
|
7
|
+
createServer?: (handler: RequestListener) => Server;
|
|
8
|
+
logger?: Pick<Console, 'warn'>;
|
|
9
|
+
directUrls?: string[];
|
|
10
|
+
}
|
|
11
|
+
/** Bare clone plus one worktree per session, adapted from 20x. */
|
|
12
|
+
export declare class GitHubWorkspaceProvider implements WorkspaceProvider {
|
|
13
|
+
private readonly options;
|
|
14
|
+
private readonly sessions;
|
|
15
|
+
private readonly gitEnvironment;
|
|
16
|
+
private server;
|
|
17
|
+
private port;
|
|
18
|
+
private serverStart;
|
|
19
|
+
private linkSecret;
|
|
20
|
+
private caCertificatePath;
|
|
21
|
+
private readonly restartNotedSessions;
|
|
22
|
+
private readonly restoredWorktreeNotes;
|
|
23
|
+
private readonly preparedWorktrees;
|
|
24
|
+
private readonly directHosts;
|
|
25
|
+
constructor(options: GitHubWorkspaceOptions);
|
|
26
|
+
private rememberDirectUrls;
|
|
27
|
+
private runGit;
|
|
28
|
+
/**
|
|
29
|
+
* A harness crash must not erase or recreate a session checkout. Accept an
|
|
30
|
+
* existing path only when Git proves that it is the exact worktree this
|
|
31
|
+
* provider would have created: same canonical root, same bare repository,
|
|
32
|
+
* and same per-session branch. Anything else fails closed.
|
|
33
|
+
*/
|
|
34
|
+
private canReuseWorktree;
|
|
35
|
+
private dirtyFileCount;
|
|
36
|
+
private worktreesForSession;
|
|
37
|
+
private failedPushMarker;
|
|
38
|
+
private readFailedPushes;
|
|
39
|
+
private writeFailedPushes;
|
|
40
|
+
setCredential(sessionId: string, credential: LeasedGitHubCredential): void;
|
|
41
|
+
private loadOrCreateLinkSecret;
|
|
42
|
+
private sentinelFor;
|
|
43
|
+
clearCredential(sessionId: string): void;
|
|
44
|
+
private sessionPath;
|
|
45
|
+
private ensureServer;
|
|
46
|
+
private startServer;
|
|
47
|
+
prepare(request: WorkspaceRequest): Promise<string>;
|
|
48
|
+
getEnvironment(sessionId: string, directUrls?: string[]): Record<string, string>;
|
|
49
|
+
takeTurnNote(sessionId: string): string | null;
|
|
50
|
+
preserveAtTurnEnd(sessionId: string): Promise<boolean>;
|
|
51
|
+
release(sessionId: string, options?: {
|
|
52
|
+
force?: boolean;
|
|
53
|
+
}): Promise<boolean>;
|
|
54
|
+
stop(): Promise<void>;
|
|
55
|
+
}
|