sentinelayer-cli 0.32.5 → 0.34.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -46,6 +46,27 @@ 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
+ ## MCP Live Entry Points And Incident Response
50
+
51
+ Live MCP entry points:
52
+
53
+ - Local stdio server: `sl mcp server run --path .`
54
+ - Hosted token mint: `sl mcp token mint --scope "sessions:read" --ttl-seconds 300 --json`
55
+ - Hosted resource smoke: `POST https://api.sentinelayer.com/mcp` with JSON-RPC `tools/list`
56
+ - CLI bridge registry: `sl mcp registry init-cli`; credential-bearing commands are blocked from bridge execution
57
+
58
+ First-debug path:
59
+
60
+ 1. Run `sl auth status`.
61
+ 2. For local MCP, run `sl mcp server validate --file .sentinelayer/mcp/server.<id>.json`.
62
+ 3. For hosted MCP, mint a 300-second token and smoke `/mcp` with `tools/list`.
63
+ 4. If a bearer or bridge output is exposed, set `SENTINELAYER_MCP_CLI_BRIDGE_DISABLED=1`, restart the MCP host, revoke the requesting CLI auth session with `sl auth revoke`, and follow [docs/mcp.md](docs/mcp.md#bearer-exposure-response).
64
+
65
+ Architecture map: local stdio runtime and incident response are in
66
+ [docs/mcp.md](docs/mcp.md); hosted OAuth/session-seat design is in
67
+ [docs/mcp-hosted-connector.md](docs/mcp-hosted-connector.md); session
68
+ coordination behavior is in [docs/sessions.md](docs/sessions.md).
69
+
49
70
  ## 60-second flow
50
71
 
51
72
  1. Trigger:
@@ -662,6 +683,7 @@ The CLI now includes deterministic MCP registry commands:
662
683
  - `sl mcp registry init-cli`
663
684
  - `sl mcp registry validate --file <path>`
664
685
  - `sl mcp registry validate-aidenid-adapter --file <path> [--registry-file <path>]`
686
+ - `sl mcp token mint --scope "sessions:read sessions:usage:read" --ttl-seconds 300 --json`
665
687
  - `sl mcp server init --id <server-id> --registry-file <path>`
666
688
  - `sl mcp server validate --file <path>`
667
689
  - `sl mcp server run --path .`
@@ -669,9 +691,70 @@ The CLI now includes deterministic MCP registry commands:
669
691
 
670
692
  Use `init-aidenid` to scaffold an Anthropic-compatible tool schema wrapper for AIdenID provisioning APIs, then customize transport/auth before runtime wiring.
671
693
  Use `init-aidenid-adapter` to scaffold a deterministic AIdenID provisioning API contract (tool binding -> HTTP path/method -> response field mapping) and cross-check it against the registry with `validate-aidenid-adapter`.
694
+ Use `token mint` to request a short-lived hosted MCP bearer token from the Sentinelayer API. Text output hides the bearer value; `--json` is the explicit operator path that includes `accessToken`. The command is blocked from the generated CLI MCP bridge.
672
695
  Use `init-session` plus `server run` for the local stdio Senti MCP server. It exposes session inbox, durable posts, actions/replies/reactions, file locks, and attention requests to local MCP clients. Hosted Claude-web/ChatGPT connectors need a separate HTTPS/OAuth service and are not shipped by the local stdio server. See [docs/mcp.md](docs/mcp.md).
673
696
  Use `init-cli` to generate a bridge registry for every `sl` leaf command from Commander metadata. This exposes the full CLI surface as `sl.<command.path>` tool schemas, but execution still requires a bridge-capable MCP host with OAuth/session-seat policy and human approval enforcement. The hosted connector target contract is documented in [docs/mcp-hosted-connector.md](docs/mcp-hosted-connector.md).
674
697
 
698
+ MCP operator verification:
699
+
700
+ 1. Local stdio demo: run `sl mcp registry init-session`, then
701
+ `sl mcp server init --id sentinelayer-session --registry-file .sentinelayer/mcp/tool-registry.session-tools.json`,
702
+ then configure the MCP host to execute `sl mcp server run --path .`.
703
+ 2. Hosted token smoke: run
704
+ `sl mcp token mint --scope "sessions:read sessions:usage:read" --ttl-seconds 300 --json`,
705
+ place `accessToken` in a local secret variable, and call hosted `/mcp`
706
+ `tools/list` as shown in [docs/mcp.md](docs/mcp.md).
707
+ 3. Kill switch: set `SENTINELAYER_MCP_CLI_BRIDGE_DISABLED=1` and restart the
708
+ MCP host if a CLI bridge or bearer credential is exposed.
709
+
710
+ Architecture references: local stdio runtime and incident response live in
711
+ [docs/mcp.md](docs/mcp.md); hosted OAuth/session-seat requirements live in
712
+ [docs/mcp-hosted-connector.md](docs/mcp-hosted-connector.md); Senti room
713
+ coordination behavior lives in [docs/sessions.md](docs/sessions.md).
714
+
715
+ Worked MCP examples:
716
+
717
+ ```bash
718
+ sl mcp list --json
719
+ sl mcp registry init-session --force
720
+ sl mcp server init --id sentinelayer-session --registry-file .sentinelayer/mcp/tool-registry.session-tools.json
721
+ sl mcp server run --path .
722
+ sl mcp registry init-cli --json
723
+ ```
724
+
725
+ ### MCP Live Entry Point Map
726
+
727
+ - Local stdio server: `sl mcp server run --path .`, configured from
728
+ `.sentinelayer/mcp/server.<id>.json`.
729
+ - Hosted token mint: `sl mcp token mint --scope "sessions:read" --ttl-seconds 300 --json`,
730
+ backed by `POST /api/v1/auth/mcp-token`.
731
+ - Hosted resource smoke: `POST https://api.sentinelayer.com/mcp` with
732
+ `Authorization: Bearer $SENTINELAYER_MCP_TOKEN` and JSON-RPC `tools/list`.
733
+ - Generated CLI bridge registry: `sl mcp registry init-cli`; secret-bearing
734
+ commands such as `sl mcp token mint` are blocked from bridge execution.
735
+
736
+ Architecture decisions:
737
+
738
+ - Local stdio and hosted HTTPS MCP are intentionally separate. Browser-hosted
739
+ clients cannot spawn the local process, so hosted execution must enforce
740
+ OAuth, session-seat binding, and server-side authorization.
741
+ - Hosted bearer minting is API-owned. The CLI requests a token with existing
742
+ auth and never signs, verifies, or derives token claims locally.
743
+ - The generated CLI bridge is fail-closed for credential-bearing commands; use
744
+ direct operator execution for token minting and the documented kill switch for
745
+ containment.
746
+
747
+ ### MCP Incident Response And Kill Switch
748
+
749
+ If a hosted MCP bearer or bridge command output is exposed:
750
+
751
+ 1. Set `SENTINELAYER_MCP_CLI_BRIDGE_DISABLED=1`.
752
+ 2. Restart the MCP host process so the generated CLI bridge cannot execute.
753
+ 3. Run `sentinelayer-cli auth revoke` or revoke the requesting session from the
754
+ dashboard, then re-authenticate with `sentinelayer-cli auth login`.
755
+ 4. Remove the exposed value from local logs/transcripts where possible.
756
+ 5. Verify `/mcp` rejects the old bearer after expiry or revocation.
757
+
675
758
  ## Plugin governance foundation (Phase 5.2 slice)
676
759
 
677
760
  The CLI now includes deterministic plugin/template/policy pack governance commands:
@@ -747,7 +830,7 @@ gh secret set SENTINELAYER_TOKEN --repo <owner/repo>
747
830
  gh secret list --repo <owner/repo>
748
831
  ```
749
832
 
750
- 3. For manual setup details: `https://sentinelayer.com/docs/getting-started/install-workflow`
833
+ 3. For manual setup details: `https://github.com/mrrCarter/create-sentinelayer#manual-fallback-if-auto-injection-is-skipped`
751
834
 
752
835
  4. BYOK mode (no Sentinelayer token):
753
836
  - keep generated `docs/spec.md`, `docs/build-guide.md`, `prompts/execution-prompt.md`, and `tasks/todo.md`
@@ -1035,6 +1118,7 @@ The CLI now supports a command tree, while keeping slash-command compatibility:
1035
1118
  - `sentinelayer-cli daemon map scope|list|show` builds hybrid deterministic+semantic impact scopes with import-graph overlay for daemon work items
1036
1119
  - `sentinelayer-cli daemon reliability run|status` and `daemon maintenance status|on|off` operate the midnight synthetic lane and maintenance billboard lifecycle
1037
1120
  - `sentinelayer-cli mcp schema|registry|server|bridge ...` manages MCP registry schema, server configs, and VS Code bridge scaffolds
1121
+ - `sentinelayer-cli mcp token mint --scope "sessions:read" --ttl-seconds 300 --json` mints a short-lived hosted MCP bearer; text mode hides the secret, and generated CLI bridge execution blocks this command
1038
1122
  - `sentinelayer-cli plugin init|validate|list|order` manages plugin/template/policy packs and deterministic load-order governance
1039
1123
  - `sentinelayer-cli policy list|use <pack-id>` manages active policy pack selection (`community`, `strict`, `compliance-soc2`, `compliance-hipaa`, plugin packs)
1040
1124
  - `sentinelayer-cli ai provision-email` scaffolds and optionally executes AIdenID identity provisioning requests
@@ -1067,4 +1151,5 @@ Roadmap:
1067
1151
  - `GitHub CLI not installed`: install `gh` or run manual fallback.
1068
1152
  - `Invalid repo format`: use exact `owner/repo`.
1069
1153
  - `Missing token in workflow`: ensure `.github/workflows/omar-gate.yml` maps `sentinelayer_token: ${{ secrets.SENTINELAYER_TOKEN }}`.
1154
+ - `Hosted MCP bearer exposed`: set `SENTINELAYER_MCP_CLI_BRIDGE_DISABLED=1`, restart the MCP host, revoke/rotate the requesting CLI auth session with `sentinelayer-cli auth revoke`, and follow [docs/mcp.md](docs/mcp.md#bearer-exposure-response).
1070
1155
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sentinelayer-cli",
3
- "version": "0.32.5",
3
+ "version": "0.34.0",
4
4
  "description": "Scaffold Sentinelayer spec/prompt/guide artifacts with secure browser auth and token bootstrap.",
5
5
  "type": "module",
6
6
  "scripts": {
@@ -6,6 +6,7 @@ import { ZodError } from "zod";
6
6
 
7
7
  import fs from "node:fs/promises";
8
8
 
9
+ import { DEFAULT_REQUEST_TIMEOUT_MS, SentinelayerApiError } from "../auth/http.js";
9
10
  import {
10
11
  buildAidenIdProvisioningAdapterTemplate,
11
12
  buildHostedSentiSessionConnectorContract,
@@ -27,8 +28,11 @@ import {
27
28
  validateMcpToolRegistry,
28
29
  writeJsonFile,
29
30
  } from "../mcp/registry.js";
31
+ import { requestHostedMcpAccessToken } from "../mcp/token-service.js";
30
32
  import { buildSentinelayerCliRegistryTemplate } from "../mcp/cli-registry.js";
31
33
  import { runMcpStdioServer } from "../mcp/session-stdio-server.js";
34
+ import { runMcpDoctorProbes } from "../mcp/doctor.js";
35
+ import { resolveActiveAuthSession } from "../auth/service.js";
32
36
  import { resolveOutputRoot } from "../config/service.js";
33
37
 
34
38
  function shouldEmitJson(options, command) {
@@ -56,8 +60,60 @@ function zodIssueSummary(error) {
56
60
  .join("; ");
57
61
  }
58
62
 
63
+ function formatApiError(error) {
64
+ if (!(error instanceof SentinelayerApiError)) {
65
+ return error instanceof Error ? error.message : String(error || "Unknown error");
66
+ }
67
+ const requestId = error.requestId ? ` request_id=${error.requestId}` : "";
68
+ return `${error.message} [${error.code}] status=${error.status}${requestId}`;
69
+ }
70
+
71
+ export async function resolveMcpDoctorApiBaseUrl({
72
+ explicitApiUrl = "",
73
+ cwd = process.cwd(),
74
+ env = process.env,
75
+ resolveActiveAuthSessionImpl = resolveActiveAuthSession,
76
+ } = {}) {
77
+ const directApiUrl = String(explicitApiUrl || "").trim();
78
+ if (directApiUrl) {
79
+ return directApiUrl;
80
+ }
81
+
82
+ // Probes are unauthenticated, but the API base URL can live in the CLI auth
83
+ // config. Read it without token rotation so doctor remains side-effect-free.
84
+ try {
85
+ const session = await resolveActiveAuthSessionImpl({
86
+ cwd,
87
+ env,
88
+ autoRotate: false,
89
+ });
90
+ return String((session && session.apiUrl) || "").trim();
91
+ } catch {
92
+ return "";
93
+ }
94
+ }
95
+
59
96
  export function registerMcpCommand(program) {
60
- const mcp = program.command("mcp").description("Manage Sentinelayer MCP registry schemas and adapters");
97
+ const mcp = program
98
+ .command("mcp")
99
+ .description("Manage local MCP registries, stdio servers, and guarded CLI bridge metadata")
100
+ .addHelpText(
101
+ "after",
102
+ `
103
+ Examples:
104
+ sl mcp list --json
105
+ sl mcp registry init-session --force
106
+ sl mcp server init --id sentinelayer-session --registry-file .sentinelayer/mcp/tool-registry.session-tools.json
107
+ sl mcp server run --path .
108
+ sl mcp registry init-cli --json
109
+
110
+ Notes:
111
+ Local stdio MCP works for clients that can spawn the CLI process.
112
+ Hosted Claude/ChatGPT connectors require the separate HTTPS/OAuth hosted connector contract.
113
+ Generated CLI bridge tools are metadata only until a bridge host enforces auth, approval, and runtime policy.
114
+
115
+ Docs: https://github.com/mrrCarter/create-sentinelayer/blob/main/docs/mcp.md`,
116
+ );
61
117
 
62
118
  mcp
63
119
  .command("list")
@@ -113,6 +169,126 @@ export function registerMcpCommand(program) {
113
169
  }
114
170
  });
115
171
 
172
+ const token = mcp.command("token").description("Mint hosted MCP resource tokens");
173
+
174
+ token
175
+ .command("mint")
176
+ .description("Mint a short-lived bearer token for the hosted Sentinelayer MCP resource")
177
+ .option(
178
+ "--scope <scopes>",
179
+ "Space- or comma-separated MCP scopes (API default: sessions:read)"
180
+ )
181
+ .option("--ttl-seconds <seconds>", "Token lifetime in seconds (API default and maximum are server-configured)")
182
+ .option("--timeout-ms <ms>", "Sentinelayer API request timeout in milliseconds", String(DEFAULT_REQUEST_TIMEOUT_MS))
183
+ .option("--api-url <url>", "Override Sentinelayer API base URL")
184
+ .option("--no-auto-rotate", "Disable stored CLI token rotation before minting")
185
+ .option("--json", "Emit machine-readable output, including the minted access token")
186
+ .action(async (options, command) => {
187
+ const emitJson = shouldEmitJson(options, command);
188
+ let result;
189
+ try {
190
+ result = await requestHostedMcpAccessToken({
191
+ cwd: process.cwd(),
192
+ env: process.env,
193
+ explicitApiUrl: options.apiUrl,
194
+ autoRotate: options.autoRotate !== false,
195
+ scope: options.scope,
196
+ ttlSeconds: options.ttlSeconds,
197
+ timeoutMs: options.timeoutMs,
198
+ });
199
+ } catch (error) {
200
+ throw new Error(formatApiError(error));
201
+ }
202
+
203
+ const payload = {
204
+ command: "mcp token mint",
205
+ apiUrl: result.apiUrl,
206
+ authSource: result.authSource,
207
+ rotated: result.rotated,
208
+ accessToken: result.accessToken,
209
+ tokenType: result.tokenType,
210
+ expiresIn: result.expiresIn,
211
+ expiresAt: result.expiresAt,
212
+ issuer: result.issuer,
213
+ audience: result.audience,
214
+ scope: result.scope,
215
+ };
216
+ if (emitJson) {
217
+ console.log(JSON.stringify(payload, null, 2));
218
+ return;
219
+ }
220
+
221
+ console.log(pc.green("Minted hosted MCP bearer credential"));
222
+ console.log(pc.gray(`API: ${payload.apiUrl}`));
223
+ console.log(`Expires in: ${payload.expiresIn}s`);
224
+ if (payload.expiresAt) {
225
+ console.log(`Expires at: ${payload.expiresAt}`);
226
+ }
227
+ console.log(`Issuer: ${payload.issuer}`);
228
+ console.log(`Audience: ${payload.audience}`);
229
+ console.log(`Scope: ${payload.scope}`);
230
+ console.log(pc.gray("Secret value hidden in text output; use --json when you need the bearer value."));
231
+ });
232
+
233
+ mcp
234
+ .command("doctor")
235
+ .description(
236
+ "Diagnose hosted MCP auth: probe the OAuth discovery chain (PRM, AS metadata, JWKS) and verify unauthenticated /mcp is rejected"
237
+ )
238
+ .option(
239
+ "--api-url <url>",
240
+ "Sentinelayer API base URL to probe (default: resolved from your CLI auth session)"
241
+ )
242
+ .option("--timeout-ms <ms>", "Per-probe request timeout in milliseconds", String(DEFAULT_REQUEST_TIMEOUT_MS))
243
+ .option("--json", "Emit machine-readable output")
244
+ .action(async (options, command) => {
245
+ const emitJson = shouldEmitJson(options, command);
246
+ const parsedTimeout = Number(options.timeoutMs);
247
+ const timeoutMs =
248
+ Number.isFinite(parsedTimeout) && parsedTimeout > 0 ? parsedTimeout : DEFAULT_REQUEST_TIMEOUT_MS;
249
+
250
+ const apiBaseUrl = await resolveMcpDoctorApiBaseUrl({
251
+ explicitApiUrl: options.apiUrl,
252
+ cwd: process.cwd(),
253
+ env: process.env,
254
+ });
255
+ if (!apiBaseUrl) {
256
+ throw new Error(
257
+ "Could not resolve the Sentinelayer API URL. Pass --api-url <url>, or run `sl auth login` first."
258
+ );
259
+ }
260
+
261
+ const result = await runMcpDoctorProbes({ apiBaseUrl, timeoutMs });
262
+
263
+ if (emitJson) {
264
+ console.log(JSON.stringify({ command: "mcp doctor", ...result }, null, 2));
265
+ } else {
266
+ console.log(pc.bold(`MCP auth doctor — ${result.apiBaseUrl}`));
267
+ for (const probe of result.probes) {
268
+ const mark =
269
+ probe.verdict === "PASS"
270
+ ? pc.green("PASS")
271
+ : probe.verdict === "WARN"
272
+ ? pc.yellow("WARN")
273
+ : pc.red("FAIL");
274
+ console.log(` [${mark}] ${probe.label} (HTTP ${probe.status})`);
275
+ console.log(pc.gray(` ${probe.url}`));
276
+ if (probe.detail) {
277
+ console.log(pc.gray(` ${probe.detail}`));
278
+ }
279
+ }
280
+ console.log(
281
+ result.ok
282
+ ? pc.green("All critical MCP auth probes passed.")
283
+ : pc.red("One or more MCP auth probes FAILED — remote agents may not authenticate correctly.")
284
+ );
285
+ }
286
+
287
+ if (!result.ok) {
288
+ process.exitCode = 1;
289
+ }
290
+ });
291
+
116
292
  const schema = mcp.command("schema").description("Inspect or materialize MCP tool-registry schema");
117
293
  schema
118
294
  .command("show")
@@ -300,7 +476,7 @@ export function registerMcpCommand(program) {
300
476
 
301
477
  registry
302
478
  .command("init-cli")
303
- .description("Write a generated MCP registry for every SentinelLayer CLI leaf command")
479
+ .description("Write guarded sl.* CLI bridge metadata for bridge-capable MCP hosts")
304
480
  .option("--path <path>", "Destination file path override")
305
481
  .option("--output-dir <path>", "Optional artifact output root override")
306
482
  .option("--force", "Overwrite destination file if it already exists")
@@ -328,7 +504,8 @@ export function registerMcpCommand(program) {
328
504
  }
329
505
  console.log(pc.green(`Wrote SentinelLayer CLI MCP registry template: ${writtenPath}`));
330
506
  console.log(pc.gray(`${template.tools.length} CLI leaf commands exposed as bridge tools.`));
331
- console.log(pc.gray("Execution requires a bridge-capable MCP host; local stdio run still exposes session tools."));
507
+ console.log(pc.gray("Execution requires a bridge-capable MCP host with auth, approval, and runtime policy."));
508
+ console.log(pc.gray("Sensitive token, export, and identity-mutation commands are runtime-blocked."));
332
509
  });
333
510
 
334
511
  registry
package/src/legacy-cli.js CHANGED
@@ -292,7 +292,13 @@ function printUsage() {
292
292
  console.log(" sl auth login && npx create-sentinelayer my-app && cd my-app");
293
293
  console.log(" # Then hand docs/spec.md to your coding agent");
294
294
  console.log("");
295
- console.log("Docs: https://sentinelayer.com/docs");
295
+ console.log("Examples:");
296
+ console.log(" sl session read <id> --remote --agent <agent> --tail 20");
297
+ console.log(" sl session react <id> ack --target-sequence <seq> --agent <agent>");
298
+ console.log(" sl mcp registry init-session --force && sl mcp server run --path .");
299
+ console.log(" sl mcp registry init-cli --json");
300
+ console.log("");
301
+ console.log("Docs: https://github.com/mrrCarter/create-sentinelayer#readme");
296
302
  }
297
303
 
298
304
  function normalizeInterviewInput(
@@ -17,6 +17,7 @@ const SENSITIVE_COMMAND_PATHS = new Set([
17
17
  "auth.login",
18
18
  "auth.logout",
19
19
  "auth.revoke",
20
+ "mcp.token.mint",
20
21
  "config.edit",
21
22
  "config.get",
22
23
  "config.list",
@@ -0,0 +1,247 @@
1
+ /**
2
+ * `sl mcp doctor` probe logic.
3
+ *
4
+ * Diagnoses whether the hosted Sentinelayer MCP server is correctly set up for
5
+ * remote-agent (ChatGPT / Claude) OAuth authentication. All probes are
6
+ * UNAUTHENTICATED on purpose: they read public OAuth discovery documents and
7
+ * confirm the resource server REJECTS an unauthenticated call. No bearer token
8
+ * is sent and no token is minted, so the command is side-effect-free and safe
9
+ * to run before `sl auth login`.
10
+ *
11
+ * Checks (RFCs map to the WARDEN hosted-MCP-auth build-spec §2/§4):
12
+ * 1. Protected Resource Metadata GET /.well-known/oauth-protected-resource (RFC 9728)
13
+ * 2. Authorization Server Metadata GET /.well-known/oauth-authorization-server (RFC 8414)
14
+ * 3. JSON Web Key Set GET /.well-known/jwks.json
15
+ * 4. Enforcement POST /mcp with no Authorization -> must 401 + WWW-Authenticate
16
+ */
17
+
18
+ export const DEFAULT_DOCTOR_TIMEOUT_MS = 10_000;
19
+
20
+ const PASS = "PASS";
21
+ const WARN = "WARN";
22
+ const FAIL = "FAIL";
23
+
24
+ function joinUrl(base, suffix) {
25
+ const normalizedBase = String(base || "").replace(/\/+$/, "");
26
+ const normalizedSuffix = String(suffix || "").replace(/^\/+/, "");
27
+ return `${normalizedBase}/${normalizedSuffix}`;
28
+ }
29
+
30
+ async function probeRequest(fetchImpl, url, { method = "GET", headers, body, timeoutMs } = {}) {
31
+ const controller = new AbortController();
32
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
33
+ try {
34
+ const response = await fetchImpl(url, { method, headers, body, signal: controller.signal });
35
+ const text = await response.text().catch(() => "");
36
+ let json = null;
37
+ if (text) {
38
+ try {
39
+ json = JSON.parse(text);
40
+ } catch {
41
+ json = null;
42
+ }
43
+ }
44
+ return { reached: true, status: response.status, headers: response.headers, json };
45
+ } catch (error) {
46
+ const aborted = Boolean(error) && error.name === "AbortError";
47
+ return {
48
+ reached: false,
49
+ status: 0,
50
+ detail: aborted ? `timed out after ${timeoutMs}ms` : String((error && error.message) || error),
51
+ };
52
+ } finally {
53
+ clearTimeout(timer);
54
+ }
55
+ }
56
+
57
+ function headerValue(headers, name) {
58
+ if (headers && typeof headers.get === "function") {
59
+ return String(headers.get(name) || "");
60
+ }
61
+ if (headers && typeof headers === "object") {
62
+ return String(headers[name] || headers[name.toLowerCase()] || "");
63
+ }
64
+ return "";
65
+ }
66
+
67
+ /**
68
+ * Run the hosted MCP auth discovery + enforcement probes.
69
+ *
70
+ * @param {{ apiBaseUrl: string, timeoutMs?: number, fetchImpl?: typeof fetch }} params
71
+ * @returns {Promise<{ apiBaseUrl: string, ok: boolean, probes: Array<{id:string,label:string,url:string,status:number,verdict:string,detail:string}> }>}
72
+ */
73
+ export async function runMcpDoctorProbes({
74
+ apiBaseUrl,
75
+ timeoutMs = DEFAULT_DOCTOR_TIMEOUT_MS,
76
+ fetchImpl = fetch,
77
+ } = {}) {
78
+ const base = String(apiBaseUrl || "").trim().replace(/\/+$/, "");
79
+ if (!base) {
80
+ throw new Error("apiBaseUrl is required to run MCP doctor probes.");
81
+ }
82
+
83
+ const probes = [];
84
+ // Whether PRM advertised an authorization server changes how severe an
85
+ // AS-metadata 503 is: advertised pointer + 503 is a broken chain (FAIL),
86
+ // while omitted pointer + 503 is a consistent fail-closed state (WARN).
87
+ let prmAdvertisesAuthServer = false;
88
+
89
+ // 1. Protected Resource Metadata (RFC 9728)
90
+ {
91
+ const url = joinUrl(base, "/.well-known/oauth-protected-resource");
92
+ const result = await probeRequest(fetchImpl, url, { timeoutMs });
93
+ let verdict = FAIL;
94
+ let detail;
95
+ if (!result.reached) {
96
+ detail = result.detail;
97
+ } else if (result.status === 200 && result.json && typeof result.json.resource === "string" && result.json.resource) {
98
+ verdict = PASS;
99
+ const count = Array.isArray(result.json.authorization_servers)
100
+ ? result.json.authorization_servers.length
101
+ : 0;
102
+ prmAdvertisesAuthServer = count > 0;
103
+ detail = `resource=${result.json.resource}; authorization_servers=${count}`;
104
+ } else if (result.status === 200) {
105
+ verdict = WARN;
106
+ detail = "200 but missing the required 'resource' field";
107
+ } else {
108
+ detail = `expected 200, got HTTP ${result.status}`;
109
+ }
110
+ probes.push({
111
+ id: "protected_resource_metadata",
112
+ label: "Protected Resource Metadata (RFC 9728)",
113
+ url,
114
+ status: result.status,
115
+ verdict,
116
+ detail,
117
+ });
118
+ }
119
+
120
+ // 2. Authorization Server Metadata (RFC 8414)
121
+ {
122
+ const url = joinUrl(base, "/.well-known/oauth-authorization-server");
123
+ const result = await probeRequest(fetchImpl, url, { timeoutMs });
124
+ let verdict = FAIL;
125
+ let detail;
126
+ if (!result.reached) {
127
+ detail = result.detail;
128
+ } else if (result.status === 200) {
129
+ verdict = PASS;
130
+ detail =
131
+ result.json && result.json.token_endpoint
132
+ ? `token_endpoint=${result.json.token_endpoint}`
133
+ : "advertised";
134
+ } else if (result.status === 503) {
135
+ if (prmAdvertisesAuthServer) {
136
+ // Broken discovery chain: PRM points clients at an authorization server
137
+ // that then returns 503. Clients WILL follow the pointer and fail.
138
+ verdict = FAIL;
139
+ detail =
140
+ "PRM advertises authorization_servers but AS metadata is 503 — remote clients will follow the advertised pointer to an unconfigured authorization server (broken discovery chain)";
141
+ } else {
142
+ // Consistent fail-closed: no AS advertised, AS metadata not configured.
143
+ verdict = WARN;
144
+ detail =
145
+ "503 unconfigured and PRM does not advertise an authorization server (fail-closed, consistent) — set MCP_OAUTH_AUTHORIZATION_ENDPOINT + MCP_OAUTH_TOKEN_ENDPOINT to enable remote auto-discovery";
146
+ }
147
+ } else {
148
+ detail = `expected 200, got HTTP ${result.status}`;
149
+ }
150
+ probes.push({
151
+ id: "authorization_server_metadata",
152
+ label: "Authorization Server Metadata (RFC 8414)",
153
+ url,
154
+ status: result.status,
155
+ verdict,
156
+ detail,
157
+ });
158
+ }
159
+
160
+ // 3. JSON Web Key Set
161
+ {
162
+ const url = joinUrl(base, "/.well-known/jwks.json");
163
+ const result = await probeRequest(fetchImpl, url, { timeoutMs });
164
+ let verdict = FAIL;
165
+ let detail;
166
+ if (!result.reached) {
167
+ detail = result.detail;
168
+ } else if (result.status === 200 && result.json && Array.isArray(result.json.keys) && result.json.keys.length > 0) {
169
+ const keys = result.json.keys;
170
+ // A PUBLIC JWKS must only ever publish ASYMMETRIC public keys. A symmetric
171
+ // key (kty "oct" / HS* alg) here IS the shared HMAC signing secret — anyone
172
+ // who can read it can forge MCP access tokens. Treat that as a hard failure.
173
+ const symmetricKeys = keys.filter((key) => {
174
+ const kty = String((key && key.kty) || "").toLowerCase();
175
+ const alg = String((key && key.alg) || "").toUpperCase();
176
+ return kty === "oct" || alg.startsWith("HS");
177
+ });
178
+ if (symmetricKeys.length > 0) {
179
+ verdict = FAIL;
180
+ detail = `CRITICAL: ${symmetricKeys.length} symmetric key(s) (kty=oct / HS*) published in the public JWKS — that is the HMAC signing secret and lets anyone forge MCP access tokens`;
181
+ } else {
182
+ verdict = PASS;
183
+ const algs = [
184
+ ...new Set(keys.map((key) => String((key && key.alg) || (key && key.kty) || "?"))),
185
+ ].join(", ");
186
+ detail = `${keys.length} asymmetric signing key(s) published (${algs})`;
187
+ }
188
+ } else if (result.status === 200) {
189
+ verdict = WARN;
190
+ detail = "200 but no signing keys published (expected for HS256/non-prod; production must publish an RS256 JWKS)";
191
+ } else {
192
+ detail = `expected 200, got HTTP ${result.status}`;
193
+ }
194
+ probes.push({
195
+ id: "jwks",
196
+ label: "JSON Web Key Set",
197
+ url,
198
+ status: result.status,
199
+ verdict,
200
+ detail,
201
+ });
202
+ }
203
+
204
+ // 4. Enforcement: an unauthenticated /mcp call must be rejected with a
205
+ // 401 + WWW-Authenticate challenge that points back at the PRM document.
206
+ {
207
+ const url = joinUrl(base, "/mcp");
208
+ const result = await probeRequest(fetchImpl, url, {
209
+ method: "POST",
210
+ headers: { "Content-Type": "application/json" },
211
+ body: JSON.stringify({ jsonrpc: "2.0", id: "mcp-doctor", method: "ping" }),
212
+ timeoutMs,
213
+ });
214
+ let verdict = FAIL;
215
+ let detail;
216
+ if (!result.reached) {
217
+ detail = result.detail;
218
+ } else if (result.status === 401) {
219
+ const challenge = headerValue(result.headers, "www-authenticate");
220
+ if (challenge && /resource_metadata=/i.test(challenge)) {
221
+ verdict = PASS;
222
+ detail = "401 + WWW-Authenticate challenge advertises the protected-resource metadata";
223
+ } else if (challenge) {
224
+ verdict = WARN;
225
+ detail = "401 but the WWW-Authenticate challenge omits resource_metadata (clients cannot auto-discover the AS)";
226
+ } else {
227
+ verdict = WARN;
228
+ detail = "401 but no WWW-Authenticate header (clients cannot auto-discover the AS)";
229
+ }
230
+ } else if (result.status === 200) {
231
+ detail = "CRITICAL: unauthenticated /mcp returned 200 — the resource server is NOT enforcing authentication";
232
+ } else {
233
+ detail = `expected 401, got HTTP ${result.status}`;
234
+ }
235
+ probes.push({
236
+ id: "mcp_enforcement",
237
+ label: "Unauthenticated /mcp is rejected",
238
+ url,
239
+ status: result.status,
240
+ verdict,
241
+ detail,
242
+ });
243
+ }
244
+
245
+ const ok = probes.every((probe) => probe.verdict !== FAIL);
246
+ return { apiBaseUrl: base, ok, probes };
247
+ }
@@ -0,0 +1,156 @@
1
+ import process from "node:process";
2
+
3
+ import { DEFAULT_REQUEST_TIMEOUT_MS, requestJsonMutation } from "../auth/http.js";
4
+ import {
5
+ DEFAULT_API_TOKEN_TTL_DAYS,
6
+ DEFAULT_TOKEN_ROTATE_THRESHOLD_DAYS,
7
+ resolveActiveAuthSession,
8
+ } from "../auth/service.js";
9
+ import { authLoginHint } from "../ui/command-hints.js";
10
+
11
+ function normalizePositiveNumber(rawValue, field, fallbackValue) {
12
+ if (rawValue === undefined || rawValue === null || String(rawValue).trim() === "") {
13
+ if (fallbackValue !== undefined) {
14
+ return fallbackValue;
15
+ }
16
+ throw new Error(`${field} must be a positive number.`);
17
+ }
18
+ const normalized = Number(rawValue);
19
+ if (!Number.isFinite(normalized) || normalized <= 0) {
20
+ throw new Error(`${field} must be a positive number.`);
21
+ }
22
+ return normalized;
23
+ }
24
+
25
+ function normalizeOptionalPositiveInteger(rawValue, field) {
26
+ if (rawValue === undefined || rawValue === null || String(rawValue).trim() === "") {
27
+ return null;
28
+ }
29
+ const normalized = Number(rawValue);
30
+ if (!Number.isFinite(normalized) || normalized <= 0 || !Number.isInteger(normalized)) {
31
+ throw new Error(`${field} must be a positive integer.`);
32
+ }
33
+ return normalized;
34
+ }
35
+
36
+ function buildApiPath(apiUrl, pathSuffix) {
37
+ const base = String(apiUrl || "").replace(/\/+$/, "");
38
+ const suffix = String(pathSuffix || "").replace(/^\/+/, "");
39
+ return `${base}/${suffix}`;
40
+ }
41
+
42
+ function toAuthHeader(token) {
43
+ return {
44
+ Authorization: `Bearer ${String(token || "").trim()}`,
45
+ };
46
+ }
47
+
48
+ function applyScope(body, { scope = "", scopes } = {}) {
49
+ const normalizedScope = String(scope || "").trim();
50
+ const normalizedScopes = Array.isArray(scopes)
51
+ ? scopes.map((item) => String(item || "").trim()).filter(Boolean)
52
+ : String(scopes || "").trim();
53
+ if (normalizedScope && normalizedScopes && (!Array.isArray(normalizedScopes) || normalizedScopes.length > 0)) {
54
+ throw new Error("Use either scope or scopes, not both.");
55
+ }
56
+ if (normalizedScope) {
57
+ body.scope = normalizedScope;
58
+ } else if (Array.isArray(normalizedScopes) && normalizedScopes.length > 0) {
59
+ body.scopes = normalizedScopes;
60
+ } else if (typeof normalizedScopes === "string" && normalizedScopes) {
61
+ body.scope = normalizedScopes;
62
+ }
63
+ }
64
+
65
+ /**
66
+ * Request a short-lived hosted MCP bearer credential.
67
+ * The CLI authenticates with its existing Sentinelayer API token; the API
68
+ * performs all credential minting server-side and returns the secret in-memory.
69
+ *
70
+ * @param {{
71
+ * cwd?: string,
72
+ * env?: NodeJS.ProcessEnv,
73
+ * explicitApiUrl?: string,
74
+ * autoRotate?: boolean,
75
+ * rotateThresholdDays?: number,
76
+ * tokenLabel?: string,
77
+ * tokenTtlDays?: number,
78
+ * homeDir?: string,
79
+ * scope?: string,
80
+ * scopes?: string[] | string,
81
+ * ttlSeconds?: number | string | null,
82
+ * timeoutMs?: number | string | null
83
+ * }} [options]
84
+ * @returns {Promise<{
85
+ * apiUrl: string,
86
+ * authSource: string,
87
+ * rotated: boolean,
88
+ * accessToken: string,
89
+ * tokenType: string,
90
+ * expiresIn: number,
91
+ * expiresAt: string,
92
+ * issuer: string,
93
+ * audience: string,
94
+ * scope: string
95
+ * }>}
96
+ */
97
+ export async function requestHostedMcpAccessToken({
98
+ cwd = process.cwd(),
99
+ env = process.env,
100
+ explicitApiUrl = "",
101
+ autoRotate = true,
102
+ rotateThresholdDays = DEFAULT_TOKEN_ROTATE_THRESHOLD_DAYS,
103
+ tokenLabel = "",
104
+ tokenTtlDays = DEFAULT_API_TOKEN_TTL_DAYS,
105
+ homeDir,
106
+ scope = "",
107
+ scopes,
108
+ ttlSeconds = null,
109
+ timeoutMs = DEFAULT_REQUEST_TIMEOUT_MS,
110
+ } = {}) {
111
+ const session = await resolveActiveAuthSession({
112
+ cwd,
113
+ env,
114
+ explicitApiUrl,
115
+ autoRotate,
116
+ rotateThresholdDays,
117
+ tokenLabel,
118
+ tokenTtlDays,
119
+ homeDir,
120
+ });
121
+ if (!session || !session.token) {
122
+ throw new Error(`Not authenticated. Run \`${authLoginHint()}\` first.`);
123
+ }
124
+
125
+ const body = {};
126
+ applyScope(body, { scope, scopes });
127
+ const normalizedTtlSeconds = normalizeOptionalPositiveInteger(ttlSeconds, "ttlSeconds");
128
+ if (normalizedTtlSeconds !== null) {
129
+ body.ttl_seconds = normalizedTtlSeconds;
130
+ }
131
+ const normalizedTimeoutMs = normalizePositiveNumber(timeoutMs, "timeoutMs", DEFAULT_REQUEST_TIMEOUT_MS);
132
+
133
+ const response = await requestJsonMutation(
134
+ buildApiPath(session.apiUrl, "/api/v1/auth/mcp-token"),
135
+ {
136
+ method: "POST",
137
+ operationName: "mcp-token-mint",
138
+ headers: toAuthHeader(session.token),
139
+ body,
140
+ timeoutMs: normalizedTimeoutMs,
141
+ }
142
+ );
143
+
144
+ return {
145
+ apiUrl: session.apiUrl,
146
+ authSource: session.source,
147
+ rotated: Boolean(session.rotated),
148
+ accessToken: String(response.access_token || ""),
149
+ tokenType: String(response.token_type || "Bearer"),
150
+ expiresIn: Number(response.expires_in || 0),
151
+ expiresAt: String(response.expires_at || ""),
152
+ issuer: String(response.issuer || ""),
153
+ audience: String(response.audience || ""),
154
+ scope: String(response.scope || ""),
155
+ };
156
+ }
@@ -498,7 +498,7 @@ export function buildSecretSetupInstructions(
498
498
  `- gh secret set ${normalizedSecret} --repo ${resolvedRepoSlug}`,
499
499
  `- Verify secret visibility: gh secret list --repo ${resolvedRepoSlug}`,
500
500
  "- Commit and push .github/workflows/omar-gate.yml",
501
- "- For manual setup: https://sentinelayer.com/docs/getting-started/install-workflow",
501
+ "- For manual setup: https://github.com/mrrCarter/create-sentinelayer#manual-fallback-if-auto-injection-is-skipped",
502
502
  ];
503
503
  }
504
504
 
@@ -1,3 +1,4 @@
1
+ import { createHash } from "node:crypto";
1
2
  import fsp from "node:fs/promises";
2
3
  import path from "node:path";
3
4
  import process from "node:process";
@@ -72,6 +73,25 @@ function normalizePositiveInteger(value, fallbackValue) {
72
73
  return Math.max(1, Math.floor(normalized));
73
74
  }
74
75
 
76
+ function buildWorkPlanSourceContext(targetPath = process.cwd(), { sourceReason = "caller_target_path" } = {}) {
77
+ const workspacePath = path.resolve(String(targetPath || "."));
78
+ const workspaceLabel = normalizeString(path.basename(workspacePath)) || ".";
79
+ const workspaceFingerprint = createHash("sha256")
80
+ .update(workspacePath)
81
+ .digest("hex")
82
+ .slice(0, 8);
83
+ const sourceLabel = `${WORK_PLAN_RELATIVE_PATH} from ${workspaceLabel}#${workspaceFingerprint}`;
84
+ return {
85
+ workspacePath,
86
+ filePath: path.join(workspacePath, WORK_PLAN_RELATIVE_PATH),
87
+ path: WORK_PLAN_RELATIVE_PATH,
88
+ workspaceLabel,
89
+ workspaceFingerprint,
90
+ sourceLabel,
91
+ sourceReason: normalizeString(sourceReason),
92
+ };
93
+ }
94
+
75
95
  function toEpoch(value, fallbackIso = new Date().toISOString()) {
76
96
  return Date.parse(normalizeIsoTimestamp(value, fallbackIso)) || 0;
77
97
  }
@@ -407,9 +427,13 @@ function emptyTaskLedgerSummary() {
407
427
  };
408
428
  }
409
429
 
410
- function emptyWorkPlanSummary() {
430
+ function emptyWorkPlanSummary(sourceContext = {}) {
411
431
  return {
412
- path: WORK_PLAN_RELATIVE_PATH,
432
+ path: normalizeString(sourceContext.path) || WORK_PLAN_RELATIVE_PATH,
433
+ workspaceLabel: normalizeString(sourceContext.workspaceLabel),
434
+ workspaceFingerprint: normalizeString(sourceContext.workspaceFingerprint),
435
+ sourceLabel: normalizeString(sourceContext.sourceLabel),
436
+ sourceReason: normalizeString(sourceContext.sourceReason),
413
437
  exists: false,
414
438
  truncated: false,
415
439
  detailSuppressed: false,
@@ -434,8 +458,11 @@ function shortWorkPlanText(value) {
434
458
  return `${text.slice(0, 97)}...`;
435
459
  }
436
460
 
437
- function summarizeWorkPlanMarkdown(raw = "", { limit = DEFAULT_WORK_PLAN_SUMMARY_LIMIT, truncated = false } = {}) {
438
- const summary = emptyWorkPlanSummary();
461
+ function summarizeWorkPlanMarkdown(
462
+ raw = "",
463
+ { limit = DEFAULT_WORK_PLAN_SUMMARY_LIMIT, truncated = false, sourceContext = {} } = {}
464
+ ) {
465
+ const summary = emptyWorkPlanSummary(sourceContext);
439
466
  summary.exists = true;
440
467
  summary.truncated = Boolean(truncated);
441
468
  if (summary.truncated) {
@@ -487,15 +514,19 @@ function summarizeWorkPlanMarkdown(raw = "", { limit = DEFAULT_WORK_PLAN_SUMMARY
487
514
  return summary;
488
515
  }
489
516
 
490
- async function readWorkPlanSummary({ targetPath = process.cwd(), limit = DEFAULT_WORK_PLAN_SUMMARY_LIMIT } = {}) {
491
- const filePath = path.join(path.resolve(String(targetPath || ".")), WORK_PLAN_RELATIVE_PATH);
517
+ async function readWorkPlanSummary({
518
+ targetPath = process.cwd(),
519
+ limit = DEFAULT_WORK_PLAN_SUMMARY_LIMIT,
520
+ sourceReason = "caller_target_path",
521
+ } = {}) {
522
+ const sourceContext = buildWorkPlanSourceContext(targetPath, { sourceReason });
492
523
  try {
493
- const stats = await fsp.stat(filePath);
524
+ const stats = await fsp.stat(sourceContext.filePath);
494
525
  let source = "";
495
526
  let truncated = false;
496
527
  if (stats.size > MAX_WORK_PLAN_BYTES) {
497
528
  truncated = true;
498
- const handle = await fsp.open(filePath, "r");
529
+ const handle = await fsp.open(sourceContext.filePath, "r");
499
530
  try {
500
531
  const buffer = Buffer.alloc(MAX_WORK_PLAN_BYTES);
501
532
  const position = Math.max(0, stats.size - MAX_WORK_PLAN_BYTES);
@@ -505,14 +536,18 @@ async function readWorkPlanSummary({ targetPath = process.cwd(), limit = DEFAULT
505
536
  await handle.close();
506
537
  }
507
538
  } else {
508
- source = await fsp.readFile(filePath, "utf-8");
539
+ source = await fsp.readFile(sourceContext.filePath, "utf-8");
509
540
  }
510
- return summarizeWorkPlanMarkdown(source, { limit, truncated });
541
+ return summarizeWorkPlanMarkdown(source, {
542
+ limit,
543
+ truncated,
544
+ sourceContext,
545
+ });
511
546
  } catch (error) {
512
547
  if (error && typeof error === "object" && error.code === "ENOENT") {
513
- return emptyWorkPlanSummary();
548
+ return emptyWorkPlanSummary(sourceContext);
514
549
  }
515
- return emptyWorkPlanSummary();
550
+ return emptyWorkPlanSummary(sourceContext);
516
551
  }
517
552
  }
518
553
 
@@ -733,9 +768,24 @@ function buildWorkPlanText(workPlan = emptyWorkPlanSummary()) {
733
768
  }
734
769
  const open = Number(workPlan.open || 0);
735
770
  const completed = Number(workPlan.completed || 0);
736
- const pathText = normalizeString(workPlan.path) || WORK_PLAN_RELATIVE_PATH;
771
+ const sourceText =
772
+ normalizeString(workPlan.sourceLabel) ||
773
+ (() => {
774
+ const pathText = normalizeString(workPlan.path) || WORK_PLAN_RELATIVE_PATH;
775
+ const workspaceLabel = normalizeString(workPlan.workspaceLabel);
776
+ const workspaceFingerprint = normalizeString(workPlan.workspaceFingerprint);
777
+ if (!workspaceLabel) {
778
+ return pathText;
779
+ }
780
+ const workspaceText = workspaceFingerprint
781
+ ? `${workspaceLabel}#${workspaceFingerprint}`
782
+ : workspaceLabel;
783
+ return `${pathText} from ${workspaceText}`;
784
+ })();
785
+ const sourceReason = normalizeString(workPlan.sourceReason);
786
+ const sourceReasonText = sourceReason ? ` (${sourceReason})` : "";
737
787
  if (workPlan.detailSuppressed || workPlan.truncated) {
738
- return `Plan: ${open} open / ${completed} done in recent ${pathText} window. Current/next items suppressed because the plan file is large.`;
788
+ return `Plan: ${open} open / ${completed} done in recent ${sourceText} window${sourceReasonText}. Current/next items suppressed because the plan file is large.`;
739
789
  }
740
790
  const currentSection = normalizeString(workPlan.currentSection);
741
791
  const currentText = currentSection ? ` Current: ${currentSection}.` : "";
@@ -752,7 +802,7 @@ function buildWorkPlanText(workPlan = emptyWorkPlanSummary()) {
752
802
  .join("; ")}.`
753
803
  : "";
754
804
  const truncatedText = workPlan.truncated ? " Recent window only." : "";
755
- return `Plan: ${open} open / ${completed} done in ${pathText}.${currentText}${nextText}${truncatedText}`;
805
+ return `Plan: ${open} open / ${completed} done in ${sourceText}${sourceReasonText}.${currentText}${nextText}${truncatedText}`;
756
806
  }
757
807
 
758
808
  function roundCurrency(value) {
@@ -911,6 +961,12 @@ export async function buildSessionRecap(
911
961
  } catch {
912
962
  sessionMetadata = null;
913
963
  }
964
+ const sessionWorkspaceTargetPath = path.resolve(
965
+ normalizeString(sessionMetadata?.targetPath) || normalizedTargetPath,
966
+ );
967
+ const workPlanSourceReason = normalizeString(sessionMetadata?.targetPath)
968
+ ? "session_metadata_target_path"
969
+ : "caller_target_path";
914
970
  const sortedEvents = sortEventsByConversationTime(dedupeSessionEvents(allEvents), normalizedNow);
915
971
  const usageSummary = normalizeUsageSummary(sortedEvents);
916
972
  const events = sortedEvents.slice(-normalizedMaxEvents);
@@ -955,7 +1011,8 @@ export async function buildSessionRecap(
955
1011
  targetPath: normalizedTargetPath,
956
1012
  });
957
1013
  const workPlan = await readWorkPlanSummary({
958
- targetPath: normalizedTargetPath,
1014
+ targetPath: sessionWorkspaceTargetPath,
1015
+ sourceReason: workPlanSourceReason,
959
1016
  });
960
1017
  const snippets = summarizeRecentActivity(actorEvents, {
961
1018
  forAgentId: normalizedForAgentId,
@@ -407,6 +407,7 @@ export function isSessionCacheExpired(metadata, nowIso = new Date().toISOString(
407
407
  function buildSessionPayload(metadata, paths, nowIso = new Date().toISOString()) {
408
408
  return {
409
409
  sessionId: metadata.sessionId,
410
+ targetPath: metadata.targetPath,
410
411
  sessionDir: paths.sessionDir,
411
412
  metadataPath: paths.metadataPath,
412
413
  streamPath: paths.streamPath,