@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 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: Hire an AI employee → Bring your own agent → copy the code.
28
- # Codes are single-use and expire 15 minutes after they're generated.
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: **Hire an AI employee → Bring your own agent**. You'll get a
52
- one-time code. It is single-use and expires 15 minutes after it's generated.
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 folder.
739
- ProhostAI shows it in the workspace's AI Usage view. No agent pairing needed.
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
- # (A machine with a paired agent can skip --key: its credential is accepted.)
744
- npx @prohost/cli usage connect --key <api-key>
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 (`backend-service`, never its
774
- path). Only usage counters are read: no prompt, reply, tool call or file content
775
- is kept or sent. The first run looks back 30 days; after that only what was
776
- appended is read, using a cursor in `$PROHOST_HOME/usage/`. A day that changes
777
- is re-sent whole, and the server replaces it.
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 <key>] [--base-url <url>]` | Store the key (`$PROHOST_HOME/usage.json`, `0600`; `$PROHOST_API_KEY` works too), then discover logins. |
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
+ }
@@ -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
- /** Load credentials, reporting the "you haven't paired" case as an exit code. */
231
- function loadOrExplain() {
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
- return loadCredentials();
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;
@@ -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
- /** Derive the API base from the server-reported MCP URL, so the server wins. */
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
- return mcpUrl.endsWith('/mcp') ? mcpUrl.slice(0, -'/mcp'.length) : fallback;
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 both "this server predates the field" and "the value is
31
- * not a websocket URL". A cosmetic bad value must not fail a redeem — the code
32
- * is already spent by the time we read the body — so the caller falls back to
33
- * {@link DEFAULT_WS_URL} and says so.
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
- try {
39
- const protocol = new URL(value).protocol;
40
- return protocol === 'ws:' || protocol === 'wss:' ? value : undefined;
41
- }
42
- catch {
43
- return undefined;
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. `DEFAULT_WS_URL`, for a server that predates the field.
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 wsUrl = options.wsUrl ?? fromServer ?? DEFAULT_WS_URL;
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: baseFromMcpUrl(body.mcp_url ?? '', baseUrl),
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 ${body.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 ${DEFAULT_WS_URL} instead — override with --url if that is wrong.`);
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 <api-key>] [--base-url <url>] Report AI usage without pairing an agent',
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
- if (next === undefined || next.startsWith('-')) {
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();
@@ -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";