@prohost/cli 0.10.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 CHANGED
@@ -4,6 +4,30 @@ 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
+
7
31
  ## 0.10.0
8
32
 
9
33
  Every Claude Code and Codex login on a machine can report its remaining
package/README.md CHANGED
@@ -682,7 +682,7 @@ decision to make.
682
682
  | `--code` | `$PROHOST_PAIRING_CODE` | The one-time pairing code. |
683
683
  | `--account` | the home's current account, else `default` | Which Claude Code / Codex login this agent runs on (see Accounts). |
684
684
  | `--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. |
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. |
686
686
 
687
687
  ### About `--allow-unverified`
688
688
 
@@ -736,12 +736,15 @@ through ProhostAI's own approval path, not around it via an API key.
736
736
  `prohost usage` reports, for every Claude Code and Codex login on this machine,
737
737
  how much of its subscription is left (the 5-hour and weekly windows, with their
738
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.
739
+ ProhostAI shows it in the workspace's Usage Monitor app. No agent pairing needed.
740
740
 
741
741
  ```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>
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.)
745
748
 
746
749
  # 2. Report now, then every 5 minutes, in the foreground…
747
750
  npx @prohost/cli usage start
@@ -792,7 +795,7 @@ under the daemon.
792
795
 
793
796
  | Command | What it does |
794
797
  | --- | --- |
795
- | `usage connect [--key <key>] [--base-url <url>]` | Store the key (`$PROHOST_HOME/usage.json`, `0600`; `$PROHOST_API_KEY` works too), then discover logins. |
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]`). |
796
799
  | `usage discover [--dry-run]` | Register logins found on this machine. |
797
800
  | `usage start [--once] [--machine <name>]` | Report every 5 minutes; `--once` runs one pass and exits. |
798
801
  | `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";
@@ -11,12 +11,14 @@ import process from 'node:process';
11
11
  import { existsSync } from 'node:fs';
12
12
  import { fileURLToPath } from 'node:url';
13
13
  import { DEFAULT_ACCOUNT_LABEL, loadAccounts, runtimeName } from '../agent/accounts.js';
14
+ import { baseUrlProblem } from '../agent/base-url.js';
14
15
  import { daemonEnvironment } from '../agent/command.js';
15
16
  import { maskSecret, prohostHome } from '../agent/credentials.js';
16
17
  import { buildSystemdUnit, installLaunchdDaemon, launchdLabel, launchdPlistPath, uninstallLaunchdDaemon, } from '../agent/daemon.js';
17
18
  import { acquireRunLock } from '../agent/lock.js';
18
19
  import { ensurePrivateDir, resolveUsageKey, saveUsageCredentials, usageStateDir } from './config.js';
19
20
  import { accountConfigDir, discoverAccounts } from './discovery.js';
21
+ import { PipedKeyTooLongError, promptHiddenKey, readPipedKey } from './key-input.js';
20
22
  import { RETENTION_DAYS, dayKey, loadScanState, localTotals, pendingDays } from './scanner.js';
21
23
  import { UsageService, loadStatus, runUsageLoop, scanStatePath } from './service.js';
22
24
  const defaultIO = {
@@ -83,21 +85,77 @@ export function renderDiscovery(result, env, dryRun) {
83
85
  lines.push(row('!', s.label, s.runtime, s.dir, `skipped: ${s.reason}`));
84
86
  return lines;
85
87
  }
88
+ /**
89
+ * The key `connect` should store, in order: `--key -` (stdin, or a hidden
90
+ * prompt at a terminal), a literal `--key <value>` (warned about), then
91
+ * `$PROHOST_API_KEY`, then piped stdin, then — at a terminal, with no
92
+ * credential to fall back on — a hidden prompt. `undefined` means "none given";
93
+ * `null` means a usage error that has already been printed.
94
+ */
95
+ async function connectKey(flags, io, env) {
96
+ const stdin = io.stdin ?? process.stdin;
97
+ const prompt = () => promptHiddenKey(stdin, (text) => process.stderr.write(text));
98
+ const literal = flagString(flags, 'key');
99
+ if (literal === '-') {
100
+ const key = stdin.isTTY ? await prompt() : await readPipedKey(stdin);
101
+ if (!key)
102
+ io.err('✗ --key - got no key. Pipe one in (pbpaste | prohost usage connect --key -) or type it at the prompt.');
103
+ return key ?? null;
104
+ }
105
+ if (literal) {
106
+ io.err('! --key <value> leaves the key in your shell history and the process list. ' +
107
+ 'Next time pipe it instead: pbpaste | prohost usage connect --key -');
108
+ return literal.trim();
109
+ }
110
+ if (env.PROHOST_API_KEY)
111
+ return env.PROHOST_API_KEY.trim();
112
+ if (!stdin.isTTY)
113
+ return readPipedKey(stdin);
114
+ // At a terminal: prompt only when there is nothing to fall back on, so a
115
+ // connected machine (or one with a paired agent) is not asked again.
116
+ if (resolveUsageKey(env))
117
+ return undefined;
118
+ return prompt();
119
+ }
86
120
  async function connectCommand(flags, io) {
87
121
  const env = io.env ?? process.env;
88
122
  if (flags.key === true) {
89
- io.err('--key needs a value. Create a key with the usage:write scope in ProhostAI → Settings → API keys.');
123
+ io.err('--key needs a value. Use --key - to read it from stdin (pbpaste | prohost usage connect --key -), ' +
124
+ 'or leave --key off to be prompted. Create a key with the usage:write scope in ProhostAI → Settings → API keys.');
125
+ return 1;
126
+ }
127
+ if (flags['base-url'] === true) {
128
+ io.err('--base-url needs a value, e.g. --base-url https://connect.prohost.ai');
129
+ return 1;
130
+ }
131
+ // Checked before any key is read or stored: the key goes to this URL.
132
+ const baseUrl = flagString(flags, 'base-url');
133
+ const problem = baseUrl ? baseUrlProblem(baseUrl) : undefined;
134
+ if (problem) {
135
+ io.err(`✗ --base-url rejected: ${problem}`);
136
+ return 1;
137
+ }
138
+ let key;
139
+ try {
140
+ key = await connectKey(flags, io, env);
141
+ }
142
+ catch (err) {
143
+ if (!(err instanceof PipedKeyTooLongError))
144
+ throw err;
145
+ io.err('✗ The piped input is not an API key (no line ended within 4096 characters). Nothing was saved.');
90
146
  return 1;
91
147
  }
92
- const key = flagString(flags, 'key') ?? env.PROHOST_API_KEY;
148
+ if (key === null)
149
+ return 1;
93
150
  if (key) {
94
- const file = saveUsageCredentials({ apiKey: key.trim(), baseUrl: flagString(flags, 'base-url') }, env);
95
- io.out(`✓ Saved key ${maskSecret(key.trim())} to ${file} (owner-only).`);
151
+ const file = saveUsageCredentials({ apiKey: key, baseUrl }, env);
152
+ io.out(`✓ Saved key ${maskSecret(key)} to ${file} (owner-only).`);
96
153
  }
97
154
  else {
98
155
  const existing = resolveUsageKey(env);
99
156
  if (!existing) {
100
- io.err('✗ No API key. Pass --key <key> (scope usage:write) or set $PROHOST_API_KEY. ' +
157
+ io.err('✗ No API key. Run prohost usage connect at a terminal to be prompted, pipe one in with --key -, ' +
158
+ 'or set $PROHOST_API_KEY (scope usage:write). ' +
101
159
  'A machine with a paired agent can skip this — its credential is used.');
102
160
  return 1;
103
161
  }
@@ -112,6 +170,26 @@ async function connectCommand(flags, io) {
112
170
  io.out('Report now and every 5 minutes: prohost usage start (or keep it running: prohost usage install-daemon)');
113
171
  return 0;
114
172
  }
173
+ /**
174
+ * The stored key, when it can be sent: present, and its base URL is https://
175
+ * (or loopback http://). Prints why not and returns `null` otherwise.
176
+ */
177
+ function usableKey(env, io) {
178
+ const key = resolveUsageKey(env);
179
+ if (!key) {
180
+ io.err('✗ Not connected. Run: prohost usage connect');
181
+ return null;
182
+ }
183
+ const problem = baseUrlProblem(key.baseUrl);
184
+ if (problem) {
185
+ io.err(`✗ Refusing to send the API key: the stored API base URL is unusable. ${problem} ` +
186
+ (key.source === 'agent'
187
+ ? 'Re-pair the agent with an https:// --base-url.'
188
+ : 'Reconnect: prohost usage connect --key - --base-url https://…'));
189
+ return null;
190
+ }
191
+ return key;
192
+ }
115
193
  function discoverCommand(flags, io) {
116
194
  const env = io.env ?? process.env;
117
195
  const dryRun = flags['dry-run'] === true;
@@ -121,10 +199,8 @@ function discoverCommand(flags, io) {
121
199
  }
122
200
  async function startCommand(flags, io) {
123
201
  const env = io.env ?? process.env;
124
- if (!resolveUsageKey(env)) {
125
- io.err('✗ Not connected. Run: prohost usage connect --key <api key>');
202
+ if (!usableKey(env, io))
126
203
  return 1;
127
- }
128
204
  // Its own lock, under $PROHOST_HOME/usage — `agent run` holds the home's.
129
205
  const stateDir = ensurePrivateDir(usageStateDir(env));
130
206
  const lock = acquireRunLock({ ...env, PROHOST_HOME: stateDir });
@@ -163,10 +239,8 @@ function cliEntrypoint() {
163
239
  }
164
240
  function installCommand(flags, io) {
165
241
  const env = io.env ?? process.env;
166
- if (!resolveUsageKey(env)) {
167
- io.err('✗ Not connected. Run: prohost usage connect --key <api key>');
242
+ if (!usableKey(env, io))
168
243
  return 1;
169
- }
170
244
  const workdir = ensurePrivateDir(usageStateDir(env));
171
245
  const spec = {
172
246
  label: usageLaunchdLabel(prohostHome(env)),
@@ -233,8 +307,9 @@ function statusCommand(io) {
233
307
  const env = io.env ?? process.env;
234
308
  const key = resolveUsageKey(env);
235
309
  io.out(key
236
- ? `Connected ${maskSecret(key.apiKey)} → ${key.baseUrl}${key.source === 'agent' ? ' (paired agent credential)' : ''}`
237
- : 'Connected no — run: prohost usage connect --key <api key>');
310
+ ? `Connected ${maskSecret(key.apiKey)} → ${key.baseUrl}${key.source === 'agent' ? ' (paired agent credential)' : ''}` +
311
+ `${baseUrlProblem(key.baseUrl) ? ' (not https:// — nothing is sent)' : ''}`
312
+ : 'Connected no — run: prohost usage connect');
238
313
  const plist = launchdPlistPath(usageLaunchdLabel(prohostHome(env)));
239
314
  io.out(`Daemon ${os.platform() === 'darwin' ? (existsSync(plist) ? `installed (${tildify(plist, env)})` : 'not installed') : 'see systemd'}`);
240
315
  const status = loadStatus(env);
@@ -0,0 +1,36 @@
1
+ /**
2
+ * Reading an API key for `prohost usage connect` without putting it on the
3
+ * command line, where it would land in shell history and the process list.
4
+ *
5
+ * Two safe paths: piped stdin (`pbpaste | prohost usage connect --key -`) and,
6
+ * at a terminal, a prompt that does not echo what is typed or pasted.
7
+ */
8
+ /** The slice of `process.stdin` this module uses, so tests can pass a stream. */
9
+ export interface KeyStdin extends AsyncIterable<string | Buffer> {
10
+ isTTY?: boolean;
11
+ setRawMode?(mode: boolean): unknown;
12
+ setEncoding(encoding: BufferEncoding): unknown;
13
+ on(event: 'data', listener: (chunk: string | Buffer) => void): unknown;
14
+ removeListener(event: 'data', listener: (chunk: string | Buffer) => void): unknown;
15
+ resume(): unknown;
16
+ pause(): unknown;
17
+ }
18
+ /** Longer than any ProhostAI key; a pipe that runs past it is not a key. */
19
+ export declare const MAX_PIPED_KEY_CHARS = 4096;
20
+ export declare class PipedKeyTooLongError extends Error {
21
+ }
22
+ /**
23
+ * The first non-empty line of piped stdin, trimmed; `undefined` when there is
24
+ * none. Returns as soon as that line ends, so a producer that keeps the pipe
25
+ * open does not hang `connect`, and stops reading past
26
+ * {@link MAX_PIPED_KEY_CHARS}.
27
+ *
28
+ * @throws PipedKeyTooLongError when no line ends within the limit.
29
+ */
30
+ export declare function readPipedKey(stdin: KeyStdin): Promise<string | undefined>;
31
+ /**
32
+ * Prompt for a key at a terminal without echoing it. Resolves with the
33
+ * trimmed key, or `undefined` when nothing was entered or the user pressed
34
+ * Ctrl-C. The prompt goes to `write` (stderr), keeping stdout clean.
35
+ */
36
+ export declare function promptHiddenKey(stdin: KeyStdin, write: (text: string) => void, prompt?: string): Promise<string | undefined>;
@@ -0,0 +1,79 @@
1
+ /**
2
+ * Reading an API key for `prohost usage connect` without putting it on the
3
+ * command line, where it would land in shell history and the process list.
4
+ *
5
+ * Two safe paths: piped stdin (`pbpaste | prohost usage connect --key -`) and,
6
+ * at a terminal, a prompt that does not echo what is typed or pasted.
7
+ */
8
+ /** Longer than any ProhostAI key; a pipe that runs past it is not a key. */
9
+ export const MAX_PIPED_KEY_CHARS = 4096;
10
+ export class PipedKeyTooLongError extends Error {
11
+ }
12
+ /**
13
+ * The first non-empty line of piped stdin, trimmed; `undefined` when there is
14
+ * none. Returns as soon as that line ends, so a producer that keeps the pipe
15
+ * open does not hang `connect`, and stops reading past
16
+ * {@link MAX_PIPED_KEY_CHARS}.
17
+ *
18
+ * @throws PipedKeyTooLongError when no line ends within the limit.
19
+ */
20
+ export async function readPipedKey(stdin) {
21
+ let pending = '';
22
+ for await (const chunk of stdin) {
23
+ pending += typeof chunk === 'string' ? chunk : chunk.toString('utf8');
24
+ let newline = pending.search(/\r?\n/);
25
+ while (newline !== -1) {
26
+ // Measured before trimming: padding counts toward the bound too.
27
+ const raw = pending.slice(0, newline);
28
+ if (raw.length > MAX_PIPED_KEY_CHARS)
29
+ throw new PipedKeyTooLongError('piped input is not a key');
30
+ const line = raw.trim();
31
+ if (line)
32
+ return line;
33
+ pending = pending.slice(newline).replace(/^\r?\n/, '');
34
+ newline = pending.search(/\r?\n/);
35
+ }
36
+ if (pending.length > MAX_PIPED_KEY_CHARS)
37
+ throw new PipedKeyTooLongError('piped input is not a key');
38
+ }
39
+ if (pending.length > MAX_PIPED_KEY_CHARS)
40
+ throw new PipedKeyTooLongError('piped input is not a key');
41
+ return pending.trim() || undefined;
42
+ }
43
+ const ENTER = new Set(['\r', '\n', '\u0004']); // Return, or Ctrl-D
44
+ const CTRL_C = '\u0003';
45
+ const BACKSPACE = new Set(['\u007f', '\b']);
46
+ /**
47
+ * Prompt for a key at a terminal without echoing it. Resolves with the
48
+ * trimmed key, or `undefined` when nothing was entered or the user pressed
49
+ * Ctrl-C. The prompt goes to `write` (stderr), keeping stdout clean.
50
+ */
51
+ export function promptHiddenKey(stdin, write, prompt = 'API key (usage:write scope; input hidden): ') {
52
+ return new Promise((resolve) => {
53
+ let value = '';
54
+ const finish = (result) => {
55
+ stdin.removeListener('data', onData);
56
+ stdin.setRawMode?.(false);
57
+ stdin.pause();
58
+ write('\n');
59
+ resolve(result);
60
+ };
61
+ const onData = (chunk) => {
62
+ for (const ch of typeof chunk === 'string' ? chunk : chunk.toString('utf8')) {
63
+ if (ENTER.has(ch))
64
+ return finish(value.trim() || undefined);
65
+ if (ch === CTRL_C)
66
+ return finish(undefined);
67
+ if (BACKSPACE.has(ch))
68
+ value = value.slice(0, -1);
69
+ else if (ch >= ' ')
70
+ value += ch;
71
+ }
72
+ };
73
+ write(prompt);
74
+ stdin.setRawMode?.(true);
75
+ stdin.setEncoding('utf8');
76
+ stdin.on('data', onData);
77
+ stdin.resume();
78
+ });
79
+ }
@@ -71,7 +71,7 @@ export interface UsageServiceOptions {
71
71
  export interface TickResult {
72
72
  reported: boolean;
73
73
  uploadedRows: number;
74
- skipped?: 'no_key' | 'no_machine_id';
74
+ skipped?: 'no_key' | 'insecure_base_url' | 'no_machine_id';
75
75
  }
76
76
  /** One process's reporter. Holds the backoff state and the scan cursor in memory. */
77
77
  export declare class UsageService {
@@ -12,6 +12,7 @@ import os from 'node:os';
12
12
  import path from 'node:path';
13
13
  import { loadAccounts, machineId as machineIdFor } from '../agent/accounts.js';
14
14
  import { postJson } from '../agent/api.js';
15
+ import { baseUrlProblem } from '../agent/base-url.js';
15
16
  import { Backoff } from './backoff.js';
16
17
  import { resolveUsageKey, usageStateDir, writePrivateFile } from './config.js';
17
18
  import { accountConfigDir } from './discovery.js';
@@ -115,10 +116,17 @@ export class UsageService {
115
116
  try {
116
117
  const key = resolveUsageKey(this.env);
117
118
  if (!key) {
118
- this.warnOnce('no_key', 'Not connected — run `prohost usage connect --key <api key>`. Checking again each tick.');
119
+ this.warnOnce('no_key', 'Not connected — run `prohost usage connect`. Checking again each tick.');
119
120
  return { ...result, skipped: 'no_key' };
120
121
  }
121
122
  this.warned.delete('no_key');
123
+ // The key travels to this URL; a stored http:// one would send it in plaintext.
124
+ const problem = baseUrlProblem(key.baseUrl);
125
+ if (problem) {
126
+ this.warnOnce('insecure_base_url', `Not sending: the stored API base URL is unusable. ${problem}`);
127
+ return { ...result, skipped: 'insecure_base_url' };
128
+ }
129
+ this.warned.delete('insecure_base_url');
122
130
  const machineId = machineIdFor(this.env);
123
131
  if (!machineId) {
124
132
  this.warnOnce('no_machine_id', 'Could not create this machine\'s id in the accounts root; nothing can be reported.');
package/dist/version.d.ts CHANGED
@@ -7,6 +7,6 @@
7
7
  * `package.json`, so a release bump that forgets this file fails the suite
8
8
  * instead of shipping a `User-Agent` that lies about which build is calling.
9
9
  */
10
- export declare const CLI_VERSION = "0.10.0";
10
+ export declare const CLI_VERSION = "0.10.1";
11
11
  /** Sent on every HTTP request the CLI makes back into ProhostAI. */
12
- export declare const USER_AGENT = "prohost-cli/0.10.0";
12
+ export declare const USER_AGENT = "prohost-cli/0.10.1";
package/dist/version.js CHANGED
@@ -7,6 +7,6 @@
7
7
  * `package.json`, so a release bump that forgets this file fails the suite
8
8
  * instead of shipping a `User-Agent` that lies about which build is calling.
9
9
  */
10
- export const CLI_VERSION = '0.10.0';
10
+ export const CLI_VERSION = '0.10.1';
11
11
  /** Sent on every HTTP request the CLI makes back into ProhostAI. */
12
12
  export const USER_AGENT = `prohost-cli/${CLI_VERSION}`;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@prohost/cli",
3
- "version": "0.10.0",
3
+ "version": "0.10.1",
4
4
  "description": "Run your own AI agent as a ProhostAI teammate, and stream your account's webhooks to your laptop.",
5
5
  "type": "module",
6
6
  "license": "MIT",