sentinelayer-cli 0.46.3 → 0.46.5
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 +16 -0
- package/package.json +1 -1
- package/src/auth/gate.js +14 -7
- package/src/auth/service.js +34 -15
- package/src/auth/session-store.js +68 -47
- package/src/commands/auth.js +24 -4
- package/src/commands/session.js +116 -56
- package/src/mcp/bridge-inputs.js +3 -0
- package/src/session/admission.js +222 -66
- package/src/session/agent-access.js +137 -0
- package/src/session/agent-registry.js +2 -1
- package/src/session/listener-process.js +427 -57
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,155 @@ 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
|
+
async function recoverAndStore(auth, sid, agent, admissionId, key, fingerprint, deps, homeDir, now, rejectedToken = null) {
|
|
498
|
+
return withAdmissionCredentialLock(admissionCredentialPath(sid, agent, { homeDir }), async () => {
|
|
499
|
+
// Another process may have completed recovery while this process waited.
|
|
500
|
+
const current = await readAdmissionCredentialState(sid, agent, { homeDir, now: now() });
|
|
501
|
+
if (current.state === "live" && current.credential.admissionId === admissionId &&
|
|
502
|
+
current.credential.requestFingerprint === fingerprint && current.credential.publicKey === key.publicKey &&
|
|
503
|
+
current.credential.apiUrl === auth.apiUrl && current.credential.token !== rejectedToken) {
|
|
504
|
+
return redacted({ status: "active", reused: true, stored: current.credential, sid, agent, homeDir });
|
|
505
|
+
}
|
|
506
|
+
const claimed = await recoverCredential(auth, sid, agent, admissionId, key, deps, now);
|
|
507
|
+
const stored = await storeClaimedCredential(claimed, { auth, sid, agent, key, fingerprint, homeDir, now });
|
|
508
|
+
return { ...redacted({ status: "active", reused: true, stored, claimed, sid, agent, homeDir }), recovered: true };
|
|
509
|
+
});
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
async function storeClaimedCredential(claimed, { auth, sid, agent, key, fingerprint, homeDir, now }) {
|
|
513
|
+
if (!String(claimed?.credential?.token || "").startsWith("sladm_")) {
|
|
514
|
+
throw new Error("Admission did not return a scoped agent credential.");
|
|
515
|
+
}
|
|
516
|
+
const identity = identityMetadata(claimed.identity);
|
|
517
|
+
const grant = grantMetadata(claimed.grant);
|
|
518
|
+
const previous = await readAdmissionCredentialState(sid, agent, { homeDir, now: now() });
|
|
519
|
+
if (previous.state === "live" && previous.credential.admissionId === claimed.admissionId &&
|
|
520
|
+
previous.credential.publicKey === key.publicKey && previous.credential.apiUrl === auth.apiUrl &&
|
|
521
|
+
previous.credential.identity?.passportId === identity.passportId &&
|
|
522
|
+
previous.credential.identity?.keyThumbprint === identity.keyThumbprint &&
|
|
523
|
+
previous.credential.grant?.grantId === grant.grantId && previous.credential.expiresAt === grant.expiresAt) {
|
|
524
|
+
// Recovery does not reissue signed documents. Retain original documents
|
|
525
|
+
// only for this exact unchanged identity/grant; never synthesize new ones.
|
|
526
|
+
identity.passport ??= previous.credential.identity.passport;
|
|
527
|
+
grant.document ??= previous.credential.grant.document;
|
|
528
|
+
}
|
|
529
|
+
const stored = {
|
|
530
|
+
version: 3, sessionId: sid, agentId: agent, apiUrl: auth.apiUrl,
|
|
531
|
+
publicKey: key.publicKey, requestFingerprint: fingerprint,
|
|
532
|
+
admissionId: claimed.admissionId, token: claimed.credential.token,
|
|
533
|
+
expiresAt: claimed.grant.expiresAt, grantedActions: claimed.grant.actions,
|
|
534
|
+
passportId: claimed.identity.passportId, subject: claimed.identity.subject,
|
|
535
|
+
// Non-secret identity metadata survives reconnects. Email is never used as
|
|
536
|
+
// authentication: the credential/key binding remains the authority.
|
|
537
|
+
identity,
|
|
538
|
+
grant,
|
|
539
|
+
correlation: claimed.correlation,
|
|
540
|
+
storedAt: new Date(now()).toISOString(),
|
|
541
|
+
};
|
|
542
|
+
await writeSecretFile(admissionCredentialPath(sid, agent, { homeDir }), stored);
|
|
543
|
+
return stored;
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
function identityMetadata(identity) {
|
|
547
|
+
return {
|
|
548
|
+
subject: identity.subject, passportId: identity.passportId,
|
|
549
|
+
passportStatus: identity.passportStatus, keyThumbprint: identity.keyThumbprint,
|
|
550
|
+
popAssurance: identity.popAssurance,
|
|
551
|
+
email: identity.email && { status: identity.email.status, address: identity.email.address },
|
|
552
|
+
passport: identity.passport,
|
|
553
|
+
};
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
function grantMetadata(grant) {
|
|
557
|
+
return {
|
|
558
|
+
grantId: grant.grantId, sessionId: grant.sessionId, actions: grant.actions,
|
|
559
|
+
expiresAt: grant.expiresAt, goalDigest: grant.goalDigest, document: grant.document,
|
|
560
|
+
};
|
|
561
|
+
}
|
|
562
|
+
|
|
405
563
|
/**
|
|
406
564
|
* Request admission, wait for a human decision, and claim the grant.
|
|
407
565
|
* Returns a REDACTED result: the credential token is never included.
|
|
@@ -419,6 +577,7 @@ export async function runAdmissionJoin(
|
|
|
419
577
|
actions = DEFAULT_ADMISSION_ACTIONS,
|
|
420
578
|
ttlSeconds = 7200,
|
|
421
579
|
wait = true,
|
|
580
|
+
recoverActive = false,
|
|
422
581
|
waitTimeoutMs = 15 * 60_000,
|
|
423
582
|
targetPath = process.cwd(),
|
|
424
583
|
homeDir,
|
|
@@ -464,9 +623,19 @@ export async function runAdmissionJoin(
|
|
|
464
623
|
// same key. A different goal, scope or duration is a question for the server,
|
|
465
624
|
// which answers with the existing grant (same purpose) or a conflict (new one).
|
|
466
625
|
const held = await readAdmissionCredentialState(sid, agent, { homeDir, now: now() });
|
|
467
|
-
if (held.state === "live" && held.credential.requestFingerprint === fingerprint) {
|
|
626
|
+
if (!recoverActive && held.state === "live" && held.credential.requestFingerprint === fingerprint) {
|
|
468
627
|
return redacted({ status: "active", reused: true, stored: held.credential, sid, agent, homeDir });
|
|
469
628
|
}
|
|
629
|
+
if (recoverActive && held.credential?.admissionId) {
|
|
630
|
+
if (held.credential.requestFingerprint !== fingerprint || held.credential.publicKey !== key.publicKey ||
|
|
631
|
+
held.credential.apiUrl !== auth.apiUrl) {
|
|
632
|
+
throw new Error("Credential recovery requires the unchanged agent key, API, goal, scope and TTL.");
|
|
633
|
+
}
|
|
634
|
+
// Go directly to recovery, never file a new request after a refused cached
|
|
635
|
+
// bearer. A revoked/expired grant must be refused, not reopened as pending.
|
|
636
|
+
return recoverAndStore(auth, sid, agent, held.credential.admissionId, key, fingerprint, deps, homeDir, now,
|
|
637
|
+
held.credential.token);
|
|
638
|
+
}
|
|
470
639
|
|
|
471
640
|
const statePath = admissionStatePath(targetPath, sid, agent);
|
|
472
641
|
let state = await readJsonFile(statePath);
|
|
@@ -501,6 +670,7 @@ export async function runAdmissionJoin(
|
|
|
501
670
|
if (
|
|
502
671
|
created?.status === "active" &&
|
|
503
672
|
created?.reused &&
|
|
673
|
+
!recoverActive &&
|
|
504
674
|
held.state === "live" &&
|
|
505
675
|
held.credential.admissionId === created.admissionId
|
|
506
676
|
) {
|
|
@@ -508,12 +678,10 @@ export async function runAdmissionJoin(
|
|
|
508
678
|
return redacted({ status: "active", reused: true, stored: held.credential, sid, agent, homeDir });
|
|
509
679
|
}
|
|
510
680
|
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
|
-
);
|
|
681
|
+
const recovered = await recoverAndStore(auth, sid, agent, created.admissionId, key, fingerprint, deps, homeDir, now,
|
|
682
|
+
recoverActive && held.state === "live" ? held.credential.token : null);
|
|
683
|
+
await fsp.rm(statePath, { force: true });
|
|
684
|
+
return recovered;
|
|
517
685
|
}
|
|
518
686
|
state = {
|
|
519
687
|
admissionId: created.admissionId,
|
|
@@ -590,46 +758,53 @@ export async function runAdmissionJoin(
|
|
|
590
758
|
return { status: polled.status, admissionId: state.admissionId, waited: true };
|
|
591
759
|
}
|
|
592
760
|
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.");
|
|
761
|
+
const recovered = await recoverAndStore(auth, sid, agent, state.admissionId, key, fingerprint, deps, homeDir, now,
|
|
762
|
+
recoverActive && held.state === "live" ? held.credential.token : null);
|
|
763
|
+
await fsp.rm(statePath, { force: true });
|
|
764
|
+
return recovered;
|
|
603
765
|
}
|
|
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
766
|
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
767
|
+
// Initial claim is a credential writer too. The same cross-process lock must
|
|
768
|
+
// cover it through publication, or a delayed first claim could overwrite a
|
|
769
|
+
// recovered replacement. Refresh the nonce only after obtaining this lock.
|
|
770
|
+
const result = await withAdmissionCredentialLock(admissionCredentialPath(sid, agent, { homeDir }), async () => {
|
|
771
|
+
const current = await readAdmissionCredentialState(sid, agent, { homeDir, now: now() });
|
|
772
|
+
if (current.state === "live" && current.credential.admissionId === state.admissionId &&
|
|
773
|
+
current.credential.requestFingerprint === fingerprint && current.credential.publicKey === key.publicKey &&
|
|
774
|
+
current.credential.apiUrl === auth.apiUrl) {
|
|
775
|
+
return redacted({ status: "active", reused: true, stored: current.credential, sid, agent, homeDir });
|
|
776
|
+
}
|
|
777
|
+
const fresh = await checkedTransport(requestRead)(admissionMemberUrl(auth.apiUrl, sid, state.admissionId), {
|
|
778
|
+
method: "GET", credential: auth.credential,
|
|
779
|
+
});
|
|
780
|
+
if (TERMINAL.has(fresh.status)) {
|
|
781
|
+
return { status: fresh.status, admissionId: state.admissionId, waited: true };
|
|
782
|
+
}
|
|
783
|
+
if (fresh.status === "active") {
|
|
784
|
+
const recovered = await recoverCredential(auth, sid, agent, state.admissionId, key, deps, now);
|
|
785
|
+
const stored = await storeClaimedCredential(recovered, { auth, sid, agent, key, fingerprint, homeDir, now });
|
|
786
|
+
return { ...redacted({ status: "active", reused: true, stored, claimed: recovered, sid, agent, homeDir }), recovered: true };
|
|
787
|
+
}
|
|
788
|
+
const fields = fresh?.claim?.preimage;
|
|
789
|
+
if (fresh.status !== "approved" || fresh.identityReady === false || !fields || fresh.claim.domain !== CLAIM_DOMAIN) {
|
|
790
|
+
throw new Error("Approved admission did not include a valid claim challenge.");
|
|
791
|
+
}
|
|
792
|
+
if (fields.publicKey !== key.publicKey || fields.agentId !== agent || fields.sessionId !== sid ||
|
|
793
|
+
fields.admissionId !== state.admissionId) {
|
|
794
|
+
throw new Error("Refusing to sign: the claim challenge does not name this agent's key.");
|
|
795
|
+
}
|
|
796
|
+
const signature = signBytes(null, canonicalPreimage(fields, { domain: CLAIM_DOMAIN, fields: [...CLAIM_FIELDS] }), key.privateKey);
|
|
797
|
+
const claimed = await mutate(
|
|
798
|
+
auth, sid, ROUTE.claim,
|
|
799
|
+
admissionMemberUrl(auth.apiUrl, sid, state.admissionId, "/claim"),
|
|
800
|
+
{ nonce: fields.nonce, signature: signature.toString("base64url") },
|
|
801
|
+
{ ...deps, operationName: "session.admission_claim" }
|
|
802
|
+
);
|
|
803
|
+
const stored = await storeClaimedCredential(claimed, { auth, sid, agent, key, fingerprint, homeDir, now });
|
|
804
|
+
return redacted({ status: "active", reused: false, stored, claimed, sid, agent, homeDir });
|
|
805
|
+
});
|
|
631
806
|
await fsp.rm(statePath, { force: true });
|
|
632
|
-
return
|
|
807
|
+
return result;
|
|
633
808
|
}
|
|
634
809
|
|
|
635
810
|
function redacted({ status, reused, stored, claimed, sid, agent, homeDir }) {
|
|
@@ -637,28 +812,9 @@ function redacted({ status, reused, stored, claimed, sid, agent, homeDir }) {
|
|
|
637
812
|
status,
|
|
638
813
|
reused,
|
|
639
814
|
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}` },
|
|
815
|
+
identity: stored.identity || (claimed ? identityMetadata(claimed.identity) : { subject: stored.subject, passportId: stored.passportId }),
|
|
816
|
+
grant: stored.grant || (claimed ? grantMetadata(claimed.grant) : { sessionId: sid, actions: stored.grantedActions, expiresAt: stored.expiresAt }),
|
|
817
|
+
correlation: claimed ? claimed.correlation : stored.correlation || { actorRef: `adm:${stored.admissionId}` },
|
|
662
818
|
jev: claimed ? claimed.jev : undefined,
|
|
663
819
|
// A reference only. The token itself never appears in output.
|
|
664
820
|
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()) {
|