@prohost/cli 0.9.0 → 0.10.1
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 +63 -0
- package/README.md +77 -2
- package/dist/agent/accounts.d.ts +17 -1
- package/dist/agent/accounts.js +43 -2
- package/dist/agent/agent_commands.js +6 -3
- package/dist/agent/api.d.ts +6 -0
- package/dist/agent/api.js +10 -5
- package/dist/agent/base-url.d.ts +43 -0
- package/dist/agent/base-url.js +72 -0
- package/dist/agent/command.js +21 -6
- package/dist/agent/credentials.d.ts +9 -0
- package/dist/agent/credentials.js +1 -1
- package/dist/agent/pair.js +41 -20
- package/dist/index.d.ts +1 -0
- package/dist/index.js +27 -2
- package/dist/usage/backoff.d.ts +42 -0
- package/dist/usage/backoff.js +85 -0
- package/dist/usage/command.d.ts +34 -0
- package/dist/usage/command.js +344 -0
- package/dist/usage/config.d.ts +38 -0
- package/dist/usage/config.js +75 -0
- package/dist/usage/discovery.d.ts +63 -0
- package/dist/usage/discovery.js +187 -0
- package/dist/usage/key-input.d.ts +36 -0
- package/dist/usage/key-input.js +79 -0
- package/dist/usage/quota.d.ts +96 -0
- package/dist/usage/quota.js +145 -0
- package/dist/usage/scanner.d.ts +117 -0
- package/dist/usage/scanner.js +438 -0
- package/dist/usage/service.d.ts +122 -0
- package/dist/usage/service.js +307 -0
- package/dist/version.d.ts +2 -2
- package/dist/version.js +1 -1
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,69 @@ 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.10.1
|
|
8
|
+
|
|
9
|
+
Safer key handling for `prohost usage connect`, and no credential ever goes to
|
|
10
|
+
a plain-http API base.
|
|
11
|
+
|
|
12
|
+
- **`usage connect` reads the key without exposing it.** It prompts with echo
|
|
13
|
+
off at a terminal, reads piped stdin (`pbpaste | prohost usage connect --key -`),
|
|
14
|
+
or takes `$PROHOST_API_KEY`. `--key <value>` still works but prints a warning:
|
|
15
|
+
it leaves the key in shell history and the process list. A connected machine
|
|
16
|
+
(or one with a paired agent) is not prompted again.
|
|
17
|
+
- **`--base-url` must be `https://`.** `usage connect --base-url` and
|
|
18
|
+
`agent pair --base-url` reject anything else before a key is stored or a
|
|
19
|
+
pairing code is spent; `http://` is allowed only for `localhost`, `127.0.0.1`
|
|
20
|
+
and `[::1]`. `usage start`, `install-daemon` and every reporter tick refuse to
|
|
21
|
+
send a key to a stored base URL that fails the same check, and `usage status`
|
|
22
|
+
flags it. A pairing whose server reports a plain-http `mcp_url` or plain-ws
|
|
23
|
+
`ws_url` keeps the requested base, `<base>/mcp` and the default stream URL
|
|
24
|
+
instead, and `agent pair --url` / `listen --url` must be `wss://` (`ws://`
|
|
25
|
+
only on loopback).
|
|
26
|
+
- **`agent run` and `agent install-daemon` refuse insecure stored credentials.**
|
|
27
|
+
An `agent.json` whose API base, MCP URL or stream URL would carry the key in
|
|
28
|
+
plaintext (paired over http by an older CLI) is refused with a re-pair hint,
|
|
29
|
+
before anything is sent.
|
|
30
|
+
|
|
31
|
+
## 0.10.0
|
|
32
|
+
|
|
33
|
+
Every Claude Code and Codex login on a machine can report its remaining
|
|
34
|
+
capacity and token use to ProhostAI, without pairing an agent.
|
|
35
|
+
|
|
36
|
+
- **`prohost usage connect [--key <key>]`** stores an API key with the
|
|
37
|
+
`usage:write` scope in `$PROHOST_HOME/usage.json` (`0600`, never printed). A
|
|
38
|
+
machine that already paired an agent can skip `--key`; its credential is
|
|
39
|
+
accepted.
|
|
40
|
+
- **Discovery.** `connect`, `usage discover [--dry-run]` and every `usage start`
|
|
41
|
+
register the config directories already on the machine — `~/.claude*`,
|
|
42
|
+
`~/.codex*`, `$CLAUDE_CONFIG_DIR`, `$CODEX_HOME` — in the machine-wide account
|
|
43
|
+
registry, labelled from the directory name (`~/.claude-work` → `work`). Only
|
|
44
|
+
directory names and non-secret markers are looked at.
|
|
45
|
+
- **`prohost usage start`** reports every 5 minutes (`POST /v1/usage/reports`,
|
|
46
|
+
source `cli`): each login's windows from the same idle probes `agent run`
|
|
47
|
+
uses, asked at most every 15 minutes per login, three at a time.
|
|
48
|
+
- **Token totals** from the CLIs' own session logs, summed per local day,
|
|
49
|
+
login, model and project folder name (`POST /v1/usage/token-totals`). Claude
|
|
50
|
+
Code responses are counted once however often their lines repeat; Codex usage
|
|
51
|
+
is the growth of each session's running total, with cached input split out.
|
|
52
|
+
The first run looks back 30 days, then reads only appended lines. No prompt
|
|
53
|
+
text is read into the totals or sent.
|
|
54
|
+
Totals for a login are sent only once a capacity report for it has been
|
|
55
|
+
accepted (`acceptedAccountKeys` when the server names them; otherwise any
|
|
56
|
+
non-zero `accepted`), so a first run or a newly found login reports first.
|
|
57
|
+
Skipped rows follow the server's `skipped[].reason`: `not_owned`,
|
|
58
|
+
`other_source` and `out_of_range` are dropped (logged once), `throttled` is
|
|
59
|
+
retried next pass, `not_reported` waits for the next accepted report. Rows
|
|
60
|
+
skipped without a reason are retried at most three times.
|
|
61
|
+
- **Fails soft.** A 401/403/404 (routes not deployed, feature off, key without
|
|
62
|
+
the scope) is logged once and retried every 30 minutes; network errors back
|
|
63
|
+
off exponentially. A tick never throws.
|
|
64
|
+
- **`prohost usage install-daemon` / `uninstall-daemon`** keep it running under
|
|
65
|
+
launchd with its own label (`ai.prohost.usage.*`), next to any agent daemon.
|
|
66
|
+
**`prohost usage status`** shows what was last sent and local totals.
|
|
67
|
+
- `prohost agent account remove` on a login registered from an existing
|
|
68
|
+
directory forgets it and leaves the directory in place.
|
|
69
|
+
|
|
7
70
|
## 0.9.0
|
|
8
71
|
|
|
9
72
|
A quick question no longer waits behind a long job in another thread.
|
package/README.md
CHANGED
|
@@ -1,12 +1,15 @@
|
|
|
1
1
|
# `@prohost/cli`
|
|
2
2
|
|
|
3
|
-
The [ProhostAI](https://www.prohost.ai) command-line tool.
|
|
3
|
+
The [ProhostAI](https://www.prohost.ai) command-line tool. Three things it does:
|
|
4
4
|
|
|
5
5
|
- **`prohost agent`** — run your own local agent as a ProhostAI teammate. Someone
|
|
6
6
|
@-mentions it in the app, it runs on your machine, and its answer is posted
|
|
7
7
|
back into the conversation.
|
|
8
8
|
- **`prohost listen`** — stream your account's webhooks to a local HTTP endpoint
|
|
9
9
|
while you develop against them (like `stripe listen`).
|
|
10
|
+
- **`prohost usage`** — show your workspace how much Claude Code and Codex
|
|
11
|
+
capacity every login on this machine has left, and how many tokens each one
|
|
12
|
+
used, without pairing an agent.
|
|
10
13
|
|
|
11
14
|
You need a ProhostAI account and **Node.js 18 or newer**. Nothing to install —
|
|
12
15
|
`npx` fetches it on demand:
|
|
@@ -679,7 +682,7 @@ decision to make.
|
|
|
679
682
|
| `--code` | `$PROHOST_PAIRING_CODE` | The one-time pairing code. |
|
|
680
683
|
| `--account` | the home's current account, else `default` | Which Claude Code / Codex login this agent runs on (see Accounts). |
|
|
681
684
|
| `--webhook-url` | none | Also push events to this HTTPS endpoint. Omit for stream-only, which is the normal laptop setup. |
|
|
682
|
-
| `--base-url` | `https://connect.prohost.ai` | Public API base. |
|
|
685
|
+
| `--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. |
|
|
683
686
|
|
|
684
687
|
### About `--allow-unverified`
|
|
685
688
|
|
|
@@ -728,6 +731,78 @@ through ProhostAI's own approval path, not around it via an API key.
|
|
|
728
731
|
|
|
729
732
|
---
|
|
730
733
|
|
|
734
|
+
## AI usage reporting
|
|
735
|
+
|
|
736
|
+
`prohost usage` reports, for every Claude Code and Codex login on this machine,
|
|
737
|
+
how much of its subscription is left (the 5-hour and weekly windows, with their
|
|
738
|
+
reset times) and how many tokens it used per day, model and project folder.
|
|
739
|
+
ProhostAI shows it in the workspace's Usage Monitor app. No agent pairing needed.
|
|
740
|
+
|
|
741
|
+
```bash
|
|
742
|
+
# 1. In ProhostAI: Settings → API keys → create a key with the usage:write scope,
|
|
743
|
+
# then paste it at the prompt (input is hidden)…
|
|
744
|
+
npx @prohost/cli usage connect
|
|
745
|
+
# …or pipe it in, so it never lands in your shell history:
|
|
746
|
+
pbpaste | npx @prohost/cli usage connect --key -
|
|
747
|
+
# (A machine with a paired agent can skip the key: its credential is accepted.)
|
|
748
|
+
|
|
749
|
+
# 2. Report now, then every 5 minutes, in the foreground…
|
|
750
|
+
npx @prohost/cli usage start
|
|
751
|
+
|
|
752
|
+
# …or keep it running across reboots (macOS launchd; prints a systemd unit on Linux).
|
|
753
|
+
npx @prohost/cli usage install-daemon
|
|
754
|
+
|
|
755
|
+
npx @prohost/cli usage status # logins, last report, local token totals
|
|
756
|
+
```
|
|
757
|
+
|
|
758
|
+
**Which logins.** `connect`, `discover` and every `start` register the config
|
|
759
|
+
directories already on this machine: `~/.claude`, `~/.codex` (as `default`) and
|
|
760
|
+
any `~/.claude-<name>` / `~/.codex-<name>` (as `<name>` — `~/.claude-work` is
|
|
761
|
+
`work`), plus `$CLAUDE_CONFIG_DIR` and `$CODEX_HOME`. They join the same
|
|
762
|
+
machine-wide registry `prohost agent account` uses (`~/.prohost-accounts`), so a
|
|
763
|
+
login added there is reported too. `prohost usage discover --dry-run` shows what
|
|
764
|
+
would be registered without writing anything. Removing a discovered login with
|
|
765
|
+
`prohost agent account remove` leaves its directory alone.
|
|
766
|
+
|
|
767
|
+
**Capacity.** Each login is asked at most every 15 minutes, three at a time,
|
|
768
|
+
through the unmodified CLI with that login's directory — `claude`'s `get_usage`
|
|
769
|
+
control request and `codex app-server`'s `account/rateLimits/read`. Neither
|
|
770
|
+
spends a model turn. Sign-in state comes from `claude auth status` /
|
|
771
|
+
`codex login status`. `prohost` never opens a credential file or keychain item.
|
|
772
|
+
|
|
773
|
+
**Tokens.** Read from the session logs the CLIs already write —
|
|
774
|
+
`<config dir>/projects/**/*.jsonl` for Claude Code, `sessions/**/rollout-*.jsonl`
|
|
775
|
+
for Codex — and summed per local day, login, model and project, where the
|
|
776
|
+
project is the **name** of the working directory (`backend-service`, never its
|
|
777
|
+
path). Only usage counters are read: no prompt, reply, tool call or file content
|
|
778
|
+
is kept or sent. The first run looks back 30 days; after that only what was
|
|
779
|
+
appended is read, using a cursor in `$PROHOST_HOME/usage/`. A day that changes
|
|
780
|
+
is re-sent whole, and the server replaces it.
|
|
781
|
+
Totals for a login go only after the server has accepted a capacity report
|
|
782
|
+
for it (the server ignores totals for logins it hasn't seen), so each pass
|
|
783
|
+
reports first. When the server says why it skipped a login's rows, the reason
|
|
784
|
+
decides: another user's or another source's login, or a day out of range, is
|
|
785
|
+
dropped (logged once); `throttled` is retried next pass; `not_reported` waits
|
|
786
|
+
for the next accepted report. Unexplained skips are retried at most three
|
|
787
|
+
times, so a login the server never takes is never resent forever and never
|
|
788
|
+
holds up the others.
|
|
789
|
+
|
|
790
|
+
**If the server says no.** Before the feature is enabled for your workspace
|
|
791
|
+
(or with a key that lacks `usage:write`) the server answers 403/404. The reporter
|
|
792
|
+
logs that once, waits 30 minutes, and tries again — it never exits over it.
|
|
793
|
+
Network errors are retried with backoff. Logs go to `$PROHOST_HOME/usage.log`
|
|
794
|
+
under the daemon.
|
|
795
|
+
|
|
796
|
+
| Command | What it does |
|
|
797
|
+
| --- | --- |
|
|
798
|
+
| `usage connect [--key -] [--base-url <url>]` | Store the key (`$PROHOST_HOME/usage.json`, `0600`), then discover logins. The key comes from `--key -` (stdin, or a hidden prompt at a terminal), piped stdin, `$PROHOST_API_KEY`, or a hidden prompt. `--key <value>` still works but warns: it leaves the key in shell history and the process list. `--base-url` must be `https://` (`http://` only for `localhost`, `127.0.0.1`, `[::1]`). |
|
|
799
|
+
| `usage discover [--dry-run]` | Register logins found on this machine. |
|
|
800
|
+
| `usage start [--once] [--machine <name>]` | Report every 5 minutes; `--once` runs one pass and exits. |
|
|
801
|
+
| `usage status` | Connection, daemon, last report and upload, and each login's local totals. |
|
|
802
|
+
| `usage install-daemon [--machine <name>]` / `uninstall-daemon` | Keep `usage start` running (its own launchd job, separate from the agent's). |
|
|
803
|
+
|
|
804
|
+
---
|
|
805
|
+
|
|
731
806
|
## Local webhook forwarding
|
|
732
807
|
|
|
733
808
|
```bash
|
package/dist/agent/accounts.d.ts
CHANGED
|
@@ -81,9 +81,25 @@ export declare function addAccount(options: {
|
|
|
81
81
|
runtime: AccountRuntime;
|
|
82
82
|
billing?: AccountBilling;
|
|
83
83
|
}, env?: NodeJS.ProcessEnv): AccountRecord;
|
|
84
|
+
/**
|
|
85
|
+
* Register a config directory that already exists — a login the operator made
|
|
86
|
+
* outside this CLI (`~/.claude-work`, a `$CODEX_HOME`). Nothing in it is read
|
|
87
|
+
* or written; signing in stays the agent CLI's own business. A directory that
|
|
88
|
+
* is already registered (under any label) is not registered twice.
|
|
89
|
+
*/
|
|
90
|
+
export declare function registerAccountDir(options: {
|
|
91
|
+
label: string;
|
|
92
|
+
runtime: AccountRuntime;
|
|
93
|
+
dir: string;
|
|
94
|
+
billing?: AccountBilling;
|
|
95
|
+
}, env?: NodeJS.ProcessEnv): AccountRecord;
|
|
96
|
+
/** Whether removing this account deletes its directory (only ones this CLI created). */
|
|
97
|
+
export declare function ownsAccountDir(record: AccountRecord, env?: NodeJS.ProcessEnv): boolean;
|
|
84
98
|
/**
|
|
85
99
|
* Forget an account and delete its config directory (the login inside it goes
|
|
86
|
-
* with it)
|
|
100
|
+
* with it) — but only a directory this CLI created; a registered existing one
|
|
101
|
+
* is left where it is. The implicit default can't be removed — it is the CLI's
|
|
102
|
+
* own login.
|
|
87
103
|
*/
|
|
88
104
|
export declare function removeAccount(label: string, runtime: AccountRuntime, env?: NodeJS.ProcessEnv): AccountRecord;
|
|
89
105
|
export declare function runtimeName(runtime: AccountRuntime): string;
|
package/dist/agent/accounts.js
CHANGED
|
@@ -236,9 +236,50 @@ function addAccountLocked(label, runtime, billing, env) {
|
|
|
236
236
|
saveAccounts([...loadAccounts(env), record], env);
|
|
237
237
|
return record;
|
|
238
238
|
}
|
|
239
|
+
/**
|
|
240
|
+
* Register a config directory that already exists — a login the operator made
|
|
241
|
+
* outside this CLI (`~/.claude-work`, a `$CODEX_HOME`). Nothing in it is read
|
|
242
|
+
* or written; signing in stays the agent CLI's own business. A directory that
|
|
243
|
+
* is already registered (under any label) is not registered twice.
|
|
244
|
+
*/
|
|
245
|
+
export function registerAccountDir(options, env = process.env) {
|
|
246
|
+
const { label, runtime } = options;
|
|
247
|
+
if (!isValidLabel(label) || label === DEFAULT_ACCOUNT_LABEL) {
|
|
248
|
+
throw new AccountError(`"${label}" is not a usable label for an existing login directory.`);
|
|
249
|
+
}
|
|
250
|
+
const dir = path.resolve(options.dir);
|
|
251
|
+
return withRegistryLock(env, () => {
|
|
252
|
+
const accounts = loadAccounts(env);
|
|
253
|
+
const same = accounts.find((a) => a.runtime === runtime && a.dir !== null && path.resolve(a.dir) === dir);
|
|
254
|
+
if (same)
|
|
255
|
+
return same;
|
|
256
|
+
if (findAccount(label, runtime, env)) {
|
|
257
|
+
throw new AccountError(`A ${runtimeName(runtime)} account named "${label}" already exists on this machine.`);
|
|
258
|
+
}
|
|
259
|
+
if (accounts.filter((a) => a.label !== DEFAULT_ACCOUNT_LABEL).length >= MAX_REPORTED_ACCOUNTS - 2) {
|
|
260
|
+
throw new AccountError(`This machine already has ${MAX_REPORTED_ACCOUNTS - 2} accounts, the most ProhostAI can show. Remove one first.`);
|
|
261
|
+
}
|
|
262
|
+
const record = {
|
|
263
|
+
key: randomUUID(),
|
|
264
|
+
label,
|
|
265
|
+
runtime,
|
|
266
|
+
billing: options.billing ?? 'subscription',
|
|
267
|
+
dir,
|
|
268
|
+
created_at: new Date().toISOString(),
|
|
269
|
+
};
|
|
270
|
+
saveAccounts([...accounts, record], env);
|
|
271
|
+
return record;
|
|
272
|
+
});
|
|
273
|
+
}
|
|
274
|
+
/** Whether removing this account deletes its directory (only ones this CLI created). */
|
|
275
|
+
export function ownsAccountDir(record, env = process.env) {
|
|
276
|
+
return !!record.dir && record.dir.startsWith(path.join(accountsRoot(env), 'accounts') + path.sep);
|
|
277
|
+
}
|
|
239
278
|
/**
|
|
240
279
|
* Forget an account and delete its config directory (the login inside it goes
|
|
241
|
-
* with it)
|
|
280
|
+
* with it) — but only a directory this CLI created; a registered existing one
|
|
281
|
+
* is left where it is. The implicit default can't be removed — it is the CLI's
|
|
282
|
+
* own login.
|
|
242
283
|
*/
|
|
243
284
|
export function removeAccount(label, runtime, env = process.env) {
|
|
244
285
|
if (label === DEFAULT_ACCOUNT_LABEL) {
|
|
@@ -253,7 +294,7 @@ export function removeAccount(label, runtime, env = process.env) {
|
|
|
253
294
|
return found;
|
|
254
295
|
});
|
|
255
296
|
// Only ever a directory this module created under the root.
|
|
256
|
-
if (record
|
|
297
|
+
if (ownsAccountDir(record, env) && record.dir) {
|
|
257
298
|
rmSync(record.dir, { recursive: true, force: true });
|
|
258
299
|
}
|
|
259
300
|
return record;
|
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
* `accounts.ts`, `list.ts` and `workspace.ts`.
|
|
5
5
|
*/
|
|
6
6
|
import process from 'node:process';
|
|
7
|
-
import { AccountError, addAccount, checkAuth, loadAccounts, parseAccountRuntime, removeAccount, runtimeName, signInCommand, waitForSignIn, } from './accounts.js';
|
|
7
|
+
import { AccountError, addAccount, checkAuth, loadAccounts, ownsAccountDir, parseAccountRuntime, removeAccount, runtimeName, signInCommand, waitForSignIn, } from './accounts.js';
|
|
8
8
|
import { ensureWorkspace } from './credentials.js';
|
|
9
9
|
import { findAgentHomes, renderAgentList } from './list.js';
|
|
10
10
|
import { WorkspaceInitError, initDeveloperWorkspace } from './workspace.js';
|
|
@@ -118,8 +118,9 @@ export async function accountCommand(args, flags, io = defaultIO) {
|
|
|
118
118
|
'Move them to another account first, or pass --force.');
|
|
119
119
|
return 1;
|
|
120
120
|
}
|
|
121
|
+
let removed;
|
|
121
122
|
try {
|
|
122
|
-
removeAccount(label, target?.runtime ?? runtime ?? 'claude_code', env);
|
|
123
|
+
removed = removeAccount(label, target?.runtime ?? runtime ?? 'claude_code', env);
|
|
123
124
|
}
|
|
124
125
|
catch (err) {
|
|
125
126
|
if (err instanceof AccountError) {
|
|
@@ -128,7 +129,9 @@ export async function accountCommand(args, flags, io = defaultIO) {
|
|
|
128
129
|
}
|
|
129
130
|
throw err;
|
|
130
131
|
}
|
|
131
|
-
io.out(
|
|
132
|
+
io.out(ownsAccountDir(removed, env)
|
|
133
|
+
? `✓ Removed "${label}" and its login directory.`
|
|
134
|
+
: `✓ Removed "${label}". Its login directory${removed.dir ? ` (${removed.dir})` : ''} was left in place.`);
|
|
132
135
|
return 0;
|
|
133
136
|
}
|
|
134
137
|
io.err('Expected: prohost agent account add|list|remove');
|
package/dist/agent/api.d.ts
CHANGED
|
@@ -68,6 +68,12 @@ export declare function describeFailure(result: ApiResult): string;
|
|
|
68
68
|
* cached, and the next replay receives its response.
|
|
69
69
|
*/
|
|
70
70
|
export declare function isIdempotencyInFlight(result: ApiResult): boolean;
|
|
71
|
+
/**
|
|
72
|
+
* POST a JSON body with the API key. Never throws: every failure comes back as
|
|
73
|
+
* an {@link ApiResult}. Exported for the `prohost usage` reporter, which owns
|
|
74
|
+
* its own paths.
|
|
75
|
+
*/
|
|
76
|
+
export declare function postJson(options: ApiOptions, path: string, body: unknown, idempotencyKey?: string, captureBody?: boolean, timeoutMs?: number): Promise<ApiResult>;
|
|
71
77
|
/**
|
|
72
78
|
* Post the agent's reply into the conversation that triggered the run.
|
|
73
79
|
*
|
package/dist/agent/api.js
CHANGED
|
@@ -52,7 +52,12 @@ export function describeFailure(result) {
|
|
|
52
52
|
export function isIdempotencyInFlight(result) {
|
|
53
53
|
return result.status === 409 && (result.error ?? '').includes('idempotency_key_in_flight');
|
|
54
54
|
}
|
|
55
|
-
|
|
55
|
+
/**
|
|
56
|
+
* POST a JSON body with the API key. Never throws: every failure comes back as
|
|
57
|
+
* an {@link ApiResult}. Exported for the `prohost usage` reporter, which owns
|
|
58
|
+
* its own paths.
|
|
59
|
+
*/
|
|
60
|
+
export async function postJson(options, path, body, idempotencyKey, captureBody = false, timeoutMs = DEFAULT_TIMEOUT_MS) {
|
|
56
61
|
const fetchImpl = options.fetchImpl ?? fetch;
|
|
57
62
|
const controller = new AbortController();
|
|
58
63
|
const deadlineMs = options.timeoutMs ?? timeoutMs;
|
|
@@ -126,7 +131,7 @@ export async function postReply(options, path, bodyKey, message, runId, extra) {
|
|
|
126
131
|
// thing in this request that must survive.
|
|
127
132
|
// Byte-identical on every attempt: the server hashes the body against the
|
|
128
133
|
// key, so a replay must serialise exactly as the original did.
|
|
129
|
-
return
|
|
134
|
+
return postJson(options, path, { ...extra, [bodyKey]: message }, `prohost-cli-run-${runId}`, false, SEND_TIMEOUT_MS);
|
|
130
135
|
}
|
|
131
136
|
function booleanField(body, key) {
|
|
132
137
|
if (!body)
|
|
@@ -154,7 +159,7 @@ function booleanField(body, key) {
|
|
|
154
159
|
*/
|
|
155
160
|
export async function heartbeatRun(options, path, progress) {
|
|
156
161
|
const trimmed = progress?.trim();
|
|
157
|
-
const result = await
|
|
162
|
+
const result = await postJson(options, path, trimmed ? { progress: trimmed.slice(0, MAX_PROGRESS_CHARS) } : {}, undefined, true);
|
|
158
163
|
if (result.ok)
|
|
159
164
|
return { kind: 'alive', cancelRequested: booleanField(result.body, 'cancel_requested') };
|
|
160
165
|
if (result.status === 404)
|
|
@@ -180,7 +185,7 @@ export async function completeRun(options, path, outcome, model) {
|
|
|
180
185
|
: { status: RUN_STATUS_FAILED, error: outcome.error.slice(0, MAX_ERROR_CHARS) };
|
|
181
186
|
if (model)
|
|
182
187
|
body.model = model.slice(0, MAX_MODEL_CHARS);
|
|
183
|
-
const result = await
|
|
188
|
+
const result = await postJson(options, path, body, undefined, false, SEND_TIMEOUT_MS);
|
|
184
189
|
if (result.status === 409)
|
|
185
190
|
return { ...result, ok: true };
|
|
186
191
|
return result;
|
|
@@ -195,5 +200,5 @@ export async function completeRun(options, path, outcome, model) {
|
|
|
195
200
|
* over-fills one gets it clipped rather than rejected whole.
|
|
196
201
|
*/
|
|
197
202
|
export async function postToolEvents(options, path, events) {
|
|
198
|
-
return
|
|
203
|
+
return postJson(options, path, { events: events.slice(0, MAX_TOOL_EVENTS_PER_BATCH) });
|
|
199
204
|
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Which URLs an API key or pairing code may be sent to.
|
|
3
|
+
*
|
|
4
|
+
* `--base-url` points the CLI at another ProhostAI deployment, and every
|
|
5
|
+
* request to it carries a secret — the pairing code on redeem, the API key on
|
|
6
|
+
* every call after, on the MCP endpoint, and in the stream's auth frame. So
|
|
7
|
+
* each of those URLs must be encrypted (`https://`, or `wss://` for the
|
|
8
|
+
* stream); plain `http://` / `ws://` is allowed only for this machine's
|
|
9
|
+
* loopback (`localhost`, `127.0.0.1`, `[::1]`), which is what local
|
|
10
|
+
* development uses.
|
|
11
|
+
*/
|
|
12
|
+
declare const SCHEMES: {
|
|
13
|
+
readonly http: {
|
|
14
|
+
readonly secure: "https:";
|
|
15
|
+
readonly plain: "http:";
|
|
16
|
+
};
|
|
17
|
+
readonly ws: {
|
|
18
|
+
readonly secure: "wss:";
|
|
19
|
+
readonly plain: "ws:";
|
|
20
|
+
};
|
|
21
|
+
};
|
|
22
|
+
/**
|
|
23
|
+
* Why a credential may not be sent to `value`, or `undefined` when it may.
|
|
24
|
+
* `kind` is `http` for API and MCP URLs, `ws` for the event stream. A whole
|
|
25
|
+
* sentence, so callers can prefix it with what was being checked.
|
|
26
|
+
*/
|
|
27
|
+
export declare function endpointProblem(value: string, kind?: keyof typeof SCHEMES): string | undefined;
|
|
28
|
+
/** Why `value` cannot be an API base URL (see {@link endpointProblem}). */
|
|
29
|
+
export declare function baseUrlProblem(value: string): string | undefined;
|
|
30
|
+
/** True when an API key may be sent to `value`. */
|
|
31
|
+
export declare function isAllowedBaseUrl(value: string): boolean;
|
|
32
|
+
/**
|
|
33
|
+
* Why stored agent credentials must not be used, or `undefined` when every URL
|
|
34
|
+
* the key would go to is safe: the API base, the MCP URL, and `streamUrl` —
|
|
35
|
+
* the stream URL `agent run` would actually connect to. An `agent.json`
|
|
36
|
+
* paired over plain http by an older CLI is caught here, before anything is
|
|
37
|
+
* sent.
|
|
38
|
+
*/
|
|
39
|
+
export declare function storedCredentialsProblem(credentials: {
|
|
40
|
+
base_url?: string;
|
|
41
|
+
mcp_url?: string;
|
|
42
|
+
}, streamUrl: string): string | undefined;
|
|
43
|
+
export {};
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Which URLs an API key or pairing code may be sent to.
|
|
3
|
+
*
|
|
4
|
+
* `--base-url` points the CLI at another ProhostAI deployment, and every
|
|
5
|
+
* request to it carries a secret — the pairing code on redeem, the API key on
|
|
6
|
+
* every call after, on the MCP endpoint, and in the stream's auth frame. So
|
|
7
|
+
* each of those URLs must be encrypted (`https://`, or `wss://` for the
|
|
8
|
+
* stream); plain `http://` / `ws://` is allowed only for this machine's
|
|
9
|
+
* loopback (`localhost`, `127.0.0.1`, `[::1]`), which is what local
|
|
10
|
+
* development uses.
|
|
11
|
+
*/
|
|
12
|
+
const LOOPBACK_HOSTS = new Set(['localhost', '127.0.0.1', '[::1]']);
|
|
13
|
+
const SCHEMES = {
|
|
14
|
+
http: { secure: 'https:', plain: 'http:' },
|
|
15
|
+
ws: { secure: 'wss:', plain: 'ws:' },
|
|
16
|
+
};
|
|
17
|
+
/**
|
|
18
|
+
* Why a credential may not be sent to `value`, or `undefined` when it may.
|
|
19
|
+
* `kind` is `http` for API and MCP URLs, `ws` for the event stream. A whole
|
|
20
|
+
* sentence, so callers can prefix it with what was being checked.
|
|
21
|
+
*/
|
|
22
|
+
export function endpointProblem(value, kind = 'http') {
|
|
23
|
+
const { secure, plain } = SCHEMES[kind];
|
|
24
|
+
let parsed;
|
|
25
|
+
try {
|
|
26
|
+
parsed = new URL(value);
|
|
27
|
+
}
|
|
28
|
+
catch {
|
|
29
|
+
// Not echoed: an unparseable value can still hold a secret.
|
|
30
|
+
return `It is not a full URL such as ${secure}//connect.prohost.ai.`;
|
|
31
|
+
}
|
|
32
|
+
if (parsed.protocol === secure)
|
|
33
|
+
return undefined;
|
|
34
|
+
if (parsed.protocol === plain && LOOPBACK_HOSTS.has(parsed.hostname))
|
|
35
|
+
return undefined;
|
|
36
|
+
if (parsed.protocol === plain) {
|
|
37
|
+
return (`${parsed.origin} is not ${secure}//, so the credential would travel in plaintext. ` +
|
|
38
|
+
`Plain ${plain}// is allowed only for localhost, 127.0.0.1 and [::1].`);
|
|
39
|
+
}
|
|
40
|
+
return `${parsed.protocol} URLs are not supported here; use ${secure}//.`;
|
|
41
|
+
}
|
|
42
|
+
/** Why `value` cannot be an API base URL (see {@link endpointProblem}). */
|
|
43
|
+
export function baseUrlProblem(value) {
|
|
44
|
+
return endpointProblem(value, 'http');
|
|
45
|
+
}
|
|
46
|
+
/** True when an API key may be sent to `value`. */
|
|
47
|
+
export function isAllowedBaseUrl(value) {
|
|
48
|
+
return baseUrlProblem(value) === undefined;
|
|
49
|
+
}
|
|
50
|
+
/**
|
|
51
|
+
* Why stored agent credentials must not be used, or `undefined` when every URL
|
|
52
|
+
* the key would go to is safe: the API base, the MCP URL, and `streamUrl` —
|
|
53
|
+
* the stream URL `agent run` would actually connect to. An `agent.json`
|
|
54
|
+
* paired over plain http by an older CLI is caught here, before anything is
|
|
55
|
+
* sent.
|
|
56
|
+
*/
|
|
57
|
+
export function storedCredentialsProblem(credentials, streamUrl) {
|
|
58
|
+
const checks = [
|
|
59
|
+
['API base URL', credentials.base_url, 'http'],
|
|
60
|
+
['MCP URL', credentials.mcp_url, 'http'],
|
|
61
|
+
['stream URL', streamUrl, 'ws'],
|
|
62
|
+
];
|
|
63
|
+
for (const [name, url, kind] of checks) {
|
|
64
|
+
if (!url)
|
|
65
|
+
continue;
|
|
66
|
+
const problem = endpointProblem(url, kind);
|
|
67
|
+
// The URL itself is not echoed: a stored one can carry userinfo or a token.
|
|
68
|
+
if (problem)
|
|
69
|
+
return `The stored ${name} is unusable. ${problem}`;
|
|
70
|
+
}
|
|
71
|
+
return undefined;
|
|
72
|
+
}
|
package/dist/agent/command.js
CHANGED
|
@@ -12,13 +12,15 @@ import { fileURLToPath } from 'node:url';
|
|
|
12
12
|
import { agentLogPath, buildSystemdUnit, harnessArguments, installLaunchdDaemon, launchdLabel, uninstallLaunchdDaemon, } from './daemon.js';
|
|
13
13
|
import { accountCommand, listCommand, workspaceCommand } from './agent_commands.js';
|
|
14
14
|
import { AccountError, loadAccounts, setAgentAccount } from './accounts.js';
|
|
15
|
+
import { storedCredentialsProblem } from './base-url.js';
|
|
15
16
|
import { acquireRunLock } from './lock.js';
|
|
16
17
|
import { PairError, runPair } from './pair.js';
|
|
17
|
-
import { CredentialsMissingError, ensureWorkspace, loadCredentials, prohostHome, tryLoadCredentials, } from './credentials.js';
|
|
18
|
+
import { CredentialsMissingError, ensureWorkspace, loadCredentials, prohostHome, streamUrlFor, tryLoadCredentials, } from './credentials.js';
|
|
18
19
|
import { DEFAULT_EXEC_TIMEOUT_MS, DEFAULT_IDLE_TIMEOUT_MS, runAgentHarness } from './run.js';
|
|
19
20
|
import { MAX_CONCURRENCY } from './scheduler.js';
|
|
20
21
|
import { ReposFlagError, isCheckout, parseReposFlag } from './worktrees.js';
|
|
21
22
|
import { announceUpgradeIfAvailable } from '../upgrade.js';
|
|
23
|
+
import { DEFAULT_WS_URL } from '../listen.js';
|
|
22
24
|
function flagString(flags, name) {
|
|
23
25
|
const value = flags[name];
|
|
24
26
|
return typeof value === 'string' ? value : undefined;
|
|
@@ -227,10 +229,16 @@ export async function runAgent(subcommand, flags, printHelp, args = []) {
|
|
|
227
229
|
printHelp();
|
|
228
230
|
return 1;
|
|
229
231
|
}
|
|
230
|
-
/**
|
|
231
|
-
|
|
232
|
+
/**
|
|
233
|
+
* Load credentials, reporting the "you haven't paired" case as an exit code —
|
|
234
|
+
* and refusing credentials whose API, MCP or stream URL (`streamOverride`
|
|
235
|
+
* when given) would carry the key in plaintext, such as an `agent.json`
|
|
236
|
+
* paired over http by a CLI older than 0.10.1.
|
|
237
|
+
*/
|
|
238
|
+
function loadOrExplain(streamOverride) {
|
|
239
|
+
let credentials;
|
|
232
240
|
try {
|
|
233
|
-
|
|
241
|
+
credentials = loadCredentials();
|
|
234
242
|
}
|
|
235
243
|
catch (err) {
|
|
236
244
|
if (err instanceof CredentialsMissingError) {
|
|
@@ -239,6 +247,13 @@ function loadOrExplain() {
|
|
|
239
247
|
}
|
|
240
248
|
throw err;
|
|
241
249
|
}
|
|
250
|
+
const problem = storedCredentialsProblem(credentials, streamOverride ?? streamUrlFor(credentials, DEFAULT_WS_URL));
|
|
251
|
+
if (problem) {
|
|
252
|
+
process.stderr.write(`✗ Refusing to send the API key: ${problem} Re-pair with an https:// --base-url ` +
|
|
253
|
+
'(prohost agent pair --code <code>).\n');
|
|
254
|
+
return null;
|
|
255
|
+
}
|
|
256
|
+
return credentials;
|
|
242
257
|
}
|
|
243
258
|
async function installDaemonCommand(flags) {
|
|
244
259
|
const exec = flagString(flags, 'exec');
|
|
@@ -246,7 +261,7 @@ async function installDaemonCommand(flags) {
|
|
|
246
261
|
process.stderr.write("Missing required flag: --exec. Example: prohost agent install-daemon --exec 'claude -p'\n");
|
|
247
262
|
return 1;
|
|
248
263
|
}
|
|
249
|
-
const credentials = loadOrExplain();
|
|
264
|
+
const credentials = loadOrExplain(flagString(flags, 'url'));
|
|
250
265
|
if (!credentials)
|
|
251
266
|
return 1;
|
|
252
267
|
if (!applyAccountFlag(flags))
|
|
@@ -380,7 +395,7 @@ async function runCommand(flags) {
|
|
|
380
395
|
process.stderr.write("Missing required flag: --exec. Example: prohost agent run --exec 'claude -p'\n");
|
|
381
396
|
return 1;
|
|
382
397
|
}
|
|
383
|
-
const credentials = loadOrExplain();
|
|
398
|
+
const credentials = loadOrExplain(flagString(flags, 'url') ?? process.env.PROHOST_WS_URL);
|
|
384
399
|
if (!credentials)
|
|
385
400
|
return 1;
|
|
386
401
|
const parsedTimeout = parseTimeoutSeconds(flags);
|
|
@@ -69,6 +69,15 @@ export declare const CREDENTIALS_VERSION = 2;
|
|
|
69
69
|
* @param fallback Used when neither the stored URL nor ``base_url`` is usable.
|
|
70
70
|
*/
|
|
71
71
|
export declare function streamUrlFor(credentials: AgentCredentials, fallback: string): string;
|
|
72
|
+
/**
|
|
73
|
+
* Turn an API base URL into the websocket URL on the same host.
|
|
74
|
+
*
|
|
75
|
+
* Mirrors the server's derivation (`shared/agents/connections.py`
|
|
76
|
+
* ``pairing_endpoints``): only plain ``http`` maps to ``ws``, so a local dev
|
|
77
|
+
* base keeps working and nothing else can downgrade a socket that carries an
|
|
78
|
+
* API key. Returns ``undefined`` for anything not parseable as a URL.
|
|
79
|
+
*/
|
|
80
|
+
export declare function wsUrlFromBaseUrl(baseUrl: string | undefined): string | undefined;
|
|
72
81
|
/** Directory holding CLI state. Overridable with ``$PROHOST_HOME`` for tests. */
|
|
73
82
|
export declare function prohostHome(env?: NodeJS.ProcessEnv): string;
|
|
74
83
|
/** Absolute path of the credential file. */
|
|
@@ -47,7 +47,7 @@ export function streamUrlFor(credentials, fallback) {
|
|
|
47
47
|
* base keeps working and nothing else can downgrade a socket that carries an
|
|
48
48
|
* API key. Returns ``undefined`` for anything not parseable as a URL.
|
|
49
49
|
*/
|
|
50
|
-
function wsUrlFromBaseUrl(baseUrl) {
|
|
50
|
+
export function wsUrlFromBaseUrl(baseUrl) {
|
|
51
51
|
if (!baseUrl)
|
|
52
52
|
return undefined;
|
|
53
53
|
let parsed;
|