sentinelayer-cli 0.46.4 → 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 +95 -31
- 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/README.md
CHANGED
|
@@ -46,6 +46,22 @@ sl watch run-events --run-id <run-id>
|
|
|
46
46
|
|
|
47
47
|
Windows PowerShell note: `sl` is a built-in alias for `Set-Location`. Use `sentinelayer-cli` (or short alias `slc`) instead.
|
|
48
48
|
|
|
49
|
+
### Human sign-in and agent identity
|
|
50
|
+
|
|
51
|
+
`sl auth login --skip-browser-open` prints the approval URL before waiting;
|
|
52
|
+
in JSON mode the URL goes to stderr. A server outage during `auth status` is
|
|
53
|
+
unavailable validation, not logout. Do not delete credential encryption keys
|
|
54
|
+
or log out to repair a keyring/file-permission error.
|
|
55
|
+
|
|
56
|
+
An admitted agent receives its own AIdenID email, Ed25519-backed passport and
|
|
57
|
+
room-scoped credential after human approval. Its email and cryptographic
|
|
58
|
+
identity remain visible on reconnect; email alone never grants access.
|
|
59
|
+
An interrupted claim or missing credential can be recovered with the retained
|
|
60
|
+
agent key. For a stale active credential, repeat the original join with
|
|
61
|
+
`--recover-admission`, the same `--goal`, `--scope` and `--ttl`.
|
|
62
|
+
Recovery requires API support, preserves the original expiry, and cannot revive
|
|
63
|
+
revoked, stopped or expired permissions. It is not TTL renewal.
|
|
64
|
+
|
|
49
65
|
## MCP Live Entry Points And Incident Response
|
|
50
66
|
|
|
51
67
|
Live MCP entry points:
|
package/package.json
CHANGED
package/src/auth/gate.js
CHANGED
|
@@ -5,6 +5,7 @@ import fs from "node:fs";
|
|
|
5
5
|
import path from "node:path";
|
|
6
6
|
import pc from "picocolors";
|
|
7
7
|
import { resolveActiveAuthSession } from "./service.js";
|
|
8
|
+
import { readStoredSessionMetadata } from "./session-store.js";
|
|
8
9
|
import { resolveAgentAdmissionTarget } from "../session/admission-auth.js";
|
|
9
10
|
import { authLoginHint } from "../ui/command-hints.js";
|
|
10
11
|
import { isAuthenticated } from "./credential-destinations.js";
|
|
@@ -378,7 +379,9 @@ export async function checkAuthGate(args) {
|
|
|
378
379
|
resolveError = "session_invalid";
|
|
379
380
|
}
|
|
380
381
|
} else {
|
|
381
|
-
|
|
382
|
+
// A keyring/decryption failure leaves the metadata intact. That is not
|
|
383
|
+
// absence, and recommending logout would destroy recoverable sign-in.
|
|
384
|
+
resolveError = (await readStoredSessionMetadata()) ? "session_token_missing" : "no_session";
|
|
382
385
|
}
|
|
383
386
|
} catch (error) {
|
|
384
387
|
resolveError = error instanceof Error ? `session_read_error: ${error.message}` : "session_read_error";
|
|
@@ -405,15 +408,19 @@ export function printAuthRequired(failureReason = null) {
|
|
|
405
408
|
if (reason === "session_expired") {
|
|
406
409
|
console.error(" Your stored session has expired. Log in again:");
|
|
407
410
|
} else if (reason === "session_token_missing") {
|
|
408
|
-
console.error(" Your session metadata is present but
|
|
409
|
-
console.error("
|
|
411
|
+
console.error(" Your session metadata is present but its credential cannot be read.");
|
|
412
|
+
console.error(" Check the current user/home, keyring, encryption key and file permissions.");
|
|
413
|
+
console.error(" Your stored credentials were not cleared; repair storage before signing in again.");
|
|
410
414
|
console.error("");
|
|
411
|
-
|
|
412
|
-
|
|
415
|
+
process.exitCode = 1;
|
|
416
|
+
return;
|
|
413
417
|
} else if (reason && reason.startsWith("session_read_error")) {
|
|
414
418
|
console.error(" Session read failed: " + pc.yellow(reason.replace(/^session_read_error:\s*/, "")));
|
|
415
|
-
console.error("
|
|
416
|
-
console.error("
|
|
419
|
+
console.error(" Check the current user/home and credential-file permissions.");
|
|
420
|
+
console.error(" Your stored credentials were not cleared; do not log out to repair a read error.");
|
|
421
|
+
console.error("");
|
|
422
|
+
process.exitCode = 1;
|
|
423
|
+
return;
|
|
417
424
|
} else {
|
|
418
425
|
console.error(" Log in to SentinelLayer to use CLI commands:");
|
|
419
426
|
}
|
package/src/auth/service.js
CHANGED
|
@@ -7,6 +7,7 @@ import open from "open";
|
|
|
7
7
|
import { loadConfig } from "../config/service.js";
|
|
8
8
|
import { scopedAdmissionAuth } from "./admission-scope.js";
|
|
9
9
|
import {
|
|
10
|
+
CredentialDestinationRefused,
|
|
10
11
|
DEFAULT_API_URL,
|
|
11
12
|
assertTrustedApiUrl,
|
|
12
13
|
credentialFor,
|
|
@@ -453,6 +454,7 @@ async function rotateStoredApiTokenIfNeeded({
|
|
|
453
454
|
tokenPrefix: issued.token_prefix || null,
|
|
454
455
|
tokenExpiresAt: issued.expires_at || null,
|
|
455
456
|
user: session.user,
|
|
457
|
+
aidenid: session.aidenid,
|
|
456
458
|
},
|
|
457
459
|
{ homeDir }
|
|
458
460
|
);
|
|
@@ -490,7 +492,8 @@ async function rotateStoredApiTokenIfNeeded({
|
|
|
490
492
|
* tokenTtlDays?: number,
|
|
491
493
|
* ide?: string,
|
|
492
494
|
* cliVersion?: string,
|
|
493
|
-
* homeDir?: string
|
|
495
|
+
* homeDir?: string,
|
|
496
|
+
* onAuthorize?: (details: { authorizeUrl: string, browserOpened: boolean }) => void | Promise<void>
|
|
494
497
|
* }} [options]
|
|
495
498
|
* @returns {Promise<{
|
|
496
499
|
* apiUrl: string,
|
|
@@ -521,6 +524,7 @@ export async function loginAndPersistSession({
|
|
|
521
524
|
ide = DEFAULT_IDE_NAME,
|
|
522
525
|
cliVersion = "",
|
|
523
526
|
homeDir,
|
|
527
|
+
onAuthorize = () => {},
|
|
524
528
|
} = {}) {
|
|
525
529
|
const apiUrl = await resolveApiUrl({ cwd, env, explicitApiUrl, homeDir });
|
|
526
530
|
// The token this flow issues is the user's, so the API that issues it must be the configured
|
|
@@ -548,6 +552,10 @@ export async function loginAndPersistSession({
|
|
|
548
552
|
}
|
|
549
553
|
}
|
|
550
554
|
|
|
555
|
+
// Headless hosts must receive the URL before approval polling can complete.
|
|
556
|
+
// The command renders this on stderr in JSON mode, keeping stdout parseable.
|
|
557
|
+
await onAuthorize({ authorizeUrl, browserOpened });
|
|
558
|
+
|
|
551
559
|
const approval = await pollCliAuthSession({
|
|
552
560
|
apiUrl,
|
|
553
561
|
sessionId: String(session.session_id || "").trim(),
|
|
@@ -768,7 +776,8 @@ export async function resolveActiveAuthSession({
|
|
|
768
776
|
* homeDir?: string
|
|
769
777
|
* }} [options]
|
|
770
778
|
* @returns {Promise<{
|
|
771
|
-
* authenticated: boolean,
|
|
779
|
+
* authenticated: boolean | null,
|
|
780
|
+
* validation: "none" | "not_checked" | "verified" | "rejected" | "unavailable" | "storage_unavailable",
|
|
772
781
|
* apiUrl: string,
|
|
773
782
|
* source: string | null,
|
|
774
783
|
* storage: string | null,
|
|
@@ -805,20 +814,25 @@ export async function getAuthStatus({
|
|
|
805
814
|
});
|
|
806
815
|
|
|
807
816
|
if (!session) {
|
|
817
|
+
// Encrypted metadata can survive a missing/unreadable key or keyring.
|
|
818
|
+
// Distinguish recoverable storage from actual absence without creating a
|
|
819
|
+
// replacement key, validating remotely, or claiming authentication.
|
|
820
|
+
const stored = await readStoredSessionMetadata({ homeDir });
|
|
808
821
|
return {
|
|
809
|
-
authenticated: false,
|
|
822
|
+
authenticated: stored ? null : false,
|
|
823
|
+
validation: stored ? "storage_unavailable" : "none",
|
|
810
824
|
apiUrl: await resolveApiUrl({ cwd, env, explicitApiUrl, homeDir }),
|
|
811
|
-
source: null,
|
|
812
|
-
storage: null,
|
|
813
|
-
user: null,
|
|
814
|
-
aidenid: null,
|
|
825
|
+
source: stored ? "storage_unknown" : null,
|
|
826
|
+
storage: stored?.storage || null,
|
|
827
|
+
user: stored ? normalizeUser(stored.user || {}) : null,
|
|
828
|
+
aidenid: stored?.aidenid || null,
|
|
815
829
|
remoteUser: null,
|
|
816
830
|
remoteError: null,
|
|
817
831
|
rotated: false,
|
|
818
|
-
tokenExpiresAt: null,
|
|
819
|
-
tokenPrefix: null,
|
|
820
|
-
tokenId: null,
|
|
821
|
-
filePath: null,
|
|
832
|
+
tokenExpiresAt: stored?.tokenExpiresAt || null,
|
|
833
|
+
tokenPrefix: stored?.tokenPrefix || null,
|
|
834
|
+
tokenId: stored?.tokenId || null,
|
|
835
|
+
filePath: stored?.filePath || null,
|
|
822
836
|
};
|
|
823
837
|
}
|
|
824
838
|
|
|
@@ -829,12 +843,12 @@ export async function getAuthStatus({
|
|
|
829
843
|
remoteUser = normalizeUser(await fetchCurrentUser({ apiUrl: session.apiUrl, credential: session.credential }));
|
|
830
844
|
} catch (error) {
|
|
831
845
|
remoteError =
|
|
832
|
-
error instanceof SentinelayerApiError
|
|
846
|
+
error instanceof SentinelayerApiError || error instanceof CredentialDestinationRefused
|
|
833
847
|
? {
|
|
834
848
|
code: error.code,
|
|
835
849
|
message: error.message,
|
|
836
|
-
status: error.status,
|
|
837
|
-
requestId: error.requestId,
|
|
850
|
+
status: error instanceof CredentialDestinationRefused ? 403 : error.status,
|
|
851
|
+
requestId: error.requestId || null,
|
|
838
852
|
}
|
|
839
853
|
: {
|
|
840
854
|
code: "UNKNOWN",
|
|
@@ -845,8 +859,13 @@ export async function getAuthStatus({
|
|
|
845
859
|
}
|
|
846
860
|
}
|
|
847
861
|
|
|
862
|
+
const validationUnavailable = remoteError && (
|
|
863
|
+
[408, 425, 429, 499].includes(remoteError.status) || remoteError.status >= 500
|
|
864
|
+
);
|
|
848
865
|
return {
|
|
849
|
-
|
|
866
|
+
// Unknown remote health is neither a successful validation nor a logout.
|
|
867
|
+
authenticated: validationUnavailable ? null : !remoteError,
|
|
868
|
+
validation: validationUnavailable ? "unavailable" : remoteError ? "rejected" : checkRemote ? "verified" : "not_checked",
|
|
850
869
|
apiUrl: session.apiUrl,
|
|
851
870
|
source: session.source,
|
|
852
871
|
storage: session.storage,
|
|
@@ -135,60 +135,68 @@ function resolveLegacyCredentialsKeyPath({ homeDir } = {}) {
|
|
|
135
135
|
return path.join(resolvedHome, ".sentinelayer", "credentials.key");
|
|
136
136
|
}
|
|
137
137
|
|
|
138
|
-
async function
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
} catch (error) {
|
|
148
|
-
if (!(error && typeof error === "object" && error.code === "ENOENT")) {
|
|
138
|
+
async function readFileKeyAt(keyPath) {
|
|
139
|
+
// Exclusive-create fallback filesystems may expose a partially written key.
|
|
140
|
+
// Retry that bounded race, but never replace an invalid or unreadable key.
|
|
141
|
+
for (let attempt = 0; ; attempt += 1) {
|
|
142
|
+
let raw;
|
|
143
|
+
try {
|
|
144
|
+
raw = await fsp.readFile(keyPath, "utf-8");
|
|
145
|
+
} catch (error) {
|
|
146
|
+
if (error?.code === "ENOENT") return null;
|
|
149
147
|
throw error;
|
|
150
148
|
}
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
await fsp.mkdir(path.dirname(keyPath), { recursive: true, mode: 0o700 });
|
|
158
|
-
await fsp.writeFile(keyPath, legacyKey.toString("base64"), { encoding: "utf-8", mode: 0o600 });
|
|
159
|
-
try {
|
|
160
|
-
await fsp.chmod(keyPath, 0o600);
|
|
161
|
-
} catch {
|
|
162
|
-
// Windows does not reliably support POSIX chmod semantics.
|
|
163
|
-
}
|
|
164
|
-
let verified = false;
|
|
165
|
-
try {
|
|
166
|
-
const verifyRaw = await fsp.readFile(keyPath, "utf-8");
|
|
167
|
-
const verifyKey = Buffer.from(String(verifyRaw || "").trim(), "base64");
|
|
168
|
-
verified = verifyKey.length === 32 && verifyKey.equals(legacyKey);
|
|
169
|
-
} catch {
|
|
170
|
-
verified = false;
|
|
171
|
-
}
|
|
172
|
-
if (verified) {
|
|
173
|
-
await fsp.rm(legacyKeyPath, { force: true });
|
|
174
|
-
}
|
|
175
|
-
return legacyKey;
|
|
176
|
-
}
|
|
177
|
-
} catch (error) {
|
|
178
|
-
if (!(error && typeof error === "object" && error.code === "ENOENT")) {
|
|
149
|
+
const encoded = String(raw || "").trim();
|
|
150
|
+
const key = Buffer.from(encoded, "base64");
|
|
151
|
+
if (key.length === 32 && key.toString("base64") === encoded) return key;
|
|
152
|
+
if (attempt >= 5) {
|
|
153
|
+
const error = new Error("Stored credential encryption key is invalid; it was not replaced.");
|
|
154
|
+
error.code = "CREDENTIALS_KEY_INVALID";
|
|
179
155
|
throw error;
|
|
180
156
|
}
|
|
157
|
+
await delayMilliseconds(20);
|
|
181
158
|
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
async function readFileKey({ homeDir } = {}) {
|
|
162
|
+
return (await readFileKeyAt(resolveCredentialsKeyPath({ homeDir }))) ||
|
|
163
|
+
(await readFileKeyAt(resolveLegacyCredentialsKeyPath({ homeDir })));
|
|
164
|
+
}
|
|
182
165
|
|
|
183
|
-
|
|
166
|
+
async function loadOrCreateFileKey({ homeDir } = {}) {
|
|
167
|
+
const keyPath = resolveCredentialsKeyPath({ homeDir });
|
|
168
|
+
const existing = await readFileKeyAt(keyPath);
|
|
169
|
+
if (existing) return existing;
|
|
170
|
+
const legacyKey = await readFileKeyAt(resolveLegacyCredentialsKeyPath({ homeDir }));
|
|
171
|
+
const key = legacyKey || crypto.randomBytes(32);
|
|
172
|
+
const encoded = key.toString("base64");
|
|
184
173
|
await fsp.mkdir(path.dirname(keyPath), { recursive: true, mode: 0o700 });
|
|
185
|
-
|
|
174
|
+
const tmpPath = `${keyPath}.${process.pid}.${crypto.randomUUID()}.tmp`;
|
|
186
175
|
try {
|
|
187
|
-
await fsp.
|
|
188
|
-
|
|
189
|
-
|
|
176
|
+
await fsp.writeFile(tmpPath, encoded, { encoding: "utf-8", mode: 0o600, flag: "wx" });
|
|
177
|
+
await syncFile(tmpPath);
|
|
178
|
+
try {
|
|
179
|
+
// Publish complete bytes once. Every concurrent writer then uses the winner.
|
|
180
|
+
await fsp.link(tmpPath, keyPath);
|
|
181
|
+
} catch (error) {
|
|
182
|
+
if (error?.code !== "EEXIST") {
|
|
183
|
+
if (!["ENOTSUP", "ENOSYS", "EXDEV", "EPERM"].includes(error?.code)) throw error;
|
|
184
|
+
try {
|
|
185
|
+
await fsp.writeFile(keyPath, encoded, { encoding: "utf-8", mode: 0o600, flag: "wx" });
|
|
186
|
+
} catch (fallbackError) {
|
|
187
|
+
if (fallbackError?.code !== "EEXIST") throw fallbackError;
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
const persisted = await readFileKeyAt(keyPath);
|
|
192
|
+
if (!persisted) throw new Error("Credential encryption key could not be persisted.");
|
|
193
|
+
await syncFile(keyPath);
|
|
194
|
+
await syncDirectory(path.dirname(keyPath));
|
|
195
|
+
// Keep the legacy key: deleting it can race a read-only legacy reader.
|
|
196
|
+
return persisted;
|
|
197
|
+
} finally {
|
|
198
|
+
await fsp.rm(tmpPath, { force: true }).catch(() => {});
|
|
190
199
|
}
|
|
191
|
-
return key;
|
|
192
200
|
}
|
|
193
201
|
|
|
194
202
|
function encryptToken(token, key) {
|
|
@@ -540,7 +548,11 @@ async function tryDecryptFileToken({ metadata, homeDir }) {
|
|
|
540
548
|
return null;
|
|
541
549
|
}
|
|
542
550
|
try {
|
|
543
|
-
const key = await
|
|
551
|
+
const key = await readFileKey({ homeDir });
|
|
552
|
+
if (!key) {
|
|
553
|
+
emitSessionWarning("FILE_CREDENTIAL_UNAVAILABLE", { reason: "key_missing", source: "file-encrypted" });
|
|
554
|
+
return null;
|
|
555
|
+
}
|
|
544
556
|
const token = decryptToken(
|
|
545
557
|
{
|
|
546
558
|
tokenEncrypted: metadata.tokenEncrypted,
|
|
@@ -550,7 +562,13 @@ async function tryDecryptFileToken({ metadata, homeDir }) {
|
|
|
550
562
|
key
|
|
551
563
|
);
|
|
552
564
|
return token || null;
|
|
553
|
-
} catch {
|
|
565
|
+
} catch (error) {
|
|
566
|
+
emitSessionWarning("FILE_CREDENTIAL_UNAVAILABLE", {
|
|
567
|
+
reason: error?.code === "EACCES" || error?.code === "EPERM"
|
|
568
|
+
? "key_unreadable"
|
|
569
|
+
: error?.code === "CREDENTIALS_KEY_INVALID" ? "key_invalid" : "decryption_failed",
|
|
570
|
+
source: "file-encrypted",
|
|
571
|
+
});
|
|
554
572
|
return null;
|
|
555
573
|
}
|
|
556
574
|
}
|
|
@@ -722,6 +740,7 @@ export async function readStoredSessionMetadata({ homeDir } = {}) {
|
|
|
722
740
|
* tokenPrefix?: string | null,
|
|
723
741
|
* tokenExpiresAt?: string | null,
|
|
724
742
|
* user?: Record<string, unknown>
|
|
743
|
+
* aidenid?: Record<string, unknown> | null
|
|
725
744
|
* }} [session]
|
|
726
745
|
* @param {{ homeDir?: string }} [options]
|
|
727
746
|
* @returns {Promise<{
|
|
@@ -754,6 +773,7 @@ export async function writeStoredSession(
|
|
|
754
773
|
tokenPrefix = null,
|
|
755
774
|
tokenExpiresAt = null,
|
|
756
775
|
user = {},
|
|
776
|
+
aidenid = null,
|
|
757
777
|
} = {},
|
|
758
778
|
{ homeDir } = {}
|
|
759
779
|
) {
|
|
@@ -778,6 +798,7 @@ export async function writeStoredSession(
|
|
|
778
798
|
tokenPrefix,
|
|
779
799
|
tokenExpiresAt,
|
|
780
800
|
user: normalizeUser(user),
|
|
801
|
+
aidenid,
|
|
781
802
|
createdAt: existingMetadata?.createdAt || updatedAt,
|
|
782
803
|
updatedAt,
|
|
783
804
|
});
|
package/src/commands/auth.js
CHANGED
|
@@ -133,6 +133,14 @@ export function registerAuthCommand(program) {
|
|
|
133
133
|
tokenLabel: options.tokenLabel,
|
|
134
134
|
tokenTtlDays,
|
|
135
135
|
cliVersion: CLI_VERSION,
|
|
136
|
+
onAuthorize: ({ authorizeUrl }) => {
|
|
137
|
+
// A spawned opener does not prove a browser exists on a headless host.
|
|
138
|
+
if (!authorizeUrl) return;
|
|
139
|
+
const safeUrl = String(authorizeUrl).replace(/[\u0000-\u001f\u007f-\u009f]/g, "").slice(0, 4096);
|
|
140
|
+
const line = `Open this URL to approve sign-in:\n${safeUrl}\n`;
|
|
141
|
+
if (shouldEmitJson(options, command)) process.stderr.write(line);
|
|
142
|
+
else console.log(line.trimEnd());
|
|
143
|
+
},
|
|
136
144
|
});
|
|
137
145
|
} catch (error) {
|
|
138
146
|
throw new Error(formatApiError(error));
|
|
@@ -159,10 +167,6 @@ export function registerAuthCommand(program) {
|
|
|
159
167
|
if (result.filePath) {
|
|
160
168
|
console.log(pc.gray(`Session metadata: ${result.filePath}`));
|
|
161
169
|
}
|
|
162
|
-
if (!result.browserOpened && result.authorizeUrl) {
|
|
163
|
-
console.log(pc.yellow("Open this URL to approve sign-in:"));
|
|
164
|
-
console.log(result.authorizeUrl);
|
|
165
|
-
}
|
|
166
170
|
});
|
|
167
171
|
|
|
168
172
|
auth
|
|
@@ -199,6 +203,13 @@ export function registerAuthCommand(program) {
|
|
|
199
203
|
|
|
200
204
|
console.log(pc.bold("Authentication status"));
|
|
201
205
|
console.log(pc.gray(`API: ${status.apiUrl}`));
|
|
206
|
+
if (status.validation === "storage_unavailable") {
|
|
207
|
+
console.log(pc.yellow("Stored sign-in metadata exists, but its credential cannot be read. Authentication is unknown."));
|
|
208
|
+
if (status.filePath) console.log(pc.gray(`Session metadata: ${status.filePath}`));
|
|
209
|
+
console.log(pc.gray("Check the current user/home, keyring availability, encryption key and file permissions."));
|
|
210
|
+
console.log(pc.gray("Credentials were not cleared. Repair storage instead of logging out or signing in again for this error."));
|
|
211
|
+
return;
|
|
212
|
+
}
|
|
202
213
|
if (!status.source) {
|
|
203
214
|
console.log(pc.yellow("No active session token found."));
|
|
204
215
|
printAuthHint();
|
|
@@ -254,6 +265,15 @@ export function registerAuthCommand(program) {
|
|
|
254
265
|
}`
|
|
255
266
|
)
|
|
256
267
|
);
|
|
268
|
+
if (status.validation === "unavailable") {
|
|
269
|
+
console.log(pc.yellow("Remote validation is temporarily unavailable; your existing credentials were not cleared."));
|
|
270
|
+
console.log(pc.gray("Retry auth status when the API is reachable. Do not sign in again solely for this validation error."));
|
|
271
|
+
return;
|
|
272
|
+
}
|
|
273
|
+
if (["CREDENTIAL_DESTINATION_REFUSED", "CREDENTIAL_PATH_REFUSED"].includes(status.remoteError.code)) {
|
|
274
|
+
console.log(pc.yellow("Credential destination was refused. Check the configured API; signing in again does not change this boundary."));
|
|
275
|
+
return;
|
|
276
|
+
}
|
|
257
277
|
} else {
|
|
258
278
|
console.log(pc.yellow("Remote validation was skipped (`--offline`)."));
|
|
259
279
|
}
|
package/src/commands/session.js
CHANGED
|
@@ -33,6 +33,7 @@ import {
|
|
|
33
33
|
unregisterAgent,
|
|
34
34
|
} from "../session/agent-registry.js";
|
|
35
35
|
import { inferSessionAgentIdentity } from "../session/agent-identity.js";
|
|
36
|
+
import { legacyJoinAdmissionRefusal, verifyLegacyAgentJoin } from "../session/agent-access.js";
|
|
36
37
|
import { startSenti, stopSenti } from "../session/daemon.js";
|
|
37
38
|
import {
|
|
38
39
|
getDaemonStatus,
|
|
@@ -3437,6 +3438,7 @@ export function registerSessionCommand(program) {
|
|
|
3437
3438
|
.option("--no-wait", "Request admission and return immediately with the approval URL")
|
|
3438
3439
|
.option("--wait-seconds <n>", "How long to wait for approval before returning pending", "900")
|
|
3439
3440
|
.option("--cancel-admission", "Cancel this workspace's pending admission request for the session")
|
|
3441
|
+
.option("--recover-admission", "Recover a live admission with the retained agent key; requires the unchanged --goal/scope/ttl")
|
|
3440
3442
|
.option("--json", "Emit machine-readable output")
|
|
3441
3443
|
.action(async (sessionId, options, command) => {
|
|
3442
3444
|
const normalizedSessionId = normalizeString(sessionId);
|
|
@@ -3455,6 +3457,27 @@ export function registerSessionCommand(program) {
|
|
|
3455
3457
|
// the local registration and every later implicit command name the same agent.
|
|
3456
3458
|
if (!explicitAgent) assertCanonicalAgentId(legacyName, "--name");
|
|
3457
3459
|
const requestedAgent = canonicalAgentId(explicitAgent || legacyName);
|
|
3460
|
+
if (options.recoverAdmission && !normalizeString(options.goal)) {
|
|
3461
|
+
throw new Error("--recover-admission requires --goal and the admission's unchanged scope/ttl.");
|
|
3462
|
+
}
|
|
3463
|
+
const rejectLegacyJoin = (verification, agentId) => {
|
|
3464
|
+
process.exitCode = 4;
|
|
3465
|
+
const payload = {
|
|
3466
|
+
command: "session join", joined: false, sessionId: normalizedSessionId,
|
|
3467
|
+
agentId, remoteAgentVerification: verification,
|
|
3468
|
+
};
|
|
3469
|
+
if (emitJson) console.log(JSON.stringify(payload, null, 2));
|
|
3470
|
+
else {
|
|
3471
|
+
const hint = verification.code.includes("UNAVAILABLE")
|
|
3472
|
+
? "Retry when the API/presence service is available; no new approval was requested."
|
|
3473
|
+
: verification.code.includes("CONTRACT_MISMATCH")
|
|
3474
|
+
? "Verify the CLI/server contract; no new authorization was created."
|
|
3475
|
+
: verification.code === "LEGACY_GRANT_ROLE_MISMATCH"
|
|
3476
|
+
? "Use the already-granted role, or request an explicit owner-approved change."
|
|
3477
|
+
: "Request scoped admission with --goal, or have the owner explicitly authorize its legacy grant.";
|
|
3478
|
+
console.log(pc.red(`Agent was not joined: ${verification.code}. ${hint}`));
|
|
3479
|
+
}
|
|
3480
|
+
};
|
|
3458
3481
|
if (options.cancelAdmission) {
|
|
3459
3482
|
const cancelled = await cancelAdmission(normalizedSessionId, {
|
|
3460
3483
|
agentId: requestedAgent,
|
|
@@ -3473,6 +3496,13 @@ export function registerSessionCommand(program) {
|
|
|
3473
3496
|
else console.log(`Admission request ${payload.admission.admissionId || ""} cancelled.`);
|
|
3474
3497
|
return;
|
|
3475
3498
|
}
|
|
3499
|
+
if (requestedAgent && !normalizeString(options.goal)) {
|
|
3500
|
+
const refusal = await legacyJoinAdmissionRefusal(normalizedSessionId, requestedAgent);
|
|
3501
|
+
if (refusal) {
|
|
3502
|
+
rejectLegacyJoin(refusal, requestedAgent);
|
|
3503
|
+
return;
|
|
3504
|
+
}
|
|
3505
|
+
}
|
|
3476
3506
|
// Membership first. Only a member may ask for an agent to be admitted, so an
|
|
3477
3507
|
// invitation is accepted BEFORE the admission request, never after.
|
|
3478
3508
|
let invitationAccept = null;
|
|
@@ -3491,13 +3521,14 @@ export function registerSessionCommand(program) {
|
|
|
3491
3521
|
: null;
|
|
3492
3522
|
}
|
|
3493
3523
|
let admission = null;
|
|
3524
|
+
let admissionJoinOptions = null;
|
|
3494
3525
|
if (normalizeString(options.goal)) {
|
|
3495
3526
|
const admissionAgent = requestedAgent;
|
|
3496
3527
|
if (!admissionAgent) {
|
|
3497
3528
|
throw new Error("--goal requires --agent <id>: admission is requested for one named agent.");
|
|
3498
3529
|
}
|
|
3499
3530
|
const waitSeconds = Number.parseInt(String(options.waitSeconds ?? "900"), 10);
|
|
3500
|
-
|
|
3531
|
+
admissionJoinOptions = {
|
|
3501
3532
|
agentId: admissionAgent,
|
|
3502
3533
|
displayName: legacyName && explicitAgent ? legacyName : "",
|
|
3503
3534
|
model: options.model,
|
|
@@ -3508,6 +3539,7 @@ export function registerSessionCommand(program) {
|
|
|
3508
3539
|
actions: parseAdmissionScope(options.scope),
|
|
3509
3540
|
ttlSeconds: parseAdmissionTtlSeconds(options.ttl),
|
|
3510
3541
|
wait: options.wait !== false,
|
|
3542
|
+
recoverActive: Boolean(options.recoverAdmission),
|
|
3511
3543
|
waitTimeoutMs: (Number.isFinite(waitSeconds) && waitSeconds >= 0 ? waitSeconds : 900) * 1000,
|
|
3512
3544
|
targetPath,
|
|
3513
3545
|
onPending: ({ approveUrl, phase }) => {
|
|
@@ -3523,7 +3555,8 @@ export function registerSessionCommand(program) {
|
|
|
3523
3555
|
else if (phase === "identity") console.log(pc.yellow(line));
|
|
3524
3556
|
else console.log(line);
|
|
3525
3557
|
},
|
|
3526
|
-
}
|
|
3558
|
+
};
|
|
3559
|
+
admission = await runAdmissionJoin(normalizedSessionId, admissionJoinOptions);
|
|
3527
3560
|
if (admission.status !== "active") {
|
|
3528
3561
|
// Only an ACTIVE admission joins. Pending, denied, cancelled or expired never
|
|
3529
3562
|
// registers the agent or materializes the room for it.
|
|
@@ -3550,6 +3583,34 @@ export function registerSessionCommand(program) {
|
|
|
3550
3583
|
}
|
|
3551
3584
|
}
|
|
3552
3585
|
|
|
3586
|
+
const admittedAgentId = admission ? requestedAgent : "";
|
|
3587
|
+
const acceptedOnboarding = normalizeSessionOnboarding(
|
|
3588
|
+
invitationAcceptResult?.onboarding,
|
|
3589
|
+
invitationAcceptResult?.claimedSeat,
|
|
3590
|
+
);
|
|
3591
|
+
const acceptedAgentId = normalizeString(acceptedOnboarding?.agentId);
|
|
3592
|
+
if (!admission && !explicitAgent && acceptedAgentId) {
|
|
3593
|
+
assertCanonicalAgentId(acceptedAgentId, "the invitation's agent id");
|
|
3594
|
+
}
|
|
3595
|
+
const agentSeed = admission ? admittedAgentId : explicitAgent || acceptedAgentId || legacyName;
|
|
3596
|
+
const resolvedAgentId = await defaultAgentId(agentSeed, targetPath);
|
|
3597
|
+
if (admission && resolvedAgentId !== admittedAgentId) {
|
|
3598
|
+
throw new Error(`join would register "${resolvedAgentId}", not the admitted agent "${admittedAgentId}".`);
|
|
3599
|
+
}
|
|
3600
|
+
const roleWasExplicit = optionWasSetByCli(command, "role");
|
|
3601
|
+
const role =
|
|
3602
|
+
normalizeString(roleWasExplicit ? options.role : acceptedOnboarding?.role || options.role) ||
|
|
3603
|
+
"coder";
|
|
3604
|
+
const model = normalizeString(options.model) || "cli";
|
|
3605
|
+
const namedLegacyAgent = !admission && Boolean(agentSeed || normalizeString(process.env.SENTINELAYER_AGENT_ID));
|
|
3606
|
+
if (namedLegacyAgent) {
|
|
3607
|
+
const refusal = await legacyJoinAdmissionRefusal(normalizedSessionId, resolvedAgentId);
|
|
3608
|
+
if (refusal) {
|
|
3609
|
+
rejectLegacyJoin(refusal, resolvedAgentId);
|
|
3610
|
+
return;
|
|
3611
|
+
}
|
|
3612
|
+
}
|
|
3613
|
+
|
|
3553
3614
|
// PR #483 contract: verify the session exists and the caller has access
|
|
3554
3615
|
// BEFORE materializing local cache state. Without this we'd silently
|
|
3555
3616
|
// create a phantom local NDJSON for a session that's archived or owned
|
|
@@ -3564,21 +3625,36 @@ export function registerSessionCommand(program) {
|
|
|
3564
3625
|
// grant's receipt, and never with the human token. A cached "active" is not
|
|
3565
3626
|
// reported until this succeeds (a revoked grant is not joined), and nothing
|
|
3566
3627
|
// about the room is fetched on the human's authority.
|
|
3567
|
-
const admittedAgentId = admission ? requestedAgent : "";
|
|
3568
3628
|
let admissionReceipt = null;
|
|
3569
3629
|
if (admission) {
|
|
3570
|
-
|
|
3571
|
-
|
|
3630
|
+
const verifyAdmissionReceipt = async () => {
|
|
3631
|
+
const receipt = await withAgentAdmission(normalizedSessionId, admittedAgentId, () =>
|
|
3572
3632
|
fetchOwnAdmissionReceipt(normalizedSessionId, { targetPath }),
|
|
3573
3633
|
);
|
|
3574
3634
|
// The grant must be for EXACTLY the identity this join registers, byte for
|
|
3575
3635
|
// byte: a receipt for any other spelling is a different agent's authority.
|
|
3576
|
-
const receiptAgent = String(
|
|
3636
|
+
const receiptAgent = String(receipt?.agentId ?? "");
|
|
3577
3637
|
if (receiptAgent !== admittedAgentId) {
|
|
3578
3638
|
throw new Error(
|
|
3579
3639
|
`the admission is for agent "${receiptAgent || "(none)"}", not "${admittedAgentId}"`,
|
|
3580
3640
|
);
|
|
3581
3641
|
}
|
|
3642
|
+
return receipt;
|
|
3643
|
+
};
|
|
3644
|
+
try {
|
|
3645
|
+
try {
|
|
3646
|
+
admissionReceipt = await verifyAdmissionReceipt();
|
|
3647
|
+
} catch (error) {
|
|
3648
|
+
// One key-possession recovery for a stale bearer, never a different
|
|
3649
|
+
// identity, denied scope, provider/network failure or generic 403.
|
|
3650
|
+
if (options.recoverAdmission || !(error instanceof SentinelayerApiError) ||
|
|
3651
|
+
![401, 403].includes(error.status) || error.code !== "INVALID_TOKEN") throw error;
|
|
3652
|
+
admission = await runAdmissionJoin(normalizedSessionId, {
|
|
3653
|
+
...admissionJoinOptions, recoverActive: true,
|
|
3654
|
+
});
|
|
3655
|
+
if (admission.status !== "active") throw new Error("The admission is not active; recovery did not join the agent.");
|
|
3656
|
+
admissionReceipt = await verifyAdmissionReceipt();
|
|
3657
|
+
}
|
|
3582
3658
|
} catch (error) {
|
|
3583
3659
|
process.exitCode = 4;
|
|
3584
3660
|
const payload = {
|
|
@@ -3594,7 +3670,7 @@ export function registerSessionCommand(program) {
|
|
|
3594
3670
|
},
|
|
3595
3671
|
};
|
|
3596
3672
|
if (emitJson) console.log(JSON.stringify(payload, null, 2));
|
|
3597
|
-
else console.log(pc.red(`The API no longer accepts this agent's admission; it was not joined.`));
|
|
3673
|
+
else console.log(pc.red(`The API no longer accepts this agent's admission; it was not joined. For a live grant with a retained key, retry the unchanged request with --recover-admission.`));
|
|
3598
3674
|
return;
|
|
3599
3675
|
}
|
|
3600
3676
|
admission = { ...admission, verified: true, receipt: admissionReceipt };
|
|
@@ -3622,6 +3698,16 @@ export function registerSessionCommand(program) {
|
|
|
3622
3698
|
}
|
|
3623
3699
|
|
|
3624
3700
|
const remoteSession = verification.session || {};
|
|
3701
|
+
let legacyAgentVerification = null;
|
|
3702
|
+
if (namedLegacyAgent) {
|
|
3703
|
+
legacyAgentVerification = await verifyLegacyAgentJoin(normalizedSessionId, {
|
|
3704
|
+
agentId: resolvedAgentId, role, model, targetPath, remoteSession,
|
|
3705
|
+
});
|
|
3706
|
+
if (!legacyAgentVerification.ok) {
|
|
3707
|
+
rejectLegacyJoin(legacyAgentVerification, resolvedAgentId);
|
|
3708
|
+
return;
|
|
3709
|
+
}
|
|
3710
|
+
}
|
|
3625
3711
|
const localSession = await ensureLocalSessionForRemoteCommand(normalizedSessionId, {
|
|
3626
3712
|
targetPath,
|
|
3627
3713
|
title: normalizeString(remoteSession.title),
|
|
@@ -3637,29 +3723,6 @@ export function registerSessionCommand(program) {
|
|
|
3637
3723
|
)
|
|
3638
3724
|
: { skipped: true, reason: "the grant does not include session.read" };
|
|
3639
3725
|
|
|
3640
|
-
const acceptedOnboarding = normalizeSessionOnboarding(
|
|
3641
|
-
invitationAcceptResult?.onboarding,
|
|
3642
|
-
invitationAcceptResult?.claimedSeat,
|
|
3643
|
-
);
|
|
3644
|
-
const acceptedAgentId = normalizeString(acceptedOnboarding?.agentId);
|
|
3645
|
-
// An admitted join registers the admitted identity and nothing else. Otherwise an
|
|
3646
|
-
// invitation's onboarding agent id is the identity only when none was given, and
|
|
3647
|
-
// it is held to the same canonical rule before anything is registered locally.
|
|
3648
|
-
if (!admission && !explicitAgent && acceptedAgentId) {
|
|
3649
|
-
assertCanonicalAgentId(acceptedAgentId, "the invitation's agent id");
|
|
3650
|
-
}
|
|
3651
|
-
const agentSeed = admission ? admittedAgentId : explicitAgent || acceptedAgentId || legacyName;
|
|
3652
|
-
const resolvedAgentId = await defaultAgentId(agentSeed, targetPath);
|
|
3653
|
-
if (admission && resolvedAgentId !== admittedAgentId) {
|
|
3654
|
-
throw new Error(`join would register "${resolvedAgentId}", not the admitted agent "${admittedAgentId}".`);
|
|
3655
|
-
}
|
|
3656
|
-
const roleWasExplicit = optionWasSetByCli(command, "role");
|
|
3657
|
-
const role =
|
|
3658
|
-
normalizeString(roleWasExplicit ? options.role : acceptedOnboarding?.role || options.role) ||
|
|
3659
|
-
"coder";
|
|
3660
|
-
const model = normalizeString(options.model) || "cli";
|
|
3661
|
-
const hasConcreteAgentIdentity = Boolean(explicitAgent || acceptedAgentId);
|
|
3662
|
-
|
|
3663
3726
|
// Registration updates the local participant projection and returns
|
|
3664
3727
|
// onboarding directly to this process. It never writes join, identity,
|
|
3665
3728
|
// or coaching rows into the transcript.
|
|
@@ -3671,7 +3734,7 @@ export function registerSessionCommand(program) {
|
|
|
3671
3734
|
clientKind: "cli",
|
|
3672
3735
|
role,
|
|
3673
3736
|
trackProcessExit: false,
|
|
3674
|
-
|
|
3737
|
+
preserveIdentity: Boolean(admission || namedLegacyAgent),
|
|
3675
3738
|
});
|
|
3676
3739
|
const agentJoinRelayed = false;
|
|
3677
3740
|
const localBriefingPayload =
|
|
@@ -3714,6 +3777,7 @@ export function registerSessionCommand(program) {
|
|
|
3714
3777
|
materializedLocalSession: localSession.materialized,
|
|
3715
3778
|
refreshedLocalSession: Boolean(localSession.refreshed),
|
|
3716
3779
|
verificationSource: verification.source,
|
|
3780
|
+
remoteAgentVerification: legacyAgentVerification,
|
|
3717
3781
|
joinHydration,
|
|
3718
3782
|
eventCount: Number.isFinite(eventCount) ? eventCount : 0,
|
|
3719
3783
|
agentCount: Number.isFinite(agentCount) ? agentCount : 0,
|
package/src/mcp/bridge-inputs.js
CHANGED
|
@@ -202,6 +202,9 @@ export const BRIDGE_DENIED_INPUTS = Object.freeze({
|
|
|
202
202
|
"ring-owner": {
|
|
203
203
|
gatewayUrl: "the gateway that receives the user's token",
|
|
204
204
|
},
|
|
205
|
+
"session.join": {
|
|
206
|
+
recoverAdmission: "restores an agent credential using the delegator's retained private key; CLI control only",
|
|
207
|
+
},
|
|
205
208
|
"swarm.create": {
|
|
206
209
|
target: "the site that this plan tests",
|
|
207
210
|
},
|
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()) {
|