@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.
- package/LICENSE +21 -0
- package/README.md +229 -0
- package/dist/agent/api.d.ts +45 -0
- package/dist/agent/api.js +80 -0
- package/dist/agent/command.d.ts +10 -0
- package/dist/agent/command.js +109 -0
- package/dist/agent/contract.d.ts +94 -0
- package/dist/agent/contract.js +125 -0
- package/dist/agent/credentials.d.ts +72 -0
- package/dist/agent/credentials.js +92 -0
- package/dist/agent/exec.d.ts +48 -0
- package/dist/agent/exec.js +151 -0
- package/dist/agent/pair.d.ts +44 -0
- package/dist/agent/pair.js +149 -0
- package/dist/agent/prompt.d.ts +24 -0
- package/dist/agent/prompt.js +77 -0
- package/dist/agent/run.d.ts +78 -0
- package/dist/agent/run.js +400 -0
- package/dist/agent/runstore.d.ts +50 -0
- package/dist/agent/runstore.js +107 -0
- package/dist/agent/signature.d.ts +35 -0
- package/dist/agent/signature.js +99 -0
- package/dist/forward.d.ts +39 -0
- package/dist/forward.js +68 -0
- package/dist/index.d.ts +51 -0
- package/dist/index.js +216 -0
- package/dist/listen.d.ts +48 -0
- package/dist/listen.js +113 -0
- package/dist/version.d.ts +12 -0
- package/dist/version.js +12 -0
- package/package.json +52 -0
|
@@ -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>;
|