@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 +24 -0
- package/README.md +9 -6
- 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/service.d.ts +1 -1
- package/dist/usage/service.js +9 -1
- 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,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
|
|
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
|
-
#
|
|
744
|
-
npx @prohost/cli usage connect
|
|
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
|
|
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
|
+
}
|
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";
|
package/dist/usage/command.js
CHANGED
|
@@ -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.
|
|
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
|
-
|
|
148
|
+
if (key === null)
|
|
149
|
+
return 1;
|
|
93
150
|
if (key) {
|
|
94
|
-
const file = saveUsageCredentials({ apiKey: key
|
|
95
|
-
io.out(`✓ Saved key ${maskSecret(key
|
|
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.
|
|
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 (!
|
|
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 (!
|
|
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
|
-
|
|
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
|
+
}
|
package/dist/usage/service.d.ts
CHANGED
|
@@ -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 {
|
package/dist/usage/service.js
CHANGED
|
@@ -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
|
|
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.
|
|
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.
|
|
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.
|
|
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}`;
|