@prohost/cli 0.10.0 → 0.10.2
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 +48 -0
- package/README.md +27 -16
- 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.js +11 -3
- package/dist/usage/command.d.ts +3 -0
- package/dist/usage/command.js +88 -13
- package/dist/usage/key-input.d.ts +36 -0
- package/dist/usage/key-input.js +79 -0
- package/dist/usage/project.d.ts +30 -0
- package/dist/usage/project.js +138 -0
- package/dist/usage/scanner.d.ts +49 -8
- package/dist/usage/scanner.js +115 -26
- package/dist/usage/service.d.ts +30 -1
- package/dist/usage/service.js +105 -10
- 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,54 @@ 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.2
|
|
8
|
+
|
|
9
|
+
Token totals are attributed to the repository, not the directory a session ran in.
|
|
10
|
+
|
|
11
|
+
- **Projects are repositories.** A session in a git worktree
|
|
12
|
+
(`.worktrees/backend-service/fix-something`, `.claude/worktrees/agent-…`) or
|
|
13
|
+
a subdirectory (`iosApp`, `Sources`) now counts toward its repository
|
|
14
|
+
(`backend-service`, `mobile`) in "By project". The repository is found from
|
|
15
|
+
the `.git` the directory sits under — a worktree's `gitdir` / `commondir`
|
|
16
|
+
lead back to the main repository — with no `git` process per line, and
|
|
17
|
+
cached per directory. A worktree that has since been removed is recognised by
|
|
18
|
+
its `…/.worktrees/<repo>/<name>` or `…/<repo>/.claude/worktrees/<name>`
|
|
19
|
+
path; anything else keeps its basename.
|
|
20
|
+
- **Days are replaced whole.** Each upload now names the (day, account) pairs
|
|
21
|
+
it carries in full (`completeDays`), and the server deletes that day's other
|
|
22
|
+
rows for the account, so a renamed project never counts twice.
|
|
23
|
+
- **The upgrade re-reads the last 30 days.** The local scan state is rebuilt
|
|
24
|
+
once with the new attribution and every day re-sent whole, which replaces
|
|
25
|
+
the old per-worktree rows. Those days wait until the server confirms it
|
|
26
|
+
replaces a day whole (an empty upload answered with `replacedDays`, asked at
|
|
27
|
+
most hourly), so an older server never counts them twice. A day that reads
|
|
28
|
+
back smaller than first counted (a log removed or rewritten since) is left
|
|
29
|
+
as it is on the server: per-folder, but with the right total.
|
|
30
|
+
|
|
31
|
+
## 0.10.1
|
|
32
|
+
|
|
33
|
+
Safer key handling for `prohost usage connect`, and no credential ever goes to
|
|
34
|
+
a plain-http API base.
|
|
35
|
+
|
|
36
|
+
- **`usage connect` reads the key without exposing it.** It prompts with echo
|
|
37
|
+
off at a terminal, reads piped stdin (`pbpaste | prohost usage connect --key -`),
|
|
38
|
+
or takes `$PROHOST_API_KEY`. `--key <value>` still works but prints a warning:
|
|
39
|
+
it leaves the key in shell history and the process list. A connected machine
|
|
40
|
+
(or one with a paired agent) is not prompted again.
|
|
41
|
+
- **`--base-url` must be `https://`.** `usage connect --base-url` and
|
|
42
|
+
`agent pair --base-url` reject anything else before a key is stored or a
|
|
43
|
+
pairing code is spent; `http://` is allowed only for `localhost`, `127.0.0.1`
|
|
44
|
+
and `[::1]`. `usage start`, `install-daemon` and every reporter tick refuse to
|
|
45
|
+
send a key to a stored base URL that fails the same check, and `usage status`
|
|
46
|
+
flags it. A pairing whose server reports a plain-http `mcp_url` or plain-ws
|
|
47
|
+
`ws_url` keeps the requested base, `<base>/mcp` and the default stream URL
|
|
48
|
+
instead, and `agent pair --url` / `listen --url` must be `wss://` (`ws://`
|
|
49
|
+
only on loopback).
|
|
50
|
+
- **`agent run` and `agent install-daemon` refuse insecure stored credentials.**
|
|
51
|
+
An `agent.json` whose API base, MCP URL or stream URL would carry the key in
|
|
52
|
+
plaintext (paired over http by an older CLI) is refused with a re-pair hint,
|
|
53
|
+
before anything is sent.
|
|
54
|
+
|
|
7
55
|
## 0.10.0
|
|
8
56
|
|
|
9
57
|
Every Claude Code and Codex login on a machine can report its remaining
|
package/README.md
CHANGED
|
@@ -24,8 +24,9 @@ npx @prohost/cli --version # the version you're running
|
|
|
24
24
|
Pair once, then leave the agent running:
|
|
25
25
|
|
|
26
26
|
```bash
|
|
27
|
-
# 1. In ProhostAI:
|
|
28
|
-
# Codes are single-use and expire 15
|
|
27
|
+
# 1. In ProhostAI: AI Employees → Computers → Pair a computer. It shows this
|
|
28
|
+
# command with your code filled in. Codes are single-use and expire 15
|
|
29
|
+
# minutes after they're generated.
|
|
29
30
|
npx @prohost/cli agent pair --code <pairing-code>
|
|
30
31
|
|
|
31
32
|
# 2. Point it at whatever agent you already use.
|
|
@@ -48,8 +49,9 @@ doesn't know or care which one it is.
|
|
|
48
49
|
|
|
49
50
|
### 1. Get a pairing code
|
|
50
51
|
|
|
51
|
-
In ProhostAI: **
|
|
52
|
-
one-time code
|
|
52
|
+
In ProhostAI: **AI Employees → Computers → Pair a computer**. You'll get a
|
|
53
|
+
one-time code, already filled into the command it shows you. It is single-use
|
|
54
|
+
and expires 15 minutes after it's generated.
|
|
53
55
|
|
|
54
56
|
### 2. Pair
|
|
55
57
|
|
|
@@ -682,7 +684,7 @@ decision to make.
|
|
|
682
684
|
| `--code` | `$PROHOST_PAIRING_CODE` | The one-time pairing code. |
|
|
683
685
|
| `--account` | the home's current account, else `default` | Which Claude Code / Codex login this agent runs on (see Accounts). |
|
|
684
686
|
| `--webhook-url` | none | Also push events to this HTTPS endpoint. Omit for stream-only, which is the normal laptop setup. |
|
|
685
|
-
| `--base-url` | `https://connect.prohost.ai` | Public API base. |
|
|
687
|
+
| `--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. |
|
|
686
688
|
|
|
687
689
|
### About `--allow-unverified`
|
|
688
690
|
|
|
@@ -735,13 +737,16 @@ through ProhostAI's own approval path, not around it via an API key.
|
|
|
735
737
|
|
|
736
738
|
`prohost usage` reports, for every Claude Code and Codex login on this machine,
|
|
737
739
|
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
|
|
739
|
-
ProhostAI shows it in the workspace's
|
|
740
|
+
reset times) and how many tokens it used per day, model and project (repository).
|
|
741
|
+
ProhostAI shows it in the workspace's Usage Monitor app. No agent pairing needed.
|
|
740
742
|
|
|
741
743
|
```bash
|
|
742
|
-
# 1. In ProhostAI: Settings → API keys → create a key with the usage:write scope
|
|
743
|
-
#
|
|
744
|
-
npx @prohost/cli usage connect
|
|
744
|
+
# 1. In ProhostAI: Settings → API keys → create a key with the usage:write scope,
|
|
745
|
+
# then paste it at the prompt (input is hidden)…
|
|
746
|
+
npx @prohost/cli usage connect
|
|
747
|
+
# …or pipe it in, so it never lands in your shell history:
|
|
748
|
+
pbpaste | npx @prohost/cli usage connect --key -
|
|
749
|
+
# (A machine with a paired agent can skip the key: its credential is accepted.)
|
|
745
750
|
|
|
746
751
|
# 2. Report now, then every 5 minutes, in the foreground…
|
|
747
752
|
npx @prohost/cli usage start
|
|
@@ -770,11 +775,17 @@ spends a model turn. Sign-in state comes from `claude auth status` /
|
|
|
770
775
|
**Tokens.** Read from the session logs the CLIs already write —
|
|
771
776
|
`<config dir>/projects/**/*.jsonl` for Claude Code, `sessions/**/rollout-*.jsonl`
|
|
772
777
|
for Codex — and summed per local day, login, model and project, where the
|
|
773
|
-
project is the **name** of the working directory
|
|
774
|
-
path).
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
is
|
|
778
|
+
project is the **name** of the repository the working directory belongs to
|
|
779
|
+
(`backend-service`, never its path). A git worktree or a subdirectory counts as
|
|
780
|
+
its main repository: `.worktrees/backend-service/fix-x` and
|
|
781
|
+
`backend-service/web/src` are both `backend-service`. A directory outside any
|
|
782
|
+
repository is its own name; a removed worktree is recognised by its
|
|
783
|
+
`…/.worktrees/<repo>/<name>` or `…/<repo>/.claude/worktrees/<name>` path. Only
|
|
784
|
+
usage counters are read: no prompt, reply, tool call or file content is kept or
|
|
785
|
+
sent. The first run looks back 30 days; after that only what was appended is
|
|
786
|
+
read, using a cursor in `$PROHOST_HOME/usage/`. A day that changes is re-sent
|
|
787
|
+
whole, marked complete, and the server replaces that day's rows for the login —
|
|
788
|
+
including deleting any project the day no longer has.
|
|
778
789
|
Totals for a login go only after the server has accepted a capacity report
|
|
779
790
|
for it (the server ignores totals for logins it hasn't seen), so each pass
|
|
780
791
|
reports first. When the server says why it skipped a login's rows, the reason
|
|
@@ -792,7 +803,7 @@ under the daemon.
|
|
|
792
803
|
|
|
793
804
|
| Command | What it does |
|
|
794
805
|
| --- | --- |
|
|
795
|
-
| `usage connect [--key
|
|
806
|
+
| `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]`). |
|
|
796
807
|
| `usage discover [--dry-run]` | Register logins found on this machine. |
|
|
797
808
|
| `usage start [--once] [--machine <name>]` | Report every 5 minutes; `--once` runs one pass and exits. |
|
|
798
809
|
| `usage status` | Connection, daemon, last report and upload, and each login's local totals. |
|
|
@@ -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;
|
package/dist/agent/pair.js
CHANGED
|
@@ -10,8 +10,9 @@
|
|
|
10
10
|
* specific than "invalid, expired, or already used".
|
|
11
11
|
*/
|
|
12
12
|
import { USER_AGENT } from '../version.js';
|
|
13
|
+
import { baseUrlProblem, endpointProblem, isAllowedBaseUrl } from './base-url.js';
|
|
13
14
|
import { DEFAULT_API_BASE_URL, REDEEM_PATH } from './contract.js';
|
|
14
|
-
import { CREDENTIALS_VERSION, clearCredentials, maskSecret, saveCredentials, tryLoadCredentials, } from './credentials.js';
|
|
15
|
+
import { CREDENTIALS_VERSION, clearCredentials, maskSecret, saveCredentials, tryLoadCredentials, wsUrlFromBaseUrl, } from './credentials.js';
|
|
15
16
|
import { DEFAULT_WS_URL } from '../listen.js';
|
|
16
17
|
export class PairError extends Error {
|
|
17
18
|
}
|
|
@@ -20,28 +21,34 @@ const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{
|
|
|
20
21
|
function isUuid(value) {
|
|
21
22
|
return typeof value === 'string' && UUID_PATTERN.test(value);
|
|
22
23
|
}
|
|
23
|
-
/**
|
|
24
|
+
/**
|
|
25
|
+
* Derive the API base from the server-reported MCP URL, so the server wins —
|
|
26
|
+
* unless it is one the API key may not be sent to (see `base-url.ts`).
|
|
27
|
+
*/
|
|
24
28
|
function baseFromMcpUrl(mcpUrl, fallback) {
|
|
25
|
-
|
|
29
|
+
const base = mcpUrl.endsWith('/mcp') ? mcpUrl.slice(0, -'/mcp'.length) : '';
|
|
30
|
+
return base && isAllowedBaseUrl(base) ? base : fallback;
|
|
26
31
|
}
|
|
27
32
|
/**
|
|
28
33
|
* The websocket URL the server told us to use, or ``undefined``.
|
|
29
34
|
*
|
|
30
|
-
* ``undefined`` covers
|
|
31
|
-
*
|
|
32
|
-
* is already spent by the time we read the body — so
|
|
33
|
-
*
|
|
35
|
+
* ``undefined`` covers "this server predates the field", "the value is not a
|
|
36
|
+
* websocket URL" and "it is plain ws:// off loopback". A bad value must not
|
|
37
|
+
* fail a redeem — the code is already spent by the time we read the body — so
|
|
38
|
+
* the caller falls back to the stream on the stored API base's host and says so.
|
|
34
39
|
*/
|
|
35
40
|
function serverWsUrl(value) {
|
|
36
41
|
if (typeof value !== 'string' || value.length === 0)
|
|
37
42
|
return undefined;
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
43
|
+
// `wss://`, or `ws://` on loopback only: the stream's auth frame carries the key.
|
|
44
|
+
return endpointProblem(value, 'ws') === undefined ? value : undefined;
|
|
45
|
+
}
|
|
46
|
+
/**
|
|
47
|
+
* The MCP URL to store: the server's, unless the API key may not be sent to
|
|
48
|
+
* it, in which case the one on the (already validated) API base.
|
|
49
|
+
*/
|
|
50
|
+
function safeMcpUrl(mcpUrl, baseUrl) {
|
|
51
|
+
return mcpUrl && isAllowedBaseUrl(mcpUrl) ? mcpUrl : `${baseUrl}/mcp`;
|
|
45
52
|
}
|
|
46
53
|
/**
|
|
47
54
|
* Redeem ``code`` and persist the resulting credentials.
|
|
@@ -52,6 +59,14 @@ export async function runPair(options) {
|
|
|
52
59
|
const fetchImpl = options.fetchImpl ?? fetch;
|
|
53
60
|
const onLine = options.onLine ?? ((line) => process.stdout.write(`${line}\n`));
|
|
54
61
|
const baseUrl = (options.baseUrl ?? DEFAULT_API_BASE_URL).replace(/\/+$/, '');
|
|
62
|
+
// Checked before the code is spent: the code goes to this URL, and the API
|
|
63
|
+
// key comes back from it.
|
|
64
|
+
const baseProblem = baseUrlProblem(baseUrl);
|
|
65
|
+
if (baseProblem)
|
|
66
|
+
throw new PairError(`--base-url rejected: ${baseProblem}`);
|
|
67
|
+
const wsProblem = options.wsUrl ? endpointProblem(options.wsUrl, 'ws') : undefined;
|
|
68
|
+
if (wsProblem)
|
|
69
|
+
throw new PairError(`--url rejected: ${wsProblem}`);
|
|
55
70
|
if (options.webhookUrl && !options.webhookUrl.startsWith('https://')) {
|
|
56
71
|
throw new PairError('--webhook-url must be an https:// URL');
|
|
57
72
|
}
|
|
@@ -136,18 +151,24 @@ export async function runPair(options) {
|
|
|
136
151
|
// 2. The server's `ws_url`, derived server-side from the same setting as
|
|
137
152
|
// `mcp_url` (`shared/agents/connections.py::pairing_endpoints`). This is
|
|
138
153
|
// the normal path, and the reason a host move no longer needs a release.
|
|
139
|
-
// 3. `
|
|
154
|
+
// 3. No usable `ws_url` (a server that predates the field, or a value that
|
|
155
|
+
// is not a websocket URL or is plain ws:// off loopback): the stream on
|
|
156
|
+
// the stored API base's host, as the server derives it — never another
|
|
157
|
+
// deployment's, which would get this key. For the default base that is
|
|
158
|
+
// `DEFAULT_WS_URL`, which is also the last resort.
|
|
159
|
+
const storedBase = baseFromMcpUrl(body.mcp_url ?? '', baseUrl);
|
|
140
160
|
const fromServer = serverWsUrl(body.ws_url);
|
|
141
|
-
const
|
|
161
|
+
const fallbackWsUrl = wsUrlFromBaseUrl(storedBase) ?? DEFAULT_WS_URL;
|
|
162
|
+
const wsUrl = options.wsUrl ?? fromServer ?? fallbackWsUrl;
|
|
142
163
|
const credentials = {
|
|
143
164
|
version: CREDENTIALS_VERSION,
|
|
144
|
-
base_url:
|
|
165
|
+
base_url: storedBase,
|
|
145
166
|
ws_url: wsUrl,
|
|
146
167
|
api_key: body.api_key,
|
|
147
168
|
webhook_secret: body.webhook_secret ?? null,
|
|
148
169
|
subscription_id: body.subscription_id ?? null,
|
|
149
170
|
stream_only: body.stream_only,
|
|
150
|
-
mcp_url: body.mcp_url,
|
|
171
|
+
mcp_url: safeMcpUrl(body.mcp_url, storedBase),
|
|
151
172
|
events: body.events ?? [],
|
|
152
173
|
agent: body.agent,
|
|
153
174
|
paired_at: new Date().toISOString(),
|
|
@@ -156,7 +177,7 @@ export async function runPair(options) {
|
|
|
156
177
|
onLine(`✓ Paired as "${body.agent.name}" (agent ${body.agent.id})`);
|
|
157
178
|
onLine(` API key ${maskSecret(body.api_key)} (stored, never printed in full)`);
|
|
158
179
|
onLine(` Credentials ${file} (chmod 600)`);
|
|
159
|
-
onLine(` MCP URL ${
|
|
180
|
+
onLine(` MCP URL ${credentials.mcp_url}`);
|
|
160
181
|
// Printed because 0.1.0's wrong websocket host was invisible here: pairing
|
|
161
182
|
// reported success and the mismatch only surfaced as a 403 reconnect loop in
|
|
162
183
|
// a different command. Showing it makes a bad host obvious at pair time.
|
|
@@ -171,7 +192,7 @@ export async function runPair(options) {
|
|
|
171
192
|
// answer either.
|
|
172
193
|
onLine('');
|
|
173
194
|
onLine(` ! The server reported an unusable stream URL (${JSON.stringify(body.ws_url)}).`);
|
|
174
|
-
onLine(` Using ${
|
|
195
|
+
onLine(` Using ${fallbackWsUrl} instead — override with --url if that is wrong.`);
|
|
175
196
|
}
|
|
176
197
|
if (!credentials.webhook_secret) {
|
|
177
198
|
// Without a secret the runner can't prove an event came from ProhostAI,
|
package/dist/index.js
CHANGED
|
@@ -16,6 +16,7 @@
|
|
|
16
16
|
import { realpathSync } from 'node:fs';
|
|
17
17
|
import process from 'node:process';
|
|
18
18
|
import { fileURLToPath } from 'node:url';
|
|
19
|
+
import { endpointProblem } from './agent/base-url.js';
|
|
19
20
|
import { runAgent } from './agent/command.js';
|
|
20
21
|
import { DEFAULT_WS_URL, runListen } from './listen.js';
|
|
21
22
|
import { runUsage } from './usage/command.js';
|
|
@@ -35,7 +36,7 @@ function printHelp() {
|
|
|
35
36
|
' prohost agent account list | remove <label>',
|
|
36
37
|
' prohost agent list Every paired agent on this machine, and its accounts',
|
|
37
38
|
' prohost agent workspace init --developer --repo <checkout> [--workdir <dir>]',
|
|
38
|
-
' prohost usage connect [--key
|
|
39
|
+
' prohost usage connect [--key -] [--base-url <url>] Report AI usage without pairing an agent (prompts for the key; --key - reads it from stdin)',
|
|
39
40
|
' prohost usage discover [--dry-run] | start [--once] [--machine <name>] | status',
|
|
40
41
|
' prohost usage install-daemon [--machine <name>] | uninstall-daemon',
|
|
41
42
|
'',
|
|
@@ -49,7 +50,7 @@ function printHelp() {
|
|
|
49
50
|
'agent pair flags:',
|
|
50
51
|
' --code One-time pairing code from the hire modal. Defaults to $PROHOST_PAIRING_CODE.',
|
|
51
52
|
' --webhook-url Also push events to this HTTPS endpoint. Omit it: stream-only is the normal setup.',
|
|
52
|
-
' --base-url Public API base. Defaults to https://connect.prohost.ai.',
|
|
53
|
+
' --base-url Public API base. Defaults to https://connect.prohost.ai. Must be https:// (http:// only for localhost).',
|
|
53
54
|
'',
|
|
54
55
|
'agent run flags:',
|
|
55
56
|
' --exec Command to run for each mention; the prompt arrives on stdin (required)',
|
|
@@ -162,7 +163,8 @@ export function parseArgs(argv) {
|
|
|
162
163
|
i += 1;
|
|
163
164
|
continue;
|
|
164
165
|
}
|
|
165
|
-
|
|
166
|
+
// A bare `-` is a value ("read it from stdin", as in `--key -`), not a flag.
|
|
167
|
+
if (next === undefined || (next.startsWith('-') && next !== '-')) {
|
|
166
168
|
flags[key] = true;
|
|
167
169
|
i += 1;
|
|
168
170
|
}
|
|
@@ -218,6 +220,12 @@ async function main(argv) {
|
|
|
218
220
|
const url = (typeof flags['url'] === 'string' ? flags['url'] : undefined) ??
|
|
219
221
|
process.env.PROHOST_WS_URL ??
|
|
220
222
|
DEFAULT_WS_URL;
|
|
223
|
+
// The auth frame carries the API key: wss://, or ws:// on loopback only.
|
|
224
|
+
const urlProblem = endpointProblem(url, 'ws');
|
|
225
|
+
if (urlProblem) {
|
|
226
|
+
process.stderr.write(`✗ --url rejected: ${urlProblem}\n`);
|
|
227
|
+
return 1;
|
|
228
|
+
}
|
|
221
229
|
const machine = typeof flags['machine'] === 'string' ? flags['machine'] : undefined;
|
|
222
230
|
const subscriptionId = typeof flags['subscription-id'] === 'string' ? flags['subscription-id'] : undefined;
|
|
223
231
|
const controller = new AbortController();
|
package/dist/usage/command.d.ts
CHANGED
|
@@ -6,11 +6,14 @@
|
|
|
6
6
|
* `scanner.ts` and `service.ts`.
|
|
7
7
|
*/
|
|
8
8
|
import type { DiscoveryResult } from './discovery.js';
|
|
9
|
+
import type { KeyStdin } from './key-input.js';
|
|
9
10
|
type Flags = Record<string, string | boolean>;
|
|
10
11
|
export interface UsageIO {
|
|
11
12
|
out: (line: string) => void;
|
|
12
13
|
err: (line: string) => void;
|
|
13
14
|
env?: NodeJS.ProcessEnv;
|
|
15
|
+
/** Where `connect` reads a key from (`--key -`, a pipe, or a hidden prompt). Defaults to `process.stdin`. */
|
|
16
|
+
stdin?: KeyStdin;
|
|
14
17
|
}
|
|
15
18
|
/** launchd label prefix for the usage reporter — distinct from the agent daemon's. */
|
|
16
19
|
export declare const USAGE_LAUNCHD_PREFIX = "ai.prohost.usage";
|