ai-spend-agent 0.9.8 → 0.9.9

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/dist/index.js CHANGED
@@ -1,5 +1,5 @@
1
1
  #!/usr/bin/env node
2
- import { beginWorkspaceEnrollment, finishWorkspaceEnrollment, workspaceStatus, prepareWorkspacePush, sendWorkspacePending, disconnectWorkspace, WORKSPACE_ORIGIN, workspaceCanonicalJson, } from "./workspaceConnect.js";
2
+ import { beginWorkspaceEnrollment, finishWorkspaceEnrollment, workspaceStatus, prepareWorkspacePush, sendWorkspacePending, disconnectWorkspace, WORKSPACE_ORIGIN, workspaceCanonicalJson, WorkspaceEnrollmentRefusal, } from "./workspaceConnect.js";
3
3
  import { workspaceClock } from "./lib/clock.js";
4
4
  import { randomUUID } from "node:crypto";
5
5
  import { realpathSync } from "node:fs";
@@ -1063,6 +1063,14 @@ async function workspaceCommand(args, runtime) {
1063
1063
  const status = await workspaceStatus(runtime.homeDirectory);
1064
1064
  if (status.state === "not_connected")
1065
1065
  return ok("This machine has no completed local Workspace pairing. No logs or network were read.");
1066
+ if (status.state === "enrollment_pending")
1067
+ return ok([
1068
+ `Unfinished connection request: ${status.requestId.slice(-8)}`,
1069
+ status.enrollment === "prepared"
1070
+ ? "No native exchange was attempted. Run npx aibill@latest workspace connect to display this request again, or npx aibill@latest workspace connect --restart to replace an expired or cancelled request."
1071
+ : `The exchange outcome is unknown. Local keys remain. Do not restart or replay; reconcile this machine at ${WORKSPACE_ORIGIN}/settings/machines.`,
1072
+ "No logs or network were read."
1073
+ ].join("\n"));
1066
1074
  return ok([`Workspace: ${status.origin}`, `Last accepted push: ${status.lastAcceptedAt ?? "no push yet"}`,
1067
1075
  `Pairing state: local record present; disconnect ${status.disconnect ?? "not requested"}`,
1068
1076
  `Acknowledged fact keys: ${status.acknowledgedFacts}`, `Pending batch: ${status.pending?.state ?? "none"}`,
@@ -1070,10 +1078,14 @@ async function workspaceCommand(args, runtime) {
1070
1078
  }
1071
1079
  if (action === "connect") {
1072
1080
  if (!args.workspaceCode) {
1073
- if (!await consent("Create a private device key and display its public enrollment request? No session facts are sent. [y/N] "))
1074
- return ok("Not paired.");
1075
- const prepared = await beginWorkspaceEnrollment(runtime.homeDirectory);
1076
- return ok([`Open ${WORKSPACE_ORIGIN}/settings/machines, paste this public request and confirm enrollment:`, prepared.bundle,
1081
+ if (!await consent(args.workspaceRestart
1082
+ ? "Replace any unused connection request with a new private key and public request? The previous unused key will be removed; connected or uncertain exchanges cannot be restarted. No session facts are sent. [y/N] "
1083
+ : "Create a private device key, or display an existing unused connection request? No session facts are sent. [y/N] "))
1084
+ return fail("Connection request unchanged. No new request was created.");
1085
+ const prepared = await beginWorkspaceEnrollment(runtime.homeDirectory, { restart: args.workspaceRestart });
1086
+ return ok([`${prepared.disposition === "reused" ? "Reused" : "New"} connection request: ${prepared.request.requestId.slice(-8)}`,
1087
+ ...(prepared.disposition === "reused" ? ["This is the same unfinished request. If it expired or was cancelled in the browser, run npx aibill@latest workspace connect --restart."] : []),
1088
+ `Open ${WORKSPACE_ORIGIN}/settings/machines, paste this public request and confirm enrollment:`, prepared.bundle,
1077
1089
  "Then run: npx aibill workspace connect <response-bundle>", "The private device key stays on this machine. No session facts were sent."].join("\n"));
1078
1090
  }
1079
1091
  const result = await finishWorkspaceEnrollment({ home: runtime.homeDirectory, bundle: args.workspaceCode,
@@ -1092,7 +1104,7 @@ async function workspaceCommand(args, runtime) {
1092
1104
  : "Revoke this machine's Workspace grant and remove its local pairing after the accepted receipt? [y/N] ") });
1093
1105
  return result.state === "revoked" ? ok("Workspace revoked this machine grant. Local pairing was removed.")
1094
1106
  : result.state === "abandoned" ? ok("Unused native pairing key removed. No remote revocation was claimed. Run npx aibill workspace connect to start a new request.")
1095
- : result.state === "cancelled" ? ok("Pairing kept.") : result.state === "not_paired" ? ok("No completed local pairing exists.")
1107
+ : result.state === "cancelled" ? fail("Pairing kept. Disconnect was not confirmed; no request or key was removed.") : result.state === "not_paired" ? ok("No completed local pairing exists.")
1096
1108
  : fail(`Disconnect outcome is unknown. Local keys remain; no retry was sent. Reconcile this machine at ${WORKSPACE_ORIGIN}/settings/machines.`);
1097
1109
  }
1098
1110
  const status = await workspaceStatus(runtime.homeDirectory);
@@ -1125,8 +1137,12 @@ async function workspaceCommand(args, runtime) {
1125
1137
  : sent.state === "refused" ? fail("Workspace refused this batch. Its exact envelope and refusal are retained; no new nonce was created.")
1126
1138
  : fail("Push outcome is unknown. The exact signed envelope is retained; run workspace push to review and explicitly retry this exact batch.");
1127
1139
  }
1128
- catch {
1140
+ catch (error) {
1129
1141
  // Never echo remote payloads, raw argument bundles, tokens or filesystem contents.
1142
+ if (error instanceof WorkspaceEnrollmentRefusal)
1143
+ return fail(error.reason === "connected"
1144
+ ? "This machine is already connected. Disconnect it before starting another connection request."
1145
+ : `The earlier exchange outcome is unknown. Local keys remain. Do not restart or replay; reconcile this machine at ${WORKSPACE_ORIGIN}/settings/machines.`);
1130
1146
  return fail("Workspace operation stopped safely. Local state was not reset; inspect pairing status and permissions before continuing.");
1131
1147
  }
1132
1148
  }
@@ -6721,8 +6737,12 @@ function parseArgs(argv) {
6721
6737
  parsed.workspaceAction = rest.shift();
6722
6738
  if (parsed.workspaceAction === "connect" && rest[0] && !rest[0].startsWith("--"))
6723
6739
  parsed.workspaceCode = rest.shift();
6740
+ if (parsed.workspaceAction === "connect" && !parsed.workspaceCode && rest.length === 1 && rest[0] === "--restart") {
6741
+ parsed.workspaceRestart = true;
6742
+ rest.shift();
6743
+ }
6724
6744
  if (rest.length)
6725
- parsed.parseErrors.push("workspace accepts only connect [response-bundle], push, status, or disconnect");
6745
+ parsed.parseErrors.push("workspace accepts only connect [response-bundle], connect --restart, push, status, or disconnect");
6726
6746
  }
6727
6747
  if (command === "statusline" && rest[0] && !rest[0].startsWith("--")) {
6728
6748
  parsed.statuslineAction = rest.shift();
@@ -7389,6 +7409,7 @@ function helpText(telemetryDisclosure) {
7389
7409
  "",
7390
7410
  "Optional Workspace machine attribution (explicit consent, no provider calls):",
7391
7411
  " npx aibill workspace connect Prepare this machine's public pairing request",
7412
+ " npx aibill workspace connect --restart Replace an unused request after browser expiry or cancellation",
7392
7413
  " npx aibill workspace connect <code> Finish the browser-confirmed pairing",
7393
7414
  " npx aibill workspace push Preview and send local session facts",
7394
7415
  " npx aibill workspace status Read local pairing/pending status only",
@@ -92,6 +92,10 @@ export declare function aggregateWorkspaceFacts(calls: readonly LocalAgentCall[]
92
92
  export declare function buildWorkspaceEnvelope(device: WorkspaceDevice, facts: WorkspaceFact[], generatedAt: string): WorkspaceEnvelope;
93
93
  export type WorkspaceStatus = {
94
94
  state: "not_connected";
95
+ } | {
96
+ state: "enrollment_pending";
97
+ enrollment: "prepared" | "exchange_uncertain";
98
+ requestId: string;
95
99
  } | {
96
100
  state: "connected";
97
101
  origin: string;
@@ -191,10 +195,17 @@ export type WorkspaceEnrollmentResponse = {
191
195
  };
192
196
  };
193
197
  export declare function encodeWorkspaceBundle(value: WorkspaceEnrollmentRequest | WorkspaceEnrollmentResponse): string;
198
+ export declare class WorkspaceEnrollmentRefusal extends Error {
199
+ readonly reason: "connected" | "exchange_uncertain";
200
+ constructor(reason: "connected" | "exchange_uncertain");
201
+ }
194
202
  /** Generates native custody before displaying public enrollment material. No network call. */
195
- export declare function beginWorkspaceEnrollment(home?: string): Promise<{
203
+ export declare function beginWorkspaceEnrollment(home?: string, options?: {
204
+ restart?: boolean;
205
+ }): Promise<{
196
206
  request: WorkspaceEnrollmentRequest;
197
207
  bundle: string;
208
+ disposition: "created" | "reused" | "restarted";
198
209
  }>;
199
210
  export declare function validateWorkspaceEnrollmentResponse(request: WorkspaceEnrollmentRequest, response: WorkspaceEnrollmentResponse, now: string): string;
200
211
  /** Exchange is marked uncertain durably before dispatch and is never retried implicitly. */
@@ -358,10 +358,14 @@ export async function workspaceStatus(home = homedir()) {
358
358
  throw error;
359
359
  }
360
360
  const state = await readState(path);
361
- return state ? { state: "connected", origin: state.device.origin, lastAcceptedAt: state.lastAcceptedAt,
361
+ if (!state) {
362
+ const enrollment = await readEnrollment(path);
363
+ return enrollment ? { state: "enrollment_pending", enrollment: enrollment.state, requestId: enrollment.request.requestId }
364
+ : { state: "not_connected" };
365
+ }
366
+ return { state: "connected", origin: state.device.origin, lastAcceptedAt: state.lastAcceptedAt,
362
367
  acknowledgedFacts: Object.keys(state.facts).length, disconnect: state.disconnect?.state ?? null, pending: state.pending ? { state: state.pending.state,
363
- factCount: state.pending.envelope.facts.length, generatedAt: state.pending.envelope.generatedAt, refusal: state.pending.refusal } : null }
364
- : { state: "not_connected" };
368
+ factCount: state.pending.envelope.facts.length, generatedAt: state.pending.envelope.generatedAt, refusal: state.pending.refusal } : null };
365
369
  }
366
370
  /** HTTPS native transport avoids browser Origin/Sec-Fetch headers and never follows redirects. */
367
371
  export const workspaceTransport = (path, payload, token) => workspaceNativeRequest(path, payload, token);
@@ -567,22 +571,29 @@ async function readEnrollment(path) {
567
571
  await handle.close();
568
572
  }
569
573
  }
574
+ export class WorkspaceEnrollmentRefusal extends Error {
575
+ reason;
576
+ constructor(reason) {
577
+ super("Workspace enrollment unavailable");
578
+ this.reason = reason;
579
+ }
580
+ }
570
581
  /** Generates native custody before displaying public enrollment material. No network call. */
571
- export async function beginWorkspaceEnrollment(home = homedir()) {
582
+ export async function beginWorkspaceEnrollment(home = homedir(), options = {}) {
572
583
  return withState(home, async (device, _save, path) => {
573
584
  if (device)
574
- throw Error("This machine is already connected. Disconnect it in the Workspace before pairing it again.");
585
+ throw new WorkspaceEnrollmentRefusal("connected");
575
586
  const retained = await readEnrollment(path);
576
587
  if (retained?.state === "exchange_uncertain")
577
- throw Error("The earlier exchange outcome is unknown. Revoke that enrollment in the Workspace before starting again.");
578
- if (retained)
579
- return { request: retained.request, bundle: encodeWorkspaceBundle(retained.request) };
588
+ throw new WorkspaceEnrollmentRefusal("exchange_uncertain");
589
+ if (retained && !options.restart)
590
+ return { request: retained.request, bundle: encodeWorkspaceBundle(retained.request), disposition: "reused" };
580
591
  const pair = generateKeyPairSync("ed25519"), ref = () => `oref_${randomBytes(32).toString("base64url")}`;
581
592
  const request = { schemaVersion: "1", kind: "tilden_machine_enrollment_request", origin: WORKSPACE_ORIGIN,
582
593
  publicKey: pair.publicKey.export({ format: "jwk" }).x, keyId: ref(), requestId: ref() };
583
594
  await atomicPrivateJson(path, ENROLLMENT_FILE, { version: 1, request,
584
595
  privateKeyPem: pair.privateKey.export({ format: "pem", type: "pkcs8" }).toString(), state: "prepared" });
585
- return { request, bundle: encodeWorkspaceBundle(request) };
596
+ return { request, bundle: encodeWorkspaceBundle(request), disposition: retained ? "restarted" : "created" };
586
597
  });
587
598
  }
588
599
  export function validateWorkspaceEnrollmentResponse(request, response, now) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ai-spend-agent",
3
- "version": "0.9.8",
3
+ "version": "0.9.9",
4
4
  "funding": "https://asktilden.com",
5
5
  "description": "Local-first financial accountability CLI: Claude Code/Codex attribution, provenance, and next actions, plus experimental Gemini CLI cost evidence.",
6
6
  "type": "module",
@@ -55,8 +55,8 @@
55
55
  "prepack": "npm run build"
56
56
  },
57
57
  "dependencies": {
58
- "@agent-finops/core": "0.9.8",
59
- "@agent-finops/report": "0.9.8",
58
+ "@agent-finops/core": "0.9.9",
59
+ "@agent-finops/report": "0.9.9",
60
60
  "yocto-spinner": "^1.2.0"
61
61
  }
62
62
  }