ai-spend-agent 0.9.7 → 0.9.8
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.d.ts +8 -0
- package/dist/index.js +106 -0
- package/dist/lib/clock.d.ts +6 -0
- package/dist/lib/clock.js +6 -0
- package/dist/projectAccountabilityState.js +26 -1
- package/dist/workspaceConnect.d.ts +228 -0
- package/dist/workspaceConnect.js +730 -0
- package/package.json +3 -3
package/dist/index.d.ts
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
+
import { type WorkspaceTransport, type WorkspaceExchangeTransport, type WorkspaceDisconnectTransport } from "./workspaceConnect.js";
|
|
2
3
|
import { type GuidedPromptSource } from "./guidedPrompt.js";
|
|
3
4
|
import { type SignupDnsResolver } from "./signup.js";
|
|
5
|
+
import { loadLocalAgentFinancialUsage } from "@agent-finops/core";
|
|
4
6
|
import { decideReportAutoOpen, openReportInBrowser } from "./reportOpener.js";
|
|
5
7
|
export type CliResult = {
|
|
6
8
|
exitCode: number;
|
|
@@ -8,6 +10,12 @@ export type CliResult = {
|
|
|
8
10
|
stderr: string;
|
|
9
11
|
};
|
|
10
12
|
export type CliRuntimeOptions = {
|
|
13
|
+
/** Workspace test seams; production uses native transport and explicit financial-only loading. */
|
|
14
|
+
workspaceNow?: string;
|
|
15
|
+
workspaceTransport?: WorkspaceTransport;
|
|
16
|
+
workspaceExchangeTransport?: WorkspaceExchangeTransport;
|
|
17
|
+
workspaceDisconnectTransport?: WorkspaceDisconnectTransport;
|
|
18
|
+
workspaceLoadCalls?: () => Promise<Awaited<ReturnType<typeof loadLocalAgentFinancialUsage>>>;
|
|
11
19
|
/** Test/embedding override. Production always defaults to the OS home. */
|
|
12
20
|
homeDirectory?: string;
|
|
13
21
|
/** Test/embedding override. Packed production reads the built runtime asset. */
|
package/dist/index.js
CHANGED
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
+
import { beginWorkspaceEnrollment, finishWorkspaceEnrollment, workspaceStatus, prepareWorkspacePush, sendWorkspacePending, disconnectWorkspace, WORKSPACE_ORIGIN, workspaceCanonicalJson, } from "./workspaceConnect.js";
|
|
3
|
+
import { workspaceClock } from "./lib/clock.js";
|
|
2
4
|
import { randomUUID } from "node:crypto";
|
|
3
5
|
import { realpathSync } from "node:fs";
|
|
4
6
|
import { lstat, mkdir, readdir, readFile, rm, stat } from "node:fs/promises";
|
|
@@ -137,6 +139,8 @@ export async function runCli(argv = process.argv.slice(2), runtime = {}) {
|
|
|
137
139
|
if (args.command === "list-sources") {
|
|
138
140
|
return listSourcesCommand(args);
|
|
139
141
|
}
|
|
142
|
+
if (args.command === "workspace")
|
|
143
|
+
return workspaceCommand(args, runtime);
|
|
140
144
|
if (args.command === "connect") {
|
|
141
145
|
return connectCommand(args);
|
|
142
146
|
}
|
|
@@ -1038,6 +1042,94 @@ function renderCliQualitativeCoverage(coverage) {
|
|
|
1038
1042
|
* persists the typed email on failure. `--forget` clears local signup state;
|
|
1039
1043
|
* `--never` records never-ask without sending anything.
|
|
1040
1044
|
*/
|
|
1045
|
+
async function workspaceCommand(args, runtime) {
|
|
1046
|
+
const fail = (message) => ({ exitCode: 1, stdout: "", stderr: message });
|
|
1047
|
+
const action = args.workspaceAction;
|
|
1048
|
+
if (!["connect", "push", "status", "disconnect"].includes(action ?? ""))
|
|
1049
|
+
return fail("Use npx aibill workspace connect [response-bundle], push, status, or disconnect.");
|
|
1050
|
+
if (action !== "status" && (runtime.interactive !== true || !runtime.consentRead && !runtime.prompt))
|
|
1051
|
+
return fail("Workspace changes require an interactive terminal and explicit consent. Status remains available without a terminal.");
|
|
1052
|
+
const consent = async (message) => {
|
|
1053
|
+
try {
|
|
1054
|
+
const answer = runtime.consentRead ? await runtime.consentRead(message, 120000) : await runtime.prompt?.(message);
|
|
1055
|
+
return answer?.trim().toLowerCase() === "y" || answer?.trim().toLowerCase() === "yes";
|
|
1056
|
+
}
|
|
1057
|
+
catch {
|
|
1058
|
+
return false;
|
|
1059
|
+
}
|
|
1060
|
+
};
|
|
1061
|
+
try {
|
|
1062
|
+
if (action === "status") {
|
|
1063
|
+
const status = await workspaceStatus(runtime.homeDirectory);
|
|
1064
|
+
if (status.state === "not_connected")
|
|
1065
|
+
return ok("This machine has no completed local Workspace pairing. No logs or network were read.");
|
|
1066
|
+
return ok([`Workspace: ${status.origin}`, `Last accepted push: ${status.lastAcceptedAt ?? "no push yet"}`,
|
|
1067
|
+
`Pairing state: local record present; disconnect ${status.disconnect ?? "not requested"}`,
|
|
1068
|
+
`Acknowledged fact keys: ${status.acknowledgedFacts}`, `Pending batch: ${status.pending?.state ?? "none"}`,
|
|
1069
|
+
"Status reads local pairing state only; it does not confirm current server grant authority."].join("\n"));
|
|
1070
|
+
}
|
|
1071
|
+
if (action === "connect") {
|
|
1072
|
+
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,
|
|
1077
|
+
"Then run: npx aibill workspace connect <response-bundle>", "The private device key stays on this machine. No session facts were sent."].join("\n"));
|
|
1078
|
+
}
|
|
1079
|
+
const result = await finishWorkspaceEnrollment({ home: runtime.homeDirectory, bundle: args.workspaceCode,
|
|
1080
|
+
now: runtime.workspaceNow ?? workspaceClock.now(), transport: runtime.workspaceExchangeTransport,
|
|
1081
|
+
confirm: details => consent([`Pair with ${details.origin} for local facts from ${details.collectionNotBefore}, grant ending ${details.grantExpiresAt}?`,
|
|
1082
|
+
`Selected tenant: ${details.tenantId}`, `Reviewed source: ${details.localSourceInstanceRef}`, `Project: ${details.projectId}`, `Source project: ${details.sourceProjectRef}`,
|
|
1083
|
+
`Exact enrollment policy: ${workspaceCanonicalJson(details.policy)}`, "Sign this enrollment exchange? [y/N] "].join("\n")) });
|
|
1084
|
+
return result.state === "connected" ? ok(`Paired. Finish all-repository consent at ${WORKSPACE_ORIGIN}/settings/machines before your first push. Then run npx aibill workspace push to preview local session facts.`)
|
|
1085
|
+
: result.state === "cancelled" ? ok("Pairing exchange not sent.")
|
|
1086
|
+
: fail("Pairing outcome is unknown. Do not replay this response bundle; inspect and revoke the enrollment in Settings before starting another.");
|
|
1087
|
+
}
|
|
1088
|
+
if (action === "disconnect") {
|
|
1089
|
+
const result = await disconnectWorkspace({ home: runtime.homeDirectory, transport: runtime.workspaceDisconnectTransport,
|
|
1090
|
+
confirm: action => consent(action === "abandon_unexchanged"
|
|
1091
|
+
? "This native request has never attempted exchange. Destroy its unused private pairing key so it can no longer complete enrollment? Cancel any browser challenge in Settings as well. [y/N] "
|
|
1092
|
+
: "Revoke this machine's Workspace grant and remove its local pairing after the accepted receipt? [y/N] ") });
|
|
1093
|
+
return result.state === "revoked" ? ok("Workspace revoked this machine grant. Local pairing was removed.")
|
|
1094
|
+
: 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.")
|
|
1096
|
+
: fail(`Disconnect outcome is unknown. Local keys remain; no retry was sent. Reconcile this machine at ${WORKSPACE_ORIGIN}/settings/machines.`);
|
|
1097
|
+
}
|
|
1098
|
+
const status = await workspaceStatus(runtime.homeDirectory);
|
|
1099
|
+
if (status.state !== "connected")
|
|
1100
|
+
return fail("Connect this machine before pushing local facts.");
|
|
1101
|
+
if (status.disconnect)
|
|
1102
|
+
return fail("Disconnect is pending or complete. No session facts were read or sent.");
|
|
1103
|
+
if (status.pending?.state === "refused")
|
|
1104
|
+
return fail("A retained batch was refused. No new envelope or nonce was created; resolve the refusal before pushing again.");
|
|
1105
|
+
const now = runtime.workspaceNow ?? workspaceClock.now();
|
|
1106
|
+
const loaded = status.pending ? undefined : runtime.workspaceLoadCalls ? await runtime.workspaceLoadCalls()
|
|
1107
|
+
: await loadLocalAgentFinancialUsage({ workspaceDailyFacts: true, sinceIso: workspaceClock.daysBefore(now, 30) });
|
|
1108
|
+
if (loaded?.diagnostics.some(item => item.code !== "directory_missing"))
|
|
1109
|
+
return fail("Local source reading is incomplete. No facts were replaced or sent; resolve the reported source coverage before pushing.");
|
|
1110
|
+
const prepared = await prepareWorkspacePush({ home: runtime.homeDirectory, calls: loaded?.calls ?? [], generatedAt: now,
|
|
1111
|
+
confirm: (payload, coverage) => consent(["Exact outgoing local facts (no prompts, paths, session IDs, or amounts):", payload,
|
|
1112
|
+
`Excluded calls: ${coverage.excludedCalls}. Missing token components: ${coverage.incompleteComponents}.`,
|
|
1113
|
+
status.pending?.state === "uncertain" ? "This retries only the identical retained signed batch with its original nonce. Send this exact batch again? [y/N] "
|
|
1114
|
+
: "Machine tokens remain separate from provider-reported tokens and billed costs. Send this batch? [y/N] "].join("\n")) });
|
|
1115
|
+
if (prepared.state === "unchanged")
|
|
1116
|
+
return ok(prepared.coverage?.excludedCalls
|
|
1117
|
+
? "No complete changed facts are available. Some local calls lack a usable day or identity; absence is not zero usage."
|
|
1118
|
+
: "No changed eligible local session facts to push.");
|
|
1119
|
+
if (prepared.state === "cancelled")
|
|
1120
|
+
return ok("Not sent.");
|
|
1121
|
+
if (prepared.state === "pending")
|
|
1122
|
+
return fail("A prior batch remains retained. No new envelope was created.");
|
|
1123
|
+
const sent = await sendWorkspacePending({ home: runtime.homeDirectory, transport: runtime.workspaceTransport });
|
|
1124
|
+
return sent.state === "accepted" ? ok(`Workspace accepted ${sent.factCount} local session facts. These are attribution inputs, not billed costs.`)
|
|
1125
|
+
: sent.state === "refused" ? fail("Workspace refused this batch. Its exact envelope and refusal are retained; no new nonce was created.")
|
|
1126
|
+
: fail("Push outcome is unknown. The exact signed envelope is retained; run workspace push to review and explicitly retry this exact batch.");
|
|
1127
|
+
}
|
|
1128
|
+
catch {
|
|
1129
|
+
// Never echo remote payloads, raw argument bundles, tokens or filesystem contents.
|
|
1130
|
+
return fail("Workspace operation stopped safely. Local state was not reset; inspect pairing status and permissions before continuing.");
|
|
1131
|
+
}
|
|
1132
|
+
}
|
|
1041
1133
|
async function signupCommand(args, runtime) {
|
|
1042
1134
|
const stateFile = signupStateFilePath(runtime.homeDirectory);
|
|
1043
1135
|
if (args.signupForget) {
|
|
@@ -6625,6 +6717,13 @@ function parseArgs(argv) {
|
|
|
6625
6717
|
path: process.cwd(),
|
|
6626
6718
|
parseErrors: []
|
|
6627
6719
|
};
|
|
6720
|
+
if (command === "workspace" && rest[0] && !rest[0].startsWith("--")) {
|
|
6721
|
+
parsed.workspaceAction = rest.shift();
|
|
6722
|
+
if (parsed.workspaceAction === "connect" && rest[0] && !rest[0].startsWith("--"))
|
|
6723
|
+
parsed.workspaceCode = rest.shift();
|
|
6724
|
+
if (rest.length)
|
|
6725
|
+
parsed.parseErrors.push("workspace accepts only connect [response-bundle], push, status, or disconnect");
|
|
6726
|
+
}
|
|
6628
6727
|
if (command === "statusline" && rest[0] && !rest[0].startsWith("--")) {
|
|
6629
6728
|
parsed.statuslineAction = rest.shift();
|
|
6630
6729
|
}
|
|
@@ -7288,6 +7387,13 @@ function helpText(telemetryDisclosure) {
|
|
|
7288
7387
|
` ${actionRuntimeCommand("outcome github")} Attach one merged PR whose observed status checks passed`,
|
|
7289
7388
|
` ${actionRuntimeCommand("accountability")} Answer owner → outcome → approval → measured-result for this project`,
|
|
7290
7389
|
"",
|
|
7390
|
+
"Optional Workspace machine attribution (explicit consent, no provider calls):",
|
|
7391
|
+
" npx aibill workspace connect Prepare this machine's public pairing request",
|
|
7392
|
+
" npx aibill workspace connect <code> Finish the browser-confirmed pairing",
|
|
7393
|
+
" npx aibill workspace push Preview and send local session facts",
|
|
7394
|
+
" npx aibill workspace status Read local pairing/pending status only",
|
|
7395
|
+
" npx aibill workspace disconnect Revoke the grant or abandon an unused pairing key",
|
|
7396
|
+
"",
|
|
7291
7397
|
"Add official provider-reported cost (ADMIN/owner-gated):",
|
|
7292
7398
|
" npx aibill connect openai Requires an org-owner Admin credential reference",
|
|
7293
7399
|
" npx aibill connect anthropic Requires an Admin credential reference",
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
/** Workspace command clock. Tests inject the instant at the command boundary. */
|
|
2
|
+
export const workspaceClock = {
|
|
3
|
+
now: () => new Date().toISOString(),
|
|
4
|
+
daysBefore: (now, days) => `${new Date(Date.parse(now) - days * 86400000).toISOString().slice(0, 10)}T00:00:00.000Z`,
|
|
5
|
+
};
|
|
6
|
+
//# sourceMappingURL=clock.js.map
|
|
@@ -566,9 +566,20 @@ async function ensureIgnoredPrivateBoundary(privateBase, gitRoot, create) {
|
|
|
566
566
|
const tracked = await execFile("git", ["-C", gitRoot, "ls-files", "--", relativeBase], {
|
|
567
567
|
encoding: "utf8",
|
|
568
568
|
maxBuffer: 64 * 1024
|
|
569
|
-
}).then(({ stdout }) => stdout.trim()).catch(() => {
|
|
569
|
+
}).then(({ stdout }) => stdout.trim()).catch((error) => {
|
|
570
|
+
// A `.git` entry that Git itself rejects is not a repository: an
|
|
571
|
+
// interrupted `git init`, or a copied `.git` with no `objects/`, leaves
|
|
572
|
+
// an entry `lstat` sees but Git refuses to open. Nothing under such a
|
|
573
|
+
// directory can be staged or committed, so there is no tracking to
|
|
574
|
+
// verify and no boundary to prove -- the `*` marker written above still
|
|
575
|
+
// protects the state if a real repository is ever initialised here.
|
|
576
|
+
// Every other failure stays genuinely unverifiable and must refuse.
|
|
577
|
+
if (isNonRepositoryGitFailure(error))
|
|
578
|
+
return undefined;
|
|
570
579
|
throw new ProjectAccountabilityStateError("malformed_state", "Private project accountability tracking status could not be verified.");
|
|
571
580
|
});
|
|
581
|
+
if (tracked === undefined)
|
|
582
|
+
return;
|
|
572
583
|
if (tracked) {
|
|
573
584
|
throw new ProjectAccountabilityStateError("malformed_state", "Private project accountability storage is already tracked by Git.");
|
|
574
585
|
}
|
|
@@ -833,6 +844,20 @@ function hasExactKeys(value, expected) {
|
|
|
833
844
|
return keys.length === sorted.length &&
|
|
834
845
|
keys.every((key, index) => key === sorted[index]);
|
|
835
846
|
}
|
|
847
|
+
/**
|
|
848
|
+
* True only when Git itself ran and reported that the directory is not a
|
|
849
|
+
* repository. A spawn failure (Git missing) carries a string `code` and a
|
|
850
|
+
* non-zero exit carries a numeric one, so an environment where Git cannot be
|
|
851
|
+
* consulted at all keeps the conservative "unverifiable" refusal.
|
|
852
|
+
*/
|
|
853
|
+
function isNonRepositoryGitFailure(error) {
|
|
854
|
+
if (!(error instanceof Error))
|
|
855
|
+
return false;
|
|
856
|
+
if (typeof error.code !== "number")
|
|
857
|
+
return false;
|
|
858
|
+
const stderr = error.stderr;
|
|
859
|
+
return typeof stderr === "string" && /not a git repository/i.test(stderr);
|
|
860
|
+
}
|
|
836
861
|
function isNodeError(error, code) {
|
|
837
862
|
return error instanceof Error && error.code === code;
|
|
838
863
|
}
|
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
import { type LocalAgentCall } from "@agent-finops/core";
|
|
2
|
+
export declare const WORKSPACE_ORIGIN = "https://app.asktilden.com";
|
|
3
|
+
export declare const WORKSPACE_FACTS_PATH = "/api/workspace/local-session-facts";
|
|
4
|
+
type Json = null | boolean | number | string | Json[] | {
|
|
5
|
+
[key: string]: Json;
|
|
6
|
+
};
|
|
7
|
+
export type WorkspaceTokens = {
|
|
8
|
+
uncachedInputTokens: string | null;
|
|
9
|
+
cacheReadTokens: string | null;
|
|
10
|
+
cacheWriteTokens: string | null;
|
|
11
|
+
outputTokens: string | null;
|
|
12
|
+
};
|
|
13
|
+
export type WorkspaceFact = {
|
|
14
|
+
day: string;
|
|
15
|
+
agent: "claude-code" | "codex";
|
|
16
|
+
provider: "anthropic" | "openai";
|
|
17
|
+
model: string;
|
|
18
|
+
projectName: string | null;
|
|
19
|
+
directoryRef: string;
|
|
20
|
+
apiKeyRef: null;
|
|
21
|
+
workspaceRef: null;
|
|
22
|
+
sessionCount: string;
|
|
23
|
+
sessionRefs: string[];
|
|
24
|
+
tokens: WorkspaceTokens;
|
|
25
|
+
sessionsHash: string;
|
|
26
|
+
factRevision: string;
|
|
27
|
+
};
|
|
28
|
+
export type WorkspaceFactState = Record<string, {
|
|
29
|
+
revision: string;
|
|
30
|
+
contentHash: string;
|
|
31
|
+
}>;
|
|
32
|
+
export type WorkspaceDevice = {
|
|
33
|
+
origin: typeof WORKSPACE_ORIGIN;
|
|
34
|
+
grantId: string;
|
|
35
|
+
keyId: string;
|
|
36
|
+
grantRevision: string;
|
|
37
|
+
connectionEpoch: string;
|
|
38
|
+
token: string;
|
|
39
|
+
privateKeyPem: string;
|
|
40
|
+
sequence: string;
|
|
41
|
+
collectionNotBefore: string;
|
|
42
|
+
authorityStartsAt: string;
|
|
43
|
+
expiresAt: string;
|
|
44
|
+
};
|
|
45
|
+
export type WorkspaceEnvelope = {
|
|
46
|
+
schemaVersion: "1";
|
|
47
|
+
deviceGrantId: string;
|
|
48
|
+
grantRevision: string;
|
|
49
|
+
deviceKeyId: string;
|
|
50
|
+
sequence: string;
|
|
51
|
+
nonce: string;
|
|
52
|
+
batchId: string;
|
|
53
|
+
idempotencyKey: string;
|
|
54
|
+
generatedAt: string;
|
|
55
|
+
facts: WorkspaceFact[];
|
|
56
|
+
manifestHash: string;
|
|
57
|
+
signature: {
|
|
58
|
+
algorithm: "ed25519";
|
|
59
|
+
canonicalization: "jcs-v1";
|
|
60
|
+
value: string;
|
|
61
|
+
};
|
|
62
|
+
};
|
|
63
|
+
export type WorkspacePending = {
|
|
64
|
+
envelope: WorkspaceEnvelope;
|
|
65
|
+
state: "ready" | "uncertain" | "refused";
|
|
66
|
+
refusal: "invalid" | "conflict" | "stale" | "expired" | null;
|
|
67
|
+
};
|
|
68
|
+
export type WorkspaceState = {
|
|
69
|
+
version: 1;
|
|
70
|
+
device: WorkspaceDevice;
|
|
71
|
+
facts: WorkspaceFactState;
|
|
72
|
+
pending: WorkspacePending | null;
|
|
73
|
+
lastAcceptedAt: string | null;
|
|
74
|
+
disconnect: null | {
|
|
75
|
+
state: "uncertain" | "revoked";
|
|
76
|
+
requestId: string;
|
|
77
|
+
revokedAt: string | null;
|
|
78
|
+
};
|
|
79
|
+
};
|
|
80
|
+
export type WorkspaceAggregate = {
|
|
81
|
+
facts: WorkspaceFact[];
|
|
82
|
+
excludedCalls: number;
|
|
83
|
+
incompleteComponents: number;
|
|
84
|
+
reasons: string[];
|
|
85
|
+
};
|
|
86
|
+
export declare function workspaceCanonicalJson(value: Json): string;
|
|
87
|
+
export declare const workspaceDomainHash: (domain: string, value: Json) => string;
|
|
88
|
+
export declare function workspaceFactKey(fact: WorkspaceFact): string;
|
|
89
|
+
export declare function workspaceFactContentHash(fact: WorkspaceFact): string;
|
|
90
|
+
/** Caller supplies approved numeric-only calls; this function never opens local logs. */
|
|
91
|
+
export declare function aggregateWorkspaceFacts(calls: readonly LocalAgentCall[], previous?: WorkspaceFactState, deviceKeyId?: string): WorkspaceAggregate;
|
|
92
|
+
export declare function buildWorkspaceEnvelope(device: WorkspaceDevice, facts: WorkspaceFact[], generatedAt: string): WorkspaceEnvelope;
|
|
93
|
+
export type WorkspaceStatus = {
|
|
94
|
+
state: "not_connected";
|
|
95
|
+
} | {
|
|
96
|
+
state: "connected";
|
|
97
|
+
origin: string;
|
|
98
|
+
lastAcceptedAt: string | null;
|
|
99
|
+
acknowledgedFacts: number;
|
|
100
|
+
disconnect: "uncertain" | "revoked" | null;
|
|
101
|
+
pending: null | {
|
|
102
|
+
state: WorkspacePending["state"];
|
|
103
|
+
factCount: number;
|
|
104
|
+
generatedAt: string;
|
|
105
|
+
refusal: WorkspacePending["refusal"];
|
|
106
|
+
};
|
|
107
|
+
};
|
|
108
|
+
/** Reads only the private pairing record; never logs, a provider, or the Workspace. */
|
|
109
|
+
export declare function workspaceStatus(home?: string): Promise<WorkspaceStatus>;
|
|
110
|
+
export type WorkspaceTransportResult = {
|
|
111
|
+
status: number;
|
|
112
|
+
body: unknown;
|
|
113
|
+
};
|
|
114
|
+
export type WorkspaceTransport = (path: typeof WORKSPACE_FACTS_PATH, payload: string, token: string) => Promise<WorkspaceTransportResult>;
|
|
115
|
+
/** HTTPS native transport avoids browser Origin/Sec-Fetch headers and never follows redirects. */
|
|
116
|
+
export declare const workspaceTransport: WorkspaceTransport;
|
|
117
|
+
export declare const WORKSPACE_DISCONNECT_PATH = "/api/workspace/machines/disconnect";
|
|
118
|
+
export type WorkspaceDisconnectTransport = (payload: string, token: string) => Promise<WorkspaceTransportResult>;
|
|
119
|
+
export declare const workspaceDisconnectTransport: WorkspaceDisconnectTransport;
|
|
120
|
+
export declare const WORKSPACE_EXCHANGE_PATH = "/api/workspace/device-enrollment/exchange";
|
|
121
|
+
export type WorkspaceExchangeTransport = (payload: string) => Promise<WorkspaceTransportResult>;
|
|
122
|
+
export declare const workspaceExchangeTransport: WorkspaceExchangeTransport;
|
|
123
|
+
/** Consent precedes durable preparation. A pending batch is never replaced with a fresh nonce. */
|
|
124
|
+
export declare function prepareWorkspacePush(options: {
|
|
125
|
+
home?: string;
|
|
126
|
+
calls: readonly LocalAgentCall[];
|
|
127
|
+
generatedAt: string;
|
|
128
|
+
confirm: (exactPayload: string, coverage: WorkspaceAggregate) => Promise<boolean>;
|
|
129
|
+
}): Promise<{
|
|
130
|
+
state: "prepared" | "pending" | "unchanged" | "cancelled";
|
|
131
|
+
coverage?: WorkspaceAggregate;
|
|
132
|
+
}>;
|
|
133
|
+
/** One attempt. Uncertain/refused batches remain retained and cannot silently be resubmitted. */
|
|
134
|
+
export declare function sendWorkspacePending(options: {
|
|
135
|
+
home?: string;
|
|
136
|
+
transport?: WorkspaceTransport;
|
|
137
|
+
}): Promise<{
|
|
138
|
+
state: "accepted" | "refused" | "unconfirmed";
|
|
139
|
+
factCount?: number;
|
|
140
|
+
}>;
|
|
141
|
+
export type WorkspaceEnrollmentRequest = {
|
|
142
|
+
schemaVersion: "1";
|
|
143
|
+
kind: "tilden_machine_enrollment_request";
|
|
144
|
+
origin: typeof WORKSPACE_ORIGIN;
|
|
145
|
+
publicKey: string;
|
|
146
|
+
keyId: string;
|
|
147
|
+
requestId: string;
|
|
148
|
+
};
|
|
149
|
+
export type WorkspaceEnrollmentIntent = {
|
|
150
|
+
publicKey: string;
|
|
151
|
+
keyId: string;
|
|
152
|
+
requestId: string;
|
|
153
|
+
localSourceInstanceRef: string;
|
|
154
|
+
policyRevision: string;
|
|
155
|
+
readerRevision: string;
|
|
156
|
+
projectAdmissionRevision: string;
|
|
157
|
+
collectionNotBefore: string;
|
|
158
|
+
authorityStartsAt: string;
|
|
159
|
+
grantExpiresAt: string;
|
|
160
|
+
};
|
|
161
|
+
export type WorkspaceEnrollmentResponse = {
|
|
162
|
+
schemaVersion: "1";
|
|
163
|
+
kind: "tilden_machine_enrollment_response";
|
|
164
|
+
origin: typeof WORKSPACE_ORIGIN;
|
|
165
|
+
intent: WorkspaceEnrollmentIntent;
|
|
166
|
+
binding: {
|
|
167
|
+
tenantId: string;
|
|
168
|
+
policy: Record<string, Json>;
|
|
169
|
+
projectId: string;
|
|
170
|
+
sourceProjectRef: string;
|
|
171
|
+
};
|
|
172
|
+
receipt: {
|
|
173
|
+
state: "prepared";
|
|
174
|
+
challengeId: string;
|
|
175
|
+
requestHash: string;
|
|
176
|
+
grantId: string;
|
|
177
|
+
deviceId: string;
|
|
178
|
+
policy: Record<string, Json>;
|
|
179
|
+
projectId: string;
|
|
180
|
+
projectRevision: string;
|
|
181
|
+
sourceProjectRef: string;
|
|
182
|
+
collectionNotBefore: string;
|
|
183
|
+
authorityStartsAt: string;
|
|
184
|
+
grantExpiresAt: string;
|
|
185
|
+
nonce: string;
|
|
186
|
+
pairingCode: string;
|
|
187
|
+
};
|
|
188
|
+
confirmation: {
|
|
189
|
+
state: "confirmed";
|
|
190
|
+
challengeId: string;
|
|
191
|
+
};
|
|
192
|
+
};
|
|
193
|
+
export declare function encodeWorkspaceBundle(value: WorkspaceEnrollmentRequest | WorkspaceEnrollmentResponse): string;
|
|
194
|
+
/** Generates native custody before displaying public enrollment material. No network call. */
|
|
195
|
+
export declare function beginWorkspaceEnrollment(home?: string): Promise<{
|
|
196
|
+
request: WorkspaceEnrollmentRequest;
|
|
197
|
+
bundle: string;
|
|
198
|
+
}>;
|
|
199
|
+
export declare function validateWorkspaceEnrollmentResponse(request: WorkspaceEnrollmentRequest, response: WorkspaceEnrollmentResponse, now: string): string;
|
|
200
|
+
/** Exchange is marked uncertain durably before dispatch and is never retried implicitly. */
|
|
201
|
+
export declare function finishWorkspaceEnrollment(options: {
|
|
202
|
+
home?: string;
|
|
203
|
+
bundle: string;
|
|
204
|
+
now: string;
|
|
205
|
+
confirm: (details: {
|
|
206
|
+
origin: string;
|
|
207
|
+
collectionNotBefore: string;
|
|
208
|
+
grantExpiresAt: string;
|
|
209
|
+
localSourceInstanceRef: string;
|
|
210
|
+
tenantId: string;
|
|
211
|
+
projectId: string;
|
|
212
|
+
sourceProjectRef: string;
|
|
213
|
+
policy: Record<string, Json>;
|
|
214
|
+
}) => Promise<boolean>;
|
|
215
|
+
transport?: WorkspaceExchangeTransport;
|
|
216
|
+
}): Promise<{
|
|
217
|
+
state: "connected" | "cancelled" | "unconfirmed";
|
|
218
|
+
}>;
|
|
219
|
+
/** Revocation is one native attempt. Unknown outcomes retain keys and block further writes. */
|
|
220
|
+
export declare function disconnectWorkspace(options: {
|
|
221
|
+
home?: string;
|
|
222
|
+
confirm: (action: "revoke" | "abandon_unexchanged") => Promise<boolean>;
|
|
223
|
+
transport?: WorkspaceDisconnectTransport;
|
|
224
|
+
}): Promise<{
|
|
225
|
+
state: "revoked" | "abandoned" | "cancelled" | "unconfirmed" | "not_paired";
|
|
226
|
+
}>;
|
|
227
|
+
export {};
|
|
228
|
+
//# sourceMappingURL=workspaceConnect.d.ts.map
|
|
@@ -0,0 +1,730 @@
|
|
|
1
|
+
/** Native Workspace pairing, transport, and retained facts. No implicit log scan. */
|
|
2
|
+
import { constants } from "node:fs";
|
|
3
|
+
import { lstat, mkdir, open, realpath, rename, unlink } from "node:fs/promises";
|
|
4
|
+
import { createHash, createPrivateKey, createPublicKey, generateKeyPairSync, randomBytes, sign, verify } from "node:crypto";
|
|
5
|
+
import { request as httpsRequest } from "node:https";
|
|
6
|
+
import { homedir } from "node:os";
|
|
7
|
+
import { isAbsolute, join, resolve } from "node:path";
|
|
8
|
+
import { dedupeCumulativeSessionCalls } from "@agent-finops/core";
|
|
9
|
+
export const WORKSPACE_ORIGIN = "https://app.asktilden.com";
|
|
10
|
+
export const WORKSPACE_FACTS_PATH = "/api/workspace/local-session-facts";
|
|
11
|
+
const MAX_BYTES = 262144;
|
|
12
|
+
const MAX_STATE_BYTES = 8 * 1024 * 1024;
|
|
13
|
+
const MAX_FACTS = 256;
|
|
14
|
+
const STATE_FILE = "workspace-device.json";
|
|
15
|
+
const LOCK_FILE = ".workspace-device.lock";
|
|
16
|
+
const REF = /^oref_[A-Za-z0-9_-]{43}$/;
|
|
17
|
+
const HASH = /^sha256_[a-f0-9]{64}$/;
|
|
18
|
+
const UINT = /^(0|[1-9][0-9]{0,24})$/;
|
|
19
|
+
const DAY_FORMAT = new Intl.DateTimeFormat("sv-SE", { timeZone: "UTC", year: "numeric", month: "2-digit", day: "2-digit" });
|
|
20
|
+
export function workspaceCanonicalJson(value) {
|
|
21
|
+
if (value === null || typeof value === "string" || typeof value === "boolean")
|
|
22
|
+
return JSON.stringify(value);
|
|
23
|
+
if (typeof value === "number") {
|
|
24
|
+
if (!Number.isFinite(value))
|
|
25
|
+
throw Error("Workspace JSON contains a non-finite number.");
|
|
26
|
+
return JSON.stringify(value);
|
|
27
|
+
}
|
|
28
|
+
if (Array.isArray(value))
|
|
29
|
+
return `[${value.map(workspaceCanonicalJson).join(",")}]`;
|
|
30
|
+
if (typeof value !== "object" || Object.getPrototypeOf(value) !== Object.prototype && Object.getPrototypeOf(value) !== null)
|
|
31
|
+
throw Error("Workspace JSON contains an unsupported value.");
|
|
32
|
+
return `{${Object.keys(value).sort().map(key => `${JSON.stringify(key)}:${workspaceCanonicalJson(value[key])}`).join(",")}}`;
|
|
33
|
+
}
|
|
34
|
+
const sha = (value) => `sha256_${createHash("sha256").update(value, "utf8").digest("hex")}`;
|
|
35
|
+
export const workspaceDomainHash = (domain, value) => {
|
|
36
|
+
if (!/^[a-z][a-z0-9-]*(?::[a-z0-9-]+)+:v[1-9][0-9]*$/.test(domain))
|
|
37
|
+
throw Error("Workspace hash domain is invalid.");
|
|
38
|
+
return sha(`${domain}\0${workspaceCanonicalJson(value)}`);
|
|
39
|
+
};
|
|
40
|
+
export function workspaceFactKey(fact) {
|
|
41
|
+
const { day, agent, provider, model, projectName, directoryRef, apiKeyRef, workspaceRef } = fact;
|
|
42
|
+
return workspaceDomainHash("tilden:local-session-fact-key:v1", { day, agent, provider, model, projectName, directoryRef, apiKeyRef, workspaceRef });
|
|
43
|
+
}
|
|
44
|
+
export function workspaceFactContentHash(fact) {
|
|
45
|
+
const { factRevision: ignored, ...content } = fact;
|
|
46
|
+
return workspaceDomainHash("tilden:local-session-fact-content:v1", content);
|
|
47
|
+
}
|
|
48
|
+
function utcDay(value) {
|
|
49
|
+
if (!/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d{1,3})?Z$/.test(value) || !Number.isFinite(Date.parse(value)))
|
|
50
|
+
throw Error("Local call has no canonical UTC timestamp.");
|
|
51
|
+
const day = DAY_FORMAT.format(Date.parse(value));
|
|
52
|
+
if (day !== value.slice(0, 10))
|
|
53
|
+
throw Error("Local call calendar day is invalid.");
|
|
54
|
+
return day;
|
|
55
|
+
}
|
|
56
|
+
function safeDirectoryRef(call) {
|
|
57
|
+
let supplied;
|
|
58
|
+
if (call.workingDirectoryRef) {
|
|
59
|
+
if (/^avref_[a-f0-9]{64}$/.test(call.workingDirectoryRef))
|
|
60
|
+
supplied = `sha256_${call.workingDirectoryRef.slice(6)}`;
|
|
61
|
+
else if (HASH.test(call.workingDirectoryRef))
|
|
62
|
+
supplied = call.workingDirectoryRef;
|
|
63
|
+
else
|
|
64
|
+
throw Error("directory_identity_invalid");
|
|
65
|
+
}
|
|
66
|
+
const derived = call.workingDirectory && isAbsolute(call.workingDirectory)
|
|
67
|
+
? sha(`project-working-directory\0${call.workingDirectory}`) : undefined;
|
|
68
|
+
if (supplied && derived && supplied !== derived)
|
|
69
|
+
throw Error("directory_identity_conflict");
|
|
70
|
+
if (!supplied && !derived)
|
|
71
|
+
throw Error("directory_identity_missing");
|
|
72
|
+
return supplied ?? derived;
|
|
73
|
+
}
|
|
74
|
+
function component(value, supported) {
|
|
75
|
+
return supported && Number.isSafeInteger(value) && value >= 0 ? BigInt(value) : null;
|
|
76
|
+
}
|
|
77
|
+
function tokenComponents(call) {
|
|
78
|
+
const supported = call.usageSupport !== "unsupported_token_shape";
|
|
79
|
+
const evidence = call.tokenComponentEvidence;
|
|
80
|
+
const cacheWrites = supported && evidence?.cacheWriteTokens === "observed"
|
|
81
|
+
? [component(call.usage.cacheWrite5mTokens ?? 0, true), component(call.usage.cacheWrite1hTokens ?? 0, true)] : [null, null];
|
|
82
|
+
return {
|
|
83
|
+
uncachedInputTokens: component(call.usage.inputTokens, supported && (call.agent !== "codex" || evidence?.cacheReadTokens === "observed")),
|
|
84
|
+
outputTokens: component(call.usage.outputTokens, supported),
|
|
85
|
+
cacheReadTokens: component(call.usage.cacheReadTokens, supported && evidence?.cacheReadTokens === "observed"),
|
|
86
|
+
cacheWriteTokens: cacheWrites.every(value => value !== null) ? cacheWrites[0] + cacheWrites[1] : null,
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
/** Caller supplies approved numeric-only calls; this function never opens local logs. */
|
|
90
|
+
export function aggregateWorkspaceFacts(calls, previous = {}, deviceKeyId) {
|
|
91
|
+
if (!deviceKeyId || !REF.test(deviceKeyId))
|
|
92
|
+
throw Error("A retained device key is required for private session identities.");
|
|
93
|
+
const groups = new Map(), reasons = new Set();
|
|
94
|
+
let excludedCalls = 0, incompleteComponents = 0;
|
|
95
|
+
for (const call of dedupeCumulativeSessionCalls([...calls])) {
|
|
96
|
+
if (call.agent !== "claude-code" && call.agent !== "codex")
|
|
97
|
+
continue;
|
|
98
|
+
try {
|
|
99
|
+
const day = utcDay(call.timestamp);
|
|
100
|
+
if (!call.sessionId)
|
|
101
|
+
throw Error("session_identity_missing");
|
|
102
|
+
if (call.usageScope === "session_cumulative") {
|
|
103
|
+
if (!call.startedAt || utcDay(call.startedAt) !== day || Date.parse(call.startedAt) > Date.parse(call.timestamp))
|
|
104
|
+
throw Error("cumulative_day_unresolved");
|
|
105
|
+
}
|
|
106
|
+
else if (call.usageScope !== "turn")
|
|
107
|
+
throw Error("call_scope_unresolved");
|
|
108
|
+
if (!/^[A-Za-z0-9][A-Za-z0-9._:/-]{0,119}$/.test(call.model))
|
|
109
|
+
throw Error("model_invalid");
|
|
110
|
+
let projectName = call.project && call.project !== "(home)" ? call.project : null;
|
|
111
|
+
if (projectName && (projectName.length > 120 || /[/\\\u0000-\u001f\u007f@]/.test(projectName) || [".", ".."].includes(projectName)))
|
|
112
|
+
projectName = null;
|
|
113
|
+
const fact = { day, agent: call.agent, provider: call.agent === "claude-code" ? "anthropic" : "openai",
|
|
114
|
+
model: call.model, projectName, directoryRef: safeDirectoryRef(call), apiKeyRef: null, workspaceRef: null,
|
|
115
|
+
sessionCount: "1", sessionRefs: [], tokens: { uncachedInputTokens: null, cacheReadTokens: null, cacheWriteTokens: null, outputTokens: null },
|
|
116
|
+
sessionsHash: sha(""), factRevision: "1" };
|
|
117
|
+
const key = workspaceFactKey(fact), tokens = tokenComponents(call), group = groups.get(key);
|
|
118
|
+
const identity = workspaceDomainHash("tilden:local-session-identity:v1", [deviceKeyId, call.agent, call.sessionId, call.subagentId ?? null]);
|
|
119
|
+
if (group) {
|
|
120
|
+
group.sessions.add(identity);
|
|
121
|
+
for (const name of Object.keys(tokens))
|
|
122
|
+
group.tokens[name] = group.tokens[name] === null || tokens[name] === null ? null : group.tokens[name] + tokens[name];
|
|
123
|
+
}
|
|
124
|
+
else
|
|
125
|
+
groups.set(key, { fact, sessions: new Set([identity]), tokens });
|
|
126
|
+
}
|
|
127
|
+
catch (error) {
|
|
128
|
+
excludedCalls++;
|
|
129
|
+
const reason = error instanceof Error ? error.message : "call_invalid";
|
|
130
|
+
reasons.add(/^[a-z_]+$/.test(reason) ? reason : "timestamp_invalid");
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
const facts = [];
|
|
134
|
+
for (const [key, group] of [...groups.entries()].sort(([a], [b]) => a.localeCompare(b))) {
|
|
135
|
+
const fact = group.fact;
|
|
136
|
+
if (group.sessions.size > 1024)
|
|
137
|
+
throw Error("A local fact exceeds the session identity bound.");
|
|
138
|
+
fact.sessionCount = String(group.sessions.size);
|
|
139
|
+
fact.sessionRefs = [...group.sessions].sort();
|
|
140
|
+
fact.sessionsHash = workspaceDomainHash("tilden:local-session-identities:v1", fact.sessionRefs);
|
|
141
|
+
for (const name of Object.keys(group.tokens)) {
|
|
142
|
+
fact.tokens[name] = group.tokens[name]?.toString() ?? null;
|
|
143
|
+
if (fact.tokens[name] === null)
|
|
144
|
+
incompleteComponents++;
|
|
145
|
+
}
|
|
146
|
+
const prior = previous[key];
|
|
147
|
+
if (prior && prior.contentHash === workspaceFactContentHash(fact))
|
|
148
|
+
continue;
|
|
149
|
+
if (prior && !UINT.test(prior.revision))
|
|
150
|
+
throw Error("Local fact revision is invalid.");
|
|
151
|
+
fact.factRevision = prior ? String(BigInt(prior.revision) + 1n) : "1";
|
|
152
|
+
if (!UINT.test(fact.factRevision))
|
|
153
|
+
throw Error("Local fact revision exceeds its bound.");
|
|
154
|
+
facts.push(fact);
|
|
155
|
+
}
|
|
156
|
+
return { facts, excludedCalls, incompleteComponents, reasons: [...reasons].sort() };
|
|
157
|
+
}
|
|
158
|
+
function exactKeys(value, keys) {
|
|
159
|
+
if (!value || typeof value !== "object" || Array.isArray(value) || Object.keys(value).sort().join("\0") !== keys.sort().join("\0"))
|
|
160
|
+
throw Error("Workspace state or receipt has an invalid shape.");
|
|
161
|
+
}
|
|
162
|
+
function bytes(value, count) {
|
|
163
|
+
return typeof value === "string" && /^[A-Za-z0-9_-]+$/.test(value) && Buffer.from(value, "base64url").length === count
|
|
164
|
+
&& Buffer.from(value, "base64url").toString("base64url") === value;
|
|
165
|
+
}
|
|
166
|
+
function validateFact(fact) {
|
|
167
|
+
exactKeys(fact, ["day", "agent", "provider", "model", "projectName", "directoryRef", "apiKeyRef", "workspaceRef", "sessionCount", "sessionRefs", "tokens", "sessionsHash", "factRevision"]);
|
|
168
|
+
exactKeys(fact.tokens, ["uncachedInputTokens", "cacheReadTokens", "cacheWriteTokens", "outputTokens"]);
|
|
169
|
+
if (!/^\d{4}-\d{2}-\d{2}$/.test(fact.day) || utcDay(`${fact.day}T00:00:00.000Z`) !== fact.day
|
|
170
|
+
|| !["claude-code", "codex"].includes(fact.agent) || fact.provider !== (fact.agent === "claude-code" ? "anthropic" : "openai")
|
|
171
|
+
|| !/^[A-Za-z0-9][A-Za-z0-9._:/-]{0,119}$/.test(fact.model) || !HASH.test(fact.directoryRef)
|
|
172
|
+
|| fact.apiKeyRef !== null || fact.workspaceRef !== null || !UINT.test(fact.sessionCount) || fact.sessionCount === "0"
|
|
173
|
+
|| !Array.isArray(fact.sessionRefs) || fact.sessionRefs.length < 1 || fact.sessionRefs.length > 1024
|
|
174
|
+
|| fact.sessionRefs.some((value, index) => !HASH.test(value) || index > 0 && value <= fact.sessionRefs[index - 1])
|
|
175
|
+
|| fact.sessionCount !== String(fact.sessionRefs.length)
|
|
176
|
+
|| workspaceDomainHash("tilden:local-session-identities:v1", fact.sessionRefs) !== fact.sessionsHash
|
|
177
|
+
|| !HASH.test(fact.sessionsHash) || !UINT.test(fact.factRevision)
|
|
178
|
+
|| Object.values(fact.tokens).some(value => value !== null && (typeof value !== "string" || !UINT.test(value)))
|
|
179
|
+
|| fact.projectName !== null && (typeof fact.projectName !== "string" || !fact.projectName.length || fact.projectName.length > 120
|
|
180
|
+
|| /[/\\\u0000-\u001f\u007f@]/.test(fact.projectName) || [".", ".."].includes(fact.projectName)))
|
|
181
|
+
throw Error("Workspace fact shape is invalid.");
|
|
182
|
+
}
|
|
183
|
+
function validateEnvelope(envelope, device) {
|
|
184
|
+
exactKeys(envelope, ["schemaVersion", "deviceGrantId", "grantRevision", "deviceKeyId", "sequence", "nonce", "batchId", "idempotencyKey", "generatedAt", "facts", "manifestHash", "signature"]);
|
|
185
|
+
exactKeys(envelope.signature, ["algorithm", "canonicalization", "value"]);
|
|
186
|
+
utcDay(envelope.generatedAt);
|
|
187
|
+
if (envelope.schemaVersion !== "1" || envelope.deviceGrantId !== device.grantId || envelope.deviceKeyId !== device.keyId
|
|
188
|
+
|| envelope.grantRevision !== device.grantRevision || !UINT.test(envelope.sequence)
|
|
189
|
+
|| envelope.sequence !== String(BigInt(device.sequence) + 1n) || !bytes(envelope.nonce, 16)
|
|
190
|
+
|| !REF.test(envelope.batchId) || !REF.test(envelope.idempotencyKey) || !HASH.test(envelope.manifestHash)
|
|
191
|
+
|| !Array.isArray(envelope.facts) || envelope.facts.length < 1 || envelope.facts.length > MAX_FACTS
|
|
192
|
+
|| envelope.signature.algorithm !== "ed25519" || envelope.signature.canonicalization !== "jcs-v1" || !bytes(envelope.signature.value, 64))
|
|
193
|
+
throw Error("Workspace envelope shape is invalid.");
|
|
194
|
+
envelope.facts.forEach(validateFact);
|
|
195
|
+
if (new Set(envelope.facts.map(workspaceFactKey)).size !== envelope.facts.length)
|
|
196
|
+
throw Error("Duplicate Workspace fact key.");
|
|
197
|
+
const { signature, manifestHash, ...body } = envelope;
|
|
198
|
+
if (workspaceDomainHash("tilden:local-session-facts-manifest:v1", body) !== manifestHash
|
|
199
|
+
|| !verify(null, Buffer.from(workspaceCanonicalJson({ ...body, manifestHash })), createPublicKey(device.privateKeyPem), Buffer.from(signature.value, "base64url"))
|
|
200
|
+
|| Buffer.byteLength(workspaceCanonicalJson(envelope)) > MAX_BYTES)
|
|
201
|
+
throw Error("Workspace envelope binding is invalid.");
|
|
202
|
+
}
|
|
203
|
+
function validateDevice(device) {
|
|
204
|
+
exactKeys(device, ["origin", "grantId", "keyId", "grantRevision", "connectionEpoch", "token", "privateKeyPem", "sequence", "collectionNotBefore", "authorityStartsAt", "expiresAt"]);
|
|
205
|
+
[device.collectionNotBefore, device.authorityStartsAt, device.expiresAt].forEach(utcDay);
|
|
206
|
+
if (device.origin !== WORKSPACE_ORIGIN || !REF.test(device.grantId) || !REF.test(device.keyId)
|
|
207
|
+
|| !UINT.test(device.grantRevision) || !UINT.test(device.connectionEpoch) || !UINT.test(device.sequence)
|
|
208
|
+
|| !bytes(device.token, 32) || typeof device.privateKeyPem !== "string" || device.privateKeyPem.length > 2048
|
|
209
|
+
|| device.authorityStartsAt > device.collectionNotBefore || device.collectionNotBefore >= device.expiresAt
|
|
210
|
+
|| createPrivateKey(device.privateKeyPem).asymmetricKeyType !== "ed25519")
|
|
211
|
+
throw Error("Workspace device state is invalid.");
|
|
212
|
+
}
|
|
213
|
+
export function buildWorkspaceEnvelope(device, facts, generatedAt) {
|
|
214
|
+
validateDevice(device);
|
|
215
|
+
utcDay(generatedAt);
|
|
216
|
+
facts.forEach(validateFact);
|
|
217
|
+
if (!/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/.test(generatedAt) || facts.length < 1 || facts.length > MAX_FACTS)
|
|
218
|
+
throw Error("Workspace envelope bounds are invalid.");
|
|
219
|
+
const sequence = String(BigInt(device.sequence) + 1n);
|
|
220
|
+
if (!UINT.test(sequence))
|
|
221
|
+
throw Error("Workspace sequence exceeds its bound.");
|
|
222
|
+
const body = { schemaVersion: "1", deviceGrantId: device.grantId, grantRevision: device.grantRevision, deviceKeyId: device.keyId,
|
|
223
|
+
sequence, nonce: randomBytes(16).toString("base64url"), batchId: `oref_${randomBytes(32).toString("base64url")}`,
|
|
224
|
+
idempotencyKey: `oref_${randomBytes(32).toString("base64url")}`, generatedAt, facts };
|
|
225
|
+
const unsigned = { ...body, manifestHash: workspaceDomainHash("tilden:local-session-facts-manifest:v1", body) };
|
|
226
|
+
const envelope = { ...unsigned, signature: { algorithm: "ed25519", canonicalization: "jcs-v1",
|
|
227
|
+
value: sign(null, Buffer.from(workspaceCanonicalJson(unsigned)), device.privateKeyPem).toString("base64url") } };
|
|
228
|
+
if (Buffer.byteLength(workspaceCanonicalJson(envelope)) > MAX_BYTES)
|
|
229
|
+
throw Error("Workspace envelope exceeds its byte bound.");
|
|
230
|
+
validateEnvelope(envelope, device);
|
|
231
|
+
return envelope;
|
|
232
|
+
}
|
|
233
|
+
const noFollow = constants.O_NOFOLLOW ?? 0;
|
|
234
|
+
const missing = (error) => error instanceof Error && error.code === "ENOENT";
|
|
235
|
+
async function privateDirectory(home, create) {
|
|
236
|
+
const canonicalHome = await realpath(resolve(home)), path = join(canonicalHome, ".aibill");
|
|
237
|
+
if (create)
|
|
238
|
+
await mkdir(path, { mode: 0o700 }).catch(error => { if (error.code !== "EEXIST")
|
|
239
|
+
throw error; });
|
|
240
|
+
const info = await lstat(path);
|
|
241
|
+
if (!info.isDirectory() || info.isSymbolicLink() || (info.mode & 0o077) !== 0 || process.getuid && info.uid !== process.getuid())
|
|
242
|
+
throw Error("Workspace private directory is unavailable.");
|
|
243
|
+
return path;
|
|
244
|
+
}
|
|
245
|
+
async function readState(path) {
|
|
246
|
+
let file;
|
|
247
|
+
try {
|
|
248
|
+
file = await open(join(path, STATE_FILE), constants.O_RDONLY | noFollow);
|
|
249
|
+
}
|
|
250
|
+
catch (error) {
|
|
251
|
+
if (missing(error))
|
|
252
|
+
return null;
|
|
253
|
+
throw Error("Workspace state could not be opened safely.");
|
|
254
|
+
}
|
|
255
|
+
try {
|
|
256
|
+
const info = await file.stat();
|
|
257
|
+
if (!info.isFile() || info.nlink !== 1 || info.mode & 0o077 || info.size > MAX_STATE_BYTES || process.getuid && info.uid !== process.getuid())
|
|
258
|
+
throw Error("Workspace state is not a private regular file.");
|
|
259
|
+
const state = JSON.parse(await file.readFile("utf8"));
|
|
260
|
+
exactKeys(state, ["version", "device", "facts", "pending", "lastAcceptedAt", "disconnect"]);
|
|
261
|
+
if (state.version !== 1 || !state.facts || typeof state.facts !== "object" || Array.isArray(state.facts))
|
|
262
|
+
throw Error("Workspace state is invalid.");
|
|
263
|
+
validateDevice(state.device);
|
|
264
|
+
if (state.lastAcceptedAt !== null)
|
|
265
|
+
utcDay(state.lastAcceptedAt);
|
|
266
|
+
for (const [key, value] of Object.entries(state.facts)) {
|
|
267
|
+
exactKeys(value, ["revision", "contentHash"]);
|
|
268
|
+
if (!HASH.test(key) || !UINT.test(value.revision) || !HASH.test(value.contentHash))
|
|
269
|
+
throw Error("Workspace fact state is invalid.");
|
|
270
|
+
}
|
|
271
|
+
if (state.pending) {
|
|
272
|
+
exactKeys(state.pending, ["envelope", "state", "refusal"]);
|
|
273
|
+
if (!["ready", "uncertain", "refused"].includes(state.pending.state))
|
|
274
|
+
throw Error("Workspace pending state is invalid.");
|
|
275
|
+
if (state.pending.state === "refused" ? !["invalid", "conflict", "stale", "expired"].includes(String(state.pending.refusal)) : state.pending.refusal !== null)
|
|
276
|
+
throw Error("Workspace refusal state is invalid.");
|
|
277
|
+
validateEnvelope(state.pending.envelope, state.device);
|
|
278
|
+
}
|
|
279
|
+
if (state.disconnect !== null) {
|
|
280
|
+
exactKeys(state.disconnect, ["state", "requestId", "revokedAt"]);
|
|
281
|
+
if (!["uncertain", "revoked"].includes(state.disconnect.state) || !REF.test(state.disconnect.requestId)
|
|
282
|
+
|| (state.disconnect.state === "uncertain" ? state.disconnect.revokedAt !== null : typeof state.disconnect.revokedAt !== "string"))
|
|
283
|
+
throw Error("Workspace disconnect state is invalid.");
|
|
284
|
+
if (state.disconnect.revokedAt !== null)
|
|
285
|
+
utcDay(state.disconnect.revokedAt);
|
|
286
|
+
}
|
|
287
|
+
return state;
|
|
288
|
+
}
|
|
289
|
+
catch {
|
|
290
|
+
throw Error("Workspace state is unreadable or invalid; it was not reset.");
|
|
291
|
+
}
|
|
292
|
+
finally {
|
|
293
|
+
await file.close();
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
async function atomicState(path, state) {
|
|
297
|
+
return atomicPrivateJson(path, STATE_FILE, state);
|
|
298
|
+
}
|
|
299
|
+
async function atomicPrivateJson(path, name, value) {
|
|
300
|
+
const serialized = `${workspaceCanonicalJson(value)}\n`;
|
|
301
|
+
if (Buffer.byteLength(serialized) > MAX_STATE_BYTES)
|
|
302
|
+
throw Error("Workspace state exceeds its byte bound.");
|
|
303
|
+
const temporary = join(path, `.workspace-device-${randomBytes(16).toString("hex")}.tmp`);
|
|
304
|
+
const handle = await open(temporary, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | noFollow, 0o600);
|
|
305
|
+
try {
|
|
306
|
+
await handle.writeFile(serialized);
|
|
307
|
+
await handle.sync();
|
|
308
|
+
await handle.close();
|
|
309
|
+
await rename(temporary, join(path, name));
|
|
310
|
+
const directory = await open(path, constants.O_RDONLY | noFollow);
|
|
311
|
+
try {
|
|
312
|
+
await directory.sync();
|
|
313
|
+
}
|
|
314
|
+
finally {
|
|
315
|
+
await directory.close();
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
finally {
|
|
319
|
+
await handle.close().catch(() => undefined);
|
|
320
|
+
await unlink(temporary).catch(error => { if (!missing(error))
|
|
321
|
+
throw error; });
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
async function withState(home, callback) {
|
|
325
|
+
const path = await privateDirectory(home, true), directory = await lstat(path);
|
|
326
|
+
let lock;
|
|
327
|
+
try {
|
|
328
|
+
lock = await open(join(path, LOCK_FILE), constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | noFollow, 0o600);
|
|
329
|
+
}
|
|
330
|
+
catch {
|
|
331
|
+
throw Error("Workspace state is locked. Do not remove a lock while another Workspace command is running.");
|
|
332
|
+
}
|
|
333
|
+
const identity = await lock.stat();
|
|
334
|
+
try {
|
|
335
|
+
return await callback(await readState(path), async (state) => {
|
|
336
|
+
const current = await lstat(path);
|
|
337
|
+
if (current.dev !== directory.dev || current.ino !== directory.ino || current.isSymbolicLink())
|
|
338
|
+
throw Error("Workspace state directory changed.");
|
|
339
|
+
await atomicState(path, state);
|
|
340
|
+
}, path);
|
|
341
|
+
}
|
|
342
|
+
finally {
|
|
343
|
+
await lock.close();
|
|
344
|
+
const current = await lstat(join(path, LOCK_FILE)).catch(() => null);
|
|
345
|
+
if (current?.dev === identity.dev && current.ino === identity.ino)
|
|
346
|
+
await unlink(join(path, LOCK_FILE));
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
/** Reads only the private pairing record; never logs, a provider, or the Workspace. */
|
|
350
|
+
export async function workspaceStatus(home = homedir()) {
|
|
351
|
+
let path;
|
|
352
|
+
try {
|
|
353
|
+
path = await privateDirectory(home, false);
|
|
354
|
+
}
|
|
355
|
+
catch (error) {
|
|
356
|
+
if (missing(error))
|
|
357
|
+
return { state: "not_connected" };
|
|
358
|
+
throw error;
|
|
359
|
+
}
|
|
360
|
+
const state = await readState(path);
|
|
361
|
+
return state ? { state: "connected", origin: state.device.origin, lastAcceptedAt: state.lastAcceptedAt,
|
|
362
|
+
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" };
|
|
365
|
+
}
|
|
366
|
+
/** HTTPS native transport avoids browser Origin/Sec-Fetch headers and never follows redirects. */
|
|
367
|
+
export const workspaceTransport = (path, payload, token) => workspaceNativeRequest(path, payload, token);
|
|
368
|
+
export const WORKSPACE_DISCONNECT_PATH = "/api/workspace/machines/disconnect";
|
|
369
|
+
export const workspaceDisconnectTransport = (payload, token) => workspaceNativeRequest(WORKSPACE_DISCONNECT_PATH, payload, token);
|
|
370
|
+
export const WORKSPACE_EXCHANGE_PATH = "/api/workspace/device-enrollment/exchange";
|
|
371
|
+
export const workspaceExchangeTransport = payload => workspaceNativeRequest(WORKSPACE_EXCHANGE_PATH, payload);
|
|
372
|
+
function workspaceNativeRequest(path, payload, token) {
|
|
373
|
+
return new Promise((resolveResult, reject) => {
|
|
374
|
+
if (![WORKSPACE_FACTS_PATH, WORKSPACE_EXCHANGE_PATH, WORKSPACE_DISCONNECT_PATH].includes(path) || Buffer.byteLength(payload) > MAX_BYTES
|
|
375
|
+
|| (path !== WORKSPACE_EXCHANGE_PATH ? !bytes(token, 32) : token !== undefined)) {
|
|
376
|
+
reject(Error("Workspace request is invalid."));
|
|
377
|
+
return;
|
|
378
|
+
}
|
|
379
|
+
let settled = false;
|
|
380
|
+
const finish = (error, result) => {
|
|
381
|
+
if (settled)
|
|
382
|
+
return;
|
|
383
|
+
settled = true;
|
|
384
|
+
clearTimeout(timer);
|
|
385
|
+
if (error)
|
|
386
|
+
reject(error);
|
|
387
|
+
else
|
|
388
|
+
resolveResult(result);
|
|
389
|
+
};
|
|
390
|
+
const request = httpsRequest(`${WORKSPACE_ORIGIN}${path}`, { method: "POST", headers: {
|
|
391
|
+
"content-type": "application/json", "content-length": Buffer.byteLength(payload), ...(token ? { authorization: `Device ${token}` } : {}),
|
|
392
|
+
} }, response => {
|
|
393
|
+
const parts = [];
|
|
394
|
+
let size = 0;
|
|
395
|
+
response.on("data", (chunk) => {
|
|
396
|
+
size += chunk.length;
|
|
397
|
+
if (size > MAX_BYTES) {
|
|
398
|
+
response.destroy();
|
|
399
|
+
request.destroy();
|
|
400
|
+
finish(Error("Workspace response exceeded its bound."));
|
|
401
|
+
}
|
|
402
|
+
else
|
|
403
|
+
parts.push(chunk);
|
|
404
|
+
});
|
|
405
|
+
response.on("error", () => finish(Error("Workspace response was interrupted.")));
|
|
406
|
+
response.on("end", () => {
|
|
407
|
+
if (settled)
|
|
408
|
+
return;
|
|
409
|
+
const status = response.statusCode ?? 0;
|
|
410
|
+
if (status >= 300 && status < 400) {
|
|
411
|
+
finish(Error("Workspace redirect refused."));
|
|
412
|
+
return;
|
|
413
|
+
}
|
|
414
|
+
try {
|
|
415
|
+
finish(undefined, { status, body: JSON.parse(Buffer.concat(parts).toString("utf8")) });
|
|
416
|
+
}
|
|
417
|
+
catch {
|
|
418
|
+
finish(Error("Workspace response was not valid JSON."));
|
|
419
|
+
}
|
|
420
|
+
});
|
|
421
|
+
});
|
|
422
|
+
const timer = setTimeout(() => { request.destroy(); finish(Error("Workspace request timed out; its outcome is unknown.")); }, 30000);
|
|
423
|
+
request.on("error", () => finish(Error("Workspace request failed; its outcome is unknown.")));
|
|
424
|
+
request.end(payload);
|
|
425
|
+
});
|
|
426
|
+
}
|
|
427
|
+
/** Consent precedes durable preparation. A pending batch is never replaced with a fresh nonce. */
|
|
428
|
+
export async function prepareWorkspacePush(options) {
|
|
429
|
+
return withState(options.home ?? homedir(), async (state, save) => {
|
|
430
|
+
if (!state)
|
|
431
|
+
throw Error("This machine is not connected to a Workspace.");
|
|
432
|
+
if (state.disconnect)
|
|
433
|
+
throw Error("Disconnect is pending or complete; no facts may be sent.");
|
|
434
|
+
if (state.pending) {
|
|
435
|
+
if (state.pending.state === "refused")
|
|
436
|
+
return { state: "pending" };
|
|
437
|
+
const coverage = { facts: state.pending.envelope.facts, excludedCalls: 0, incompleteComponents: 0, reasons: ["retained_pending_batch"] };
|
|
438
|
+
if (!await options.confirm(workspaceCanonicalJson(state.pending.envelope), coverage))
|
|
439
|
+
return { state: "cancelled", coverage };
|
|
440
|
+
// Explicit consent permits only the identical retained envelope to be attempted again.
|
|
441
|
+
if (state.pending.state === "uncertain")
|
|
442
|
+
await save({ ...state, pending: { ...state.pending, state: "ready" } });
|
|
443
|
+
return { state: "prepared", coverage };
|
|
444
|
+
}
|
|
445
|
+
const coverage = aggregateWorkspaceFacts(options.calls, state.facts, state.device.keyId);
|
|
446
|
+
if (coverage.excludedCalls)
|
|
447
|
+
throw Error("Incomplete source identities cannot replace complete daily facts.");
|
|
448
|
+
const today = utcDay(options.generatedAt), authority = Math.max(Date.parse(state.device.collectionNotBefore), Date.parse(state.device.authorityStartsAt));
|
|
449
|
+
const eligible = coverage.facts.filter(fact => fact.day < today && Date.parse(`${fact.day}T00:00:00.000Z`) >= authority
|
|
450
|
+
&& Date.parse(`${fact.day}T00:00:00.000Z`) + 86400000 <= Date.parse(state.device.expiresAt));
|
|
451
|
+
if (eligible.length !== coverage.facts.length)
|
|
452
|
+
coverage.reasons.push("outside_closed_authorized_days");
|
|
453
|
+
coverage.facts = eligible;
|
|
454
|
+
if (!coverage.facts.length)
|
|
455
|
+
return { state: "unchanged", coverage };
|
|
456
|
+
const envelope = buildWorkspaceEnvelope(state.device, coverage.facts.slice(0, MAX_FACTS), options.generatedAt);
|
|
457
|
+
if (!await options.confirm(workspaceCanonicalJson(envelope), coverage))
|
|
458
|
+
return { state: "cancelled", coverage };
|
|
459
|
+
await save({ ...state, pending: { envelope, state: "ready", refusal: null } });
|
|
460
|
+
return { state: "prepared", coverage };
|
|
461
|
+
});
|
|
462
|
+
}
|
|
463
|
+
/** One attempt. Uncertain/refused batches remain retained and cannot silently be resubmitted. */
|
|
464
|
+
export async function sendWorkspacePending(options) {
|
|
465
|
+
return withState(options.home ?? homedir(), async (state, save) => {
|
|
466
|
+
if (!state?.pending)
|
|
467
|
+
throw Error("No prepared Workspace batch exists.");
|
|
468
|
+
if (state.disconnect)
|
|
469
|
+
throw Error("Disconnect is pending or complete; no facts may be sent.");
|
|
470
|
+
if (state.pending.state !== "ready")
|
|
471
|
+
throw Error("This batch already has a result or an uncertain outcome; reconcile it before another push.");
|
|
472
|
+
const envelope = state.pending.envelope;
|
|
473
|
+
const dispatched = { ...state, pending: { ...state.pending, state: "uncertain" } };
|
|
474
|
+
await save(dispatched);
|
|
475
|
+
let result;
|
|
476
|
+
try {
|
|
477
|
+
result = await (options.transport ?? workspaceTransport)(WORKSPACE_FACTS_PATH, workspaceCanonicalJson(envelope), state.device.token);
|
|
478
|
+
}
|
|
479
|
+
catch {
|
|
480
|
+
return { state: "unconfirmed" };
|
|
481
|
+
}
|
|
482
|
+
try {
|
|
483
|
+
const receipt = result.body;
|
|
484
|
+
if (result.status === 200) {
|
|
485
|
+
exactKeys(receipt, ["state", "batchId", "manifestHash", "factCount", "introduced", "replaced", "unchanged", "acceptedAt"]);
|
|
486
|
+
if (receipt.state !== "accepted" || receipt.batchId !== envelope.batchId || receipt.manifestHash !== envelope.manifestHash
|
|
487
|
+
|| receipt.factCount !== envelope.facts.length || typeof receipt.acceptedAt !== "string")
|
|
488
|
+
throw Error("receipt_mismatch");
|
|
489
|
+
utcDay(receipt.acceptedAt);
|
|
490
|
+
const counts = [receipt.introduced, receipt.replaced, receipt.unchanged];
|
|
491
|
+
if (counts.some(value => !Number.isSafeInteger(value) || value < 0)
|
|
492
|
+
|| counts.reduce((sum, value) => sum + value, 0) !== envelope.facts.length)
|
|
493
|
+
throw Error("receipt_counts");
|
|
494
|
+
const facts = { ...state.facts };
|
|
495
|
+
for (const fact of envelope.facts)
|
|
496
|
+
facts[workspaceFactKey(fact)] = { revision: fact.factRevision, contentHash: workspaceFactContentHash(fact) };
|
|
497
|
+
await save({ ...state, device: { ...state.device, sequence: envelope.sequence }, facts, pending: null, lastAcceptedAt: receipt.acceptedAt });
|
|
498
|
+
return { state: "accepted", factCount: envelope.facts.length };
|
|
499
|
+
}
|
|
500
|
+
if (result.status === 409) {
|
|
501
|
+
exactKeys(receipt, ["state", "reason"]);
|
|
502
|
+
if (receipt.state !== "refused" || !["invalid", "conflict", "stale", "expired"].includes(String(receipt.reason)))
|
|
503
|
+
throw Error("receipt_refusal");
|
|
504
|
+
await save({ ...dispatched, pending: { envelope, state: "refused", refusal: receipt.reason } });
|
|
505
|
+
return { state: "refused" };
|
|
506
|
+
}
|
|
507
|
+
}
|
|
508
|
+
catch {
|
|
509
|
+
return { state: "unconfirmed" };
|
|
510
|
+
}
|
|
511
|
+
return { state: "unconfirmed" };
|
|
512
|
+
});
|
|
513
|
+
}
|
|
514
|
+
const ENROLLMENT_FILE = "workspace-enrollment.json";
|
|
515
|
+
export function encodeWorkspaceBundle(value) {
|
|
516
|
+
return Buffer.from(workspaceCanonicalJson(value), "utf8").toString("base64url");
|
|
517
|
+
}
|
|
518
|
+
function parseWorkspaceResponseBundle(code) {
|
|
519
|
+
if (code.length > 32768 || !/^[A-Za-z0-9_-]+$/.test(code))
|
|
520
|
+
throw Error("The Workspace response bundle is invalid.");
|
|
521
|
+
const decoded = Buffer.from(code, "base64url");
|
|
522
|
+
if (decoded.toString("base64url") !== code)
|
|
523
|
+
throw Error("The Workspace response bundle is not canonical.");
|
|
524
|
+
let value;
|
|
525
|
+
try {
|
|
526
|
+
value = JSON.parse(decoded.toString("utf8"));
|
|
527
|
+
}
|
|
528
|
+
catch {
|
|
529
|
+
throw Error("The Workspace response bundle is invalid.");
|
|
530
|
+
}
|
|
531
|
+
if (Buffer.from(workspaceCanonicalJson(value)).toString("base64url") !== code)
|
|
532
|
+
throw Error("The Workspace response bundle is not canonical.");
|
|
533
|
+
exactKeys(value, ["schemaVersion", "kind", "origin", "intent", "binding", "receipt", "confirmation"]);
|
|
534
|
+
if (value.schemaVersion !== "1" || value.kind !== "tilden_machine_enrollment_response" || value.origin !== WORKSPACE_ORIGIN)
|
|
535
|
+
throw Error("The Workspace response bundle has a different origin or purpose.");
|
|
536
|
+
return value;
|
|
537
|
+
}
|
|
538
|
+
async function readEnrollment(path) {
|
|
539
|
+
let handle;
|
|
540
|
+
try {
|
|
541
|
+
handle = await open(join(path, ENROLLMENT_FILE), constants.O_RDONLY | noFollow);
|
|
542
|
+
}
|
|
543
|
+
catch (error) {
|
|
544
|
+
if (missing(error))
|
|
545
|
+
return null;
|
|
546
|
+
throw Error("Workspace enrollment could not be opened safely.");
|
|
547
|
+
}
|
|
548
|
+
try {
|
|
549
|
+
const info = await handle.stat();
|
|
550
|
+
if (!info.isFile() || info.nlink !== 1 || info.mode & 0o077 || info.size > 16384 || process.getuid && info.uid !== process.getuid())
|
|
551
|
+
throw Error("Workspace enrollment is not a private regular file.");
|
|
552
|
+
const state = JSON.parse(await handle.readFile("utf8"));
|
|
553
|
+
exactKeys(state, ["version", "request", "privateKeyPem", "state"]);
|
|
554
|
+
exactKeys(state.request, ["schemaVersion", "kind", "origin", "publicKey", "keyId", "requestId"]);
|
|
555
|
+
if (state.version !== 1 || !["prepared", "exchange_uncertain"].includes(state.state)
|
|
556
|
+
|| state.request.schemaVersion !== "1" || state.request.kind !== "tilden_machine_enrollment_request"
|
|
557
|
+
|| state.request.origin !== WORKSPACE_ORIGIN || !bytes(state.request.publicKey, 32)
|
|
558
|
+
|| ![state.request.keyId, state.request.requestId].every(value => REF.test(value))
|
|
559
|
+
|| createPublicKey(state.privateKeyPem).export({ format: "jwk" }).x !== state.request.publicKey)
|
|
560
|
+
throw Error("Workspace enrollment state is invalid.");
|
|
561
|
+
return state;
|
|
562
|
+
}
|
|
563
|
+
catch {
|
|
564
|
+
throw Error("Workspace enrollment state is unreadable or invalid; it was not reset.");
|
|
565
|
+
}
|
|
566
|
+
finally {
|
|
567
|
+
await handle.close();
|
|
568
|
+
}
|
|
569
|
+
}
|
|
570
|
+
/** Generates native custody before displaying public enrollment material. No network call. */
|
|
571
|
+
export async function beginWorkspaceEnrollment(home = homedir()) {
|
|
572
|
+
return withState(home, async (device, _save, path) => {
|
|
573
|
+
if (device)
|
|
574
|
+
throw Error("This machine is already connected. Disconnect it in the Workspace before pairing it again.");
|
|
575
|
+
const retained = await readEnrollment(path);
|
|
576
|
+
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) };
|
|
580
|
+
const pair = generateKeyPairSync("ed25519"), ref = () => `oref_${randomBytes(32).toString("base64url")}`;
|
|
581
|
+
const request = { schemaVersion: "1", kind: "tilden_machine_enrollment_request", origin: WORKSPACE_ORIGIN,
|
|
582
|
+
publicKey: pair.publicKey.export({ format: "jwk" }).x, keyId: ref(), requestId: ref() };
|
|
583
|
+
await atomicPrivateJson(path, ENROLLMENT_FILE, { version: 1, request,
|
|
584
|
+
privateKeyPem: pair.privateKey.export({ format: "pem", type: "pkcs8" }).toString(), state: "prepared" });
|
|
585
|
+
return { request, bundle: encodeWorkspaceBundle(request) };
|
|
586
|
+
});
|
|
587
|
+
}
|
|
588
|
+
export function validateWorkspaceEnrollmentResponse(request, response, now) {
|
|
589
|
+
utcDay(now);
|
|
590
|
+
const { intent, binding, receipt, confirmation } = response;
|
|
591
|
+
exactKeys(intent, ["publicKey", "keyId", "requestId", "localSourceInstanceRef", "policyRevision", "readerRevision", "projectAdmissionRevision", "collectionNotBefore", "authorityStartsAt", "grantExpiresAt"]);
|
|
592
|
+
exactKeys(binding, ["tenantId", "policy", "projectId", "sourceProjectRef"]);
|
|
593
|
+
if (typeof binding.tenantId !== "string" || !/^[A-Za-z0-9][A-Za-z0-9._:-]{0,254}$/.test(binding.tenantId))
|
|
594
|
+
throw Error("The selected tenant reference is invalid.");
|
|
595
|
+
exactKeys(receipt, ["state", "challengeId", "requestHash", "grantId", "deviceId", "policy", "projectId", "projectRevision", "sourceProjectRef", "collectionNotBefore", "authorityStartsAt", "grantExpiresAt", "nonce", "pairingCode"]);
|
|
596
|
+
exactKeys(confirmation, ["state", "challengeId"]);
|
|
597
|
+
for (const key of ["publicKey", "keyId", "requestId"])
|
|
598
|
+
if (intent[key] !== request[key])
|
|
599
|
+
throw Error("The response belongs to a different native enrollment request.");
|
|
600
|
+
const refs = [intent.keyId, intent.requestId, intent.localSourceInstanceRef, intent.policyRevision, intent.readerRevision,
|
|
601
|
+
intent.projectAdmissionRevision, receipt.grantId, receipt.deviceId, receipt.projectId, receipt.projectRevision, receipt.sourceProjectRef];
|
|
602
|
+
if (!refs.every(value => REF.test(value)) || !bytes(receipt.nonce, 32) || !bytes(receipt.pairingCode, 16))
|
|
603
|
+
throw Error("Enrollment receipt contains invalid references.");
|
|
604
|
+
for (const value of [intent.collectionNotBefore, intent.authorityStartsAt, intent.grantExpiresAt])
|
|
605
|
+
utcDay(value);
|
|
606
|
+
const requestHash = workspaceDomainHash("tilden:device-enrollment-request:v1", { ...intent, challengeId: intent.requestId,
|
|
607
|
+
deviceId: receipt.deviceId, grantId: receipt.grantId, nonceHash: sha(receipt.nonce), pairingCodeHash: sha(receipt.pairingCode) });
|
|
608
|
+
if (receipt.state !== "prepared" || confirmation.state !== "confirmed" || receipt.challengeId !== intent.requestId
|
|
609
|
+
|| confirmation.challengeId !== receipt.challengeId || receipt.requestHash !== requestHash
|
|
610
|
+
|| receipt.policy.active !== true || receipt.policy.consentRevisionRef !== intent.policyRevision
|
|
611
|
+
|| workspaceCanonicalJson(receipt.policy) !== workspaceCanonicalJson(binding.policy)
|
|
612
|
+
|| receipt.projectId !== binding.projectId || receipt.sourceProjectRef !== binding.sourceProjectRef
|
|
613
|
+
|| receipt.projectRevision !== intent.projectAdmissionRevision || receipt.collectionNotBefore !== intent.collectionNotBefore
|
|
614
|
+
|| receipt.authorityStartsAt !== intent.authorityStartsAt || receipt.grantExpiresAt !== intent.grantExpiresAt
|
|
615
|
+
|| intent.authorityStartsAt > intent.collectionNotBefore || intent.collectionNotBefore >= intent.grantExpiresAt
|
|
616
|
+
|| intent.grantExpiresAt <= now)
|
|
617
|
+
throw Error("The prepared enrollment does not match its native request and browser confirmation.");
|
|
618
|
+
return workspaceCanonicalJson({ purpose: "device_enrollment_v1", challengeId: receipt.challengeId,
|
|
619
|
+
nonce: receipt.nonce, keyId: intent.keyId, requestHash });
|
|
620
|
+
}
|
|
621
|
+
/** Exchange is marked uncertain durably before dispatch and is never retried implicitly. */
|
|
622
|
+
export async function finishWorkspaceEnrollment(options) {
|
|
623
|
+
return withState(options.home ?? homedir(), async (device, save, path) => {
|
|
624
|
+
if (device)
|
|
625
|
+
throw Error("This machine is already connected.");
|
|
626
|
+
const retained = await readEnrollment(path);
|
|
627
|
+
if (!retained || retained.state !== "prepared")
|
|
628
|
+
throw Error("No unused native enrollment exists. Do not replay a completed or uncertain exchange.");
|
|
629
|
+
const response = parseWorkspaceResponseBundle(options.bundle);
|
|
630
|
+
const proof = validateWorkspaceEnrollmentResponse(retained.request, response, options.now);
|
|
631
|
+
if (!await options.confirm({ origin: WORKSPACE_ORIGIN, collectionNotBefore: response.intent.collectionNotBefore,
|
|
632
|
+
grantExpiresAt: response.intent.grantExpiresAt, localSourceInstanceRef: response.intent.localSourceInstanceRef,
|
|
633
|
+
tenantId: response.binding.tenantId, projectId: response.binding.projectId, sourceProjectRef: response.binding.sourceProjectRef, policy: response.binding.policy }))
|
|
634
|
+
return { state: "cancelled" };
|
|
635
|
+
const body = workspaceCanonicalJson({ challengeId: response.receipt.challengeId, nonce: response.receipt.nonce,
|
|
636
|
+
signature: sign(null, Buffer.from(proof), retained.privateKeyPem).toString("base64url") });
|
|
637
|
+
await atomicPrivateJson(path, ENROLLMENT_FILE, { ...retained, state: "exchange_uncertain" });
|
|
638
|
+
try {
|
|
639
|
+
const result = await (options.transport ?? workspaceExchangeTransport)(body), receipt = result.body;
|
|
640
|
+
exactKeys(receipt, ["state", "deviceGrantId", "grantRevision", "deviceKeyId", "connectionEpoch", "expiresAt", "token", "tokenHashKeyVersion"]);
|
|
641
|
+
if (result.status !== 200 || receipt.state !== "enrolled" || receipt.deviceGrantId !== response.receipt.grantId
|
|
642
|
+
|| receipt.deviceKeyId !== retained.request.keyId || receipt.expiresAt !== response.intent.grantExpiresAt
|
|
643
|
+
|| typeof receipt.grantRevision !== "string" || !UINT.test(receipt.grantRevision)
|
|
644
|
+
|| typeof receipt.connectionEpoch !== "string" || !UINT.test(receipt.connectionEpoch) || !bytes(receipt.token, 32))
|
|
645
|
+
return { state: "unconfirmed" };
|
|
646
|
+
const enrolled = { origin: WORKSPACE_ORIGIN, grantId: receipt.deviceGrantId,
|
|
647
|
+
keyId: receipt.deviceKeyId, grantRevision: receipt.grantRevision, connectionEpoch: receipt.connectionEpoch,
|
|
648
|
+
token: receipt.token, privateKeyPem: retained.privateKeyPem, sequence: "0",
|
|
649
|
+
collectionNotBefore: response.intent.collectionNotBefore, authorityStartsAt: response.intent.authorityStartsAt, expiresAt: response.intent.grantExpiresAt };
|
|
650
|
+
validateDevice(enrolled);
|
|
651
|
+
await save({ version: 1, device: enrolled, facts: {}, pending: null, lastAcceptedAt: null, disconnect: null });
|
|
652
|
+
await unlink(join(path, ENROLLMENT_FILE));
|
|
653
|
+
return { state: "connected" };
|
|
654
|
+
}
|
|
655
|
+
catch {
|
|
656
|
+
return { state: "unconfirmed" };
|
|
657
|
+
}
|
|
658
|
+
});
|
|
659
|
+
}
|
|
660
|
+
/** Revocation is one native attempt. Unknown outcomes retain keys and block further writes. */
|
|
661
|
+
export async function disconnectWorkspace(options) {
|
|
662
|
+
return withState(options.home ?? homedir(), async (state, save, path) => {
|
|
663
|
+
if (!state) {
|
|
664
|
+
const enrollment = await readEnrollment(path);
|
|
665
|
+
if (!enrollment)
|
|
666
|
+
return { state: "not_paired" };
|
|
667
|
+
// No exchange can have been dispatched while this state is prepared:
|
|
668
|
+
// finishWorkspaceEnrollment persists exchange_uncertain before transport.
|
|
669
|
+
// An unsigned pasted browser receipt never overrides uncertain custody.
|
|
670
|
+
if (enrollment.state !== "prepared")
|
|
671
|
+
return { state: "unconfirmed" };
|
|
672
|
+
if (!await options.confirm("abandon_unexchanged"))
|
|
673
|
+
return { state: "cancelled" };
|
|
674
|
+
await unlink(join(path, ENROLLMENT_FILE));
|
|
675
|
+
const directory = await open(path, constants.O_RDONLY | noFollow);
|
|
676
|
+
try {
|
|
677
|
+
await directory.sync();
|
|
678
|
+
}
|
|
679
|
+
finally {
|
|
680
|
+
await directory.close();
|
|
681
|
+
}
|
|
682
|
+
return { state: "abandoned" };
|
|
683
|
+
}
|
|
684
|
+
if (state.disconnect?.state === "uncertain")
|
|
685
|
+
return { state: "unconfirmed" };
|
|
686
|
+
if (!await options.confirm("revoke"))
|
|
687
|
+
return { state: "cancelled" };
|
|
688
|
+
if (!state.disconnect) {
|
|
689
|
+
const proof = { purpose: "machine_disconnect_v1", grantId: state.device.grantId,
|
|
690
|
+
grantRevision: state.device.grantRevision, connectionEpoch: state.device.connectionEpoch,
|
|
691
|
+
requestId: `oref_${randomBytes(32).toString("base64url")}` };
|
|
692
|
+
const body = { ...proof, publicKey: createPublicKey(state.device.privateKeyPem).export({ format: "jwk" }).x,
|
|
693
|
+
signature: sign(null, Buffer.from(workspaceCanonicalJson(proof)), state.device.privateKeyPem).toString("base64url") };
|
|
694
|
+
const uncertain = { ...state, disconnect: { state: "uncertain", requestId: proof.requestId, revokedAt: null } };
|
|
695
|
+
await save(uncertain);
|
|
696
|
+
try {
|
|
697
|
+
const result = await (options.transport ?? workspaceDisconnectTransport)(workspaceCanonicalJson(body), state.device.token);
|
|
698
|
+
const receipt = result.body;
|
|
699
|
+
exactKeys(receipt, ["state", "grantId", "connectionEpoch", "revokedAt"]);
|
|
700
|
+
if (result.status !== 200 || receipt.state !== "revoked" || receipt.grantId !== proof.grantId
|
|
701
|
+
|| receipt.connectionEpoch !== String(BigInt(proof.connectionEpoch) + 1n) || typeof receipt.revokedAt !== "string")
|
|
702
|
+
return { state: "unconfirmed" };
|
|
703
|
+
utcDay(receipt.revokedAt);
|
|
704
|
+
await save({ ...uncertain, disconnect: { state: "revoked", requestId: proof.requestId, revokedAt: receipt.revokedAt } });
|
|
705
|
+
}
|
|
706
|
+
catch {
|
|
707
|
+
return { state: "unconfirmed" };
|
|
708
|
+
}
|
|
709
|
+
}
|
|
710
|
+
// A durable accepted receipt precedes removal, so a crash cannot authorize another request.
|
|
711
|
+
for (const name of [ENROLLMENT_FILE, STATE_FILE]) {
|
|
712
|
+
const target = join(path, name), info = await lstat(target).catch(error => { if (missing(error))
|
|
713
|
+
return null; throw error; });
|
|
714
|
+
if (info) {
|
|
715
|
+
if (!info.isFile() || info.isSymbolicLink() || info.nlink !== 1 || info.mode & 0o077 || process.getuid && info.uid !== process.getuid())
|
|
716
|
+
throw Error("Workspace pairing file is not private; no link target was removed.");
|
|
717
|
+
await unlink(target);
|
|
718
|
+
}
|
|
719
|
+
}
|
|
720
|
+
const directory = await open(path, constants.O_RDONLY | noFollow);
|
|
721
|
+
try {
|
|
722
|
+
await directory.sync();
|
|
723
|
+
}
|
|
724
|
+
finally {
|
|
725
|
+
await directory.close();
|
|
726
|
+
}
|
|
727
|
+
return { state: "revoked" };
|
|
728
|
+
});
|
|
729
|
+
}
|
|
730
|
+
//# sourceMappingURL=workspaceConnect.js.map
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "ai-spend-agent",
|
|
3
|
-
"version": "0.9.
|
|
3
|
+
"version": "0.9.8",
|
|
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.
|
|
59
|
-
"@agent-finops/report": "0.9.
|
|
58
|
+
"@agent-finops/core": "0.9.8",
|
|
59
|
+
"@agent-finops/report": "0.9.8",
|
|
60
60
|
"yocto-spinner": "^1.2.0"
|
|
61
61
|
}
|
|
62
62
|
}
|