@prohost/cli 0.10.4 → 0.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -4,6 +4,47 @@ Versions follow [semver](https://semver.org/). Publishing is automated: merging
4
4
  a version bump to `main` triggers `.github/workflows/npm-publish-cli.yml`, which
5
5
  builds via `prepack`, runs the suite, publishes, and tags `cli-v<version>`.
6
6
 
7
+ ## 0.11.0
8
+
9
+ - **Pair only with the workspace you expect.** `prohost agent pair --code <c>
10
+ --expect-account <workspace-id>` (or `$PROHOST_EXPECT_ACCOUNT`) redeems the
11
+ code only if it belongs to that workspace. A pairing code doesn't say whose
12
+ it is, and redeeming one makes this computer that workspace's runtime, so a
13
+ code from somewhere unexpected could pair a computer with the wrong
14
+ workspace. With an expectation the CLI calls the new
15
+ `POST /v1/agent-pairing/redeem-bound` and nothing else:
16
+ - a code for another workspace is refused and left unspent, and an existing
17
+ pairing keeps working;
18
+ - a refusal is never retried on the plain endpoint. Its message covers every
19
+ reading of the server's one generic 404: an invalid, expired or used code,
20
+ a code for a different workspace, or a server without bound pairing;
21
+ - a value that isn't a workspace id, a bare flag, or an environment variable
22
+ that is set but empty stops the pairing before the code is spent.
23
+ - **Pairing says which workspace it paired in**, and records it in `agent.json`
24
+ as `prohost_account: { id, name }` when the server reports it, with or
25
+ without `--expect-account`. `agent list` shows the name. Files written by an
26
+ older CLI load as before.
27
+ - Without `--expect-account` the pairing request is byte-for-byte what 0.10.4
28
+ sent.
29
+ - **A structured result for programs that run `agent pair`.** Set
30
+ `$PROHOST_PAIR_RESULT_FILE` to an absolute path and the CLI writes
31
+ `{ "version": 1, "outcome", "revoked_previous_credential", "message" }` there
32
+ on every exit it controls. The printed output includes names the server
33
+ chose, which can be worded as anything, so a program that classified a
34
+ pairing by searching that output could be steered by them; `outcome` and
35
+ `revoked_previous_credential` are set by what the CLI did instead. Outcomes:
36
+ `paired`, `code_rejected`, `bound_rejected`, `workspace_unconfirmed`,
37
+ `rate_limited`, `unreachable`, `no_subscription`, `invalid_arguments`,
38
+ `failed`. The file holds no pairing code, API key or signing secret and
39
+ nothing from `agent.json`; it is written atomically, `chmod 600`. A path
40
+ that isn't absolute or can't be written is a one-line warning and never
41
+ fails a pairing. Unset, nothing changes.
42
+
43
+ To tell whether a CLI supports this without pairing: `prohost --version` is
44
+ `0.11.0` or later, or `prohost --help` mentions `--expect-account`. An older
45
+ CLI silently ignores the flag and the environment variable and pairs without
46
+ the check.
47
+
7
48
  ## 0.10.4
8
49
 
9
50
  - **A day's rebuilt totals wait for its 0.10.1 rows to land.** If the server
package/README.md CHANGED
@@ -74,6 +74,84 @@ required to receive runs.
74
74
 
75
75
  You can also pass the code as `$PROHOST_PAIRING_CODE`.
76
76
 
77
+ #### Pairing only with the workspace you expect
78
+
79
+ A pairing code doesn't say which workspace it belongs to, and redeeming one
80
+ makes this computer that workspace's runtime. If you know which workspace you
81
+ mean to pair with, say so, and a code for any other workspace is refused:
82
+
83
+ ```bash
84
+ npx @prohost/cli agent pair --code <pairing-code> --expect-account <workspace-id>
85
+ ```
86
+
87
+ `<workspace-id>` is the workspace's id (a UUID). You can pass it as
88
+ `$PROHOST_EXPECT_ACCOUNT` instead. The ProhostAI desktop app does this for you
89
+ with the workspace it is signed in to.
90
+
91
+ - A code for another workspace is **refused and left unspent**: it stays valid
92
+ for whoever it belongs to, and if this computer was already paired, that
93
+ pairing keeps working.
94
+ - A refusal is never retried without the check. The server answers one generic
95
+ 404 whether the code is invalid, expired, already used, or simply for a
96
+ different workspace — or if it is a server that doesn't support this yet — so
97
+ the CLI tells you all of those and stops.
98
+ - A value that isn't a workspace id stops the pairing before the code is spent.
99
+ - On success the CLI prints the workspace it paired in and records it in
100
+ `agent.json` as `prohost_account` (`{ "id", "name" }`). Pairing without
101
+ `--expect-account` records it too, when the server reports it.
102
+
103
+ `--expect-account` is unrelated to `--account`, which picks the Claude Code or
104
+ Codex login an agent runs on.
105
+
106
+ Needs CLI 0.11.0 or later. An older CLI ignores a flag it doesn't know and
107
+ pairs without the check, so a script that depends on it should confirm the
108
+ version first (`prohost --version`), or that `prohost --help` mentions
109
+ `--expect-account`.
110
+
111
+ #### If a program runs `agent pair` for you
112
+
113
+ What `agent pair` prints is written for a person. It includes text the server
114
+ chose — the agent's name, the workspace's name — and those can be anything,
115
+ including a sentence this CLI prints itself. So a program must not decide what
116
+ happened by searching the output. Set `$PROHOST_PAIR_RESULT_FILE` to an
117
+ absolute path and read the JSON the CLI writes there instead:
118
+
119
+ ```json
120
+ { "version": 1, "outcome": "paired", "revoked_previous_credential": false, "message": "Paired." }
121
+ ```
122
+
123
+ | `outcome` | What happened |
124
+ | --- | --- |
125
+ | `paired` | The code was redeemed and the credential stored. |
126
+ | `code_rejected` | Pairing without `--expect-account` got a 404: the code is invalid, expired or already used. |
127
+ | `bound_rejected` | Pairing with `--expect-account` got a 404: any of the above, or the code is for another workspace, or the server has no workspace-bound pairing. Not retried without the check. |
128
+ | `workspace_unconfirmed` | Pairing with `--expect-account` succeeded, but the answer did not name the expected workspace. The code is spent; nothing was saved. |
129
+ | `rate_limited` | Too many attempts from this IP. |
130
+ | `unreachable` | The request got no answer. |
131
+ | `no_subscription` | Redeemed, but no event subscription was provisioned. The code is spent; nothing was saved. |
132
+ | `invalid_arguments` | Stopped before the code was spent: a missing code, a flag with no value, an expectation that isn't a workspace id (or is set but empty), a refused URL, an unknown `--account`. |
133
+ | `failed` | Anything else. |
134
+
135
+ - `outcome` and `revoked_previous_credential` are set by what the CLI did, never
136
+ worked out from any text.
137
+ - `revoked_previous_credential` is `true` exactly when this run found the
138
+ credential stored for the same agent already revoked by the redeem and
139
+ removed it from disk.
140
+ - `message` is what the CLI said about the outcome, for display only. It can
141
+ contain server-chosen text; don't parse it. On `paired` it is the fixed
142
+ string `Paired.`
143
+ - The file never contains the pairing code, the API key (not even its masked
144
+ prefix), the signing secret, or anything from `agent.json`.
145
+ - It is written on every exit `agent pair` controls, atomically (a temp file
146
+ beside it, then a rename) and `chmod 600`, replacing whatever was at the
147
+ path. The directory must already exist.
148
+ - It never changes what pairing does. A path that isn't absolute, or one that
149
+ can't be written, is a one-line warning on stderr and nothing more. Without
150
+ the variable, nothing is written and the output is unchanged.
151
+
152
+ The names live in `src/agent/pair_result.ts` (`PAIR_RESULT_FILE_ENV`,
153
+ `PAIR_RESULT_VERSION`, `PAIR_OUTCOMES`).
154
+
77
155
  ### 3. Run
78
156
 
79
157
  ```bash
@@ -682,6 +760,7 @@ decision to make.
682
760
  | Flag | Default | What it does |
683
761
  | --- | --- | --- |
684
762
  | `--code` | `$PROHOST_PAIRING_CODE` | The one-time pairing code. |
763
+ | `--expect-account` | `$PROHOST_EXPECT_ACCOUNT` | Pair only if the code belongs to this workspace (its id). A code for any other workspace is refused and left unspent, and the pairing is never retried without the check. |
685
764
  | `--account` | the home's current account, else `default` | Which Claude Code / Codex login this agent runs on (see Accounts). |
686
765
  | `--webhook-url` | none | Also push events to this HTTPS endpoint. Omit for stream-only, which is the normal laptop setup. |
687
766
  | `--base-url` | `https://connect.prohost.ai` | Public API base. Must be `https://`; `http://` only for `localhost`, `127.0.0.1` and `[::1]`. `agent run` refuses an `agent.json` whose API, MCP or stream URL is plain http/ws off loopback. |
@@ -702,6 +781,8 @@ has real credentials.
702
781
  | `No paired agent found` | Run `prohost agent pair --code <code>` first. |
703
782
  | `no event subscription was provisioned` | Pairing half-failed server-side and nothing was saved. The code is spent — generate a fresh one and pair again. |
704
783
  | `Pairing code is invalid, expired, or already used` | Codes are single-use with a 15-minute life. Generate a fresh one. |
784
+ | `Pairing was refused, and nothing was paired` | You paired with `--expect-account` (or the desktop app did). The code is invalid, expired or used; or it belongs to a different workspace than the one expected; or the server doesn't support workspace-bound pairing yet. Generate a fresh code in the workspace you mean to pair with. Don't retry without the check unless you trust where the code came from. |
785
+ | `must be a workspace id` | `--expect-account` / `$PROHOST_EXPECT_ACCOUNT` was given something that isn't a workspace id (a UUID), or was set but empty. Nothing was sent. |
705
786
  | `auth_error: invalid_api_key` | The connection was revoked in ProhostAI. Re-pair. |
706
787
  | `auth_error: insufficient_scope` | The credential isn't allowed to stream events — ask your ProhostAI admin to re-issue the pairing. |
707
788
  | `auth_error: stream_subscription_unavailable` | The connection has no event subscription to stream. Re-pair. |
@@ -15,6 +15,7 @@ import { AccountError, loadAccounts, setAgentAccount } from './accounts.js';
15
15
  import { storedCredentialsProblem } from './base-url.js';
16
16
  import { acquireRunLock } from './lock.js';
17
17
  import { PairError, runPair } from './pair.js';
18
+ import { PAIR_RESULT_VERSION, withoutCode, writePairResult } from './pair_result.js';
18
19
  import { CredentialsMissingError, ensureWorkspace, loadCredentials, prohostHome, streamUrlFor, tryLoadCredentials, } from './credentials.js';
19
20
  import { DEFAULT_EXEC_TIMEOUT_MS, DEFAULT_IDLE_TIMEOUT_MS, runAgentHarness } from './run.js';
20
21
  import { MAX_CONCURRENCY } from './scheduler.js';
@@ -345,49 +346,78 @@ async function uninstallDaemonCommand() {
345
346
  }
346
347
  async function pairCommand(flags) {
347
348
  const code = flagString(flags, 'code') ?? process.env.PROHOST_PAIRING_CODE;
348
- if (!code) {
349
- process.stderr.write('Missing pairing code. Pass --code or set $PROHOST_PAIRING_CODE.\n');
349
+ // For a program that launched this: what happened, as a fact set by the
350
+ // branch that ran — written to `$PROHOST_PAIR_RESULT_FILE` when that is set,
351
+ // and nothing at all when it is not. See `pair_result.ts`.
352
+ const report = (outcome, message, revokedPreviousCredential = false) => writePairResult({
353
+ version: PAIR_RESULT_VERSION,
354
+ outcome,
355
+ revoked_previous_credential: revokedPreviousCredential,
356
+ message: withoutCode(message, code),
357
+ });
358
+ /** Stop before the code is spent: say why, record it, exit 1. */
359
+ const refuse = (line) => {
360
+ process.stderr.write(`${line}\n`);
361
+ report('invalid_arguments', line.replace(/^✗\s*/, ''));
350
362
  return 1;
351
- }
363
+ };
364
+ if (!code)
365
+ return refuse('Missing pairing code. Pass --code or set $PROHOST_PAIRING_CODE.');
352
366
  // Checked before the code is spent: a typo here must not cost a pairing.
353
367
  const account = flagString(flags, 'account');
354
- if (flags.account === true) {
355
- process.stderr.write('--account needs a label, e.g. --account work\n');
356
- return 1;
357
- }
368
+ if (flags.account === true)
369
+ return refuse('--account needs a label, e.g. --account work');
358
370
  // A re-pair keeps the account the home already runs on unless told otherwise.
359
371
  const previousAccount = tryLoadCredentials()?.account;
360
- if (account && !accountExists(account))
361
- return 1;
372
+ const accountProblem = account ? unknownAccountProblem(account) : undefined;
373
+ if (accountProblem)
374
+ return refuse(accountProblem);
375
+ // The workspace this computer expects the code to belong to. Once asked for
376
+ // — by flag or by environment, with or without a usable value — the pairing
377
+ // is workspace-bound or it does not happen: a bare flag stops here, and an
378
+ // empty or malformed value is refused by `runPair` before the code is spent.
379
+ // It is never dropped in favour of an unbound pairing.
380
+ if (flags['expect-account'] === true) {
381
+ return refuse('--expect-account needs a workspace id, e.g. --expect-account 0190a1b2-c3d4-7e5f-8a9b-0c1d2e3f4a5b');
382
+ }
383
+ const expectAccount = flagString(flags, 'expect-account') ?? process.env.PROHOST_EXPECT_ACCOUNT;
362
384
  try {
363
385
  await runPair({
364
386
  code,
365
387
  baseUrl: flagString(flags, 'base-url'),
366
388
  wsUrl: flagString(flags, 'url') ?? process.env.PROHOST_WS_URL,
367
389
  webhookUrl: flagString(flags, 'webhook-url'),
390
+ expectAccount,
368
391
  });
369
- const chosen = account ?? previousAccount;
370
- if (chosen) {
371
- try {
372
- setAgentAccount(chosen, undefined);
373
- process.stdout.write(` Account ${chosen}\n`);
374
- }
375
- catch (err) {
376
- // Paired either way; only the carried-over account is gone.
377
- if (!(err instanceof AccountError))
378
- throw err;
379
- process.stderr.write(`! ${err.message} This agent will use the default login.\n`);
380
- }
381
- }
382
- return 0;
383
392
  }
384
393
  catch (err) {
385
394
  if (err instanceof PairError) {
386
395
  process.stderr.write(`✗ ${err.message}\n`);
396
+ report(err.outcome, err.message, err.revokedPreviousCredential);
387
397
  return 1;
388
398
  }
399
+ report('failed', 'Pairing failed unexpectedly.');
389
400
  throw err;
390
401
  }
402
+ // Recorded now, before the login step below: the pairing is done and stored,
403
+ // and nothing that follows can unsay it. Deliberately says nothing else —
404
+ // the agent and workspace are in `agent.json`, and a result file that
405
+ // repeated them would be one more place for a credential-adjacent fact.
406
+ report('paired', 'Paired.');
407
+ const chosen = account ?? previousAccount;
408
+ if (chosen) {
409
+ try {
410
+ setAgentAccount(chosen, undefined);
411
+ process.stdout.write(` Account ${chosen}\n`);
412
+ }
413
+ catch (err) {
414
+ // Paired either way; only the carried-over account is gone.
415
+ if (!(err instanceof AccountError))
416
+ throw err;
417
+ process.stderr.write(`! ${err.message} This agent will use the default login.\n`);
418
+ }
419
+ }
420
+ return 0;
391
421
  }
392
422
  async function runCommand(flags) {
393
423
  const exec = flagString(flags, 'exec');
@@ -462,20 +492,19 @@ async function runCommand(flags) {
462
492
  lock.release();
463
493
  }
464
494
  }
465
- /** Whether an account label exists on this machine (any runtime), explaining if not. */
466
- function accountExists(label) {
495
+ /** What to say when an account label is not on this machine (any runtime); `undefined` when it is. */
496
+ function unknownAccountProblem(label) {
467
497
  if (label === 'default')
468
- return true;
498
+ return undefined;
469
499
  try {
470
500
  // Validates only; the write happens once pairing has succeeded.
471
501
  if (loadAccounts().some((a) => a.label === label))
472
- return true;
502
+ return undefined;
473
503
  }
474
504
  catch {
475
505
  /* fall through */
476
506
  }
477
- process.stderr.write(`✗ No account named "${label}" on this machine. Add it first: prohost agent account add ${label} --runtime claude|codex\n`);
478
- return false;
507
+ return `✗ No account named "${label}" on this machine. Add it first: prohost agent account add ${label} --runtime claude|codex`;
479
508
  }
480
509
  /** `--account <label>` on install-daemon: persist it to agent.json. */
481
510
  function applyAccountFlag(flags) {
@@ -16,6 +16,7 @@
16
16
  * - `POST /v1/agent-runs/{id}/heartbeat` — `api_public/routers/agent_runs.py`
17
17
  * - `POST /v1/agent-runs/{id}/events` — `api_public/routers/agent_runs.py`
18
18
  * - `POST /v1/agent-pairing/redeem` — `api_public/routers/agent_pairing.py`
19
+ * - `POST /v1/agent-pairing/redeem-bound` — `api_public/routers/agent_pairing.py`
19
20
  * - `POST /v1/conversations/{id}/messages` — `api_public/routers/conversations.py`
20
21
  */
21
22
  /** Default host for the public API (`api_public`, mounted under `/v1`). */
@@ -103,6 +104,17 @@ export interface ToolEvent {
103
104
  }
104
105
  /** `POST` — exchange a one-time pairing code for a scoped credential. */
105
106
  export declare const REDEEM_PATH = "/v1/agent-pairing/redeem";
107
+ /**
108
+ * `POST` — the same exchange, but only if the code belongs to the workspace
109
+ * named in `expected_account_id`. A code for any other workspace answers the
110
+ * same generic 404 as an unknown one and is not consumed.
111
+ *
112
+ * A separate path on purpose, so it fails closed: a server that predates it
113
+ * answers 404 and nothing is redeemed, where an older `/redeem` would ignore
114
+ * an unknown field and redeem unbound. So a 404 here must NEVER be retried on
115
+ * {@link REDEEM_PATH}.
116
+ */
117
+ export declare const REDEEM_BOUND_PATH = "/v1/agent-pairing/redeem-bound";
106
118
  /** `POST` — send a message into a conversation as the paired agent. */
107
119
  export declare function conversationMessagesPath(conversationId: string): string;
108
120
  /** `POST` — close out a run. Must be called for every run, success or not. */
@@ -16,6 +16,7 @@
16
16
  * - `POST /v1/agent-runs/{id}/heartbeat` — `api_public/routers/agent_runs.py`
17
17
  * - `POST /v1/agent-runs/{id}/events` — `api_public/routers/agent_runs.py`
18
18
  * - `POST /v1/agent-pairing/redeem` — `api_public/routers/agent_pairing.py`
19
+ * - `POST /v1/agent-pairing/redeem-bound` — `api_public/routers/agent_pairing.py`
19
20
  * - `POST /v1/conversations/{id}/messages` — `api_public/routers/conversations.py`
20
21
  */
21
22
  /** Default host for the public API (`api_public`, mounted under `/v1`). */
@@ -81,6 +82,17 @@ export const MAX_PROGRESS_CHARS = 200;
81
82
  export const MAX_TOOL_EVENTS_PER_BATCH = 50;
82
83
  /** `POST` — exchange a one-time pairing code for a scoped credential. */
83
84
  export const REDEEM_PATH = '/v1/agent-pairing/redeem';
85
+ /**
86
+ * `POST` — the same exchange, but only if the code belongs to the workspace
87
+ * named in `expected_account_id`. A code for any other workspace answers the
88
+ * same generic 404 as an unknown one and is not consumed.
89
+ *
90
+ * A separate path on purpose, so it fails closed: a server that predates it
91
+ * answers 404 and nothing is redeemed, where an older `/redeem` would ignore
92
+ * an unknown field and redeem unbound. So a 404 here must NEVER be retried on
93
+ * {@link REDEEM_PATH}.
94
+ */
95
+ export const REDEEM_BOUND_PATH = '/v1/agent-pairing/redeem-bound';
84
96
  /** `POST` — send a message into a conversation as the paired agent. */
85
97
  export function conversationMessagesPath(conversationId) {
86
98
  return `/v1/conversations/${encodeURIComponent(conversationId)}/messages`;
@@ -10,6 +10,15 @@ export interface StoredAgent {
10
10
  name: string;
11
11
  user_id: string;
12
12
  }
13
+ /**
14
+ * The ProhostAI workspace an agent is paired in, as the server named it when
15
+ * the code was redeemed.
16
+ */
17
+ export interface ProhostAccount {
18
+ id: string;
19
+ /** ``null`` for a workspace that has no name. */
20
+ name: string | null;
21
+ }
13
22
  export interface AgentCredentials {
14
23
  /** Schema version, so a future format change can migrate rather than crash. */
15
24
  version: number;
@@ -43,6 +52,17 @@ export interface AgentCredentials {
43
52
  * — see `accounts.ts`. Absent means the CLI's own default login.
44
53
  */
45
54
  account?: string;
55
+ /**
56
+ * The ProhostAI workspace this agent was paired in. Not to be confused with
57
+ * `account` above, which is a login on this machine and predates this field
58
+ * — nor with a "paired account", which on the server also means such a
59
+ * login. Hence `prohost_account`: the account on ProhostAI's side.
60
+ *
61
+ * Written by pairing since 0.11.0, from what the server reported. Absent in
62
+ * a file written by an older CLI, or paired against a server that did not
63
+ * say — nothing reads it to make a decision, so an absent one is fine.
64
+ */
65
+ prohost_account?: ProhostAccount;
46
66
  }
47
67
  /**
48
68
  * On-disk credential schema version.
@@ -15,6 +15,9 @@ export interface AgentHomeInfo {
15
15
  home: string;
16
16
  /** Agent name from `agent.json`; `undefined` when the home is not paired. */
17
17
  name?: string;
18
+ /** Name of the ProhostAI workspace it was paired in, when pairing recorded one. */
19
+ pairedIn?: string;
20
+ /** The Claude Code / Codex login it runs on — not the workspace. */
18
21
  account: string;
19
22
  /** From the daemon's `--exec`, when a daemon names one. */
20
23
  exec?: string;
@@ -87,6 +87,7 @@ export function findAgentHomes(options = {}) {
87
87
  found.push({
88
88
  home,
89
89
  name: credentials?.agent?.name,
90
+ pairedIn: credentials?.prohost_account?.name ?? undefined,
90
91
  account: credentials?.account ?? DEFAULT_ACCOUNT_LABEL,
91
92
  exec: meta.exec,
92
93
  daemon: meta.plist ? (daemonRunning(label) ? 'running' : 'installed') : 'not installed',
@@ -108,7 +109,10 @@ export async function renderAgentList(options = {}) {
108
109
  lines.push(' (none paired on this machine — prohost agent pair --code <code>)');
109
110
  for (const agent of agents) {
110
111
  const running = agent.runPid ? `running (pid ${agent.runPid})` : `daemon ${agent.daemon}`;
111
- lines.push(` ${agent.name ?? '(not paired)'} · account ${agent.account} · ${running}`);
112
+ // The ProhostAI workspace, when pairing recorded one (0.11.0+). `account`
113
+ // on the same line is the login on this machine, a different thing.
114
+ const pairedIn = agent.pairedIn ? ` · paired in ${agent.pairedIn}` : '';
115
+ lines.push(` ${agent.name ?? '(not paired)'}${pairedIn} · account ${agent.account} · ${running}`);
112
116
  lines.push(` home ${agent.home}${agent.exec ? ` · exec ${agent.exec}` : ''}`);
113
117
  }
114
118
  const accounts = loadAccounts(env);
@@ -8,8 +8,23 @@
8
8
  * Codes are single-use and expire after 15 minutes. Every redeem failure comes
9
9
  * back as one generic 404 by design, so the error text here can't be more
10
10
  * specific than "invalid, expired, or already used".
11
+ *
12
+ * Workspace-bound pairing (`--expect-account`)
13
+ * --------------------------------------------
14
+ * A code says nothing about which workspace it belongs to, and redeeming one
15
+ * makes this computer that workspace's runtime. Someone handed another
16
+ * person's code would pair their computer with that person's workspace. A
17
+ * caller that knows which workspace it means — the desktop app, signed in to
18
+ * one — passes its id, and the code is then redeemed on the bound endpoint,
19
+ * which refuses a code for any other workspace without consuming it.
20
+ *
21
+ * With an expectation, the bound endpoint is the ONLY one called. Its 404 is
22
+ * never retried on the plain endpoint: that would redeem the very code the
23
+ * check exists to stop, and against a server that predates the bound endpoint
24
+ * a fallback would turn "refused" into "redeemed unbound".
11
25
  */
12
26
  import type { AgentCredentials } from './credentials.js';
27
+ import type { PairOutcome } from './pair_result.js';
13
28
  export interface PairOptions {
14
29
  /** The one-time code from the hire modal. */
15
30
  code: string;
@@ -28,6 +43,14 @@ export interface PairOptions {
28
43
  * needs the events pushed over HTTPS.
29
44
  */
30
45
  webhookUrl?: string;
46
+ /**
47
+ * The id of the workspace this computer expects the code to belong to
48
+ * (`--expect-account` / `$PROHOST_EXPECT_ACCOUNT`). When set — to anything,
49
+ * including a value that turns out not to be an id — the pairing is bound:
50
+ * it either goes through the bound endpoint for exactly that workspace or
51
+ * does not happen. `undefined` is the unbound pairing, unchanged.
52
+ */
53
+ expectAccount?: string;
31
54
  fetchImpl?: typeof fetch;
32
55
  /** Line-oriented output sink. Defaults to stdout. */
33
56
  onLine?: (line: string) => void;
@@ -38,7 +61,20 @@ export interface PairResult {
38
61
  credentials: AgentCredentials;
39
62
  path: string;
40
63
  }
64
+ /**
65
+ * A pairing that did not complete, with what kind of failure it was.
66
+ *
67
+ * `outcome` and `revokedPreviousCredential` are set where the failure is
68
+ * detected — by the branch that ran — so a caller never has to read them back
69
+ * out of `message`, which can contain text the server chose.
70
+ */
41
71
  export declare class PairError extends Error {
72
+ readonly outcome: Exclude<PairOutcome, 'paired'>;
73
+ /** This run removed a stored credential the redeem had already revoked. */
74
+ readonly revokedPreviousCredential: boolean;
75
+ constructor(message: string, outcome?: Exclude<PairOutcome, 'paired'>,
76
+ /** This run removed a stored credential the redeem had already revoked. */
77
+ revokedPreviousCredential?: boolean);
42
78
  }
43
79
  /**
44
80
  * Redeem ``code`` and persist the resulting credentials.
@@ -8,19 +8,63 @@
8
8
  * Codes are single-use and expire after 15 minutes. Every redeem failure comes
9
9
  * back as one generic 404 by design, so the error text here can't be more
10
10
  * specific than "invalid, expired, or already used".
11
+ *
12
+ * Workspace-bound pairing (`--expect-account`)
13
+ * --------------------------------------------
14
+ * A code says nothing about which workspace it belongs to, and redeeming one
15
+ * makes this computer that workspace's runtime. Someone handed another
16
+ * person's code would pair their computer with that person's workspace. A
17
+ * caller that knows which workspace it means — the desktop app, signed in to
18
+ * one — passes its id, and the code is then redeemed on the bound endpoint,
19
+ * which refuses a code for any other workspace without consuming it.
20
+ *
21
+ * With an expectation, the bound endpoint is the ONLY one called. Its 404 is
22
+ * never retried on the plain endpoint: that would redeem the very code the
23
+ * check exists to stop, and against a server that predates the bound endpoint
24
+ * a fallback would turn "refused" into "redeemed unbound".
11
25
  */
12
26
  import { USER_AGENT } from '../version.js';
13
27
  import { baseUrlProblem, endpointProblem, isAllowedBaseUrl } from './base-url.js';
14
- import { DEFAULT_API_BASE_URL, REDEEM_PATH } from './contract.js';
28
+ import { DEFAULT_API_BASE_URL, REDEEM_BOUND_PATH, REDEEM_PATH } from './contract.js';
15
29
  import { CREDENTIALS_VERSION, clearCredentials, maskSecret, saveCredentials, tryLoadCredentials, wsUrlFromBaseUrl, } from './credentials.js';
16
30
  import { DEFAULT_WS_URL } from '../listen.js';
31
+ /**
32
+ * A pairing that did not complete, with what kind of failure it was.
33
+ *
34
+ * `outcome` and `revokedPreviousCredential` are set where the failure is
35
+ * detected — by the branch that ran — so a caller never has to read them back
36
+ * out of `message`, which can contain text the server chose.
37
+ */
17
38
  export class PairError extends Error {
39
+ outcome;
40
+ revokedPreviousCredential;
41
+ constructor(message, outcome = 'failed',
42
+ /** This run removed a stored credential the redeem had already revoked. */
43
+ revokedPreviousCredential = false) {
44
+ super(message);
45
+ this.outcome = outcome;
46
+ this.revokedPreviousCredential = revokedPreviousCredential;
47
+ this.name = 'PairError';
48
+ }
18
49
  }
19
50
  const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
20
51
  /** Whether a value is a UUID string, which is how the server types ids. */
21
52
  function isUuid(value) {
22
53
  return typeof value === 'string' && UUID_PATTERN.test(value);
23
54
  }
55
+ /**
56
+ * The workspace the server says the agent was paired in, or `undefined` when
57
+ * it did not say (an older server) or said something unusable.
58
+ */
59
+ function serverAccount(value) {
60
+ if (!value || typeof value !== 'object' || !isUuid(value.id))
61
+ return undefined;
62
+ return { id: value.id, name: typeof value.name === 'string' && value.name ? value.name : null };
63
+ }
64
+ /** How a workspace is shown: its name when it has one, always its id. */
65
+ function describeAccount(account) {
66
+ return account.name ? `${account.name} (${account.id})` : `(unnamed workspace) (${account.id})`;
67
+ }
24
68
  /**
25
69
  * Derive the API base from the server-reported MCP URL, so the server wins —
26
70
  * unless it is one the API key may not be sent to (see `base-url.ts`).
@@ -63,33 +107,55 @@ export async function runPair(options) {
63
107
  // key comes back from it.
64
108
  const baseProblem = baseUrlProblem(baseUrl);
65
109
  if (baseProblem)
66
- throw new PairError(`--base-url rejected: ${baseProblem}`);
110
+ throw new PairError(`--base-url rejected: ${baseProblem}`, 'invalid_arguments');
67
111
  const wsProblem = options.wsUrl ? endpointProblem(options.wsUrl, 'ws') : undefined;
68
112
  if (wsProblem)
69
- throw new PairError(`--url rejected: ${wsProblem}`);
113
+ throw new PairError(`--url rejected: ${wsProblem}`, 'invalid_arguments');
70
114
  if (options.webhookUrl && !options.webhookUrl.startsWith('https://')) {
71
- throw new PairError('--webhook-url must be an https:// URL');
115
+ throw new PairError('--webhook-url must be an https:// URL', 'invalid_arguments');
116
+ }
117
+ // An expectation that was asked for is never quietly dropped: a value that
118
+ // is not a workspace id stops the pairing here, before the code is spent,
119
+ // rather than falling through to an unbound one.
120
+ const bound = options.expectAccount !== undefined;
121
+ const expected = bound && isUuid(options.expectAccount) ? options.expectAccount.toLowerCase() : undefined;
122
+ if (bound && expected === undefined) {
123
+ throw new PairError('--expect-account (or $PROHOST_EXPECT_ACCOUNT) must be a workspace id — a UUID such as ' +
124
+ '0190a1b2-c3d4-7e5f-8a9b-0c1d2e3f4a5b. Nothing was sent, and the code is unspent.', 'invalid_arguments');
72
125
  }
73
126
  let response;
74
127
  try {
75
- response = await fetchImpl(`${baseUrl}${REDEEM_PATH}`, {
128
+ response = await fetchImpl(`${baseUrl}${bound ? REDEEM_BOUND_PATH : REDEEM_PATH}`, {
76
129
  method: 'POST',
77
130
  headers: { 'Content-Type': 'application/json', 'User-Agent': USER_AGENT },
78
131
  body: JSON.stringify({
79
132
  code: options.code,
133
+ ...(bound ? { expected_account_id: expected } : {}),
80
134
  ...(options.webhookUrl ? { webhook_url: options.webhookUrl } : {}),
81
135
  }),
82
136
  });
83
137
  }
84
138
  catch (err) {
85
- throw new PairError(`Could not reach ${baseUrl}: ${err instanceof Error ? err.message : String(err)}`);
139
+ throw new PairError(`Could not reach ${baseUrl}: ${err instanceof Error ? err.message : String(err)}`, 'unreachable');
140
+ }
141
+ if (response.status === 404 && bound) {
142
+ // Deliberately NOT retried on the plain endpoint — see the module comment.
143
+ // The server answers one generic 404 for all of these, so say all of them.
144
+ throw new PairError('Pairing was refused, and nothing was paired. One of these is true:\n' +
145
+ ' - the code is invalid, expired, or already used (codes are single-use and expire ' +
146
+ '15 minutes after they are generated);\n' +
147
+ ` - the code is not for the workspace this computer expects (${expected});\n` +
148
+ ' - this server does not support workspace-bound pairing yet.\n' +
149
+ 'Generate a fresh code in the workspace you mean to pair with. This was not retried ' +
150
+ 'without the workspace check: a code from a workspace you did not expect is exactly ' +
151
+ 'what the check is there to stop.', 'bound_rejected');
86
152
  }
87
153
  if (response.status === 404) {
88
154
  throw new PairError('Pairing code is invalid, expired, or already used. Codes are single-use and ' +
89
- 'expire 15 minutes after they are generated — generate a fresh one.');
155
+ 'expire 15 minutes after they are generated — generate a fresh one.', 'code_rejected');
90
156
  }
91
157
  if (response.status === 429) {
92
- throw new PairError('Too many pairing attempts from this IP. Wait an hour and retry.');
158
+ throw new PairError('Too many pairing attempts from this IP. Wait an hour and retry.', 'rate_limited');
93
159
  }
94
160
  if (!response.ok) {
95
161
  throw new PairError(`Pairing failed with HTTP ${response.status}.`);
@@ -104,6 +170,30 @@ export async function runPair(options) {
104
170
  if (!body || typeof body.api_key !== 'string' || !body.agent) {
105
171
  throw new PairError('Pairing response was missing an api_key or agent.');
106
172
  }
173
+ // Redeem revokes the credential previously paired for this agent, on every
174
+ // path that reaches here. So when what came back cannot be kept, whatever is
175
+ // on disk for the same agent is already dead — leaving it would send the
176
+ // operator chasing an `invalid_api_key` at `agent run` instead of the real
177
+ // cause.
178
+ const discardRevokedCredential = () => {
179
+ const stored = tryLoadCredentials(options.env);
180
+ return stored?.agent?.id === body.agent.id && clearCredentials(options.env);
181
+ };
182
+ // A bound redeem that succeeded names the workspace it paired in. One that
183
+ // does not — or names another — did not do what was asked, whatever the
184
+ // status code said; the credential it returned is not one to keep.
185
+ const prohostAccount = serverAccount(body.account);
186
+ if (bound && prohostAccount?.id.toLowerCase() !== expected) {
187
+ const staleRevoked = discardRevokedCredential();
188
+ throw new PairError(`The server did not confirm the workspace this computer expects (${expected}): it ` +
189
+ `${prohostAccount ? `answered for ${describeAccount(prohostAccount)}` : 'named no workspace'}.\n` +
190
+ ' The pairing code is spent, and nothing from this pairing was saved. If an agent now ' +
191
+ 'shows as paired in ProhostAI that you did not mean to pair, disconnect it there.' +
192
+ (staleRevoked
193
+ ? '\n The previously stored credential for this agent was revoked by this redeem ' +
194
+ 'and has been removed.'
195
+ : ''), 'workspace_unconfirmed', staleRevoked);
196
+ }
107
197
  // The response is JSON off the wire, so the TypeScript cast proves nothing.
108
198
  // Check the two fields that decide how this pairing behaves, rather than
109
199
  // coercing them and reporting the consequences much later as a websocket
@@ -131,18 +221,15 @@ export async function runPair(options) {
131
221
  if (!body.subscription_id) {
132
222
  // Redeem revokes the previously paired credential unconditionally, even on
133
223
  // this path (`connections.py` step 6 runs after the provisioning failure is
134
- // swallowed). So on a re-pair of the same agent, whatever is on disk is
135
- // already dead — leaving it would send the operator chasing an
136
- // `invalid_api_key` at `agent run` instead of the real cause.
137
- const stored = tryLoadCredentials(options.env);
138
- const staleRevoked = stored?.agent?.id === body.agent.id && clearCredentials(options.env);
224
+ // swallowed) — see `discardRevokedCredential`.
225
+ const staleRevoked = discardRevokedCredential();
139
226
  throw new PairError('Pairing completed but no event subscription was provisioned, so this agent ' +
140
227
  'would never receive a run.\n' +
141
228
  ' The pairing code is spent either way — generate a fresh one and pair again.\n' +
142
229
  (staleRevoked
143
230
  ? ' The previously stored credential for this agent was revoked by this ' +
144
231
  'redeem and has been removed.'
145
- : ' Nothing was saved.'));
232
+ : ' Nothing was saved.'), 'no_subscription', staleRevoked);
146
233
  }
147
234
  // Where the websocket host comes from, in order:
148
235
  //
@@ -172,9 +259,15 @@ export async function runPair(options) {
172
259
  events: body.events ?? [],
173
260
  agent: body.agent,
174
261
  paired_at: new Date().toISOString(),
262
+ // Only what the server just said: a workspace remembered from an earlier
263
+ // pairing would be a guess about this one.
264
+ ...(prohostAccount ? { prohost_account: prohostAccount } : {}),
175
265
  };
176
266
  const file = saveCredentials(credentials, options.env);
177
267
  onLine(`✓ Paired as "${body.agent.name}" (agent ${body.agent.id})`);
268
+ if (prohostAccount) {
269
+ onLine(` Workspace ${describeAccount(prohostAccount)}${bound ? ' — the workspace this computer expected' : ''}`);
270
+ }
178
271
  onLine(` API key ${maskSecret(body.api_key)} (stored, never printed in full)`);
179
272
  onLine(` Credentials ${file} (chmod 600)`);
180
273
  onLine(` MCP URL ${credentials.mcp_url}`);
@@ -0,0 +1,73 @@
1
+ /**
2
+ * A structured result of `prohost agent pair`, for a program that launched it.
3
+ *
4
+ * The CLI's stdout and stderr are for a person. They carry text the SERVER
5
+ * chose — the agent's name, the workspace's name — and whoever controls a
6
+ * workspace can name either anything, including a sentence this CLI prints
7
+ * itself. A caller that decides what happened by searching that output can
8
+ * therefore be steered by text an attacker wrote. This is what such a caller
9
+ * reads instead: `outcome` and `revoked_previous_credential` are set by the
10
+ * code path that ran, never derived from any text.
11
+ *
12
+ * Opt-in: written only when `$PROHOST_PAIR_RESULT_FILE` names an absolute
13
+ * path, on every exit path `agent pair` controls, success and failure. It
14
+ * never changes what pairing does: a path that is not absolute, or a write
15
+ * that fails, is one warning line on stderr and nothing else.
16
+ *
17
+ * It holds no secret and nothing from `agent.json`: not the pairing code, not
18
+ * the API key (not even its masked prefix), not the signing secret. `message`
19
+ * is for display only and must not be used to decide anything — it may
20
+ * contain server-chosen text.
21
+ *
22
+ * This module is the contract. A caller that wants to pin it (the desktop
23
+ * app's tests) reads the constants below.
24
+ */
25
+ /** Environment variable naming the file to write. Must be an absolute path. */
26
+ export declare const PAIR_RESULT_FILE_ENV = "PROHOST_PAIR_RESULT_FILE";
27
+ /** `version` of the shape below. */
28
+ export declare const PAIR_RESULT_VERSION = 1;
29
+ /**
30
+ * Every value `outcome` can take.
31
+ *
32
+ * - `paired` — the code was redeemed and the credential stored.
33
+ * - `code_rejected` — an unbound redeem answered 404: the code is invalid,
34
+ * expired or already used.
35
+ * - `bound_rejected` — a workspace-bound redeem answered 404: any of the
36
+ * above, or the code is for another workspace, or the server has no bound
37
+ * redeem. Never retried unbound.
38
+ * - `workspace_unconfirmed` — a workspace-bound redeem succeeded but its
39
+ * answer did not name the expected workspace. The code is spent; nothing
40
+ * was saved.
41
+ * - `rate_limited` — 429.
42
+ * - `unreachable` — the request never got an answer.
43
+ * - `no_subscription` — redeemed, but no event subscription was provisioned.
44
+ * The code is spent; nothing was saved.
45
+ * - `invalid_arguments` — stopped before the code was spent: a missing code,
46
+ * a bare or malformed flag, an empty or malformed expectation, a refused URL.
47
+ * - `failed` — anything else.
48
+ */
49
+ export declare const PAIR_OUTCOMES: readonly ["paired", "code_rejected", "bound_rejected", "workspace_unconfirmed", "rate_limited", "unreachable", "no_subscription", "invalid_arguments", "failed"];
50
+ export type PairOutcome = (typeof PAIR_OUTCOMES)[number];
51
+ export interface PairResult {
52
+ version: typeof PAIR_RESULT_VERSION;
53
+ outcome: PairOutcome;
54
+ /**
55
+ * True exactly when this run found the credential previously stored for the
56
+ * same agent already revoked by the redeem, and removed it from disk.
57
+ */
58
+ revoked_previous_credential: boolean;
59
+ /** What the CLI said about this outcome. Display only. */
60
+ message: string;
61
+ }
62
+ /**
63
+ * `message` with the pairing code removed, should anything have echoed it
64
+ * (a transport error quoting a URL or a body, say). Nothing this CLI writes
65
+ * includes the code; this is so that stays true whatever a library says.
66
+ */
67
+ export declare function withoutCode(message: string, code: string | undefined): string;
68
+ /**
69
+ * Write `result` to the file `$PROHOST_PAIR_RESULT_FILE` names, if it names
70
+ * one. Atomic (a temp file beside it, then a rename) and owner-only. Never
71
+ * throws and never affects pairing: every failure is one line on stderr.
72
+ */
73
+ export declare function writePairResult(result: PairResult, env?: NodeJS.ProcessEnv, warn?: (line: string) => void): void;
@@ -0,0 +1,99 @@
1
+ /**
2
+ * A structured result of `prohost agent pair`, for a program that launched it.
3
+ *
4
+ * The CLI's stdout and stderr are for a person. They carry text the SERVER
5
+ * chose — the agent's name, the workspace's name — and whoever controls a
6
+ * workspace can name either anything, including a sentence this CLI prints
7
+ * itself. A caller that decides what happened by searching that output can
8
+ * therefore be steered by text an attacker wrote. This is what such a caller
9
+ * reads instead: `outcome` and `revoked_previous_credential` are set by the
10
+ * code path that ran, never derived from any text.
11
+ *
12
+ * Opt-in: written only when `$PROHOST_PAIR_RESULT_FILE` names an absolute
13
+ * path, on every exit path `agent pair` controls, success and failure. It
14
+ * never changes what pairing does: a path that is not absolute, or a write
15
+ * that fails, is one warning line on stderr and nothing else.
16
+ *
17
+ * It holds no secret and nothing from `agent.json`: not the pairing code, not
18
+ * the API key (not even its masked prefix), not the signing secret. `message`
19
+ * is for display only and must not be used to decide anything — it may
20
+ * contain server-chosen text.
21
+ *
22
+ * This module is the contract. A caller that wants to pin it (the desktop
23
+ * app's tests) reads the constants below.
24
+ */
25
+ import { chmodSync, renameSync, rmSync, writeFileSync } from 'node:fs';
26
+ import path from 'node:path';
27
+ /** Environment variable naming the file to write. Must be an absolute path. */
28
+ export const PAIR_RESULT_FILE_ENV = 'PROHOST_PAIR_RESULT_FILE';
29
+ /** `version` of the shape below. */
30
+ export const PAIR_RESULT_VERSION = 1;
31
+ /**
32
+ * Every value `outcome` can take.
33
+ *
34
+ * - `paired` — the code was redeemed and the credential stored.
35
+ * - `code_rejected` — an unbound redeem answered 404: the code is invalid,
36
+ * expired or already used.
37
+ * - `bound_rejected` — a workspace-bound redeem answered 404: any of the
38
+ * above, or the code is for another workspace, or the server has no bound
39
+ * redeem. Never retried unbound.
40
+ * - `workspace_unconfirmed` — a workspace-bound redeem succeeded but its
41
+ * answer did not name the expected workspace. The code is spent; nothing
42
+ * was saved.
43
+ * - `rate_limited` — 429.
44
+ * - `unreachable` — the request never got an answer.
45
+ * - `no_subscription` — redeemed, but no event subscription was provisioned.
46
+ * The code is spent; nothing was saved.
47
+ * - `invalid_arguments` — stopped before the code was spent: a missing code,
48
+ * a bare or malformed flag, an empty or malformed expectation, a refused URL.
49
+ * - `failed` — anything else.
50
+ */
51
+ export const PAIR_OUTCOMES = [
52
+ 'paired',
53
+ 'code_rejected',
54
+ 'bound_rejected',
55
+ 'workspace_unconfirmed',
56
+ 'rate_limited',
57
+ 'unreachable',
58
+ 'no_subscription',
59
+ 'invalid_arguments',
60
+ 'failed',
61
+ ];
62
+ /** Codes shorter than this are not redacted: the replacement would mangle ordinary words. */
63
+ const MIN_REDACTABLE_CODE = 6;
64
+ /**
65
+ * `message` with the pairing code removed, should anything have echoed it
66
+ * (a transport error quoting a URL or a body, say). Nothing this CLI writes
67
+ * includes the code; this is so that stays true whatever a library says.
68
+ */
69
+ export function withoutCode(message, code) {
70
+ if (!code || code.length < MIN_REDACTABLE_CODE)
71
+ return message;
72
+ return message.split(code).join('[pairing code]');
73
+ }
74
+ /**
75
+ * Write `result` to the file `$PROHOST_PAIR_RESULT_FILE` names, if it names
76
+ * one. Atomic (a temp file beside it, then a rename) and owner-only. Never
77
+ * throws and never affects pairing: every failure is one line on stderr.
78
+ */
79
+ export function writePairResult(result, env = process.env, warn = (line) => process.stderr.write(`${line}\n`)) {
80
+ const target = env[PAIR_RESULT_FILE_ENV];
81
+ if (target === undefined)
82
+ return;
83
+ if (!path.isAbsolute(target)) {
84
+ warn(`! $${PAIR_RESULT_FILE_ENV} must be an absolute path; no pairing result was written.`);
85
+ return;
86
+ }
87
+ const temp = path.join(path.dirname(target), `.${path.basename(target)}.${process.pid}.${Math.random().toString(36).slice(2)}.tmp`);
88
+ try {
89
+ // `wx`: never write through something already sitting at the temp name.
90
+ writeFileSync(temp, `${JSON.stringify(result)}\n`, { mode: 0o600, flag: 'wx' });
91
+ // The mode above is masked by the umask; set it outright.
92
+ chmodSync(temp, 0o600);
93
+ renameSync(temp, target);
94
+ }
95
+ catch (err) {
96
+ rmSync(temp, { force: true });
97
+ warn(`! Could not write the pairing result to $${PAIR_RESULT_FILE_ENV}: ${err instanceof Error ? err.message : String(err)}`);
98
+ }
99
+ }
package/dist/index.js CHANGED
@@ -27,7 +27,8 @@ function printHelp() {
27
27
  '',
28
28
  'Usage:',
29
29
  ' prohost listen --forward-to <local-url> [--api-key <key>] [--url <ws-url>] [--machine <name>] [--subscription-id <id>]',
30
- ' prohost agent pair --code <pairing-code> [--account <label>] [--webhook-url <https-url>] [--base-url <url>]',
30
+ ' prohost agent pair --code <pairing-code> [--expect-account <workspace-id>] [--account <label>]',
31
+ ' [--webhook-url <https-url>] [--base-url <url>]',
31
32
  " prohost agent run --exec '<command>' [--concurrency <n>] [--repos <checkout,…>] [--idle-timeout <seconds>]",
32
33
  ' [--timeout <seconds>] [--dry-run] [--workdir <dir>]',
33
34
  " prohost agent install-daemon --exec '<command>' [--workdir <dir>] [--account <label>] Keep the agent running",
@@ -49,6 +50,10 @@ function printHelp() {
49
50
  '',
50
51
  'agent pair flags:',
51
52
  ' --code One-time pairing code from the hire modal. Defaults to $PROHOST_PAIRING_CODE.',
53
+ ' --expect-account Pair only if the code belongs to this ProhostAI workspace (its id, a UUID).',
54
+ ' A code for any other workspace is refused and left unspent, and the pairing',
55
+ ' is never retried without the check. Defaults to $PROHOST_EXPECT_ACCOUNT.',
56
+ ' Not the same as --account, which picks a Claude Code / Codex login.',
52
57
  ' --webhook-url Also push events to this HTTPS endpoint. Omit it: stream-only is the normal setup.',
53
58
  ' --base-url Public API base. Defaults to https://connect.prohost.ai. Must be https:// (http:// only for localhost).',
54
59
  '',
@@ -128,6 +133,9 @@ function printHelp() {
128
133
  ' PROHOST_API_KEY Used by `listen` when --api-key is not provided.',
129
134
  ' PROHOST_WS_URL Used when --url is not provided.',
130
135
  ' PROHOST_PAIRING_CODE Used by `agent pair` when --code is not provided.',
136
+ ' PROHOST_EXPECT_ACCOUNT Used by `agent pair` when --expect-account is not provided.',
137
+ ' PROHOST_PAIR_RESULT_FILE Absolute path. `agent pair` writes what happened there as JSON, for a',
138
+ ' program that launched it to read instead of this output. See the README.',
131
139
  ' PROHOST_HOME Credential, ledger, workspace and log directory. Defaults to ~/.prohost.',
132
140
  ' PROHOST_ACCOUNTS_ROOT Machine-wide account registry shared by every agent. Defaults to ~/.prohost-accounts.',
133
141
  ];
package/dist/version.d.ts CHANGED
@@ -7,6 +7,6 @@
7
7
  * `package.json`, so a release bump that forgets this file fails the suite
8
8
  * instead of shipping a `User-Agent` that lies about which build is calling.
9
9
  */
10
- export declare const CLI_VERSION = "0.10.4";
10
+ export declare const CLI_VERSION = "0.11.0";
11
11
  /** Sent on every HTTP request the CLI makes back into ProhostAI. */
12
- export declare const USER_AGENT = "prohost-cli/0.10.4";
12
+ export declare const USER_AGENT = "prohost-cli/0.11.0";
package/dist/version.js CHANGED
@@ -7,6 +7,6 @@
7
7
  * `package.json`, so a release bump that forgets this file fails the suite
8
8
  * instead of shipping a `User-Agent` that lies about which build is calling.
9
9
  */
10
- export const CLI_VERSION = '0.10.4';
10
+ export const CLI_VERSION = '0.11.0';
11
11
  /** Sent on every HTTP request the CLI makes back into ProhostAI. */
12
12
  export const USER_AGENT = `prohost-cli/${CLI_VERSION}`;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@prohost/cli",
3
- "version": "0.10.4",
3
+ "version": "0.11.0",
4
4
  "description": "Run your own AI agent as a ProhostAI teammate, and stream your account's webhooks to your laptop.",
5
5
  "type": "module",
6
6
  "license": "MIT",