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.
@@ -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
- admission = await runAdmissionJoin(normalizedSessionId, {
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
- try {
3572
- admissionReceipt = await withAgentAdmission(normalizedSessionId, admittedAgentId, () =>
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(admissionReceipt?.agentId ?? "");
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
- awaitRemoteSync: hasConcreteAgentIdentity,
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
- const stopMatchesResult = await stopMatchingListenerProcesses(normalizedSessionId, agentId);
5519
- if (!stopMatchesResult.ok) {
5520
- // The listener being replaced is known (a verified pid record or a scan moments
5521
- // ago), so stopping it and starting this one never adds a listener. Only the
5522
- // cleanup of extra copies is skipped, and that is said rather than assumed.
5523
- console.error(
5524
- pc.yellow(
5525
- `The local process check did not complete (${stopMatchesResult.reason}); replacing listener pid ${existingListener.pid} only. Other copies of this listener, if any, were not checked.`,
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 scannedStops = stopMatchesResult.ok ? stopMatchesResult.results : [];
5530
- const stoppedPids = new Set(
5531
- scannedStops
5532
- .filter((result) => result.stopped)
5533
- .map((result) => Number(result.pid)),
5534
- );
5535
- let explicitStopResult = null;
5536
- if (existingListener.pid && !stoppedPids.has(Number(existingListener.pid))) {
5537
- explicitStopResult = await requestListenerProcessStop(existingListener.pid);
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(
@@ -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
  },