@peakflo/agent-harness 0.2.10-rc.3 → 0.2.11-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.
- package/README.md +22 -0
- package/dist/cli.js +34 -3
- package/dist/connect/approval-callback.d.ts +25 -0
- package/dist/connect/approval-callback.js +122 -0
- package/dist/connect/connect.d.ts +95 -0
- package/dist/connect/connect.js +427 -0
- package/dist/harness.js +1 -1
- package/dist/service.d.ts +20 -2
- package/dist/service.js +75 -23
- package/package.json +4 -2
package/README.md
CHANGED
|
@@ -52,6 +52,28 @@ Optional flags include `--health-port <port>`, `--runtime <desktop|self-hosted>`
|
|
|
52
52
|
`--workspace-root <path>`, and `--github-proxy-port <port>`. Run
|
|
53
53
|
`npx @peakflo/agent-harness@rc --help` for the full list.
|
|
54
54
|
|
|
55
|
+
## Connect your own agent in one command
|
|
56
|
+
|
|
57
|
+
The Welcome window in Spaces shows one command for Codex, Claude Code, or
|
|
58
|
+
Cursor, for example:
|
|
59
|
+
|
|
60
|
+
```sh
|
|
61
|
+
npx --yes @peakflo/agent-harness@<version> connect claude-code --connection <id>
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
`connect` checks that the vendor CLI is installed and signed in, opens your
|
|
65
|
+
browser so you can approve this computer (you must be signed in as the same
|
|
66
|
+
user and company), registers the computer as your personal runner for that
|
|
67
|
+
vendor only, and installs a background service for it. The command holds no
|
|
68
|
+
secret: `--connection` is a public id, and the one-time enrolment token is
|
|
69
|
+
fetched only after the approval, with a PKCE verifier that never leaves the
|
|
70
|
+
process. If port 7777 or 7780 is in use, `connect` picks free ports.
|
|
71
|
+
|
|
72
|
+
Each vendor gets its own service and workspace under
|
|
73
|
+
`~/.pf-agent-harness/connect/<vendor>`. Check or remove one with
|
|
74
|
+
`service status --vendor <vendor>` or `service uninstall --vendor <vendor>`.
|
|
75
|
+
`connect` needs macOS or Linux.
|
|
76
|
+
|
|
55
77
|
## Run after you close the terminal
|
|
56
78
|
|
|
57
79
|
The background service supports macOS launchd user agents and Linux systemd
|
package/dist/cli.js
CHANGED
|
@@ -45,12 +45,16 @@ const version_1 = require("./version");
|
|
|
45
45
|
const USAGE = `pf-agent-harness — headless Peakflo coding-agent harness
|
|
46
46
|
|
|
47
47
|
Usage:
|
|
48
|
+
pf-agent-harness connect <codex|claude-code|cursor> --connection <id> [--control-plane <url>]
|
|
49
|
+
one command from the Welcome window: approve
|
|
50
|
+
this computer in the browser, connect the agent
|
|
51
|
+
as your personal agent, run it in the background
|
|
48
52
|
pf-agent-harness start [options]
|
|
49
53
|
pf-agent-harness version
|
|
50
54
|
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
|
|
55
|
+
pf-agent-harness service status [--vendor <name>]
|
|
56
|
+
pf-agent-harness service repair [--vendor <name>]
|
|
57
|
+
pf-agent-harness service uninstall [--vendor <name>]
|
|
54
58
|
|
|
55
59
|
Options:
|
|
56
60
|
--control-plane <url> workflow-api base url (env PF_CONTROL_PLANE_URL)
|
|
@@ -150,6 +154,33 @@ async function main() {
|
|
|
150
154
|
});
|
|
151
155
|
return;
|
|
152
156
|
}
|
|
157
|
+
if (command === 'connect') {
|
|
158
|
+
const floor = (0, node_floor_1.checkNodeFloor)({
|
|
159
|
+
current: process.versions.node,
|
|
160
|
+
floor: (0, version_1.getNodeFloor)(),
|
|
161
|
+
runtime: 'desktop',
|
|
162
|
+
env: process.env
|
|
163
|
+
});
|
|
164
|
+
if (floor.action === 'refuse') {
|
|
165
|
+
process.stderr.write(`${floor.message}\n`);
|
|
166
|
+
process.exit(1);
|
|
167
|
+
}
|
|
168
|
+
const { defaultConnectDeps, parseConnectArgs, runConnect } = await Promise.resolve().then(() => __importStar(require('./connect/connect')));
|
|
169
|
+
try {
|
|
170
|
+
await runConnect(parseConnectArgs(argv.slice(1), process.env), defaultConnectDeps({
|
|
171
|
+
nodePath: process.execPath,
|
|
172
|
+
cliPath: __filename,
|
|
173
|
+
env: process.env
|
|
174
|
+
}));
|
|
175
|
+
}
|
|
176
|
+
catch (error) {
|
|
177
|
+
// A message for a person, not a stack. Never includes the token.
|
|
178
|
+
process.stderr.write(`\n✗ ${error instanceof Error ? error.message : String(error)}\n`);
|
|
179
|
+
process.exit(1);
|
|
180
|
+
}
|
|
181
|
+
// Loopback servers and timers are closed; exit promptly anyway.
|
|
182
|
+
process.exit(0);
|
|
183
|
+
}
|
|
153
184
|
if (command !== 'start') {
|
|
154
185
|
process.stderr.write(`Unknown command: ${command}\n\n${USAGE}`);
|
|
155
186
|
process.exitCode = 2;
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The 127.0.0.1 receiver for the `connect` approval.
|
|
3
|
+
*
|
|
4
|
+
* After the user approves in the browser, the approval page sends
|
|
5
|
+
* `?state=<nonce>&code=<one-time code>` here. The code is useless alone: the
|
|
6
|
+
* exchange also needs the PKCE verifier, which never leaves this process. The
|
|
7
|
+
* `state` nonce is compared in constant time, so another page in the browser
|
|
8
|
+
* cannot feed this process a different approval. No CORS headers are sent.
|
|
9
|
+
*/
|
|
10
|
+
export declare const CALLBACK_PORT_START = 48690;
|
|
11
|
+
export declare const CALLBACK_PORT_END = 48710;
|
|
12
|
+
export declare const APPROVAL_TIMEOUT_MS: number;
|
|
13
|
+
export declare class ApprovalCallback {
|
|
14
|
+
readonly state: string;
|
|
15
|
+
port: number;
|
|
16
|
+
private server;
|
|
17
|
+
private resolve;
|
|
18
|
+
private timer;
|
|
19
|
+
private used;
|
|
20
|
+
start(): Promise<number>;
|
|
21
|
+
private listen;
|
|
22
|
+
wait(timeoutMs?: number): Promise<string>;
|
|
23
|
+
private clearTimer;
|
|
24
|
+
stop(): void;
|
|
25
|
+
}
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* The 127.0.0.1 receiver for the `connect` approval.
|
|
4
|
+
*
|
|
5
|
+
* After the user approves in the browser, the approval page sends
|
|
6
|
+
* `?state=<nonce>&code=<one-time code>` here. The code is useless alone: the
|
|
7
|
+
* exchange also needs the PKCE verifier, which never leaves this process. The
|
|
8
|
+
* `state` nonce is compared in constant time, so another page in the browser
|
|
9
|
+
* cannot feed this process a different approval. No CORS headers are sent.
|
|
10
|
+
*/
|
|
11
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
12
|
+
exports.ApprovalCallback = exports.APPROVAL_TIMEOUT_MS = exports.CALLBACK_PORT_END = exports.CALLBACK_PORT_START = void 0;
|
|
13
|
+
const node_crypto_1 = require("node:crypto");
|
|
14
|
+
const node_http_1 = require("node:http");
|
|
15
|
+
exports.CALLBACK_PORT_START = 48690;
|
|
16
|
+
exports.CALLBACK_PORT_END = 48710;
|
|
17
|
+
exports.APPROVAL_TIMEOUT_MS = 10 * 60 * 1000;
|
|
18
|
+
const CODE = /^[A-Za-z0-9_-]{43}$/;
|
|
19
|
+
function page(title, detail) {
|
|
20
|
+
return `<!doctype html><html><head><meta charset="utf-8"><title>${title}</title>
|
|
21
|
+
<style>body{font:15px -apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;display:flex;align-items:center;justify-content:center;height:100vh;margin:0}
|
|
22
|
+
.c{text-align:center;max-width:28rem;padding:2rem}h1{font-size:1.2rem;margin:0 0 .5rem}p{color:#666;margin:0}</style>
|
|
23
|
+
</head><body><div class="c"><h1>${title}</h1><p>${detail}</p></div></body></html>`;
|
|
24
|
+
}
|
|
25
|
+
function matches(expected, actual) {
|
|
26
|
+
if (actual === null)
|
|
27
|
+
return false;
|
|
28
|
+
const a = Buffer.from(expected);
|
|
29
|
+
const b = Buffer.from(actual);
|
|
30
|
+
return a.length === b.length && (0, node_crypto_1.timingSafeEqual)(a, b);
|
|
31
|
+
}
|
|
32
|
+
class ApprovalCallback {
|
|
33
|
+
state = (0, node_crypto_1.randomBytes)(24).toString('base64url');
|
|
34
|
+
port = 0;
|
|
35
|
+
server = null;
|
|
36
|
+
resolve = null;
|
|
37
|
+
timer = null;
|
|
38
|
+
used = false;
|
|
39
|
+
async start() {
|
|
40
|
+
for (let port = exports.CALLBACK_PORT_START; port <= exports.CALLBACK_PORT_END; port += 1) {
|
|
41
|
+
try {
|
|
42
|
+
await this.listen(port);
|
|
43
|
+
this.port = port;
|
|
44
|
+
return port;
|
|
45
|
+
}
|
|
46
|
+
catch {
|
|
47
|
+
continue;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
throw new Error(`No free local port for the approval in ${exports.CALLBACK_PORT_START}-${exports.CALLBACK_PORT_END}.`);
|
|
51
|
+
}
|
|
52
|
+
listen(port) {
|
|
53
|
+
return new Promise((resolve, reject) => {
|
|
54
|
+
const server = (0, node_http_1.createServer)((req, res) => {
|
|
55
|
+
const url = new URL(req.url ?? '/', `http://127.0.0.1:${port}`);
|
|
56
|
+
// DNS-rebinding guard: only a request addressed to this loopback port.
|
|
57
|
+
const host = req.headers.host ?? '';
|
|
58
|
+
if (host !== `127.0.0.1:${port}` && host !== `localhost:${port}`) {
|
|
59
|
+
res.writeHead(421);
|
|
60
|
+
res.end();
|
|
61
|
+
return;
|
|
62
|
+
}
|
|
63
|
+
if (req.method !== 'GET' || url.pathname !== '/connect/callback') {
|
|
64
|
+
res.writeHead(404);
|
|
65
|
+
res.end();
|
|
66
|
+
return;
|
|
67
|
+
}
|
|
68
|
+
if (this.used) {
|
|
69
|
+
res.writeHead(410, { 'Content-Type': 'text/html; charset=utf-8' });
|
|
70
|
+
res.end(page('Already approved', 'You can close this tab.'));
|
|
71
|
+
return;
|
|
72
|
+
}
|
|
73
|
+
const code = url.searchParams.get('code');
|
|
74
|
+
if (!matches(this.state, url.searchParams.get('state'))) {
|
|
75
|
+
res.writeHead(403, { 'Content-Type': 'text/html; charset=utf-8' });
|
|
76
|
+
res.end(page('Approval refused', 'Run the connect command again.'));
|
|
77
|
+
return;
|
|
78
|
+
}
|
|
79
|
+
if (!code || !CODE.test(code)) {
|
|
80
|
+
res.writeHead(400, { 'Content-Type': 'text/html; charset=utf-8' });
|
|
81
|
+
res.end(page('Approval incomplete', 'Run the connect command again.'));
|
|
82
|
+
return;
|
|
83
|
+
}
|
|
84
|
+
res.writeHead(200, {
|
|
85
|
+
'Content-Type': 'text/html; charset=utf-8',
|
|
86
|
+
'Cache-Control': 'no-store',
|
|
87
|
+
'Referrer-Policy': 'no-referrer'
|
|
88
|
+
});
|
|
89
|
+
res.end(page('Approved', 'Finishing setup in your terminal. You can close this tab.'));
|
|
90
|
+
this.used = true;
|
|
91
|
+
this.clearTimer();
|
|
92
|
+
this.resolve?.(code);
|
|
93
|
+
this.resolve = null;
|
|
94
|
+
});
|
|
95
|
+
server.on('error', reject);
|
|
96
|
+
// 127.0.0.1 only: never reachable from another machine.
|
|
97
|
+
server.listen(port, '127.0.0.1', () => {
|
|
98
|
+
this.server = server;
|
|
99
|
+
resolve();
|
|
100
|
+
});
|
|
101
|
+
});
|
|
102
|
+
}
|
|
103
|
+
wait(timeoutMs = exports.APPROVAL_TIMEOUT_MS) {
|
|
104
|
+
return new Promise((resolve, reject) => {
|
|
105
|
+
this.resolve = resolve;
|
|
106
|
+
this.timer = setTimeout(() => reject(new Error('Timed out waiting for approval in the browser. Run the command again.')), timeoutMs);
|
|
107
|
+
this.timer.unref?.();
|
|
108
|
+
});
|
|
109
|
+
}
|
|
110
|
+
clearTimer() {
|
|
111
|
+
if (this.timer)
|
|
112
|
+
clearTimeout(this.timer);
|
|
113
|
+
this.timer = null;
|
|
114
|
+
}
|
|
115
|
+
stop() {
|
|
116
|
+
this.clearTimer();
|
|
117
|
+
this.server?.close();
|
|
118
|
+
this.server = null;
|
|
119
|
+
this.resolve = null;
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
exports.ApprovalCallback = ApprovalCallback;
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `pf-agent-harness connect <vendor> --connection <id> [--control-plane <url>]`
|
|
3
|
+
*
|
|
4
|
+
* ONE COMMAND, copied from the Welcome window, that:
|
|
5
|
+
* 1. checks the vendor CLI is installed and signed in;
|
|
6
|
+
* 2. asks the signed-in user to approve this computer in the browser
|
|
7
|
+
* (loopback + PKCE + `state`; see workflow-api `byo-connect.ts`);
|
|
8
|
+
* 3. enrols this computer as the user's PERSONAL runner for that vendor only;
|
|
9
|
+
* 4. installs and starts a background service for it, choosing free local
|
|
10
|
+
* ports when 7777/7780 are taken;
|
|
11
|
+
* 5. prints one clear success line.
|
|
12
|
+
*
|
|
13
|
+
* The command carries no secret. The enrolment token exists only in memory
|
|
14
|
+
* here, between the exchange and the registration call, and is never printed,
|
|
15
|
+
* written to disk, or placed in argv. The service starts from the saved
|
|
16
|
+
* runner credential, exactly like `service install` after `start`.
|
|
17
|
+
*/
|
|
18
|
+
import { type VendorBackend } from '../vendor-backends';
|
|
19
|
+
export declare const DEFAULT_CONTROL_PLANE = "https://api.peakflo.ai";
|
|
20
|
+
/** Network text shown in a terminal: no control characters, bounded. */
|
|
21
|
+
export declare function printable(text: string): string;
|
|
22
|
+
/** A failure with a message written for the person at the terminal. */
|
|
23
|
+
export declare class ConnectError extends Error {
|
|
24
|
+
}
|
|
25
|
+
export declare const VENDOR_SETUP: Record<VendorBackend, {
|
|
26
|
+
label: string;
|
|
27
|
+
install: string;
|
|
28
|
+
login: string;
|
|
29
|
+
}>;
|
|
30
|
+
export interface ConnectArgs {
|
|
31
|
+
vendor: VendorBackend;
|
|
32
|
+
connection: string;
|
|
33
|
+
controlPlane: string;
|
|
34
|
+
appUrl?: string;
|
|
35
|
+
openBrowser: boolean;
|
|
36
|
+
}
|
|
37
|
+
export declare const CONNECT_USAGE = "Usage: pf-agent-harness connect <codex|claude-code|cursor> --connection <id> [--control-plane <url>]\n\nCopy this command from the Welcome window in Spaces. It approves this computer\nin your browser, connects the agent as your personal agent, and keeps it\nrunning in the background.";
|
|
38
|
+
export declare function parseConnectArgs(argv: string[], env?: NodeJS.ProcessEnv): ConnectArgs;
|
|
39
|
+
/** The machine name the approval page shows. Must match the API's rule. */
|
|
40
|
+
export declare function machineName(raw: string): string;
|
|
41
|
+
export interface VendorCheck {
|
|
42
|
+
installed: boolean;
|
|
43
|
+
usable: boolean;
|
|
44
|
+
/** A check that timed out; the runner keeps checking in the background. */
|
|
45
|
+
transient: boolean;
|
|
46
|
+
reason?: string;
|
|
47
|
+
}
|
|
48
|
+
export interface ServicePorts {
|
|
49
|
+
healthPort: number;
|
|
50
|
+
proxyPort: number;
|
|
51
|
+
}
|
|
52
|
+
export interface ConnectDeps {
|
|
53
|
+
fetchImpl: typeof fetch;
|
|
54
|
+
log: (message: string) => void;
|
|
55
|
+
openBrowser: (url: string) => void;
|
|
56
|
+
hostname: () => string;
|
|
57
|
+
home: string;
|
|
58
|
+
platform: NodeJS.Platform;
|
|
59
|
+
checkVendor: (vendor: VendorBackend) => Promise<VendorCheck>;
|
|
60
|
+
createCallback: () => {
|
|
61
|
+
state: string;
|
|
62
|
+
start(): Promise<number>;
|
|
63
|
+
wait(): Promise<string>;
|
|
64
|
+
stop(): void;
|
|
65
|
+
};
|
|
66
|
+
register: (input: {
|
|
67
|
+
controlPlane: string;
|
|
68
|
+
enrolmentToken: string;
|
|
69
|
+
vendor: VendorBackend;
|
|
70
|
+
workspaceRoot: string;
|
|
71
|
+
}) => Promise<{
|
|
72
|
+
runnerId: string;
|
|
73
|
+
}>;
|
|
74
|
+
/** Start args of an installed service, or null. `undefined` = default one. */
|
|
75
|
+
installedStartArgs: (instance?: VendorBackend) => string[] | null;
|
|
76
|
+
portOccupied: (port: number) => Promise<boolean>;
|
|
77
|
+
/** A copy of the harness outside the npx cache, so the service survives. */
|
|
78
|
+
stableInstall: () => {
|
|
79
|
+
nodePath: string;
|
|
80
|
+
cliPath: string;
|
|
81
|
+
};
|
|
82
|
+
installService: (instance: VendorBackend, startArgs: string[], paths: {
|
|
83
|
+
nodePath: string;
|
|
84
|
+
cliPath: string;
|
|
85
|
+
}) => Promise<void>;
|
|
86
|
+
waitForHealth: (port: number) => Promise<boolean>;
|
|
87
|
+
}
|
|
88
|
+
/** Reuse this vendor's previous ports; otherwise pick free ones. */
|
|
89
|
+
export declare function choosePorts(vendor: VendorBackend, deps: Pick<ConnectDeps, 'installedStartArgs' | 'portOccupied'>): Promise<ServicePorts>;
|
|
90
|
+
export declare function runConnect(args: ConnectArgs, deps: ConnectDeps): Promise<void>;
|
|
91
|
+
export declare function defaultConnectDeps(context: {
|
|
92
|
+
nodePath: string;
|
|
93
|
+
cliPath: string;
|
|
94
|
+
env: NodeJS.ProcessEnv;
|
|
95
|
+
}): ConnectDeps;
|
|
@@ -0,0 +1,427 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* `pf-agent-harness connect <vendor> --connection <id> [--control-plane <url>]`
|
|
4
|
+
*
|
|
5
|
+
* ONE COMMAND, copied from the Welcome window, that:
|
|
6
|
+
* 1. checks the vendor CLI is installed and signed in;
|
|
7
|
+
* 2. asks the signed-in user to approve this computer in the browser
|
|
8
|
+
* (loopback + PKCE + `state`; see workflow-api `byo-connect.ts`);
|
|
9
|
+
* 3. enrols this computer as the user's PERSONAL runner for that vendor only;
|
|
10
|
+
* 4. installs and starts a background service for it, choosing free local
|
|
11
|
+
* ports when 7777/7780 are taken;
|
|
12
|
+
* 5. prints one clear success line.
|
|
13
|
+
*
|
|
14
|
+
* The command carries no secret. The enrolment token exists only in memory
|
|
15
|
+
* here, between the exchange and the registration call, and is never printed,
|
|
16
|
+
* written to disk, or placed in argv. The service starts from the saved
|
|
17
|
+
* runner credential, exactly like `service install` after `start`.
|
|
18
|
+
*/
|
|
19
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
20
|
+
exports.CONNECT_USAGE = exports.VENDOR_SETUP = exports.ConnectError = exports.DEFAULT_CONTROL_PLANE = void 0;
|
|
21
|
+
exports.printable = printable;
|
|
22
|
+
exports.parseConnectArgs = parseConnectArgs;
|
|
23
|
+
exports.machineName = machineName;
|
|
24
|
+
exports.choosePorts = choosePorts;
|
|
25
|
+
exports.runConnect = runConnect;
|
|
26
|
+
exports.defaultConnectDeps = defaultConnectDeps;
|
|
27
|
+
const node_child_process_1 = require("node:child_process");
|
|
28
|
+
const node_crypto_1 = require("node:crypto");
|
|
29
|
+
const node_fs_1 = require("node:fs");
|
|
30
|
+
const node_net_1 = require("node:net");
|
|
31
|
+
const node_os_1 = require("node:os");
|
|
32
|
+
const node_path_1 = require("node:path");
|
|
33
|
+
const approval_callback_1 = require("./approval-callback");
|
|
34
|
+
const capabilities_1 = require("../capabilities");
|
|
35
|
+
const control_plane_1 = require("../control-plane");
|
|
36
|
+
const credential_store_1 = require("../credential-store");
|
|
37
|
+
const browser_enrol_1 = require("../enrol/browser-enrol");
|
|
38
|
+
const harness_discovery_1 = require("../harness-discovery");
|
|
39
|
+
const service_1 = require("../service");
|
|
40
|
+
const vendor_backends_1 = require("../vendor-backends");
|
|
41
|
+
const version_1 = require("../version");
|
|
42
|
+
exports.DEFAULT_CONTROL_PLANE = 'https://api.peakflo.ai';
|
|
43
|
+
const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
44
|
+
const HEALTH_PORTS = [7777, 7778, 7779, ...range(7790, 7799)];
|
|
45
|
+
const PROXY_PORTS = range(7780, 7789);
|
|
46
|
+
function range(from, to) {
|
|
47
|
+
return Array.from({ length: to - from + 1 }, (_, index) => from + index);
|
|
48
|
+
}
|
|
49
|
+
/** Network text shown in a terminal: no control characters, bounded. */
|
|
50
|
+
function printable(text) {
|
|
51
|
+
// eslint-disable-next-line no-control-regex
|
|
52
|
+
return text.replace(/[\u0000-\u001f\u007f-\u009f]/g, ' ').slice(0, 300);
|
|
53
|
+
}
|
|
54
|
+
/** A failure with a message written for the person at the terminal. */
|
|
55
|
+
class ConnectError extends Error {
|
|
56
|
+
}
|
|
57
|
+
exports.ConnectError = ConnectError;
|
|
58
|
+
exports.VENDOR_SETUP = {
|
|
59
|
+
'claude-code': {
|
|
60
|
+
label: 'Claude Code',
|
|
61
|
+
install: 'npm install -g @anthropic-ai/claude-code',
|
|
62
|
+
login: 'claude auth login'
|
|
63
|
+
},
|
|
64
|
+
codex: {
|
|
65
|
+
label: 'Codex',
|
|
66
|
+
install: 'npm install -g @openai/codex',
|
|
67
|
+
login: 'codex login'
|
|
68
|
+
},
|
|
69
|
+
cursor: {
|
|
70
|
+
label: 'Cursor',
|
|
71
|
+
install: 'curl https://cursor.com/install -fsS | bash',
|
|
72
|
+
login: 'cursor-agent login'
|
|
73
|
+
}
|
|
74
|
+
};
|
|
75
|
+
exports.CONNECT_USAGE = `Usage: pf-agent-harness connect <codex|claude-code|cursor> --connection <id> [--control-plane <url>]
|
|
76
|
+
|
|
77
|
+
Copy this command from the Welcome window in Spaces. It approves this computer
|
|
78
|
+
in your browser, connects the agent as your personal agent, and keeps it
|
|
79
|
+
running in the background.`;
|
|
80
|
+
function flag(argv, name) {
|
|
81
|
+
const inline = argv.find((arg) => arg.startsWith(`--${name}=`));
|
|
82
|
+
if (inline)
|
|
83
|
+
return inline.slice(name.length + 3);
|
|
84
|
+
const at = argv.indexOf(`--${name}`);
|
|
85
|
+
if (at < 0)
|
|
86
|
+
return undefined;
|
|
87
|
+
const value = argv[at + 1];
|
|
88
|
+
return value && !value.startsWith('--') ? value : undefined;
|
|
89
|
+
}
|
|
90
|
+
function checkUrl(value, name) {
|
|
91
|
+
let url;
|
|
92
|
+
try {
|
|
93
|
+
url = new URL(value);
|
|
94
|
+
}
|
|
95
|
+
catch {
|
|
96
|
+
throw new ConnectError(`The ${name} URL is invalid.`);
|
|
97
|
+
}
|
|
98
|
+
const loopback = ['localhost', '127.0.0.1', '[::1]'].includes(url.hostname);
|
|
99
|
+
if (url.username ||
|
|
100
|
+
url.password ||
|
|
101
|
+
url.search ||
|
|
102
|
+
url.hash ||
|
|
103
|
+
!(url.protocol === 'https:' || (url.protocol === 'http:' && loopback)))
|
|
104
|
+
throw new ConnectError(`The ${name} URL must use HTTPS (or HTTP on this computer).`);
|
|
105
|
+
return url.origin + url.pathname.replace(/\/+$/, '');
|
|
106
|
+
}
|
|
107
|
+
function parseConnectArgs(argv, env = {}) {
|
|
108
|
+
const known = new Set([
|
|
109
|
+
'--connection',
|
|
110
|
+
'--control-plane',
|
|
111
|
+
'--app-url',
|
|
112
|
+
'--no-browser'
|
|
113
|
+
]);
|
|
114
|
+
for (let index = 1; index < argv.length; index += 1) {
|
|
115
|
+
const name = argv[index].split('=')[0];
|
|
116
|
+
if (!known.has(name))
|
|
117
|
+
throw new ConnectError(`Unknown option: ${name}\n\n${exports.CONNECT_USAGE}`);
|
|
118
|
+
if (name !== '--no-browser' && !argv[index].includes('='))
|
|
119
|
+
index += 1;
|
|
120
|
+
}
|
|
121
|
+
const vendor = argv[0];
|
|
122
|
+
if (!vendor || !(0, vendor_backends_1.isVendorBackend)(vendor))
|
|
123
|
+
throw new ConnectError(`Choose codex, claude-code, or cursor.\n\n${exports.CONNECT_USAGE}`);
|
|
124
|
+
// `--token` is refused on purpose: this command never takes a secret.
|
|
125
|
+
const connection = flag(argv, 'connection');
|
|
126
|
+
if (!connection || !UUID.test(connection))
|
|
127
|
+
throw new ConnectError(`Copy the command from the Welcome window: it includes --connection <id>.\n\n${exports.CONNECT_USAGE}`);
|
|
128
|
+
const appUrl = flag(argv, 'app-url') ?? env.PF_APP_URL;
|
|
129
|
+
return {
|
|
130
|
+
vendor,
|
|
131
|
+
connection,
|
|
132
|
+
controlPlane: checkUrl(flag(argv, 'control-plane') ??
|
|
133
|
+
env.PF_CONTROL_PLANE_URL ??
|
|
134
|
+
exports.DEFAULT_CONTROL_PLANE, 'control plane'),
|
|
135
|
+
...(appUrl ? { appUrl: checkUrl(appUrl, 'app') } : {}),
|
|
136
|
+
openBrowser: !argv.includes('--no-browser')
|
|
137
|
+
};
|
|
138
|
+
}
|
|
139
|
+
/** The machine name the approval page shows. Must match the API's rule. */
|
|
140
|
+
function machineName(raw) {
|
|
141
|
+
const cleaned = raw
|
|
142
|
+
.replace(/[^A-Za-z0-9._ '-]/g, '-')
|
|
143
|
+
.replace(/^[^A-Za-z0-9]+/, '')
|
|
144
|
+
.slice(0, 64);
|
|
145
|
+
return cleaned || 'computer';
|
|
146
|
+
}
|
|
147
|
+
function argNumber(args, name) {
|
|
148
|
+
if (!args)
|
|
149
|
+
return null;
|
|
150
|
+
const at = args.indexOf(name);
|
|
151
|
+
const value = at >= 0 ? Number(args[at + 1]) : NaN;
|
|
152
|
+
return Number.isInteger(value) ? value : null;
|
|
153
|
+
}
|
|
154
|
+
/** Reuse this vendor's previous ports; otherwise pick free ones. */
|
|
155
|
+
async function choosePorts(vendor, deps) {
|
|
156
|
+
const prior = deps.installedStartArgs(vendor);
|
|
157
|
+
const priorHealth = argNumber(prior, '--health-port');
|
|
158
|
+
const priorProxy = argNumber(prior, '--github-proxy-port');
|
|
159
|
+
if (prior && priorHealth && priorProxy)
|
|
160
|
+
return { healthPort: priorHealth, proxyPort: priorProxy };
|
|
161
|
+
// Ports other services of ours were given, even if they are stopped now.
|
|
162
|
+
const reserved = new Set();
|
|
163
|
+
for (const other of [undefined, 'codex', 'claude-code', 'cursor']) {
|
|
164
|
+
if (other === vendor)
|
|
165
|
+
continue;
|
|
166
|
+
const args = deps.installedStartArgs(other);
|
|
167
|
+
if (!args)
|
|
168
|
+
continue;
|
|
169
|
+
reserved.add(argNumber(args, '--health-port') ?? 7777);
|
|
170
|
+
reserved.add(argNumber(args, '--github-proxy-port') ?? 7780);
|
|
171
|
+
}
|
|
172
|
+
const pick = async (candidates, what) => {
|
|
173
|
+
for (const port of candidates) {
|
|
174
|
+
if (reserved.has(port))
|
|
175
|
+
continue;
|
|
176
|
+
if (!(await deps.portOccupied(port))) {
|
|
177
|
+
reserved.add(port);
|
|
178
|
+
return port;
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
throw new ConnectError(`No free local ${what} port (${candidates[0]}-${candidates.at(-1)}). Close other runners and try again.`);
|
|
182
|
+
};
|
|
183
|
+
return {
|
|
184
|
+
healthPort: priorHealth ?? (await pick(HEALTH_PORTS, 'health')),
|
|
185
|
+
proxyPort: priorProxy ?? (await pick(PROXY_PORTS, 'proxy'))
|
|
186
|
+
};
|
|
187
|
+
}
|
|
188
|
+
async function postJson(fetchImpl, url, body) {
|
|
189
|
+
let response;
|
|
190
|
+
try {
|
|
191
|
+
response = await fetchImpl(url, {
|
|
192
|
+
method: 'POST',
|
|
193
|
+
headers: { 'Content-Type': 'application/json' },
|
|
194
|
+
body: JSON.stringify(body)
|
|
195
|
+
});
|
|
196
|
+
}
|
|
197
|
+
catch {
|
|
198
|
+
throw new ConnectError(`Could not reach ${new URL(url).origin}. Check your internet connection and try again.`);
|
|
199
|
+
}
|
|
200
|
+
const json = (await response.json().catch(() => ({})));
|
|
201
|
+
return { status: response.status, json };
|
|
202
|
+
}
|
|
203
|
+
async function runConnect(args, deps) {
|
|
204
|
+
const { vendor, connection, controlPlane } = args;
|
|
205
|
+
const setup = exports.VENDOR_SETUP[vendor];
|
|
206
|
+
const log = deps.log;
|
|
207
|
+
if (deps.platform !== 'darwin' && deps.platform !== 'linux')
|
|
208
|
+
throw new ConnectError('connect needs macOS or Linux. On Windows, use "Ask my agent to do it" in the Welcome window.');
|
|
209
|
+
const machine = machineName(deps.hostname());
|
|
210
|
+
log(`Connecting ${setup.label} on ${machine}…`);
|
|
211
|
+
/* 1. The vendor CLI must be installed and signed in. */
|
|
212
|
+
const check = await deps.checkVendor(vendor);
|
|
213
|
+
if (!check.installed)
|
|
214
|
+
throw new ConnectError(`${setup.label} is not installed on this computer.\n` +
|
|
215
|
+
` Install it: ${setup.install}\n` +
|
|
216
|
+
` Sign in: ${setup.login}\n` +
|
|
217
|
+
'Then run this command again.');
|
|
218
|
+
if (!check.usable && !check.transient)
|
|
219
|
+
throw new ConnectError(`${setup.label} is installed but not signed in.\n` +
|
|
220
|
+
` Sign in: ${setup.login}\n` +
|
|
221
|
+
'Then run this command again.');
|
|
222
|
+
log(check.usable
|
|
223
|
+
? `✓ ${setup.label} is installed and signed in.`
|
|
224
|
+
: `! ${setup.label} is installed; its sign-in check is slow and will be retried in the background.`);
|
|
225
|
+
/* 2. Browser approval, bound to this process by `state` and PKCE. */
|
|
226
|
+
const verifier = (0, node_crypto_1.randomBytes)(32).toString('base64url');
|
|
227
|
+
const challenge = (0, node_crypto_1.createHash)('sha256')
|
|
228
|
+
.update(verifier, 'utf8')
|
|
229
|
+
.digest('base64url');
|
|
230
|
+
const callback = deps.createCallback();
|
|
231
|
+
let code;
|
|
232
|
+
try {
|
|
233
|
+
const port = await callback.start();
|
|
234
|
+
const appOrigin = args.appUrl
|
|
235
|
+
? new URL(args.appUrl).origin
|
|
236
|
+
: (0, browser_enrol_1.deriveAppOrigin)(controlPlane);
|
|
237
|
+
const query = new URLSearchParams({
|
|
238
|
+
connection,
|
|
239
|
+
vendor,
|
|
240
|
+
machine,
|
|
241
|
+
port: String(port),
|
|
242
|
+
state: callback.state,
|
|
243
|
+
challenge
|
|
244
|
+
});
|
|
245
|
+
const approvalUrl = `${appOrigin}/connect-computer?${query.toString()}`;
|
|
246
|
+
log('');
|
|
247
|
+
log('Approve this computer in your browser:');
|
|
248
|
+
log(` ${approvalUrl}`);
|
|
249
|
+
log('Waiting for approval… (Ctrl-C to cancel)');
|
|
250
|
+
const waiting = callback.wait();
|
|
251
|
+
// Never leave the timeout rejection unhandled if opening the browser throws.
|
|
252
|
+
waiting.catch(() => undefined);
|
|
253
|
+
if (args.openBrowser)
|
|
254
|
+
deps.openBrowser(approvalUrl);
|
|
255
|
+
code = await waiting;
|
|
256
|
+
}
|
|
257
|
+
finally {
|
|
258
|
+
callback.stop();
|
|
259
|
+
}
|
|
260
|
+
/* 3. Exchange the approval for a one-time enrolment token. */
|
|
261
|
+
const exchange = await postJson(deps.fetchImpl, `${controlPlane}/api/onboarding/byo/connect/${connection}/exchange`, { code, codeVerifier: verifier });
|
|
262
|
+
const enrolmentToken = exchange.json.enrolmentToken;
|
|
263
|
+
if (exchange.status !== 200 ||
|
|
264
|
+
typeof enrolmentToken !== 'string' ||
|
|
265
|
+
exchange.json.vendor !== vendor)
|
|
266
|
+
throw new ConnectError(typeof exchange.json.error === 'string'
|
|
267
|
+
? printable(exchange.json.error)
|
|
268
|
+
: `The approval could not be used (${exchange.status}). Get a new command from the Welcome window.`);
|
|
269
|
+
const company = typeof exchange.json.companyName === 'string'
|
|
270
|
+
? exchange.json.companyName
|
|
271
|
+
: 'your company';
|
|
272
|
+
log(`✓ Approved for ${company}.`);
|
|
273
|
+
/* 4. Enrol: a personal runner for this vendor only. */
|
|
274
|
+
const workspaceRoot = (0, node_path_1.join)(deps.home, '.pf-agent-harness', 'connect', vendor, 'workspaces');
|
|
275
|
+
const ports = await choosePorts(vendor, deps);
|
|
276
|
+
await deps.register({ controlPlane, enrolmentToken, vendor, workspaceRoot });
|
|
277
|
+
log('✓ This computer is registered as your personal runner.');
|
|
278
|
+
/* 5. Background service, started from the saved credential. */
|
|
279
|
+
log('Setting up the background service…');
|
|
280
|
+
const paths = deps.stableInstall();
|
|
281
|
+
if (paths.cliPath.includes('/_npx/'))
|
|
282
|
+
log("! The service runs from npm's npx cache. If it stops after a cache cleanup, run this command again.");
|
|
283
|
+
const startArgs = [
|
|
284
|
+
'--control-plane',
|
|
285
|
+
controlPlane,
|
|
286
|
+
...(args.appUrl ? ['--app-url', args.appUrl] : []),
|
|
287
|
+
'--runtime',
|
|
288
|
+
'desktop',
|
|
289
|
+
'--vendor-harnesses',
|
|
290
|
+
vendor,
|
|
291
|
+
'--workspace-root',
|
|
292
|
+
workspaceRoot,
|
|
293
|
+
'--health-port',
|
|
294
|
+
String(ports.healthPort),
|
|
295
|
+
'--github-proxy-port',
|
|
296
|
+
String(ports.proxyPort)
|
|
297
|
+
];
|
|
298
|
+
await deps.installService(vendor, startArgs, paths);
|
|
299
|
+
log('✓ Background service installed. It starts when you log in.');
|
|
300
|
+
const healthy = await deps.waitForHealth(ports.healthPort);
|
|
301
|
+
if (!healthy)
|
|
302
|
+
log(`! The service has not answered yet (it may still be starting, or a local port was taken). Check it with: npx @peakflo/agent-harness service status --vendor ${vendor}`);
|
|
303
|
+
log('');
|
|
304
|
+
log(`✓ ${setup.label} is connected to ${company} on ${machine}. Return to the Welcome window.`);
|
|
305
|
+
}
|
|
306
|
+
/* -------------------------------------------------------------------------- */
|
|
307
|
+
/* Real dependencies */
|
|
308
|
+
/* -------------------------------------------------------------------------- */
|
|
309
|
+
function openInBrowser(url) {
|
|
310
|
+
const command = process.platform === 'darwin' ? 'open' : 'xdg-open';
|
|
311
|
+
try {
|
|
312
|
+
const child = (0, node_child_process_1.spawn)(command, [url], { stdio: 'ignore', detached: true });
|
|
313
|
+
child.on('error', () => undefined);
|
|
314
|
+
child.unref();
|
|
315
|
+
}
|
|
316
|
+
catch {
|
|
317
|
+
/* the printed url is the fallback */
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
function portInUse(port) {
|
|
321
|
+
return new Promise((resolve) => {
|
|
322
|
+
const server = (0, node_net_1.createServer)();
|
|
323
|
+
server.once('error', () => resolve(true));
|
|
324
|
+
server.listen(port, '127.0.0.1', () => server.close(() => resolve(false)));
|
|
325
|
+
});
|
|
326
|
+
}
|
|
327
|
+
function defaultConnectDeps(context) {
|
|
328
|
+
const home = (0, node_os_1.homedir)();
|
|
329
|
+
const manager = (instance) => new service_1.ServiceManager({ ...context, instance });
|
|
330
|
+
return {
|
|
331
|
+
fetchImpl: fetch,
|
|
332
|
+
log: (message) => process.stdout.write(`${message}\n`),
|
|
333
|
+
openBrowser: openInBrowser,
|
|
334
|
+
hostname: node_os_1.hostname,
|
|
335
|
+
home,
|
|
336
|
+
platform: process.platform,
|
|
337
|
+
checkVendor: async (vendor) => {
|
|
338
|
+
const [entry] = await (0, harness_discovery_1.discoverHarnesses)({ only: [vendor] });
|
|
339
|
+
return {
|
|
340
|
+
installed: Boolean(entry?.installed),
|
|
341
|
+
usable: Boolean(entry?.usable),
|
|
342
|
+
transient: Boolean(entry?.transient),
|
|
343
|
+
reason: entry?.reason
|
|
344
|
+
};
|
|
345
|
+
},
|
|
346
|
+
createCallback: () => new approval_callback_1.ApprovalCallback(),
|
|
347
|
+
register: async ({ controlPlane, enrolmentToken, vendor, workspaceRoot }) => {
|
|
348
|
+
const capabilities = await (0, capabilities_1.probeCapabilities)({
|
|
349
|
+
maxConcurrentSessions: 10
|
|
350
|
+
});
|
|
351
|
+
capabilities.vendorHarnesses = [vendor];
|
|
352
|
+
const client = new control_plane_1.ControlPlaneClient(controlPlane);
|
|
353
|
+
const response = await client.register({
|
|
354
|
+
enrolmentToken,
|
|
355
|
+
capabilities,
|
|
356
|
+
runtime: 'desktop',
|
|
357
|
+
degraded: false
|
|
358
|
+
});
|
|
359
|
+
const runnerId = client.getRunnerId();
|
|
360
|
+
if (!client.isRegistered() || !runnerId || !response.runnerCredential)
|
|
361
|
+
throw new ConnectError(`The control plane did not accept this computer: ${response.reason ?? 'no runner credential'}.`);
|
|
362
|
+
new credential_store_1.FileCredentialStore(workspaceRoot).save({
|
|
363
|
+
runnerId,
|
|
364
|
+
tenantId: response.tenantId ?? null,
|
|
365
|
+
credential: response.runnerCredential,
|
|
366
|
+
credentialExpiresAt: response.credentialExpiresAt ?? null,
|
|
367
|
+
savedAt: new Date().toISOString()
|
|
368
|
+
});
|
|
369
|
+
return { runnerId };
|
|
370
|
+
},
|
|
371
|
+
installedStartArgs: (instance) => manager(instance).installedStartArgs(),
|
|
372
|
+
portOccupied: portInUse,
|
|
373
|
+
stableInstall: () => {
|
|
374
|
+
const inNpxCache = context.cliPath.includes('/_npx/') ||
|
|
375
|
+
context.cliPath.includes('/.npm/_npx/');
|
|
376
|
+
if (!inNpxCache)
|
|
377
|
+
return { nodePath: context.nodePath, cliPath: context.cliPath };
|
|
378
|
+
// npx may delete its cache at any time; the service must not depend on it.
|
|
379
|
+
const prefix = (0, node_path_1.join)(home, '.pf-agent-harness', 'app');
|
|
380
|
+
const cliPath = (0, node_path_1.join)(prefix, 'node_modules', '@peakflo', 'agent-harness', 'dist', 'cli.js');
|
|
381
|
+
try {
|
|
382
|
+
(0, node_child_process_1.execFileSync)('npm', [
|
|
383
|
+
'install',
|
|
384
|
+
'--prefix',
|
|
385
|
+
prefix,
|
|
386
|
+
'--no-audit',
|
|
387
|
+
'--no-fund',
|
|
388
|
+
'--omit=dev',
|
|
389
|
+
`@peakflo/agent-harness@${(0, version_1.getHarnessVersion)()}`
|
|
390
|
+
],
|
|
391
|
+
// A slow network must not look like a hang for ever.
|
|
392
|
+
{ stdio: ['ignore', 'ignore', 'pipe'], timeout: 180_000 });
|
|
393
|
+
if ((0, node_fs_1.existsSync)(cliPath))
|
|
394
|
+
return { nodePath: context.nodePath, cliPath };
|
|
395
|
+
}
|
|
396
|
+
catch {
|
|
397
|
+
/* fall back to the npx copy, with service install's warning */
|
|
398
|
+
}
|
|
399
|
+
return { nodePath: context.nodePath, cliPath: context.cliPath };
|
|
400
|
+
},
|
|
401
|
+
installService: async (instance, startArgs, paths) => {
|
|
402
|
+
await new service_1.ServiceManager({
|
|
403
|
+
...context,
|
|
404
|
+
...paths,
|
|
405
|
+
instance,
|
|
406
|
+
// service install prints its own lines; connect prints its own summary.
|
|
407
|
+
output: () => undefined
|
|
408
|
+
}).install(startArgs);
|
|
409
|
+
},
|
|
410
|
+
waitForHealth: async (port) => {
|
|
411
|
+
for (let attempt = 0; attempt < 45; attempt += 1) {
|
|
412
|
+
try {
|
|
413
|
+
const response = await fetch(`http://127.0.0.1:${port}/health`, {
|
|
414
|
+
signal: AbortSignal.timeout(2000)
|
|
415
|
+
});
|
|
416
|
+
if (response.ok)
|
|
417
|
+
return true;
|
|
418
|
+
}
|
|
419
|
+
catch {
|
|
420
|
+
/* not up yet */
|
|
421
|
+
}
|
|
422
|
+
await new Promise((resolve) => setTimeout(resolve, 1000));
|
|
423
|
+
}
|
|
424
|
+
return false;
|
|
425
|
+
}
|
|
426
|
+
};
|
|
427
|
+
}
|
package/dist/harness.js
CHANGED
|
@@ -1577,7 +1577,7 @@ class Harness {
|
|
|
1577
1577
|
if (this.options.runtime !== 'sandbox' ||
|
|
1578
1578
|
typeof version !== 'string' ||
|
|
1579
1579
|
version.length === 0 ||
|
|
1580
|
-
version === (0, version_1.getHarnessVersion)() ||
|
|
1580
|
+
(version === (0, version_1.getHarnessVersion)() && !this.options.degraded) ||
|
|
1581
1581
|
this.pendingHarnessVersion) {
|
|
1582
1582
|
return;
|
|
1583
1583
|
}
|
package/dist/service.d.ts
CHANGED
|
@@ -17,7 +17,20 @@ export interface ServiceContext {
|
|
|
17
17
|
output?: (message: string) => void;
|
|
18
18
|
sleep?: (ms: number) => void;
|
|
19
19
|
portOccupied?: (port: number) => Promise<boolean>;
|
|
20
|
+
/**
|
|
21
|
+
* A per-vendor service from `connect` (`ai.peakflo.agent-harness.<vendor>`),
|
|
22
|
+
* so Claude Code, Codex and Cursor can each run as their own personal
|
|
23
|
+
* runner on one machine. Unset means the original single service.
|
|
24
|
+
*/
|
|
25
|
+
instance?: string;
|
|
20
26
|
}
|
|
27
|
+
/** Service names for an instance. Only vendor names are accepted. */
|
|
28
|
+
export declare function serviceNames(instance?: string): {
|
|
29
|
+
label: string;
|
|
30
|
+
unit: string;
|
|
31
|
+
stateFile: string;
|
|
32
|
+
logDir: string;
|
|
33
|
+
};
|
|
21
34
|
export declare function permanentServiceExitCode(platform: Platform, linuxCode: 2 | 78): number;
|
|
22
35
|
/** Only failures that a restart cannot repair belong in this list. */
|
|
23
36
|
export declare function permanentStartupFailure(error: unknown): string | null;
|
|
@@ -25,7 +38,7 @@ export declare function permanentStartupFailure(error: unknown): string | null;
|
|
|
25
38
|
export declare function isServiceStart(argv: string[]): boolean;
|
|
26
39
|
/** Parse only documented start options. A token can never enter service state. */
|
|
27
40
|
export declare function serviceStartArgs(input: string[], env: NodeJS.ProcessEnv, home: string): string[];
|
|
28
|
-
export declare function renderLaunchAgent(config: ServiceConfig, logDir: string): string;
|
|
41
|
+
export declare function renderLaunchAgent(config: ServiceConfig, logDir: string, label?: string): string;
|
|
29
42
|
export declare function renderSystemdUnit(config: ServiceConfig): string;
|
|
30
43
|
export declare function recordServiceFailure(reason: string, home?: string): void;
|
|
31
44
|
export declare function clearServiceFailure(home?: string): void;
|
|
@@ -38,12 +51,17 @@ export declare class ServiceManager {
|
|
|
38
51
|
private readonly stateFile;
|
|
39
52
|
private readonly serviceFile;
|
|
40
53
|
private readonly logDir;
|
|
54
|
+
private readonly label;
|
|
55
|
+
private readonly unit;
|
|
41
56
|
constructor(context: ServiceContext);
|
|
57
|
+
/** Settings of the installed service, or null when there is none. */
|
|
58
|
+
installedStartArgs(): string[] | null;
|
|
42
59
|
private supported;
|
|
43
60
|
private readConfig;
|
|
44
61
|
private checkCredential;
|
|
45
62
|
private writeAndStart;
|
|
46
63
|
install(input: string[]): Promise<void>;
|
|
64
|
+
private vendorFlag;
|
|
47
65
|
private checkNodeVersion;
|
|
48
66
|
repair(): void;
|
|
49
67
|
assertNoDuplicateRunner(options: {
|
|
@@ -53,5 +71,5 @@ export declare class ServiceManager {
|
|
|
53
71
|
status(): void;
|
|
54
72
|
uninstall(): void;
|
|
55
73
|
}
|
|
56
|
-
export declare function runServiceCommand(
|
|
74
|
+
export declare function runServiceCommand(input: string[], context: ServiceContext): Promise<void>;
|
|
57
75
|
export {};
|
package/dist/service.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
3
|
exports.ServiceManager = void 0;
|
|
4
|
+
exports.serviceNames = serviceNames;
|
|
4
5
|
exports.permanentServiceExitCode = permanentServiceExitCode;
|
|
5
6
|
exports.permanentStartupFailure = permanentStartupFailure;
|
|
6
7
|
exports.isServiceStart = isServiceStart;
|
|
@@ -37,6 +38,24 @@ const VALUE_FLAGS = new Set([
|
|
|
37
38
|
'--vendor-harnesses'
|
|
38
39
|
]);
|
|
39
40
|
const SWITCH_FLAGS = new Set(['--no-harness-discovery']);
|
|
41
|
+
/** Service names for an instance. Only vendor names are accepted. */
|
|
42
|
+
function serviceNames(instance) {
|
|
43
|
+
if (instance === undefined)
|
|
44
|
+
return {
|
|
45
|
+
label: LABEL,
|
|
46
|
+
unit: UNIT,
|
|
47
|
+
stateFile: 'service.json',
|
|
48
|
+
logDir: 'logs'
|
|
49
|
+
};
|
|
50
|
+
if (!(0, vendor_backends_1.isVendorBackend)(instance))
|
|
51
|
+
throw new Error('Use codex, claude-code, or cursor as the service name.');
|
|
52
|
+
return {
|
|
53
|
+
label: `${LABEL}.${instance}`,
|
|
54
|
+
unit: `pf-agent-harness-${instance}.service`,
|
|
55
|
+
stateFile: `service-${instance}.json`,
|
|
56
|
+
logDir: (0, node_path_1.join)('logs', instance)
|
|
57
|
+
};
|
|
58
|
+
}
|
|
40
59
|
const LAST_ERROR_FILE = 'service-last-error';
|
|
41
60
|
function permanentServiceExitCode(platform, linuxCode) {
|
|
42
61
|
return platform === 'darwin' ? 0 : linuxCode;
|
|
@@ -176,12 +195,12 @@ function serviceStartArgs(input, env, home) {
|
|
|
176
195
|
result.push(flag);
|
|
177
196
|
return result;
|
|
178
197
|
}
|
|
179
|
-
function renderLaunchAgent(config, logDir) {
|
|
198
|
+
function renderLaunchAgent(config, logDir, label = LABEL) {
|
|
180
199
|
const args = [config.nodePath, config.cliPath, 'start', ...config.startArgs];
|
|
181
200
|
const array = args
|
|
182
201
|
.map((arg) => ` <string>${xml(arg)}</string>`)
|
|
183
202
|
.join('\n');
|
|
184
|
-
return `<?xml version="1.0" encoding="UTF-8"?>\n<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">\n<plist version="1.0">\n<dict>\n <key>Label</key><string>${
|
|
203
|
+
return `<?xml version="1.0" encoding="UTF-8"?>\n<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">\n<plist version="1.0">\n<dict>\n <key>Label</key><string>${xml(label)}</string>\n <key>ProgramArguments</key><array>\n${array}\n </array>\n <key>RunAtLoad</key><true/>\n <key>KeepAlive</key><dict><key>SuccessfulExit</key><false/></dict>\n <key>ThrottleInterval</key><integer>30</integer>\n <key>EnvironmentVariables</key><dict><key>PATH</key><string>${xml(config.path)}</string></dict>\n <key>StandardOutPath</key><string>${xml((0, node_path_1.join)(logDir, 'stdout.log'))}</string>\n <key>StandardErrorPath</key><string>${xml((0, node_path_1.join)(logDir, 'stderr.log'))}</string>\n</dict>\n</plist>\n`;
|
|
185
204
|
}
|
|
186
205
|
function renderSystemdUnit(config) {
|
|
187
206
|
const command = [
|
|
@@ -230,6 +249,8 @@ class ServiceManager {
|
|
|
230
249
|
stateFile;
|
|
231
250
|
serviceFile;
|
|
232
251
|
logDir;
|
|
252
|
+
label;
|
|
253
|
+
unit;
|
|
233
254
|
constructor(context) {
|
|
234
255
|
this.context = context;
|
|
235
256
|
this.home = context.home ?? (0, node_os_1.homedir)();
|
|
@@ -237,12 +258,26 @@ class ServiceManager {
|
|
|
237
258
|
this.run = context.run ?? defaultRun;
|
|
238
259
|
this.output =
|
|
239
260
|
context.output ?? ((message) => process.stdout.write(`${message}\n`));
|
|
240
|
-
|
|
241
|
-
this.
|
|
261
|
+
const names = serviceNames(context.instance);
|
|
262
|
+
this.label = names.label;
|
|
263
|
+
this.unit = names.unit;
|
|
264
|
+
this.stateFile = (0, node_path_1.join)(this.home, '.pf-agent-harness', names.stateFile);
|
|
265
|
+
this.logDir = (0, node_path_1.join)(this.home, '.pf-agent-harness', names.logDir);
|
|
242
266
|
this.serviceFile =
|
|
243
267
|
this.platform === 'darwin'
|
|
244
|
-
? (0, node_path_1.join)(this.home, 'Library', 'LaunchAgents', `${
|
|
245
|
-
: (0, node_path_1.join)(this.home, '.config', 'systemd', 'user',
|
|
268
|
+
? (0, node_path_1.join)(this.home, 'Library', 'LaunchAgents', `${this.label}.plist`)
|
|
269
|
+
: (0, node_path_1.join)(this.home, '.config', 'systemd', 'user', this.unit);
|
|
270
|
+
}
|
|
271
|
+
/** Settings of the installed service, or null when there is none. */
|
|
272
|
+
installedStartArgs() {
|
|
273
|
+
if (!(0, node_fs_1.existsSync)(this.stateFile))
|
|
274
|
+
return null;
|
|
275
|
+
try {
|
|
276
|
+
return this.readConfig().startArgs;
|
|
277
|
+
}
|
|
278
|
+
catch {
|
|
279
|
+
return null;
|
|
280
|
+
}
|
|
246
281
|
}
|
|
247
282
|
supported() {
|
|
248
283
|
if (this.platform !== 'darwin' && this.platform !== 'linux') {
|
|
@@ -280,10 +315,10 @@ class ServiceManager {
|
|
|
280
315
|
(0, node_fs_1.mkdirSync)(this.logDir, { recursive: true, mode: 0o700 });
|
|
281
316
|
writePrivate(this.stateFile, `${JSON.stringify(config, null, 2)}\n`);
|
|
282
317
|
if (this.platform === 'darwin') {
|
|
283
|
-
writePrivate(this.serviceFile, renderLaunchAgent(config, this.logDir));
|
|
318
|
+
writePrivate(this.serviceFile, renderLaunchAgent(config, this.logDir, this.label));
|
|
284
319
|
const domain = `gui/${this.context.uid ?? process.getuid?.() ?? (0, node_os_1.userInfo)().uid}`;
|
|
285
320
|
try {
|
|
286
|
-
this.run('launchctl', ['bootout', `${domain}/${
|
|
321
|
+
this.run('launchctl', ['bootout', `${domain}/${this.label}`]);
|
|
287
322
|
}
|
|
288
323
|
catch {
|
|
289
324
|
/* absent */
|
|
@@ -306,15 +341,15 @@ class ServiceManager {
|
|
|
306
341
|
else {
|
|
307
342
|
writePrivate(this.serviceFile, renderSystemdUnit(config));
|
|
308
343
|
this.run('systemctl', ['--user', 'daemon-reload']);
|
|
309
|
-
this.run('systemctl', ['--user', 'enable',
|
|
344
|
+
this.run('systemctl', ['--user', 'enable', this.unit]);
|
|
310
345
|
try {
|
|
311
|
-
this.run('systemctl', ['--user', 'reset-failed',
|
|
346
|
+
this.run('systemctl', ['--user', 'reset-failed', this.unit]);
|
|
312
347
|
}
|
|
313
348
|
catch {
|
|
314
349
|
// A new unit may have no failed state to clear. Restart gives the
|
|
315
350
|
// operator the actionable error if systemd cannot start it.
|
|
316
351
|
}
|
|
317
|
-
this.run('systemctl', ['--user', 'restart',
|
|
352
|
+
this.run('systemctl', ['--user', 'restart', this.unit]);
|
|
318
353
|
}
|
|
319
354
|
}
|
|
320
355
|
async install(input) {
|
|
@@ -339,12 +374,12 @@ class ServiceManager {
|
|
|
339
374
|
const info = this.platform === 'darwin'
|
|
340
375
|
? this.run('launchctl', [
|
|
341
376
|
'print',
|
|
342
|
-
`gui/${this.context.uid ?? process.getuid?.() ?? (0, node_os_1.userInfo)().uid}/${
|
|
377
|
+
`gui/${this.context.uid ?? process.getuid?.() ?? (0, node_os_1.userInfo)().uid}/${this.label}`
|
|
343
378
|
])
|
|
344
379
|
: this.run('systemctl', [
|
|
345
380
|
'--user',
|
|
346
381
|
'show',
|
|
347
|
-
|
|
382
|
+
this.unit,
|
|
348
383
|
'--property=ActiveState',
|
|
349
384
|
'--property=MainPID'
|
|
350
385
|
]);
|
|
@@ -378,10 +413,13 @@ class ServiceManager {
|
|
|
378
413
|
if (this.context.nodePath.includes('/.nvm/versions/node/')) {
|
|
379
414
|
this.output('Warning: this Node.js path can change after an nvm upgrade. Run service repair after upgrades.');
|
|
380
415
|
}
|
|
381
|
-
this.output(
|
|
416
|
+
this.output(`After a Node.js or harness upgrade, run: pf-agent-harness service repair${this.vendorFlag()}`);
|
|
382
417
|
if (this.platform === 'linux')
|
|
383
418
|
this.output('To run after logout, ask your system administrator to run: loginctl enable-linger $USER');
|
|
384
419
|
}
|
|
420
|
+
vendorFlag() {
|
|
421
|
+
return this.context.instance ? ` --vendor ${this.context.instance}` : '';
|
|
422
|
+
}
|
|
385
423
|
checkNodeVersion() {
|
|
386
424
|
const floor = (0, node_floor_1.checkNodeFloor)({
|
|
387
425
|
current: process.versions.node,
|
|
@@ -416,12 +454,12 @@ class ServiceManager {
|
|
|
416
454
|
this.platform === 'darwin'
|
|
417
455
|
? this.run('launchctl', [
|
|
418
456
|
'print',
|
|
419
|
-
`gui/${this.context.uid ?? process.getuid?.() ?? (0, node_os_1.userInfo)().uid}/${
|
|
457
|
+
`gui/${this.context.uid ?? process.getuid?.() ?? (0, node_os_1.userInfo)().uid}/${this.label}`
|
|
420
458
|
])
|
|
421
459
|
: this.run('systemctl', [
|
|
422
460
|
'--user',
|
|
423
461
|
'show',
|
|
424
|
-
|
|
462
|
+
this.unit,
|
|
425
463
|
'--property=ActiveState',
|
|
426
464
|
'--property=MainPID'
|
|
427
465
|
]);
|
|
@@ -463,7 +501,10 @@ class ServiceManager {
|
|
|
463
501
|
try {
|
|
464
502
|
if (this.platform === 'darwin') {
|
|
465
503
|
const domain = `gui/${this.context.uid ?? process.getuid?.() ?? (0, node_os_1.userInfo)().uid}`;
|
|
466
|
-
const info = this.run('launchctl', [
|
|
504
|
+
const info = this.run('launchctl', [
|
|
505
|
+
'print',
|
|
506
|
+
`${domain}/${this.label}`
|
|
507
|
+
]);
|
|
467
508
|
const running = /\bstate = running\b/.test(info);
|
|
468
509
|
const exit = info.match(/last exit code = (\d+)/)?.[1];
|
|
469
510
|
this.output(`Status: ${running ? 'running' : 'stopped'}. Last error: ${!running && savedError ? savedError : exit && exit !== '0' ? `exit ${exit}` : 'none'}.`);
|
|
@@ -472,7 +513,7 @@ class ServiceManager {
|
|
|
472
513
|
const info = this.run('systemctl', [
|
|
473
514
|
'--user',
|
|
474
515
|
'show',
|
|
475
|
-
|
|
516
|
+
this.unit,
|
|
476
517
|
'--property=ActiveState',
|
|
477
518
|
'--property=Result',
|
|
478
519
|
'--property=ExecMainStatus'
|
|
@@ -492,14 +533,14 @@ class ServiceManager {
|
|
|
492
533
|
if (this.platform === 'darwin') {
|
|
493
534
|
const domain = `gui/${this.context.uid ?? process.getuid?.() ?? (0, node_os_1.userInfo)().uid}`;
|
|
494
535
|
try {
|
|
495
|
-
this.run('launchctl', ['bootout', `${domain}/${
|
|
536
|
+
this.run('launchctl', ['bootout', `${domain}/${this.label}`]);
|
|
496
537
|
}
|
|
497
538
|
catch {
|
|
498
539
|
/* absent */
|
|
499
540
|
}
|
|
500
541
|
}
|
|
501
542
|
else if ((0, node_fs_1.existsSync)(this.serviceFile)) {
|
|
502
|
-
this.run('systemctl', ['--user', 'disable', '--now',
|
|
543
|
+
this.run('systemctl', ['--user', 'disable', '--now', this.unit]);
|
|
503
544
|
}
|
|
504
545
|
(0, node_fs_1.rmSync)(this.serviceFile, { force: true });
|
|
505
546
|
(0, node_fs_1.rmSync)(this.stateFile, { force: true });
|
|
@@ -510,12 +551,23 @@ class ServiceManager {
|
|
|
510
551
|
}
|
|
511
552
|
}
|
|
512
553
|
exports.ServiceManager = ServiceManager;
|
|
513
|
-
async function runServiceCommand(
|
|
514
|
-
|
|
554
|
+
async function runServiceCommand(input, context) {
|
|
555
|
+
// `--vendor <name>` selects a per-vendor service installed by `connect`.
|
|
556
|
+
const args = [...input];
|
|
557
|
+
const at = args.findIndex((arg) => arg === '--vendor' || arg.startsWith('--vendor='));
|
|
558
|
+
let instance = context.instance;
|
|
559
|
+
if (at > 0 && args[0] !== 'install') {
|
|
560
|
+
const raw = args[at];
|
|
561
|
+
instance = raw.includes('=')
|
|
562
|
+
? raw.slice(raw.indexOf('=') + 1)
|
|
563
|
+
: args[at + 1];
|
|
564
|
+
args.splice(at, raw.includes('=') ? 1 : 2);
|
|
565
|
+
}
|
|
566
|
+
const manager = new ServiceManager({ ...context, instance });
|
|
515
567
|
switch (args[0]) {
|
|
516
568
|
case 'help':
|
|
517
569
|
case '--help':
|
|
518
|
-
(context.output ?? ((message) => process.stdout.write(`${message}\n`)))('Use service install [start options], service status, service repair, or service uninstall.');
|
|
570
|
+
(context.output ?? ((message) => process.stdout.write(`${message}\n`)))('Use service install [start options], service status, service repair, or service uninstall. Add --vendor <name> for a service made by connect.');
|
|
519
571
|
break;
|
|
520
572
|
case 'install':
|
|
521
573
|
await manager.install(args.slice(1));
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@peakflo/agent-harness",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.11-rc.2",
|
|
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",
|
|
@@ -36,11 +36,13 @@
|
|
|
36
36
|
"undici": "^7.21.0"
|
|
37
37
|
},
|
|
38
38
|
"dependencies": {
|
|
39
|
-
"@anthropic-ai/claude-agent-sdk": "0.3.258",
|
|
40
39
|
"@modelcontextprotocol/client": "2.0.0",
|
|
41
40
|
"@peculiar/x509": "^2.1.0",
|
|
42
41
|
"reflect-metadata": "^0.2.2"
|
|
43
42
|
},
|
|
43
|
+
"optionalDependencies": {
|
|
44
|
+
"@anthropic-ai/claude-agent-sdk": "0.3.258"
|
|
45
|
+
},
|
|
44
46
|
"repository": {
|
|
45
47
|
"type": "git",
|
|
46
48
|
"url": "https://github.com/peakflo/workflow-builder.git",
|