sentinelayer-cli 0.46.4 → 0.48.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/README.md +63 -3
- package/package.json +12 -7
- package/skills/omar-gate-setup/SKILL.md +103 -0
- package/skills/senti-agent-identity/SKILL.md +80 -0
- package/skills/senti-judge/SKILL.md +139 -0
- package/skills/senti-room-notifications/SKILL.md +73 -0
- package/skills/senti-room-notifications/listen-wrapper.cjs +150 -0
- package/skills/senti-room-notifications/package.json +1 -0
- package/skills/senti-room-notifications/test-classifier.js +109 -0
- package/skills/senti-room-notifications/test-receipt.js +54 -0
- package/skills/sentinelayer-cli/SKILL.md +222 -0
- package/src/agents/devtestbot/runner.js +22 -11
- package/src/agents/devtestbot/tool.js +24 -11
- package/src/agents/jules/fix-cycle.js +7 -1
- package/src/agents/jules/tools/runtime-audit.js +17 -12
- package/src/agents/shared-tools/dispatch-core.js +10 -5
- package/src/ai/aidenid.js +4 -2
- package/src/ai/domain-target-store.js +11 -5
- package/src/ai/identity-store.js +11 -5
- package/src/ai/site-store.js +8 -4
- package/src/audit/orchestrator.js +21 -14
- package/src/audit/package.js +6 -4
- package/src/audit/persona-loop.js +7 -0
- package/src/audit/replay.js +4 -2
- package/src/auth/gate.js +46 -8
- package/src/auth/service.js +34 -15
- package/src/auth/session-store.js +105 -47
- package/src/board/evidence-resolvers.js +3 -4
- package/src/cli.js +23 -3
- package/src/commands/ai/identity-lifecycle.js +30 -26
- package/src/commands/ai/provision-governance.js +32 -24
- package/src/commands/ai/shared.js +6 -4
- package/src/commands/audit.js +14 -12
- package/src/commands/auth.js +24 -4
- package/src/commands/board.js +2 -2
- package/src/commands/chat.js +8 -6
- package/src/commands/judge.js +125 -0
- package/src/commands/legacy-args.js +1 -0
- package/src/commands/mcp.js +45 -52
- package/src/commands/omargate.js +2 -1
- package/src/commands/plugin.js +11 -3
- package/src/commands/review.js +9 -4
- package/src/commands/scan.js +7 -7
- package/src/commands/session.js +607 -57
- package/src/commands/skills.js +45 -0
- package/src/commands/watch.js +9 -7
- package/src/config/output-filesystem.js +156 -0
- package/src/config/schema.js +4 -2
- package/src/config/service.js +40 -4
- package/src/cost/history.js +15 -9
- package/src/daemon/artifact-lineage.js +15 -12
- package/src/daemon/assignment-ledger.js +31 -25
- package/src/daemon/ast-drift.js +7 -3
- package/src/daemon/budget-governor.js +26 -20
- package/src/daemon/error-worker.js +28 -22
- package/src/daemon/fix-cycle.js +5 -4
- package/src/daemon/hybrid-mapper.js +19 -16
- package/src/daemon/ingest-refresh.js +4 -0
- package/src/daemon/jira-lifecycle.js +21 -17
- package/src/daemon/operator-control.js +18 -15
- package/src/daemon/reliability-lane.js +18 -15
- package/src/daemon/scope-engine.js +12 -8
- package/src/daemon/watchdog.js +15 -12
- package/src/engram/build-state.js +24 -3
- package/src/engram/capability-index.js +367 -0
- package/src/engram/compile-graph.js +1063 -0
- package/src/engram/contracts/action-receipt.js +82 -0
- package/src/engram/contracts/capability-index.js +259 -0
- package/src/engram/contracts/capability.js +377 -0
- package/src/engram/contracts/content-hash.js +8 -0
- package/src/engram/contracts/evidence.js +258 -0
- package/src/engram/contracts/graph.js +594 -0
- package/src/engram/contracts/identity.js +8 -0
- package/src/engram/contracts/index.js +149 -0
- package/src/engram/contracts/media-asset.js +85 -0
- package/src/engram/contracts/money.js +249 -0
- package/src/engram/contracts/source-snapshot.js +57 -0
- package/src/engram/contracts/task-context-pack.js +114 -0
- package/src/engram/contracts/temporal-segment.js +72 -0
- package/src/engram/contracts/versions.js +96 -0
- package/src/engram/extract-html.js +692 -0
- package/src/engram/graph-store.js +877 -0
- package/src/engram/index.js +98 -14
- package/src/engram/ingest-site.js +132 -0
- package/src/engram/live-state.js +1180 -0
- package/src/engram/namespace.js +34 -1
- package/src/engram/retrieve-site.js +753 -0
- package/src/engram/safe-reader.js +597 -0
- package/src/engram/source-intake.js +251 -0
- package/src/engram/store.js +227 -9
- package/src/ingest/engine.js +21 -28
- package/src/judge/verdict.js +305 -0
- package/src/legacy-cli.js +96 -19
- package/src/mcp/bridge-inputs.js +28 -4
- package/src/mcp/cli-command-tools.js +70 -0
- package/src/mcp/registry.js +17 -13
- package/src/mcp/session-stdio-server.js +297 -19
- package/src/memory/blackboard.js +5 -4
- package/src/net/gh-api-path.js +35 -9
- package/src/net/test-egress-guard.js +16 -4
- package/src/plugin/manifest.js +12 -8
- package/src/review/ai-review.js +110 -10
- package/src/review/investor-dd-devtestbot.js +17 -6
- package/src/review/investor-dd-orchestrator.js +21 -13
- package/src/review/local-review.js +26 -16
- package/src/review/omargate-cache.js +5 -5
- package/src/review/omargate-orchestrator.js +89 -17
- package/src/review/replay.js +9 -4
- package/src/review/report.js +22 -13
- package/src/review/typesafe-severity.js +201 -0
- package/src/session/admission-auth.js +34 -7
- package/src/session/admission.js +322 -71
- package/src/session/agent-access.js +137 -0
- package/src/session/agent-registry.js +2 -1
- package/src/session/attachment-summary.js +77 -0
- package/src/session/edit-summary.js +19 -0
- package/src/session/invitations.js +65 -1
- package/src/session/mentions.js +17 -9
- package/src/session/message-edits.js +5 -4
- package/src/session/preview.js +10 -2
- package/src/session/recap.js +3 -2
- package/src/session/room-files.js +519 -0
- package/src/session/route-classes.js +22 -8
- package/src/session/setup-guides.js +4 -0
- package/src/session/standup.js +192 -0
- package/src/session/sync.js +229 -10
- package/src/session/tickets.js +41 -0
- package/src/session/transcript.js +2 -1
- package/src/session/wake/resolve-target.js +7 -3
- package/src/skills/listener-runtime.cjs +92 -0
- package/src/skills/service.js +207 -0
- package/src/swarm/factory.js +5 -5
- package/src/swarm/pentest.js +9 -9
- package/src/swarm/report.js +4 -4
- package/src/swarm/runtime.js +15 -12
- package/src/swarm/scenario-dsl.js +7 -5
- package/src/telemetry/ledger.js +16 -7
package/src/session/admission.js
CHANGED
|
@@ -60,9 +60,18 @@ export const CLAIM_FIELDS = Object.freeze([
|
|
|
60
60
|
"keyThumbprint",
|
|
61
61
|
"nonce",
|
|
62
62
|
]);
|
|
63
|
+
export const RECOVERY_DOMAIN = "sentinelayer.admission.recover.v1";
|
|
64
|
+
export const RECOVERY_FIELDS = Object.freeze([
|
|
65
|
+
"admissionId", "sessionId", "agentId", "delegatorUserId", "publicKey",
|
|
66
|
+
"keyThumbprint", "grantId", "goalDigest", "actions", "policyVersion",
|
|
67
|
+
"expiresAt", "nonce", "challengeExpiresAt", "tenantUserId", "passportId",
|
|
68
|
+
"aidenidIdentityId", "aidenidHandle",
|
|
69
|
+
]);
|
|
63
70
|
const ROUTE = {
|
|
64
71
|
request: "POST /api/v1/sessions/{session_id}/admissions",
|
|
65
72
|
claim: "POST /api/v1/sessions/{session_id}/admissions/{admission_id}/claim",
|
|
73
|
+
recoverChallenge: "POST /api/v1/sessions/{session_id}/admissions/{admission_id}/recover/challenge",
|
|
74
|
+
recover: "POST /api/v1/sessions/{session_id}/admissions/{admission_id}/recover",
|
|
66
75
|
cancel: "POST /api/v1/sessions/{session_id}/admissions/{admission_id}/cancel",
|
|
67
76
|
};
|
|
68
77
|
// Final admission statuses: polling stops, nothing is claimed, local request state is cleared.
|
|
@@ -402,6 +411,201 @@ async function mutate(auth, sessionId, routeId, url, body, { requestMutation, op
|
|
|
402
411
|
});
|
|
403
412
|
}
|
|
404
413
|
|
|
414
|
+
// Recovery rotates only an ACTIVE credential. It never extends a permission or
|
|
415
|
+
// signs the old claim domain, and it needs both the human connection and this
|
|
416
|
+
// agent's own retained private key. A lost response can be recovered again with
|
|
417
|
+
// another fresh server challenge; plaintext credentials are never redelivered.
|
|
418
|
+
async function recoverCredential(auth, sid, agent, admissionId, key, deps, now) {
|
|
419
|
+
const challenge = await mutate(
|
|
420
|
+
auth, sid, ROUTE.recoverChallenge,
|
|
421
|
+
admissionMemberUrl(auth.apiUrl, sid, admissionId, "/recover/challenge"), {},
|
|
422
|
+
{ ...deps, operationName: "session.admission_recovery_challenge" }
|
|
423
|
+
);
|
|
424
|
+
const fields = challenge?.recovery?.preimage;
|
|
425
|
+
const advertised = challenge?.recovery?.fields;
|
|
426
|
+
if (
|
|
427
|
+
challenge?.status !== "active" || challenge.admissionId !== admissionId ||
|
|
428
|
+
challenge?.recovery?.domain !== RECOVERY_DOMAIN || !fields ||
|
|
429
|
+
!Array.isArray(advertised) || advertised.length !== RECOVERY_FIELDS.length ||
|
|
430
|
+
[...advertised].sort().join("\0") !== [...RECOVERY_FIELDS].sort().join("\0") ||
|
|
431
|
+
fields.admissionId !== admissionId || fields.sessionId !== sid ||
|
|
432
|
+
fields.agentId !== agent || fields.publicKey !== key.publicKey ||
|
|
433
|
+
!normalizeString(fields.aidenidIdentityId) || !normalizeString(fields.aidenidHandle) ||
|
|
434
|
+
!Array.isArray(fields.actions) || !fields.actions.length ||
|
|
435
|
+
fields.actions.some((action) => !ADMISSION_ACTIONS.includes(action)) ||
|
|
436
|
+
!Number.isSafeInteger(fields.expiresAt) || fields.expiresAt * 1000 <= now() ||
|
|
437
|
+
!Number.isSafeInteger(fields.challengeExpiresAt) || fields.challengeExpiresAt * 1000 <= now()
|
|
438
|
+
) {
|
|
439
|
+
throw new Error("Refusing to sign: invalid or mismatched admission recovery challenge.");
|
|
440
|
+
}
|
|
441
|
+
const signature = signBytes(null, canonicalPreimage(fields, {
|
|
442
|
+
domain: RECOVERY_DOMAIN, fields: [...RECOVERY_FIELDS],
|
|
443
|
+
}), key.privateKey);
|
|
444
|
+
const recovered = await mutate(
|
|
445
|
+
auth, sid, ROUTE.recover,
|
|
446
|
+
admissionMemberUrl(auth.apiUrl, sid, admissionId, "/recover"),
|
|
447
|
+
{ nonce: fields.nonce, signature: signature.toString("base64url") },
|
|
448
|
+
{ ...deps, operationName: "session.admission_recover" }
|
|
449
|
+
);
|
|
450
|
+
if (
|
|
451
|
+
recovered?.status !== "active" || recovered.admissionId !== admissionId ||
|
|
452
|
+
recovered?.grant?.sessionId !== sid || recovered.grant.grantId !== fields.grantId ||
|
|
453
|
+
recovered.grant.expiresAt !== fields.expiresAt ||
|
|
454
|
+
recovered.grant.goalDigest !== fields.goalDigest ||
|
|
455
|
+
!Array.isArray(recovered.grant.actions) ||
|
|
456
|
+
[...recovered.grant.actions].sort().join("\0") !== [...fields.actions].sort().join("\0") ||
|
|
457
|
+
recovered?.identity?.keyThumbprint !== fields.keyThumbprint ||
|
|
458
|
+
recovered.identity.passportId !== fields.passportId ||
|
|
459
|
+
recovered.identity.email?.address !== fields.aidenidHandle
|
|
460
|
+
) {
|
|
461
|
+
throw new Error("Recovered admission does not match the unchanged signed authority.");
|
|
462
|
+
}
|
|
463
|
+
return recovered;
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
/** Serialize credential rotation AND publication across CLI processes.
|
|
467
|
+
* No age-based lock stealing: an API call may still commit after a local timer
|
|
468
|
+
* expires. Unknown/crashed owners fail explicitly without changing identity.
|
|
469
|
+
*/
|
|
470
|
+
export async function withAdmissionCredentialLock(filePath, run, { timeoutMs = 180_000 } = {}) {
|
|
471
|
+
const lockPath = `${filePath}.recovery-lock`;
|
|
472
|
+
await fsp.mkdir(path.dirname(lockPath), { recursive: true, mode: 0o700 });
|
|
473
|
+
const deadline = Date.now() + timeoutMs;
|
|
474
|
+
let handle;
|
|
475
|
+
for (;;) {
|
|
476
|
+
try {
|
|
477
|
+
handle = await fsp.open(lockPath, "wx", 0o600);
|
|
478
|
+
break;
|
|
479
|
+
} catch (error) {
|
|
480
|
+
if (error?.code !== "EEXIST") throw error;
|
|
481
|
+
if (Date.now() >= deadline) {
|
|
482
|
+
throw new Error(`Agent credential recovery is already locked at ${lockPath}. ` +
|
|
483
|
+
"Wait for the other join; if its process crashed, verify it has stopped before removing only this lock file. Human sign-in and the agent key were not changed.");
|
|
484
|
+
}
|
|
485
|
+
await sleepMs(25);
|
|
486
|
+
}
|
|
487
|
+
}
|
|
488
|
+
try {
|
|
489
|
+
await handle.writeFile(JSON.stringify({ pid: process.pid, host: os.hostname(), createdAt: new Date().toISOString() }));
|
|
490
|
+
return await run();
|
|
491
|
+
} finally {
|
|
492
|
+
await handle.close();
|
|
493
|
+
await fsp.unlink(lockPath);
|
|
494
|
+
}
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
// Persist noAutoGrant on a cached credential when the caller requests it.
|
|
498
|
+
// Never downgrade: noAutoGrant=false on an already-true credential is a no-op.
|
|
499
|
+
async function persistNoAutoGrantIfNeeded(credential, noAutoGrant, sid, agent, homeDir) {
|
|
500
|
+
if (noAutoGrant && !credential.noAutoGrant) {
|
|
501
|
+
const updated = { ...credential, noAutoGrant: true };
|
|
502
|
+
await writeSecretFile(admissionCredentialPath(sid, agent, { homeDir }), updated);
|
|
503
|
+
return updated;
|
|
504
|
+
}
|
|
505
|
+
return credential;
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
async function recoverAndStore(auth, sid, agent, admissionId, key, fingerprint, deps, homeDir, now, rejectedToken = null, noAutoGrant = false, statePath = null) {
|
|
509
|
+
return withAdmissionCredentialLock(admissionCredentialPath(sid, agent, { homeDir }), async () => {
|
|
510
|
+
const effectiveNoAutoGrant = noAutoGrant || await pendingNoAutoGrantAtPath(statePath, admissionId, fingerprint);
|
|
511
|
+
// Another process may have completed recovery while this process waited.
|
|
512
|
+
const current = await readAdmissionCredentialState(sid, agent, { homeDir, now: now() });
|
|
513
|
+
if (current.state === "live" && current.credential.admissionId === admissionId &&
|
|
514
|
+
current.credential.requestFingerprint === fingerprint && current.credential.publicKey === key.publicKey &&
|
|
515
|
+
current.credential.apiUrl === auth.apiUrl && current.credential.token !== rejectedToken) {
|
|
516
|
+
const updated = await persistNoAutoGrantIfNeeded(current.credential, effectiveNoAutoGrant, sid, agent, homeDir);
|
|
517
|
+
return redacted({ status: "active", reused: true, stored: updated, sid, agent, homeDir });
|
|
518
|
+
}
|
|
519
|
+
const claimed = await recoverCredential(auth, sid, agent, admissionId, key, deps, now);
|
|
520
|
+
const stored = await storeClaimedCredential(claimed, { auth, sid, agent, key, fingerprint, homeDir, now, noAutoGrant: effectiveNoAutoGrant });
|
|
521
|
+
return { ...redacted({ status: "active", reused: true, stored, claimed, sid, agent, homeDir }), recovered: true };
|
|
522
|
+
});
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
async function pendingNoAutoGrantAtPath(statePath, admissionId, fingerprint) {
|
|
526
|
+
if (!statePath) return false;
|
|
527
|
+
const state = await readJsonFile(statePath);
|
|
528
|
+
return state?.admissionId === admissionId && state.requestFingerprint === fingerprint && state.noAutoGrant === true;
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
// Pending writers share the credential lock with claim/recovery. Merge the
|
|
532
|
+
// opt-out monotonically so a waiter holding an older snapshot cannot erase it.
|
|
533
|
+
async function writePendingAdmissionState(statePath, state, sid, agent, homeDir, now) {
|
|
534
|
+
return withAdmissionCredentialLock(admissionCredentialPath(sid, agent, { homeDir }), async () => {
|
|
535
|
+
const latest = await readJsonFile(statePath);
|
|
536
|
+
const sameRequest = latest?.admissionId === state.admissionId &&
|
|
537
|
+
latest.requestFingerprint === state.requestFingerprint;
|
|
538
|
+
state.noAutoGrant = state.noAutoGrant === true || (sameRequest && latest.noAutoGrant === true);
|
|
539
|
+
const current = await readAdmissionCredentialState(sid, agent, { homeDir, now: now() });
|
|
540
|
+
if (current.state === "live" && current.credential.admissionId === state.admissionId &&
|
|
541
|
+
current.credential.requestFingerprint === state.requestFingerprint &&
|
|
542
|
+
current.credential.publicKey === state.publicKey && current.credential.apiUrl === state.apiUrl) {
|
|
543
|
+
const stored = await persistNoAutoGrantIfNeeded(current.credential, state.noAutoGrant, sid, agent, homeDir);
|
|
544
|
+
if (sameRequest) await fsp.rm(statePath, { force: true });
|
|
545
|
+
return stored;
|
|
546
|
+
}
|
|
547
|
+
await fsp.mkdir(path.dirname(statePath), { recursive: true });
|
|
548
|
+
await fsp.writeFile(statePath, `${JSON.stringify(state, null, 2)}\n`);
|
|
549
|
+
return null;
|
|
550
|
+
});
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
async function storeClaimedCredential(claimed, { auth, sid, agent, key, fingerprint, homeDir, now, noAutoGrant = false }) {
|
|
554
|
+
if (!String(claimed?.credential?.token || "").startsWith("sladm_")) {
|
|
555
|
+
throw new Error("Admission did not return a scoped agent credential.");
|
|
556
|
+
}
|
|
557
|
+
const identity = identityMetadata(claimed.identity);
|
|
558
|
+
const grant = grantMetadata(claimed.grant);
|
|
559
|
+
const previous = await readAdmissionCredentialState(sid, agent, { homeDir, now: now() });
|
|
560
|
+
if (previous.state === "live" && previous.credential.admissionId === claimed.admissionId &&
|
|
561
|
+
previous.credential.publicKey === key.publicKey && previous.credential.apiUrl === auth.apiUrl &&
|
|
562
|
+
previous.credential.identity?.passportId === identity.passportId &&
|
|
563
|
+
previous.credential.identity?.keyThumbprint === identity.keyThumbprint &&
|
|
564
|
+
previous.credential.grant?.grantId === grant.grantId && previous.credential.expiresAt === grant.expiresAt) {
|
|
565
|
+
// Recovery does not reissue signed documents. Retain original documents
|
|
566
|
+
// only for this exact unchanged identity/grant; never synthesize new ones.
|
|
567
|
+
identity.passport ??= previous.credential.identity.passport;
|
|
568
|
+
grant.document ??= previous.credential.grant.document;
|
|
569
|
+
}
|
|
570
|
+
const stored = {
|
|
571
|
+
version: 3, sessionId: sid, agentId: agent, apiUrl: auth.apiUrl,
|
|
572
|
+
publicKey: key.publicKey, requestFingerprint: fingerprint,
|
|
573
|
+
admissionId: claimed.admissionId, token: claimed.credential.token,
|
|
574
|
+
expiresAt: claimed.grant.expiresAt, grantedActions: claimed.grant.actions,
|
|
575
|
+
passportId: claimed.identity.passportId, subject: claimed.identity.subject,
|
|
576
|
+
// Non-secret identity metadata survives reconnects. Email is never used as
|
|
577
|
+
// authentication: the credential/key binding remains the authority.
|
|
578
|
+
identity,
|
|
579
|
+
grant,
|
|
580
|
+
correlation: claimed.correlation,
|
|
581
|
+
storedAt: new Date(now()).toISOString(),
|
|
582
|
+
// Preserve an existing noAutoGrant opt-out: recovery/replacement without
|
|
583
|
+
// the flag must not clear a previously persisted refusal. Only an explicit
|
|
584
|
+
// `noAutoGrant: true` overrides (never downgrades).
|
|
585
|
+
// Preserve existing opt-out regardless of credential state (live, expired, revoked).
|
|
586
|
+
noAutoGrant: noAutoGrant || (previous.credential?.noAutoGrant === true),
|
|
587
|
+
};
|
|
588
|
+
await writeSecretFile(admissionCredentialPath(sid, agent, { homeDir }), stored);
|
|
589
|
+
return stored;
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
function identityMetadata(identity) {
|
|
593
|
+
return {
|
|
594
|
+
subject: identity.subject, passportId: identity.passportId,
|
|
595
|
+
passportStatus: identity.passportStatus, keyThumbprint: identity.keyThumbprint,
|
|
596
|
+
popAssurance: identity.popAssurance,
|
|
597
|
+
email: identity.email && { status: identity.email.status, address: identity.email.address },
|
|
598
|
+
passport: identity.passport,
|
|
599
|
+
};
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
function grantMetadata(grant) {
|
|
603
|
+
return {
|
|
604
|
+
grantId: grant.grantId, sessionId: grant.sessionId, actions: grant.actions,
|
|
605
|
+
expiresAt: grant.expiresAt, goalDigest: grant.goalDigest, document: grant.document,
|
|
606
|
+
};
|
|
607
|
+
}
|
|
608
|
+
|
|
405
609
|
/**
|
|
406
610
|
* Request admission, wait for a human decision, and claim the grant.
|
|
407
611
|
* Returns a REDACTED result: the credential token is never included.
|
|
@@ -419,7 +623,9 @@ export async function runAdmissionJoin(
|
|
|
419
623
|
actions = DEFAULT_ADMISSION_ACTIONS,
|
|
420
624
|
ttlSeconds = 7200,
|
|
421
625
|
wait = true,
|
|
626
|
+
recoverActive = false,
|
|
422
627
|
waitTimeoutMs = 15 * 60_000,
|
|
628
|
+
noAutoGrant = false,
|
|
423
629
|
targetPath = process.cwd(),
|
|
424
630
|
homeDir,
|
|
425
631
|
origin = SESSION_MUTATION_ORIGIN,
|
|
@@ -464,8 +670,37 @@ export async function runAdmissionJoin(
|
|
|
464
670
|
// same key. A different goal, scope or duration is a question for the server,
|
|
465
671
|
// which answers with the existing grant (same purpose) or a conflict (new one).
|
|
466
672
|
const held = await readAdmissionCredentialState(sid, agent, { homeDir, now: now() });
|
|
467
|
-
if (held.state === "live" && held.credential.requestFingerprint === fingerprint) {
|
|
468
|
-
|
|
673
|
+
if (!recoverActive && held.state === "live" && held.credential.requestFingerprint === fingerprint) {
|
|
674
|
+
const fastPathCredential = await withAdmissionCredentialLock(
|
|
675
|
+
admissionCredentialPath(sid, agent, { homeDir }),
|
|
676
|
+
async () => {
|
|
677
|
+
// Reread under lock: a concurrent recovery may have rotated the token.
|
|
678
|
+
const current = await readAdmissionCredentialState(sid, agent, { homeDir, now: now() });
|
|
679
|
+
if (current.state === "live" && current.credential.requestFingerprint === fingerprint) {
|
|
680
|
+
const cred = await persistNoAutoGrantIfNeeded(current.credential, noAutoGrant, sid, agent, homeDir);
|
|
681
|
+
return cred;
|
|
682
|
+
}
|
|
683
|
+
// Credential changed under us; re-read one final time under the
|
|
684
|
+
// held lock. If another writer replaced the credential, fail rather
|
|
685
|
+
// than returning a stale snapshot.
|
|
686
|
+
const retry = await readAdmissionCredentialState(sid, agent, { homeDir, now: now() });
|
|
687
|
+
if (retry.state === "live" && retry.credential.requestFingerprint === fingerprint) {
|
|
688
|
+
const cred = await persistNoAutoGrantIfNeeded(retry.credential, noAutoGrant, sid, agent, homeDir);
|
|
689
|
+
return cred;
|
|
690
|
+
}
|
|
691
|
+
throw new Error("Admission credential changed during join; retry the join.");
|
|
692
|
+
});
|
|
693
|
+
return redacted({ status: "active", reused: true, stored: fastPathCredential, sid, agent, homeDir });
|
|
694
|
+
}
|
|
695
|
+
if (recoverActive && held.credential?.admissionId) {
|
|
696
|
+
if (held.credential.requestFingerprint !== fingerprint || held.credential.publicKey !== key.publicKey ||
|
|
697
|
+
held.credential.apiUrl !== auth.apiUrl) {
|
|
698
|
+
throw new Error("Credential recovery requires the unchanged agent key, API, goal, scope and TTL.");
|
|
699
|
+
}
|
|
700
|
+
// Go directly to recovery, never file a new request after a refused cached
|
|
701
|
+
// bearer. A revoked/expired grant must be refused, not reopened as pending.
|
|
702
|
+
return recoverAndStore(auth, sid, agent, held.credential.admissionId, key, fingerprint, deps, homeDir, now,
|
|
703
|
+
held.credential.token, noAutoGrant);
|
|
469
704
|
}
|
|
470
705
|
|
|
471
706
|
const statePath = admissionStatePath(targetPath, sid, agent);
|
|
@@ -478,6 +713,14 @@ export async function runAdmissionJoin(
|
|
|
478
713
|
);
|
|
479
714
|
}
|
|
480
715
|
const resumable = inFlight && state.publicKey === key.publicKey;
|
|
716
|
+
// An opt-out made while approval is pending must survive a later invocation
|
|
717
|
+
// that resumes the same request without repeating the flag.
|
|
718
|
+
const pendingNoAutoGrant = Boolean(noAutoGrant || (resumable && state.noAutoGrant === true));
|
|
719
|
+
if (resumable && pendingNoAutoGrant && state.noAutoGrant !== true) {
|
|
720
|
+
state.noAutoGrant = true;
|
|
721
|
+
const stored = await writePendingAdmissionState(statePath, state, sid, agent, homeDir, now);
|
|
722
|
+
if (stored) return redacted({ status: "active", reused: true, stored, sid, agent, homeDir });
|
|
723
|
+
}
|
|
481
724
|
|
|
482
725
|
if (!resumable) {
|
|
483
726
|
const created = await mutate(
|
|
@@ -501,19 +744,35 @@ export async function runAdmissionJoin(
|
|
|
501
744
|
if (
|
|
502
745
|
created?.status === "active" &&
|
|
503
746
|
created?.reused &&
|
|
747
|
+
!recoverActive &&
|
|
504
748
|
held.state === "live" &&
|
|
505
749
|
held.credential.admissionId === created.admissionId
|
|
506
750
|
) {
|
|
507
751
|
// The server judged this request covered by the grant we already hold.
|
|
508
|
-
|
|
752
|
+
// Lock and reread: a concurrent recovery may have rotated the token.
|
|
753
|
+
const fastPathCredential = await withAdmissionCredentialLock(
|
|
754
|
+
admissionCredentialPath(sid, agent, { homeDir }),
|
|
755
|
+
async () => {
|
|
756
|
+
const current = await readAdmissionCredentialState(sid, agent, { homeDir, now: now() });
|
|
757
|
+
if (current.state === "live" && current.credential?.admissionId === created.admissionId) {
|
|
758
|
+
return persistNoAutoGrantIfNeeded(current.credential, noAutoGrant, sid, agent, homeDir);
|
|
759
|
+
}
|
|
760
|
+
// Credential changed under us; re-read one final time under
|
|
761
|
+
// the held lock. If another writer replaced the credential, fail
|
|
762
|
+
// rather than writing a stale snapshot.
|
|
763
|
+
const retry = await readAdmissionCredentialState(sid, agent, { homeDir, now: now() });
|
|
764
|
+
if (retry.state === "live" && retry.credential?.admissionId === created.admissionId) {
|
|
765
|
+
return persistNoAutoGrantIfNeeded(retry.credential, noAutoGrant, sid, agent, homeDir);
|
|
766
|
+
}
|
|
767
|
+
throw new Error("Admission credential changed during join; retry the join.");
|
|
768
|
+
});
|
|
769
|
+
return redacted({ status: "active", reused: true, stored: fastPathCredential, sid, agent, homeDir });
|
|
509
770
|
}
|
|
510
771
|
if (created?.status === "active" && created?.reused) {
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
"on this machine. Ask the room owner to revoke it, then join again."
|
|
516
|
-
);
|
|
772
|
+
const recovered = await recoverAndStore(auth, sid, agent, created.admissionId, key, fingerprint, deps, homeDir, now,
|
|
773
|
+
recoverActive && held.state === "live" ? held.credential.token : null, pendingNoAutoGrant, statePath);
|
|
774
|
+
await fsp.rm(statePath, { force: true });
|
|
775
|
+
return recovered;
|
|
517
776
|
}
|
|
518
777
|
state = {
|
|
519
778
|
admissionId: created.admissionId,
|
|
@@ -525,9 +784,10 @@ export async function runAdmissionJoin(
|
|
|
525
784
|
approveUrl: created.approveUrl || null,
|
|
526
785
|
pendingExpiresAt: created.pendingExpiresAt || null,
|
|
527
786
|
requestedAt: new Date(now()).toISOString(),
|
|
787
|
+
noAutoGrant: pendingNoAutoGrant,
|
|
528
788
|
};
|
|
529
|
-
await
|
|
530
|
-
|
|
789
|
+
const stored = await writePendingAdmissionState(statePath, state, sid, agent, homeDir, now);
|
|
790
|
+
if (stored) return redacted({ status: "active", reused: true, stored, sid, agent, homeDir });
|
|
531
791
|
}
|
|
532
792
|
|
|
533
793
|
if (!wait) {
|
|
@@ -569,7 +829,8 @@ export async function runAdmissionJoin(
|
|
|
569
829
|
identity: polled.identity || null,
|
|
570
830
|
});
|
|
571
831
|
if (now() >= deadline) {
|
|
572
|
-
await
|
|
832
|
+
const stored = await writePendingAdmissionState(statePath, state, sid, agent, homeDir, now);
|
|
833
|
+
if (stored) return redacted({ status: "active", reused: true, stored, sid, agent, homeDir });
|
|
573
834
|
return {
|
|
574
835
|
status: polled.status === "approved" ? "approved" : "pending",
|
|
575
836
|
phase,
|
|
@@ -590,46 +851,55 @@ export async function runAdmissionJoin(
|
|
|
590
851
|
return { status: polled.status, admissionId: state.admissionId, waited: true };
|
|
591
852
|
}
|
|
592
853
|
if (polled.status === "active") {
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
if (!fields || polled.claim.domain !== CLAIM_DOMAIN) {
|
|
598
|
-
throw new Error("Approved admission did not include a valid claim challenge.");
|
|
599
|
-
}
|
|
600
|
-
// Never sign a claim for a key or agent that is not ours.
|
|
601
|
-
if (fields.publicKey !== key.publicKey || fields.agentId !== agent || fields.sessionId !== sid) {
|
|
602
|
-
throw new Error("Refusing to sign: the claim challenge does not name this agent's key.");
|
|
854
|
+
const recovered = await recoverAndStore(auth, sid, agent, state.admissionId, key, fingerprint, deps, homeDir, now,
|
|
855
|
+
recoverActive && held.state === "live" ? held.credential.token : null, pendingNoAutoGrant, statePath);
|
|
856
|
+
await fsp.rm(statePath, { force: true });
|
|
857
|
+
return recovered;
|
|
603
858
|
}
|
|
604
|
-
const signature = signBytes(null, canonicalPreimage(fields, { domain: CLAIM_DOMAIN, fields: [...CLAIM_FIELDS] }), key.privateKey);
|
|
605
|
-
const claimed = await mutate(
|
|
606
|
-
auth,
|
|
607
|
-
sid,
|
|
608
|
-
ROUTE.claim,
|
|
609
|
-
admissionMemberUrl(auth.apiUrl, sid, state.admissionId, "/claim"),
|
|
610
|
-
{ nonce: fields.nonce, signature: signature.toString("base64url") },
|
|
611
|
-
{ ...deps, operationName: "session.admission_claim" }
|
|
612
|
-
);
|
|
613
859
|
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
860
|
+
// Initial claim is a credential writer too. The same cross-process lock must
|
|
861
|
+
// cover it through publication, or a delayed first claim could overwrite a
|
|
862
|
+
// recovered replacement. Refresh the nonce only after obtaining this lock.
|
|
863
|
+
const result = await withAdmissionCredentialLock(admissionCredentialPath(sid, agent, { homeDir }), async () => {
|
|
864
|
+
const claimNoAutoGrant = pendingNoAutoGrant || await pendingNoAutoGrantAtPath(statePath, state.admissionId, fingerprint);
|
|
865
|
+
const current = await readAdmissionCredentialState(sid, agent, { homeDir, now: now() });
|
|
866
|
+
if (current.state === "live" && current.credential.admissionId === state.admissionId &&
|
|
867
|
+
current.credential.requestFingerprint === fingerprint && current.credential.publicKey === key.publicKey &&
|
|
868
|
+
current.credential.apiUrl === auth.apiUrl) {
|
|
869
|
+
const stored = await persistNoAutoGrantIfNeeded(current.credential, claimNoAutoGrant, sid, agent, homeDir);
|
|
870
|
+
return redacted({ status: "active", reused: true, stored, sid, agent, homeDir });
|
|
871
|
+
}
|
|
872
|
+
const fresh = await checkedTransport(requestRead)(admissionMemberUrl(auth.apiUrl, sid, state.admissionId), {
|
|
873
|
+
method: "GET", credential: auth.credential,
|
|
874
|
+
});
|
|
875
|
+
if (TERMINAL.has(fresh.status)) {
|
|
876
|
+
return { status: fresh.status, admissionId: state.admissionId, waited: true };
|
|
877
|
+
}
|
|
878
|
+
if (fresh.status === "active") {
|
|
879
|
+
const recovered = await recoverCredential(auth, sid, agent, state.admissionId, key, deps, now);
|
|
880
|
+
const stored = await storeClaimedCredential(recovered, { auth, sid, agent, key, fingerprint, homeDir, now, noAutoGrant: claimNoAutoGrant });
|
|
881
|
+
return { ...redacted({ status: "active", reused: true, stored, claimed: recovered, sid, agent, homeDir }), recovered: true };
|
|
882
|
+
}
|
|
883
|
+
const fields = fresh?.claim?.preimage;
|
|
884
|
+
if (fresh.status !== "approved" || fresh.identityReady === false || !fields || fresh.claim.domain !== CLAIM_DOMAIN) {
|
|
885
|
+
throw new Error("Approved admission did not include a valid claim challenge.");
|
|
886
|
+
}
|
|
887
|
+
if (fields.publicKey !== key.publicKey || fields.agentId !== agent || fields.sessionId !== sid ||
|
|
888
|
+
fields.admissionId !== state.admissionId) {
|
|
889
|
+
throw new Error("Refusing to sign: the claim challenge does not name this agent's key.");
|
|
890
|
+
}
|
|
891
|
+
const signature = signBytes(null, canonicalPreimage(fields, { domain: CLAIM_DOMAIN, fields: [...CLAIM_FIELDS] }), key.privateKey);
|
|
892
|
+
const claimed = await mutate(
|
|
893
|
+
auth, sid, ROUTE.claim,
|
|
894
|
+
admissionMemberUrl(auth.apiUrl, sid, state.admissionId, "/claim"),
|
|
895
|
+
{ nonce: fields.nonce, signature: signature.toString("base64url") },
|
|
896
|
+
{ ...deps, operationName: "session.admission_claim" }
|
|
897
|
+
);
|
|
898
|
+
const stored = await storeClaimedCredential(claimed, { auth, sid, agent, key, fingerprint, homeDir, now, noAutoGrant: claimNoAutoGrant });
|
|
899
|
+
return redacted({ status: "active", reused: false, stored, claimed, sid, agent, homeDir });
|
|
900
|
+
});
|
|
631
901
|
await fsp.rm(statePath, { force: true });
|
|
632
|
-
return
|
|
902
|
+
return result;
|
|
633
903
|
}
|
|
634
904
|
|
|
635
905
|
function redacted({ status, reused, stored, claimed, sid, agent, homeDir }) {
|
|
@@ -637,28 +907,9 @@ function redacted({ status, reused, stored, claimed, sid, agent, homeDir }) {
|
|
|
637
907
|
status,
|
|
638
908
|
reused,
|
|
639
909
|
admissionId: stored.admissionId,
|
|
640
|
-
identity: claimed
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
passportId: claimed.identity.passportId,
|
|
644
|
-
passportStatus: claimed.identity.passportStatus,
|
|
645
|
-
keyThumbprint: claimed.identity.keyThumbprint,
|
|
646
|
-
popAssurance: claimed.identity.popAssurance,
|
|
647
|
-
email: claimed.identity.email,
|
|
648
|
-
passport: claimed.identity.passport,
|
|
649
|
-
}
|
|
650
|
-
: { subject: stored.subject, passportId: stored.passportId },
|
|
651
|
-
grant: claimed
|
|
652
|
-
? {
|
|
653
|
-
grantId: claimed.grant.grantId,
|
|
654
|
-
sessionId: claimed.grant.sessionId,
|
|
655
|
-
actions: claimed.grant.actions,
|
|
656
|
-
expiresAt: claimed.grant.expiresAt,
|
|
657
|
-
goalDigest: claimed.grant.goalDigest,
|
|
658
|
-
document: claimed.grant.document,
|
|
659
|
-
}
|
|
660
|
-
: { sessionId: sid, actions: stored.grantedActions, expiresAt: stored.expiresAt },
|
|
661
|
-
correlation: claimed ? claimed.correlation : { actorRef: `adm:${stored.admissionId}` },
|
|
910
|
+
identity: stored.identity || (claimed ? identityMetadata(claimed.identity) : { subject: stored.subject, passportId: stored.passportId }),
|
|
911
|
+
grant: stored.grant || (claimed ? grantMetadata(claimed.grant) : { sessionId: sid, actions: stored.grantedActions, expiresAt: stored.expiresAt }),
|
|
912
|
+
correlation: claimed ? claimed.correlation : stored.correlation || { actorRef: `adm:${stored.admissionId}` },
|
|
662
913
|
jev: claimed ? claimed.jev : undefined,
|
|
663
914
|
// A reference only. The token itself never appears in output.
|
|
664
915
|
credential: { storage: admissionCredentialPath(sid, agent, { homeDir }), redacted: true },
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
import process from "node:process";
|
|
2
|
+
|
|
3
|
+
import { checkedTransport, credentialFor, isAuthenticated } from "../auth/credential-destinations.js";
|
|
4
|
+
import { requestJson, requestJsonMutation } from "../auth/http.js";
|
|
5
|
+
import { resolveActiveAuthSession } from "../auth/service.js";
|
|
6
|
+
import { urlPathSegment } from "../net/url-path.js";
|
|
7
|
+
import { readAdmissionCredentialState } from "./admission.js";
|
|
8
|
+
import { assertCanonicalAgentId, currentAdmittedAgent } from "./admission-auth.js";
|
|
9
|
+
|
|
10
|
+
const GRANT_ROLES = new Set(["auditor", "coder", "coordinator", "observer", "orchestrator", "reviewer"]);
|
|
11
|
+
const MAX_GRANT_PAGES = 20;
|
|
12
|
+
const PAGE_LIMIT = 100;
|
|
13
|
+
|
|
14
|
+
function refused(code, reason, status = null) {
|
|
15
|
+
return { ok: false, code, reason, status, source: "legacy_grant_presence" };
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** A stored admission is never permission to enter the human-account legacy path. */
|
|
19
|
+
export async function legacyJoinAdmissionRefusal(sessionId, agentId, {
|
|
20
|
+
homeDir,
|
|
21
|
+
readAdmissionState = readAdmissionCredentialState,
|
|
22
|
+
} = {}) {
|
|
23
|
+
if (currentAdmittedAgent()) return refused("ADMISSION_CREDENTIAL_REFUSED", "admitted_context");
|
|
24
|
+
let held;
|
|
25
|
+
try {
|
|
26
|
+
held = await readAdmissionState(sessionId, agentId, { homeDir });
|
|
27
|
+
} catch {
|
|
28
|
+
return refused("ADMISSION_CREDENTIAL_REFUSED", "admission_lookup_unavailable");
|
|
29
|
+
}
|
|
30
|
+
if (held?.state === "none") return null;
|
|
31
|
+
return held?.state === "live"
|
|
32
|
+
? refused("ADMISSION_REQUIRED", "stored_admission_requires_scoped_join")
|
|
33
|
+
: refused("ADMISSION_CREDENTIAL_REFUSED", held?.reason || "admission_lookup_unavailable");
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Verify an existing account-bound legacy grant and its room-scoped presence.
|
|
38
|
+
* Never POST /agent-grants: GET lists active grants only, whereas POST reactivates
|
|
39
|
+
* revoked grants. Absence therefore cannot safely authorize an automatic grant.
|
|
40
|
+
* No transcript event, email or cryptographic identity is minted here.
|
|
41
|
+
*/
|
|
42
|
+
export async function verifyLegacyAgentJoin(sessionId, {
|
|
43
|
+
agentId,
|
|
44
|
+
role = "coder",
|
|
45
|
+
model = "cli",
|
|
46
|
+
targetPath = process.cwd(),
|
|
47
|
+
homeDir,
|
|
48
|
+
remoteSession = {},
|
|
49
|
+
timeoutMs = 10_000,
|
|
50
|
+
now = () => Date.now(),
|
|
51
|
+
readAdmissionState = readAdmissionCredentialState,
|
|
52
|
+
resolveAuthSession = resolveActiveAuthSession,
|
|
53
|
+
requestRead = requestJson,
|
|
54
|
+
requestMutation = requestJsonMutation,
|
|
55
|
+
} = {}) {
|
|
56
|
+
const sid = String(sessionId || "").trim();
|
|
57
|
+
const agent = String(agentId || "").trim().toLowerCase();
|
|
58
|
+
const requestedRole = String(role || "").trim().toLowerCase();
|
|
59
|
+
try {
|
|
60
|
+
assertCanonicalAgentId(agent, "legacy join agent");
|
|
61
|
+
if (!sid || !/^[a-z0-9][a-z0-9._-]{0,63}$/.test(agent) || agent.startsWith("human-") || ["cli-user", "unknown"].includes(agent)) {
|
|
62
|
+
return refused("LEGACY_AGENT_ID_INVALID", "ungrantable_agent");
|
|
63
|
+
}
|
|
64
|
+
if (!GRANT_ROLES.has(requestedRole)) return refused("LEGACY_GRANT_ROLE_MISMATCH", "unsupported_role");
|
|
65
|
+
if (remoteSession.agentAdmissionMode && remoteSession.agentAdmissionMode !== "legacy") {
|
|
66
|
+
return refused("ADMISSION_REQUIRED", "room_requires_admission");
|
|
67
|
+
}
|
|
68
|
+
const admissionOptions = { homeDir, readAdmissionState };
|
|
69
|
+
let refusal = await legacyJoinAdmissionRefusal(sid, agent, admissionOptions);
|
|
70
|
+
if (refusal) return refusal;
|
|
71
|
+
const auth = await resolveAuthSession({ cwd: targetPath, env: process.env, homeDir, autoRotate: false });
|
|
72
|
+
if (!isAuthenticated(auth)) return refused("AUTH_REQUIRED", "not_authenticated", 401);
|
|
73
|
+
if (auth.source === "session_admission") return refused("ADMISSION_CREDENTIAL_REFUSED", "admitted_context");
|
|
74
|
+
const credential = await credentialFor(auth);
|
|
75
|
+
const apiUrl = String(auth.apiUrl || "").replace(/\/+$/, "");
|
|
76
|
+
const deadline = now() + Math.max(1, Math.min(Number(timeoutMs) || 10_000, 30_000));
|
|
77
|
+
const remaining = () => Math.max(0, deadline - now());
|
|
78
|
+
let after = "";
|
|
79
|
+
let grant;
|
|
80
|
+
for (let page = 0; page < MAX_GRANT_PAGES; page += 1) {
|
|
81
|
+
refusal = await legacyJoinAdmissionRefusal(sid, agent, admissionOptions);
|
|
82
|
+
if (refusal) return refusal;
|
|
83
|
+
if (!remaining()) return refused("LEGACY_GRANT_UNAVAILABLE", "verification_timeout");
|
|
84
|
+
const query = new URLSearchParams({ limit: String(PAGE_LIMIT) });
|
|
85
|
+
if (after) query.set("after", after);
|
|
86
|
+
const result = await checkedTransport(requestRead)(`${apiUrl}/api/v1/sessions/agent-grants?${query}`, {
|
|
87
|
+
method: "GET", credential, timeoutMs: remaining(), maxRetries: 0,
|
|
88
|
+
});
|
|
89
|
+
if (!Array.isArray(result?.grants) || result.grants.length > PAGE_LIMIT) {
|
|
90
|
+
return refused("LEGACY_GRANT_CONTRACT_MISMATCH", "invalid_grant_page");
|
|
91
|
+
}
|
|
92
|
+
const matches = result.grants.filter((item) => item?.agentId === agent);
|
|
93
|
+
if (matches.length > 1) return refused("LEGACY_GRANT_CONTRACT_MISMATCH", "duplicate_grant_identity");
|
|
94
|
+
if (matches.length) { grant = matches[0]; break; }
|
|
95
|
+
if (result.nextAfter === null) return refused("LEGACY_GRANT_NOT_ACTIVE", "missing_or_revoked");
|
|
96
|
+
const next = result.nextAfter;
|
|
97
|
+
if (typeof next !== "string" || !next || next <= after || !result.grants.some((item) => item?.agentId === next)) {
|
|
98
|
+
return refused("LEGACY_GRANT_CONTRACT_MISMATCH", "invalid_grant_cursor");
|
|
99
|
+
}
|
|
100
|
+
after = next;
|
|
101
|
+
}
|
|
102
|
+
if (!grant) return refused("LEGACY_GRANT_UNAVAILABLE", "grant_page_limit");
|
|
103
|
+
if (grant.exists === false || grant.revoked === true || grant.revokedAt != null) {
|
|
104
|
+
return refused("LEGACY_GRANT_NOT_ACTIVE", "missing_or_revoked");
|
|
105
|
+
}
|
|
106
|
+
if (grant.exists !== true || grant.revoked !== false || grant.revokedAt !== null || typeof grant.id !== "string" || !grant.id) {
|
|
107
|
+
return refused("LEGACY_GRANT_CONTRACT_MISMATCH", "invalid_grant_confirmation");
|
|
108
|
+
}
|
|
109
|
+
if (grant.role !== requestedRole) return refused("LEGACY_GRANT_ROLE_MISMATCH", "requested_role_not_granted");
|
|
110
|
+
refusal = await legacyJoinAdmissionRefusal(sid, agent, admissionOptions);
|
|
111
|
+
if (refusal) return refusal;
|
|
112
|
+
if (!remaining()) return refused("LEGACY_PRESENCE_UNAVAILABLE", "verification_timeout");
|
|
113
|
+
const presence = await checkedTransport(requestMutation)(
|
|
114
|
+
`${apiUrl}/api/v1/sessions/${urlPathSegment(sid, { label: "sessionId" })}/presence`, {
|
|
115
|
+
method: "PUT", operationName: "session.legacy_join_presence", credential,
|
|
116
|
+
timeoutMs: remaining(), maxRetries: 0,
|
|
117
|
+
body: { agentId: agent, state: "idle", model, clientKind: "cli", observedAt: new Date(now()).toISOString() },
|
|
118
|
+
},
|
|
119
|
+
);
|
|
120
|
+
if (presence?.agentId !== agent) return refused("LEGACY_PRESENCE_CONTRACT_MISMATCH", "presence_identity_mismatch");
|
|
121
|
+
if (presence.status !== "ok" || presence.recorded !== true || !Number.isFinite(presence.ttlSeconds) || presence.ttlSeconds <= 0) {
|
|
122
|
+
return refused("LEGACY_PRESENCE_UNAVAILABLE", "presence_not_recorded");
|
|
123
|
+
}
|
|
124
|
+
return {
|
|
125
|
+
ok: true, source: "legacy_grant_presence",
|
|
126
|
+
grant: { id: grant.id, agentId: grant.agentId, role: grant.role },
|
|
127
|
+
presence: { recorded: true, agentId: agent, ttlSeconds: presence.ttlSeconds },
|
|
128
|
+
};
|
|
129
|
+
} catch (error) {
|
|
130
|
+
const status = Number(error?.status) || null;
|
|
131
|
+
const code = error?.code;
|
|
132
|
+
if (code === "ADMISSION_REQUIRED" || code === "ADMISSION_SCOPE_DENIED") return refused(code, "admission_required_or_refused", status);
|
|
133
|
+
if (status === 401) return refused("AUTH_REQUIRED", "credential_rejected", status);
|
|
134
|
+
if (status === 403) return refused("LEGACY_AGENT_ACCESS_DENIED", "grant_or_membership_refused", status);
|
|
135
|
+
return refused("LEGACY_AGENT_ACCESS_UNAVAILABLE", "verification_failed", status);
|
|
136
|
+
}
|
|
137
|
+
}
|
|
@@ -305,6 +305,7 @@ export async function registerAgent(
|
|
|
305
305
|
role = "observer",
|
|
306
306
|
targetPath = process.cwd(),
|
|
307
307
|
trackProcessExit = true,
|
|
308
|
+
preserveIdentity = false,
|
|
308
309
|
} = {}
|
|
309
310
|
) {
|
|
310
311
|
const paths = resolveSessionPaths(sessionId, { targetPath });
|
|
@@ -320,7 +321,7 @@ export async function registerAgent(
|
|
|
320
321
|
// a random hex blob. Caller-supplied real ids are NEVER renamed
|
|
321
322
|
// (kill tests like PR 348/351 register `codex-task-holder-1` with
|
|
322
323
|
// model="" and need the id to round-trip verbatim).
|
|
323
|
-
if (shouldAutoRenameInRegistry({ originalCallerAgentId })) {
|
|
324
|
+
if (!preserveIdentity && shouldAutoRenameInRegistry({ originalCallerAgentId })) {
|
|
324
325
|
const existingAgents = await listAgentsInternal(paths);
|
|
325
326
|
const friendly = assignFriendlyName({ model, existingAgents });
|
|
326
327
|
if (friendly && friendly !== resolvedAgentId.toLowerCase()) {
|