@join-pantheon/cli 0.29.1 → 0.30.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.
Files changed (46) hide show
  1. package/README.md +45 -22
  2. package/bin/pantheon.js +12 -0
  3. package/dist/accounts.js +151 -26
  4. package/dist/agents.js +197 -14
  5. package/dist/claude-confine.js +283 -0
  6. package/dist/cli.js +725 -120
  7. package/dist/client.js +37 -2
  8. package/dist/command-state.js +79 -0
  9. package/dist/config.js +211 -10
  10. package/dist/doctor.js +103 -13
  11. package/dist/grants.js +1 -1
  12. package/dist/history-view.js +2 -1
  13. package/dist/home-guard.js +210 -0
  14. package/dist/hooks-install.js +2 -1
  15. package/dist/identity.js +24 -0
  16. package/dist/install.js +76 -26
  17. package/dist/kiro-files.js +2 -2
  18. package/dist/ledger.js +111 -28
  19. package/dist/machine.js +494 -32
  20. package/dist/mcp-supervisor.js +25 -6
  21. package/dist/mcp-windows.js +47 -25
  22. package/dist/mcp.js +25 -10
  23. package/dist/network.js +39 -8
  24. package/dist/notify.js +17 -6
  25. package/dist/pending.js +8 -14
  26. package/dist/persona.js +21 -0
  27. package/dist/profile.js +4 -1
  28. package/dist/quickstart.js +82 -14
  29. package/dist/reads.js +30 -0
  30. package/dist/refusal.js +14 -1
  31. package/dist/relay.js +1143 -180
  32. package/dist/roster-file.js +169 -23
  33. package/dist/run-failure.js +83 -3
  34. package/dist/secret-guard.js +141 -0
  35. package/dist/service.js +84 -34
  36. package/dist/setup-guide.js +28 -9
  37. package/dist/spend.js +6 -2
  38. package/dist/store.js +94 -10
  39. package/dist/text-file.js +74 -0
  40. package/dist/tray.js +11 -2
  41. package/dist/update-check.js +2 -0
  42. package/dist/which.js +69 -12
  43. package/dist/worker-policy.js +137 -3
  44. package/dist/worker.js +702 -81
  45. package/helper/macos/menubar.swift +15 -1
  46. package/package.json +2 -2
package/dist/client.js CHANGED
@@ -1,4 +1,5 @@
1
1
  import WebSocket from "ws";
2
+ import { updateMembershipToken } from "./config.js";
2
3
  import { signChallenge } from "./identity.js";
3
4
  import { newId } from "./protocol.js";
4
5
  import { VERSION } from "./version.js";
@@ -23,6 +24,11 @@ export class PantheonClient {
23
24
  return !this.connected && !this.closed && this.everConnected;
24
25
  }
25
26
  cid = 0;
27
+ static REFUSED_RETRY_MS = 5 * 60_000;
28
+ static REFUSED_GRACE_MS = 2 * 60_000;
29
+ get refused() { return this.connected ? null : this.refusedCode; }
30
+ refusedCode = null;
31
+ refusedSince = 0;
26
32
  seq = 0;
27
33
  readSeq = 0;
28
34
  head = 0;
@@ -46,13 +52,24 @@ export class PantheonClient {
46
52
  enroll = null;
47
53
  inviteUrl = null;
48
54
  invitesList = null;
55
+ persistToken;
49
56
  constructor(cfg, onEvent, opts) {
50
57
  this.cfg = cfg;
51
58
  this.onEvent = onEvent;
59
+ this.persistToken = opts?.persistToken ?? ((space, oldToken, newToken) => { updateMembershipToken(space, oldToken, newToken); });
52
60
  this.enroll = opts?.enroll ?? null;
53
61
  this.session = opts?.session ?? newId("s");
54
62
  this.role = opts?.role ?? "interactive";
55
63
  }
64
+ adoptToken(token) {
65
+ const old = this.cfg.token;
66
+ if (!token || token === old)
67
+ return;
68
+ try {
69
+ this.persistToken(this.cfg.space, old, token);
70
+ }
71
+ catch { }
72
+ }
56
73
  async loopbackFallback() {
57
74
  let u;
58
75
  try {
@@ -217,6 +234,7 @@ export class PantheonClient {
217
234
  case "welcome":
218
235
  this.connected = true;
219
236
  this.pending = null;
237
+ this.refusedCode = null;
220
238
  this.session = msg.session;
221
239
  this.everConnected = true;
222
240
  this.head = msg.seq;
@@ -228,7 +246,10 @@ export class PantheonClient {
228
246
  this.readSeq = msg.seq;
229
247
  this.everReadMarked = true;
230
248
  if (msg.token) {
231
- this.enrolledToken = msg.token;
249
+ if (this.enroll)
250
+ this.enrolledToken = msg.token;
251
+ else
252
+ this.adoptToken(msg.token);
232
253
  this.cfg.token = msg.token;
233
254
  }
234
255
  if (msg.space_id) {
@@ -250,6 +271,12 @@ export class PantheonClient {
250
271
  resolve();
251
272
  }
252
273
  break;
274
+ case "token":
275
+ if (typeof msg.token === "string" && msg.token) {
276
+ this.adoptToken(msg.token);
277
+ this.cfg.token = msg.token;
278
+ }
279
+ break;
253
280
  case "repos":
254
281
  this.repos = msg.repos;
255
282
  this.adoptKnownRepo();
@@ -285,6 +312,12 @@ export class PantheonClient {
285
312
  }
286
313
  case "error": {
287
314
  this.lastError = `${msg.code}: ${msg.message}`;
315
+ if (!msg.cid) {
316
+ const code = ["REVOKED", "KEY_MISMATCH", "BAD_TOKEN"].includes(msg.code) ? msg.code : null;
317
+ if (code && !this.refusedCode)
318
+ this.refusedSince = Date.now();
319
+ this.refusedCode = code;
320
+ }
288
321
  if (msg.code === "SPACE_DELETED")
289
322
  this.closed = true;
290
323
  const fn = msg.cid ? this.acks.get(msg.cid) : undefined;
@@ -325,8 +358,10 @@ export class PantheonClient {
325
358
  }
326
359
  reject(new Error(this.lastError ?? "relay connection failed"));
327
360
  }
361
+ const slow = !!this.refused && Date.now() - this.refusedSince >= PantheonClient.REFUSED_GRACE_MS;
362
+ const wait = slow ? PantheonClient.REFUSED_RETRY_MS : Math.round(this.backoff * (0.5 + Math.random()));
328
363
  setTimeout(() => { if (!this.closed)
329
- this.connect().catch(() => { }); }, Math.round(this.backoff * (0.5 + Math.random())));
364
+ this.connect().catch(() => { }); }, wait);
330
365
  this.backoff = Math.min(this.backoff * 2, 15_000);
331
366
  };
332
367
  ws.on("close", down);
@@ -0,0 +1,79 @@
1
+ import { execFileSync } from "node:child_process";
2
+ import { existsSync, readFileSync } from "node:fs";
3
+ import { platform } from "node:os";
4
+ import { parseRepoCommands, REPO_COMMANDS_FILE } from "./agents.js";
5
+ import { loadPersonas } from "./persona.js";
6
+ import { plistPath, existingUnitPath } from "./service.js";
7
+ import { loadPolicy } from "./worker-policy.js";
8
+ export function workerServiceAllowsBash(plat = platform()) {
9
+ try {
10
+ if (plat === "darwin") {
11
+ const p = plistPath("worker");
12
+ if (!existsSync(p))
13
+ return null;
14
+ const xml = readFileSync(p, "utf8");
15
+ const args = [...(/<key>ProgramArguments<\/key>\s*<array>([\s\S]*?)<\/array>/.exec(xml)?.[1] ?? "").matchAll(/<string>([^<]*)<\/string>/g)].map((m) => m[1]);
16
+ return args.includes("--allow-bash");
17
+ }
18
+ if (plat === "win32")
19
+ return null;
20
+ const u = existingUnitPath("worker");
21
+ if (!u)
22
+ return null;
23
+ const exec = /^ExecStart=(.*)$/m.exec(readFileSync(u, "utf8"))?.[1] ?? "";
24
+ return /(^|\s|")--allow-bash("|\s|$)/.test(exec);
25
+ }
26
+ catch {
27
+ return null;
28
+ }
29
+ }
30
+ export function commandState(o = {}) {
31
+ const allowBash = o.allowBash ?? workerServiceAllowsBash(o.plat) ?? false;
32
+ let grant = null;
33
+ try {
34
+ grant = loadPolicy(o.home).grants.run_commands ?? null;
35
+ }
36
+ catch { }
37
+ let personasOff = [];
38
+ try {
39
+ personasOff = loadPersonas(o.project ?? process.cwd(), o.home).filter((p) => p.allowBash === false).map((p) => p.name);
40
+ }
41
+ catch { }
42
+ const level = allowBash || grant?.mode === "always" ? "always" : grant ? "once" : "off";
43
+ const except = personasOff.length ? `, except runs handled by ${personasOff.length === 1 ? "persona" : "personas"} ${personasOff.join(", ")} (allow_bash: false)` : "";
44
+ const summary = level === "off"
45
+ ? "off — woken runs edit files only; a run that needs more asks here (pantheon policy grant run-commands)"
46
+ : allowBash
47
+ ? `on — the worker runs with --allow-bash${grant ? ", and run-commands is granted here too" : ""}${except}`
48
+ : grant.mode === "always"
49
+ ? `on — run-commands granted on this machine (${grant.by}, ${new Date(grant.at).toLocaleDateString()})${except}`
50
+ : `on for the next run only — run-commands granted once on this machine${except}`;
51
+ return { level, allowBash, grant, personasOff, summary };
52
+ }
53
+ export function repoCommandsAtBase(project, base) {
54
+ const git = (...a) => execFileSync("git", ["-C", project, ...a], { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"], timeout: 5_000 }).trim();
55
+ const exists = (ref) => { try {
56
+ git("rev-parse", "--verify", "--quiet", `${ref}^{commit}`);
57
+ return true;
58
+ }
59
+ catch {
60
+ return false;
61
+ } };
62
+ let remoteHead = null;
63
+ try {
64
+ remoteHead = git("symbolic-ref", "--quiet", "--short", "refs/remotes/origin/HEAD") || null;
65
+ }
66
+ catch { }
67
+ const ref = [base ? `origin/${base}` : null, base ?? null, remoteHead, "HEAD"].filter((r) => !!r).find(exists) ?? null;
68
+ if (!ref)
69
+ return { ref: null, ok: [], refused: [] };
70
+ let text = "";
71
+ try {
72
+ text = git("show", `${ref}:${REPO_COMMANDS_FILE}`);
73
+ }
74
+ catch {
75
+ return { ref, ok: [], refused: [] };
76
+ }
77
+ return { ref, ...parseRepoCommands(text) };
78
+ }
79
+ //# sourceMappingURL=command-state.js.map
package/dist/config.js CHANGED
@@ -1,8 +1,9 @@
1
- import { readFileSync, writeFileSync, mkdirSync, existsSync } from "node:fs";
1
+ import { readFileSync, mkdirSync, existsSync, openSync, writeSync, fsyncSync, closeSync, renameSync, rmSync, statSync, chmodSync, realpathSync } from "node:fs";
2
2
  import { execFileSync } from "node:child_process";
3
- import { createHash } from "node:crypto";
3
+ import { createHash, randomBytes } from "node:crypto";
4
4
  import { homedir } from "node:os";
5
- import { join, resolve } from "node:path";
5
+ import { join, resolve, sep } from "node:path";
6
+ import { VERSION } from "./version.js";
6
7
  export const CONFIG_DIR = process.env.PANTHEON_HOME ?? join(homedir(), ".pantheon");
7
8
  export const CONFIG_PATH = join(CONFIG_DIR, "config.json");
8
9
  export const DEFAULT_RELAY = process.env.PANTHEON_DEFAULT_RELAY
@@ -60,13 +61,34 @@ function sameRepo(host, path) {
60
61
  return [host, path];
61
62
  }
62
63
  const repoCache = new Map();
64
+ const gitUnanswered = new Set();
65
+ export function repoUnknown(dir = process.cwd()) {
66
+ const key = resolve(dir);
67
+ if (!gitUnanswered.has(key))
68
+ return false;
69
+ for (let d = key;; d = resolve(d, "..")) {
70
+ if (existsSync(join(d, ".git")))
71
+ return true;
72
+ if (resolve(d, "..") === d)
73
+ return false;
74
+ }
75
+ }
63
76
  export function repoInfo(dir = process.cwd()) {
64
77
  const key = resolve(dir);
65
78
  if (repoCache.has(key))
66
79
  return repoCache.get(key);
67
80
  let info = null;
68
81
  try {
69
- const root = execFileSync("git", ["-C", key, "rev-parse", "--show-toplevel"], { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"], timeout: 3000 }).trim();
82
+ let root;
83
+ try {
84
+ root = execFileSync("git", ["-C", key, "rev-parse", "--show-toplevel"], { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"], timeout: 10_000 }).trim();
85
+ }
86
+ catch (e) {
87
+ if (e.status !== 128)
88
+ gitUnanswered.add(key);
89
+ throw e;
90
+ }
91
+ gitUnanswered.delete(key);
70
92
  let origin = null;
71
93
  try {
72
94
  origin = normalizeOrigin(execFileSync("git", ["-C", root, "remote", "get-url", "origin"], { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"], timeout: 3000 }));
@@ -103,7 +125,7 @@ export function migrate(raw) {
103
125
  export function loadAll() {
104
126
  if (!existsSync(CONFIG_PATH))
105
127
  return null;
106
- return migrate(JSON.parse(readFileSync(CONFIG_PATH, "utf8")));
128
+ return migrate(JSON.parse(readFileSync(CONFIG_PATH, "utf8").replace(/^\uFEFF/, "")));
107
129
  }
108
130
  export function saveAll(f) {
109
131
  const out = { ...f, version: 2, spaces: f.spaces ?? {}, bindings: f.bindings ?? {} };
@@ -114,10 +136,113 @@ export function saveAll(f) {
114
136
  const def = out.default_space ? out.spaces[out.default_space] : undefined;
115
137
  out.space = def?.name ?? "";
116
138
  out.token = def?.token ?? "";
117
- mkdirSync(CONFIG_DIR, { recursive: true });
118
- writeFileSync(CONFIG_PATH, JSON.stringify(out, null, 2) + "\n", { mode: 0o600 });
139
+ writeConfigAtomic(JSON.stringify(out, null, 2) + "\n");
119
140
  return CONFIG_PATH;
120
141
  }
142
+ function privateDir() {
143
+ mkdirSync(CONFIG_DIR, { recursive: true, mode: 0o700 });
144
+ if (process.platform === "win32")
145
+ return;
146
+ try {
147
+ if ((statSync(CONFIG_DIR).mode & 0o077) !== 0)
148
+ chmodSync(CONFIG_DIR, 0o700);
149
+ }
150
+ catch { }
151
+ }
152
+ function writeConfigAtomic(data) {
153
+ privateDir();
154
+ const tmp = `${CONFIG_PATH}.${process.pid}.${randomBytes(4).toString("hex")}.tmp`;
155
+ try {
156
+ const fd = openSync(tmp, "w", 0o600);
157
+ try {
158
+ writeSync(fd, data);
159
+ fsyncSync(fd);
160
+ }
161
+ finally {
162
+ closeSync(fd);
163
+ }
164
+ if (process.platform !== "win32")
165
+ chmodSync(tmp, 0o600);
166
+ for (let i = 0;; i++) {
167
+ try {
168
+ renameSync(tmp, CONFIG_PATH);
169
+ break;
170
+ }
171
+ catch (e) {
172
+ if (i >= 20 || !["EPERM", "EACCES", "EBUSY"].includes(e.code ?? ""))
173
+ throw e;
174
+ sleepSync(10);
175
+ }
176
+ }
177
+ }
178
+ catch (e) {
179
+ try {
180
+ rmSync(tmp, { force: true });
181
+ }
182
+ catch { }
183
+ throw e;
184
+ }
185
+ let dfd = null;
186
+ try {
187
+ dfd = openSync(CONFIG_DIR, "r");
188
+ fsyncSync(dfd);
189
+ }
190
+ catch { }
191
+ finally {
192
+ if (dfd !== null)
193
+ try {
194
+ closeSync(dfd);
195
+ }
196
+ catch { }
197
+ }
198
+ }
199
+ const CONFIG_LOCK_STALE_MS = 5_000;
200
+ const CONFIG_LOCK_WAIT_MS = 15_000;
201
+ const sleepSync = (ms) => { try {
202
+ Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
203
+ }
204
+ catch { } };
205
+ export function withConfigLock(fn) {
206
+ privateDir();
207
+ const lock = `${CONFIG_PATH}.lock`;
208
+ const t0 = Date.now();
209
+ let fd = null;
210
+ while (fd === null) {
211
+ try {
212
+ fd = openSync(lock, "wx", 0o600);
213
+ }
214
+ catch (e) {
215
+ const code = e.code;
216
+ if (code !== "EEXIST" && !(process.platform === "win32" && (code === "EPERM" || code === "EACCES" || code === "EBUSY")))
217
+ throw e;
218
+ try {
219
+ if (Date.now() - statSync(lock).mtimeMs > CONFIG_LOCK_STALE_MS) {
220
+ rmSync(lock, { force: true });
221
+ continue;
222
+ }
223
+ }
224
+ catch {
225
+ continue;
226
+ }
227
+ if (Date.now() - t0 > CONFIG_LOCK_WAIT_MS)
228
+ throw new Error(`config.json is locked by another pantheon process (${lock})`);
229
+ sleepSync(2 + Math.floor(Math.random() * 8));
230
+ }
231
+ }
232
+ try {
233
+ return fn();
234
+ }
235
+ finally {
236
+ try {
237
+ closeSync(fd);
238
+ }
239
+ catch { }
240
+ try {
241
+ rmSync(lock, { force: true });
242
+ }
243
+ catch { }
244
+ }
245
+ }
121
246
  export function configFor(f, m, repo) { return view(f, m, repo); }
122
247
  function view(f, m, repo) {
123
248
  return {
@@ -134,14 +259,19 @@ function cwdRepo(repo) {
134
259
  }
135
260
  function envView(onDisk) {
136
261
  const e = process.env;
137
- if (!(e.PANTHEON_RELAY && e.PANTHEON_SPACE && e.PANTHEON_TOKEN && e.PANTHEON_MEMBER))
262
+ if (!(e.PANTHEON_RELAY && e.PANTHEON_SPACE && e.PANTHEON_MEMBER))
263
+ return null;
264
+ const token = e.PANTHEON_TOKEN ?? (onDisk?.member === e.PANTHEON_MEMBER
265
+ ? Object.values(onDisk.spaces ?? {}).find((m) => (e.PANTHEON_SPACE_ID && m.id === e.PANTHEON_SPACE_ID) || (m.relay === e.PANTHEON_RELAY && m.name === e.PANTHEON_SPACE))?.token
266
+ : undefined);
267
+ if (!token)
138
268
  return null;
139
269
  const pub = e.PANTHEON_PUBKEY ?? onDisk?.publicKey;
140
270
  const priv = e.PANTHEON_PRIVKEY ?? onDisk?.privateKey;
141
271
  if (!pub || !priv)
142
272
  return null;
143
273
  return {
144
- relay: e.PANTHEON_RELAY, space: e.PANTHEON_SPACE, token: e.PANTHEON_TOKEN, member: e.PANTHEON_MEMBER,
274
+ relay: e.PANTHEON_RELAY, space: e.PANTHEON_SPACE, token, member: e.PANTHEON_MEMBER,
145
275
  publicKey: pub, privateKey: priv, display: e.PANTHEON_DISPLAY, agent: e.PANTHEON_AGENT,
146
276
  ...(e.PANTHEON_SPACE_ID ? { spaceId: e.PANTHEON_SPACE_ID } : {}),
147
277
  ...(e.PANTHEON_REPO ? { repo: e.PANTHEON_REPO } : {}),
@@ -166,6 +296,17 @@ export function resolveFor(cwd = process.cwd(), opts = {}) {
166
296
  return { cfg: null, reason: "stale", memberships: n, binding: { ...b, fingerprint: repo.fingerprint }, repo };
167
297
  }
168
298
  }
299
+ if (!repo) {
300
+ const fb = folderBinding(f, cwd);
301
+ if (fb) {
302
+ const m = spaces[fb.space];
303
+ if (m)
304
+ return { cfg: applyAgentEnv(view(f, m, null)), reason: "binding", memberships: n, binding: { ...fb, fingerprint: fb.key }, repo };
305
+ return { cfg: null, reason: "stale", memberships: n, binding: { ...fb, fingerprint: fb.key }, repo };
306
+ }
307
+ }
308
+ if (!repo && n > 1 && repoUnknown(cwd))
309
+ return { cfg: null, reason: "unbound", memberships: n, repo, repoUnknown: true };
169
310
  if (n === 1) {
170
311
  const m = Object.values(spaces)[0];
171
312
  return { cfg: applyAgentEnv({ ...view(f, m, null), ...cwdRepo(repo) }), reason: "single", memberships: 1, repo };
@@ -255,12 +396,40 @@ export function save(cfg) {
255
396
  }
256
397
  else {
257
398
  const id = cfg.spaceId ?? legacySpaceId(cfg.relay, cfg.space);
258
- f.spaces[id] = { id, name: cfg.space, relay: cfg.relay, token: cfg.token, joined_at: Date.now() };
399
+ f.spaces[id] = { id, name: cfg.space, relay: cfg.relay, token: cfg.token, joined_at: Date.now(), since: VERSION };
259
400
  f.default_space = id;
260
401
  }
261
402
  }
262
403
  return saveAll(f);
263
404
  }
405
+ export function updateMembershipToken(space, oldToken, newToken) {
406
+ if (!space || !oldToken || !newToken || oldToken === newToken)
407
+ return false;
408
+ if (!existsSync(CONFIG_PATH))
409
+ return false;
410
+ return withConfigLock(() => {
411
+ let f;
412
+ try {
413
+ f = loadAll();
414
+ }
415
+ catch {
416
+ return false;
417
+ }
418
+ if (!f)
419
+ return false;
420
+ let hit = false;
421
+ for (const m of Object.values(f.spaces ?? {})) {
422
+ if (m.name === space && m.token === oldToken) {
423
+ m.token = newToken;
424
+ hit = true;
425
+ }
426
+ }
427
+ if (!hit)
428
+ return false;
429
+ saveAll(f);
430
+ return true;
431
+ });
432
+ }
264
433
  function adoptId(f, oldId, newId) {
265
434
  for (const b of Object.values(f.bindings ?? {}))
266
435
  if (b.space === oldId)
@@ -268,6 +437,38 @@ function adoptId(f, oldId, newId) {
268
437
  if (f.default_space === oldId)
269
438
  f.default_space = newId;
270
439
  }
440
+ export function bindFolder(dir, spaceId) {
441
+ const f = loadAll();
442
+ if (!f)
443
+ throw new Error("not configured");
444
+ f.bindings ??= {};
445
+ const path = canonDir(dir);
446
+ const key = `d_${sha16(path)}`;
447
+ const prev = f.bindings[key] ?? null;
448
+ f.bindings[key] = { space: spaceId, path, origin: null, bound_at: Date.now() };
449
+ saveAll(f);
450
+ return { key, replaced: prev && prev.space !== spaceId ? prev : null };
451
+ }
452
+ function canonDir(dir) {
453
+ try {
454
+ return realpathSync.native(dir);
455
+ }
456
+ catch {
457
+ return resolve(dir);
458
+ }
459
+ }
460
+ export function folderBinding(f, dir) {
461
+ const here = canonDir(dir);
462
+ let best = null;
463
+ for (const [key, b] of Object.entries(f.bindings ?? {})) {
464
+ if (!key.startsWith("d_"))
465
+ continue;
466
+ const inside = here === b.path || here.startsWith(b.path.endsWith(sep) ? b.path : b.path + sep);
467
+ if (inside && (!best || b.path.length > best.path.length))
468
+ best = { ...b, key };
469
+ }
470
+ return best;
471
+ }
271
472
  export function bindRepo(repo, spaceId) {
272
473
  const f = loadAll();
273
474
  if (!f)