sentinelayer-cli 0.41.0 → 0.42.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 (44) hide show
  1. package/README.md +18 -0
  2. package/package.json +3 -2
  3. package/src/auth/admission-scope.js +34 -0
  4. package/src/auth/gate.js +6 -0
  5. package/src/auth/http.js +4 -0
  6. package/src/auth/service.js +6 -0
  7. package/src/board/done-gate.js +155 -0
  8. package/src/board/evidence-resolvers.js +138 -0
  9. package/src/cli.js +24 -1
  10. package/src/commands/board.js +116 -0
  11. package/src/commands/session.js +483 -47
  12. package/src/engram/build-state.js +183 -0
  13. package/src/engram/canonical.js +75 -0
  14. package/src/engram/document.js +190 -0
  15. package/src/engram/index.js +11 -2
  16. package/src/engram/ingest-document.js +107 -0
  17. package/src/engram/store.js +6 -1
  18. package/src/engram/tools.js +6 -0
  19. package/src/events/schema.js +18 -0
  20. package/src/legacy-cli.js +2 -2
  21. package/src/net/test-egress-guard.js +158 -0
  22. package/src/review/investor-dd-orchestrator.js +14 -2
  23. package/src/session/admission-auth.js +312 -0
  24. package/src/session/admission-preimage.js +90 -0
  25. package/src/session/admission.js +620 -0
  26. package/src/session/checkpoints.js +25 -1
  27. package/src/session/codebase-context.js +54 -0
  28. package/src/session/coordination-guidance.js +1 -1
  29. package/src/session/daemon-spawn.js +20 -2
  30. package/src/session/daemon.js +7 -1
  31. package/src/session/event-identity.js +9 -2
  32. package/src/session/first-message.js +11 -3
  33. package/src/session/listener.js +3 -0
  34. package/src/session/live-source.js +47 -16
  35. package/src/session/message-edits.js +89 -0
  36. package/src/session/recap.js +26 -4
  37. package/src/session/remote-hydrate.js +7 -19
  38. package/src/session/store.js +13 -14
  39. package/src/session/stream.js +23 -3
  40. package/src/session/sync.js +128 -11
  41. package/src/session/tickets.js +316 -0
  42. package/src/session/title-sync.js +2 -1
  43. package/src/session/usage-report.js +41 -2
  44. package/src/session/wake/resolve-target.js +10 -5
@@ -0,0 +1,620 @@
1
+ // admission.js — the agent side of goal-and-scope admission (API SPEC "Agent
2
+ // Admission Contract").
3
+ //
4
+ // Flow: request (goal + closed-enum scope + our public key) -> a human approves in
5
+ // the web UI -> poll -> claim by signing a server nonce with OUR private key ->
6
+ // receive a room-scoped, expiring credential plus a passport over our key.
7
+ //
8
+ // Invariants this module keeps:
9
+ // - The private key is generated here and never leaves this machine. Only the
10
+ // raw public key is sent. Files holding secrets are written 0600, atomically.
11
+ // - We sign only a claim preimage that names OUR public key and OUR agent id.
12
+ // A server response naming anything else is refused, not signed.
13
+ // - The admission credential is never printed or logged. Callers get a storage
14
+ // reference; the token lives only in the 0600 credential file.
15
+ // - Waiting is bounded, honours the server's poll interval, and is resumable:
16
+ // non-secret state is persisted per room so a re-run continues the same
17
+ // request instead of filing a new one.
18
+
19
+ import {
20
+ createHash,
21
+ createPrivateKey,
22
+ createPublicKey,
23
+ generateKeyPairSync,
24
+ randomUUID,
25
+ sign as signBytes,
26
+ } from "node:crypto";
27
+ import fsp from "node:fs/promises";
28
+ import os from "node:os";
29
+ import path from "node:path";
30
+ import process from "node:process";
31
+ import { setTimeout as sleepMs } from "node:timers/promises";
32
+
33
+ import { requestJson, requestJsonMutation } from "../auth/http.js";
34
+ import { resolveActiveAuthSession } from "../auth/service.js";
35
+ import { canonicalize } from "../engram/canonical.js";
36
+ import { canonicalPreimage } from "./admission-preimage.js";
37
+ import {
38
+ SESSION_MUTATION_ORIGIN,
39
+ createSessionMutationHeaders,
40
+ createSessionMutationIdempotencyKey,
41
+ } from "./invitations.js";
42
+ import { resolveSessionPaths } from "./paths.js";
43
+
44
+ export const ADMISSION_ACTIONS = Object.freeze([
45
+ "session.read",
46
+ "session.post",
47
+ "session.react",
48
+ "tickets.read",
49
+ "tickets.work",
50
+ ]);
51
+ export const DEFAULT_ADMISSION_ACTIONS = Object.freeze(["session.read", "session.post"]);
52
+ export const CLAIM_DOMAIN = "sentinelayer.admission.claim.v1";
53
+ export const CLAIM_FIELDS = Object.freeze([
54
+ "admissionId",
55
+ "sessionId",
56
+ "agentId",
57
+ "publicKey",
58
+ "keyThumbprint",
59
+ "nonce",
60
+ ]);
61
+ const ROUTE = {
62
+ request: "POST /api/v1/sessions/{session_id}/admissions",
63
+ claim: "POST /api/v1/sessions/{session_id}/admissions/{admission_id}/claim",
64
+ cancel: "POST /api/v1/sessions/{session_id}/admissions/{admission_id}/cancel",
65
+ };
66
+ const TERMINAL = new Set(["denied", "cancelled", "expired", "revoked"]);
67
+ const TTL_MIN_SECONDS = 300;
68
+ const TTL_MAX_SECONDS = 86_400;
69
+
70
+ function normalizeString(value) {
71
+ return String(value ?? "").trim();
72
+ }
73
+
74
+ // One identity, one path, on every filesystem. Ids are case-folded (the API folds
75
+ // agent ids to lowercase, and Windows paths fold case anyway), and ":" -- legal in
76
+ // an id, illegal in a Windows file name -- becomes "%3a". "%" can never appear in
77
+ // an id, so the mapping is injective: "agent:one" and "agent_one" stay distinct.
78
+ function safeSegment(value, label) {
79
+ const text = normalizeString(value).toLowerCase();
80
+ if (!/^[a-z0-9][a-z0-9._:-]{0,63}$/.test(text)) {
81
+ throw new Error(`${label} is not a valid identifier.`);
82
+ }
83
+ return text.replace(/:/g, "%3a");
84
+ }
85
+
86
+ function sentinelayerHome(homeDir) {
87
+ return path.join(homeDir || os.homedir(), ".sentinelayer");
88
+ }
89
+
90
+ async function writeSecretFile(filePath, data) {
91
+ await fsp.mkdir(path.dirname(filePath), { recursive: true, mode: 0o700 });
92
+ const tmp = `${filePath}.${randomUUID()}.tmp`;
93
+ await fsp.writeFile(tmp, `${JSON.stringify(data, null, 2)}\n`, { mode: 0o600, flag: "wx" });
94
+ await fsp.rename(tmp, filePath);
95
+ // rename preserves the tmp file's mode; chmod again in case the file pre-existed
96
+ // with a looser mode on a platform where rename replaced only the contents.
97
+ await fsp.chmod(filePath, 0o600).catch(() => {});
98
+ }
99
+
100
+ async function readJsonFile(filePath) {
101
+ try {
102
+ return JSON.parse(await fsp.readFile(filePath, "utf8"));
103
+ } catch (error) {
104
+ if (error && error.code === "ENOENT") return null;
105
+ throw error;
106
+ }
107
+ }
108
+
109
+ /** Parse "90m", "2h", "1d" or seconds into bounded TTL seconds. */
110
+ export function parseTtlSeconds(value, fallback = 7200) {
111
+ const raw = normalizeString(value);
112
+ if (!raw) return fallback;
113
+ const match = /^(\d+)\s*(s|m|h|d)?$/i.exec(raw);
114
+ if (!match) throw new Error(`--ttl must look like 300, 90m, 2h or 1d (got "${raw}").`);
115
+ const scale = { s: 1, m: 60, h: 3600, d: 86_400 }[(match[2] || "s").toLowerCase()];
116
+ const seconds = Number(match[1]) * scale;
117
+ if (seconds < TTL_MIN_SECONDS || seconds > TTL_MAX_SECONDS) {
118
+ throw new Error(`--ttl must be between 5m and 24h (got ${seconds}s).`);
119
+ }
120
+ return seconds;
121
+ }
122
+
123
+ /** Validate a --scope list against the closed action enum. */
124
+ export function parseScope(value) {
125
+ const raw = normalizeString(value);
126
+ if (!raw) return [...DEFAULT_ADMISSION_ACTIONS];
127
+ const actions = [...new Set(raw.split(",").map((item) => item.trim()).filter(Boolean))];
128
+ const unknown = actions.filter((action) => !ADMISSION_ACTIONS.includes(action));
129
+ if (unknown.length) {
130
+ throw new Error(`Unknown --scope action(s): ${unknown.join(", ")}. Allowed: ${ADMISSION_ACTIONS.join(", ")}.`);
131
+ }
132
+ if (!actions.length) throw new Error("--scope must name at least one action.");
133
+ return actions;
134
+ }
135
+
136
+ // ---------------------------------------------------------------------- keys
137
+
138
+ export function agentKeyPath(agentId, { homeDir } = {}) {
139
+ return path.join(sentinelayerHome(homeDir), "agents", safeSegment(agentId, "agentId"), "identity-ed25519.json");
140
+ }
141
+
142
+ const LINK_UNSUPPORTED = new Set(["ENOTSUP", "ENOSYS", "EXDEV", "EPERM"]);
143
+
144
+ /** The key on disk for this agent, or null. Refuses a file that is not this agent's. */
145
+ async function readAgentKey(keyPath, agent) {
146
+ // A reader can only race a writer on filesystems without hard links (see
147
+ // publishKeyFile), where the file may be seen part-written. Retry briefly.
148
+ for (let attempt = 0; ; attempt += 1) {
149
+ let stored;
150
+ try {
151
+ stored = await readJsonFile(keyPath);
152
+ } catch (error) {
153
+ if (error instanceof SyntaxError && attempt < 5) {
154
+ await sleepMs(20);
155
+ continue;
156
+ }
157
+ throw error;
158
+ }
159
+ if (stored === null) return null;
160
+ if (stored.agentId !== agent || !stored.privateKeyPkcs8 || !stored.publicKey) {
161
+ throw new Error(`The key file at ${keyPath} does not belong to agent "${agent}"; refusing to use it.`);
162
+ }
163
+ const privateKey = createPrivateKey({
164
+ key: Buffer.from(stored.privateKeyPkcs8, "base64url"),
165
+ format: "der",
166
+ type: "pkcs8",
167
+ });
168
+ if (createPublicKey(privateKey).export({ format: "jwk" }).x !== stored.publicKey) {
169
+ throw new Error(`The key file at ${keyPath} is inconsistent (public key does not match); refusing to use it.`);
170
+ }
171
+ return { publicKey: stored.publicKey, privateKey, keyPath, created: false };
172
+ }
173
+ }
174
+
175
+ /**
176
+ * Publish a new key file exactly once. link() is atomic and fails with EEXIST when
177
+ * the name is taken, so of any number of concurrent first uses -- threads or
178
+ * processes -- one wins and nobody overwrites anybody. The file is complete
179
+ * before it has its final name.
180
+ */
181
+ export async function publishKeyFile(keyPath, record) {
182
+ await fsp.mkdir(path.dirname(keyPath), { recursive: true, mode: 0o700 });
183
+ const body = `${JSON.stringify(record, null, 2)}\n`;
184
+ const tmp = `${keyPath}.${randomUUID()}.tmp`;
185
+ await fsp.writeFile(tmp, body, { mode: 0o600, flag: "wx" });
186
+ try {
187
+ await fsp.link(tmp, keyPath);
188
+ } catch (error) {
189
+ if (error?.code === "EEXIST") return;
190
+ if (!LINK_UNSUPPORTED.has(error?.code)) throw error;
191
+ // No hard links here: an exclusive create still guarantees a single winner.
192
+ try {
193
+ await fsp.writeFile(keyPath, body, { mode: 0o600, flag: "wx" });
194
+ } catch (fallbackError) {
195
+ if (fallbackError?.code !== "EEXIST") throw fallbackError;
196
+ }
197
+ } finally {
198
+ await fsp.rm(tmp, { force: true });
199
+ }
200
+ }
201
+
202
+ /**
203
+ * Load this agent's Ed25519 key, creating it on first use. The private half never
204
+ * leaves disk. Every caller, however many race, gets the key that is persisted.
205
+ */
206
+ export async function loadOrCreateAgentKey(agentId, { homeDir } = {}) {
207
+ const agent = normalizeString(agentId).toLowerCase();
208
+ const keyPath = agentKeyPath(agent, { homeDir });
209
+ const existing = await readAgentKey(keyPath, agent);
210
+ if (existing) return existing;
211
+ const { publicKey, privateKey } = generateKeyPairSync("ed25519");
212
+ const rawPublic = publicKey.export({ format: "jwk" }).x; // base64url, 32 raw bytes
213
+ await publishKeyFile(keyPath, {
214
+ version: 1,
215
+ agentId: agent,
216
+ algorithm: "Ed25519",
217
+ publicKey: rawPublic,
218
+ privateKeyPkcs8: privateKey.export({ format: "der", type: "pkcs8" }).toString("base64url"),
219
+ createdAt: new Date().toISOString(),
220
+ });
221
+ const persisted = await readAgentKey(keyPath, agent);
222
+ if (!persisted) throw new Error(`Could not create the agent key at ${keyPath}.`);
223
+ return { ...persisted, created: persisted.publicKey === rawPublic };
224
+ }
225
+
226
+ // ---------------------------------------------------------------- state files
227
+
228
+ // One in-flight request per (session, agent): two agents joining from the same
229
+ // workspace never read or overwrite each other's pending request.
230
+ function admissionStatePath(targetPath, sessionId, agentId) {
231
+ const paths = resolveSessionPaths(sessionId, { targetPath });
232
+ return path.join(paths.sessionDir, "admissions", `${safeSegment(agentId, "agentId")}.json`);
233
+ }
234
+
235
+ export function admissionCredentialPath(sessionId, agentId, { homeDir } = {}) {
236
+ return path.join(
237
+ sentinelayerHome(homeDir),
238
+ "agents",
239
+ safeSegment(agentId, "agentId"),
240
+ "admissions",
241
+ `${safeSegment(sessionId, "sessionId")}.json`
242
+ );
243
+ }
244
+
245
+ /**
246
+ * What this machine holds for (session, agent):
247
+ * { state: "none" } -- never admitted here: the legacy path applies
248
+ * { state: "live", credential } -- use it, and only it
249
+ * { state: "refused", reason, credential } -- a TOMBSTONE: expired, malformed, or bound
250
+ * to another session, agent or authority
251
+ * A refused credential is never read as absence. Absence is the only state that lets a
252
+ * command fall back to the human's own token.
253
+ */
254
+ export async function readAdmissionCredentialState(sessionId, agentId, { homeDir, now = Date.now() } = {}) {
255
+ const sid = normalizeString(sessionId).toLowerCase();
256
+ const agent = normalizeString(agentId).toLowerCase();
257
+ let filePath;
258
+ try {
259
+ filePath = admissionCredentialPath(sid, agent, { homeDir });
260
+ } catch {
261
+ return { state: "none" }; // not a storable identity, so nothing can be stored for it
262
+ }
263
+ // Only a MISSING file is absence. A present file that is unreadable, or parses to
264
+ // null, an array or a scalar, is a tombstone: it never permits the legacy path.
265
+ let raw;
266
+ try {
267
+ raw = await fsp.readFile(filePath, "utf8");
268
+ } catch (error) {
269
+ if (error && error.code === "ENOENT") return { state: "none" };
270
+ return { state: "refused", reason: "malformed", credential: null };
271
+ }
272
+ let stored;
273
+ try {
274
+ stored = JSON.parse(raw);
275
+ } catch {
276
+ return { state: "refused", reason: "malformed", credential: null };
277
+ }
278
+ const refused = (reason) => ({ state: "refused", reason, credential: stored });
279
+ if (stored === null || typeof stored !== "object" || Array.isArray(stored)) return refused("malformed");
280
+ if (!String(stored.token || "").startsWith("sladm_")) return refused("malformed");
281
+ if (!Number.isFinite(stored.expiresAt)) return refused("malformed");
282
+ if (normalizeString(stored.sessionId).toLowerCase() !== sid || stored.agentId !== agent) {
283
+ return refused("bound_to_another_session_or_agent");
284
+ }
285
+ if (!/^https?:\/\/[^\s/]+/i.test(normalizeString(stored.apiUrl))) return refused("no_issuing_authority");
286
+ if (stored.expiresAt * 1000 <= now) return refused("expired");
287
+ return { state: "live", credential: stored };
288
+ }
289
+
290
+ /** The canonical digest of what an agent asked for. Any change means a new decision. */
291
+ function requestFingerprint({ apiUrl, agentId, publicKey, goal, actions, ttlSeconds }) {
292
+ const body = canonicalize({
293
+ apiUrl,
294
+ agentId,
295
+ publicKey,
296
+ goal,
297
+ actions: [...actions].sort(),
298
+ ttlSeconds,
299
+ });
300
+ return createHash("sha256").update(body, "utf8").digest("hex");
301
+ }
302
+
303
+ // ---------------------------------------------------------------------- API
304
+
305
+ async function authContext({ targetPath, resolveAuthSession }) {
306
+ const auth = await resolveAuthSession({ cwd: targetPath, env: process.env, autoRotate: false });
307
+ if (!auth?.token || !auth?.apiUrl) {
308
+ throw new Error("Not authenticated. Run `sl auth login` first.");
309
+ }
310
+ return { token: auth.token, apiUrl: normalizeString(auth.apiUrl).replace(/\/+$/, "") };
311
+ }
312
+
313
+ function admissionUrl(apiUrl, sessionId, suffix = "") {
314
+ return `${apiUrl}/api/v1/sessions/${encodeURIComponent(sessionId)}/admissions${suffix}`;
315
+ }
316
+
317
+ async function mutate(auth, sessionId, routeId, url, body, { requestMutation, operationName, origin }) {
318
+ const idempotencyKey = createSessionMutationIdempotencyKey(operationName);
319
+ return requestMutation(url, {
320
+ method: "POST",
321
+ operationName,
322
+ idempotencyKey,
323
+ headers: createSessionMutationHeaders({
324
+ bearerToken: auth.token,
325
+ sessionId,
326
+ routeId,
327
+ idempotencyKey,
328
+ origin,
329
+ }),
330
+ body,
331
+ });
332
+ }
333
+
334
+ /**
335
+ * Request admission, wait for a human decision, and claim the grant.
336
+ * Returns a REDACTED result: the credential token is never included.
337
+ */
338
+ export async function runAdmissionJoin(
339
+ sessionId,
340
+ {
341
+ agentId,
342
+ displayName = "",
343
+ model = "cli",
344
+ provider = "unknown",
345
+ goal,
346
+ deliverables = [],
347
+ stopConditions = [],
348
+ actions = DEFAULT_ADMISSION_ACTIONS,
349
+ ttlSeconds = 7200,
350
+ wait = true,
351
+ waitTimeoutMs = 15 * 60_000,
352
+ targetPath = process.cwd(),
353
+ homeDir,
354
+ origin = SESSION_MUTATION_ORIGIN,
355
+ onPending = () => {},
356
+ resolveAuthSession = resolveActiveAuthSession,
357
+ requestMutation = requestJsonMutation,
358
+ requestRead = requestJson,
359
+ sleep = sleepMs,
360
+ now = () => Date.now(),
361
+ } = {}
362
+ ) {
363
+ const sid = normalizeString(sessionId);
364
+ const agent = normalizeString(agentId).toLowerCase();
365
+ if (!sid) throw new Error("session id is required.");
366
+ if (!agent) throw new Error("--agent is required for admission.");
367
+ // Every entry path, not only the flags the dispatcher sees: admission is never
368
+ // requested for a spelling that later commands would resolve to another identity.
369
+ // Imported lazily: admission-auth imports this module.
370
+ const { assertCanonicalAgentId } = await import("./admission-auth.js");
371
+ assertCanonicalAgentId(agent, "the admission agent id");
372
+ const goalSummary = normalizeString(goal);
373
+ if (!goalSummary) throw new Error("--goal is required: say what this agent is coming to do.");
374
+
375
+ const deps = { requestMutation, origin };
376
+ const auth = await authContext({ targetPath, resolveAuthSession });
377
+ const key = await loadOrCreateAgentKey(agent, { homeDir });
378
+ const goalContract = {
379
+ summary: goalSummary,
380
+ deliverables: deliverables.map(normalizeString).filter(Boolean),
381
+ stopConditions: stopConditions.map(normalizeString).filter(Boolean),
382
+ };
383
+ const fingerprint = requestFingerprint({
384
+ apiUrl: auth.apiUrl,
385
+ agentId: agent,
386
+ publicKey: key.publicKey,
387
+ goal: goalContract,
388
+ actions,
389
+ ttlSeconds,
390
+ });
391
+
392
+ // Reuse locally ONLY for the identical request against the same API with the
393
+ // same key. A different goal, scope or duration is a question for the server,
394
+ // which answers with the existing grant (same purpose) or a conflict (new one).
395
+ const held = await readAdmissionCredentialState(sid, agent, { homeDir, now: now() });
396
+ if (held.state === "live" && held.credential.requestFingerprint === fingerprint) {
397
+ return redacted({ status: "active", reused: true, stored: held.credential, sid, agent, homeDir });
398
+ }
399
+
400
+ const statePath = admissionStatePath(targetPath, sid, agent);
401
+ let state = await readJsonFile(statePath);
402
+ const inFlight = state?.admissionId && state.agentId === agent && ["pending", "approved"].includes(state.status);
403
+ if (inFlight && state.requestFingerprint !== fingerprint) {
404
+ throw new Error(
405
+ `A different admission request (${state.admissionId}) for "${agent}" is already waiting in this room. ` +
406
+ `Cancel it with \`sl session join ${sid} --agent ${agent} --cancel-admission\`, then request again.`
407
+ );
408
+ }
409
+ const resumable = inFlight && state.publicKey === key.publicKey;
410
+
411
+ if (!resumable) {
412
+ const created = await mutate(
413
+ auth,
414
+ sid,
415
+ ROUTE.request,
416
+ admissionUrl(auth.apiUrl, sid),
417
+ {
418
+ agentId: agent,
419
+ displayName: normalizeString(displayName) || null,
420
+ model: normalizeString(model) || "cli",
421
+ provider: normalizeString(provider) || "unknown",
422
+ clientKind: "cli",
423
+ publicKey: key.publicKey,
424
+ popAssurance: "agent_held_key",
425
+ goal: goalContract,
426
+ requestedScope: { actions: [...actions], ttlSeconds },
427
+ },
428
+ { ...deps, operationName: "session.admission_request" }
429
+ );
430
+ if (
431
+ created?.status === "active" &&
432
+ created?.reused &&
433
+ held.state === "live" &&
434
+ held.credential.admissionId === created.admissionId
435
+ ) {
436
+ // The server judged this request covered by the grant we already hold.
437
+ return redacted({ status: "active", reused: true, stored: held.credential, sid, agent, homeDir });
438
+ }
439
+ if (created?.status === "active" && created?.reused) {
440
+ // The server already holds a live grant for this key, but we have no stored
441
+ // credential for it (e.g. a different machine). It cannot be re-issued.
442
+ throw new Error(
443
+ "This agent already holds an active admission here, but no credential is stored " +
444
+ "on this machine. Ask the room owner to revoke it, then join again."
445
+ );
446
+ }
447
+ state = {
448
+ admissionId: created.admissionId,
449
+ agentId: agent,
450
+ publicKey: key.publicKey,
451
+ apiUrl: auth.apiUrl,
452
+ requestFingerprint: fingerprint,
453
+ status: created.status,
454
+ approveUrl: created.approveUrl || null,
455
+ pendingExpiresAt: created.pendingExpiresAt || null,
456
+ requestedAt: new Date(now()).toISOString(),
457
+ };
458
+ await fsp.mkdir(path.dirname(statePath), { recursive: true });
459
+ await fsp.writeFile(statePath, `${JSON.stringify(state, null, 2)}\n`);
460
+ }
461
+
462
+ if (!wait) {
463
+ return { status: state.status, admissionId: state.admissionId, approveUrl: state.approveUrl, waited: false };
464
+ }
465
+
466
+ const deadline = now() + Math.max(0, waitTimeoutMs);
467
+ let polled;
468
+ for (;;) {
469
+ polled = await requestRead(admissionUrl(auth.apiUrl, sid, `/${encodeURIComponent(state.admissionId)}`), {
470
+ method: "GET",
471
+ headers: { Authorization: `Bearer ${auth.token}` },
472
+ });
473
+ state.status = polled.status;
474
+ if (polled.status === "approved" || TERMINAL.has(polled.status) || polled.status === "active") break;
475
+ onPending({ admissionId: state.admissionId, approveUrl: polled.approveUrl || state.approveUrl });
476
+ if (now() >= deadline) {
477
+ await fsp.writeFile(statePath, `${JSON.stringify(state, null, 2)}\n`);
478
+ return {
479
+ status: "pending",
480
+ admissionId: state.admissionId,
481
+ approveUrl: polled.approveUrl || state.approveUrl,
482
+ waited: true,
483
+ timedOut: true,
484
+ };
485
+ }
486
+ const interval = Math.min(Math.max(Number(polled.pollAfterSeconds) || 5, 2), 30) * 1000;
487
+ await sleep(Math.min(interval, Math.max(0, deadline - now())));
488
+ }
489
+
490
+ if (TERMINAL.has(polled.status)) {
491
+ await fsp.rm(statePath, { force: true });
492
+ return { status: polled.status, admissionId: state.admissionId, waited: true };
493
+ }
494
+ if (polled.status === "active") {
495
+ throw new Error("Admission is already active but this machine holds no credential for it.");
496
+ }
497
+
498
+ const fields = polled?.claim?.preimage;
499
+ if (!fields || polled.claim.domain !== CLAIM_DOMAIN) {
500
+ throw new Error("Approved admission did not include a valid claim challenge.");
501
+ }
502
+ // Never sign a claim for a key or agent that is not ours.
503
+ if (fields.publicKey !== key.publicKey || fields.agentId !== agent || fields.sessionId !== sid) {
504
+ throw new Error("Refusing to sign: the claim challenge does not name this agent's key.");
505
+ }
506
+ const signature = signBytes(null, canonicalPreimage(fields, { domain: CLAIM_DOMAIN, fields: [...CLAIM_FIELDS] }), key.privateKey);
507
+ const claimed = await mutate(
508
+ auth,
509
+ sid,
510
+ ROUTE.claim,
511
+ admissionUrl(auth.apiUrl, sid, `/${encodeURIComponent(state.admissionId)}/claim`),
512
+ { nonce: fields.nonce, signature: signature.toString("base64url") },
513
+ { ...deps, operationName: "session.admission_claim" }
514
+ );
515
+
516
+ const stored = {
517
+ version: 2,
518
+ sessionId: sid,
519
+ agentId: agent,
520
+ // The credential is only ever sent to the API that issued it.
521
+ apiUrl: auth.apiUrl,
522
+ publicKey: key.publicKey,
523
+ requestFingerprint: fingerprint,
524
+ admissionId: claimed.admissionId,
525
+ token: claimed.credential.token,
526
+ expiresAt: claimed.grant.expiresAt,
527
+ grantedActions: claimed.grant.actions,
528
+ passportId: claimed.identity.passportId,
529
+ subject: claimed.identity.subject,
530
+ storedAt: new Date(now()).toISOString(),
531
+ };
532
+ await writeSecretFile(admissionCredentialPath(sid, agent, { homeDir }), stored);
533
+ await fsp.rm(statePath, { force: true });
534
+ return redacted({ status: "active", reused: false, stored, claimed, sid, agent, homeDir });
535
+ }
536
+
537
+ function redacted({ status, reused, stored, claimed, sid, agent, homeDir }) {
538
+ return {
539
+ status,
540
+ reused,
541
+ admissionId: stored.admissionId,
542
+ identity: claimed
543
+ ? {
544
+ subject: claimed.identity.subject,
545
+ passportId: claimed.identity.passportId,
546
+ passportStatus: claimed.identity.passportStatus,
547
+ keyThumbprint: claimed.identity.keyThumbprint,
548
+ popAssurance: claimed.identity.popAssurance,
549
+ email: claimed.identity.email,
550
+ passport: claimed.identity.passport,
551
+ }
552
+ : { subject: stored.subject, passportId: stored.passportId },
553
+ grant: claimed
554
+ ? {
555
+ grantId: claimed.grant.grantId,
556
+ sessionId: claimed.grant.sessionId,
557
+ actions: claimed.grant.actions,
558
+ expiresAt: claimed.grant.expiresAt,
559
+ goalDigest: claimed.grant.goalDigest,
560
+ document: claimed.grant.document,
561
+ }
562
+ : { sessionId: sid, actions: stored.grantedActions, expiresAt: stored.expiresAt },
563
+ correlation: claimed ? claimed.correlation : { actorRef: `adm:${stored.admissionId}` },
564
+ jev: claimed ? claimed.jev : undefined,
565
+ // A reference only. The token itself never appears in output.
566
+ credential: { storage: admissionCredentialPath(sid, agent, { homeDir }), redacted: true },
567
+ };
568
+ }
569
+
570
+ /**
571
+ * The grant's own live receipt (GET /admissions/self). Call it INSIDE the agent's
572
+ * admission scope: it then goes out on the admission credential, never the human
573
+ * token, and a narrow grant (no session.read) can still prove it is live. Throws
574
+ * when the API does not accept the admission (revoked, expired, wrong room).
575
+ */
576
+ export async function fetchOwnAdmissionReceipt(
577
+ sessionId,
578
+ { targetPath = process.cwd(), resolveAuthSession = resolveActiveAuthSession, requestRead = requestJson } = {}
579
+ ) {
580
+ const sid = normalizeString(sessionId);
581
+ const auth = await resolveAuthSession({ cwd: targetPath, env: process.env, autoRotate: false });
582
+ if (!auth?.token || auth.source !== "session_admission") {
583
+ throw new Error("The admission receipt must be fetched with the admission credential.");
584
+ }
585
+ const apiUrl = normalizeString(auth.apiUrl).replace(/\/+$/, "");
586
+ return requestRead(admissionUrl(apiUrl, sid, "/self"), {
587
+ method: "GET",
588
+ headers: { Authorization: `Bearer ${auth.token}` },
589
+ });
590
+ }
591
+
592
+ /** Cancel this agent's pending admission request filed from this workspace. */
593
+ export async function cancelAdmission(
594
+ sessionId,
595
+ {
596
+ agentId,
597
+ targetPath = process.cwd(),
598
+ origin = SESSION_MUTATION_ORIGIN,
599
+ resolveAuthSession = resolveActiveAuthSession,
600
+ requestMutation = requestJsonMutation,
601
+ } = {}
602
+ ) {
603
+ const sid = normalizeString(sessionId);
604
+ const agent = normalizeString(agentId).toLowerCase();
605
+ if (!agent) throw new Error("--agent is required to cancel its admission request.");
606
+ const statePath = admissionStatePath(targetPath, sid, agent);
607
+ const state = await readJsonFile(statePath);
608
+ if (!state?.admissionId) throw new Error(`No pending admission request for "${agent}" in this workspace.`);
609
+ const auth = await authContext({ targetPath, resolveAuthSession });
610
+ const result = await mutate(
611
+ auth,
612
+ sid,
613
+ ROUTE.cancel,
614
+ admissionUrl(auth.apiUrl, sid, `/${encodeURIComponent(state.admissionId)}/cancel`),
615
+ undefined,
616
+ { requestMutation, origin, operationName: "session.admission_cancel" }
617
+ );
618
+ await fsp.rm(statePath, { force: true });
619
+ return result;
620
+ }
@@ -17,6 +17,15 @@ const DEFAULT_RESTORE_MAX_EVENTS = 120;
17
17
  const MAX_RESTORE_CONTEXT_EVENTS = 50;
18
18
  const MAX_RESTORE_EVENTS = 200;
19
19
  const DEFAULT_CREATED_BY_AGENT_ID = "senti";
20
+ // Checkpoint origin honesty (Carter, room 430135 lane 2): a cut is `service` ONLY when a managed
21
+ // infra cutter creates it; everything else — an agent's CLI, an agent-run `sl session daemon` — is
22
+ // `agent`. Never INFER `service`: absent/unknown/anything-but-the-literal-string collapses to `agent`,
23
+ // so honesty can't ride on a remembered flag. `createdBy` only defaults to the service id on `service`.
24
+ const CHECKPOINT_ORIGIN_SERVICE = "service";
25
+ const CHECKPOINT_ORIGIN_AGENT = "agent";
26
+ function normalizeCheckpointOrigin(origin) {
27
+ return normalizeString(origin) === CHECKPOINT_ORIGIN_SERVICE ? CHECKPOINT_ORIGIN_SERVICE : CHECKPOINT_ORIGIN_AGENT;
28
+ }
20
29
 
21
30
  function normalizeString(value) {
22
31
  return String(value ?? "").trim();
@@ -196,6 +205,7 @@ export function buildManualCheckpointPayload(sessionId, {
196
205
  title,
197
206
  summary,
198
207
  createdByAgentId = "",
208
+ origin = "",
199
209
  tokenStart,
200
210
  tokenEnd,
201
211
  } = {}) {
@@ -229,6 +239,7 @@ export function buildManualCheckpointPayload(sessionId, {
229
239
  if (normalizedCreatedBy) {
230
240
  body.createdByAgentId = normalizedCreatedBy;
231
241
  }
242
+ body.origin = normalizeCheckpointOrigin(origin);
232
243
  if (tokenRange) {
233
244
  body.tokenRange = tokenRange;
234
245
  }
@@ -243,6 +254,7 @@ export function buildGenerateCheckpointPayload(sessionId, {
243
254
  minEvents = DEFAULT_MIN_EVENTS,
244
255
  maxEvents = DEFAULT_MAX_EVENTS,
245
256
  createdByAgentId = "",
257
+ origin = "",
246
258
  idempotencyKey = "",
247
259
  } = {}) {
248
260
  const normalizedSessionId = normalizeString(sessionId);
@@ -268,6 +280,7 @@ export function buildGenerateCheckpointPayload(sessionId, {
268
280
  if (normalizedCreatedBy) {
269
281
  body.createdByAgentId = normalizedCreatedBy;
270
282
  }
283
+ body.origin = normalizeCheckpointOrigin(origin);
271
284
  return {
272
285
  body,
273
286
  idempotencyKey:
@@ -612,9 +625,17 @@ export async function generateSessionCheckpointBestEffort(sessionId, options = {
612
625
  return buildCheckpointNoop("remote_sync_disabled_env");
613
626
  }
614
627
  try {
628
+ // ORIGIN HONESTY (lane 2): default `createdBy` to the service id ONLY for a real `service`-origin cut.
629
+ // An agent-run best-effort cut (the CLI, an agent's daemon) is `agent` — its createdBy is the real agent
630
+ // id or ABSENT, never silently "senti". Absence of a creator is not the service; the humble state is honest.
631
+ const origin = normalizeCheckpointOrigin(options.origin);
632
+ const createdByAgentId = origin === CHECKPOINT_ORIGIN_SERVICE
633
+ ? (normalizeString(options.createdByAgentId) || DEFAULT_CREATED_BY_AGENT_ID)
634
+ : normalizeString(options.createdByAgentId);
615
635
  const result = await generateSessionCheckpoint(normalizedSessionId, {
616
636
  ...options,
617
- createdByAgentId: normalizeString(options.createdByAgentId) || DEFAULT_CREATED_BY_AGENT_ID,
637
+ origin,
638
+ createdByAgentId,
618
639
  });
619
640
  return normalizeCheckpointGenerationResult(result);
620
641
  } catch (error) {
@@ -633,6 +654,9 @@ export async function generateSessionCheckpointBestEffort(sessionId, options = {
633
654
 
634
655
  export {
635
656
  DEFAULT_CREATED_BY_AGENT_ID,
657
+ CHECKPOINT_ORIGIN_SERVICE,
658
+ CHECKPOINT_ORIGIN_AGENT,
659
+ normalizeCheckpointOrigin,
636
660
  DEFAULT_BATCH_MAX_CHECKPOINTS,
637
661
  DEFAULT_MAX_EVENTS,
638
662
  DEFAULT_MIN_EVENTS,