worktrust 0.0.0-stage → 0.5.0
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/LICENSE +21 -0
- package/README.md +99 -2
- package/SECURITY.md +42 -0
- package/count-behaviour.mjs +1430 -0
- package/log-session.mjs +858 -0
- package/package.json +41 -4
- package/setup-mcp.mjs +140 -0
- package/worktrust.mjs +470 -0
package/worktrust.mjs
ADDED
|
@@ -0,0 +1,470 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* WorkTrust on a computer, in one command — like `gh auth login` (2026-10-03).
|
|
4
|
+
*
|
|
5
|
+
* npx worktrust show the plan, ask, approve this computer in the app, couple;
|
|
6
|
+
* on a computer already coupled: what is coupled here
|
|
7
|
+
* npx worktrust --dry-run show the plan only; nothing is written, nothing is sent
|
|
8
|
+
* npx worktrust --yes no question (for scripts); the computer is still approved in the app
|
|
9
|
+
* npx worktrust --device a computer without a browser (SSH): type a code on another device
|
|
10
|
+
* npx worktrust connect couple again, also on a computer already coupled
|
|
11
|
+
* npx worktrust disconnect show what comes out, ask, take it out again
|
|
12
|
+
* npx worktrust status what is coupled here
|
|
13
|
+
*
|
|
14
|
+
* WHAT RUNS. From the npm package, everything that runs is in the package: this file,
|
|
15
|
+
* `setup-mcp.mjs` (gives every AI app here the WorkTrust door), `log-session.mjs` (the session hook)
|
|
16
|
+
* and `count-behaviour.mjs` (the local counter, pinned: never replaced from the network). Nothing is
|
|
17
|
+
* downloaded. Run as a single file from WorkTrust's site instead, it fetches those three from that
|
|
18
|
+
* same site, and says so before it asks.
|
|
19
|
+
*
|
|
20
|
+
* HOW THE ACCOUNT IS DECIDED. By default this terminal listens on 127.0.0.1 for one answer and holds
|
|
21
|
+
* a PKCE secret. Your browser opens WorkTrust, you approve this computer there (signed in, second
|
|
22
|
+
* factor), and the browser is sent back to 127.0.0.1 with a one-time code that only this terminal,
|
|
23
|
+
* with its secret, can exchange for the key. Nothing is typed, and a link forwarded to someone else
|
|
24
|
+
* approves nothing they can collect. Without a browser (`--device`, or over SSH) the terminal shows
|
|
25
|
+
* a code that you type yourself at app.worktrust.io/connect/computer.
|
|
26
|
+
*
|
|
27
|
+
* WHERE THE KEY LIVES. In one file only you can read (~/.worktrust/key.json, mode 600, folder 700).
|
|
28
|
+
* No keychain is touched. The AI apps never hold it:
|
|
29
|
+
* each runs `worktrust.mjs mcp`, a small local bridge that reads the key and speaks to WorkTrust for
|
|
30
|
+
* them, and the session hook runs through `worktrust.mjs hook` the same way. `--direct` writes the
|
|
31
|
+
* key into each app's settings instead (for an app that cannot run a local command).
|
|
32
|
+
*
|
|
33
|
+
* THE KEY ONLY WORKS ON THIS COMPUTER. connect makes an Ed25519 key pair; the public half goes to
|
|
34
|
+
* WorkTrust, the private half stays in the key file. Every call the bridge, the hook and
|
|
35
|
+
* the counter make carries a fresh signature (`WorkTrust-Proof`), and WorkTrust refuses a call
|
|
36
|
+
* through this key without one: a copied key alone is worth nothing.
|
|
37
|
+
*
|
|
38
|
+
* THE KEY RENEWS ITSELF EVERY WEEK. The bridge or the hook makes a new secret on this computer, sends
|
|
39
|
+
* only its hash, signed with the current key, and stores the new one; a copy of last week's key stops
|
|
40
|
+
* working. If the answer is lost on the way, the next run finds out which of the two still works.
|
|
41
|
+
*
|
|
42
|
+
* WHAT IS SENT. To pair: this computer's system (macOS, Windows, Linux) and host name. After: what
|
|
43
|
+
* the scripts send, which is metadata only — no prompt, no answer, no code, no file path. The key
|
|
44
|
+
* reaches the scripts in their environment, never on a command line, and is written only into the
|
|
45
|
+
* AI apps' own settings, where an MCP key always lives.
|
|
46
|
+
*
|
|
47
|
+
* No dependencies: Node 18 or later.
|
|
48
|
+
*/
|
|
49
|
+
import { spawn } from "node:child_process";
|
|
50
|
+
import { createHash, generateKeyPairSync, randomBytes, sign } from "node:crypto";
|
|
51
|
+
import { createServer } from "node:http";
|
|
52
|
+
import { chmodSync, closeSync, copyFileSync, existsSync, mkdirSync, openSync, readFileSync, renameSync, rmSync, statSync, writeFileSync } from "node:fs";
|
|
53
|
+
import { homedir, hostname, platform } from "node:os";
|
|
54
|
+
import { dirname, join } from "node:path";
|
|
55
|
+
import { createInterface } from "node:readline/promises";
|
|
56
|
+
import { createInterface as lines } from "node:readline";
|
|
57
|
+
import { fileURLToPath } from "node:url";
|
|
58
|
+
|
|
59
|
+
const args = process.argv.slice(2);
|
|
60
|
+
const VALUED = ["--name", "--origin", "--url"];
|
|
61
|
+
const command = args.find((arg, at) => !arg.startsWith("--") && !(at > 0 && VALUED.includes(args[at - 1]))) ?? (args.includes("--help") ? "help" : "default");
|
|
62
|
+
const flag = (name) => { const at = args.indexOf(`--${name}`); return at >= 0 ? args[at + 1] : undefined; };
|
|
63
|
+
const has = (name) => args.includes(`--${name}`);
|
|
64
|
+
const ORIGIN = (flag("origin") ?? process.env.WORKTRUST_ORIGIN ?? "https://app.worktrust.io").replace(/\/$/, "");
|
|
65
|
+
const MCP = flag("url") ?? process.env.WORKTRUST_MCP_URL ?? `${ORIGIN}/api/mcp`;
|
|
66
|
+
const HOME_DIR = join(homedir(), ".worktrust");
|
|
67
|
+
const HERE = dirname(fileURLToPath(import.meta.url));
|
|
68
|
+
const SCRIPTS = ["setup-mcp.mjs", "log-session.mjs", "count-behaviour.mjs"];
|
|
69
|
+
const BUNDLED = SCRIPTS.every((name) => existsSync(join(HERE, name)));
|
|
70
|
+
const PLACEHOLDER = `wt_${"0".repeat(43)}`;
|
|
71
|
+
const say = (line = "") => process.stdout.write(`${line}\n`);
|
|
72
|
+
const fail = (line) => { process.stderr.write(`worktrust: ${line}\n`); process.exit(1); };
|
|
73
|
+
const OS = { darwin: "macos", win32: "windows", linux: "linux" }[platform()] ?? "other";
|
|
74
|
+
const OS_NAME = { macos: "macOS", windows: "Windows", linux: "Linux", other: "this system" }[OS];
|
|
75
|
+
|
|
76
|
+
if (Number(process.versions.node.split(".")[0]) < 18) fail("needs Node 18 or later (fetch is built in from 18).");
|
|
77
|
+
|
|
78
|
+
async function post(path, body) {
|
|
79
|
+
const response = await fetch(`${ORIGIN}${path}`, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(body) });
|
|
80
|
+
return { status: response.status, json: await response.json().catch(() => ({})) };
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** The three scripts: from the package when it carries them; otherwise from the same site, once, into ~/.worktrust. */
|
|
84
|
+
async function scripts() {
|
|
85
|
+
if (BUNDLED) return Object.fromEntries(SCRIPTS.map((name) => [name, join(HERE, name)]));
|
|
86
|
+
mkdirSync(HOME_DIR, { recursive: true, mode: 0o700 });
|
|
87
|
+
const paths = {};
|
|
88
|
+
for (const name of SCRIPTS) {
|
|
89
|
+
const response = await fetch(`${ORIGIN}/counter/${name}`);
|
|
90
|
+
if (!response.ok) fail(`could not download ${name} (${response.status}).`);
|
|
91
|
+
paths[name] = join(HOME_DIR, `${name}.download.mjs`);
|
|
92
|
+
writeFileSync(paths[name], await response.text(), { mode: 0o600 });
|
|
93
|
+
}
|
|
94
|
+
return paths;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** Run a script; its output is shown, or collected for the plan. The key travels in the environment only. */
|
|
98
|
+
function run(path, scriptArgs, token, collect = false) {
|
|
99
|
+
return new Promise((resolve) => {
|
|
100
|
+
const child = spawn(process.execPath, [path, ...scriptArgs], { stdio: collect ? ["ignore", "pipe", "pipe"] : "inherit", env: { ...process.env, WORKTRUST_MCP_URL: MCP, WORKTRUST_MCP_TOKEN: token, WORKTRUST_NODE: NODE } });
|
|
101
|
+
let out = "";
|
|
102
|
+
child.stdout?.on("data", (chunk) => { out += chunk; });
|
|
103
|
+
child.stderr?.on("data", (chunk) => { out += chunk; });
|
|
104
|
+
child.on("exit", (code) => resolve({ code: code ?? 1, out }));
|
|
105
|
+
});
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
async function ask(question) {
|
|
109
|
+
if (has("yes")) return true;
|
|
110
|
+
if (!process.stdin.isTTY) fail("not a terminal: nothing was changed. Run it in a terminal, or add --yes to agree in advance.");
|
|
111
|
+
const prompt = createInterface({ input: process.stdin, output: process.stdout });
|
|
112
|
+
const answer = (await prompt.question(` ${question} [Y/n] `)).trim().toLowerCase();
|
|
113
|
+
prompt.close();
|
|
114
|
+
return answer === "" || answer === "y" || answer === "yes";
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function openBrowser(url) {
|
|
118
|
+
const [cmd, cmdArgs] = OS === "macos" ? ["open", [url]] : OS === "windows" ? ["cmd", ["/c", "start", "", url]] : ["xdg-open", [url]];
|
|
119
|
+
try { spawn(cmd, cmdArgs, { stdio: "ignore", detached: true }).on("error", () => {}).unref(); } catch { /* the code can be typed */ }
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/** The apps setup-mcp found and the file each would change, from its own dry run. */
|
|
123
|
+
const planLines = (out, replacing = false) => out.split("\n").filter((line) => /^\s+[+✓!−-] /.test(line)).map((line) => {
|
|
124
|
+
const text = line.trim()
|
|
125
|
+
// connect takes the old entry out and writes the new key, so "already there" means "replaced".
|
|
126
|
+
.replace(/^✓ (.+?): already names the door \((.+)\)$/, replacing ? "↻ $1: the WorkTrust entry is replaced ($2)" : "✓ $1: already names the door ($2)")
|
|
127
|
+
.replace(/^✓ Claude Code: already coupled$/, replacing ? "↻ Claude Code: the WorkTrust entry is replaced (`claude mcp`)" : "✓ Claude Code: already coupled")
|
|
128
|
+
.replace(/^! Claude Code: run `(claude mcp remove[^`]*)`$/, "− Claude Code: `$1`");
|
|
129
|
+
return ` ${text}`;
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
/** No browser to send back: a session over SSH, a Linux box without a display, or asked for. */
|
|
133
|
+
const useDevice = () => has("device") || has("no-browser") || Boolean(process.env.SSH_CONNECTION || process.env.SSH_TTY) || (OS === "linux" && !process.env.DISPLAY && !process.env.WAYLAND_DISPLAY);
|
|
134
|
+
|
|
135
|
+
const DONE_PAGE = "<!doctype html><meta charset=utf-8><title>WorkTrust</title><body style=\"font:16px system-ui;margin:4rem auto;max-width:32rem;padding:0 1rem\"><h1 style=\"font-size:1.25rem\">This computer is connected</h1><p>Return to your terminal: it finishes setting up by itself. You can close this tab.</p>";
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* THE DEFAULT: approve in the browser, come back to this computer. The terminal listens on 127.0.0.1
|
|
139
|
+
* (a random free port, this machine only) for exactly one answer that carries the state it chose,
|
|
140
|
+
* and exchanges the one-time code with its PKCE verifier. Ten minutes, then it gives up.
|
|
141
|
+
*/
|
|
142
|
+
async function pairByBrowser(host, extra) {
|
|
143
|
+
const extraBound = Boolean(extra.device_key);
|
|
144
|
+
const verifier = randomBytes(32).toString("base64url");
|
|
145
|
+
const challenge = createHash("sha256").update(verifier).digest("base64url");
|
|
146
|
+
const state = randomBytes(24).toString("base64url");
|
|
147
|
+
let deliver;
|
|
148
|
+
const answer = new Promise((resolve) => { deliver = resolve; });
|
|
149
|
+
const server = createServer((request, response) => {
|
|
150
|
+
const url = new URL(request.url ?? "/", "http://127.0.0.1");
|
|
151
|
+
if (url.pathname !== "/callback" || url.searchParams.get("state") !== state || !url.searchParams.get("code")) { response.writeHead(404).end(); return; }
|
|
152
|
+
response.writeHead(200, { "content-type": "text/html; charset=utf-8", "cache-control": "no-store" }).end(DONE_PAGE);
|
|
153
|
+
deliver(url.searchParams.get("code"));
|
|
154
|
+
});
|
|
155
|
+
await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve));
|
|
156
|
+
const port = server.address().port;
|
|
157
|
+
const pairing = await post("/api/cli/pair", { host, os: OS, flow: "loopback", code_challenge: challenge, port, state, ...extra }).catch(() => null);
|
|
158
|
+
if (!pairing) { server.close(); fail(`cannot reach ${ORIGIN}.`); }
|
|
159
|
+
if (pairing.status !== 200) { server.close(); fail(pairing.json.error === "rate_limited" ? "too many attempts from this network; wait a minute." : `pairing refused (${pairing.json.error ?? pairing.status}).`); }
|
|
160
|
+
const { device_code: secret, authorize_url: url, user_code: shown, expires_in: lifetime = 600, features = [] } = pairing.json;
|
|
161
|
+
if (!features.includes("loopback")) { server.close(); fail(`${ORIGIN} is older than this command. Use the command WorkTrust shows under Sources → Add a computer.`); }
|
|
162
|
+
say();
|
|
163
|
+
say(` Your code: ${shown} (the WorkTrust page shows the same; approve only if it matches)`);
|
|
164
|
+
say(" Approve this computer in your browser (opening it now).");
|
|
165
|
+
say(` If it does not open, open this link on THIS computer: ${url}`);
|
|
166
|
+
openBrowser(url);
|
|
167
|
+
const timer = setTimeout(() => deliver(null), lifetime * 1000);
|
|
168
|
+
const code = await answer;
|
|
169
|
+
clearTimeout(timer);
|
|
170
|
+
server.close();
|
|
171
|
+
if (!code) fail("no approval arrived in time. Run `worktrust connect` again.");
|
|
172
|
+
const exchange = await post("/api/cli/token", { device_code: secret, code, code_verifier: verifier }).catch(() => null);
|
|
173
|
+
if (!exchange || exchange.status !== 200 || !exchange.json.token) fail(`the approval could not be exchanged (${exchange?.json?.error ?? "no answer"}). Run \`worktrust connect\` again.`);
|
|
174
|
+
say(` ✓ Approved as "${exchange.json.label}"${exchange.json.account ? ` for the WorkTrust account ${exchange.json.account}` : ""}`);
|
|
175
|
+
if (exchange.json.account) say(" Not your account? Run `npx worktrust disconnect` now and revoke it in WorkTrust.");
|
|
176
|
+
if (extraBound && exchange.json.bound === false) say(" ! WorkTrust did not bind the key to this computer; it works like a key made by hand.");
|
|
177
|
+
return exchange.json.token;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/** WITHOUT A BROWSER: a code you type yourself on another device. Never a link with the code in it. */
|
|
181
|
+
async function pairByCode(host, extra) {
|
|
182
|
+
const pairing = await post("/api/cli/pair", { host, os: OS, flow: "device", ...extra }).catch(() => fail(`cannot reach ${ORIGIN}.`));
|
|
183
|
+
if (pairing.status !== 200) fail(pairing.json.error === "rate_limited" ? "too many attempts from this network; wait a minute." : `pairing refused (${pairing.json.error ?? pairing.status}).`);
|
|
184
|
+
const { user_code: code, device_code: secret, verification_uri: page, interval = 3, expires_in: lifetime = 600 } = pairing.json;
|
|
185
|
+
say();
|
|
186
|
+
say(` On any device, open ${page}`);
|
|
187
|
+
say(` and type this code: ${code}`);
|
|
188
|
+
say(" Only you should type it: never approve a code someone else sent you.");
|
|
189
|
+
const until = Date.now() + lifetime * 1000;
|
|
190
|
+
let wait = interval * 1000;
|
|
191
|
+
while (Date.now() < until) {
|
|
192
|
+
await new Promise((resolve) => setTimeout(resolve, wait));
|
|
193
|
+
const answer = await post("/api/cli/token", { device_code: secret }).catch(() => null);
|
|
194
|
+
if (!answer) continue;
|
|
195
|
+
if (answer.status === 200 && answer.json.token) {
|
|
196
|
+
say(` ✓ Approved as "${answer.json.label}"${answer.json.account ? ` for the WorkTrust account ${answer.json.account}` : ""}`);
|
|
197
|
+
if (answer.json.account) say(" Not your account? Run `npx worktrust disconnect` now and revoke it in WorkTrust.");
|
|
198
|
+
return answer.json.token;
|
|
199
|
+
}
|
|
200
|
+
if (answer.json.error === "slow_down") wait += 2000;
|
|
201
|
+
else if (answer.json.error === "expired_token") fail("the code expired. Run `worktrust connect` again.");
|
|
202
|
+
else if (answer.json.error === "invalid_grant") fail("this pairing was already used. Run `worktrust connect` again.");
|
|
203
|
+
}
|
|
204
|
+
fail("the code expired. Run `worktrust connect` again.");
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/**
|
|
208
|
+
* The node the AI apps will start the bridge with: the one on PATH (a link such as /opt/homebrew/bin/node
|
|
209
|
+
* that survives an upgrade), not this process's resolved path, which names one version and breaks
|
|
210
|
+
* with the next `brew upgrade node`.
|
|
211
|
+
*/
|
|
212
|
+
const NODE = (process.env.PATH ?? "").split(OS === "windows" ? ";" : ":").map((dir) => join(dir, OS === "windows" ? "node.exe" : "node")).find((path) => existsSync(path)) ?? process.execPath;
|
|
213
|
+
const KEY_FILE = join(HOME_DIR, "key.json");
|
|
214
|
+
const STABLE = join(HOME_DIR, "worktrust.mjs");
|
|
215
|
+
const RENEW_EVERY_MS = 7 * 24 * 3600_000;
|
|
216
|
+
const KEY_PLACE = "~/.worktrust/key.json (only you can read it)";
|
|
217
|
+
const ensureHome = () => { mkdirSync(HOME_DIR, { recursive: true, mode: 0o700 }); try { chmodSync(HOME_DIR, 0o700); } catch { /* Windows */ } };
|
|
218
|
+
|
|
219
|
+
/**
|
|
220
|
+
* THE KEY'S PLACE: one file only you can read (mode 600 in a 700 folder), holding the key, its device
|
|
221
|
+
* private key and when it was last renewed. NO KEYCHAIN (owner, 2026-10-03): a keychain tool can ask
|
|
222
|
+
* the person to "reset" a keychain it cannot find, which deletes every password on the machine, and
|
|
223
|
+
* many will not let a tool near their keychain at all. This file is never written into an AI app's
|
|
224
|
+
* settings: the apps run the bridge, which reads it.
|
|
225
|
+
*/
|
|
226
|
+
const keyStore = {
|
|
227
|
+
/** Written whole or not at all: a temporary file renamed over the old one. */
|
|
228
|
+
save(entry) {
|
|
229
|
+
ensureHome();
|
|
230
|
+
const temporary = `${KEY_FILE}.${process.pid}.tmp`;
|
|
231
|
+
writeFileSync(temporary, Buffer.from(JSON.stringify(entry)).toString("base64"), { mode: 0o600 });
|
|
232
|
+
try { chmodSync(temporary, 0o600); } catch { /* Windows */ }
|
|
233
|
+
renameSync(temporary, KEY_FILE);
|
|
234
|
+
},
|
|
235
|
+
load() {
|
|
236
|
+
try { return JSON.parse(Buffer.from(readFileSync(KEY_FILE, "utf8"), "base64").toString("utf8")); } catch { return null; }
|
|
237
|
+
},
|
|
238
|
+
erase() { try { rmSync(KEY_FILE); } catch { /* gone */ } },
|
|
239
|
+
};
|
|
240
|
+
|
|
241
|
+
/** `WorkTrust-Proof` for one request: seconds, a one-time nonce, and the Ed25519 signature over method, path, both and the body's hash. */
|
|
242
|
+
function proof(device, url, body) {
|
|
243
|
+
if (!device) return {};
|
|
244
|
+
const seconds = String(Math.floor(Date.now() / 1000));
|
|
245
|
+
const nonce = randomBytes(18).toString("base64url");
|
|
246
|
+
const message = `POST\n${new URL(url).pathname}\n${seconds}\n${nonce}\n${createHash("sha256").update(body).digest("hex")}`;
|
|
247
|
+
return { "worktrust-proof": `${seconds}.${nonce}.${sign(null, Buffer.from(message), device).toString("base64url")}` };
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
/** Whether the door accepts a key: one call that reads nothing back. */
|
|
251
|
+
async function accepted(url, token, device) {
|
|
252
|
+
try {
|
|
253
|
+
const body = JSON.stringify({ jsonrpc: "2.0", id: 0, method: "tools/list" });
|
|
254
|
+
const response = await fetch(url, { method: "POST", headers: { "content-type": "application/json", accept: "application/json, text/event-stream", authorization: `Bearer ${token}`, "user-agent": "worktrust-bridge", ...proof(device, url, body) }, body });
|
|
255
|
+
return response.status !== 401 && response.status !== 403;
|
|
256
|
+
} catch { return null; }
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
/**
|
|
260
|
+
* RENEW WHEN DUE. The new secret is made here and stored as `pending` BEFORE it is announced, so a
|
|
261
|
+
* lost answer is recoverable: next time, whichever of the two keys the door accepts is kept.
|
|
262
|
+
*/
|
|
263
|
+
/**
|
|
264
|
+
* ONE RENEWAL AT A TIME (security audit 2026-10-03, MEDIUM-2). Claude Code starts the hook and every
|
|
265
|
+
* AI app starts a bridge at the same moment, and two renewals racing used to leave the file holding a
|
|
266
|
+
* dead key. Now a renewal holds ~/.worktrust/renew.lock (created exclusively; a lock older than a minute
|
|
267
|
+
* is a crashed run's and is taken over), re-reads the file inside it, and writes back only what it
|
|
268
|
+
* just read. Whoever does not get the lock reads the file again afterwards and uses what is there.
|
|
269
|
+
*/
|
|
270
|
+
const LOCK_FILE = join(HOME_DIR, "renew.lock");
|
|
271
|
+
function withLock(work) {
|
|
272
|
+
ensureHome();
|
|
273
|
+
try { if (Date.now() - statSync(LOCK_FILE).mtimeMs > 60_000) rmSync(LOCK_FILE); } catch { /* no lock */ }
|
|
274
|
+
let handle;
|
|
275
|
+
try { handle = openSync(LOCK_FILE, "wx", 0o600); } catch { return null; }
|
|
276
|
+
return work().finally(() => { closeSync(handle); try { rmSync(LOCK_FILE); } catch { /* gone */ } });
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
async function freshKey() {
|
|
280
|
+
const first = keyStore.load();
|
|
281
|
+
if (!first) return null;
|
|
282
|
+
if (!first.pending && Date.now() - Date.parse(first.renewedAt ?? 0) < RENEW_EVERY_MS) return first;
|
|
283
|
+
const settled = await withLock(async () => {
|
|
284
|
+
const value = keyStore.load();
|
|
285
|
+
if (!value) return null;
|
|
286
|
+
if (value.pending) {
|
|
287
|
+
const next = await accepted(value.url, value.pending, value.device);
|
|
288
|
+
if (next === true) { const promoted = { ...value, token: value.pending, renewedAt: new Date().toISOString() }; delete promoted.pending; keyStore.save(promoted); return promoted; }
|
|
289
|
+
if (next === false) { delete value.pending; keyStore.save(value); }
|
|
290
|
+
}
|
|
291
|
+
if (Date.now() - Date.parse(value.renewedAt ?? 0) < RENEW_EVERY_MS) return value;
|
|
292
|
+
const pending = `wt_${randomBytes(32).toString("base64url")}`;
|
|
293
|
+
keyStore.save({ ...value, pending });
|
|
294
|
+
try {
|
|
295
|
+
const renewUrl = `${new URL(value.url).origin}/api/cli/renew`;
|
|
296
|
+
const body = JSON.stringify({ token_hash: createHash("sha256").update(pending).digest("hex"), token_prefix: pending.slice(0, 11) });
|
|
297
|
+
const response = await fetch(renewUrl, { method: "POST", headers: { "content-type": "application/json", authorization: `Bearer ${value.token}`, ...proof(value.device, renewUrl, body) }, body });
|
|
298
|
+
if (response.ok) { const renewed = { ...value, token: pending, renewedAt: new Date().toISOString() }; keyStore.save(renewed); return renewed; }
|
|
299
|
+
// Refused: drop the pending key only if the file still holds the value this renewal read.
|
|
300
|
+
if (response.status === 400 || response.status === 401) { const now = keyStore.load(); if (now?.token === value.token && now?.pending === pending) keyStore.save(value); }
|
|
301
|
+
} catch { /* offline: the pending key is settled next time */ }
|
|
302
|
+
return keyStore.load();
|
|
303
|
+
});
|
|
304
|
+
if (settled) return settled;
|
|
305
|
+
// Someone else holds the lock: wait for them (ten seconds at most), then their result is in the file.
|
|
306
|
+
for (let waited = 0; waited < 10_000 && existsSync(LOCK_FILE); waited += 200) await new Promise((resolve) => setTimeout(resolve, 200));
|
|
307
|
+
return keyStore.load();
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
/**
|
|
311
|
+
* THE BRIDGE an AI app runs (`worktrust.mjs mcp`): JSON-RPC lines on stdin, forwarded to the door with
|
|
312
|
+
* the key from the key file, answers on stdout. It names the app that started it (from `initialize`),
|
|
313
|
+
* so WorkTrust still sees Claude Code or Cursor, not the bridge.
|
|
314
|
+
*/
|
|
315
|
+
async function bridge() {
|
|
316
|
+
let key = await freshKey();
|
|
317
|
+
const answer = (id, message) => process.stdout.write(`${JSON.stringify({ jsonrpc: "2.0", id, error: { code: -32603, message } })}\n`);
|
|
318
|
+
let session = null;
|
|
319
|
+
let agent = "worktrust-bridge";
|
|
320
|
+
for await (const line of lines({ input: process.stdin })) {
|
|
321
|
+
if (!line.trim()) continue;
|
|
322
|
+
let message;
|
|
323
|
+
try { message = JSON.parse(line); } catch { continue; }
|
|
324
|
+
if (message.method === "initialize" && message.params?.clientInfo?.name) agent = `${message.params.clientInfo.name}/${message.params.clientInfo.version ?? "0"} (worktrust-bridge)`;
|
|
325
|
+
if (!key) { if (message.id !== undefined) answer(message.id, "WorkTrust is not connected on this computer: run `npx worktrust connect`."); continue; }
|
|
326
|
+
try {
|
|
327
|
+
const response = await fetch(key.url, { method: "POST", headers: { "content-type": "application/json", accept: "application/json, text/event-stream", authorization: `Bearer ${key.token}`, "user-agent": agent, ...(session ? { "mcp-session-id": session } : {}), ...proof(key.device, key.url, line) }, body: line });
|
|
328
|
+
session = response.headers.get("mcp-session-id") ?? session;
|
|
329
|
+
if (message.id === undefined) continue;
|
|
330
|
+
if (response.status === 401) {
|
|
331
|
+
// Another process may just have renewed the key: read the file again and try once more.
|
|
332
|
+
const reread = keyStore.load();
|
|
333
|
+
if (reread?.token && reread.token !== key.token) {
|
|
334
|
+
key = reread;
|
|
335
|
+
const again = await fetch(key.url, { method: "POST", headers: { "content-type": "application/json", accept: "application/json, text/event-stream", authorization: `Bearer ${key.token}`, "user-agent": agent, ...(session ? { "mcp-session-id": session } : {}), ...proof(key.device, key.url, line) }, body: line });
|
|
336
|
+
if (again.status !== 401) { const text = await again.text(); const parts = (again.headers.get("content-type") ?? "").includes("event-stream") ? text.split("\n").filter((part) => part.startsWith("data:")).map((part) => part.slice(5).trim()) : [text.trim()]; for (const part of parts.filter(Boolean)) process.stdout.write(`${part}\n`); continue; }
|
|
337
|
+
}
|
|
338
|
+
answer(message.id, "WorkTrust did not accept this computer's key: run `npx worktrust connect` again.");
|
|
339
|
+
continue;
|
|
340
|
+
}
|
|
341
|
+
const text = await response.text();
|
|
342
|
+
const parts = (response.headers.get("content-type") ?? "").includes("event-stream") ? text.split("\n").filter((part) => part.startsWith("data:")).map((part) => part.slice(5).trim()) : [text.trim()];
|
|
343
|
+
for (const part of parts.filter(Boolean)) process.stdout.write(`${part}\n`);
|
|
344
|
+
} catch { if (message.id !== undefined) answer(message.id, "WorkTrust is unreachable from this computer right now."); }
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
/** THE HOOK Claude Code runs (`worktrust.mjs hook`): the key from the key file, handed to the session hook. */
|
|
349
|
+
async function hook() {
|
|
350
|
+
const key = await freshKey();
|
|
351
|
+
if (!key) process.exit(0);
|
|
352
|
+
const child = spawn(process.execPath, [join(HOME_DIR, "log-session.mjs"), ...args.filter((arg) => arg !== "hook")], { stdio: "inherit", env: { ...process.env, WORKTRUST_MCP_URL: key.url, WORKTRUST_MCP_TOKEN: key.token, ...(key.device ? { WORKTRUST_DEVICE_KEY: key.device } : {}) } });
|
|
353
|
+
child.on("exit", (code) => process.exit(code ?? 0));
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
async function connect() {
|
|
357
|
+
const host = (flag("name") ?? hostname()).replace(/\.local$/i, "").slice(0, 64);
|
|
358
|
+
say();
|
|
359
|
+
say(` WorkTrust · connect ${host} (${OS_NAME})`);
|
|
360
|
+
say();
|
|
361
|
+
if (!BUNDLED) say(` The three scripts that do the work are downloaded from ${ORIGIN} first. From npm they come in the package.\n`);
|
|
362
|
+
const paths = await scripts();
|
|
363
|
+
const direct = has("direct");
|
|
364
|
+
const plan = await run(paths["setup-mcp.mjs"], direct ? [] : ["--bridge", STABLE], PLACEHOLDER, true);
|
|
365
|
+
say(useDevice() ? " 1. You type a code at WorkTrust on another device, signed in. That decides the account." : " 1. You approve this computer in WorkTrust, in your browser, signed in. That decides the account.");
|
|
366
|
+
say(` WorkTrust receives this computer's system and name: ${OS_NAME}, "${host}".`);
|
|
367
|
+
say(direct ? " 2. Every AI app found here gets the WorkTrust door, with the key in its settings:" : " 2. Every AI app found here gets the WorkTrust door, a local command that reads the key for it:");
|
|
368
|
+
const foundApps = planLines(plan.out, true).filter((line) => !line.includes("not on this machine"));
|
|
369
|
+
for (const line of foundApps) say(line);
|
|
370
|
+
if (foundApps.length === 0) say(" (no AI app found here yet; run connect again after installing one)");
|
|
371
|
+
if (!direct) say(` 3. The key is kept in ${KEY_PLACE}, works only on this computer (every call is signed) and is renewed every week.`);
|
|
372
|
+
say(` ${direct ? 3 : 4}. The session hook is installed:`);
|
|
373
|
+
say(` + ${join(HOME_DIR, "log-session.mjs")} and ${join(HOME_DIR, "count-behaviour.mjs")}${BUNDLED ? " (from this package, not updated from the network)" : ""}`);
|
|
374
|
+
say(` + ${join(homedir(), ".claude", "settings.json")} → SessionStart, SessionEnd and PreCompact run it`);
|
|
375
|
+
say();
|
|
376
|
+
say(" Sent to WorkTrust afterwards: durations, token counts, model names, a layer keyword, counts.");
|
|
377
|
+
say(" Never sent: prompts, answers, code, file paths, commit messages.");
|
|
378
|
+
say();
|
|
379
|
+
if (has("dry-run")) { say(" Dry run: nothing was written and nothing was sent."); return; }
|
|
380
|
+
if (!(await ask("Continue?"))) { say(" Stopped. Nothing was written and nothing was sent."); return; }
|
|
381
|
+
|
|
382
|
+
// The device key: made here, its private half kept with the key, its public half bound at WorkTrust.
|
|
383
|
+
const device = direct ? null : generateKeyPairSync("ed25519");
|
|
384
|
+
const apps = foundApps.map((line) => /^\s*[↻+✓] ([^:]+):/.exec(line)?.[1]).filter(Boolean).slice(0, 12);
|
|
385
|
+
const extra = { apps, ...(device ? { device_key: device.publicKey.export({ format: "jwk" }).x } : {}) };
|
|
386
|
+
const token = useDevice() ? await pairByCode(host, extra) : await pairByBrowser(host, extra);
|
|
387
|
+
|
|
388
|
+
say();
|
|
389
|
+
// Out first, then in: an app that already held a WorkTrust key moves to the new one.
|
|
390
|
+
await run(paths["setup-mcp.mjs"], ["--remove", "--write"], token, true);
|
|
391
|
+
if (!direct) {
|
|
392
|
+
keyStore.save({ url: MCP, token, renewedAt: new Date().toISOString(), device: device.privateKey.export({ format: "pem", type: "pkcs8" }) });
|
|
393
|
+
ensureHome();
|
|
394
|
+
copyFileSync(fileURLToPath(import.meta.url), STABLE);
|
|
395
|
+
say(` ✓ Key kept in ${KEY_PLACE}`);
|
|
396
|
+
}
|
|
397
|
+
if ((await run(paths["setup-mcp.mjs"], direct ? ["--write"] : ["--write", "--bridge", STABLE], token)).code !== 0) fail("coupling the AI apps failed; the output above says where.");
|
|
398
|
+
say();
|
|
399
|
+
const hookArgs = ["--install", ...(BUNDLED ? ["--counter", paths["count-behaviour.mjs"]] : []), ...(direct ? [] : ["--via", STABLE])];
|
|
400
|
+
if ((await run(paths["log-session.mjs"], hookArgs, token)).code !== 0) fail("installing the session hook failed; the output above says where.");
|
|
401
|
+
say();
|
|
402
|
+
say(" Done. Quit your AI apps completely and open them again. This computer appears in WorkTrust");
|
|
403
|
+
say(" under Sources → Devices once one of them has spoken.");
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
async function disconnect() {
|
|
407
|
+
say();
|
|
408
|
+
say(` WorkTrust · disconnect ${hostname().replace(/\.local$/i, "")}`);
|
|
409
|
+
say();
|
|
410
|
+
const paths = await scripts();
|
|
411
|
+
const plan = await run(paths["setup-mcp.mjs"], ["--remove"], PLACEHOLDER, true);
|
|
412
|
+
say(" 1. The WorkTrust door comes out of every AI app here:");
|
|
413
|
+
for (const line of planLines(plan.out)) say(line);
|
|
414
|
+
say(" 2. The session hook comes out of ~/.claude/settings.json, the key file is deleted, and ~/.worktrust is emptied.");
|
|
415
|
+
say(" 3. The key itself stays valid until you revoke it: WorkTrust → Sources → Devices → this computer → Revoke.");
|
|
416
|
+
say();
|
|
417
|
+
if (has("dry-run")) { say(" Dry run: nothing was changed."); return; }
|
|
418
|
+
if (!(await ask("Continue?"))) { say(" Stopped. Nothing was changed."); return; }
|
|
419
|
+
await run(paths["setup-mcp.mjs"], ["--remove", "--write"], PLACEHOLDER);
|
|
420
|
+
await run(paths["log-session.mjs"], ["--uninstall", "--purge"], PLACEHOLDER);
|
|
421
|
+
keyStore.erase();
|
|
422
|
+
// Everything of ours on this computer goes; the folder too, when nothing else is left in it.
|
|
423
|
+
for (const name of [STABLE, ...SCRIPTS.map((script) => join(HOME_DIR, `${script}.download.mjs`)), join(HOME_DIR, "pinned")]) { try { rmSync(name); } catch { /* gone */ } }
|
|
424
|
+
try { rmSync(HOME_DIR, { recursive: false }); } catch { /* not empty, or gone */ }
|
|
425
|
+
say();
|
|
426
|
+
say(" Done. Revoke the key in WorkTrust to finish.");
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
function status() {
|
|
430
|
+
const servers = {};
|
|
431
|
+
try {
|
|
432
|
+
const config = JSON.parse(readFileSync(join(homedir(), ".claude.json"), "utf8"));
|
|
433
|
+
Object.assign(servers, config.mcpServers ?? {});
|
|
434
|
+
for (const project of Object.values(config.projects ?? {})) Object.assign(servers, project.mcpServers ?? {});
|
|
435
|
+
} catch { /* no Claude Code here */ }
|
|
436
|
+
const door = Object.entries(servers).find(([name, server]) => /worktrust/i.test(`${name} ${server?.url ?? ""} ${(server?.args ?? []).join(" ")}`));
|
|
437
|
+
say(door ? ` Claude Code: coupled (${door[1].url ?? "through the local bridge"})` : " Claude Code: not coupled. Run `npx worktrust connect`.");
|
|
438
|
+
const key = keyStore.load();
|
|
439
|
+
say(key ? ` Key: in ${KEY_PLACE}, ${key.device ? "bound to this computer, " : ""}last renewed ${String(key.renewedAt ?? "").slice(0, 10) || "never"}` : " Key: not on this computer (or written into the apps' settings with --direct)");
|
|
440
|
+
let hook = false;
|
|
441
|
+
try { hook = /log-session|worktrust\.mjs\\?" hook/.test(readFileSync(join(homedir(), ".claude", "settings.json"), "utf8")); } catch { /* none */ }
|
|
442
|
+
say(hook ? " Session hook: installed" : " Session hook: not installed");
|
|
443
|
+
say(existsSync(join(HOME_DIR, "pinned")) ? " Counter: from the package, pinned" : existsSync(join(HOME_DIR, "count-behaviour.mjs")) ? " Counter: follows the site" : " Counter: not installed");
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
function help() {
|
|
447
|
+
say("WorkTrust: couple this computer to your record. Metadata only.");
|
|
448
|
+
say();
|
|
449
|
+
say(" worktrust couple this computer: show the plan, ask, approve in your browser");
|
|
450
|
+
say(" (already coupled: shows what is coupled here)");
|
|
451
|
+
say(" --dry-run the plan only: nothing is written, nothing is sent");
|
|
452
|
+
say(" --yes no question (the computer is still approved in the browser)");
|
|
453
|
+
say(" --device no browser here (SSH): type a code on another device");
|
|
454
|
+
say(" --name <name> the name this computer gets (default: its host name)");
|
|
455
|
+
say(" --direct put the key in each app's settings instead of the key file");
|
|
456
|
+
say(" worktrust connect couple again, also when this computer is already coupled");
|
|
457
|
+
say(" worktrust disconnect take WorkTrust out of every AI app here (asks first)");
|
|
458
|
+
say(" worktrust status what is coupled here");
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
// THE BARE COMMAND COUPLES (owner, 2026-10-03: "zo kort mogelijk"). `npx worktrust` on a computer
|
|
462
|
+
// without a key here shows the plan and asks, exactly as `connect` does; with one it says what is
|
|
463
|
+
// coupled, so running it twice never re-pairs by surprise.
|
|
464
|
+
if (command === "default") { if (keyStore.load()) { status(); say(); say(" This computer is coupled. `npx worktrust connect` couples it again; `npx worktrust disconnect` takes it out."); } else await connect(); }
|
|
465
|
+
else if (command === "mcp") await bridge();
|
|
466
|
+
else if (command === "hook") await hook();
|
|
467
|
+
else if (command === "connect") await connect();
|
|
468
|
+
else if (command === "disconnect") await disconnect();
|
|
469
|
+
else if (command === "status") status();
|
|
470
|
+
else help();
|