@peakflo/agent-harness 0.2.9 → 0.2.10-rc.2

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.
@@ -21,6 +21,7 @@ const string_decoder_1 = require("string_decoder");
21
21
  const util_1 = require("util");
22
22
  const coding_agent_adapter_1 = require("./coding-agent-adapter");
23
23
  const model_gateway_1 = require("./model-gateway");
24
+ const vendor_environment_1 = require("./vendor-environment");
24
25
  const cold_start_steps_1 = require("../runtime/cold-start-steps");
25
26
  const dev_server_guard_1 = require("../runtime/dev-server-guard");
26
27
  const execFileAsync = (0, util_1.promisify)(child_process_1.execFile);
@@ -66,6 +67,7 @@ function findPiNodeRuntime(platform, electronExecutable, pathExists = fs_1.exist
66
67
  */
67
68
  exports.PI_MCP_SERVER_ALIASES = {
68
69
  '[Workflo] Organisation Workspace': 'workflo',
70
+ '[Spaces] Organisation Workspace': 'workflo',
69
71
  '[Workflo] MCP Dev Server': 'workflo-dev'
70
72
  };
71
73
  function slugifyPiName(value, maxLength) {
@@ -428,6 +430,7 @@ class PiAdapter {
428
430
  // precedence over its config, which would undo `directTools: false` and
429
431
  // recreate overlong provider-facing names.
430
432
  delete env.MCP_DIRECT_TOOLS;
433
+ env.GIT_SSH_COMMAND = vendor_environment_1.DISABLED_GIT_SSH_COMMAND;
431
434
  return env;
432
435
  }
433
436
  buildSkillFiles(config) {
@@ -0,0 +1,12 @@
1
+ import type { SessionConfig } from './coding-agent-adapter';
2
+ export declare function allowedVendorSecretKey(key: string): boolean;
3
+ /** Remove folders for vendor sessions that have not been used for 90 days. */
4
+ export declare function sweepVendorConfigDirs(now?: number): number;
5
+ export declare function vendorConfigDir(kind: 'claude' | 'codex' | 'cursor', config: SessionConfig, sourceDir: string, authFiles: readonly string[]): string;
6
+ /** Keep Claude API-key turns away from a subscription credential link. */
7
+ export declare function claudeVendorConfigDir(config: SessionConfig): string;
8
+ /** The isolated Codex home can use only the owner's file-based login. */
9
+ export declare function codexVendorConfigDir(config: SessionConfig, useApiKey: boolean): string;
10
+ export declare function isolateVendorHome(env: NodeJS.ProcessEnv, directory: string): void;
11
+ export declare const DISABLED_GIT_SSH_COMMAND: string;
12
+ export declare function vendorEnvironment(config: SessionConfig, auth?: Record<string, string | undefined>): NodeJS.ProcessEnv;
@@ -0,0 +1,186 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.DISABLED_GIT_SSH_COMMAND = void 0;
4
+ exports.allowedVendorSecretKey = allowedVendorSecretKey;
5
+ exports.sweepVendorConfigDirs = sweepVendorConfigDirs;
6
+ exports.vendorConfigDir = vendorConfigDir;
7
+ exports.claudeVendorConfigDir = claudeVendorConfigDir;
8
+ exports.codexVendorConfigDir = codexVendorConfigDir;
9
+ exports.isolateVendorHome = isolateVendorHome;
10
+ exports.vendorEnvironment = vendorEnvironment;
11
+ const node_fs_1 = require("node:fs");
12
+ const node_os_1 = require("node:os");
13
+ const node_path_1 = require("node:path");
14
+ // A vendor CLI runs commands on behalf of the agent. Do not give it the
15
+ // runner's control-plane credentials or the owner's GitHub login.
16
+ const HOST_KEYS = [
17
+ 'PATH',
18
+ 'USER',
19
+ 'TMPDIR',
20
+ 'TMP',
21
+ 'TEMP',
22
+ 'SHELL',
23
+ 'LANG',
24
+ 'LC_ALL',
25
+ 'LC_CTYPE',
26
+ 'TERM',
27
+ 'SYSTEMROOT',
28
+ 'WINDIR',
29
+ 'COMSPEC',
30
+ 'PATHEXT'
31
+ ];
32
+ const SESSION_KEYS = [
33
+ 'HTTPS_PROXY',
34
+ 'https_proxy',
35
+ 'NO_PROXY',
36
+ 'no_proxy',
37
+ 'SSL_CERT_FILE',
38
+ 'GIT_SSL_CAINFO',
39
+ 'CURL_CA_BUNDLE',
40
+ 'NODE_EXTRA_CA_CERTS',
41
+ 'GH_TOKEN',
42
+ 'GH_CONFIG_DIR'
43
+ ];
44
+ const FORBIDDEN = /^(?:PF_|PEAKFLO_|GH_|GIT_|GITHUB_TOKEN$|SSH_|HOME$|USERPROFILE$|CODEX_HOME$|CLAUDE_CONFIG_DIR$|CLAUDE_SECURESTORAGE_CONFIG_DIR$|XDG_CONFIG_HOME$|APPDATA$|LOCALAPPDATA$|PATH$|HTTPS?_PROXY$|NO_PROXY$|SSL_CERT_FILE$|CURL_CA_BUNDLE$|NODE_EXTRA_CA_CERTS$|NODE_OPTIONS$|LD_PRELOAD$|DYLD_)/i;
45
+ function allowedVendorSecretKey(key) {
46
+ return !FORBIDDEN.test(key);
47
+ }
48
+ // Persist across harness restarts so native vendor sessions can resume.
49
+ // Each runner OS account owns this directory; it is not under the worktree.
50
+ const configRoot = process.env.NODE_ENV === 'test'
51
+ ? (0, node_fs_1.mkdtempSync)((0, node_path_1.join)((0, node_os_1.tmpdir)(), 'pf-vendor-config-test-'))
52
+ : (0, node_path_1.join)((0, node_os_1.homedir)(), '.pf-agent-harness', 'vendor-config');
53
+ if (process.env.NODE_ENV === 'test') {
54
+ process.once('exit', () => (0, node_fs_1.rmSync)(configRoot, { recursive: true, force: true }));
55
+ }
56
+ const VENDOR_CONFIG_MAX_AGE_MS = 90 * 24 * 60 * 60 * 1000;
57
+ /** Remove folders for vendor sessions that have not been used for 90 days. */
58
+ function sweepVendorConfigDirs(now = Date.now()) {
59
+ if (!(0, node_fs_1.existsSync)(configRoot) || !(0, node_fs_1.lstatSync)(configRoot).isDirectory())
60
+ return 0;
61
+ let removed = 0;
62
+ for (const kind of ['claude', 'codex', 'cursor']) {
63
+ const kindRoot = (0, node_path_1.join)(configRoot, kind);
64
+ if (!(0, node_fs_1.existsSync)(kindRoot) || !(0, node_fs_1.lstatSync)(kindRoot).isDirectory())
65
+ continue;
66
+ for (const entry of (0, node_fs_1.readdirSync)(kindRoot, { withFileTypes: true })) {
67
+ if (!entry.isDirectory())
68
+ continue;
69
+ const taskRoot = (0, node_path_1.join)(kindRoot, entry.name);
70
+ if ((0, node_fs_1.lstatSync)(taskRoot).mtimeMs >= now - VENDOR_CONFIG_MAX_AGE_MS)
71
+ continue;
72
+ (0, node_fs_1.rmSync)(taskRoot, { recursive: true, force: true });
73
+ removed++;
74
+ }
75
+ }
76
+ return removed;
77
+ }
78
+ function vendorConfigDir(kind, config, sourceDir, authFiles) {
79
+ const directory = (0, node_path_1.join)(configRoot, kind, config.taskId.replace(/[^a-zA-Z0-9_-]/g, '_'));
80
+ (0, node_fs_1.mkdirSync)(configRoot, { recursive: true, mode: 0o700 });
81
+ (0, node_fs_1.chmodSync)(configRoot, 0o700);
82
+ (0, node_fs_1.mkdirSync)(directory, { recursive: true, mode: 0o700 });
83
+ (0, node_fs_1.chmodSync)(directory, 0o700);
84
+ for (const name of authFiles) {
85
+ const source = (0, node_path_1.resolve)(sourceDir, name);
86
+ const target = (0, node_path_1.join)(directory, name);
87
+ if ((0, node_fs_1.existsSync)(source)) {
88
+ // Codex CLI 0.163.0 opens auth.json with truncate(true) and writes
89
+ // through this link. A copy would diverge when Codex refreshes login.
90
+ try {
91
+ if ((0, node_fs_1.lstatSync)(target).isSymbolicLink() &&
92
+ (0, node_fs_1.readlinkSync)(target) === source)
93
+ continue;
94
+ (0, node_fs_1.unlinkSync)(target);
95
+ }
96
+ catch (error) {
97
+ if (error.code !== 'ENOENT')
98
+ throw error;
99
+ }
100
+ (0, node_fs_1.symlinkSync)(source, target);
101
+ }
102
+ }
103
+ (0, node_fs_1.utimesSync)(directory, new Date(), new Date());
104
+ return directory;
105
+ }
106
+ /** Keep Claude API-key turns away from a subscription credential link. */
107
+ function claudeVendorConfigDir(config) {
108
+ if (config.authMethod === 'api_key')
109
+ return vendorConfigDir('claude', { ...config, taskId: `${config.taskId}-api-key` }, '', []);
110
+ const ownerDir = (0, node_path_1.resolve)(process.env.CLAUDE_CONFIG_DIR || (0, node_path_1.join)((0, node_os_1.homedir)(), '.claude'));
111
+ return vendorConfigDir('claude', config, process.platform === 'linux' ? ownerDir : '', process.platform === 'linux' ? ['.credentials.json'] : []);
112
+ }
113
+ /** The isolated Codex home can use only the owner's file-based login. */
114
+ function codexVendorConfigDir(config, useApiKey) {
115
+ if (useApiKey)
116
+ return vendorConfigDir('codex', { ...config, taskId: `${config.taskId}-api-key` }, '', []);
117
+ const ownerDir = (0, node_path_1.resolve)(process.env.CODEX_HOME || (0, node_path_1.join)((0, node_os_1.homedir)(), '.codex'));
118
+ const configFile = (0, node_path_1.join)(ownerDir, 'config.toml');
119
+ if ((0, node_fs_1.existsSync)(configFile)) {
120
+ const contents = (0, node_fs_1.readFileSync)(configFile, 'utf8');
121
+ // cli_auth_credentials_store is a top-level TOML key. A key below a
122
+ // table heading is not the CLI auth store setting.
123
+ for (const line of contents.split(/\r?\n/)) {
124
+ const trimmed = line.trim();
125
+ if (trimmed.startsWith('['))
126
+ break;
127
+ const assignment = trimmed.match(/^cli_auth_credentials_store\s*=\s*(.*)$/);
128
+ if (!assignment)
129
+ continue;
130
+ const mode = assignment[1].match(/^['"]([a-z]+)['"](?:\s*(?:#.*)?)?$/)?.[1];
131
+ if (mode !== 'file') {
132
+ throw new Error(`Codex subscription login uses cli_auth_credentials_store=${mode ?? 'unknown'}. ` +
133
+ 'The runner supports file login only. Set cli_auth_credentials_store="file" and run codex login, or use an API key.');
134
+ }
135
+ }
136
+ }
137
+ if (!(0, node_fs_1.existsSync)((0, node_path_1.join)(ownerDir, 'auth.json'))) {
138
+ throw new Error('Codex file login was not found. Run codex login with cli_auth_credentials_store="file", or use an API key. Keyring and auto login cannot be shared with an isolated CODEX_HOME.');
139
+ }
140
+ return vendorConfigDir('codex', config, ownerDir, ['auth.json']);
141
+ }
142
+ function isolateVendorHome(env, directory) {
143
+ env.HOME = directory;
144
+ env.USERPROFILE = directory;
145
+ env.XDG_CONFIG_HOME = directory;
146
+ env.APPDATA = directory;
147
+ env.LOCALAPPDATA = directory;
148
+ }
149
+ exports.DISABLED_GIT_SSH_COMMAND = `ssh -F ${process.platform === 'win32' ? 'NUL' : '/dev/null'} -o IdentitiesOnly=yes -o IdentityFile=${process.platform === 'win32' ? 'NUL' : '/dev/null'} -o IdentityAgent=none`;
150
+ function vendorEnvironment(config, auth = {}) {
151
+ const env = {};
152
+ for (const key of HOST_KEYS) {
153
+ if (process.env[key])
154
+ env[key] = process.env[key];
155
+ }
156
+ for (const key of SESSION_KEYS) {
157
+ if (config.environment?.[key])
158
+ env[key] = config.environment[key];
159
+ }
160
+ // A missing guard environment must never select the owner's gh login.
161
+ if (!env.GH_CONFIG_DIR) {
162
+ env.GH_CONFIG_DIR = (0, node_fs_1.mkdtempSync)((0, node_path_1.join)((0, node_os_1.tmpdir)(), 'pf-vendor-gh-'));
163
+ }
164
+ else {
165
+ (0, node_fs_1.mkdirSync)(env.GH_CONFIG_DIR, { recursive: true, mode: 0o700 });
166
+ }
167
+ for (const [key, value] of Object.entries(config.secretEnvVars ?? {})) {
168
+ if (allowedVendorSecretKey(key))
169
+ env[key] = value;
170
+ }
171
+ for (const [key, value] of Object.entries(auth)) {
172
+ if (value && allowedVendorSecretKey(key))
173
+ env[key] = value;
174
+ }
175
+ env.GIT_CONFIG_GLOBAL = process.platform === 'win32' ? 'NUL' : '/dev/null';
176
+ env.GIT_CONFIG_NOSYSTEM = '1';
177
+ env.GIT_TERMINAL_PROMPT = '0';
178
+ env.GIT_SSH_COMMAND = exports.DISABLED_GIT_SSH_COMMAND;
179
+ // A non-repository turn can still create a local Git repository and commit.
180
+ // Keep the identity independent of the owner's global Git configuration.
181
+ env.GIT_AUTHOR_NAME = 'Peakflo Agent';
182
+ env.GIT_AUTHOR_EMAIL = 'agent@peakflo.bot';
183
+ env.GIT_COMMITTER_NAME = 'Peakflo Agent';
184
+ env.GIT_COMMITTER_EMAIL = 'agent@peakflo.bot';
185
+ return env;
186
+ }
package/dist/cli.js CHANGED
@@ -40,12 +40,17 @@ const credential_store_1 = require("./credential-store");
40
40
  const harness_1 = require("./harness");
41
41
  const node_floor_1 = require("./node-floor");
42
42
  const options_1 = require("./options");
43
+ const service_1 = require("./service");
43
44
  const version_1 = require("./version");
44
45
  const USAGE = `pf-agent-harness — headless Peakflo coding-agent harness
45
46
 
46
47
  Usage:
47
48
  pf-agent-harness start [options]
48
49
  pf-agent-harness version
50
+ pf-agent-harness service install [start options]
51
+ pf-agent-harness service status
52
+ pf-agent-harness service repair
53
+ pf-agent-harness service uninstall
49
54
 
50
55
  Options:
51
56
  --control-plane <url> workflow-api base url (env PF_CONTROL_PLANE_URL)
@@ -76,6 +81,8 @@ Options:
76
81
  --max-sessions <n> concurrent sessions (env PF_MAX_CONCURRENT_SESSIONS,
77
82
  default 10 for desktop/self-hosted; sandbox is configured by control plane)
78
83
  --labels <a,b> routing labels (env PF_RUNNER_LABELS)
84
+ --vendor-harnesses <list> allow codex,claude-code,cursor on this machine
85
+ (env PF_VENDOR_HARNESSES; default none)
79
86
  --no-harness-discovery do not look for Codex, Claude Code, Cursor,
80
87
  Pi and OpenCode on this machine, and report
81
88
  none (env PF_HARNESS_DISCOVERY=0). On by
@@ -88,6 +95,19 @@ PF_ALLOW_UNSUPPORTED_NODE=1 starts anyway, at your own risk.
88
95
  In a managed sandbox pf-bootstrap supplies these through the environment, so
89
96
  the enrolment token never appears in /proc/<pid>/cmdline.
90
97
  `;
98
+ const serviceStart = (0, service_1.isServiceStart)(process.argv.slice(2));
99
+ function stopPermanentServiceFailure(reason, linuxCode) {
100
+ try {
101
+ (0, service_1.recordServiceFailure)(reason);
102
+ }
103
+ catch {
104
+ /* exit even if the disk is full */
105
+ }
106
+ process.stderr.write(`Service stopped: ${reason}.\n`);
107
+ // launchd can prevent a restart on a successful exit. systemd uses
108
+ // RestartPreventExitStatus for the two permanent error codes.
109
+ process.exit((0, service_1.permanentServiceExitCode)(process.platform, linuxCode));
110
+ }
91
111
  /**
92
112
  * Read `--flag value` from argv. A tiny duplicate of the reader in options.ts,
93
113
  * because the login decision has to be made BEFORE `parseOptions` runs — that
@@ -122,6 +142,14 @@ async function main() {
122
142
  })}\n`);
123
143
  return;
124
144
  }
145
+ if (command === 'service') {
146
+ await (0, service_1.runServiceCommand)(argv.slice(1), {
147
+ nodePath: process.execPath,
148
+ cliPath: __filename,
149
+ env: process.env
150
+ });
151
+ return;
152
+ }
125
153
  if (command !== 'start') {
126
154
  process.stderr.write(`Unknown command: ${command}\n\n${USAGE}`);
127
155
  process.exitCode = 2;
@@ -141,6 +169,8 @@ async function main() {
141
169
  env: process.env
142
170
  });
143
171
  if (nodeFloor.action === 'refuse') {
172
+ if (serviceStart)
173
+ throw new options_1.OptionsError(nodeFloor.message);
144
174
  process.stderr.write(`${nodeFloor.message}\n`);
145
175
  process.exit(1);
146
176
  }
@@ -165,7 +195,7 @@ async function main() {
165
195
  // `--token=x` counts as a token, hence readOptionValue rather than indexOf.
166
196
  const hasToken = Boolean(readOptionValue(startArgs, 'token') ?? process.env.PF_ENROLMENT_TOKEN);
167
197
  const interactive = Boolean(process.stdin.isTTY && process.stdout.isTTY);
168
- if (!hasToken && (wantsLogin || interactive)) {
198
+ if (!serviceStart && !hasToken && (wantsLogin || interactive)) {
169
199
  const { enrolViaBrowser } = await Promise.resolve().then(() => __importStar(require('./enrol/browser-enrol')));
170
200
  const token = await enrolViaBrowser({
171
201
  controlPlaneUrl: readOptionValue(startArgs, 'control-plane') ??
@@ -182,6 +212,11 @@ async function main() {
182
212
  process.env.PF_ENROLMENT_TOKEN = token;
183
213
  }
184
214
  const options = (0, options_1.parseOptions)(startArgs, process.env);
215
+ new service_1.ServiceManager({
216
+ nodePath: process.execPath,
217
+ cliPath: __filename,
218
+ env: process.env
219
+ }).assertNoDuplicateRunner(options);
185
220
  const handedOffCredential = (0, credential_store_1.takeHandedOffCredential)(process.env);
186
221
  const resolveAdapter = (0, adapter_resolver_1.createAdapterResolver)(options.runtime, options.workspaceRoot);
187
222
  const harness = new harness_1.Harness(options, {
@@ -207,6 +242,8 @@ async function main() {
207
242
  process.on('SIGTERM', () => shutdown('SIGTERM'));
208
243
  process.on('SIGINT', () => shutdown('SIGINT'));
209
244
  await harness.start();
245
+ if (serviceStart)
246
+ (0, service_1.clearServiceFailure)();
210
247
  // BLOCK ON THE LEASE LOOP, and let it decide when the process is done.
211
248
  //
212
249
  // The Phase 0 skeleton relied on the health socket keeping the event loop
@@ -216,9 +253,13 @@ async function main() {
216
253
  // build, so the exit code carries the reason.
217
254
  try {
218
255
  await harness.wait();
256
+ if (serviceStart && process.platform === 'darwin')
257
+ process.exit(75);
219
258
  }
220
259
  catch (error) {
221
260
  if (error instanceof harness_1.UpgradeRequestedError) {
261
+ if (serviceStart)
262
+ stopPermanentServiceFailure('harness upgrade required', 78);
222
263
  process.exit(contract_1.HARNESS_UPGRADE_EXIT_CODE);
223
264
  }
224
265
  throw error;
@@ -231,9 +272,22 @@ main().catch((error) => {
231
272
  // ever. A sandbox that hangs is worse than one that dies: the control plane
232
273
  // cannot recycle it, so the version gate would never take effect.
233
274
  if (error instanceof options_1.OptionsError) {
275
+ if (serviceStart)
276
+ stopPermanentServiceFailure('invalid service options', 2);
234
277
  process.stderr.write(`${error.message}\n\n${USAGE}`);
235
278
  process.exit(2);
236
279
  }
280
+ if (serviceStart && error instanceof harness_1.StoredCredentialUnavailableError) {
281
+ stopPermanentServiceFailure('runner credential unavailable', 78);
282
+ }
283
+ if (serviceStart && error instanceof harness_1.VersionRefusedError) {
284
+ stopPermanentServiceFailure('harness version or protocol refused', 78);
285
+ }
286
+ if (serviceStart) {
287
+ const reason = (0, service_1.permanentStartupFailure)(error);
288
+ if (reason)
289
+ stopPermanentServiceFailure(reason, 78);
290
+ }
237
291
  const message = error instanceof Error ? error.stack : String(error);
238
292
  process.stderr.write(`pf-agent-harness failed to start:\n${message}\n`);
239
293
  process.exit(1);
@@ -7,6 +7,7 @@ exports.installPushGuard = installPushGuard;
7
7
  const node_fs_1 = require("node:fs");
8
8
  const promises_1 = require("node:fs/promises");
9
9
  const node_path_1 = require("node:path");
10
+ const vendor_environment_1 = require("./adapters/vendor-environment");
10
11
  exports.MERGE_REFUSAL = 'GitHub merge refused: agents may open pull requests but must not merge them.';
11
12
  let guardPathState;
12
13
  let guardPathQueue = Promise.resolve();
@@ -59,6 +60,9 @@ for (const key of Object.keys(gitEnv)) {
59
60
  if (key.startsWith('GH_') && !['GH_TOKEN', 'GH_CONFIG_DIR'].includes(key)) delete gitEnv[key];
60
61
  }
61
62
  gitEnv.GIT_TERMINAL_PROMPT = '0';
63
+ gitEnv.GIT_CONFIG_GLOBAL = process.platform === 'win32' ? 'NUL' : '/dev/null';
64
+ gitEnv.GIT_CONFIG_NOSYSTEM = '1';
65
+ gitEnv.GIT_SSH_COMMAND = ${JSON.stringify(vendor_environment_1.DISABLED_GIT_SSH_COMMAND)};
62
66
  // The wrapper deliberately removes caller-controlled GIT_* overrides, but
63
67
  // Git's libcurl requires GIT_SSL_CAINFO to trust the harness TLS proxy. Pin it
64
68
  // to the harness-owned bundle instead of accepting the caller's value.
@@ -152,6 +156,7 @@ for (const key of Object.keys(cleanEnv)) {
152
156
  if (key.startsWith('GH_') && !['GH_TOKEN', 'GH_CONFIG_DIR'].includes(key)) delete cleanEnv[key];
153
157
  }
154
158
  cleanEnv.GIT_TERMINAL_PROMPT = '0';
159
+ cleanEnv.GIT_SSH_COMMAND = ${JSON.stringify(vendor_environment_1.DISABLED_GIT_SSH_COMMAND)};
155
160
  // Inspect all args, including endpoint=... and flags before the subcommand.
156
161
  const decodedArgs = args.map(arg => {
157
162
  try { return decodeURIComponent(arg); } catch { return arg; }
@@ -8,6 +8,7 @@ const promises_1 = require("node:fs/promises");
8
8
  const node_http_1 = require("node:http");
9
9
  const node_path_1 = require("node:path");
10
10
  const node_util_1 = require("node:util");
11
+ const vendor_environment_1 = require("./adapters/vendor-environment");
11
12
  const github_command_guards_1 = require("./github-command-guards");
12
13
  const github_proxy_1 = require("./github-proxy");
13
14
  const github_proxy_certificates_1 = require("./github-proxy-certificates");
@@ -393,6 +394,31 @@ class GitHubWorkspaceProvider {
393
394
  await this.runGit(['config', '--worktree', 'core.hooksPath', hooks], {
394
395
  cwd: worktree
395
396
  });
397
+ // A direct /usr/bin/git call also uses this worktree configuration.
398
+ // Rewrite GitHub SSH addresses before transport selection and disable
399
+ // owner SSH keys for any address that is not rewritten.
400
+ await this.runGit([
401
+ 'config',
402
+ '--worktree',
403
+ '--unset-all',
404
+ 'url.https://github.com/.insteadOf'
405
+ ], {
406
+ cwd: worktree
407
+ }).catch(() => undefined);
408
+ for (const sshPrefix of ['git@github.com:', 'ssh://git@github.com/']) {
409
+ await this.runGit([
410
+ 'config',
411
+ '--worktree',
412
+ '--add',
413
+ 'url.https://github.com/.insteadOf',
414
+ sshPrefix
415
+ ], {
416
+ cwd: worktree
417
+ });
418
+ }
419
+ await this.runGit(['config', '--worktree', 'core.sshCommand', vendor_environment_1.DISABLED_GIT_SSH_COMMAND], { cwd: worktree });
420
+ await this.runGit(['config', '--worktree', 'user.name', 'Peakflo Agent'], { cwd: worktree });
421
+ await this.runGit(['config', '--worktree', 'user.email', 'agent@peakflo.bot'], { cwd: worktree });
396
422
  request.onRepositoryStatus?.({
397
423
  fullName: repository.fullName,
398
424
  status: 'checkedOut'
@@ -412,7 +438,7 @@ class GitHubWorkspaceProvider {
412
438
  '# GitHub repository access',
413
439
  '',
414
440
  'Each repository is a child directory. Commit and push only the agent/<session-id> branch.',
415
- 'GitHub is reachable through a local proxy; use gh and git as usual. The token in GH_TOKEN is a session sentinel that works only inside this sandbox. Merges and pushes outside agent/<session-id> are refused.',
441
+ '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.',
416
442
  ''
417
443
  ].join('\n');
418
444
  await (0, promises_1.writeFile)((0, node_path_1.join)(sessionRoot, 'AGENTS.md'), instructions, {
@@ -443,7 +469,8 @@ class GitHubWorkspaceProvider {
443
469
  CURL_CA_BUNDLE: this.caCertificatePath,
444
470
  NODE_EXTRA_CA_CERTS: this.caCertificatePath,
445
471
  GH_TOKEN: session.sentinel,
446
- GH_CONFIG_DIR: (0, node_path_1.join)(this.sessionPath(sessionId), '.gh')
472
+ GH_CONFIG_DIR: (0, node_path_1.join)(this.sessionPath(sessionId), '.gh'),
473
+ GIT_SSH_COMMAND: vendor_environment_1.DISABLED_GIT_SSH_COMMAND
447
474
  };
448
475
  }
449
476
  takeTurnNote(sessionId) {
@@ -454,7 +481,7 @@ class GitHubWorkspaceProvider {
454
481
  this.restartNotedSessions.add(sessionId);
455
482
  const restored = this.restoredWorktreeNotes.get(sessionId);
456
483
  return [
457
- 'Harness restarted; GitHub access is through the local proxy — use gh/git as usual.',
484
+ 'Harness restarted; use gh and git only in this session worktree through the local proxy.',
458
485
  restored
459
486
  ]
460
487
  .filter(Boolean)
package/dist/harness.d.ts CHANGED
@@ -49,6 +49,10 @@ export declare function harnessContentKey(harnesses: readonly RunnerHarness[]):
49
49
  export declare class VersionRefusedError extends Error {
50
50
  constructor(message: string);
51
51
  }
52
+ /** A service cannot re-enrol without a new one-time token from a user. */
53
+ export declare class StoredCredentialUnavailableError extends Error {
54
+ constructor();
55
+ }
52
56
  /** The sandbox supervisor, not the harness, owns package installation. */
53
57
  export declare class UpgradeRequestedError extends Error {
54
58
  readonly harnessVersion: string;
package/dist/harness.js CHANGED
@@ -1,11 +1,12 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.Harness = exports.UpgradeRequestedError = exports.VersionRefusedError = exports.HARNESS_DISCOVERY_FEATURE = exports.HARNESS_RETRY_DELAYS_MS = exports.HARNESS_DISCOVERY_REFRESH_MS = void 0;
3
+ exports.Harness = exports.UpgradeRequestedError = exports.StoredCredentialUnavailableError = exports.VersionRefusedError = exports.HARNESS_DISCOVERY_FEATURE = exports.HARNESS_RETRY_DELAYS_MS = exports.HARNESS_DISCOVERY_REFRESH_MS = void 0;
4
4
  exports.mergeHarnessReports = mergeHarnessReports;
5
5
  exports.harnessContentKey = harnessContentKey;
6
6
  const node_crypto_1 = require("node:crypto");
7
7
  const node_path_1 = require("node:path");
8
8
  const model_gateway_1 = require("./adapters/model-gateway");
9
+ const vendor_environment_1 = require("./adapters/vendor-environment");
9
10
  const capabilities_1 = require("./capabilities");
10
11
  const contract_1 = require("./contract");
11
12
  const control_plane_1 = require("./control-plane");
@@ -111,6 +112,14 @@ class VersionRefusedError extends Error {
111
112
  }
112
113
  }
113
114
  exports.VersionRefusedError = VersionRefusedError;
115
+ /** A service cannot re-enrol without a new one-time token from a user. */
116
+ class StoredCredentialUnavailableError extends Error {
117
+ constructor() {
118
+ super('No usable stored runner credential. Start the runner in a terminal to sign in again.');
119
+ this.name = 'StoredCredentialUnavailableError';
120
+ }
121
+ }
122
+ exports.StoredCredentialUnavailableError = StoredCredentialUnavailableError;
114
123
  /** The sandbox supervisor, not the harness, owns package installation. */
115
124
  class UpgradeRequestedError extends Error {
116
125
  harnessVersion;
@@ -365,6 +374,16 @@ class Harness {
365
374
  // The health check comes up FIRST, so a probe sees 'starting' rather than a
366
375
  // connection refused while registration is still retrying.
367
376
  await this.health.start();
377
+ try {
378
+ const removed = (0, vendor_environment_1.sweepVendorConfigDirs)();
379
+ if (removed)
380
+ log('Old vendor config folders removed', { removed });
381
+ }
382
+ catch (error) {
383
+ log('Vendor config cleanup failed', {
384
+ reason: error instanceof Error ? error.message : String(error)
385
+ });
386
+ }
368
387
  await this.proxy.start();
369
388
  this.capabilities = await (0, capabilities_1.probeCapabilities)({
370
389
  maxConcurrentSessions: this.options.maxConcurrentSessions,
@@ -373,10 +392,8 @@ class Harness {
373
392
  // Install immediately before model probing can start a backend. The lease
374
393
  // is process-wide and reference-counted, so concurrent Harness instances
375
394
  // share one generic launcher instead of racing to rewrite global PATH.
376
- if (this.options.runtime === 'sandbox') {
377
- const guard = await (0, github_command_guards_1.acquireGhGuardPath)(this.options.workspaceRoot);
378
- this.releaseGitHubGuard = guard.release;
379
- }
395
+ const guard = await (0, github_command_guards_1.acquireGhGuardPath)(this.options.workspaceRoot);
396
+ this.releaseGitHubGuard = guard.release;
380
397
  /*
381
398
  * MODELS ARE PROBED ONCE, AT START-UP — not on every heartbeat, because
382
399
  * enumerating providers starts a Pi discovery process. The catalogue is
@@ -534,8 +551,7 @@ class Harness {
534
551
  // 2. ENROL. The ordinary first-boot path, and the fallback when no usable
535
552
  // stored credential exists (absent, expired or rejected).
536
553
  if (!this.options.enrolmentToken) {
537
- throw new Error('No usable stored runner credential and no enrolment token. Provide ' +
538
- '--token / PF_ENROLMENT_TOKEN for the first registration.');
554
+ throw new StoredCredentialUnavailableError();
539
555
  }
540
556
  // Back-off and the final-status short-circuit both live in the client, so
541
557
  // every route obeys the same retry rule and no route can drift from it.
@@ -945,6 +961,7 @@ class Harness {
945
961
  if (!this.harnessReport) {
946
962
  return;
947
963
  }
964
+ capabilities.vendorHarnesses = this.options.vendorHarnesses ?? [];
948
965
  const blocked = this.addVendorBackends(capabilities);
949
966
  const harnesses = this.harnessReport.map((harness) => {
950
967
  const backend = capabilities.backends.find((candidate) => candidate.name === harness.name);
@@ -1030,6 +1047,14 @@ class Harness {
1030
1047
  continue;
1031
1048
  if (capabilities.backends.some((b) => b.name === harness.name))
1032
1049
  continue;
1050
+ if (!this.options.vendorHarnesses?.includes(harness.name)) {
1051
+ blocked.set(harness.name, 'Not enabled on this machine. Start the runner with --vendor-harnesses ' +
1052
+ harness.name +
1053
+ ' or set PF_VENDOR_HARNESSES=' +
1054
+ harness.name +
1055
+ '.');
1056
+ continue;
1057
+ }
1033
1058
  const reason = blocker(harness.name, this.options.runtime);
1034
1059
  if (reason) {
1035
1060
  blocked.set(harness.name, reason);
package/dist/options.js CHANGED
@@ -4,6 +4,7 @@ exports.OptionsError = void 0;
4
4
  exports.parseOptions = parseOptions;
5
5
  const node_os_1 = require("node:os");
6
6
  const node_path_1 = require("node:path");
7
+ const vendor_backends_1 = require("./vendor-backends");
7
8
  const DEFAULT_HEALTH_PORT = 7777;
8
9
  const DEFAULT_GITHUB_PROXY_PORT = 7780;
9
10
  const DEFAULT_HEARTBEAT_INTERVAL_MS = 30_000;
@@ -141,6 +142,20 @@ function parseLabels(raw) {
141
142
  .filter(Boolean);
142
143
  return labels.length > 0 ? labels : undefined;
143
144
  }
145
+ function parseVendorHarnesses(raw) {
146
+ if (!raw?.trim())
147
+ return [];
148
+ const names = raw
149
+ .split(',')
150
+ .map((name) => name.trim())
151
+ .filter(Boolean);
152
+ for (const name of names) {
153
+ if (!(0, vendor_backends_1.isVendorBackend)(name)) {
154
+ throw new OptionsError(`Invalid vendor harness: '${name}'. Expected codex, claude-code or cursor.`);
155
+ }
156
+ }
157
+ return [...new Set(names)];
158
+ }
144
159
  function parseBoolean(raw, fallback, name) {
145
160
  if (raw === undefined || raw === '')
146
161
  return fallback;
@@ -178,7 +193,7 @@ function parseOptions(argv, env) {
178
193
  throw new OptionsError('Missing control plane url. Pass --control-plane <url> or set PF_CONTROL_PLANE_URL.');
179
194
  }
180
195
  const enrolmentToken = readFlag(argv, 'token') ?? env.PF_ENROLMENT_TOKEN;
181
- if (!enrolmentToken) {
196
+ if (!enrolmentToken && !readBoolFlag(argv, 'stored-credential')) {
182
197
  throw new OptionsError('Missing enrolment token. Pass --token <token> or set PF_ENROLMENT_TOKEN.');
183
198
  }
184
199
  let parsed;
@@ -205,7 +220,7 @@ function parseOptions(argv, env) {
205
220
  const runtime = parseRuntime(readFlag(argv, 'runtime') ?? env.PF_RUNNER_RUNTIME);
206
221
  return {
207
222
  controlPlaneUrl,
208
- enrolmentToken,
223
+ enrolmentToken: enrolmentToken ?? '',
209
224
  healthPort: parsePort(readFlag(argv, 'health-port') ?? env.PF_HARNESS_HEALTH_PORT, DEFAULT_HEALTH_PORT),
210
225
  githubProxyPort: parsePort(readFlag(argv, 'github-proxy-port') ?? env.PF_GITHUB_PROXY_PORT, DEFAULT_GITHUB_PROXY_PORT),
211
226
  autoCommitDirtyWorktree: parseBoolean(readFlag(argv, 'worktree-wip-commit') ?? env.PF_WORKTREE_WIP_COMMIT, runtime === 'sandbox', 'worktree WIP commit flag'),
@@ -219,6 +234,7 @@ function parseOptions(argv, env) {
219
234
  runtime,
220
235
  maxConcurrentSessions: parseConcurrency(readFlag(argv, 'max-sessions') ?? env.PF_MAX_CONCURRENT_SESSIONS, runtime),
221
236
  labels: parseLabels(readFlag(argv, 'labels') ?? env.PF_RUNNER_LABELS),
237
+ vendorHarnesses: parseVendorHarnesses(readFlag(argv, 'vendor-harnesses') ?? env.PF_VENDOR_HARNESSES),
222
238
  // The flag wins over the environment, so one start can turn it off
223
239
  // without editing a service file.
224
240
  harnessDiscovery: readBoolFlag(argv, 'no-harness-discovery')
@@ -17,9 +17,8 @@
17
17
  * adapter default. That is defect G1 — a Permission control that is recorded,
18
18
  * served, rendered, and enforced nowhere.
19
19
  *
20
- * A stored mode keeps the user's choice. Missing mode uses the new-agent
21
- * default, `bypassPermissions`. An unrecognised value fails closed onto
22
- * `'ask'`; it may be a mode from a newer control plane.
20
+ * A stored mode keeps the user's choice. Missing and unrecognised modes
21
+ * fail closed onto 'ask'.
23
22
  *
24
23
  * ---------------------------------------------------------------------------
25
24
  * THE TABLE, AND WHAT IS AND IS NOT FAITHFUL
@@ -18,9 +18,8 @@
18
18
  * adapter default. That is defect G1 — a Permission control that is recorded,
19
19
  * served, rendered, and enforced nowhere.
20
20
  *
21
- * A stored mode keeps the user's choice. Missing mode uses the new-agent
22
- * default, `bypassPermissions`. An unrecognised value fails closed onto
23
- * `'ask'`; it may be a mode from a newer control plane.
21
+ * A stored mode keeps the user's choice. Missing and unrecognised modes
22
+ * fail closed onto 'ask'.
24
23
  *
25
24
  * ---------------------------------------------------------------------------
26
25
  * THE TABLE, AND WHAT IS AND IS NOT FAITHFUL
@@ -92,8 +91,6 @@ exports.claudeCodePermissionMode = claudeCodePermissionMode;
92
91
  */
93
92
  function mapPermissionMode(mode) {
94
93
  switch (mode) {
95
- case null:
96
- case undefined:
97
94
  case 'bypassPermissions':
98
95
  return {
99
96
  permissionMode: 'allow',
@@ -25,6 +25,7 @@ import { LeaseResponse, LeasedTurnInput, ModelMessageInput, TranscriptPartInput,
25
25
  import { KnowledgeToolHost, type KnowledgeControl } from './knowledge-tools';
26
26
  import { McpAuthProxy } from './mcp-auth-proxy';
27
27
  import { type FailedMcpCheck } from './mcp-preflight';
28
+ export declare function approvalTimeoutMs(value: string | undefined): number;
28
29
  export interface SessionRunnerDeps {
29
30
  /** Resolves a backend name from the lease to a working adapter. */
30
31
  getAdapter(backend: string): CodingAgentAdapter;
@@ -347,6 +348,7 @@ export declare class SessionRunner {
347
348
  private openApproval;
348
349
  /** The transcript identity of an ordinary agent question, if one is open. */
349
350
  private openQuestionPartId;
351
+ private openQuestionStartedAt;
350
352
  /**
351
353
  * Expiry of the model token THIS RUNNING BACKEND is holding, when the
352
354
  * control plane reported one — see `LeaseResponse.modelTokenExpiresAt`.