@peakflo/agent-harness 0.2.18-rc.1 → 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.
@@ -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;
@@ -23,6 +23,7 @@ export declare enum MessageRole {
23
23
  }
24
24
  export declare enum MessagePartType {
25
25
  TEXT = "text",
26
+ RETRY = "retry",
26
27
  REASONING = "reasoning",
27
28
  TOOL = "tool",
28
29
  QUESTION = "question",
@@ -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 friendly command-line refusals before backend processes start.
8
- * These launchers are UX only. The TLS proxy is the security boundary and
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. Generated
14
- * launchers read policy from the target worktree, so sharing the launcher is
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 friendly command-line refusals before backend processes start.
16
- * These launchers are UX only. The TLS proxy is the security boundary and
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 = inputBuffer
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. Generated
271
- * launchers read policy from the target worktree, so sharing the launcher is
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
- }
@@ -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;
@@ -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
- async function readBody(request) {
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' && this.options.isDirectHostAllowed?.(hostname)) {
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
- // Some clients do not consistently honor NO_PROXY. Tunnel only the exact
344
- // server-configured direct hosts so their public TLS certificate remains
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, Buffer.alloc(0), undefined);
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
- this.logRefusal('unknown', 'missing-sentinel', path);
393
- reply(response, 401, 'GitHub session sentinel is required.', gitRequest);
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, body, authorization, receivePack);
222
+ this.forward(hostname, request, response, authorization);
458
223
  }
459
- forward(hostname, request, response, body, authorization, pipeRemainder = false) {
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 (pipeRemainder) {
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
  }
@@ -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
- await (0, github_command_guards_1.installPushGuard)(hooks, request.sessionId, repository.defaultBranch, transportUrl);
602
- await this.runGit(['config', '--worktree', 'core.hooksPath', hooks], {
603
- cwd: worktree
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. Commit and push only the agent/<session-id> branch.',
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. Merges and pushes outside agent/<session-id> are refused.',
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.18-rc.1",
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",