@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,125 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The wire contract between this CLI and ProhostAI's agent-run loop.
|
|
3
|
+
*
|
|
4
|
+
* Everything the server owns — event names, HTTP paths, run-status values —
|
|
5
|
+
* lives here and nowhere else, so a server-side rename is a one-line change
|
|
6
|
+
* in this file rather than a hunt through the runner.
|
|
7
|
+
*
|
|
8
|
+
* The `agent.run_requested` payload spells out its own reply and completion
|
|
9
|
+
* paths precisely so a harness doesn't hand-derive them, and this module
|
|
10
|
+
* prefers those server-supplied paths. The constants below are the fallback
|
|
11
|
+
* for a payload that omits them.
|
|
12
|
+
*
|
|
13
|
+
* Server-side sources of truth:
|
|
14
|
+
* - payload shape — `shared/events/payloads/agent_runs.py`
|
|
15
|
+
* - `POST /v1/agent-runs/{id}/complete` — `api_public/routers/agent_runs.py`
|
|
16
|
+
* - `POST /v1/agent-pairing/redeem` — `api_public/routers/agent_pairing.py`
|
|
17
|
+
* - `POST /v1/conversations/{id}/messages` — `api_public/routers/conversations.py`
|
|
18
|
+
*/
|
|
19
|
+
/** Default host for the public API (`api_public`, mounted under `/v1`). */
|
|
20
|
+
export const DEFAULT_API_BASE_URL = 'https://connect.prohost.ai';
|
|
21
|
+
/** Fired when a paired agent is given work — the trigger to run. */
|
|
22
|
+
export const EVENT_AGENT_RUN_REQUESTED = 'agent.run_requested';
|
|
23
|
+
/** Informational events the harness logs but does not act on. */
|
|
24
|
+
export const EVENT_MENTION_CREATED = 'mention.created';
|
|
25
|
+
export const EVENT_MESSAGE_TEAM_CHAT = 'message.team_chat';
|
|
26
|
+
/** Terminal statuses accepted by the run-completion endpoint. */
|
|
27
|
+
export const RUN_STATUS_SUCCEEDED = 'succeeded';
|
|
28
|
+
export const RUN_STATUS_FAILED = 'failed';
|
|
29
|
+
/** Server-side cap on the `error` field of a completion (`MAX_ERROR_CHARS`). */
|
|
30
|
+
export const MAX_ERROR_CHARS = 1000;
|
|
31
|
+
/** `POST` — exchange a one-time pairing code for a scoped credential. */
|
|
32
|
+
export const REDEEM_PATH = '/v1/agent-pairing/redeem';
|
|
33
|
+
/** `POST` — send a message into a conversation as the paired agent. */
|
|
34
|
+
export function conversationMessagesPath(conversationId) {
|
|
35
|
+
return `/v1/conversations/${encodeURIComponent(conversationId)}/messages`;
|
|
36
|
+
}
|
|
37
|
+
/** `POST` — close out a run. Must be called for every run, success or not. */
|
|
38
|
+
export function agentRunCompletePath(runId) {
|
|
39
|
+
return `/v1/agent-runs/${encodeURIComponent(runId)}/complete`;
|
|
40
|
+
}
|
|
41
|
+
/**
|
|
42
|
+
* Fallback for a server that predates `reply_instructions.body_field`.
|
|
43
|
+
*
|
|
44
|
+
* Comment endpoints and message endpoints disagree on the field name, so before
|
|
45
|
+
* the server named it a harness had to encode the mapping itself. The event is
|
|
46
|
+
* authoritative when it says — this is only the older-server path.
|
|
47
|
+
*/
|
|
48
|
+
function legacyBodyKeyFor(surface) {
|
|
49
|
+
if (surface === 'conversation')
|
|
50
|
+
return 'message';
|
|
51
|
+
if (surface === 'task' || surface === 'cleaning')
|
|
52
|
+
return 'content';
|
|
53
|
+
return undefined;
|
|
54
|
+
}
|
|
55
|
+
function str(value) {
|
|
56
|
+
return typeof value === 'string' && value.length > 0 ? value : undefined;
|
|
57
|
+
}
|
|
58
|
+
function obj(value) {
|
|
59
|
+
return value && typeof value === 'object' && !Array.isArray(value)
|
|
60
|
+
? value
|
|
61
|
+
: {};
|
|
62
|
+
}
|
|
63
|
+
/**
|
|
64
|
+
* Extract the event body from a websocket frame's payload.
|
|
65
|
+
*
|
|
66
|
+
* Deliveries arrive wrapped in the outbound-webhook envelope
|
|
67
|
+
* (`{id, event, created_at, schema_version, data}`); the fields we want live
|
|
68
|
+
* in `data`. Frames without an envelope (the server's `echo` test frame) are
|
|
69
|
+
* passed through unchanged.
|
|
70
|
+
*/
|
|
71
|
+
export function eventData(payload) {
|
|
72
|
+
const data = payload.data;
|
|
73
|
+
if (data && typeof data === 'object' && !Array.isArray(data)) {
|
|
74
|
+
return data;
|
|
75
|
+
}
|
|
76
|
+
return payload;
|
|
77
|
+
}
|
|
78
|
+
/**
|
|
79
|
+
* Validate an `agent.run_requested` body. Returns ``null`` when the frame is
|
|
80
|
+
* missing a ``run_id`` — without one there is no run to complete, so the only
|
|
81
|
+
* safe move is to ignore it rather than execute an unattributable prompt.
|
|
82
|
+
*/
|
|
83
|
+
export function parseRunRequest(data) {
|
|
84
|
+
const runId = str(data.run_id);
|
|
85
|
+
if (!runId)
|
|
86
|
+
return null;
|
|
87
|
+
const trigger = obj(data.trigger);
|
|
88
|
+
const replyInstructions = obj(data.reply_instructions);
|
|
89
|
+
const completion = obj(data.completion);
|
|
90
|
+
const declaredSurface = str(replyInstructions.surface);
|
|
91
|
+
const conversationId = str(data.conversation_id) ?? str(replyInstructions.conversation_id);
|
|
92
|
+
const taskId = str(data.task_id) ?? str(replyInstructions.task_id);
|
|
93
|
+
const cleaningId = str(data.cleaning_id) ?? str(replyInstructions.cleaning_id);
|
|
94
|
+
const serverPath = str(replyInstructions.path);
|
|
95
|
+
// Keep the surface the server declared, even when it gave no path. A task run
|
|
96
|
+
// whose reply can't be posted is still a task run, and the prompt has to say
|
|
97
|
+
// so or the agent answers as if it were a chat message.
|
|
98
|
+
const surface = declaredSurface ?? (conversationId ? 'conversation' : 'none');
|
|
99
|
+
return {
|
|
100
|
+
run_id: runId,
|
|
101
|
+
agent_id: str(data.agent_id),
|
|
102
|
+
agent_user_id: str(data.agent_user_id),
|
|
103
|
+
agent_name: str(data.agent_name),
|
|
104
|
+
account_id: str(data.account_id),
|
|
105
|
+
kind: str(data.kind),
|
|
106
|
+
conversation_id: conversationId,
|
|
107
|
+
task_id: taskId,
|
|
108
|
+
cleaning_id: cleaningId,
|
|
109
|
+
trigger_source: str(trigger.source) ?? str(data.trigger_source),
|
|
110
|
+
trigger_message_id: str(trigger.message_id),
|
|
111
|
+
trigger_text: str(trigger.text_excerpt),
|
|
112
|
+
trigger_user_id: str(trigger.user_id),
|
|
113
|
+
trigger_type: str(trigger.type),
|
|
114
|
+
trigger_entity_id: str(trigger.entity_id),
|
|
115
|
+
reply_surface: surface,
|
|
116
|
+
// Prefer the server's path; fall back to the documented conversation path
|
|
117
|
+
// rather than drop a reply that plainly has somewhere to go.
|
|
118
|
+
reply_path: serverPath ?? (conversationId ? conversationMessagesPath(conversationId) : undefined),
|
|
119
|
+
// New events spell this out, which also makes future surfaces postable
|
|
120
|
+
// without a CLI release. A path with no nameable field is not postable;
|
|
121
|
+
// keep the known-surface fallback only for old events.
|
|
122
|
+
reply_body_key: str(replyInstructions.body_field) ?? legacyBodyKeyFor(surface),
|
|
123
|
+
completion_path: str(completion.path) ?? agentRunCompletePath(runId),
|
|
124
|
+
};
|
|
125
|
+
}
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* On-disk credential store for a paired agent (`~/.prohost/agent.json`).
|
|
3
|
+
*
|
|
4
|
+
* The file holds a live API key and a webhook signing secret, so it is written
|
|
5
|
+
* ``0600`` inside a ``0700`` directory and never echoed back to the terminal —
|
|
6
|
+
* :func:`maskSecret` is the only way this CLI prints any part of it.
|
|
7
|
+
*/
|
|
8
|
+
export interface StoredAgent {
|
|
9
|
+
id: string;
|
|
10
|
+
name: string;
|
|
11
|
+
user_id: string;
|
|
12
|
+
}
|
|
13
|
+
export interface AgentCredentials {
|
|
14
|
+
/** Schema version, so a future format change can migrate rather than crash. */
|
|
15
|
+
version: number;
|
|
16
|
+
/** Base URL of the public API these credentials belong to. */
|
|
17
|
+
base_url: string;
|
|
18
|
+
/** Websocket endpoint events stream over. */
|
|
19
|
+
ws_url: string;
|
|
20
|
+
api_key: string;
|
|
21
|
+
/** ``null`` when pairing did not provision a webhook subscription. */
|
|
22
|
+
webhook_secret: string | null;
|
|
23
|
+
/**
|
|
24
|
+
* The subscription provisioned for this connection. Sent on the websocket
|
|
25
|
+
* auth frame so the connection's ``connected_from`` is stamped with this
|
|
26
|
+
* machine's name — a presence signal, not a scoping one: the server derives
|
|
27
|
+
* which subscription's events a paired socket receives from the connection
|
|
28
|
+
* itself (`api_public/websockets/webhooks.py`).
|
|
29
|
+
*/
|
|
30
|
+
subscription_id: string | null;
|
|
31
|
+
/**
|
|
32
|
+
* ``true`` when pairing carried no ``webhook_url``. Events arrive over the
|
|
33
|
+
* websocket only; nothing is ever POSTed anywhere. This is the normal path
|
|
34
|
+
* for an agent on a laptop, which has no public HTTPS endpoint to offer.
|
|
35
|
+
*/
|
|
36
|
+
stream_only: boolean;
|
|
37
|
+
mcp_url: string;
|
|
38
|
+
events: string[];
|
|
39
|
+
agent: StoredAgent;
|
|
40
|
+
paired_at: string;
|
|
41
|
+
}
|
|
42
|
+
export declare const CREDENTIALS_VERSION = 1;
|
|
43
|
+
/** Directory holding CLI state. Overridable with ``$PROHOST_HOME`` for tests. */
|
|
44
|
+
export declare function prohostHome(env?: NodeJS.ProcessEnv): string;
|
|
45
|
+
/** Absolute path of the credential file. */
|
|
46
|
+
export declare function credentialsPath(env?: NodeJS.ProcessEnv): string;
|
|
47
|
+
/**
|
|
48
|
+
* Show enough of a secret to identify it and no more — ``ph_live_ab…`` style.
|
|
49
|
+
* Anything shorter than the prefix is reported as ``***`` so a short or
|
|
50
|
+
* malformed value can't leak in full.
|
|
51
|
+
*/
|
|
52
|
+
export declare function maskSecret(secret: string, keep?: number): string;
|
|
53
|
+
/** Persist credentials with owner-only permissions. Returns the path written. */
|
|
54
|
+
export declare function saveCredentials(credentials: AgentCredentials, env?: NodeJS.ProcessEnv): string;
|
|
55
|
+
export declare class CredentialsMissingError extends Error {
|
|
56
|
+
}
|
|
57
|
+
/** Read stored credentials, or ``null`` when there aren't any usable ones. */
|
|
58
|
+
export declare function tryLoadCredentials(env?: NodeJS.ProcessEnv): AgentCredentials | null;
|
|
59
|
+
/**
|
|
60
|
+
* Delete the stored credentials. Returns whether a file was removed.
|
|
61
|
+
*
|
|
62
|
+
* Used when the server has already revoked what's on disk, so keeping it would
|
|
63
|
+
* only produce a misleading error later.
|
|
64
|
+
*/
|
|
65
|
+
export declare function clearCredentials(env?: NodeJS.ProcessEnv): boolean;
|
|
66
|
+
/**
|
|
67
|
+
* Read stored credentials.
|
|
68
|
+
*
|
|
69
|
+
* @throws CredentialsMissingError when the file is absent, unreadable, or
|
|
70
|
+
* doesn't contain an API key — all of which mean "run `prohost agent pair`".
|
|
71
|
+
*/
|
|
72
|
+
export declare function loadCredentials(env?: NodeJS.ProcessEnv): AgentCredentials;
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* On-disk credential store for a paired agent (`~/.prohost/agent.json`).
|
|
3
|
+
*
|
|
4
|
+
* The file holds a live API key and a webhook signing secret, so it is written
|
|
5
|
+
* ``0600`` inside a ``0700`` directory and never echoed back to the terminal —
|
|
6
|
+
* :func:`maskSecret` is the only way this CLI prints any part of it.
|
|
7
|
+
*/
|
|
8
|
+
import { chmodSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
|
9
|
+
import os from 'node:os';
|
|
10
|
+
import path from 'node:path';
|
|
11
|
+
export const CREDENTIALS_VERSION = 1;
|
|
12
|
+
/** Directory holding CLI state. Overridable with ``$PROHOST_HOME`` for tests. */
|
|
13
|
+
export function prohostHome(env = process.env) {
|
|
14
|
+
return env.PROHOST_HOME ?? path.join(os.homedir(), '.prohost');
|
|
15
|
+
}
|
|
16
|
+
/** Absolute path of the credential file. */
|
|
17
|
+
export function credentialsPath(env = process.env) {
|
|
18
|
+
return path.join(prohostHome(env), 'agent.json');
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* Show enough of a secret to identify it and no more — ``ph_live_ab…`` style.
|
|
22
|
+
* Anything shorter than the prefix is reported as ``***`` so a short or
|
|
23
|
+
* malformed value can't leak in full.
|
|
24
|
+
*/
|
|
25
|
+
export function maskSecret(secret, keep = 11) {
|
|
26
|
+
if (secret.length <= keep)
|
|
27
|
+
return '***';
|
|
28
|
+
return `${secret.slice(0, keep)}…`;
|
|
29
|
+
}
|
|
30
|
+
/** Persist credentials with owner-only permissions. Returns the path written. */
|
|
31
|
+
export function saveCredentials(credentials, env = process.env) {
|
|
32
|
+
const dir = prohostHome(env);
|
|
33
|
+
const file = credentialsPath(env);
|
|
34
|
+
mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
35
|
+
// mkdirSync's mode is masked by the process umask and is a no-op when the
|
|
36
|
+
// directory already exists, so tighten it explicitly.
|
|
37
|
+
chmodSync(dir, 0o700);
|
|
38
|
+
writeFileSync(file, `${JSON.stringify(credentials, null, 2)}\n`, { mode: 0o600 });
|
|
39
|
+
chmodSync(file, 0o600);
|
|
40
|
+
return file;
|
|
41
|
+
}
|
|
42
|
+
export class CredentialsMissingError extends Error {
|
|
43
|
+
}
|
|
44
|
+
/** Read stored credentials, or ``null`` when there aren't any usable ones. */
|
|
45
|
+
export function tryLoadCredentials(env = process.env) {
|
|
46
|
+
try {
|
|
47
|
+
return loadCredentials(env);
|
|
48
|
+
}
|
|
49
|
+
catch {
|
|
50
|
+
return null;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
/**
|
|
54
|
+
* Delete the stored credentials. Returns whether a file was removed.
|
|
55
|
+
*
|
|
56
|
+
* Used when the server has already revoked what's on disk, so keeping it would
|
|
57
|
+
* only produce a misleading error later.
|
|
58
|
+
*/
|
|
59
|
+
export function clearCredentials(env = process.env) {
|
|
60
|
+
const file = credentialsPath(env);
|
|
61
|
+
const existed = tryLoadCredentials(env) !== null;
|
|
62
|
+
rmSync(file, { force: true });
|
|
63
|
+
return existed;
|
|
64
|
+
}
|
|
65
|
+
/**
|
|
66
|
+
* Read stored credentials.
|
|
67
|
+
*
|
|
68
|
+
* @throws CredentialsMissingError when the file is absent, unreadable, or
|
|
69
|
+
* doesn't contain an API key — all of which mean "run `prohost agent pair`".
|
|
70
|
+
*/
|
|
71
|
+
export function loadCredentials(env = process.env) {
|
|
72
|
+
const file = credentialsPath(env);
|
|
73
|
+
let raw;
|
|
74
|
+
try {
|
|
75
|
+
raw = readFileSync(file, 'utf8');
|
|
76
|
+
}
|
|
77
|
+
catch {
|
|
78
|
+
throw new CredentialsMissingError(`No paired agent found at ${file}. Run: prohost agent pair --code <pairing-code>`);
|
|
79
|
+
}
|
|
80
|
+
let parsed;
|
|
81
|
+
try {
|
|
82
|
+
parsed = JSON.parse(raw);
|
|
83
|
+
}
|
|
84
|
+
catch {
|
|
85
|
+
throw new CredentialsMissingError(`Credential file is not valid JSON: ${file}`);
|
|
86
|
+
}
|
|
87
|
+
const c = parsed;
|
|
88
|
+
if (!c || typeof c !== 'object' || typeof c.api_key !== 'string' || !c.api_key) {
|
|
89
|
+
throw new CredentialsMissingError(`Credential file is missing an api_key: ${file}`);
|
|
90
|
+
}
|
|
91
|
+
return c;
|
|
92
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Runs the user's agent command and captures its answer.
|
|
3
|
+
*
|
|
4
|
+
* Deliberately agent-agnostic: the command is whatever the host passed to
|
|
5
|
+
* ``--exec`` (``claude -p``, ``codex exec -``, ``goose run -i -``, a shell
|
|
6
|
+
* script). The contract is the lowest common denominator every CLI agent
|
|
7
|
+
* already speaks — prompt in on stdin, reply out on stdout.
|
|
8
|
+
*
|
|
9
|
+
* The prompt goes in over **stdin, never the command line**. ``shell: true``
|
|
10
|
+
* is what makes ``--exec 'claude -p'`` work as typed, and the command string
|
|
11
|
+
* comes from the host's own flag — but event-derived text never reaches the
|
|
12
|
+
* shell, so a crafted mention cannot inject a command.
|
|
13
|
+
*/
|
|
14
|
+
import { spawn } from 'node:child_process';
|
|
15
|
+
export interface ExecOptions {
|
|
16
|
+
/** Shell command to run, exactly as the host typed it after ``--exec``. */
|
|
17
|
+
command: string;
|
|
18
|
+
/** Prompt written to the child's stdin. */
|
|
19
|
+
prompt: string;
|
|
20
|
+
/** Hard cap on wall-clock runtime. */
|
|
21
|
+
timeoutMs: number;
|
|
22
|
+
/** Working directory for the child. Defaults to the CLI's cwd. */
|
|
23
|
+
cwd?: string;
|
|
24
|
+
/** Extra environment for the child, merged over ``process.env``. */
|
|
25
|
+
env?: NodeJS.ProcessEnv;
|
|
26
|
+
/** Injectable spawn — tests substitute a fake process. */
|
|
27
|
+
spawnImpl?: typeof spawn;
|
|
28
|
+
}
|
|
29
|
+
export interface ExecResult {
|
|
30
|
+
ok: boolean;
|
|
31
|
+
stdout: string;
|
|
32
|
+
stderr: string;
|
|
33
|
+
exitCode: number | null;
|
|
34
|
+
timedOut: boolean;
|
|
35
|
+
/** Populated when the process could not be started at all. */
|
|
36
|
+
error?: string;
|
|
37
|
+
durationMs: number;
|
|
38
|
+
}
|
|
39
|
+
/** Cap on captured output (in characters) so a runaway agent can't exhaust memory. */
|
|
40
|
+
export declare const MAX_OUTPUT_CHARS: number;
|
|
41
|
+
/**
|
|
42
|
+
* Execute the agent command with ``prompt`` on stdin and return its stdout.
|
|
43
|
+
*
|
|
44
|
+
* Never throws: a spawn failure, a non-zero exit, and a timeout all come back
|
|
45
|
+
* as ``ok: false`` with the detail filled in, because the caller must report
|
|
46
|
+
* *some* outcome to the server for every run.
|
|
47
|
+
*/
|
|
48
|
+
export declare function execAgent(options: ExecOptions): Promise<ExecResult>;
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Runs the user's agent command and captures its answer.
|
|
3
|
+
*
|
|
4
|
+
* Deliberately agent-agnostic: the command is whatever the host passed to
|
|
5
|
+
* ``--exec`` (``claude -p``, ``codex exec -``, ``goose run -i -``, a shell
|
|
6
|
+
* script). The contract is the lowest common denominator every CLI agent
|
|
7
|
+
* already speaks — prompt in on stdin, reply out on stdout.
|
|
8
|
+
*
|
|
9
|
+
* The prompt goes in over **stdin, never the command line**. ``shell: true``
|
|
10
|
+
* is what makes ``--exec 'claude -p'`` work as typed, and the command string
|
|
11
|
+
* comes from the host's own flag — but event-derived text never reaches the
|
|
12
|
+
* shell, so a crafted mention cannot inject a command.
|
|
13
|
+
*/
|
|
14
|
+
import { spawn } from 'node:child_process';
|
|
15
|
+
/** Cap on captured output (in characters) so a runaway agent can't exhaust memory. */
|
|
16
|
+
export const MAX_OUTPUT_CHARS = 512 * 1024;
|
|
17
|
+
/** Grace period between SIGTERM and SIGKILL when a run overruns its timeout. */
|
|
18
|
+
const KILL_GRACE_MS = 5_000;
|
|
19
|
+
function clamp(text) {
|
|
20
|
+
if (text.length <= MAX_OUTPUT_CHARS)
|
|
21
|
+
return text;
|
|
22
|
+
return `${text.slice(0, MAX_OUTPUT_CHARS)}\n…[truncated]`;
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* Execute the agent command with ``prompt`` on stdin and return its stdout.
|
|
26
|
+
*
|
|
27
|
+
* Never throws: a spawn failure, a non-zero exit, and a timeout all come back
|
|
28
|
+
* as ``ok: false`` with the detail filled in, because the caller must report
|
|
29
|
+
* *some* outcome to the server for every run.
|
|
30
|
+
*/
|
|
31
|
+
export async function execAgent(options) {
|
|
32
|
+
const spawnImpl = options.spawnImpl ?? spawn;
|
|
33
|
+
const started = Date.now();
|
|
34
|
+
return new Promise((resolve) => {
|
|
35
|
+
let stdout = '';
|
|
36
|
+
let stderr = '';
|
|
37
|
+
let timedOut = false;
|
|
38
|
+
let settled = false;
|
|
39
|
+
const spawnOptions = {
|
|
40
|
+
shell: true,
|
|
41
|
+
cwd: options.cwd,
|
|
42
|
+
env: options.env ? { ...process.env, ...options.env } : process.env,
|
|
43
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
44
|
+
// Own process group. `--exec 'foo | bar'` runs under a shell, and
|
|
45
|
+
// signalling the shell alone leaves its children holding our stdio
|
|
46
|
+
// pipes — `close` then never fires and the run queue wedges past the
|
|
47
|
+
// timeout. With a group we can signal the whole tree.
|
|
48
|
+
detached: process.platform !== 'win32',
|
|
49
|
+
};
|
|
50
|
+
let child;
|
|
51
|
+
try {
|
|
52
|
+
child = spawnImpl(options.command, spawnOptions);
|
|
53
|
+
}
|
|
54
|
+
catch (err) {
|
|
55
|
+
resolve({
|
|
56
|
+
ok: false,
|
|
57
|
+
stdout: '',
|
|
58
|
+
stderr: '',
|
|
59
|
+
exitCode: null,
|
|
60
|
+
timedOut: false,
|
|
61
|
+
error: err instanceof Error ? err.message : String(err),
|
|
62
|
+
durationMs: Date.now() - started,
|
|
63
|
+
});
|
|
64
|
+
return;
|
|
65
|
+
}
|
|
66
|
+
let killTimer;
|
|
67
|
+
const finish = (result) => {
|
|
68
|
+
if (settled)
|
|
69
|
+
return;
|
|
70
|
+
settled = true;
|
|
71
|
+
clearTimeout(timer);
|
|
72
|
+
if (killTimer !== undefined)
|
|
73
|
+
clearTimeout(killTimer);
|
|
74
|
+
resolve({ ...result, durationMs: Date.now() - started });
|
|
75
|
+
};
|
|
76
|
+
/**
|
|
77
|
+
* Signal the child's whole process group, falling back to the child alone
|
|
78
|
+
* when there is no group (Windows, or a spawn that never got a pid).
|
|
79
|
+
*/
|
|
80
|
+
const signalTree = (signal) => {
|
|
81
|
+
const pid = child.pid;
|
|
82
|
+
try {
|
|
83
|
+
if (pid !== undefined && spawnOptions.detached)
|
|
84
|
+
process.kill(-pid, signal);
|
|
85
|
+
else
|
|
86
|
+
child.kill(signal);
|
|
87
|
+
}
|
|
88
|
+
catch {
|
|
89
|
+
// ESRCH — the group is already gone, which is the outcome we wanted.
|
|
90
|
+
}
|
|
91
|
+
};
|
|
92
|
+
const timer = setTimeout(() => {
|
|
93
|
+
timedOut = true;
|
|
94
|
+
signalTree('SIGTERM');
|
|
95
|
+
// Escalate if the agent ignores SIGTERM, so a wedged child can't hold
|
|
96
|
+
// the harness open past its timeout.
|
|
97
|
+
killTimer = setTimeout(() => {
|
|
98
|
+
signalTree('SIGKILL');
|
|
99
|
+
// A descendant can still hold the pipes open after the group is
|
|
100
|
+
// killed; don't wait on `close` forever for a run we already gave up
|
|
101
|
+
// on.
|
|
102
|
+
finish({
|
|
103
|
+
ok: false,
|
|
104
|
+
stdout: clamp(stdout),
|
|
105
|
+
stderr: clamp(stderr),
|
|
106
|
+
exitCode: null,
|
|
107
|
+
timedOut: true,
|
|
108
|
+
error: `timed out after ${options.timeoutMs}ms`,
|
|
109
|
+
});
|
|
110
|
+
}, KILL_GRACE_MS);
|
|
111
|
+
}, options.timeoutMs);
|
|
112
|
+
child.stdout?.on('data', (chunk) => {
|
|
113
|
+
if (stdout.length < MAX_OUTPUT_CHARS)
|
|
114
|
+
stdout += chunk.toString();
|
|
115
|
+
});
|
|
116
|
+
child.stderr?.on('data', (chunk) => {
|
|
117
|
+
if (stderr.length < MAX_OUTPUT_CHARS)
|
|
118
|
+
stderr += chunk.toString();
|
|
119
|
+
});
|
|
120
|
+
child.on('error', (err) => {
|
|
121
|
+
finish({
|
|
122
|
+
ok: false,
|
|
123
|
+
stdout: clamp(stdout),
|
|
124
|
+
stderr: clamp(stderr),
|
|
125
|
+
exitCode: null,
|
|
126
|
+
timedOut,
|
|
127
|
+
error: err.message,
|
|
128
|
+
});
|
|
129
|
+
});
|
|
130
|
+
child.on('close', (code) => {
|
|
131
|
+
finish({
|
|
132
|
+
ok: !timedOut && code === 0,
|
|
133
|
+
stdout: clamp(stdout),
|
|
134
|
+
stderr: clamp(stderr),
|
|
135
|
+
exitCode: code,
|
|
136
|
+
timedOut,
|
|
137
|
+
error: timedOut ? `timed out after ${options.timeoutMs}ms` : undefined,
|
|
138
|
+
});
|
|
139
|
+
});
|
|
140
|
+
// stdin may already be closed if the command failed to start.
|
|
141
|
+
try {
|
|
142
|
+
child.stdin?.on('error', () => {
|
|
143
|
+
/* the close/error handlers above report the real failure */
|
|
144
|
+
});
|
|
145
|
+
child.stdin?.end(options.prompt);
|
|
146
|
+
}
|
|
147
|
+
catch {
|
|
148
|
+
/* ignore — reported via 'error'/'close' */
|
|
149
|
+
}
|
|
150
|
+
});
|
|
151
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
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 type { AgentCredentials } from './credentials.js';
|
|
13
|
+
export interface PairOptions {
|
|
14
|
+
/** The one-time code from the hire modal. */
|
|
15
|
+
code: string;
|
|
16
|
+
/** Public API base. Defaults to production. */
|
|
17
|
+
baseUrl?: string;
|
|
18
|
+
/** Websocket endpoint stored for `prohost agent run`. */
|
|
19
|
+
wsUrl?: string;
|
|
20
|
+
/**
|
|
21
|
+
* HTTPS endpoint to ALSO deliver agent events to. Optional and rarely
|
|
22
|
+
* wanted: omitting it gives a stream-only pairing, which is the normal path
|
|
23
|
+
* for an agent on a laptop. Supply one only if something else of yours also
|
|
24
|
+
* needs the events pushed over HTTPS.
|
|
25
|
+
*/
|
|
26
|
+
webhookUrl?: string;
|
|
27
|
+
fetchImpl?: typeof fetch;
|
|
28
|
+
/** Line-oriented output sink. Defaults to stdout. */
|
|
29
|
+
onLine?: (line: string) => void;
|
|
30
|
+
/** Environment used to resolve the credential path (tests override this). */
|
|
31
|
+
env?: NodeJS.ProcessEnv;
|
|
32
|
+
}
|
|
33
|
+
export interface PairResult {
|
|
34
|
+
credentials: AgentCredentials;
|
|
35
|
+
path: string;
|
|
36
|
+
}
|
|
37
|
+
export declare class PairError extends Error {
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* Redeem ``code`` and persist the resulting credentials.
|
|
41
|
+
*
|
|
42
|
+
* @throws PairError when the code is rejected or the response is malformed.
|
|
43
|
+
*/
|
|
44
|
+
export declare function runPair(options: PairOptions): Promise<PairResult>;
|