@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.
@@ -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
- export declare const CREDENTIALS_VERSION = 1;
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
- export const CREDENTIALS_VERSION = 1;
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, '&amp;')
70
+ .replace(/</g, '&lt;')
71
+ .replace(/>/g, '&gt;')
72
+ .replace(/"/g, '&quot;')
73
+ .replace(/'/g, '&apos;');
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
+ }
@@ -15,7 +15,11 @@ export interface PairOptions {
15
15
  code: string;
16
16
  /** Public API base. Defaults to production. */
17
17
  baseUrl?: string;
18
- /** Websocket endpoint stored for `prohost agent run`. */
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