@prohost/cli 0.1.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.
@@ -0,0 +1,149 @@
1
+ /**
2
+ * `prohost agent pair` — redeem a one-time pairing code.
3
+ *
4
+ * The host generates the code in ProhostAI ("Hire an AI employee" → "Bring your
5
+ * own agent"); this exchanges it for a scoped API key, the webhook signing
6
+ * secret, and the MCP URL, then writes them to `~/.prohost/agent.json`.
7
+ *
8
+ * Codes are single-use and expire after 15 minutes. Every redeem failure comes
9
+ * back as one generic 404 by design, so the error text here can't be more
10
+ * specific than "invalid, expired, or already used".
11
+ */
12
+ import { USER_AGENT } from '../version.js';
13
+ import { DEFAULT_API_BASE_URL, REDEEM_PATH } from './contract.js';
14
+ import { CREDENTIALS_VERSION, clearCredentials, maskSecret, saveCredentials, tryLoadCredentials, } from './credentials.js';
15
+ import { DEFAULT_WS_URL } from '../listen.js';
16
+ export class PairError extends Error {
17
+ }
18
+ const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
19
+ /** Whether a value is a UUID string, which is how the server types ids. */
20
+ function isUuid(value) {
21
+ return typeof value === 'string' && UUID_PATTERN.test(value);
22
+ }
23
+ /** Derive the API base from the server-reported MCP URL, so the server wins. */
24
+ function baseFromMcpUrl(mcpUrl, fallback) {
25
+ return mcpUrl.endsWith('/mcp') ? mcpUrl.slice(0, -'/mcp'.length) : fallback;
26
+ }
27
+ /**
28
+ * Redeem ``code`` and persist the resulting credentials.
29
+ *
30
+ * @throws PairError when the code is rejected or the response is malformed.
31
+ */
32
+ export async function runPair(options) {
33
+ const fetchImpl = options.fetchImpl ?? fetch;
34
+ const onLine = options.onLine ?? ((line) => process.stdout.write(`${line}\n`));
35
+ const baseUrl = (options.baseUrl ?? DEFAULT_API_BASE_URL).replace(/\/+$/, '');
36
+ const wsUrl = options.wsUrl ?? DEFAULT_WS_URL;
37
+ if (options.webhookUrl && !options.webhookUrl.startsWith('https://')) {
38
+ throw new PairError('--webhook-url must be an https:// URL');
39
+ }
40
+ let response;
41
+ try {
42
+ response = await fetchImpl(`${baseUrl}${REDEEM_PATH}`, {
43
+ method: 'POST',
44
+ headers: { 'Content-Type': 'application/json', 'User-Agent': USER_AGENT },
45
+ body: JSON.stringify({
46
+ code: options.code,
47
+ ...(options.webhookUrl ? { webhook_url: options.webhookUrl } : {}),
48
+ }),
49
+ });
50
+ }
51
+ catch (err) {
52
+ throw new PairError(`Could not reach ${baseUrl}: ${err instanceof Error ? err.message : String(err)}`);
53
+ }
54
+ if (response.status === 404) {
55
+ throw new PairError('Pairing code is invalid, expired, or already used. Codes are single-use and ' +
56
+ 'expire 15 minutes after they are generated — generate a fresh one.');
57
+ }
58
+ if (response.status === 429) {
59
+ throw new PairError('Too many pairing attempts from this IP. Wait an hour and retry.');
60
+ }
61
+ if (!response.ok) {
62
+ throw new PairError(`Pairing failed with HTTP ${response.status}.`);
63
+ }
64
+ let body;
65
+ try {
66
+ body = (await response.json());
67
+ }
68
+ catch {
69
+ throw new PairError('Pairing succeeded but the response was not valid JSON.');
70
+ }
71
+ if (!body || typeof body.api_key !== 'string' || !body.agent) {
72
+ throw new PairError('Pairing response was missing an api_key or agent.');
73
+ }
74
+ // The response is JSON off the wire, so the TypeScript cast proves nothing.
75
+ // Check the two fields that decide how this pairing behaves, rather than
76
+ // coercing them and reporting the consequences much later as a websocket
77
+ // failure or an "also POSTed to undefined" line.
78
+ if (typeof body.stream_only !== 'boolean') {
79
+ throw new PairError('Pairing response had no usable `stream_only` flag. The server may predate ' +
80
+ 'stream-only pairing — check that it is running a current build.');
81
+ }
82
+ // The server types this `UUID | None`, so anything present that isn't a UUID
83
+ // is a malformed response. A looser "non-empty string" check would let a
84
+ // truncated or placeholder id through to be stored, and it would only fail
85
+ // later at the websocket where nothing points back to pairing.
86
+ if (body.subscription_id !== null &&
87
+ body.subscription_id !== undefined &&
88
+ !isUuid(body.subscription_id)) {
89
+ throw new PairError(`Pairing response contained an invalid subscription_id: ${JSON.stringify(body.subscription_id)}`);
90
+ }
91
+ // Redeem provisions a subscription on every path, stream-only included. A
92
+ // missing one means provisioning failed after the code was already spent
93
+ // (`shared/agents/connections.py` degrades rather than re-minting), and both
94
+ // the run dispatcher and the websocket auth gate refuse a connection that
95
+ // has none — so this credential can never receive a run. Fail here instead
96
+ // of storing it and letting the operator meet it later as an auth error
97
+ // with no obvious cause.
98
+ if (!body.subscription_id) {
99
+ // Redeem revokes the previously paired credential unconditionally, even on
100
+ // this path (`connections.py` step 6 runs after the provisioning failure is
101
+ // swallowed). So on a re-pair of the same agent, whatever is on disk is
102
+ // already dead — leaving it would send the operator chasing an
103
+ // `invalid_api_key` at `agent run` instead of the real cause.
104
+ const stored = tryLoadCredentials(options.env);
105
+ const staleRevoked = stored?.agent?.id === body.agent.id && clearCredentials(options.env);
106
+ throw new PairError('Pairing completed but no event subscription was provisioned, so this agent ' +
107
+ 'would never receive a run.\n' +
108
+ ' The pairing code is spent either way — generate a fresh one and pair again.\n' +
109
+ (staleRevoked
110
+ ? ' The previously stored credential for this agent was revoked by this ' +
111
+ 'redeem and has been removed.'
112
+ : ' Nothing was saved.'));
113
+ }
114
+ const credentials = {
115
+ version: CREDENTIALS_VERSION,
116
+ base_url: baseFromMcpUrl(body.mcp_url ?? '', baseUrl),
117
+ ws_url: wsUrl,
118
+ api_key: body.api_key,
119
+ webhook_secret: body.webhook_secret ?? null,
120
+ subscription_id: body.subscription_id ?? null,
121
+ stream_only: body.stream_only,
122
+ mcp_url: body.mcp_url,
123
+ events: body.events ?? [],
124
+ agent: body.agent,
125
+ paired_at: new Date().toISOString(),
126
+ };
127
+ const file = saveCredentials(credentials, options.env);
128
+ onLine(`✓ Paired as "${body.agent.name}" (agent ${body.agent.id})`);
129
+ onLine(` API key ${maskSecret(body.api_key)} (stored, never printed in full)`);
130
+ onLine(` Credentials ${file} (chmod 600)`);
131
+ onLine(` MCP URL ${body.mcp_url}`);
132
+ onLine(` Events ${credentials.events.join(', ') || '(none)'}`);
133
+ onLine(` Delivery ${credentials.stream_only
134
+ ? 'stream-only — events arrive over the websocket, nothing is POSTed to you'
135
+ : `over the websocket, and POSTed to ${options.webhookUrl ?? 'your registered endpoint'}`}`);
136
+ if (!credentials.webhook_secret) {
137
+ // Without a secret the runner can't prove an event came from ProhostAI,
138
+ // and a run event makes this machine execute a command — so it refuses to
139
+ // run rather than trusting it.
140
+ onLine('');
141
+ onLine(' ! No signing secret was issued, so incoming events cannot be verified');
142
+ onLine(' and runs will be refused. Re-pair, or pass --allow-unverified if you');
143
+ onLine(' accept unauthenticated triggers.');
144
+ }
145
+ onLine('');
146
+ onLine('Next: start your agent and leave it running.');
147
+ onLine(" prohost agent run --exec 'claude -p'");
148
+ return { credentials, path: file };
149
+ }
@@ -0,0 +1,24 @@
1
+ /**
2
+ * Turns an `agent.run_requested` event into the prompt handed to the local agent.
3
+ *
4
+ * Kept plain and self-describing because we have no idea what is on the other
5
+ * end of ``--exec``: the text has to make sense to Claude Code, Codex, goose, or
6
+ * a shell script that greps it. The reply contract is stated explicitly so an
7
+ * agent that would otherwise narrate its reasoning returns something sendable.
8
+ */
9
+ import type { AgentRunRequest } from './contract.js';
10
+ export interface PromptContext {
11
+ /** Display name of the paired AI employee, e.g. "Ada". */
12
+ agentName: string;
13
+ /** MCP endpoint the agent can use to act back into the account. */
14
+ mcpUrl?: string;
15
+ }
16
+ /** Build the stdin prompt for one run. */
17
+ export declare function buildPrompt(run: AgentRunRequest, context: PromptContext): string;
18
+ /**
19
+ * Normalize an agent's stdout into a postable message.
20
+ *
21
+ * Only trims surrounding whitespace: everything else the agent wrote is the
22
+ * host's message to send, and second-guessing it here would be surprising.
23
+ */
24
+ export declare function replyFromStdout(stdout: string): string;
@@ -0,0 +1,77 @@
1
+ /**
2
+ * Turns an `agent.run_requested` event into the prompt handed to the local agent.
3
+ *
4
+ * Kept plain and self-describing because we have no idea what is on the other
5
+ * end of ``--exec``: the text has to make sense to Claude Code, Codex, goose, or
6
+ * a shell script that greps it. The reply contract is stated explicitly so an
7
+ * agent that would otherwise narrate its reasoning returns something sendable.
8
+ */
9
+ /** Build the stdin prompt for one run. */
10
+ export function buildPrompt(run, context) {
11
+ const name = run.agent_name ?? context.agentName;
12
+ const canReply = Boolean(run.reply_surface !== 'none' && run.reply_path && run.reply_body_key);
13
+ const lines = [
14
+ `You are "${name}", an AI teammate in a ProhostAI workspace.`,
15
+ canReply && run.reply_surface === 'conversation'
16
+ ? 'You were mentioned in a conversation and someone is waiting on a reply.'
17
+ : run.reply_surface === 'none'
18
+ ? 'You have been given work to do. There is nowhere to reply.'
19
+ : canReply
20
+ ? `You were mentioned on a ${run.reply_surface} and someone is waiting on a reply.`
21
+ : `You have been given work on a ${run.reply_surface}. There is nowhere to post a reply.`,
22
+ '',
23
+ ];
24
+ const facts = [];
25
+ if (run.kind)
26
+ facts.push(`Why you woke up: ${run.kind}`);
27
+ if (run.trigger_source)
28
+ facts.push(`Surface: ${run.trigger_source}`);
29
+ // The entity the run is about, so the agent can look it up over MCP rather
30
+ // than guess at what "this" refers to.
31
+ if (run.conversation_id)
32
+ facts.push(`Conversation: ${run.conversation_id}`);
33
+ if (run.task_id)
34
+ facts.push(`Task: ${run.task_id}`);
35
+ if (run.cleaning_id)
36
+ facts.push(`Cleaning: ${run.cleaning_id}`);
37
+ if (run.trigger_type)
38
+ facts.push(`Triggering event: ${run.trigger_type}`);
39
+ if (run.trigger_entity_id)
40
+ facts.push(`Event subject: ${run.trigger_entity_id}`);
41
+ // Comment surfaces carry no inline text excerpt, so the comment id is the
42
+ // only handle the agent has on what was actually said.
43
+ if (run.trigger_message_id)
44
+ facts.push(`Comment/message id: ${run.trigger_message_id}`);
45
+ if (context.mcpUrl) {
46
+ facts.push(`ProhostAI MCP endpoint (for looking things up): ${context.mcpUrl}`);
47
+ }
48
+ if (facts.length > 0) {
49
+ lines.push('Context:', ...facts.map((f) => `- ${f}`), '');
50
+ }
51
+ if (run.trigger_text) {
52
+ // The server caps this excerpt, so say so rather than let an agent assume
53
+ // it has the whole message.
54
+ lines.push('Message that woke you (may be truncated by the server):', run.trigger_text, '');
55
+ }
56
+ else {
57
+ lines.push('The message text was not included in the event.', '');
58
+ }
59
+ // Postability is a separate question from what the run is about: a task run
60
+ // has no comment route in the external API yet, so the agent must be told its
61
+ // output won't be posted rather than left to assume it will.
62
+ lines.push(canReply
63
+ ? 'Write your reply to that message. Reply with the message text only — no'
64
+ : 'Summarize what you did, in one short message. Output the message only — no', 'preamble, no explanation of your reasoning, no surrounding quotes. Your', canReply
65
+ ? `entire stdout is posted verbatim as your reply on the ${run.reply_surface}.`
66
+ : 'stdout is recorded but not posted anywhere — act through your own tools.');
67
+ return `${lines.join('\n')}\n`;
68
+ }
69
+ /**
70
+ * Normalize an agent's stdout into a postable message.
71
+ *
72
+ * Only trims surrounding whitespace: everything else the agent wrote is the
73
+ * host's message to send, and second-guessing it here would be surprising.
74
+ */
75
+ export function replyFromStdout(stdout) {
76
+ return stdout.trim();
77
+ }
@@ -0,0 +1,78 @@
1
+ /**
2
+ * `prohost agent run` — the event loop that makes a local agent a teammate.
3
+ *
4
+ * Connects to the same authenticated `/ws/webhooks` stream `prohost listen`
5
+ * uses, and for every `agent.run_requested` frame: verifies the HMAC, builds a
6
+ * prompt, runs the host's ``--exec`` command with that prompt on stdin, posts
7
+ * stdout back into the conversation, and closes the run out. A run is *always*
8
+ * completed — a crashed or timed-out command reports ``failed`` rather than
9
+ * being left hanging.
10
+ *
11
+ * Two deliberate safety properties:
12
+ *
13
+ * * **Runs are serialized.** One local agent, one run at a time; concurrent
14
+ * frames queue. Agents hold working directories and rate limits, and running
15
+ * several at once is a good way to corrupt both.
16
+ * * **Runs are deduped on ``run_id``.** Webhook deliveries retry, and each
17
+ * attempt is mirrored to this socket. Without dedupe a retry would execute
18
+ * the command a second time and post a second reply.
19
+ */
20
+ import type { spawn } from 'node:child_process';
21
+ import WebSocket from 'ws';
22
+ import type { AgentCredentials } from './credentials.js';
23
+ /** Default ceiling on one agent invocation. */
24
+ export declare const DEFAULT_EXEC_TIMEOUT_MS = 300000;
25
+ export interface AgentRunOptions {
26
+ /** Shell command that runs the agent, e.g. ``claude -p``. */
27
+ exec: string;
28
+ credentials: AgentCredentials;
29
+ /** Websocket endpoint override. Defaults to the paired value. */
30
+ url?: string;
31
+ /** Machine name shown in the ProhostAI UI. Defaults to the OS hostname. */
32
+ machine?: string;
33
+ /** Per-run execution timeout. */
34
+ timeoutMs?: number;
35
+ /** Print the reply that would be posted and make no writes. */
36
+ dryRun?: boolean;
37
+ /** Execute runs whose signature could not be verified. Off by default. */
38
+ allowUnverified?: boolean;
39
+ /** Working directory for the agent command. */
40
+ cwd?: string;
41
+ onLine?: (line: string) => void;
42
+ webSocketCtor?: typeof WebSocket;
43
+ fetchImpl?: typeof fetch;
44
+ spawnImpl?: typeof spawn;
45
+ signal?: AbortSignal;
46
+ /** Stop after this many connection attempts. Unlimited when unset (tests set it). */
47
+ maxConnections?: number;
48
+ /** Injectable sleep so tests don't wait out the reconnect backoff. */
49
+ sleepImpl?: (ms: number) => Promise<void>;
50
+ /** Environment used to locate the run ledger (tests point this at a temp dir). */
51
+ env?: NodeJS.ProcessEnv;
52
+ }
53
+ export interface AgentRunMetrics {
54
+ connections: number;
55
+ eventsReceived: number;
56
+ runsStarted: number;
57
+ runsSucceeded: number;
58
+ runsFailed: number;
59
+ /** Frames dropped because the HMAC didn't verify (or couldn't be checked). */
60
+ signatureRejections: number;
61
+ /** Frames for events the harness doesn't act on. */
62
+ ignored: number;
63
+ /** Repeat deliveries of a run already handled this session. */
64
+ duplicates: number;
65
+ /**
66
+ * Set when the harness stopped for a reason retrying can't fix (a rejected
67
+ * credential). The CLI exits nonzero on it, so a service manager doesn't
68
+ * read "never connected" as success.
69
+ */
70
+ fatal?: string;
71
+ }
72
+ /**
73
+ * Run the harness until the abort signal fires (or ``maxConnections`` is hit).
74
+ *
75
+ * Reconnects on any drop with jittered exponential backoff; an ``auth_error``
76
+ * is fatal, because retrying a rejected credential just hammers the server.
77
+ */
78
+ export declare function runAgentHarness(options: AgentRunOptions): Promise<AgentRunMetrics>;