@proagentstore/cli 0.4.31 → 0.4.33

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.
@@ -0,0 +1,55 @@
1
+ // What credential the engine process ACTUALLY got — read off the merged env, on the machine
2
+ // where the merge happens (#248).
3
+ //
4
+ // The platform decides an engine's sign-in MODE (auto / machine / subscription / api-key) in the
5
+ // cloud, but the mode is only half an answer: the runner spawns the CLI with
6
+ // `{...process.env, ...overlay}`, so the outcome also depends on what the user's shell exports —
7
+ // which the cloud cannot see. "Which credential am I actually billing?" was therefore
8
+ // unanswerable from any surface, and the one time it went wrong it went wrong SILENTLY: a shell
9
+ // `ANTHROPIC_API_KEY` beat the injected subscription token, so picking "subscription" billed per
10
+ // token anyway (see the note on `mergeEnv`).
11
+ //
12
+ // This module derives the answer from the same env the process is spawned with, so the report is
13
+ // an observation, never a restatement of the setting.
14
+ //
15
+ // **Presence only, never values.** Nothing here returns, logs or echoes a key or token — the
16
+ // caller gets an enum. This is a transparency feature, not a credential channel.
17
+ /**
18
+ * The env var each engine reads a PER-TOKEN API key from. Deliberately duplicated from the
19
+ * cloud's `ENGINE_API_KEYS` (workers/api/src/lib/coding-engines.ts) rather than imported: the
20
+ * runner ships inside the published CLI and does not depend on the API worker — vendoring is the
21
+ * house rule for shared constants across packages.
22
+ */
23
+ const API_KEY_ENV = {
24
+ claude: "ANTHROPIC_API_KEY",
25
+ gemini: "GEMINI_API_KEY",
26
+ codex: "OPENAI_API_KEY",
27
+ grok: "XAI_API_KEY",
28
+ };
29
+ /**
30
+ * The env var holding a SUBSCRIPTION token. Only Claude Code has one — for every other engine
31
+ * "subscription" and "machine" both mean the machine's own login, so there is nothing to detect.
32
+ */
33
+ const SUBSCRIPTION_ENV = {
34
+ claude: "CLAUDE_CODE_OAUTH_TOKEN",
35
+ };
36
+ /** A var counts as set only when it holds something. `mergeEnv` deletes on empty, but an
37
+ * inherited `FOO=` from the shell is present-and-empty and means "no credential", not one. */
38
+ function has(env, name) {
39
+ return !!name && !!env[name] && env[name].trim() !== "";
40
+ }
41
+ /**
42
+ * Derive what the engine actually authenticates with from its merged spawn env.
43
+ *
44
+ * API key wins over the subscription token because that is what the CLI itself does — Claude Code
45
+ * prefers `ANTHROPIC_API_KEY` over `CLAUDE_CODE_OAUTH_TOKEN`. Reporting "subscription" whenever a
46
+ * token happened to be present would reproduce the exact illusion this exists to dispel: the
47
+ * setting said subscription, the bill said per-token.
48
+ */
49
+ export function resolveEngineAuth(clientType, env) {
50
+ if (has(env, API_KEY_ENV[clientType]))
51
+ return "api-key";
52
+ if (has(env, SUBSCRIPTION_ENV[clientType]))
53
+ return "subscription";
54
+ return "machine-login";
55
+ }
@@ -0,0 +1,79 @@
1
+ /**
2
+ * Engine spend, taken from the CLI's OWN report (#267).
3
+ *
4
+ * The usage ledger is written at three cloud-side choke points (`runUserWorkersAi`,
5
+ * `recordVoiceUsage`, `recordPlatformUsage`). The coding Engine passes through none of them: it
6
+ * is a child process on the user's machine talking straight to Anthropic. So the single largest
7
+ * line item for anyone using Coder in earnest was recorded nowhere, and the Usage page showed a
8
+ * total with no hint that it was incomplete.
9
+ *
10
+ * Claude Code already emits everything needed on the `result` event that ends each turn — token
11
+ * counts AND `total_cost_usd`, which it computes itself. That number is the one figure in the
12
+ * whole ledger that is NOT an estimate, so it is carried through as reported rather than being
13
+ * re-derived from `ai-pricing.ts` list prices.
14
+ *
15
+ * Raw (non-Claude) engines never reach this module: `HeadlessSession` only parses JSON in
16
+ * stream-json mode, so a Codex/Grok session produces no records at all. That is deliberate —
17
+ * a zero row would read as "this engine is free", which is a worse lie than a gap.
18
+ */
19
+ const num = (v) => {
20
+ const n = Number(v);
21
+ return Number.isFinite(n) && n > 0 ? n : 0;
22
+ };
23
+ /**
24
+ * Pick the model id out of Claude Code's `modelUsage` map, which is keyed by model.
25
+ *
26
+ * A single turn can legitimately span more than one model (a subagent on a cheaper one), and the
27
+ * ledger has one model column per row. The most expensive one is the honest label for the row:
28
+ * attributing the turn to the cheap helper model would make an expensive session look cheap in
29
+ * the by-model breakdown, which is the exact question that breakdown exists to answer.
30
+ */
31
+ function pickModel(modelUsage) {
32
+ if (!modelUsage || typeof modelUsage !== "object")
33
+ return "unknown";
34
+ let best = "";
35
+ let bestCost = -1;
36
+ for (const [model, entry] of Object.entries(modelUsage)) {
37
+ const cost = num(entry?.costUSD);
38
+ if (cost > bestCost) {
39
+ bestCost = cost;
40
+ best = model;
41
+ }
42
+ }
43
+ return best || "unknown";
44
+ }
45
+ /**
46
+ * Turn a stream-json `result` event into a usage record, or null when it carries no measurement.
47
+ *
48
+ * `fallbackId` is used only when the event has no `uuid` of its own (older Claude Code builds);
49
+ * the caller supplies something unique per turn per process.
50
+ *
51
+ * Returns null — never a zeroed record — when there is nothing to report, for the same reason
52
+ * raw engines record nothing: an all-zero row is indistinguishable from "this was free".
53
+ */
54
+ export function parseEngineUsage(ev, fallbackId) {
55
+ if (!ev || typeof ev !== "object")
56
+ return null;
57
+ const e = ev;
58
+ if (e.type !== "result")
59
+ return null;
60
+ const usage = (e.usage && typeof e.usage === "object" ? e.usage : {});
61
+ const inputTokens = num(usage.input_tokens);
62
+ const outputTokens = num(usage.output_tokens);
63
+ const cacheReadTokens = num(usage.cache_read_input_tokens);
64
+ const cacheWriteTokens = num(usage.cache_creation_input_tokens);
65
+ const costUsd = num(e.total_cost_usd);
66
+ if (!inputTokens && !outputTokens && !cacheReadTokens && !cacheWriteTokens && !costUsd)
67
+ return null;
68
+ const uuid = typeof e.uuid === "string" && e.uuid.trim() ? e.uuid.trim() : "";
69
+ return {
70
+ id: uuid || fallbackId,
71
+ model: pickModel(e.modelUsage),
72
+ inputTokens,
73
+ outputTokens,
74
+ cacheReadTokens,
75
+ cacheWriteTokens,
76
+ costUsd,
77
+ at: new Date().toISOString(),
78
+ };
79
+ }
@@ -1,4 +1,5 @@
1
1
  import { spawn } from "node:child_process";
2
+ import { parseEngineUsage } from "./engine-usage.js";
2
3
  /**
3
4
  * Merge the platform's resolved engine env over the machine's, where an EMPTY value means
4
5
  * REMOVE rather than "set to empty".
@@ -22,6 +23,16 @@ export function mergeEnv(base, overlay) {
22
23
  import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
23
24
  import { dirname, join } from "node:path";
24
25
  import { handlerFor } from "./handlers.js";
26
+ import { resolveEngineAuth } from "./engine-auth.js";
27
+ /**
28
+ * How many un-drained usage records a session holds (#267).
29
+ *
30
+ * Records are drained by the cloud on capture, which polls every 3s while a session is watched,
31
+ * so this only fills when nobody is looking. Dropping the OLDEST past the cap is the right
32
+ * direction: an unbounded queue on a long-running runner is a leak, and the alternative
33
+ * (refusing new records) would lose the turns that just happened rather than ancient ones.
34
+ */
35
+ const MAX_PENDING_USAGE = 200;
25
36
  export class HeadlessSession {
26
37
  config;
27
38
  /**
@@ -53,6 +64,19 @@ export class HeadlessSession {
53
64
  turnStartedAt = 0;
54
65
  /** Set by stop() — the only thing that ends a one-shot session (see `alive`). */
55
66
  stopped = false;
67
+ /** Measured engine spend not yet handed to the cloud (#267). Drained by {@link takeUsage}. */
68
+ pendingUsage = [];
69
+ /** Turn counter — only used to build a fallback id when the CLI's event has no `uuid`. */
70
+ usageSeq = 0;
71
+ /**
72
+ * Per-PROCESS salt for that fallback id.
73
+ *
74
+ * Without it, a runner restart resets `usageSeq` and the new session's first turn reuses the
75
+ * id of a turn from before the restart — which the cloud's conflict-ignoring insert would
76
+ * silently drop, undercounting exactly the long-lived sessions this feature exists to measure.
77
+ * The queue is in memory and dies with the process, so a salt can never cause a double-count.
78
+ */
79
+ usageRunId = Math.random().toString(36).slice(2, 10);
56
80
  /**
57
81
  * The engine binary could not be spawned (ENOENT, not executable).
58
82
  *
@@ -62,6 +86,24 @@ export class HeadlessSession {
62
86
  * all 40 BYOK decisions re-spawning a binary that isn't there.
63
87
  */
64
88
  spawnFailed = false;
89
+ /**
90
+ * What this engine IS, so the question is answerable instead of implied (#248, #247).
91
+ *
92
+ * Sessions have not used tmux since the move to stream-json, but the surface kept saying
93
+ * `tmuxSession`/`pagsTmuxTotal` long enough that a user's reasonable next move
94
+ * (`tmux attach -t …`) failed and looked like a broken engine. Stating the runtime beats
95
+ * removing the wrong word and leaving nothing in its place.
96
+ */
97
+ engineRuntime = "child-process";
98
+ /**
99
+ * The credential this engine actually runs on — computed from the SAME expression `spawn`
100
+ * uses, so it reports what happened rather than what was configured (#248). A getter, not a
101
+ * cached field, because it must never drift from the env the next turn is spawned with.
102
+ * Presence only: no key or token value leaves this class.
103
+ */
104
+ get authResolved() {
105
+ return resolveEngineAuth(this.config.clientType, mergeEnv(process.env, this.config.env));
106
+ }
65
107
  constructor(config) {
66
108
  this.config = config;
67
109
  this.engineLabel = `${config.clientType}:${config.id}`;
@@ -423,11 +465,21 @@ export class HeadlessSession {
423
465
  this.push(` ↳ ${toolResult(block.content)}`); // ↳
424
466
  }
425
467
  break;
426
- case "result":
468
+ case "result": {
427
469
  if (ev.is_error)
428
470
  this.push(`[error] ${ev.result ?? ev.subtype ?? "failed"}`);
471
+ // The same event that ends the turn also reports what the turn COST (#267). It was
472
+ // parsed and thrown away, which is why Engine spend was absent from the ledger.
473
+ // An errored turn still burned tokens, so this is recorded regardless of is_error.
474
+ const usage = parseEngineUsage(ev, `${this.config.id}:${this.usageRunId}:${this.usageSeq++}`);
475
+ if (usage) {
476
+ this.pendingUsage.push(usage);
477
+ if (this.pendingUsage.length > MAX_PENDING_USAGE)
478
+ this.pendingUsage.shift();
479
+ }
429
480
  this.run = "idle"; // the turn is OVER — a fact, not a guess
430
481
  break;
482
+ }
431
483
  default:
432
484
  break;
433
485
  }
@@ -438,6 +490,21 @@ export class HeadlessSession {
438
490
  push(line) {
439
491
  this.transcript.push(line);
440
492
  }
493
+ /**
494
+ * Hand over the measured spend since the last drain, and forget it (#267).
495
+ *
496
+ * Draining rather than re-reporting keeps a 3s capture poll from re-sending the same rows
497
+ * forever; the cloud's insert is keyed on {@link EngineUsageRecord.id} and ignores conflicts,
498
+ * so the genuine race — two callers draining at once, or a retry — still cannot double-count.
499
+ *
500
+ * A raw engine returns an empty array here, always: nothing parses its stdout for usage, so
501
+ * there is nothing to hand over and the cloud writes no row for it.
502
+ */
503
+ takeUsage() {
504
+ const out = this.pendingUsage;
505
+ this.pendingUsage = [];
506
+ return out;
507
+ }
441
508
  }
442
509
  /** Local wall-clock "HH:MM:SS" for transcript timestamps (runner is a Node process). */
443
510
  function stamp() {
@@ -86,8 +86,13 @@ export class CodingRuntime {
86
86
  session.start();
87
87
  return this.snapshot(input.sessionId);
88
88
  }
89
- /** The pane the brain reasons over + the inferred run state. */
90
- snapshot(sessionId) {
89
+ /**
90
+ * The pane the brain reasons over + the inferred run state.
91
+ *
92
+ * `drainUsage` is opt-in because draining is destructive: nine cloud call sites hit
93
+ * `/coding/capture` and only the two that actually write the ledger may consume the records.
94
+ */
95
+ snapshot(sessionId, opts = {}) {
91
96
  const session = this.require(sessionId);
92
97
  const alive = session.alive;
93
98
  // ALWAYS return the transcript — it holds the produced output AND the
@@ -101,6 +106,11 @@ export class CodingRuntime {
101
106
  alive,
102
107
  ready: alive ? session.ready : false,
103
108
  runState: alive ? session.runState() : "idle",
109
+ // Reported even when the process is not alive: "what would this session bill?" is
110
+ // exactly the question asked about a session that just stopped.
111
+ authResolved: session.authResolved,
112
+ engineRuntime: session.engineRuntime,
113
+ ...(opts.drainUsage ? { usage: session.takeUsage() } : {}),
104
114
  };
105
115
  }
106
116
  /** Perform one action, then return the fresh snapshot (non-blocking, like browser act). */
@@ -122,14 +132,23 @@ export class CodingRuntime {
122
132
  return this.snapshot(sessionId);
123
133
  }
124
134
  /** Tear down a session. */
135
+ /**
136
+ * Stop and forget a session.
137
+ *
138
+ * Returns any un-drained spend (#267) rather than discarding it with the session: the last
139
+ * turn of a session very often runs after the final capture poll, and ending is where that
140
+ * record would otherwise be lost — silently, and only for the turns at the end of every
141
+ * session, which is a bias rather than noise.
142
+ */
125
143
  end(sessionId) {
126
144
  const session = this.sessions.get(sessionId);
145
+ const usage = session ? session.takeUsage() : [];
127
146
  if (session) {
128
147
  session.stop();
129
148
  this.sessions.delete(sessionId);
130
149
  }
131
150
  this.takeovers.delete(sessionId);
132
- return { ok: true };
151
+ return { ok: true, usage };
133
152
  }
134
153
  list() {
135
154
  return [...this.sessions.entries()].map(([sessionId, s]) => ({
@@ -150,6 +169,8 @@ export class CodingRuntime {
150
169
  clientType: s.config.clientType,
151
170
  workDir: s.config.workDir,
152
171
  takeover: this.takeovers.has(sessionId),
172
+ authResolved: s.authResolved,
173
+ engineRuntime: s.engineRuntime,
153
174
  }));
154
175
  }
155
176
  // ── Human takeover (the "stuck" handoff) ────────────────────────────────
@@ -2,6 +2,7 @@
2
2
  import { homedir } from "node:os";
3
3
  import { join } from "node:path";
4
4
  import { startRunnerServer } from "./server.js";
5
+ import { randomUUID } from "node:crypto";
5
6
  // Resilience: a stray error in any runtime must NOT take the whole runner down —
6
7
  // that drops the tunnel and forces the user to restart `pags up` (and lose their
7
8
  // session). Log it and keep serving. Per-component handlers catch most things;
@@ -29,7 +30,11 @@ function configFromArgs() {
29
30
  host: arg("--host", process.env.PAGS_RUNNER_HOST || "127.0.0.1") || "127.0.0.1",
30
31
  port: Number(arg("--port", process.env.PAGS_RUNNER_PORT || "49171")),
31
32
  dataDir,
32
- token: arg("--token", process.env.PAGS_RUNNER_TOKEN),
33
+ // Never start unauthenticated (#245). This surface drives a coding CLI with permissions
34
+ // skipped, and `authorize` now fails closed — so a missing token would make the runner
35
+ // answer nothing rather than answer everyone. Generate one and print it, mirroring what
36
+ // `pags runner connect` has always done.
37
+ token: arg("--token", process.env.PAGS_RUNNER_TOKEN) || `pags_runner_${randomUUID()}`,
33
38
  instanceId: arg("--instance-id", process.env.PAGS_INSTANCE_ID),
34
39
  headless: flag("--headless") || process.env.PAGS_RUNNER_HEADLESS === "1",
35
40
  };
@@ -147,8 +147,11 @@ async function route(runner, req, res) {
147
147
  return json(res, 200, runner.coding.start(b));
148
148
  }
149
149
  if (req.method === "POST" && path === "/coding/capture") {
150
+ // `drainUsage` opts this caller in to consuming the session's measured engine spend (#267).
151
+ // Only the cloud paths that write the ledger send it; an older cloud omits it and simply
152
+ // gets the snapshot it always got.
150
153
  const b = await readJson(req);
151
- return json(res, 200, runner.coding.snapshot(b.sessionId));
154
+ return json(res, 200, runner.coding.snapshot(b.sessionId, { drainUsage: b.drainUsage === true }));
152
155
  }
153
156
  if (req.method === "POST" && path === "/coding/act") {
154
157
  const b = await readJson(req);
@@ -391,13 +394,35 @@ async function route(runner, req, res) {
391
394
  }
392
395
  return json(res, 404, { error: "Not found" });
393
396
  }
397
+ /**
398
+ * Authorize a request to the local runner (#245).
399
+ *
400
+ * This surface drives a coding CLI that `pags up` launches with
401
+ * `--dangerously-skip-permissions` / `--sandbox danger-full-access`, so "who may POST here" is
402
+ * the whole security boundary. Two things were the wrong way round:
403
+ *
404
+ * 1. **No token used to mean ALLOW.** `pags up` always generates one, so that path was safe —
405
+ * but `pags-browser-runner` run directly (its own --help documents this) passes
406
+ * `token: undefined`, and served the entire surface unauthenticated. Now it fails CLOSED;
407
+ * the standalone entrypoint generates a token instead of starting open.
408
+ *
409
+ * 2. **A browser could reach it.** Binding to loopback is not isolation: a page the user is
410
+ * visiting cannot READ a cross-origin response, but it can still SEND the POST, and the
411
+ * server sets no CORS headers and did no Origin check. The token already made that
412
+ * unguessable — but nothing legitimate that calls this runner is a browser (the cloud
413
+ * dispatches over the relay; the CLI calls it directly), and neither sends `Origin`. So the
414
+ * presence of that header is by itself proof the caller is a web page, and is refused before
415
+ * the token is even considered. Also closes DNS-rebinding, which loopback does not.
416
+ */
394
417
  function authorize(req, config) {
418
+ if (req.headers.origin)
419
+ return false;
395
420
  const token = config.token;
396
421
  if (config.instanceId && req.headers["x-pags-instance-id"] !== config.instanceId) {
397
422
  return false;
398
423
  }
399
424
  if (!token)
400
- return true;
425
+ return false;
401
426
  const auth = req.headers.authorization || "";
402
427
  const headerToken = req.headers["x-pags-runner-token"];
403
428
  return auth === `Bearer ${token}` || headerToken === token;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@proagentstore/cli",
3
- "version": "0.4.31",
3
+ "version": "0.4.33",
4
4
  "description": "CLI for creating, publishing, and running ProAgentStore agents",
5
5
  "license": "MIT",
6
6
  "type": "module",