@prohost/cli 0.10.4 → 0.12.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,73 @@ 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.12.0
8
+
9
+ - **`agent run` handles up to three conversations at once by default.**
10
+ `--concurrency` used to default to 1, so a one-line question in one thread
11
+ waited behind a long job in another. It now defaults to 3. Runs in the same
12
+ conversation still take turns, in order; only different conversations
13
+ overlap.
14
+ - **This changes existing pairings on upgrade.** `install-daemon` records
15
+ `--concurrency` only when it was given, so a daemon installed without it
16
+ goes from one run at a time to three once it restarts on 0.12.0. Two things
17
+ follow:
18
+ - every run spends the same login's quota, so at busy times a subscription's
19
+ window is used up to three times as fast;
20
+ - without `--repos`, overlapping runs share every checkout on the machine.
21
+ Each run is told so, and told not to switch a shared checkout's branch.
22
+ Pass `--repos` to give each conversation its own.
23
+ - **`--concurrency 1` is the old behaviour**, exactly: one run at a time, in
24
+ arrival order, and a prompt that says nothing about sharing the machine. For
25
+ a daemon, run `install-daemon` again with `--concurrency 1`.
26
+ - **`--repos ~/app,~/site` means what it says.** A shell expands only the `~`
27
+ that starts the word, so every entry after the first used to resolve to a
28
+ directory named `~` under the current one. The CLI now reads a leading `~`
29
+ or `~/` on each entry as the home directory.
30
+ - The startup line that reports the limit says when it is the default and how
31
+ to turn it off.
32
+
33
+ ## 0.11.0
34
+
35
+ - **Pair only with the workspace you expect.** `prohost agent pair --code <c>
36
+ --expect-account <workspace-id>` (or `$PROHOST_EXPECT_ACCOUNT`) redeems the
37
+ code only if it belongs to that workspace. A pairing code doesn't say whose
38
+ it is, and redeeming one makes this computer that workspace's runtime, so a
39
+ code from somewhere unexpected could pair a computer with the wrong
40
+ workspace. With an expectation the CLI calls the new
41
+ `POST /v1/agent-pairing/redeem-bound` and nothing else:
42
+ - a code for another workspace is refused and left unspent, and an existing
43
+ pairing keeps working;
44
+ - a refusal is never retried on the plain endpoint. Its message covers every
45
+ reading of the server's one generic 404: an invalid, expired or used code,
46
+ a code for a different workspace, or a server without bound pairing;
47
+ - a value that isn't a workspace id, a bare flag, or an environment variable
48
+ that is set but empty stops the pairing before the code is spent.
49
+ - **Pairing says which workspace it paired in**, and records it in `agent.json`
50
+ as `prohost_account: { id, name }` when the server reports it, with or
51
+ without `--expect-account`. `agent list` shows the name. Files written by an
52
+ older CLI load as before.
53
+ - Without `--expect-account` the pairing request is byte-for-byte what 0.10.4
54
+ sent.
55
+ - **A structured result for programs that run `agent pair`.** Set
56
+ `$PROHOST_PAIR_RESULT_FILE` to an absolute path and the CLI writes
57
+ `{ "version": 1, "outcome", "revoked_previous_credential", "message" }` there
58
+ on every exit it controls. The printed output includes names the server
59
+ chose, which can be worded as anything, so a program that classified a
60
+ pairing by searching that output could be steered by them; `outcome` and
61
+ `revoked_previous_credential` are set by what the CLI did instead. Outcomes:
62
+ `paired`, `code_rejected`, `bound_rejected`, `workspace_unconfirmed`,
63
+ `rate_limited`, `unreachable`, `no_subscription`, `invalid_arguments`,
64
+ `failed`. The file holds no pairing code, API key or signing secret and
65
+ nothing from `agent.json`; it is written atomically, `chmod 600`. A path
66
+ that isn't absolute or can't be written is a one-line warning and never
67
+ fails a pairing. Unset, nothing changes.
68
+
69
+ To tell whether a CLI supports this without pairing: `prohost --version` is
70
+ `0.11.0` or later, or `prohost --help` mentions `--expect-account`. An older
71
+ CLI silently ignores the flag and the environment variable and pairs without
72
+ the check.
73
+
7
74
  ## 0.10.4
8
75
 
9
76
  - **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
@@ -435,9 +513,9 @@ on the machine via `ps` and a process environment is not. Literal `headers`,
435
513
  treatment: the CLI translates them to Codex's environment-indirection fields,
436
514
  so their values do not appear in process arguments.
437
515
 
438
- Runs are handled one at a time unless you raise `--concurrency` (see "Running
439
- several conversations at once"), and each run executes **at most once on this
440
- machine** — even across restarts. Started runs are recorded in
516
+ Up to three runs are handled at once, in different conversations (see "Running
517
+ several conversations at once"; `--concurrency 1` is one at a time), and each
518
+ run executes **at most once on this machine** — even across restarts. Started runs are recorded in
441
519
  `~/.prohost/runs.json` before the command launches, so a webhook redelivery
442
520
  that arrives after a crash or restart will close the run out rather than run
443
521
  your agent a second time. That matters because your agent holds a credential:
@@ -491,21 +569,30 @@ naming the pid that holds `$PROHOST_HOME/run.lock`.
491
569
 
492
570
  ### Running several conversations at once
493
571
 
494
- By default a second request waits for the first, however unrelated they are —
495
- a one-line question in one thread sits behind a two-hour job in another.
496
- `--concurrency <n>` (1 to 16) lets up to `n` runs execute at once:
572
+ Up to three runs execute at once, so a one-line question in one thread does
573
+ not sit behind a two-hour job in another. `--concurrency <n>` (1 to 16) sets
574
+ the limit:
497
575
 
498
576
  ```bash
499
- prohost agent run --exec 'claude -p' --concurrency 3 --repos ~/code/app,~/code/site
577
+ prohost agent run --exec 'claude -p' --concurrency 5 --repos ~/code/app,~/code/site
500
578
  ```
501
579
 
580
+ `--concurrency 1` is one run at a time, in the order they arrived — what every
581
+ version before 0.12.0 did unless told otherwise.
582
+
502
583
  - **Runs in the same conversation still take turns, in order.** Each turn
503
584
  resumes the session the last one left, so two turns of one thread never
504
585
  overlap. Only different conversations run side by side.
505
586
  - **Every run spends the same login's quota.** Three runs at once use a
506
- subscription's 5-hour window about three times as fast. Start small.
587
+ subscription's 5-hour window about three times as fast. Lower the limit if
588
+ the window runs out at busy times.
507
589
  - **They share the machine.** The agent is told other runs may be executing
508
- beside it. If it edits code, also pass `--repos`.
590
+ beside it and, without `--repos`, that no git checkout is its own. If it
591
+ edits code, pass `--repos`.
592
+ - **A daemon follows the default.** `install-daemon` records `--concurrency`
593
+ only when you give it, so a daemon installed without it runs three at once
594
+ after upgrading to 0.12.0 and restarting. Reinstall with `--concurrency 1`
595
+ to keep it at one.
509
596
 
510
597
  `--repos` takes a comma-separated list of git checkouts the agent works on.
511
598
  Each conversation then gets its own `git worktree` of every one, at
@@ -523,7 +610,7 @@ original repository and are never touched. Each worktree is a full working
523
610
  copy, and dependencies installed inside it are not shared, so budget disk for
524
611
  it.
525
612
 
526
- `--repos` works without `--concurrency` too. If a checkout cannot be made (a
613
+ `--repos` works at any `--concurrency`, 1 included. If a checkout cannot be made (a
527
614
  full disk, a path that is no longer a repository), the run still happens: the
528
615
  agent is told to treat that repository as read-only for the run.
529
616
 
@@ -581,7 +668,7 @@ same judgement.
581
668
  | `--exec` | *(required)* | Command to run for each requested run. Prompt arrives on stdin. |
582
669
  | `--idle-timeout` | `900` | Seconds a run may produce **no output at all** before it is treated as stalled and killed. Any byte on stdout or stderr resets it. `0` disables it — see "Long runs". |
583
670
  | `--timeout` | `0` | Seconds one run may take before the command is killed, however busy it is. `0` — the default — means no wall-clock limit. Set it if you want a hard ceiling as well. |
584
- | `--concurrency` | `1` | How many runs may execute at once (1–16). Runs in one conversation always take turns. See "Running several conversations at once". |
671
+ | `--concurrency` | `3` | How many runs may execute at once (1–16); `1` is one at a time. Runs in one conversation always take turns. See "Running several conversations at once". |
585
672
  | `--repos` | — | Comma-separated git checkouts the agent works on. Each conversation gets its own worktree of every one. |
586
673
  | `--dry-run` | off | Print the reply that would be posted; write nothing. |
587
674
  | `--workdir` | `~/.prohost/workspace` | Working directory for the command. See "Where your agent runs". |
@@ -682,6 +769,7 @@ decision to make.
682
769
  | Flag | Default | What it does |
683
770
  | --- | --- | --- |
684
771
  | `--code` | `$PROHOST_PAIRING_CODE` | The one-time pairing code. |
772
+ | `--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
773
  | `--account` | the home's current account, else `default` | Which Claude Code / Codex login this agent runs on (see Accounts). |
686
774
  | `--webhook-url` | none | Also push events to this HTTPS endpoint. Omit for stream-only, which is the normal laptop setup. |
687
775
  | `--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 +790,8 @@ has real credentials.
702
790
  | `No paired agent found` | Run `prohost agent pair --code <code>` first. |
703
791
  | `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
792
  | `Pairing code is invalid, expired, or already used` | Codes are single-use with a 15-minute life. Generate a fresh one. |
793
+ | `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. |
794
+ | `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
795
  | `auth_error: invalid_api_key` | The connection was revoked in ProhostAI. Re-pair. |
706
796
  | `auth_error: insufficient_scope` | The credential isn't allowed to stream events — ask your ProhostAI admin to re-issue the pairing. |
707
797
  | `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.
@@ -90,7 +90,10 @@ export interface HarnessInvocation {
90
90
  allowUnverified?: boolean;
91
91
  /** Gate tools behind permission prompts (the `--safe-tools` opt-out). */
92
92
  safeTools?: boolean;
93
- /** Runs allowed at once; omitted for the default of one. */
93
+ /**
94
+ * Runs allowed at once. Omitted unless `--concurrency` was given, so an
95
+ * installed daemon follows the CLI's default rather than freezing one.
96
+ */
94
97
  concurrency?: number;
95
98
  /** Absolute checkouts each conversation gets its own worktree of. */
96
99
  repos?: string[];
@@ -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
+ }
@@ -74,7 +74,7 @@ export interface PromptContext {
74
74
  idleTimeoutMs?: number;
75
75
  /**
76
76
  * Other runs may be executing on this machine at the same moment
77
- * (`--concurrency` above 1).
77
+ * (the default; `--concurrency 1` turns it off).
78
78
  *
79
79
  * Said because it changes what is safe: an agent that believes it has the
80
80
  * machine to itself switches branches in a shared checkout and writes
@@ -10,7 +10,7 @@ import { NO_REPLY_TOKEN } from './contract.js';
10
10
  /**
11
11
  * What the agent is told about sharing the machine and where its checkouts are.
12
12
  *
13
- * Empty when neither `--concurrency` nor `--repos` is in play.
13
+ * Empty for `--concurrency 1` without `--repos`.
14
14
  */
15
15
  function isolationLines(context) {
16
16
  const lines = [];
@@ -18,6 +18,12 @@ function isolationLines(context) {
18
18
  const failures = context.checkoutFailures ?? [];
19
19
  if (context.parallel) {
20
20
  lines.push('- Other conversations may be running on this machine right now, as other', ' processes of you, in this same working directory. Give any scratch file', ' a name unique to this run, and do not assume a file or a git branch', ' you did not create in this conversation is yours to change.');
21
+ // Without `--repos` — the usual pairing — nothing moves a run off the
22
+ // checkouts its standing instructions name, and `git switch` there changes
23
+ // the files under whichever run is reading them.
24
+ if (checkouts.length === 0 && failures.length === 0) {
25
+ lines.push('- No git checkout on this machine is private to this conversation.', ' Another run may be reading one while you work, so do not switch its', ' branch or leave it half-changed: if the work needs a branch of its', ' own, do it in a separate `git worktree` you create for it.');
26
+ }
21
27
  }
22
28
  if (checkouts.length > 0) {
23
29
  lines.push('- This conversation has its own git checkouts. Do all repository work', ' in them, and nowhere else:', ...checkouts.map((checkout) => ` ${checkout.name}: ${checkout.path}`), ' They are worktrees private to this conversation and they persist', ' between your runs here, so a branch or an uncommitted change you find', ' in one is your own earlier work in this thread. Wherever your other', ' instructions name one of these as the place to work, use the path', ' above instead:', ...checkouts.map((checkout) => ` ${checkout.source}`), ' Never switch branches, commit, or edit files in those originals —', ' other runs read them. Do not run `git worktree add` yourself.');
@@ -10,12 +10,13 @@
10
10
  *
11
11
  * Two deliberate safety properties:
12
12
  *
13
- * * **Runs are serialized by default.** One local agent, one run at a time;
14
- * concurrent frames queue. Agents hold working directories and rate limits,
15
- * and running several at once is a good way to corrupt both. `--concurrency`
16
- * raises the limit for runs in *different* conversations only — two turns of
17
- * one conversation never overlap (see `scheduler.ts`) — and `--repos` gives
18
- * each conversation its own git checkouts to do it in (see `worktrees.ts`).
13
+ * * **Runs in one conversation are serialized.** Two turns of one conversation
14
+ * never overlap (see `scheduler.ts`). Runs in *different* conversations may:
15
+ * up to `--concurrency` of them at once, {@link DEFAULT_CONCURRENCY} when the
16
+ * flag is absent, and frames beyond that queue. Agents hold working
17
+ * directories and rate limits, so every overlapping run is told it is
18
+ * sharing the machine, `--repos` gives each conversation its own git
19
+ * checkouts (see `worktrees.ts`), and `--concurrency 1` is one run at a time.
19
20
  * * **Runs are deduped on ``run_id``.** Webhook deliveries retry, and each
20
21
  * attempt is mirrored to this socket. Without dedupe a retry would execute
21
22
  * the command a second time and post a second reply.
@@ -108,9 +109,10 @@ export interface AgentRunOptions {
108
109
  */
109
110
  cwd?: string;
110
111
  /**
111
- * How many runs may execute at once. Defaults to 1 — one run at a time, as
112
- * before. Above 1, runs in different conversations overlap; runs in the same
113
- * conversation still wait for each other.
112
+ * How many runs may execute at once. Defaults to
113
+ * {@link DEFAULT_CONCURRENCY}. Above 1, runs in different conversations
114
+ * overlap; runs in the same conversation still wait for each other. 1 is one
115
+ * run at a time, in arrival order.
114
116
  */
115
117
  concurrency?: number;
116
118
  /**
package/dist/agent/run.js CHANGED
@@ -10,12 +10,13 @@
10
10
  *
11
11
  * Two deliberate safety properties:
12
12
  *
13
- * * **Runs are serialized by default.** One local agent, one run at a time;
14
- * concurrent frames queue. Agents hold working directories and rate limits,
15
- * and running several at once is a good way to corrupt both. `--concurrency`
16
- * raises the limit for runs in *different* conversations only — two turns of
17
- * one conversation never overlap (see `scheduler.ts`) — and `--repos` gives
18
- * each conversation its own git checkouts to do it in (see `worktrees.ts`).
13
+ * * **Runs in one conversation are serialized.** Two turns of one conversation
14
+ * never overlap (see `scheduler.ts`). Runs in *different* conversations may:
15
+ * up to `--concurrency` of them at once, {@link DEFAULT_CONCURRENCY} when the
16
+ * flag is absent, and frames beyond that queue. Agents hold working
17
+ * directories and rate limits, so every overlapping run is told it is
18
+ * sharing the machine, `--repos` gives each conversation its own git
19
+ * checkouts (see `worktrees.ts`), and `--concurrency 1` is one run at a time.
19
20
  * * **Runs are deduped on ``run_id``.** Webhook deliveries retry, and each
20
21
  * attempt is mirrored to this socket. Without dedupe a retry would execute
21
22
  * the command a second time and post a second reply.
@@ -43,7 +44,7 @@ import { HeartbeatLoop } from './heartbeat.js';
43
44
  import { buildPrompt, replyFromStdout } from './prompt.js';
44
45
  import { NO_CAPABILITIES, capabilityProbe } from './capabilities.js';
45
46
  import { RunStore } from './runstore.js';
46
- import { MAX_CONCURRENCY, RunScheduler } from './scheduler.js';
47
+ import { DEFAULT_CONCURRENCY, MAX_CONCURRENCY, RunScheduler } from './scheduler.js';
47
48
  import { RUNTIME_STATUS_FEATURE, RuntimeStatusReporter, claudeQuotaProvider, codexConfiguredModel, codexQuotaProvider, modelFromExec, parseVersion, readCodexObservation, } from './runtime_status.js';
48
49
  import { SessionStore, sessionKeyFor } from './sessionstore.js';
49
50
  import { verifySignature } from './signature.js';
@@ -1152,11 +1153,11 @@ export async function runAgentHarness(options) {
1152
1153
  }
1153
1154
  return true;
1154
1155
  };
1155
- const concurrency = Math.min(MAX_CONCURRENCY, Math.max(1, Math.floor(options.concurrency ?? 1)));
1156
+ const concurrency = Math.min(MAX_CONCURRENCY, Math.max(1, Math.floor(options.concurrency ?? DEFAULT_CONCURRENCY)));
1156
1157
  const scheduler = new RunScheduler(concurrency);
1157
1158
  const worktrees = new WorktreeManager({ repos: options.repos ?? [], env: options.env, log });
1158
1159
  if (concurrency > 1) {
1159
- log(`${timestamp()} ⚙ up to ${concurrency} runs at once — runs in the same conversation still take turns` +
1160
+ log(`${timestamp()} ⚙ up to ${concurrency} runs at once${options.concurrency === undefined ? ' (the default; --concurrency 1 for one at a time)' : ''} — runs in the same conversation still take turns` +
1160
1161
  (worktrees.enabled
1161
1162
  ? ''
1162
1163
  : '. No --repos given: runs share every checkout on this machine, so name the ' +
@@ -2,10 +2,11 @@
2
2
  * Which accepted runs execute now, and which wait.
3
3
  *
4
4
  * `agent run` used to chain every run onto one promise: one local agent, one
5
- * run at a time. That is still the default, and with a limit of 1 this
6
- * scheduler is exactly that chain — strict arrival order, nothing overlapping.
5
+ * run at a time. With a limit of 1 this scheduler is still exactly that chain
6
+ * — strict arrival order, nothing overlapping — and `--concurrency 1` asks for
7
+ * it. The harness default is {@link DEFAULT_CONCURRENCY}.
7
8
  *
8
- * `--concurrency N` lifts the limit without lifting the rule that actually
9
+ * A limit above 1 lifts the cap without lifting the rule that actually
9
10
  * protects an answer: **two runs on the same lane never overlap, and they start
10
11
  * in the order they arrived.** A lane is a conversation (see `sessionKeyFor`).
11
12
  * Each turn resumes the session the previous turn left, so running two turns of
@@ -19,6 +20,13 @@
19
20
  */
20
21
  /** Most runs one harness will execute at once, whatever the flag says. */
21
22
  export declare const MAX_CONCURRENCY = 16;
23
+ /**
24
+ * Runs `agent run` executes at once when `--concurrency` is not given.
25
+ *
26
+ * Not 1: a one-line question in one thread should not sit behind a two-hour
27
+ * job in another. Not higher: every run spends the same login's quota.
28
+ */
29
+ export declare const DEFAULT_CONCURRENCY = 3;
22
30
  export declare class RunScheduler {
23
31
  private readonly limit;
24
32
  private readonly pending;
@@ -2,10 +2,11 @@
2
2
  * Which accepted runs execute now, and which wait.
3
3
  *
4
4
  * `agent run` used to chain every run onto one promise: one local agent, one
5
- * run at a time. That is still the default, and with a limit of 1 this
6
- * scheduler is exactly that chain — strict arrival order, nothing overlapping.
5
+ * run at a time. With a limit of 1 this scheduler is still exactly that chain
6
+ * — strict arrival order, nothing overlapping — and `--concurrency 1` asks for
7
+ * it. The harness default is {@link DEFAULT_CONCURRENCY}.
7
8
  *
8
- * `--concurrency N` lifts the limit without lifting the rule that actually
9
+ * A limit above 1 lifts the cap without lifting the rule that actually
9
10
  * protects an answer: **two runs on the same lane never overlap, and they start
10
11
  * in the order they arrived.** A lane is a conversation (see `sessionKeyFor`).
11
12
  * Each turn resumes the session the previous turn left, so running two turns of
@@ -19,6 +20,13 @@
19
20
  */
20
21
  /** Most runs one harness will execute at once, whatever the flag says. */
21
22
  export const MAX_CONCURRENCY = 16;
23
+ /**
24
+ * Runs `agent run` executes at once when `--concurrency` is not given.
25
+ *
26
+ * Not 1: a one-line question in one thread should not sit behind a two-hour
27
+ * job in another. Not higher: every run spends the same login's quota.
28
+ */
29
+ export const DEFAULT_CONCURRENCY = 3;
22
30
  export class RunScheduler {
23
31
  limit;
24
32
  pending = [];
@@ -72,10 +72,16 @@ export declare function laneDirName(lane: string): string;
72
72
  * forever. A running harness instead reports the checkout it could not make on
73
73
  * each run, and keeps answering.
74
74
  *
75
+ * A leading `~` is the home directory. A shell only expands the one that
76
+ * starts the word, so in `--repos ~/app,~/site` the second arrives as typed —
77
+ * and resolved as-is it would name a directory called `~` under wherever the
78
+ * harness was started.
79
+ *
75
80
  * @throws ReposFlagError naming the entry that is wrong.
76
81
  */
77
82
  export declare function parseReposFlag(raw: string, options?: {
78
83
  requireCheckouts?: boolean;
84
+ home?: string;
79
85
  }): string[];
80
86
  /** Whether `repo` is the top of a git checkout (a clone, or a worktree of one). */
81
87
  export declare function isCheckout(repo: string): boolean;
@@ -30,6 +30,7 @@
30
30
  import { execFile } from 'node:child_process';
31
31
  import { createHash } from 'node:crypto';
32
32
  import { existsSync, mkdirSync, readdirSync, rmdirSync, rmSync, statSync, utimesSync, writeFileSync } from 'node:fs';
33
+ import os from 'node:os';
33
34
  import path from 'node:path';
34
35
  import { prohostHome } from './credentials.js';
35
36
  /** Child-process env var naming the directory that holds this run's checkouts. */
@@ -83,6 +84,11 @@ function lastLine(text) {
83
84
  * forever. A running harness instead reports the checkout it could not make on
84
85
  * each run, and keeps answering.
85
86
  *
87
+ * A leading `~` is the home directory. A shell only expands the one that
88
+ * starts the word, so in `--repos ~/app,~/site` the second arrives as typed —
89
+ * and resolved as-is it would name a directory called `~` under wherever the
90
+ * harness was started.
91
+ *
86
92
  * @throws ReposFlagError naming the entry that is wrong.
87
93
  */
88
94
  export function parseReposFlag(raw, options = {}) {
@@ -95,7 +101,7 @@ export function parseReposFlag(raw, options = {}) {
95
101
  const repos = [];
96
102
  const names = new Map();
97
103
  for (const entry of entries) {
98
- const repo = path.resolve(entry);
104
+ const repo = path.resolve(expandHome(entry, options.home));
99
105
  if (options.requireCheckouts && !isCheckout(repo)) {
100
106
  throw new ReposFlagError(`--repos: ${repo} is not the top of a git checkout (no .git there).`);
101
107
  }
@@ -110,6 +116,11 @@ export function parseReposFlag(raw, options = {}) {
110
116
  }
111
117
  return repos;
112
118
  }
119
+ function expandHome(entry, home = os.homedir()) {
120
+ if (entry === '~')
121
+ return home;
122
+ return entry.startsWith('~/') ? path.join(home, entry.slice(2)) : entry;
123
+ }
113
124
  /** Whether `repo` is the top of a git checkout (a clone, or a worktree of one). */
114
125
  export function isCheckout(repo) {
115
126
  return existsSync(path.join(repo, '.git'));
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
  '',
@@ -60,10 +65,10 @@ function printHelp() {
60
65
  ' --timeout Seconds one run may take before it is killed, however busy it is.',
61
66
  ' Defaults to 0 — no wall-clock limit, because a long run is not a stuck',
62
67
  ' one. Set it if you want a hard ceiling as well as the stall watchdog.',
63
- ' --concurrency How many runs may execute at once, 1 to 16. Defaults to 1. Runs in the same',
68
+ ' --concurrency How many runs may execute at once, 1 to 16. Defaults to 3. Runs in the same',
64
69
  ' conversation always take turns, so only different conversations overlap.',
65
70
  ' Every run spends the same login\'s quota, and they share this machine —',
66
- ' pair it with --repos if the agent edits code.',
71
+ ' pair it with --repos if the agent edits code. Pass 1 for one run at a time.',
67
72
  ' --repos Comma-separated git checkouts the agent works on. Each conversation gets',
68
73
  ' its own `git worktree` of every one under $PROHOST_HOME/worktrees, kept for',
69
74
  ' the life of the thread and removed after 14 idle days if it is clean. The',
@@ -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.12.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.12.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.12.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.12.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",