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/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,
|
|
@@ -78,7 +79,6 @@ import {
|
|
|
78
79
|
getListenerProcessStatus,
|
|
79
80
|
LISTENER_PROCESS_SCAN_RETRY_TIMEOUT_MS,
|
|
80
81
|
removeListenerPidRecord,
|
|
81
|
-
requestListenerProcessStop,
|
|
82
82
|
summarizeLocalListenerProcesses,
|
|
83
83
|
stopMatchingListenerProcesses,
|
|
84
84
|
writeListenerPidRecord,
|
|
@@ -3438,6 +3438,7 @@ export function registerSessionCommand(program) {
|
|
|
3438
3438
|
.option("--no-wait", "Request admission and return immediately with the approval URL")
|
|
3439
3439
|
.option("--wait-seconds <n>", "How long to wait for approval before returning pending", "900")
|
|
3440
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")
|
|
3441
3442
|
.option("--json", "Emit machine-readable output")
|
|
3442
3443
|
.action(async (sessionId, options, command) => {
|
|
3443
3444
|
const normalizedSessionId = normalizeString(sessionId);
|
|
@@ -3456,6 +3457,27 @@ export function registerSessionCommand(program) {
|
|
|
3456
3457
|
// the local registration and every later implicit command name the same agent.
|
|
3457
3458
|
if (!explicitAgent) assertCanonicalAgentId(legacyName, "--name");
|
|
3458
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
|
+
};
|
|
3459
3481
|
if (options.cancelAdmission) {
|
|
3460
3482
|
const cancelled = await cancelAdmission(normalizedSessionId, {
|
|
3461
3483
|
agentId: requestedAgent,
|
|
@@ -3474,6 +3496,13 @@ export function registerSessionCommand(program) {
|
|
|
3474
3496
|
else console.log(`Admission request ${payload.admission.admissionId || ""} cancelled.`);
|
|
3475
3497
|
return;
|
|
3476
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
|
+
}
|
|
3477
3506
|
// Membership first. Only a member may ask for an agent to be admitted, so an
|
|
3478
3507
|
// invitation is accepted BEFORE the admission request, never after.
|
|
3479
3508
|
let invitationAccept = null;
|
|
@@ -3492,13 +3521,14 @@ export function registerSessionCommand(program) {
|
|
|
3492
3521
|
: null;
|
|
3493
3522
|
}
|
|
3494
3523
|
let admission = null;
|
|
3524
|
+
let admissionJoinOptions = null;
|
|
3495
3525
|
if (normalizeString(options.goal)) {
|
|
3496
3526
|
const admissionAgent = requestedAgent;
|
|
3497
3527
|
if (!admissionAgent) {
|
|
3498
3528
|
throw new Error("--goal requires --agent <id>: admission is requested for one named agent.");
|
|
3499
3529
|
}
|
|
3500
3530
|
const waitSeconds = Number.parseInt(String(options.waitSeconds ?? "900"), 10);
|
|
3501
|
-
|
|
3531
|
+
admissionJoinOptions = {
|
|
3502
3532
|
agentId: admissionAgent,
|
|
3503
3533
|
displayName: legacyName && explicitAgent ? legacyName : "",
|
|
3504
3534
|
model: options.model,
|
|
@@ -3509,6 +3539,7 @@ export function registerSessionCommand(program) {
|
|
|
3509
3539
|
actions: parseAdmissionScope(options.scope),
|
|
3510
3540
|
ttlSeconds: parseAdmissionTtlSeconds(options.ttl),
|
|
3511
3541
|
wait: options.wait !== false,
|
|
3542
|
+
recoverActive: Boolean(options.recoverAdmission),
|
|
3512
3543
|
waitTimeoutMs: (Number.isFinite(waitSeconds) && waitSeconds >= 0 ? waitSeconds : 900) * 1000,
|
|
3513
3544
|
targetPath,
|
|
3514
3545
|
onPending: ({ approveUrl, phase }) => {
|
|
@@ -3524,7 +3555,8 @@ export function registerSessionCommand(program) {
|
|
|
3524
3555
|
else if (phase === "identity") console.log(pc.yellow(line));
|
|
3525
3556
|
else console.log(line);
|
|
3526
3557
|
},
|
|
3527
|
-
}
|
|
3558
|
+
};
|
|
3559
|
+
admission = await runAdmissionJoin(normalizedSessionId, admissionJoinOptions);
|
|
3528
3560
|
if (admission.status !== "active") {
|
|
3529
3561
|
// Only an ACTIVE admission joins. Pending, denied, cancelled or expired never
|
|
3530
3562
|
// registers the agent or materializes the room for it.
|
|
@@ -3551,6 +3583,34 @@ export function registerSessionCommand(program) {
|
|
|
3551
3583
|
}
|
|
3552
3584
|
}
|
|
3553
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
|
+
|
|
3554
3614
|
// PR #483 contract: verify the session exists and the caller has access
|
|
3555
3615
|
// BEFORE materializing local cache state. Without this we'd silently
|
|
3556
3616
|
// create a phantom local NDJSON for a session that's archived or owned
|
|
@@ -3565,21 +3625,36 @@ export function registerSessionCommand(program) {
|
|
|
3565
3625
|
// grant's receipt, and never with the human token. A cached "active" is not
|
|
3566
3626
|
// reported until this succeeds (a revoked grant is not joined), and nothing
|
|
3567
3627
|
// about the room is fetched on the human's authority.
|
|
3568
|
-
const admittedAgentId = admission ? requestedAgent : "";
|
|
3569
3628
|
let admissionReceipt = null;
|
|
3570
3629
|
if (admission) {
|
|
3571
|
-
|
|
3572
|
-
|
|
3630
|
+
const verifyAdmissionReceipt = async () => {
|
|
3631
|
+
const receipt = await withAgentAdmission(normalizedSessionId, admittedAgentId, () =>
|
|
3573
3632
|
fetchOwnAdmissionReceipt(normalizedSessionId, { targetPath }),
|
|
3574
3633
|
);
|
|
3575
3634
|
// The grant must be for EXACTLY the identity this join registers, byte for
|
|
3576
3635
|
// byte: a receipt for any other spelling is a different agent's authority.
|
|
3577
|
-
const receiptAgent = String(
|
|
3636
|
+
const receiptAgent = String(receipt?.agentId ?? "");
|
|
3578
3637
|
if (receiptAgent !== admittedAgentId) {
|
|
3579
3638
|
throw new Error(
|
|
3580
3639
|
`the admission is for agent "${receiptAgent || "(none)"}", not "${admittedAgentId}"`,
|
|
3581
3640
|
);
|
|
3582
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
|
+
}
|
|
3583
3658
|
} catch (error) {
|
|
3584
3659
|
process.exitCode = 4;
|
|
3585
3660
|
const payload = {
|
|
@@ -3595,7 +3670,7 @@ export function registerSessionCommand(program) {
|
|
|
3595
3670
|
},
|
|
3596
3671
|
};
|
|
3597
3672
|
if (emitJson) console.log(JSON.stringify(payload, null, 2));
|
|
3598
|
-
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.`));
|
|
3599
3674
|
return;
|
|
3600
3675
|
}
|
|
3601
3676
|
admission = { ...admission, verified: true, receipt: admissionReceipt };
|
|
@@ -3623,6 +3698,16 @@ export function registerSessionCommand(program) {
|
|
|
3623
3698
|
}
|
|
3624
3699
|
|
|
3625
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
|
+
}
|
|
3626
3711
|
const localSession = await ensureLocalSessionForRemoteCommand(normalizedSessionId, {
|
|
3627
3712
|
targetPath,
|
|
3628
3713
|
title: normalizeString(remoteSession.title),
|
|
@@ -3638,29 +3723,6 @@ export function registerSessionCommand(program) {
|
|
|
3638
3723
|
)
|
|
3639
3724
|
: { skipped: true, reason: "the grant does not include session.read" };
|
|
3640
3725
|
|
|
3641
|
-
const acceptedOnboarding = normalizeSessionOnboarding(
|
|
3642
|
-
invitationAcceptResult?.onboarding,
|
|
3643
|
-
invitationAcceptResult?.claimedSeat,
|
|
3644
|
-
);
|
|
3645
|
-
const acceptedAgentId = normalizeString(acceptedOnboarding?.agentId);
|
|
3646
|
-
// An admitted join registers the admitted identity and nothing else. Otherwise an
|
|
3647
|
-
// invitation's onboarding agent id is the identity only when none was given, and
|
|
3648
|
-
// it is held to the same canonical rule before anything is registered locally.
|
|
3649
|
-
if (!admission && !explicitAgent && acceptedAgentId) {
|
|
3650
|
-
assertCanonicalAgentId(acceptedAgentId, "the invitation's agent id");
|
|
3651
|
-
}
|
|
3652
|
-
const agentSeed = admission ? admittedAgentId : explicitAgent || acceptedAgentId || legacyName;
|
|
3653
|
-
const resolvedAgentId = await defaultAgentId(agentSeed, targetPath);
|
|
3654
|
-
if (admission && resolvedAgentId !== admittedAgentId) {
|
|
3655
|
-
throw new Error(`join would register "${resolvedAgentId}", not the admitted agent "${admittedAgentId}".`);
|
|
3656
|
-
}
|
|
3657
|
-
const roleWasExplicit = optionWasSetByCli(command, "role");
|
|
3658
|
-
const role =
|
|
3659
|
-
normalizeString(roleWasExplicit ? options.role : acceptedOnboarding?.role || options.role) ||
|
|
3660
|
-
"coder";
|
|
3661
|
-
const model = normalizeString(options.model) || "cli";
|
|
3662
|
-
const hasConcreteAgentIdentity = Boolean(explicitAgent || acceptedAgentId);
|
|
3663
|
-
|
|
3664
3726
|
// Registration updates the local participant projection and returns
|
|
3665
3727
|
// onboarding directly to this process. It never writes join, identity,
|
|
3666
3728
|
// or coaching rows into the transcript.
|
|
@@ -3672,7 +3734,7 @@ export function registerSessionCommand(program) {
|
|
|
3672
3734
|
clientKind: "cli",
|
|
3673
3735
|
role,
|
|
3674
3736
|
trackProcessExit: false,
|
|
3675
|
-
|
|
3737
|
+
preserveIdentity: Boolean(admission || namedLegacyAgent),
|
|
3676
3738
|
});
|
|
3677
3739
|
const agentJoinRelayed = false;
|
|
3678
3740
|
const localBriefingPayload =
|
|
@@ -3715,6 +3777,7 @@ export function registerSessionCommand(program) {
|
|
|
3715
3777
|
materializedLocalSession: localSession.materialized,
|
|
3716
3778
|
refreshedLocalSession: Boolean(localSession.refreshed),
|
|
3717
3779
|
verificationSource: verification.source,
|
|
3780
|
+
remoteAgentVerification: legacyAgentVerification,
|
|
3718
3781
|
joinHydration,
|
|
3719
3782
|
eventCount: Number.isFinite(eventCount) ? eventCount : 0,
|
|
3720
3783
|
agentCount: Number.isFinite(agentCount) ? agentCount : 0,
|
|
@@ -5515,33 +5578,30 @@ export function registerSessionCommand(program) {
|
|
|
5515
5578
|
);
|
|
5516
5579
|
}
|
|
5517
5580
|
if (existingListener.running && options.force) {
|
|
5518
|
-
|
|
5519
|
-
|
|
5520
|
-
|
|
5521
|
-
|
|
5522
|
-
|
|
5523
|
-
|
|
5524
|
-
|
|
5525
|
-
`
|
|
5526
|
-
),
|
|
5581
|
+
// --force never stops a process this command runs under, and never on a guess:
|
|
5582
|
+
// it refuses, and stops and starts nothing, unless the takeover scan confirms
|
|
5583
|
+
// what it stops.
|
|
5584
|
+
const refuseTakeover = ({ pid, reason }) =>
|
|
5585
|
+
new Error(
|
|
5586
|
+
reason === "ancestor_listener"
|
|
5587
|
+
? `A session listener is already running for session ${normalizedSessionId} as ${agentId} (pid ${pid}), and this command runs under it, so --force will not stop it. No listener was stopped or started. Stop that listener first, or use --allow-duplicate if you intentionally need more than one.`
|
|
5588
|
+
: `Could not confirm that pid ${pid} is a session listener --force can replace for session ${normalizedSessionId} as ${agentId} (${reason}). No listener was stopped or started. Run the command again, or use --allow-duplicate to start without this check (it can then run alongside an existing listener).`,
|
|
5527
5589
|
);
|
|
5590
|
+
if (existingListener.ancestor) {
|
|
5591
|
+
throw refuseTakeover({ pid: existingListener.pid, reason: "ancestor_listener" });
|
|
5528
5592
|
}
|
|
5529
|
-
const
|
|
5530
|
-
|
|
5531
|
-
|
|
5532
|
-
|
|
5533
|
-
|
|
5534
|
-
|
|
5535
|
-
|
|
5536
|
-
|
|
5537
|
-
|
|
5593
|
+
const stopMatchesResult = await stopMatchingListenerProcesses(normalizedSessionId, agentId, {
|
|
5594
|
+
listener: existingListener,
|
|
5595
|
+
});
|
|
5596
|
+
if (!stopMatchesResult.ok) {
|
|
5597
|
+
// What the status check found may no longer hold, and an unread table cannot
|
|
5598
|
+
// show which processes this command runs under, so nothing is stopped.
|
|
5599
|
+
throw refuseTakeover({ pid: existingListener.pid, reason: stopMatchesResult.reason });
|
|
5600
|
+
}
|
|
5601
|
+
if (stopMatchesResult.refused) {
|
|
5602
|
+
throw refuseTakeover(stopMatchesResult.refused);
|
|
5538
5603
|
}
|
|
5539
|
-
const failedStops =
|
|
5540
|
-
...scannedStops.filter((result) => !result.stopped),
|
|
5541
|
-
...(explicitStopResult && !explicitStopResult.stopped
|
|
5542
|
-
? [{ pid: existingListener.pid, ...explicitStopResult }]
|
|
5543
|
-
: []),
|
|
5544
|
-
];
|
|
5604
|
+
const failedStops = stopMatchesResult.results.filter((result) => !result.stopped);
|
|
5545
5605
|
if (failedStops.length > 0) {
|
|
5546
5606
|
const failed = failedStops[0];
|
|
5547
5607
|
throw new Error(
|
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
|
},
|