@prohost/cli 0.1.0 → 0.2.0
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/CHANGELOG.md +99 -0
- package/README.md +161 -18
- package/dist/agent/api.d.ts +6 -1
- package/dist/agent/api.js +10 -2
- package/dist/agent/claude.d.ts +161 -0
- package/dist/agent/claude.js +336 -0
- package/dist/agent/command.js +169 -19
- package/dist/agent/contract.d.ts +57 -4
- package/dist/agent/contract.js +71 -0
- package/dist/agent/credentials.d.ts +40 -1
- package/dist/agent/credentials.js +76 -1
- package/dist/agent/daemon.d.ts +131 -0
- package/dist/agent/daemon.js +274 -0
- package/dist/agent/pair.d.ts +5 -1
- package/dist/agent/pair.js +41 -1
- package/dist/agent/prompt.d.ts +17 -2
- package/dist/agent/prompt.js +55 -3
- package/dist/agent/run.d.ts +13 -1
- package/dist/agent/run.js +211 -27
- package/dist/agent/sessionstore.d.ts +71 -0
- package/dist/agent/sessionstore.js +138 -0
- package/dist/index.d.ts +2 -1
- package/dist/index.js +19 -5
- package/dist/listen.d.ts +13 -1
- package/dist/listen.js +13 -1
- package/dist/version.d.ts +2 -2
- package/dist/version.js +1 -1
- package/package.json +2 -1
package/dist/agent/contract.js
CHANGED
|
@@ -38,6 +38,16 @@ export function conversationMessagesPath(conversationId) {
|
|
|
38
38
|
export function agentRunCompletePath(runId) {
|
|
39
39
|
return `/v1/agent-runs/${encodeURIComponent(runId)}/complete`;
|
|
40
40
|
}
|
|
41
|
+
/**
|
|
42
|
+
* Ceiling on how many context entries are kept from one payload.
|
|
43
|
+
*
|
|
44
|
+
* The server bounds these, but the prompt is written to a child process's
|
|
45
|
+
* stdin and the harness is the last thing standing between a pathological
|
|
46
|
+
* payload and an agent's context window. Entries past the limit are dropped
|
|
47
|
+
* from the *front*, because both lists are oldest-first and the recent end is
|
|
48
|
+
* the part worth spending prompt on.
|
|
49
|
+
*/
|
|
50
|
+
export const MAX_CONTEXT_ENTRIES = 50;
|
|
41
51
|
/**
|
|
42
52
|
* Fallback for a server that predates `reply_instructions.body_field`.
|
|
43
53
|
*
|
|
@@ -60,6 +70,60 @@ function obj(value) {
|
|
|
60
70
|
? value
|
|
61
71
|
: {};
|
|
62
72
|
}
|
|
73
|
+
/**
|
|
74
|
+
* Keep the last {@link MAX_CONTEXT_ENTRIES} entries of a list the server sent.
|
|
75
|
+
*
|
|
76
|
+
* Everything about this is deliberately forgiving. The two context lists are
|
|
77
|
+
* additive fields on an event this CLI already had to parse without them, and
|
|
78
|
+
* they will keep changing shape after this build is published: a version that
|
|
79
|
+
* throws — or that drops the whole run — because a list arrived as `null`, held
|
|
80
|
+
* an entry with a new key, or carried one malformed row would turn a server-side
|
|
81
|
+
* improvement into a fleet-wide outage of laptops nobody can redeploy. A
|
|
82
|
+
* missing, null, or wrong-typed list degrades to "this server doesn't send it",
|
|
83
|
+
* and one bad entry costs that entry only.
|
|
84
|
+
*/
|
|
85
|
+
function contextList(value, parseEntry) {
|
|
86
|
+
if (!Array.isArray(value))
|
|
87
|
+
return undefined;
|
|
88
|
+
const entries = [];
|
|
89
|
+
for (const raw of value) {
|
|
90
|
+
if (!raw || typeof raw !== 'object' || Array.isArray(raw))
|
|
91
|
+
continue;
|
|
92
|
+
const parsed = parseEntry(raw);
|
|
93
|
+
if (parsed !== null)
|
|
94
|
+
entries.push(parsed);
|
|
95
|
+
}
|
|
96
|
+
if (entries.length === 0)
|
|
97
|
+
return undefined;
|
|
98
|
+
return entries.slice(-MAX_CONTEXT_ENTRIES);
|
|
99
|
+
}
|
|
100
|
+
/**
|
|
101
|
+
* Validate ``reply_instructions.body_extra``.
|
|
102
|
+
*
|
|
103
|
+
* Anything that isn't a plain object is treated as absent rather than as an
|
|
104
|
+
* error: these fields are additive, and a malformed one must not cost the run
|
|
105
|
+
* its reply. Values are passed through untouched — the server owns their
|
|
106
|
+
* meaning, and coercing them here would be this CLI inventing a schema it does
|
|
107
|
+
* not have.
|
|
108
|
+
*/
|
|
109
|
+
function bodyExtra(value) {
|
|
110
|
+
if (!value || typeof value !== 'object' || Array.isArray(value))
|
|
111
|
+
return undefined;
|
|
112
|
+
const entries = value;
|
|
113
|
+
return Object.keys(entries).length > 0 ? entries : undefined;
|
|
114
|
+
}
|
|
115
|
+
function parseMessage(entry) {
|
|
116
|
+
const text = str(entry.text);
|
|
117
|
+
if (!text)
|
|
118
|
+
return null;
|
|
119
|
+
return { text, author: str(entry.author), role: str(entry.role), at: str(entry.at) };
|
|
120
|
+
}
|
|
121
|
+
function parseMemory(entry) {
|
|
122
|
+
const content = str(entry.content);
|
|
123
|
+
if (!content)
|
|
124
|
+
return null;
|
|
125
|
+
return { content, scope: str(entry.scope) };
|
|
126
|
+
}
|
|
63
127
|
/**
|
|
64
128
|
* Extract the event body from a websocket frame's payload.
|
|
65
129
|
*
|
|
@@ -87,6 +151,7 @@ export function parseRunRequest(data) {
|
|
|
87
151
|
const trigger = obj(data.trigger);
|
|
88
152
|
const replyInstructions = obj(data.reply_instructions);
|
|
89
153
|
const completion = obj(data.completion);
|
|
154
|
+
const context = obj(data.context);
|
|
90
155
|
const declaredSurface = str(replyInstructions.surface);
|
|
91
156
|
const conversationId = str(data.conversation_id) ?? str(replyInstructions.conversation_id);
|
|
92
157
|
const taskId = str(data.task_id) ?? str(replyInstructions.task_id);
|
|
@@ -120,6 +185,12 @@ export function parseRunRequest(data) {
|
|
|
120
185
|
// without a CLI release. A path with no nameable field is not postable;
|
|
121
186
|
// keep the known-surface fallback only for old events.
|
|
122
187
|
reply_body_key: str(replyInstructions.body_field) ?? legacyBodyKeyFor(surface),
|
|
188
|
+
reply_body_extra: bodyExtra(replyInstructions.body_extra),
|
|
123
189
|
completion_path: str(completion.path) ?? agentRunCompletePath(runId),
|
|
190
|
+
// Accepted both at the top level and nested under `context`, because the
|
|
191
|
+
// server can move them without a CLI release either way — and a published
|
|
192
|
+
// CLI cannot be redeployed to follow.
|
|
193
|
+
recent_messages: contextList(data.recent_messages ?? context.recent_messages, parseMessage),
|
|
194
|
+
memories: contextList(data.memories ?? context.memories, parseMemory),
|
|
124
195
|
};
|
|
125
196
|
}
|
|
@@ -39,11 +39,50 @@ export interface AgentCredentials {
|
|
|
39
39
|
agent: StoredAgent;
|
|
40
40
|
paired_at: string;
|
|
41
41
|
}
|
|
42
|
-
|
|
42
|
+
/**
|
|
43
|
+
* On-disk credential schema version.
|
|
44
|
+
*
|
|
45
|
+
* ``2`` means "``ws_url`` is trustworthy". Version ``1`` files were written by
|
|
46
|
+
* a CLI that composed that URL from a hardcoded constant, and 0.1.0 shipped a
|
|
47
|
+
* constant naming the wrong host — so a v1 file may carry a socket URL that can
|
|
48
|
+
* only ever 403. Upgrading the CLI alone would not have helped those installs:
|
|
49
|
+
* {@link streamUrlFor} prefers the stored value, so the bad URL would keep
|
|
50
|
+
* being used until the operator re-paired. The version bump is what lets a new
|
|
51
|
+
* build recognise a stale file and re-derive instead.
|
|
52
|
+
*/
|
|
53
|
+
export declare const CREDENTIALS_VERSION = 2;
|
|
54
|
+
/**
|
|
55
|
+
* Websocket URL to use for a stored credential.
|
|
56
|
+
*
|
|
57
|
+
* For a current (v2+) file this is just ``ws_url`` — the value the server
|
|
58
|
+
* reported at pair time. For a v1 file the stored URL is not trusted: it came
|
|
59
|
+
* from a client-side constant, and 0.1.0's was wrong. It is re-derived from
|
|
60
|
+
* ``base_url``, which the server did report (via ``mcp_url``) and which is
|
|
61
|
+
* therefore right even in a v1 file.
|
|
62
|
+
*
|
|
63
|
+
* @param credentials Stored credentials.
|
|
64
|
+
* @param fallback Used when neither the stored URL nor ``base_url`` is usable.
|
|
65
|
+
*/
|
|
66
|
+
export declare function streamUrlFor(credentials: AgentCredentials, fallback: string): string;
|
|
43
67
|
/** Directory holding CLI state. Overridable with ``$PROHOST_HOME`` for tests. */
|
|
44
68
|
export declare function prohostHome(env?: NodeJS.ProcessEnv): string;
|
|
45
69
|
/** Absolute path of the credential file. */
|
|
46
70
|
export declare function credentialsPath(env?: NodeJS.ProcessEnv): string;
|
|
71
|
+
/**
|
|
72
|
+
* Default working directory for the agent command.
|
|
73
|
+
*
|
|
74
|
+
* A dedicated, boring directory — **not** whatever the operator happened to
|
|
75
|
+
* `cd` into. Coding agents read their working directory as configuration: a
|
|
76
|
+
* `CLAUDE.md`, a `.claude/settings.json` permission list, a project `.mcp.json`.
|
|
77
|
+
* Launched from a checkout of some unrelated service, the agent inherits that
|
|
78
|
+
* repo's instructions and denials and answers a customer's question as if it
|
|
79
|
+
* were working on that codebase — which is exactly what happened when this was
|
|
80
|
+
* first run in production from a dev checkout. Defaulting somewhere neutral
|
|
81
|
+
* makes the inherited-context case something you opt into with `--workdir`.
|
|
82
|
+
*/
|
|
83
|
+
export declare function workspacePath(env?: NodeJS.ProcessEnv): string;
|
|
84
|
+
/** Create the workspace directory if it isn't there, and return its path. */
|
|
85
|
+
export declare function ensureWorkspace(env?: NodeJS.ProcessEnv): string;
|
|
47
86
|
/**
|
|
48
87
|
* Show enough of a secret to identify it and no more — ``ph_live_ab…`` style.
|
|
49
88
|
* Anything shorter than the prefix is reported as ``***`` so a short or
|
|
@@ -8,7 +8,61 @@
|
|
|
8
8
|
import { chmodSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
|
9
9
|
import os from 'node:os';
|
|
10
10
|
import path from 'node:path';
|
|
11
|
-
|
|
11
|
+
/**
|
|
12
|
+
* On-disk credential schema version.
|
|
13
|
+
*
|
|
14
|
+
* ``2`` means "``ws_url`` is trustworthy". Version ``1`` files were written by
|
|
15
|
+
* a CLI that composed that URL from a hardcoded constant, and 0.1.0 shipped a
|
|
16
|
+
* constant naming the wrong host — so a v1 file may carry a socket URL that can
|
|
17
|
+
* only ever 403. Upgrading the CLI alone would not have helped those installs:
|
|
18
|
+
* {@link streamUrlFor} prefers the stored value, so the bad URL would keep
|
|
19
|
+
* being used until the operator re-paired. The version bump is what lets a new
|
|
20
|
+
* build recognise a stale file and re-derive instead.
|
|
21
|
+
*/
|
|
22
|
+
export const CREDENTIALS_VERSION = 2;
|
|
23
|
+
/**
|
|
24
|
+
* Websocket URL to use for a stored credential.
|
|
25
|
+
*
|
|
26
|
+
* For a current (v2+) file this is just ``ws_url`` — the value the server
|
|
27
|
+
* reported at pair time. For a v1 file the stored URL is not trusted: it came
|
|
28
|
+
* from a client-side constant, and 0.1.0's was wrong. It is re-derived from
|
|
29
|
+
* ``base_url``, which the server did report (via ``mcp_url``) and which is
|
|
30
|
+
* therefore right even in a v1 file.
|
|
31
|
+
*
|
|
32
|
+
* @param credentials Stored credentials.
|
|
33
|
+
* @param fallback Used when neither the stored URL nor ``base_url`` is usable.
|
|
34
|
+
*/
|
|
35
|
+
export function streamUrlFor(credentials, fallback) {
|
|
36
|
+
if ((credentials.version ?? 1) >= 2 && credentials.ws_url)
|
|
37
|
+
return credentials.ws_url;
|
|
38
|
+
// Truthiness, not `??`: a hand-edited or truncated file can hold `""`, which
|
|
39
|
+
// is not a URL but is not nullish either, so `??` would return it.
|
|
40
|
+
return wsUrlFromBaseUrl(credentials.base_url) || credentials.ws_url || fallback;
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* Turn an API base URL into the websocket URL on the same host.
|
|
44
|
+
*
|
|
45
|
+
* Mirrors the server's derivation (`shared/agents/connections.py`
|
|
46
|
+
* ``pairing_endpoints``): only plain ``http`` maps to ``ws``, so a local dev
|
|
47
|
+
* base keeps working and nothing else can downgrade a socket that carries an
|
|
48
|
+
* API key. Returns ``undefined`` for anything not parseable as a URL.
|
|
49
|
+
*/
|
|
50
|
+
function wsUrlFromBaseUrl(baseUrl) {
|
|
51
|
+
if (!baseUrl)
|
|
52
|
+
return undefined;
|
|
53
|
+
let parsed;
|
|
54
|
+
try {
|
|
55
|
+
parsed = new URL(baseUrl);
|
|
56
|
+
}
|
|
57
|
+
catch {
|
|
58
|
+
return undefined;
|
|
59
|
+
}
|
|
60
|
+
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:')
|
|
61
|
+
return undefined;
|
|
62
|
+
const scheme = parsed.protocol === 'http:' ? 'ws' : 'wss';
|
|
63
|
+
const prefix = parsed.pathname.replace(/\/+$/, '');
|
|
64
|
+
return `${scheme}://${parsed.host}${prefix}/ws/webhooks`;
|
|
65
|
+
}
|
|
12
66
|
/** Directory holding CLI state. Overridable with ``$PROHOST_HOME`` for tests. */
|
|
13
67
|
export function prohostHome(env = process.env) {
|
|
14
68
|
return env.PROHOST_HOME ?? path.join(os.homedir(), '.prohost');
|
|
@@ -17,6 +71,27 @@ export function prohostHome(env = process.env) {
|
|
|
17
71
|
export function credentialsPath(env = process.env) {
|
|
18
72
|
return path.join(prohostHome(env), 'agent.json');
|
|
19
73
|
}
|
|
74
|
+
/**
|
|
75
|
+
* Default working directory for the agent command.
|
|
76
|
+
*
|
|
77
|
+
* A dedicated, boring directory — **not** whatever the operator happened to
|
|
78
|
+
* `cd` into. Coding agents read their working directory as configuration: a
|
|
79
|
+
* `CLAUDE.md`, a `.claude/settings.json` permission list, a project `.mcp.json`.
|
|
80
|
+
* Launched from a checkout of some unrelated service, the agent inherits that
|
|
81
|
+
* repo's instructions and denials and answers a customer's question as if it
|
|
82
|
+
* were working on that codebase — which is exactly what happened when this was
|
|
83
|
+
* first run in production from a dev checkout. Defaulting somewhere neutral
|
|
84
|
+
* makes the inherited-context case something you opt into with `--workdir`.
|
|
85
|
+
*/
|
|
86
|
+
export function workspacePath(env = process.env) {
|
|
87
|
+
return path.join(prohostHome(env), 'workspace');
|
|
88
|
+
}
|
|
89
|
+
/** Create the workspace directory if it isn't there, and return its path. */
|
|
90
|
+
export function ensureWorkspace(env = process.env) {
|
|
91
|
+
const dir = workspacePath(env);
|
|
92
|
+
mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
93
|
+
return dir;
|
|
94
|
+
}
|
|
20
95
|
/**
|
|
21
96
|
* Show enough of a secret to identify it and no more — ``ph_live_ab…`` style.
|
|
22
97
|
* Anything shorter than the prefix is reported as ``***`` so a short or
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Keeping `agent run` alive across logouts, crashes, and reboots.
|
|
3
|
+
*
|
|
4
|
+
* A paired agent that only runs while a terminal window is open is not a
|
|
5
|
+
* teammate — it is a demo. Someone @-mentions it at 2am, the laptop had
|
|
6
|
+
* rebooted for an update, and the run times out server-side with no indication
|
|
7
|
+
* that anything is wrong on this end. So the harness ships a way to hand itself
|
|
8
|
+
* to the OS supervisor.
|
|
9
|
+
*
|
|
10
|
+
* On macOS that is a per-user launchd agent: a plist in `~/Library/LaunchAgents`
|
|
11
|
+
* with `KeepAlive`, bootstrapped into the `gui/$UID` domain so it starts at
|
|
12
|
+
* login and is restarted whenever it exits. On Linux there is no equivalent
|
|
13
|
+
* this CLI can write safely (systemd-user layouts differ per distro and per
|
|
14
|
+
* init), so we print a unit file to install by hand rather than guess.
|
|
15
|
+
*
|
|
16
|
+
* Only plist *generation* is unit-tested. Asserting on `launchctl` would be
|
|
17
|
+
* testing Apple's binary, and doing it for real in CI would install a live
|
|
18
|
+
* daemon on the runner.
|
|
19
|
+
*/
|
|
20
|
+
/** Reverse-DNS prefix every generated label starts with. */
|
|
21
|
+
export declare const LAUNCHD_LABEL_PREFIX = "ai.prohost.agent";
|
|
22
|
+
export interface DaemonSpec {
|
|
23
|
+
/** launchd label, also the plist filename stem. */
|
|
24
|
+
label: string;
|
|
25
|
+
/** argv[0] plus arguments, exactly as launchd will exec them. */
|
|
26
|
+
programArguments: string[];
|
|
27
|
+
/** Working directory for the harness process. */
|
|
28
|
+
workdir: string;
|
|
29
|
+
/** Combined stdout/stderr destination. */
|
|
30
|
+
logPath: string;
|
|
31
|
+
/** Extra environment launchd must set — launchd inherits almost nothing. */
|
|
32
|
+
env?: Record<string, string>;
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* Label for the daemon serving one `$PROHOST_HOME`.
|
|
36
|
+
*
|
|
37
|
+
* Keyed on the home directory rather than on the agent id, because the home
|
|
38
|
+
* directory is what actually scopes an install: there is exactly one
|
|
39
|
+
* `agent.json` in it, so every daemon pointed at the same home runs the *same*
|
|
40
|
+
* agent no matter which agent was paired when it was installed. An agent-keyed
|
|
41
|
+
* label got that backwards — re-pairing and re-installing produced a second
|
|
42
|
+
* label whose plist loaded the same single credential, so both jobs came up as
|
|
43
|
+
* the new agent (two harnesses, duplicate work) and `uninstall-daemon` could no
|
|
44
|
+
* longer name the first one to remove it.
|
|
45
|
+
*
|
|
46
|
+
* Running two agents at once is still supported, and this is what makes it
|
|
47
|
+
* honest: give each one its own `$PROHOST_HOME`, and they get separate
|
|
48
|
+
* credentials, separate ledgers, and separate daemons.
|
|
49
|
+
*
|
|
50
|
+
* A digest rather than the path itself — a label is a filename, and home
|
|
51
|
+
* directories contain spaces, `~`, and non-ASCII.
|
|
52
|
+
*/
|
|
53
|
+
export declare function launchdLabel(home: string): string;
|
|
54
|
+
/** Where a label's plist lives. */
|
|
55
|
+
export declare function launchdPlistPath(label: string, home?: string): string;
|
|
56
|
+
/**
|
|
57
|
+
* Render the launchd property list for a daemon spec.
|
|
58
|
+
*
|
|
59
|
+
* `KeepAlive` is unconditional `true` rather than `SuccessfulExit: false`:
|
|
60
|
+
* `agent run` exits 0 on a clean shutdown *and* on a fatal auth error, and a
|
|
61
|
+
* daemon that stops restarting after a revoked-then-restored credential is
|
|
62
|
+
* exactly the silent-agent failure this whole feature exists to prevent.
|
|
63
|
+
* Restarting into a rejected credential is loud and cheap; not restarting is
|
|
64
|
+
* quiet and expensive.
|
|
65
|
+
*/
|
|
66
|
+
export declare function buildLaunchdPlist(spec: DaemonSpec): string;
|
|
67
|
+
/**
|
|
68
|
+
* The systemd-user unit we print on Linux.
|
|
69
|
+
*
|
|
70
|
+
* Printed rather than installed. `systemctl --user` needs a running user
|
|
71
|
+
* manager and lingering enabled for it to survive logout, neither of which this
|
|
72
|
+
* CLI can arrange without making decisions about the machine that aren't its
|
|
73
|
+
* to make.
|
|
74
|
+
*/
|
|
75
|
+
export declare function buildSystemdUnit(spec: DaemonSpec): string;
|
|
76
|
+
/** Reconstruct the `agent run` argv a daemon should launch. */
|
|
77
|
+
export interface HarnessInvocation {
|
|
78
|
+
/** Interpreter to exec — normally the Node binary running this process. */
|
|
79
|
+
execPath: string;
|
|
80
|
+
/** Absolute path of the CLI entrypoint script. */
|
|
81
|
+
scriptPath: string;
|
|
82
|
+
/** The `--exec` command, verbatim. */
|
|
83
|
+
exec: string;
|
|
84
|
+
workdir: string;
|
|
85
|
+
timeoutSeconds?: number;
|
|
86
|
+
machine?: string;
|
|
87
|
+
url?: string;
|
|
88
|
+
allowUnverified?: boolean;
|
|
89
|
+
/** Gate tools behind permission prompts (the `--safe-tools` opt-out). */
|
|
90
|
+
safeTools?: boolean;
|
|
91
|
+
}
|
|
92
|
+
export declare function harnessArguments(invocation: HarnessInvocation): string[];
|
|
93
|
+
/** Default log destination — alongside the credential and the run ledger. */
|
|
94
|
+
export declare function agentLogPath(env?: NodeJS.ProcessEnv): string;
|
|
95
|
+
export interface InstallResult {
|
|
96
|
+
ok: boolean;
|
|
97
|
+
/** Human-readable report, already formatted for the terminal. */
|
|
98
|
+
message: string;
|
|
99
|
+
/** Path written, when one was. */
|
|
100
|
+
plistPath?: string;
|
|
101
|
+
}
|
|
102
|
+
type Runner = (command: string, args: string[]) => {
|
|
103
|
+
status: number | null;
|
|
104
|
+
stderr: string;
|
|
105
|
+
};
|
|
106
|
+
/**
|
|
107
|
+
* Write the plist and hand it to launchd.
|
|
108
|
+
*
|
|
109
|
+
* `bootout` first, ignoring its result: bootstrapping a label that is already
|
|
110
|
+
* loaded fails outright, so a plain re-install of an agent whose `--exec`
|
|
111
|
+
* changed would otherwise keep running the old command and report success.
|
|
112
|
+
*/
|
|
113
|
+
export declare function installLaunchdDaemon(spec: DaemonSpec, options?: {
|
|
114
|
+
home?: string;
|
|
115
|
+
uid?: number;
|
|
116
|
+
run?: Runner;
|
|
117
|
+
}): InstallResult;
|
|
118
|
+
/**
|
|
119
|
+
* Unload the daemon and delete its plist. Missing pieces are not an error.
|
|
120
|
+
*
|
|
121
|
+
* Reports whether anything was actually there. "✓ Removed" printed over an
|
|
122
|
+
* empty home reads as confirmation that a daemon has been stopped — which is
|
|
123
|
+
* exactly the wrong thing to tell someone whose agent is still running from a
|
|
124
|
+
* *different* `$PROHOST_HOME` than the one they just pointed this at.
|
|
125
|
+
*/
|
|
126
|
+
export declare function uninstallLaunchdDaemon(label: string, options?: {
|
|
127
|
+
home?: string;
|
|
128
|
+
uid?: number;
|
|
129
|
+
run?: Runner;
|
|
130
|
+
}): InstallResult;
|
|
131
|
+
export {};
|
|
@@ -0,0 +1,274 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Keeping `agent run` alive across logouts, crashes, and reboots.
|
|
3
|
+
*
|
|
4
|
+
* A paired agent that only runs while a terminal window is open is not a
|
|
5
|
+
* teammate — it is a demo. Someone @-mentions it at 2am, the laptop had
|
|
6
|
+
* rebooted for an update, and the run times out server-side with no indication
|
|
7
|
+
* that anything is wrong on this end. So the harness ships a way to hand itself
|
|
8
|
+
* to the OS supervisor.
|
|
9
|
+
*
|
|
10
|
+
* On macOS that is a per-user launchd agent: a plist in `~/Library/LaunchAgents`
|
|
11
|
+
* with `KeepAlive`, bootstrapped into the `gui/$UID` domain so it starts at
|
|
12
|
+
* login and is restarted whenever it exits. On Linux there is no equivalent
|
|
13
|
+
* this CLI can write safely (systemd-user layouts differ per distro and per
|
|
14
|
+
* init), so we print a unit file to install by hand rather than guess.
|
|
15
|
+
*
|
|
16
|
+
* Only plist *generation* is unit-tested. Asserting on `launchctl` would be
|
|
17
|
+
* testing Apple's binary, and doing it for real in CI would install a live
|
|
18
|
+
* daemon on the runner.
|
|
19
|
+
*/
|
|
20
|
+
import { spawnSync } from 'node:child_process';
|
|
21
|
+
import { createHash } from 'node:crypto';
|
|
22
|
+
import { chmodSync, existsSync, mkdirSync, rmSync, writeFileSync } from 'node:fs';
|
|
23
|
+
import os from 'node:os';
|
|
24
|
+
import path from 'node:path';
|
|
25
|
+
import process from 'node:process';
|
|
26
|
+
import { prohostHome } from './credentials.js';
|
|
27
|
+
/** Reverse-DNS prefix every generated label starts with. */
|
|
28
|
+
export const LAUNCHD_LABEL_PREFIX = 'ai.prohost.agent';
|
|
29
|
+
/** Hex characters of the home-directory digest that go into the label. */
|
|
30
|
+
const LABEL_DIGEST_CHARS = 8;
|
|
31
|
+
/**
|
|
32
|
+
* Label for the daemon serving one `$PROHOST_HOME`.
|
|
33
|
+
*
|
|
34
|
+
* Keyed on the home directory rather than on the agent id, because the home
|
|
35
|
+
* directory is what actually scopes an install: there is exactly one
|
|
36
|
+
* `agent.json` in it, so every daemon pointed at the same home runs the *same*
|
|
37
|
+
* agent no matter which agent was paired when it was installed. An agent-keyed
|
|
38
|
+
* label got that backwards — re-pairing and re-installing produced a second
|
|
39
|
+
* label whose plist loaded the same single credential, so both jobs came up as
|
|
40
|
+
* the new agent (two harnesses, duplicate work) and `uninstall-daemon` could no
|
|
41
|
+
* longer name the first one to remove it.
|
|
42
|
+
*
|
|
43
|
+
* Running two agents at once is still supported, and this is what makes it
|
|
44
|
+
* honest: give each one its own `$PROHOST_HOME`, and they get separate
|
|
45
|
+
* credentials, separate ledgers, and separate daemons.
|
|
46
|
+
*
|
|
47
|
+
* A digest rather than the path itself — a label is a filename, and home
|
|
48
|
+
* directories contain spaces, `~`, and non-ASCII.
|
|
49
|
+
*/
|
|
50
|
+
export function launchdLabel(home) {
|
|
51
|
+
const digest = createHash('sha256').update(path.resolve(home)).digest('hex').slice(0, LABEL_DIGEST_CHARS);
|
|
52
|
+
return `${LAUNCHD_LABEL_PREFIX}.${digest}`;
|
|
53
|
+
}
|
|
54
|
+
/** Where a label's plist lives. */
|
|
55
|
+
export function launchdPlistPath(label, home = os.homedir()) {
|
|
56
|
+
return path.join(home, 'Library', 'LaunchAgents', `${label}.plist`);
|
|
57
|
+
}
|
|
58
|
+
/**
|
|
59
|
+
* XML-escape a plist string value.
|
|
60
|
+
*
|
|
61
|
+
* Not optional politeness: an `--exec` command is arbitrary shell the operator
|
|
62
|
+
* typed, and `--exec 'sh -c "foo && bar"'` is a perfectly ordinary thing to
|
|
63
|
+
* want. Interpolated raw, its `&` alone produces a plist that `launchctl`
|
|
64
|
+
* rejects as malformed — with an error that points at the file, not at the
|
|
65
|
+
* flag that poisoned it.
|
|
66
|
+
*/
|
|
67
|
+
function xmlEscape(value) {
|
|
68
|
+
return value
|
|
69
|
+
.replace(/&/g, '&')
|
|
70
|
+
.replace(/</g, '<')
|
|
71
|
+
.replace(/>/g, '>')
|
|
72
|
+
.replace(/"/g, '"')
|
|
73
|
+
.replace(/'/g, ''');
|
|
74
|
+
}
|
|
75
|
+
function stringEntries(values, indent) {
|
|
76
|
+
return Object.entries(values).flatMap(([key, value]) => [
|
|
77
|
+
`${indent}<key>${xmlEscape(key)}</key>`,
|
|
78
|
+
`${indent}<string>${xmlEscape(value)}</string>`,
|
|
79
|
+
]);
|
|
80
|
+
}
|
|
81
|
+
/**
|
|
82
|
+
* Render the launchd property list for a daemon spec.
|
|
83
|
+
*
|
|
84
|
+
* `KeepAlive` is unconditional `true` rather than `SuccessfulExit: false`:
|
|
85
|
+
* `agent run` exits 0 on a clean shutdown *and* on a fatal auth error, and a
|
|
86
|
+
* daemon that stops restarting after a revoked-then-restored credential is
|
|
87
|
+
* exactly the silent-agent failure this whole feature exists to prevent.
|
|
88
|
+
* Restarting into a rejected credential is loud and cheap; not restarting is
|
|
89
|
+
* quiet and expensive.
|
|
90
|
+
*/
|
|
91
|
+
export function buildLaunchdPlist(spec) {
|
|
92
|
+
const lines = [
|
|
93
|
+
'<?xml version="1.0" encoding="UTF-8"?>',
|
|
94
|
+
'<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" ' +
|
|
95
|
+
'"http://www.apple.com/DTDs/PropertyList-1.0.dtd">',
|
|
96
|
+
'<plist version="1.0">',
|
|
97
|
+
'<dict>',
|
|
98
|
+
' <key>Label</key>',
|
|
99
|
+
` <string>${xmlEscape(spec.label)}</string>`,
|
|
100
|
+
' <key>ProgramArguments</key>',
|
|
101
|
+
' <array>',
|
|
102
|
+
...spec.programArguments.map((arg) => ` <string>${xmlEscape(arg)}</string>`),
|
|
103
|
+
' </array>',
|
|
104
|
+
' <key>WorkingDirectory</key>',
|
|
105
|
+
` <string>${xmlEscape(spec.workdir)}</string>`,
|
|
106
|
+
' <key>RunAtLoad</key>',
|
|
107
|
+
' <true/>',
|
|
108
|
+
' <key>KeepAlive</key>',
|
|
109
|
+
' <true/>',
|
|
110
|
+
// Without this a crash-looping harness would be restarted as fast as it can
|
|
111
|
+
// fail, which on a bad credential means hammering the pairing endpoint.
|
|
112
|
+
' <key>ThrottleInterval</key>',
|
|
113
|
+
' <integer>10</integer>',
|
|
114
|
+
' <key>StandardOutPath</key>',
|
|
115
|
+
` <string>${xmlEscape(spec.logPath)}</string>`,
|
|
116
|
+
' <key>StandardErrorPath</key>',
|
|
117
|
+
` <string>${xmlEscape(spec.logPath)}</string>`,
|
|
118
|
+
];
|
|
119
|
+
if (spec.env && Object.keys(spec.env).length > 0) {
|
|
120
|
+
lines.push(' <key>EnvironmentVariables</key>', ' <dict>', ...stringEntries(spec.env, ' '), ' </dict>');
|
|
121
|
+
}
|
|
122
|
+
lines.push('</dict>', '</plist>');
|
|
123
|
+
return `${lines.join('\n')}\n`;
|
|
124
|
+
}
|
|
125
|
+
/**
|
|
126
|
+
* Quote one `ExecStart` word for systemd.
|
|
127
|
+
*
|
|
128
|
+
* systemd re-splits `ExecStart` on whitespace, so joining argv with spaces
|
|
129
|
+
* silently corrupts any argument that contains one — and the very first thing
|
|
130
|
+
* this CLI passes is `--exec 'claude -p'`. Unquoted, systemd hands `--exec` the
|
|
131
|
+
* value `claude` and treats `-p` as a flag of its own; `codex exec -` loses two
|
|
132
|
+
* words the same way. Quoting is not decoration here, it is the difference
|
|
133
|
+
* between a working unit and one that starts the wrong process.
|
|
134
|
+
*/
|
|
135
|
+
function systemdQuote(value) {
|
|
136
|
+
return `"${value.replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"`;
|
|
137
|
+
}
|
|
138
|
+
/**
|
|
139
|
+
* The systemd-user unit we print on Linux.
|
|
140
|
+
*
|
|
141
|
+
* Printed rather than installed. `systemctl --user` needs a running user
|
|
142
|
+
* manager and lingering enabled for it to survive logout, neither of which this
|
|
143
|
+
* CLI can arrange without making decisions about the machine that aren't its
|
|
144
|
+
* to make.
|
|
145
|
+
*/
|
|
146
|
+
export function buildSystemdUnit(spec) {
|
|
147
|
+
const environment = Object.entries(spec.env ?? {})
|
|
148
|
+
.map(([key, value]) => `Environment=${key}=${systemdQuote(value)}`)
|
|
149
|
+
.join('\n');
|
|
150
|
+
return [
|
|
151
|
+
'[Unit]',
|
|
152
|
+
'Description=ProhostAI paired agent',
|
|
153
|
+
'After=network-online.target',
|
|
154
|
+
'',
|
|
155
|
+
'[Service]',
|
|
156
|
+
'Type=simple',
|
|
157
|
+
`WorkingDirectory=${systemdQuote(spec.workdir)}`,
|
|
158
|
+
`ExecStart=${spec.programArguments.map(systemdQuote).join(' ')}`,
|
|
159
|
+
'Restart=always',
|
|
160
|
+
'RestartSec=10',
|
|
161
|
+
...(environment ? [environment] : []),
|
|
162
|
+
'',
|
|
163
|
+
'[Install]',
|
|
164
|
+
'WantedBy=default.target',
|
|
165
|
+
'',
|
|
166
|
+
].join('\n');
|
|
167
|
+
}
|
|
168
|
+
export function harnessArguments(invocation) {
|
|
169
|
+
const args = [
|
|
170
|
+
invocation.execPath,
|
|
171
|
+
invocation.scriptPath,
|
|
172
|
+
'agent',
|
|
173
|
+
'run',
|
|
174
|
+
'--exec',
|
|
175
|
+
invocation.exec,
|
|
176
|
+
'--workdir',
|
|
177
|
+
invocation.workdir,
|
|
178
|
+
];
|
|
179
|
+
if (invocation.timeoutSeconds !== undefined)
|
|
180
|
+
args.push('--timeout', String(invocation.timeoutSeconds));
|
|
181
|
+
if (invocation.machine)
|
|
182
|
+
args.push('--machine', invocation.machine);
|
|
183
|
+
if (invocation.url)
|
|
184
|
+
args.push('--url', invocation.url);
|
|
185
|
+
if (invocation.allowUnverified)
|
|
186
|
+
args.push('--allow-unverified');
|
|
187
|
+
if (invocation.safeTools)
|
|
188
|
+
args.push('--safe-tools');
|
|
189
|
+
return args;
|
|
190
|
+
}
|
|
191
|
+
/** Default log destination — alongside the credential and the run ledger. */
|
|
192
|
+
export function agentLogPath(env = process.env) {
|
|
193
|
+
return path.join(prohostHome(env), 'agent.log');
|
|
194
|
+
}
|
|
195
|
+
const defaultRunner = (command, args) => {
|
|
196
|
+
const result = spawnSync(command, args, { encoding: 'utf8' });
|
|
197
|
+
return { status: result.status, stderr: result.stderr ?? '' };
|
|
198
|
+
};
|
|
199
|
+
/**
|
|
200
|
+
* Write the plist and hand it to launchd.
|
|
201
|
+
*
|
|
202
|
+
* `bootout` first, ignoring its result: bootstrapping a label that is already
|
|
203
|
+
* loaded fails outright, so a plain re-install of an agent whose `--exec`
|
|
204
|
+
* changed would otherwise keep running the old command and report success.
|
|
205
|
+
*/
|
|
206
|
+
export function installLaunchdDaemon(spec, options = {}) {
|
|
207
|
+
const home = options.home ?? os.homedir();
|
|
208
|
+
const uid = options.uid ?? process.getuid?.() ?? 0;
|
|
209
|
+
const run = options.run ?? defaultRunner;
|
|
210
|
+
const plistPath = launchdPlistPath(spec.label, home);
|
|
211
|
+
const domain = `gui/${uid}`;
|
|
212
|
+
mkdirSync(path.dirname(plistPath), { recursive: true });
|
|
213
|
+
// 0600 rather than the usual 0644 for a LaunchAgent: this plist carries the
|
|
214
|
+
// installing shell's environment, and while today that is only PATH and
|
|
215
|
+
// PROHOST_HOME, a file whose whole job is "environment for a credentialed
|
|
216
|
+
// process" should not be readable by other local accounts. launchd runs it as
|
|
217
|
+
// the owning user, so nothing needs wider access.
|
|
218
|
+
writeFileSync(plistPath, buildLaunchdPlist(spec), { mode: 0o600 });
|
|
219
|
+
chmodSync(plistPath, 0o600);
|
|
220
|
+
run('launchctl', ['bootout', `${domain}/${spec.label}`]);
|
|
221
|
+
const bootstrap = run('launchctl', ['bootstrap', domain, plistPath]);
|
|
222
|
+
if (bootstrap.status !== 0) {
|
|
223
|
+
return {
|
|
224
|
+
ok: false,
|
|
225
|
+
plistPath,
|
|
226
|
+
message: `Wrote ${plistPath}, but \`launchctl bootstrap ${domain}\` failed` +
|
|
227
|
+
`${bootstrap.stderr.trim() ? `: ${bootstrap.stderr.trim()}` : '.'}\n` +
|
|
228
|
+
`Load it by hand with: launchctl bootstrap ${domain} ${plistPath}`,
|
|
229
|
+
};
|
|
230
|
+
}
|
|
231
|
+
run('launchctl', ['enable', `${domain}/${spec.label}`]);
|
|
232
|
+
return {
|
|
233
|
+
ok: true,
|
|
234
|
+
plistPath,
|
|
235
|
+
message: `✓ Installed ${spec.label}\n` +
|
|
236
|
+
` plist: ${plistPath}\n` +
|
|
237
|
+
` logs: ${spec.logPath}\n` +
|
|
238
|
+
` The agent now starts at login and is restarted if it exits.\n` +
|
|
239
|
+
// Said at install time because the alternative is finding out from a
|
|
240
|
+
// crash-loop in a log nobody is watching: a supervised process does not
|
|
241
|
+
// inherit the shell you installed from, so an --exec that depends on an
|
|
242
|
+
// exported API key works interactively and fails here.
|
|
243
|
+
` It runs with only PATH and PROHOST_HOME from this shell. If your\n` +
|
|
244
|
+
` command needs other environment (an API key, a proxy setting), point\n` +
|
|
245
|
+
` --exec at a wrapper script that sets it.\n` +
|
|
246
|
+
` Stop it with: prohost agent uninstall-daemon`,
|
|
247
|
+
};
|
|
248
|
+
}
|
|
249
|
+
/**
|
|
250
|
+
* Unload the daemon and delete its plist. Missing pieces are not an error.
|
|
251
|
+
*
|
|
252
|
+
* Reports whether anything was actually there. "✓ Removed" printed over an
|
|
253
|
+
* empty home reads as confirmation that a daemon has been stopped — which is
|
|
254
|
+
* exactly the wrong thing to tell someone whose agent is still running from a
|
|
255
|
+
* *different* `$PROHOST_HOME` than the one they just pointed this at.
|
|
256
|
+
*/
|
|
257
|
+
export function uninstallLaunchdDaemon(label, options = {}) {
|
|
258
|
+
const home = options.home ?? os.homedir();
|
|
259
|
+
const uid = options.uid ?? process.getuid?.() ?? 0;
|
|
260
|
+
const run = options.run ?? defaultRunner;
|
|
261
|
+
const plistPath = launchdPlistPath(label, home);
|
|
262
|
+
const existed = existsSync(plistPath);
|
|
263
|
+
run('launchctl', ['bootout', `gui/${uid}/${label}`]);
|
|
264
|
+
rmSync(plistPath, { force: true });
|
|
265
|
+
return {
|
|
266
|
+
ok: true,
|
|
267
|
+
plistPath,
|
|
268
|
+
message: existed
|
|
269
|
+
? `✓ Removed ${label} (${plistPath})`
|
|
270
|
+
: `No daemon was installed for this PROHOST_HOME (looked for ${plistPath}).\n` +
|
|
271
|
+
` Nothing to remove. If an agent is still running, it belongs to a\n` +
|
|
272
|
+
` different PROHOST_HOME — check with: launchctl list | grep ${LAUNCHD_LABEL_PREFIX}`,
|
|
273
|
+
};
|
|
274
|
+
}
|
package/dist/agent/pair.d.ts
CHANGED
|
@@ -15,7 +15,11 @@ export interface PairOptions {
|
|
|
15
15
|
code: string;
|
|
16
16
|
/** Public API base. Defaults to production. */
|
|
17
17
|
baseUrl?: string;
|
|
18
|
-
/**
|
|
18
|
+
/**
|
|
19
|
+
* Explicit websocket endpoint to store for `prohost agent run` (`--url` /
|
|
20
|
+
* `$PROHOST_WS_URL`). An operator override, so it beats what the server
|
|
21
|
+
* reports; leave it unset and the server's `ws_url` is used.
|
|
22
|
+
*/
|
|
19
23
|
wsUrl?: string;
|
|
20
24
|
/**
|
|
21
25
|
* HTTPS endpoint to ALSO deliver agent events to. Optional and rarely
|