@join-pantheon/cli 0.30.0 → 0.31.1

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/relay.js CHANGED
@@ -4,10 +4,10 @@ import { rmSync, readdirSync, readFileSync, statSync, existsSync, writeFileSync,
4
4
  import { spawn } from "node:child_process";
5
5
  import { join } from "node:path";
6
6
  import { WebSocketServer } from "ws";
7
- import { badRemote, Inbound, isContinuationFor, LIMITS, newId, PROTOCOL_VERSION, SENDABLE, shouldDeferToHuman, } from "./protocol.js";
7
+ import { badRemote, Inbound, isContinuationFor, LIMITS, newId, PROTOCOL_VERSION, RELAY_FEATURES, SENDABLE, shouldDeferToHuman, } from "./protocol.js";
8
8
  import { VERSION } from "./version.js";
9
9
  import { Ledger, SpaceFull } from "./ledger.js";
10
- import { fingerprint, newNonce, verifyChallenge, verifyActivity, verifyAccount, verifyRead } from "./identity.js";
10
+ import { fingerprint, newNonce, verifyChallenge, verifyActivity, verifyAccount, verifyRead, actPayload, goalPayload, verifyBytes, verifyDelegate, canonicalJson } from "./identity.js";
11
11
  import { Accounts, normalizeUsername, validEmail, validUsername } from "./accounts.js";
12
12
  import { emailSenderFromEnv } from "./email.js";
13
13
  import { Allowlist, RosterUnreadable } from "./roster-file.js";
@@ -208,6 +208,12 @@ function firstLedgerName(file) {
208
208
  }
209
209
  const GOAL_OPENED = /^goal opened: ([^\n]*)\nacceptance: ([\s\S]*?)(?:\ndeadline: (\S+))?(?:\nkind: (\w+)[\s\S]*)?$/;
210
210
  const LIMITS_GOAL_READ = 1000;
211
+ function repoLabelSafe(v) {
212
+ if (!v)
213
+ return null;
214
+ const s = v.normalize("NFKC").replace(/[^A-Za-z0-9._@:/+~-]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 100);
215
+ return s || null;
216
+ }
211
217
  const LIMITS_SPACE = { metaBytes: 8192, openGoals: 500, repos: 100, openInvites: 50 };
212
218
  const SPACE_MAX_BYTES_DEFAULT = 50 * 1024 * 1024;
213
219
  const SPACE_HARD_SLACK = 1024 * 1024;
@@ -287,6 +293,76 @@ export function netOf(ip) {
287
293
  }
288
294
  return h;
289
295
  }
296
+ function idIsFresh(id) {
297
+ const body = /^a_([a-z0-9]+)[0-9a-f]{18}$/.exec(id)?.[1];
298
+ const t = body ? parseInt(body, 36) : NaN;
299
+ return Number.isFinite(t) && Math.abs(Date.now() - t) < DAY;
300
+ }
301
+ function actSigProblem(m, member, memberPub, teams, delegated) {
302
+ const sig = m.sig;
303
+ if (!sig)
304
+ return "a woken run's session must sign every act";
305
+ if (canonicalJson(sig.meta ?? {}) !== canonicalJson(m.meta ?? {}))
306
+ return "the signed meta is not the meta sent";
307
+ if ((sig.repo ?? null) !== (m.repo ?? null))
308
+ return "the signed repo is not the repo sent";
309
+ const keyOk = sigKeyProblem(sig, member, memberPub, teams, delegated);
310
+ if (keyOk)
311
+ return keyOk;
312
+ if (!m.id)
313
+ return "a signed act names its own id";
314
+ if (Math.abs(Date.now() - sig.ts) > DAY)
315
+ return "signed at a time more than a day from now — check this machine's clock";
316
+ const ok = teams.filter((t) => !!t).some((team) => verifyBytes(sig.pub, actPayload({
317
+ id: m.id, team, member, keyRole: sig.role, nonce: sig.nonce, ts: sig.ts,
318
+ act: m.act, goal: m.goal ?? null, ref: m.ref ?? null, to: m.to ?? null,
319
+ defers_to: m.defers_to ?? null, affects: m.affects ?? null, repo: m.repo ?? null,
320
+ summary: m.summary ?? null, body: m.body, meta: m.meta ?? {},
321
+ }), sig.s));
322
+ return ok ? null : "the signature does not match this act";
323
+ }
324
+ function goalSigProblem(m, member, memberPub, teams, delegated) {
325
+ const sig = m.sig;
326
+ if (!sig)
327
+ return "a woken run's session must sign the goals it opens";
328
+ const inviteSent = m.invite ? [...new Set(m.invite)].sort().join(",") : null;
329
+ const inviteSigned = sig.invite === null ? null : [...new Set(sig.invite)].sort().join(",");
330
+ if (inviteSent !== inviteSigned)
331
+ return "the signed invitations are not the ones sent";
332
+ if ((sig.budget ?? null) !== (m.budget ?? null) || (sig.deadline ?? null) !== (m.deadline ?? null) || (sig.kind ?? null) !== (m.kind ?? null))
333
+ return "the signed budget, deadline or kind is not what was sent";
334
+ const keyOk = sigKeyProblem(sig, member, memberPub, teams, delegated);
335
+ if (keyOk)
336
+ return keyOk;
337
+ if (!m.id)
338
+ return "a signed goal names its own id";
339
+ if (Math.abs(Date.now() - sig.ts) > DAY)
340
+ return "signed at a time more than a day from now — check this machine's clock";
341
+ const ok = teams.filter((t) => !!t).some((team) => verifyBytes(sig.pub, goalPayload({
342
+ id: m.id, team, member, keyRole: sig.role, nonce: sig.nonce, ts: sig.ts,
343
+ title: m.title, acceptance: m.acceptance ?? "", invite: sig.invite,
344
+ budget: sig.budget ?? null, deadline: sig.deadline ?? null, kind: sig.kind ?? null,
345
+ }), sig.s));
346
+ return ok ? null : "the signature does not match this goal";
347
+ }
348
+ function sigKeyProblem(sig, member, memberPub, teams, delegated) {
349
+ if (delegated) {
350
+ if (sig.role !== "run" || sig.pub !== delegated.pub || sig.cert?.pub !== delegated.pub)
351
+ return "a woken run signs with the key its session was opened with";
352
+ }
353
+ if (sig.role === "member")
354
+ return sig.pub === memberPub ? null : `signed with a key that is not ${member}'s`;
355
+ if (!sig.cert || sig.cert.pub !== sig.pub)
356
+ return "a run's signature needs its certificate";
357
+ const c = sig.cert;
358
+ return teams.filter((t) => !!t).some((t) => verifyDelegate(memberPub, t, member, c)) ? null : `that run key is not vouched for by ${member}, or it has expired`;
359
+ }
360
+ function withoutFromRepo(meta) {
361
+ if (!meta || typeof meta !== "object")
362
+ return {};
363
+ const { from_repo: _f, ...rest } = meta;
364
+ return rest;
365
+ }
290
366
  export class Relay {
291
367
  opts;
292
368
  spaces = new Map();
@@ -357,6 +433,7 @@ export class Relay {
357
433
  authEpoch = 0;
358
434
  rotateOnRemove;
359
435
  backupTimer;
436
+ get features() { return this.opts.features ?? RELAY_FEATURES; }
360
437
  constructor(opts = {}) {
361
438
  this.opts = opts;
362
439
  this.dataDir = opts.dataDir ?? "./pantheon-data";
@@ -566,12 +643,12 @@ export class Relay {
566
643
  const now = Date.now();
567
644
  const owed = [...space.obligations.values()]
568
645
  .filter((o) => (member ? o.owner === member : true))
569
- .map((o) => ({ ...o, claimed: !!o.claimed_by && now - o.claimed_by.at < LIMITS.claimTtlMs }));
646
+ .map((o) => ({ ...o, claimed: !!o.claimed_by && this.claimHolds(space, o.claimed_by, now) }));
570
647
  const closed = [...space.goals.values()].filter((g) => g.state === "CLOSED").map((g) => g.id);
571
648
  const worker_online = member
572
649
  ? [...(space.members.get(member)?.sessions.values() ?? [])].some((s) => s.role === "worker")
573
650
  : false;
574
- return this.json(res, 200, { obligations: owed, closed_goals: closed, seq: space.ledger.head, worker_online });
651
+ return this.json(res, 200, { obligations: owed, closed_goals: closed, seq: space.ledger.head, worker_online, space_id: space.allow.spaceId(), members: this.members(space) });
575
652
  }
576
653
  if (url.pathname === "/activity") {
577
654
  if (req.method !== "POST")
@@ -613,7 +690,10 @@ export class Relay {
613
690
  if (oldest)
614
691
  member.activity.delete(oldest[0]);
615
692
  }
616
- member.activity.set(session, { busy: state === "busy", at: Date.now() });
693
+ const prev = member.activity.get(session);
694
+ if (prev?.ts !== undefined && ts < prev.ts)
695
+ return this.json(res, 409, { error: "an activity report older than the last one from this session — ignored" });
696
+ member.activity.set(session, { busy: state === "busy", at: Date.now(), ts });
617
697
  const seen = Number(url.searchParams.get("seen"));
618
698
  if (Number.isFinite(seen) && seen > (member.seen_seq ?? 0))
619
699
  member.seen_seq = seen;
@@ -1621,7 +1701,14 @@ button:hover{background:#1d4ed8}.err{color:var(--bad);margin-top:12px}
1621
1701
  if (!pub || !Number.isFinite(ts) || Math.abs(Date.now() - ts) >= LIMITS.activityFreshMs)
1622
1702
  return refused;
1623
1703
  const { sig, ...signed } = params;
1624
- if (!verifyRead(pub, req.method ?? "GET", url.pathname, signed, sig))
1704
+ let signer = pub;
1705
+ if (signed.dpub) {
1706
+ const cert = { pub: signed.dpub, run: signed.drun ?? "", expires: Number(signed.dexp), sig: signed.dcert ?? "" };
1707
+ if (![target.allow.spaceId(), name].some((t) => t && verifyDelegate(pub, t, reader, cert)))
1708
+ return refused;
1709
+ signer = signed.dpub;
1710
+ }
1711
+ if (!verifyRead(signer, req.method ?? "GET", url.pathname, signed, sig))
1625
1712
  return refused;
1626
1713
  if (this.requireAccounts && this.accounts && !this.localTrusted(ip)) {
1627
1714
  const auth = this.accounts.authorize(pub, reader);
@@ -2235,6 +2322,7 @@ pantheon signup --email you@work.com --username you</code></pre>
2235
2322
  let sessionId = "";
2236
2323
  let role = "interactive";
2237
2324
  let myPubkey = "";
2325
+ let delegated = null;
2238
2326
  let authEpoch = -1, authOk = true;
2239
2327
  let lastMsg = "";
2240
2328
  let alive = true;
@@ -2300,7 +2388,7 @@ pantheon signup --email you@work.com --username you</code></pre>
2300
2388
  helloTimer = null;
2301
2389
  } };
2302
2390
  ws.on("close", clearHelloTimer);
2303
- this.send(ws, { t: "challenge", nonce, v: PROTOCOL_VERSION });
2391
+ this.send(ws, { t: "challenge", nonce, v: PROTOCOL_VERSION, features: [...this.features] });
2304
2392
  ws.on("message", (raw) => {
2305
2393
  if (refusedOut)
2306
2394
  return;
@@ -2333,7 +2421,11 @@ pantheon signup --email you@work.com --username you</code></pre>
2333
2421
  return reject("BAD_NONCE", "challenge mismatch — reconnect");
2334
2422
  if (!m.token && !m.enroll)
2335
2423
  return reject("NO_CREDENTIAL", "present either a space token or an invite");
2336
- if (!verifyChallenge(m.pubkey, m.space, m.member, m.nonce, m.sig)) {
2424
+ if (m.delegate && !this.features.includes("delegate"))
2425
+ return reject("BAD_SIGNATURE", `signature does not match the presented key for "${m.member}"`);
2426
+ if (m.delegate && m.enroll)
2427
+ return reject("BAD_DELEGATE", "a woken run cannot redeem an invite");
2428
+ if (!verifyChallenge(m.delegate ? m.delegate.pub : m.pubkey, m.space, m.member, m.nonce, m.sig)) {
2337
2429
  return reject("BAD_SIGNATURE", `signature does not match the presented key for "${m.member}"`);
2338
2430
  }
2339
2431
  const gated = this.requireAccounts && !!this.accounts && !this.localTrusted(ip);
@@ -2481,17 +2573,24 @@ pantheon signup --email you@work.com --username you</code></pre>
2481
2573
  newRepo = m.repo;
2482
2574
  else {
2483
2575
  const known = sp.allow.repoList().map((r) => r.origin ?? r.fingerprint).join(", ");
2484
- this.log(`[${sp.name}] ! REFUSED ${m.member}: repo ${m.repo_origin ?? m.repo} not bound to this space`);
2576
+ this.log(`[${sp.name}] ! REFUSED ${m.member}: repo ${repoLabelSafe(m.repo_origin) ?? m.repo} not bound to this space`);
2485
2577
  return reject("REPO_MISMATCH", `this checkout (${m.repo_origin ?? "no origin"}) is not one of the repos "${sp.name}" works from (${known || "none yet"}). ` +
2486
2578
  `Open your agent in the team's repo, or if this repo really belongs to the team: pantheon repo add`);
2487
2579
  }
2488
2580
  }
2581
+ if (m.delegate) {
2582
+ const teamIds = [sp.allow.spaceId(), sp.name];
2583
+ if (!teamIds.some((t) => verifyDelegate(m.pubkey, t, m.member, m.delegate))) {
2584
+ return reject("BAD_DELEGATE", `that run key is not vouched for by ${m.member}'s key for "${sp.name}", or its certificate has expired`);
2585
+ }
2586
+ delegated = { pub: m.delegate.pub, run: m.delegate.run, expires: m.delegate.expires };
2587
+ }
2489
2588
  space = sp;
2490
2589
  myPubkey = m.pubkey;
2491
2590
  clearHelloTimer();
2492
2591
  sessionId = acctSession ?? this.mintSession();
2493
- role = m.role;
2494
- me = this.attach(sp, m.member, m.display ?? m.member, m.agent ?? "unknown", sessionId, m.role, ws, m.pkg);
2592
+ role = delegated ? "worker" : m.role;
2593
+ me = this.attach(sp, m.member, m.display ?? m.member, m.agent ?? "unknown", sessionId, role, ws, m.pkg);
2495
2594
  const firstSession = me.sessions.size === 1;
2496
2595
  const prior = typeof m.session === "string" ? m.session : "";
2497
2596
  const stale = prior && prior !== sessionId ? me.sessions.get(prior) : undefined;
@@ -2519,16 +2618,17 @@ pantheon signup --email you@work.com --username you</code></pre>
2519
2618
  this.pushObligations(sp);
2520
2619
  }
2521
2620
  sp.lastTouch = Date.now();
2522
- if (newRepo && sp.allow.addRepo(newRepo, m.repo_origin ?? null, me.id)) {
2523
- this.log(`[${sp.name}] + repo ${m.repo_origin ?? newRepo} registered by ${me.id}`);
2524
- this.commit(sp, { from: me.id, act: "NOTE", to: null, goal: null, ref: null, body: `repo registered with this space: ${m.repo_origin ?? newRepo}`, meta: { repo: newRepo, repo_origin: m.repo_origin ?? null } });
2621
+ if (newRepo && sp.allow.addRepo(newRepo, repoLabelSafe(m.repo_origin), me.id)) {
2622
+ const label = repoLabelSafe(m.repo_origin);
2623
+ this.log(`[${sp.name}] + repo ${label ?? newRepo} registered by ${me.id}`);
2624
+ this.commit(sp, { from: me.id, act: "NOTE", to: null, goal: null, ref: null, body: `repo registered with this space: ${label ?? newRepo}`, meta: { repo: newRepo, repo_origin: label ?? null } });
2525
2625
  this.broadcast(sp, { t: "repos", repos: sp.allow.repoList() });
2526
2626
  }
2527
2627
  this.send(ws, {
2528
2628
  t: "welcome", member: me.id, session: sessionId, space: sp.name, seq: sp.ledger.head,
2529
2629
  roster: this.roster(sp), goals: [...sp.goals.values()],
2530
2630
  obligations: [...sp.obligations.values()], members: this.members(sp), profiles: [...sp.profiles.values()],
2531
- space_id: sp.allow.spaceId(), repos: sp.allow.repoList(),
2631
+ space_id: sp.allow.spaceId(), repos: sp.allow.repoList(), features: [...this.features],
2532
2632
  ...(acct ? { plan: this.planView(acct, sp) } : {}),
2533
2633
  ...(enrolled ? { token: enrolled } : staleToken && this.knownToken(sp) ? { token: sp.token } : {}),
2534
2634
  });
@@ -2565,6 +2665,14 @@ pantheon signup --email you@work.com --username you</code></pre>
2565
2665
  this.hangUp(ws, "REVOKED");
2566
2666
  return;
2567
2667
  }
2668
+ if (delegated && Date.now() > delegated.expires + 10 * 60_000) {
2669
+ if (ws.readyState === 1)
2670
+ this.hangUp(ws, "REVOKED");
2671
+ return;
2672
+ }
2673
+ if (delegated && m.t !== "act" && m.t !== "open_goal" && m.t !== "claim" && m.t !== "worker_state") {
2674
+ return fail("NOT_FOR_A_RUN", `an unattended run cannot ${m.t.replace(/_/g, " ")} — its member does that from their own session`, m.cid);
2675
+ }
2568
2676
  if (m.t === "publish_profile") {
2569
2677
  if (m.profile.member !== me.id) {
2570
2678
  return fail("PROFILE_NOT_YOURS", `you may publish only the profile for ${me.id}`, m.cid);
@@ -2597,6 +2705,13 @@ pantheon signup --email you@work.com --username you</code></pre>
2597
2705
  const target = space.allow.get(m.member);
2598
2706
  if (!target)
2599
2707
  return fail("NO_SUCH_MEMBER", `"${m.member}" has never connected, so there is no key to admit`, m.cid);
2708
+ if (!m.revoke && !m.forget && !m.wake_only && !target.admitted && space.allow.removedWhen(m.member) !== null) {
2709
+ const founder = space.allow.founder();
2710
+ if (founder && me.id !== founder)
2711
+ return fail("FOUNDER_ONLY", `${m.member} was removed from "${space.name}"; only ${founder}, who founded it, can bring them back`, m.cid);
2712
+ }
2713
+ if (m.wake_only && (m.revoke || m.forget || !target.admitted))
2714
+ return fail("NOT_ADMITTED", `"${m.member}" is not in "${space.name}" now, so there is no wake permission to change`, m.cid);
2600
2715
  if (!m.revoke && !m.forget) {
2601
2716
  const plan = this.spacePlan(space.name);
2602
2717
  if (plan && m.may_wake && !plan.limits.wake)
@@ -2727,14 +2842,14 @@ pantheon signup --email you@work.com --username you</code></pre>
2727
2842
  if (existing && m.base && existing.added_by !== me.id && space.allow.founder() !== me.id) {
2728
2843
  return fail("NOT_YOURS", `only ${existing.added_by} (who registered this repo) or the team's founder can change which branch its runs start from`, m.cid);
2729
2844
  }
2730
- const added = space.allow.addRepo(m.repo, m.origin ?? null, me.id, m.base ?? null);
2845
+ const added = space.allow.addRepo(m.repo, repoLabelSafe(m.origin), me.id, m.base ?? null);
2731
2846
  if (!added && m.base && space.allow.setRepoBase(m.repo, m.base)) {
2732
- this.log(`[${space.name}] ~ repo ${m.origin ?? m.repo} base branch → ${m.base} (by ${me.id})`);
2847
+ this.log(`[${space.name}] ~ repo ${repoLabelSafe(m.origin) ?? m.repo} base branch → ${m.base} (by ${me.id})`);
2733
2848
  this.broadcast(space, { t: "repos", repos: space.allow.repoList() });
2734
2849
  }
2735
2850
  if (added) {
2736
- this.log(`[${space.name}] + repo ${m.origin ?? m.repo} registered by ${me.id}${m.base ? ` (base ${m.base})` : ""}`);
2737
- this.commit(space, { from: me.id, session: sessionId, role, act: "NOTE", to: null, goal: null, ref: null, body: `repo registered with this space: ${m.origin ?? m.repo}`, meta: { repo: m.repo, repo_origin: m.origin ?? null } });
2851
+ this.log(`[${space.name}] + repo ${repoLabelSafe(m.origin) ?? m.repo} registered by ${me.id}${m.base ? ` (base ${m.base})` : ""}`);
2852
+ this.commit(space, { from: me.id, session: sessionId, role, act: "NOTE", to: null, goal: null, ref: null, body: `repo registered with this space: ${repoLabelSafe(m.origin) ?? m.repo}`, meta: { repo: m.repo, repo_origin: repoLabelSafe(m.origin) ?? null } });
2738
2853
  this.broadcast(space, { t: "repos", repos: space.allow.repoList() });
2739
2854
  }
2740
2855
  this.send(ws, { t: "ack", cid: m.cid, id: m.repo, seq: space.ledger.head });
@@ -2770,11 +2885,11 @@ pantheon signup --email you@work.com --username you</code></pre>
2770
2885
  }
2771
2886
  }
2772
2887
  const held = space.contLocks.get(m.obligation);
2773
- if (held && held.session !== sessionId && Date.now() - held.at < LIMITS.claimTtlMs) {
2888
+ if (held && held.session !== sessionId && this.claimHolds(space, held)) {
2774
2889
  return fail("CLAIMED", `another of your sessions (${held.session}) is handling this`, m.cid);
2775
2890
  }
2776
2891
  for (const [k, v] of space.contLocks) {
2777
- if (Date.now() - v.at > LIMITS.claimTtlMs)
2892
+ if (!this.claimHolds(space, v))
2778
2893
  space.contLocks.delete(k);
2779
2894
  }
2780
2895
  space.contLocks.set(m.obligation, { session: sessionId, member: me.id, at: Date.now() });
@@ -2787,7 +2902,7 @@ pantheon signup --email you@work.com --username you</code></pre>
2787
2902
  if (o.owner !== me.id)
2788
2903
  return fail("NOT_YOURS", `${o.owner} owes that, not you`, m.cid);
2789
2904
  if (o.claimed_by && o.claimed_by.session !== sessionId) {
2790
- if (Date.now() - o.claimed_by.at < LIMITS.claimTtlMs) {
2905
+ if (this.claimHolds(space, o.claimed_by)) {
2791
2906
  const holder = o.claimed_by;
2792
2907
  const live = [...me.sessions.keys()].includes(holder.session);
2793
2908
  const left = Math.ceil((LIMITS.claimTtlMs - (Date.now() - holder.at)) / 60_000);
@@ -2826,12 +2941,19 @@ pantheon signup --email you@work.com --username you</code></pre>
2826
2941
  if (!space.members.has(id) && !space.allow.get(id)?.admitted)
2827
2942
  return fail("NO_SUCH_MEMBER", `nobody here is called "${id}"`, m.cid);
2828
2943
  }
2944
+ if (!this.features.includes("act-sig"))
2945
+ delete m.sig;
2946
+ if (m.sig || delegated) {
2947
+ const why = goalSigProblem(m, me.id, myPubkey, [space.allow.spaceId(), space.name], delegated);
2948
+ if (why)
2949
+ return fail("BAD_GOAL_SIGNATURE", why, m.cid);
2950
+ }
2829
2951
  const invited = (m.invite ?? [...space.members.keys()]).filter((x) => x !== me.id);
2830
2952
  if (m.deadline !== undefined && m.deadline <= Date.now()) {
2831
2953
  return fail("DEADLINE_PAST", `deadline ${new Date(m.deadline).toISOString()} is already in the past`, m.cid);
2832
2954
  }
2833
2955
  const goal = {
2834
- id: newId("g"),
2956
+ id: m.id && !space.goals.has(m.id) ? m.id : newId("g"),
2835
2957
  title: m.title,
2836
2958
  acceptance: m.acceptance,
2837
2959
  members: [me.id, ...new Set(invited)],
@@ -2842,6 +2964,7 @@ pantheon signup --email you@work.com --username you</code></pre>
2842
2964
  opened_at: Date.now(),
2843
2965
  ...(m.deadline !== undefined ? { deadline: m.deadline } : {}),
2844
2966
  ...(m.kind !== undefined ? { kind: m.kind } : {}),
2967
+ ...(m.sig ? { sig: m.sig } : {}),
2845
2968
  };
2846
2969
  space.goals.set(goal.id, goal);
2847
2970
  space.store.flush(space.goals, space.obligations, space.profiles);
@@ -2851,13 +2974,27 @@ pantheon signup --email you@work.com --username you</code></pre>
2851
2974
  body: `goal opened: ${goal.title}\nacceptance: ${goal.acceptance || "(none stated)"}` +
2852
2975
  (goal.deadline ? `\ndeadline: ${new Date(goal.deadline).toISOString()}` : "") +
2853
2976
  (goal.kind === "worklist" ? `\nkind: worklist — ${me.id}'s own list; peers may read it, nobody owes anything in it` : ""),
2854
- meta: { goal_members: goal.members, goal_budget: goal.budget },
2977
+ meta: { goal_members: goal.members, goal_budget: goal.budget, ...(m.sig ? { goal_sig: m.sig, goal_text: { title: goal.title, acceptance: goal.acceptance } } : {}) },
2855
2978
  });
2856
2979
  this.send(ws, { t: "ack", ...(m.cid ? { cid: m.cid } : {}), id: goal.id, seq: rec.seq });
2857
2980
  return;
2858
2981
  }
2859
- const storedMeta = m.meta || m.defers_to || m.affects
2860
- ? { ...(m.meta ?? {}), ...(m.defers_to ? { defers_to: m.defers_to } : {}), ...(m.affects ? { affects: m.affects } : {}) }
2982
+ if (!this.features.includes("act-sig"))
2983
+ delete m.sig;
2984
+ if (m.sig || delegated) {
2985
+ const why = actSigProblem(m, me.id, myPubkey, [space.allow.spaceId(), space.name], delegated);
2986
+ if (why)
2987
+ return fail("BAD_ACT_SIGNATURE", why, m.cid);
2988
+ }
2989
+ if (m.act === "NOTE" && m.meta && ("goal_members" in m.meta || "organization_profile" in m.meta || "notice" in m.meta))
2990
+ return fail("BAD_META", "goal_members, organization_profile and notice are written by the relay, not sent", m.cid);
2991
+ if (m.act === "NOTE" && m.body.startsWith("repo registered with this space: "))
2992
+ return fail("BAD_META", "that line is written by the relay, not sent", m.cid);
2993
+ const fromRepo = m.repo && space.allow.hasRepo(m.repo) ? m.repo : null;
2994
+ const { from_repo: _forged, ...senderMeta } = (m.meta ?? {});
2995
+ const hasMeta = !!m.meta && (Object.keys(senderMeta).length > 0 || _forged === undefined);
2996
+ const storedMeta = hasMeta || m.defers_to || m.affects || fromRepo
2997
+ ? { ...senderMeta, ...(m.defers_to ? { defers_to: m.defers_to } : {}), ...(m.affects ? { affects: m.affects } : {}), ...(fromRepo ? { from_repo: fromRepo } : {}) }
2861
2998
  : undefined;
2862
2999
  const fp = actFingerprint(me.id, m.act, m.body, m.goal ?? null, m.ref ?? null, m.to ?? null, storedMeta);
2863
3000
  const prior = space.recent.get(fp);
@@ -2865,6 +3002,10 @@ pantheon signup --email you@work.com --username you</code></pre>
2865
3002
  this.send(ws, { t: "ack", ...(m.cid ? { cid: m.cid } : {}), id: prior.id, seq: prior.seq });
2866
3003
  return;
2867
3004
  }
3005
+ if (m.sig && m.id && (space.ledger.find(m.id) || space.droppedIds?.has(m.id)))
3006
+ return fail("ID_TAKEN", `an act with id ${m.id} already exists`, m.cid);
3007
+ if (m.sig && m.id && !idIsFresh(m.id))
3008
+ return fail("BAD_ACT_ID", `act id ${m.id} was not made in the last day — check this machine's clock`, m.cid);
2868
3009
  if (m.after_seq !== undefined) {
2869
3010
  const mine = me.id;
2870
3011
  const missed = space.ledger.since(m.after_seq)
@@ -2894,13 +3035,16 @@ pantheon signup --email you@work.com --username you</code></pre>
2894
3035
  if (wasFull && !this.freesRoom(space, me.id, m.act, m.ref ?? null))
2895
3036
  return fail("SPACE_FULL", this.spaceFullMessage(space), m.cid);
2896
3037
  const rec = this.commit(space, {
3038
+ ...(m.sig && m.id ? { id: m.id } : {}),
2897
3039
  from: me.id, session: sessionId, role, act: m.act, body: m.body,
2898
3040
  ...(m.summary ? { summary: m.summary } : {}),
3041
+ ...(m.sig ? { sig: m.sig } : {}),
2899
3042
  to: m.to ?? null, goal: m.goal ?? null, ref: m.ref ?? null,
2900
- ...(m.meta || m.defers_to || m.affects
2901
- ? { meta: { ...(m.meta ?? {}),
3043
+ ...((m.meta && (Object.keys(withoutFromRepo(m.meta)).length > 0 || !("from_repo" in m.meta))) || m.defers_to || m.affects || fromRepo
3044
+ ? { meta: { ...withoutFromRepo(m.meta),
2902
3045
  ...(m.defers_to ? { defers_to: m.defers_to } : {}),
2903
- ...(m.affects ? { affects: m.affects } : {}) } }
3046
+ ...(m.affects ? { affects: m.affects } : {}),
3047
+ ...(fromRepo ? { from_repo: fromRepo } : {}) } }
2904
3048
  : {}),
2905
3049
  });
2906
3050
  if (wasFull && m.act === "CLOSE") {
@@ -3022,6 +3166,10 @@ pantheon signup --email you@work.com --username you</code></pre>
3022
3166
  this.noteStorageFailure("evicting idle spaces", e);
3023
3167
  }
3024
3168
  }
3169
+ claimHolds(sp, holder, now = Date.now()) {
3170
+ const live = [...sp.members.values()].some((m) => m.sessions.has(holder.session));
3171
+ return now - holder.at < (live ? LIMITS.claimLiveTtlMs : LIMITS.claimTtlMs);
3172
+ }
3025
3173
  lapseClaims(sp, now) {
3026
3174
  const live = new Set();
3027
3175
  for (const m of sp.members.values())
@@ -3029,14 +3177,15 @@ pantheon signup --email you@work.com --username you</code></pre>
3029
3177
  live.add(id);
3030
3178
  let lapsed = 0;
3031
3179
  for (const o of sp.obligations.values()) {
3032
- if (o.claimed_by && !live.has(o.claimed_by.session) && now - o.claimed_by.at >= LIMITS.claimTtlMs) {
3180
+ if (o.claimed_by && !this.claimHolds(sp, o.claimed_by, now)) {
3033
3181
  delete o.claimed_by;
3034
3182
  lapsed++;
3035
3183
  }
3036
3184
  }
3037
3185
  for (const [k, v] of sp.contLocks)
3038
- if (!live.has(v.session) && now - v.at >= LIMITS.claimTtlMs)
3186
+ if (!this.claimHolds(sp, v, now))
3039
3187
  sp.contLocks.delete(k);
3188
+ void live;
3040
3189
  if (lapsed)
3041
3190
  this.pushObligations(sp);
3042
3191
  }
@@ -3071,6 +3220,10 @@ pantheon signup --email you@work.com --username you</code></pre>
3071
3220
  return { code: "ESCALATE_IS_LOCAL",
3072
3221
  message: "ESCALATE is for your own human. Use HUMAN_REQUIRED to notify one named peer's human." };
3073
3222
  }
3223
+ if (meta && ("authority" in meta || "unverified_domains" in meta)) {
3224
+ delete meta["authority"];
3225
+ delete meta["unverified_domains"];
3226
+ }
3074
3227
  if (act === "HUMAN_REQUIRED" && (!to || to.length !== 1)) {
3075
3228
  return { code: "ONE_HUMAN_REQUIRED",
3076
3229
  message: "HUMAN_REQUIRED must name exactly one human owner. Send separate requests when separate people must decide." };
@@ -3079,6 +3232,9 @@ pantheon signup --email you@work.com --username you</code></pre>
3079
3232
  const domains = Array.isArray(meta?.decision_domains)
3080
3233
  ? [...new Set(meta.decision_domains.filter((x) => typeof x === "string" && x.trim().length > 0))]
3081
3234
  : [];
3235
+ if (domains.some((d) => d.length > 64 || /[\u0000-\u001f\u007f-\u009f\u2028\u2029]/.test(d))) {
3236
+ return { code: "BAD_DECISION_DOMAIN", message: "decision_domains are single-line names of up to 64 characters, as profiles publish them" };
3237
+ }
3082
3238
  if (!domains.length) {
3083
3239
  return { code: "DECISION_DOMAINS_REQUIRED",
3084
3240
  message: "HUMAN_REQUIRED must name decision_domains so the relay can verify the human owner. Call pantheon_route_decision first." };
@@ -3253,7 +3409,10 @@ pantheon signup --email you@work.com --username you</code></pre>
3253
3409
  if ((partial.act === "JOIN" || partial.act === "LEAVE") && this.spaceFull(sp)) {
3254
3410
  return { ...partial, id: newId("a"), seq: sp.ledger.head, ts: Date.now(), space: sp.name };
3255
3411
  }
3256
- const rec = sp.ledger.append({ ...partial, id: newId("a"), ts: Date.now(), space: sp.name }, this.maxLedgerBytes + SPACE_HARD_SLACK);
3412
+ let id = partial.id ?? newId("a");
3413
+ while (!partial.id && sp.ledger.find(id))
3414
+ id = newId("a");
3415
+ const rec = sp.ledger.append({ ...partial, id, ts: Date.now(), space: sp.name }, this.maxLedgerBytes + SPACE_HARD_SLACK);
3257
3416
  sp.lastTouch = rec.ts;
3258
3417
  const m = sp.members.get(rec.from);
3259
3418
  if (m) {
@@ -3365,6 +3524,7 @@ pantheon signup --email you@work.com --username you</code></pre>
3365
3524
  break;
3366
3525
  case "ESCALATE":
3367
3526
  this.oblige(sp, { kind: "HUMAN", owner: `human:${rec.from}`, ref: rec.id, goal: rec.goal,
3527
+ ...(rec.ref ? { about: rec.ref } : {}),
3368
3528
  body: `${rec.from} escalated: ${first(rec.body)}` });
3369
3529
  if (goal && goal.state === "OPEN") {
3370
3530
  goal.state = "BLOCKED";
@@ -3400,6 +3560,7 @@ pantheon signup --email you@work.com --username you</code></pre>
3400
3560
  const alreadyLive = asked && [...sp.obligations.values()].some((o) => o.kind === "HUMAN" && o.ref === asked.id);
3401
3561
  if (asked && decider === rec.from && !stillAnswered && !alreadyLive && !retracted.has(asked.id)) {
3402
3562
  this.oblige(sp, { kind: "HUMAN", owner: `human:${decider}`, ref: asked.id, goal: asked.goal,
3563
+ ...(asked.act === "ESCALATE" && asked.ref ? { about: asked.ref } : {}),
3403
3564
  body: asked.act === "ESCALATE" ? `${asked.from} escalated: ${first(asked.body)}` : `${asked.from} needs a decision: ${first(asked.body)}` });
3404
3565
  const g = asked.goal ? sp.goals.get(asked.goal) : undefined;
3405
3566
  if (g && g.state === "OPEN") {
@@ -3762,8 +3923,9 @@ pantheon signup --email you@work.com --username you</code></pre>
3762
3923
  const budget = typeof meta?.goal_budget === "number" && Number.isInteger(meta.goal_budget) && meta.goal_budget > 0 ? meta.goal_budget : LIMITS.goalBudget;
3763
3924
  return {
3764
3925
  id: r.goal,
3765
- title: mm[1],
3766
- acceptance: mm[2] !== "(none stated)" ? mm[2] : "",
3926
+ title: typeof meta?.goal_text?.title === "string" ? meta.goal_text.title : mm[1],
3927
+ acceptance: typeof meta?.goal_text?.acceptance === "string" ? meta.goal_text.acceptance : (mm[2] !== "(none stated)" ? mm[2] : ""),
3928
+ ...(meta?.goal_sig && typeof meta.goal_sig === "object" ? { sig: meta.goal_sig } : {}),
3767
3929
  members,
3768
3930
  state: "OPEN",
3769
3931
  acts: 0,
@@ -3785,7 +3947,18 @@ pantheon signup --email you@work.com --username you</code></pre>
3785
3947
  return !g || g.state !== "CLOSED";
3786
3948
  };
3787
3949
  const referenced = new Set([...sp.obligations.values()].map((o) => o.ref));
3950
+ const before = sp.ledger.all().map((r) => r.id);
3788
3951
  const { dropped, kept } = sp.ledger.compact({ keepRecent: this.opts.ledgerKeepRecent ?? LIMITS.ledgerKeepRecent, isLive, keep: (id) => referenced.has(id) });
3952
+ if (dropped) {
3953
+ const now = Date.now();
3954
+ const d = (sp.droppedIds ??= new Map());
3955
+ for (const [id, at] of d)
3956
+ if (now - at > DAY)
3957
+ d.delete(id);
3958
+ for (const id of before)
3959
+ if (!sp.ledger.find(id))
3960
+ d.set(id, now);
3961
+ }
3789
3962
  if (dropped)
3790
3963
  this.log(`[${sp.name}] compacted ledger — dropped ${dropped} old act(s) from closed goals, ${kept} kept`);
3791
3964
  }
@@ -3821,7 +3994,7 @@ pantheon signup --email you@work.com --username you</code></pre>
3821
3994
  members(sp) {
3822
3995
  return sp.allow.all().map((e) => ({
3823
3996
  member: e.member, admitted: e.admitted, may_wake: e.may_wake,
3824
- fingerprint: fingerprint(e.publicKey),
3997
+ fingerprint: fingerprint(e.publicKey), pubkey: e.publicKey,
3825
3998
  ...(e.admitted_by ? { admitted_by: e.admitted_by } : {}),
3826
3999
  ...(e.revoked_at ? { revoked_at: e.revoked_at, ...(e.revoked_by ? { revoked_by: e.revoked_by } : {}) } : {}),
3827
4000
  ...(sp.allow.founder() === e.member ? { founder: true } : {}),
@@ -0,0 +1,127 @@
1
+ import { existsSync, readdirSync, readFileSync, statSync } from "node:fs";
2
+ import { homedir, platform } from "node:os";
3
+ import { join, resolve } from "node:path";
4
+ import { repoInfo } from "./config.js";
5
+ const SKIP = new Set(["node_modules", "vendor", "dist", "build", "target", "out", ".next", "Pods", "DerivedData", "venv", ".venv", "__pycache__", "Library", "Applications"]);
6
+ export function reposUnder(dir, depth = 3, budget = { left: 4000 }) {
7
+ const out = [];
8
+ const seen = new Set();
9
+ const walk = (d, level) => {
10
+ if (budget.left-- <= 0)
11
+ return;
12
+ if (existsSync(join(d, ".git"))) {
13
+ const r = repoInfo(d);
14
+ if (r && !seen.has(r.root)) {
15
+ seen.add(r.root);
16
+ out.push(r);
17
+ }
18
+ return;
19
+ }
20
+ if (level >= depth)
21
+ return;
22
+ let entries = [];
23
+ try {
24
+ entries = readdirSync(d);
25
+ }
26
+ catch {
27
+ return;
28
+ }
29
+ for (const e of entries) {
30
+ if (e.startsWith(".") || SKIP.has(e))
31
+ continue;
32
+ const p = join(d, e);
33
+ try {
34
+ if (!statSync(p).isDirectory())
35
+ continue;
36
+ }
37
+ catch {
38
+ continue;
39
+ }
40
+ walk(p, level + 1);
41
+ }
42
+ };
43
+ walk(resolve(dir), 0);
44
+ return out;
45
+ }
46
+ export function holdsRepo(dir, budget = 20_000) {
47
+ let left = budget;
48
+ const stack = [resolve(dir)];
49
+ while (stack.length) {
50
+ const d = stack.pop();
51
+ if (left-- <= 0)
52
+ return true;
53
+ let entries = [];
54
+ try {
55
+ entries = readdirSync(d, { withFileTypes: true });
56
+ }
57
+ catch {
58
+ continue;
59
+ }
60
+ for (const e of entries) {
61
+ if (e.name === ".git")
62
+ return true;
63
+ if (e.isDirectory())
64
+ stack.push(join(d, e.name));
65
+ }
66
+ }
67
+ return false;
68
+ }
69
+ export function commonCodeRoots(home = homedir()) {
70
+ return ["code", "Code", "src", "dev", "Developer", "projects", "Projects", "workspace", "work", "repos", "git", "github", "GitHub", "Documents/GitHub", "Documents/code", "Sites"]
71
+ .map((d) => join(home, d)).filter((d) => { try {
72
+ return statSync(d).isDirectory();
73
+ }
74
+ catch {
75
+ return false;
76
+ } });
77
+ }
78
+ export function openedFolders(home = homedir(), os = platform()) {
79
+ const out = new Set();
80
+ const add = (p) => { if (typeof p === "string" && (p.startsWith("/") || /^[A-Za-z]:[\\/]/.test(p)))
81
+ out.add(p); };
82
+ try {
83
+ for (const k of Object.keys(JSON.parse(readFileSync(join(home, ".claude.json"), "utf8")).projects ?? {}))
84
+ add(k);
85
+ }
86
+ catch { }
87
+ const appData = os === "darwin" ? join(home, "Library", "Application Support") : os === "win32" ? (process.env["APPDATA"] ?? join(home, "AppData", "Roaming")) : join(home, ".config");
88
+ for (const app of ["Code", "Cursor", "Code - Insiders", "Windsurf"]) {
89
+ try {
90
+ const text = readFileSync(join(appData, app, "User", "globalStorage", "storage.json"), "utf8");
91
+ for (const m of text.matchAll(/"file:\/\/([^"]+)"/g)) {
92
+ try {
93
+ add(decodeURIComponent(m[1]).replace(/^\/([A-Za-z]:)/, "$1"));
94
+ }
95
+ catch { }
96
+ }
97
+ }
98
+ catch { }
99
+ }
100
+ return [...out];
101
+ }
102
+ export function findTeamClones(teamFps, opts = {}) {
103
+ const want = new Set(teamFps);
104
+ const found = new Map();
105
+ const consider = (r, how) => { if (r && want.has(r.fingerprint) && !found.has(r.fingerprint))
106
+ found.set(r.fingerprint, { repo: r, how }); };
107
+ const home = opts.home ?? homedir();
108
+ if (opts.recents)
109
+ for (const d of openedFolders(home, opts.os)) {
110
+ if (found.size === want.size)
111
+ break;
112
+ try {
113
+ if (existsSync(d))
114
+ consider(repoInfo(d), "opened in your editor or agent");
115
+ }
116
+ catch { }
117
+ }
118
+ const budget = { left: 6000 };
119
+ for (const root of [...(opts.extra ?? []), ...commonCodeRoots(home)]) {
120
+ if (found.size === want.size)
121
+ break;
122
+ for (const r of reposUnder(root, 3, budget))
123
+ consider(r, `found in ${root.startsWith(home) ? `~${root.slice(home.length)}` : root}`);
124
+ }
125
+ return [...found.values()];
126
+ }
127
+ //# sourceMappingURL=repo-find.js.map