@cmmd-center/forge 0.13.12 → 0.13.13

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.
Files changed (24) hide show
  1. package/dist/bin.cjs +454 -71
  2. package/dist/bin.mjs +478 -95
  3. package/dist/client/assets/{CanvasPanel-BjHxCGlb.js → CanvasPanel-RpD9Eh_S.js} +2 -2
  4. package/dist/client/assets/{CompositeItem-D4LER3__.js → CompositeItem-DEIBUHJR.js} +2 -2
  5. package/dist/client/assets/{DiffPanel-D3btZGjW.js → DiffPanel-cwm7LEtZ.js} +2 -2
  6. package/dist/client/assets/{DiffPanel.logic-DWcwE9Wj.js → DiffPanel.logic-B9AWYFxE.js} +2 -2
  7. package/dist/client/assets/{DiffPanelShell-oXHh--ae.js → DiffPanelShell-BhokrrDZ.js} +103 -103
  8. package/dist/client/assets/{DiffWorkerPoolProvider-R9YPaaNy.js → DiffWorkerPoolProvider-BZjEmEzL.js} +2 -2
  9. package/dist/client/assets/{PullRequestCodePanel-DTCc2TBl.js → PullRequestCodePanel-BPJINzCp.js} +2 -2
  10. package/dist/client/assets/{Virtualizer-arKGxq_z.js → Virtualizer-i6HiQ7Qm.js} +2 -2
  11. package/dist/client/assets/{canvasStateStore-B5zd1u9r.js → canvasStateStore-NrAxDqvM.js} +2 -2
  12. package/dist/client/assets/{cmmdAuthRedirect-DeFAIQpj.js → cmmdAuthRedirect-BahlX0gF.js} +2 -2
  13. package/dist/client/assets/index-DMWfw0Ih.js +154 -0
  14. package/dist/client/assets/index-dEPiAWVG.css +1 -0
  15. package/dist/client/assets/{popover-DM7IA0zJ.js → popover-Z8Tvl-OD.js} +2 -2
  16. package/dist/client/assets/src-BU7yHzO9.js +3 -0
  17. package/dist/client/assets/{toggle-DVtS0BJJ.js → toggle-CWYdWYBn.js} +2 -2
  18. package/dist/client/index.html +11 -11
  19. package/dist/forge-build.json +1 -1
  20. package/dist/{open-D8mGyWmt.mjs → open-FIgTDosf.mjs} +3 -3
  21. package/package.json +1 -1
  22. package/dist/client/assets/index-Bn-ASQRG.css +0 -1
  23. package/dist/client/assets/index-C7YKDHIe.js +0 -154
  24. package/dist/client/assets/src-LDQtPKQt.js +0 -3
package/dist/bin.cjs CHANGED
@@ -17402,7 +17402,8 @@ const ORCHESTRATION_WS_METHODS = {
17402
17402
  getArchivedShellSnapshot: "orchestration.getArchivedShellSnapshot",
17403
17403
  subscribeShell: "orchestration.subscribeShell",
17404
17404
  subscribeThread: "orchestration.subscribeThread",
17405
- searchThreads: "orchestration.searchThreads"
17405
+ searchThreads: "orchestration.searchThreads",
17406
+ getThreadDetailPage: "orchestration.getThreadDetailPage"
17406
17407
  };
17407
17408
  const ProviderKind = require_Schema$1.Literals([
17408
17409
  "codex",
@@ -17931,6 +17932,21 @@ require_Schema$1.Struct({
17931
17932
  beforeCursor: require_Schema$1.optionalKey(TrimmedNonEmptyString)
17932
17933
  });
17933
17934
  /**
17935
+ * Reads one windowed page of a thread's detail directly over the RPC socket.
17936
+ *
17937
+ * The HTTP snapshot route and `subscribeThread`'s fallback snapshot both take
17938
+ * the same `turnLimit`/`beforeCursor` window, but neither can serve "load
17939
+ * earlier" on a connection with no HTTP endpoint: the fallback snapshot only
17940
+ * fires once, on subscribe. This method exists for exactly that case (a
17941
+ * relay-only connection), gated on the server's `threadDetailPageRpc`
17942
+ * capability so older servers are never sent a method they do not know.
17943
+ */
17944
+ const OrchestrationGetThreadDetailPageInput = require_Schema$1.Struct({
17945
+ threadId: ThreadId,
17946
+ turnLimit: require_Schema$1.optionalKey(PositiveInt),
17947
+ beforeCursor: require_Schema$1.optionalKey(TrimmedNonEmptyString)
17948
+ });
17949
+ /**
17934
17950
  * Page metadata for a windowed thread detail read. `beforeCursor` is opaque and
17935
17951
  * exclusive: passing it back returns the adjacent slice of older turns. `null`
17936
17952
  * means nothing older is left. `snapshotSequence` mirrors the top-level
@@ -18928,6 +18944,10 @@ const OrchestrationRpcSchemas = {
18928
18944
  searchThreads: {
18929
18945
  input: OrchestrationSearchThreadsInput,
18930
18946
  output: OrchestrationSearchThreadsResult
18947
+ },
18948
+ getThreadDetailPage: {
18949
+ input: OrchestrationGetThreadDetailPageInput,
18950
+ output: OrchestrationThreadDetailSnapshot
18931
18951
  }
18932
18952
  };
18933
18953
  var OrchestrationGetSnapshotError = class extends require_Schema$1.TaggedErrorClass()("OrchestrationGetSnapshotError", {
@@ -23006,6 +23026,7 @@ const ServerConfig$1 = require_Schema$1.Struct({
23006
23026
  shellRevealInFileManagerKind: require_Schema$1.optionalKey(FileManagerRevealKind),
23007
23027
  threadResumeCompletionMarker: require_Schema$1.optionalKey(require_Schema$1.Boolean),
23008
23028
  threadSnapshotPagination: require_Schema$1.optionalKey(require_Schema$1.Boolean),
23029
+ threadDetailPageRpc: require_Schema$1.optionalKey(require_Schema$1.Boolean),
23009
23030
  observability: ServerObservability,
23010
23031
  settings: ServerSettings
23011
23032
  });
@@ -26965,6 +26986,17 @@ const WsOrchestrationSearchThreadsRpc = make$30(ORCHESTRATION_WS_METHODS.searchT
26965
26986
  error: OrchestrationSearchThreadsError
26966
26987
  });
26967
26988
  /**
26989
+ * The socket-only path for "load earlier turns": a relay-only connection has
26990
+ * no HTTP endpoint, so this reads the same windowed page the HTTP snapshot
26991
+ * route and `subscribeThread`'s fallback snapshot serve, over the RPC socket
26992
+ * instead.
26993
+ */
26994
+ const WsOrchestrationGetThreadDetailPageRpc = make$30(ORCHESTRATION_WS_METHODS.getThreadDetailPage, {
26995
+ payload: OrchestrationRpcSchemas.getThreadDetailPage.input,
26996
+ success: OrchestrationRpcSchemas.getThreadDetailPage.output,
26997
+ error: OrchestrationGetSnapshotError
26998
+ });
26999
+ /**
26968
27000
  * Resolve `owner/repo` to the repository the caller is allowed to clone.
26969
27001
  *
26970
27002
  * Both of these run against the same CMMD-brokered GitHub credential the web
@@ -27077,7 +27109,7 @@ const WsRpcGroup = make$29(WsServerGetConfigRpc, WsServerRefreshProvidersRpc, Ws
27077
27109
  payload: ProviderUploadFeedbackInput,
27078
27110
  success: ProviderUploadFeedbackResult,
27079
27111
  error: ProviderUploadFeedbackError
27080
- }), WsOrchestrationSearchThreadsRpc, WsSourceControlLookupRepositoryRpc, WsSourceControlCloneRepositoryRpc);
27112
+ }), WsOrchestrationSearchThreadsRpc, WsSourceControlLookupRepositoryRpc, WsSourceControlCloneRepositoryRpc, WsOrchestrationGetThreadDetailPageRpc);
27081
27113
  const RelayWsRpcGroup = WsRpcGroup.middleware(RelayAuthorizationMiddleware).middleware(RelayDeliveryMiddleware);
27082
27114
  /**
27083
27115
  * The direct-WebSocket mount of the same group, with per-method scope
@@ -27150,6 +27182,32 @@ function encodeCmmdRuntimeToolActivityPayload(kind, payload) {
27150
27182
  }
27151
27183
  };
27152
27184
  }
27185
+ const WORKTREE_LAUNCH_ACTIVITY_KINDS = new Set([
27186
+ "worktree.requested",
27187
+ "worktree.stage",
27188
+ "worktree.output",
27189
+ "worktree.created",
27190
+ "worktree.failed",
27191
+ "setup-script.requested",
27192
+ "setup-script.started",
27193
+ "setup-script.skipped",
27194
+ "setup-script.failed",
27195
+ "launch.agent-starting",
27196
+ "launch.cancelled"
27197
+ ]);
27198
+ function encodeCmmdRuntimeWorktreeLaunchActivityPayload(kind, payload) {
27199
+ return {
27200
+ ...isRecord$23(payload) ? payload : { sourcePayload: payload },
27201
+ forgeWorktreeLaunchActivity: {
27202
+ schemaVersion: 1,
27203
+ source: "native",
27204
+ kind
27205
+ }
27206
+ };
27207
+ }
27208
+ function isCmmdRuntimeWorktreeLaunchActivityKind(kind) {
27209
+ return WORKTREE_LAUNCH_ACTIVITY_KINDS.has(kind);
27210
+ }
27153
27211
  //#endregion
27154
27212
  //#region ../../packages/contracts/src/cmmdProjectPatch.ts
27155
27213
  const CMMD_PROJECT_PATCH_FIELDS = {
@@ -32260,6 +32318,11 @@ const RELAY_METHOD_POLICY_BASES = [
32260
32318
  requiredScope: "thread.read",
32261
32319
  deliveryClass: "subscription"
32262
32320
  },
32321
+ {
32322
+ method: ORCHESTRATION_WS_METHODS.getThreadDetailPage,
32323
+ requiredScope: "thread.read",
32324
+ deliveryClass: "read"
32325
+ },
32263
32326
  {
32264
32327
  method: WS_METHODS.assetsCreateUrl,
32265
32328
  requiredScope: "thread.read",
@@ -66317,7 +66380,7 @@ function normalizeNumberish(value) {
66317
66380
  }
66318
66381
  //#endregion
66319
66382
  //#region package.json
66320
- var version$1 = "0.13.12";
66383
+ var version$1 = "0.13.13";
66321
66384
  //#endregion
66322
66385
  //#region src/sentry.ts
66323
66386
  const SERVER_APP_NAME = "forge-server";
@@ -66719,6 +66782,129 @@ const respondToAuthError = (error) => require_Schema$1.gen(function* () {
66719
66782
  }));
66720
66783
  return response;
66721
66784
  });
66785
+ //#endregion
66786
+ //#region src/http/rateLimit.ts
66787
+ /**
66788
+ * Per-client, per-route-class rate limiting for Forge's own HTTP surface.
66789
+ *
66790
+ * Scoped to the routes a broken or looping client can hammer before it ever
66791
+ * has an authenticated session — the CMMD device-code poll, the bootstrap and
66792
+ * pairing-token exchanges — plus the authenticated /api/cmmd proxy, which
66793
+ * every desktop UI interaction runs through. None of these had any limit
66794
+ * before FOR-36706: a stuck pairing loop or a poll that never saw `slow_down`
66795
+ * could retry as fast as the network allowed, with nothing to stop it and
66796
+ * nothing telling the client to back off.
66797
+ *
66798
+ * Defaults are generous on purpose. This exists to bound a runaway client,
66799
+ * not to throttle a working one — see `defaultRateLimitPolicies` for the
66800
+ * reasoning behind each number and how to override it.
66801
+ *
66802
+ * Deliberately plain module state, not an Effect service: every route this
66803
+ * guards is a synchronous decision (count, compare, maybe reset a window),
66804
+ * and threading a new Context requirement through the whole server's layer
66805
+ * graph for that would touch every test file that stands up any of these
66806
+ * routes. `cmmdJwtResolver.ts`'s in-flight-refresh map is the same call.
66807
+ */
66808
+ function envInt(name, fallback) {
66809
+ const raw = process.env[name];
66810
+ if (!raw) return fallback;
66811
+ const parsed = Number(raw);
66812
+ return Number.isFinite(parsed) && parsed > 0 ? Math.floor(parsed) : fallback;
66813
+ }
66814
+ /**
66815
+ * One reasoning line per class, so a future change to a default has to
66816
+ * confront why the old one was picked:
66817
+ *
66818
+ * - `auth-device-poll` (`/api/auth/cmmd-oauth/device` and `/token`): RFC 8628
66819
+ * polls every `interval` seconds, 5 by default, so a well-behaved client
66820
+ * sits under 12/min. 120/min leaves room for several concurrent sign-in
66821
+ * attempts and CMMD's own `slow_down` handling without ever tripping on
66822
+ * normal use, while still bounding a client that ignores `slow_down`.
66823
+ * - `auth-bootstrap` (`/api/auth/bootstrap*`, the bearer bootstrap routes):
66824
+ * a working client bootstraps once per sign-in. 30/min is generous headroom
66825
+ * for retries after a network blip, not a budget anyone should approach.
66826
+ * - `auth-token-exchange` (`/oauth/token`, the vendored pairing exchange):
66827
+ * same shape as bootstrap — one pairing attempt, occasionally retried.
66828
+ * - `cmmd-proxy` (every `/api/cmmd/*` route): this is the one real desktop
66829
+ * traffic actually leans on — task lists, project browsing, comments, all
66830
+ * day. 1200/min (20/sec) is set high enough that no normal session should
66831
+ * ever see it; it exists for a retry loop or a runaway resync, not for
66832
+ * ordinary use.
66833
+ */
66834
+ function buildPoliciesFromEnv() {
66835
+ return {
66836
+ "auth-device-poll": {
66837
+ maxRequests: envInt("FORGE_RATE_LIMIT_AUTH_DEVICE_POLL_MAX", 120),
66838
+ windowMs: envInt("FORGE_RATE_LIMIT_AUTH_DEVICE_POLL_WINDOW_SECONDS", 60) * 1e3
66839
+ },
66840
+ "auth-bootstrap": {
66841
+ maxRequests: envInt("FORGE_RATE_LIMIT_AUTH_BOOTSTRAP_MAX", 30),
66842
+ windowMs: envInt("FORGE_RATE_LIMIT_AUTH_BOOTSTRAP_WINDOW_SECONDS", 60) * 1e3
66843
+ },
66844
+ "auth-token-exchange": {
66845
+ maxRequests: envInt("FORGE_RATE_LIMIT_AUTH_TOKEN_EXCHANGE_MAX", 30),
66846
+ windowMs: envInt("FORGE_RATE_LIMIT_AUTH_TOKEN_EXCHANGE_WINDOW_SECONDS", 60) * 1e3
66847
+ },
66848
+ "cmmd-proxy": {
66849
+ maxRequests: envInt("FORGE_RATE_LIMIT_CMMD_PROXY_MAX", 1200),
66850
+ windowMs: envInt("FORGE_RATE_LIMIT_CMMD_PROXY_WINDOW_SECONDS", 60) * 1e3
66851
+ }
66852
+ };
66853
+ }
66854
+ let policies = buildPoliciesFromEnv();
66855
+ const windows = /* @__PURE__ */ new Map();
66856
+ function windowKey(routeClass, clientKey) {
66857
+ return `${routeClass}\0${clientKey}`;
66858
+ }
66859
+ /**
66860
+ * Fixed-window counting: cheap, and precise enough for a loop-guard rather
66861
+ * than a security boundary. The one imprecision this trades away — up to 2x
66862
+ * the configured rate right at a window boundary — matters for an attacker
66863
+ * budgeting requests against the limiter's clock, not for the runaway
66864
+ * clients this exists to catch.
66865
+ */
66866
+ function checkHttpRateLimit(input) {
66867
+ const policy = policies[input.routeClass];
66868
+ const nowMs = Date.now();
66869
+ const key = windowKey(input.routeClass, input.clientKey);
66870
+ const existing = windows.get(key);
66871
+ const windowIsLive = existing !== void 0 && nowMs - existing.windowStartMs < policy.windowMs;
66872
+ const windowStartMs = windowIsLive ? existing.windowStartMs : nowMs;
66873
+ const count = (windowIsLive ? existing.count : 0) + 1;
66874
+ windows.set(key, {
66875
+ windowStartMs,
66876
+ count
66877
+ });
66878
+ return {
66879
+ allowed: count <= policy.maxRequests,
66880
+ retryAfterSeconds: Math.max(0, Math.ceil((windowStartMs + policy.windowMs - nowMs) / 1e3))
66881
+ };
66882
+ }
66883
+ /**
66884
+ * Checks the limiter for one request and, when the caller is over budget,
66885
+ * returns the 429 to send instead of running the route — `null` means
66886
+ * proceed. Every route this guards is expected to call this first and return
66887
+ * early on a non-null result, before doing any other work.
66888
+ */
66889
+ function rateLimitOrRespond(input) {
66890
+ const decision = checkHttpRateLimit(input);
66891
+ if (decision.allowed) return null;
66892
+ return require_HttpServer.jsonUnsafe({ error: "Too many requests. Try again later." }, {
66893
+ status: 429,
66894
+ headers: { "retry-after": String(decision.retryAfterSeconds) }
66895
+ });
66896
+ }
66897
+ /**
66898
+ * The client identity for a route with no session yet — the device-code
66899
+ * poll, bootstrap and pairing-token exchange all run before Forge has issued
66900
+ * one. IP address is the only stable-enough signal available at that point;
66901
+ * the authenticated `/api/cmmd` proxy keys on the session instead (see its
66902
+ * call site), so one desktop and one phone sharing a NAT never share a
66903
+ * budget there.
66904
+ */
66905
+ function clientKeyFromRequest(request) {
66906
+ return normalizeIpAddress(require_Schema$1.getOrNull(request.remoteAddress)) ?? "unknown";
66907
+ }
66722
66908
  const OAUTH_ACCESS_TOKEN_TYPE = "urn:ietf:params:oauth:token-type:access_token";
66723
66909
  /**
66724
66910
  * The one exchange this endpoint performs, or the reason it will not.
@@ -68566,6 +68752,11 @@ function commandOAuthProxyStatus(path, status, body) {
68566
68752
  }
68567
68753
  const authBootstrapRouteLayer = add$1("POST", "/api/auth/bootstrap", require_Schema$1.gen(function* () {
68568
68754
  const request = yield* require_HttpServer.HttpServerRequest;
68755
+ const limited = rateLimitOrRespond({
68756
+ routeClass: "auth-bootstrap",
68757
+ clientKey: clientKeyFromRequest(request)
68758
+ });
68759
+ if (limited) return limited;
68569
68760
  const serverAuth = yield* ServerAuth;
68570
68761
  const sessions = yield* SessionCredentialService;
68571
68762
  const payload = yield* require_HttpServer.schemaBodyJson(AuthBootstrapInput).pipe(require_Schema$1.mapError((cause) => new AuthError({
@@ -68583,6 +68774,11 @@ const authBootstrapRouteLayer = add$1("POST", "/api/auth/bootstrap", require_Sch
68583
68774
  }).pipe(require_Schema$1.catchTag("AuthError", (error) => respondToAuthError(error))));
68584
68775
  const authCommandBootstrapRouteLayer = add$1("POST", "/api/auth/bootstrap/command", require_Schema$1.gen(function* () {
68585
68776
  const request = yield* require_HttpServer.HttpServerRequest;
68777
+ const limited = rateLimitOrRespond({
68778
+ routeClass: "auth-bootstrap",
68779
+ clientKey: clientKeyFromRequest(request)
68780
+ });
68781
+ if (limited) return limited;
68586
68782
  const serverAuth = yield* ServerAuth;
68587
68783
  const sessions = yield* SessionCredentialService;
68588
68784
  if (serverAuth.isForgeCloudShell?.() === true) return yield* new AuthError({
@@ -68631,6 +68827,11 @@ const authCommandBootstrapRouteLayer = add$1("POST", "/api/auth/bootstrap/comman
68631
68827
  }).pipe(require_Schema$1.catchTag("AuthError", (error) => respondToAuthError(error))));
68632
68828
  const authCommandBearerBootstrapRouteLayer = add$1("POST", "/api/auth/bootstrap/command/bearer", require_Schema$1.gen(function* () {
68633
68829
  const request = yield* require_HttpServer.HttpServerRequest;
68830
+ const limited = rateLimitOrRespond({
68831
+ routeClass: "auth-bootstrap",
68832
+ clientKey: clientKeyFromRequest(request)
68833
+ });
68834
+ if (limited) return limited;
68634
68835
  const serverAuth = yield* ServerAuth;
68635
68836
  const sessions = yield* SessionCredentialService;
68636
68837
  if (serverAuth.isForgeCloudShell?.() === true) return yield* new AuthError({
@@ -68679,6 +68880,11 @@ const authCommandBearerBootstrapRouteLayer = add$1("POST", "/api/auth/bootstrap/
68679
68880
  }, { status: 200 });
68680
68881
  }).pipe(require_Schema$1.catchTag("AuthError", (error) => respondToAuthError(error))));
68681
68882
  const authCmmdOAuthDeviceRouteLayer = add$1("POST", "/api/auth/cmmd-oauth/device", require_Schema$1.gen(function* () {
68883
+ const limited = rateLimitOrRespond({
68884
+ routeClass: "auth-device-poll",
68885
+ clientKey: clientKeyFromRequest(yield* require_HttpServer.HttpServerRequest)
68886
+ });
68887
+ if (limited) return limited;
68682
68888
  const payload = yield* require_HttpServer.schemaBodyJson(CommandOAuthDeviceInput).pipe(require_Schema$1.mapError((cause) => new AuthError({
68683
68889
  message: "Invalid CMMD OAuth device payload.",
68684
68890
  status: 400,
@@ -68690,7 +68896,17 @@ const authCmmdOAuthDeviceRouteLayer = add$1("POST", "/api/auth/cmmd-oauth/device
68690
68896
  ...payload.scope ? { scope: payload.scope } : {}
68691
68897
  });
68692
68898
  }).pipe(require_Schema$1.catchTag("AuthError", (error) => respondToAuthError(error))));
68899
+ /**
68900
+ * The device-code poll. Called every `interval` seconds by design (RFC
68901
+ * 8628), which is exactly why FOR-36706 puts a limiter on it: a client that
68902
+ * ignores `slow_down` — a bug, not a person — has no other backstop here.
68903
+ */
68693
68904
  const authCmmdOAuthTokenRouteLayer = add$1("POST", "/api/auth/cmmd-oauth/token", require_Schema$1.gen(function* () {
68905
+ const limited = rateLimitOrRespond({
68906
+ routeClass: "auth-device-poll",
68907
+ clientKey: clientKeyFromRequest(yield* require_HttpServer.HttpServerRequest)
68908
+ });
68909
+ if (limited) return limited;
68694
68910
  const payload = yield* require_HttpServer.schemaBodyJson(CommandOAuthTokenInput).pipe(require_Schema$1.mapError((cause) => new AuthError({
68695
68911
  message: "Invalid CMMD OAuth token payload.",
68696
68912
  status: 400,
@@ -68985,6 +69201,11 @@ const authWebCallbackRouteLayer = add$1("GET", "/api/auth/callback", require_Sch
68985
69201
  }).pipe(require_Schema$1.catchTag("AuthError", (error) => respondToAuthError(error))));
68986
69202
  const authBearerBootstrapRouteLayer = add$1("POST", "/api/auth/bootstrap/bearer", require_Schema$1.gen(function* () {
68987
69203
  const request = yield* require_HttpServer.HttpServerRequest;
69204
+ const limited = rateLimitOrRespond({
69205
+ routeClass: "auth-bootstrap",
69206
+ clientKey: clientKeyFromRequest(request)
69207
+ });
69208
+ if (limited) return limited;
68988
69209
  const serverAuth = yield* ServerAuth;
68989
69210
  const payload = yield* require_HttpServer.schemaBodyJson(AuthBootstrapInput).pipe(require_Schema$1.mapError((cause) => new AuthError({
68990
69211
  message: "Invalid bootstrap payload.",
@@ -69017,6 +69238,11 @@ const authBearerBootstrapRouteLayer = add$1("POST", "/api/auth/bootstrap/bearer"
69017
69238
  */
69018
69239
  const authOAuthTokenExchangeRouteLayer = add$1("POST", "/oauth/token", require_Schema$1.gen(function* () {
69019
69240
  const request = yield* require_HttpServer.HttpServerRequest;
69241
+ const limited = rateLimitOrRespond({
69242
+ routeClass: "auth-token-exchange",
69243
+ clientKey: clientKeyFromRequest(request)
69244
+ });
69245
+ if (limited) return limited;
69020
69246
  const serverAuth = yield* ServerAuth;
69021
69247
  const payload = yield* require_HttpServer.schemaBodyUrlParams(OAuthTokenExchangeInput).pipe(require_Schema$1.mapError((cause) => new AuthError({
69022
69248
  message: "Invalid token exchange payload.",
@@ -71242,9 +71468,12 @@ function trySubjectFallback(sessionId, subject, logPrefix) {
71242
71468
  * Fail-closed: never returns a non-user / malformed Bearer. Callers must map
71243
71469
  * null to CMMD_AUTH_REQUIRED and must not call CMMD upstream with a bad token.
71244
71470
  */
71245
- function resolveCmmdJwtForSession({ sessionId, subject, logPrefix = "cmmd-auth", forceRefresh = false }) {
71471
+ function resolveCmmdJwtForSession({ sessionId, subject, logPrefix = "cmmd-auth", forceRefresh = false, staleAccessToken = null }) {
71246
71472
  const cached = usableCmmdUserJwt(readCommandJwtForSession(sessionId));
71247
- if (!forceRefresh && cached && !isJwtExpiredForSession(sessionId)) return require_Schema$1.succeed(cached);
71473
+ if (cached && !isJwtExpiredForSession(sessionId)) {
71474
+ if (!forceRefresh) return require_Schema$1.succeed(cached);
71475
+ if (staleAccessToken && cached !== staleAccessToken) return require_Schema$1.succeed(cached);
71476
+ }
71248
71477
  const normalizedSubject = subject?.trim() || null;
71249
71478
  if (!normalizedSubject && !forceRefresh) return require_Schema$1.succeed(null);
71250
71479
  return require_Schema$1.gen(function* () {
@@ -72081,6 +72310,11 @@ function buildProxyRouteLayer(route) {
72081
72310
  const handler = require_Schema$1.gen(function* () {
72082
72311
  const req = yield* require_HttpServer.HttpServerRequest;
72083
72312
  const session = yield* (yield* ServerAuth).authenticateHttpRequest(req);
72313
+ const limited = rateLimitOrRespond({
72314
+ routeClass: "cmmd-proxy",
72315
+ clientKey: session.sessionId
72316
+ });
72317
+ if (limited) return limited;
72084
72318
  const pathParams = yield* params;
72085
72319
  const cmmdPath = substitutePath(route.cmmdPath, pathParams);
72086
72320
  if (!cmmdPath) return require_HttpServer.jsonUnsafe({ error: "Missing required path parameter." }, { status: 400 });
@@ -72141,6 +72375,11 @@ function buildProxyHandlerEffect(route) {
72141
72375
  return require_Schema$1.gen(function* () {
72142
72376
  const req = yield* require_HttpServer.HttpServerRequest;
72143
72377
  const session = yield* (yield* ServerAuth).authenticateHttpRequest(req);
72378
+ const limited = rateLimitOrRespond({
72379
+ routeClass: "cmmd-proxy",
72380
+ clientKey: session.sessionId
72381
+ });
72382
+ if (limited) return limited;
72144
72383
  if (!readCommandAccessTokenForSession(session.sessionId)) return yield* respondCmmdConnectionRequired(session.sessionId);
72145
72384
  const parsed = yield* route.parse(req);
72146
72385
  if (!parsed.ok) return require_HttpServer.jsonUnsafe(parsed.body, { status: parsed.status });
@@ -98040,6 +98279,26 @@ function serializeNativeToolActivity(activity) {
98040
98279
  }
98041
98280
  };
98042
98281
  }
98282
+ function serializeWorktreeLaunchActivity(activity) {
98283
+ if (decodeWorktreeLaunchActivityKind(activity.kind) === null) return null;
98284
+ const summary = text(activity.summary, 4e3);
98285
+ if (summary === null) return null;
98286
+ return {
98287
+ sourceSequence: null,
98288
+ event: {
98289
+ eventId: `activity:${activity.id}`,
98290
+ occurredAt: canonicalRuntimeEventTimestamp(activity.createdAt),
98291
+ ...eventTurn(activity.turnId),
98292
+ kind: "tool_call",
98293
+ summary,
98294
+ status: "completed",
98295
+ payload: encodeCmmdRuntimeWorktreeLaunchActivityPayload(activity.kind, activity.payload)
98296
+ }
98297
+ };
98298
+ }
98299
+ function decodeWorktreeLaunchActivityKind(kind) {
98300
+ return isCmmdRuntimeWorktreeLaunchActivityKind(kind) ? kind : null;
98301
+ }
98043
98302
  function isReplicatedFailureActivity(activity) {
98044
98303
  return activity.kind === "runtime.error" || activity.kind.startsWith("provider.") && activity.kind.endsWith(".failed");
98045
98304
  }
@@ -98420,12 +98679,17 @@ function serializeRuntimeThreadEvents(thread) {
98420
98679
  const event = serializeNativeToolActivity(activity);
98421
98680
  return event === null ? [] : [event];
98422
98681
  });
98682
+ const worktreeLaunchActivities = thread.activities.flatMap((activity) => {
98683
+ const event = serializeWorktreeLaunchActivity(activity);
98684
+ return event === null ? [] : [event];
98685
+ });
98423
98686
  const recordedTurns = serializeRecordedTurns(thread);
98424
98687
  const textGenerations = serializeTextGenerations(thread);
98425
98688
  return [
98426
98689
  ...messages,
98427
98690
  ...replicaActivities,
98428
98691
  ...toolActivities,
98692
+ ...worktreeLaunchActivities,
98429
98693
  ...failureActivities,
98430
98694
  ...recordedTurns,
98431
98695
  ...textGenerations
@@ -106589,6 +106853,39 @@ function loadInitialShellSnapshot(input) {
106589
106853
  return yield* input.projectionSnapshotQuery.getShellSnapshot();
106590
106854
  });
106591
106855
  }
106856
+ /**
106857
+ * The thread snapshot a subscription falls back to, windowed when the
106858
+ * subscriber asked for a window. A missing thread is a failure rather than an
106859
+ * empty snapshot: the subscriber asked about a thread this server does not
106860
+ * have, and saying so is the only honest answer.
106861
+ *
106862
+ * Also the read behind `getThreadDetailPage`, the socket RPC a relay-only
106863
+ * connection uses for "load earlier turns" in place of the HTTP snapshot
106864
+ * route: same window, same pure `windowThreadDetail`, so the two reads cannot
106865
+ * disagree about what a page is.
106866
+ */
106867
+ function loadThreadDetailSnapshot(input) {
106868
+ return require_Schema$1.gen(function* () {
106869
+ const [threadDetail, snapshotSequence] = yield* require_Schema$1.all([input.projectionSnapshotQuery.getThreadDetailById(input.threadId).pipe(require_Schema$1.mapError((cause) => new OrchestrationGetSnapshotError({
106870
+ message: `Failed to load thread ${input.threadId}`,
106871
+ cause
106872
+ }))), input.orchestrationEngine.getReadModel().pipe(require_Schema$1.map((readModel) => readModel.snapshotSequence))]);
106873
+ if (require_Schema$1.isNone(threadDetail)) return yield* new OrchestrationGetSnapshotError({
106874
+ message: `Thread ${input.threadId} was not found`,
106875
+ cause: input.threadId
106876
+ });
106877
+ const windowed = windowThreadDetail({
106878
+ thread: threadDetail.value,
106879
+ window: input.window,
106880
+ snapshotSequence
106881
+ });
106882
+ return {
106883
+ snapshotSequence,
106884
+ thread: windowed.thread,
106885
+ ...windowed.page === void 0 ? {} : { page: windowed.page }
106886
+ };
106887
+ });
106888
+ }
106592
106889
  function makeSnapshotThenLiveStream(input) {
106593
106890
  return require_Path.unwrap(require_Schema$1.gen(function* () {
106594
106891
  const liveEvents = yield* input.subscribeLiveEvents;
@@ -106815,32 +107112,6 @@ const makeWsRpcLayer = (currentSessionId, currentUserId) => WsRpcGroup.toLayer(r
106815
107112
  }, normalizedCommand) : (normalizedCommand.type === "thread.turn.interrupt" ? interruptBootstrapTurnStart(orchestrationEngine, normalizedCommand) : orchestrationEngine.dispatch(normalizedCommand)).pipe(require_Schema$1.mapError((cause) => toDispatchCommandError(cause, "Failed to dispatch orchestration command")));
106816
107113
  return startup.enqueueCommand(dispatchEffect).pipe(require_Schema$1.mapError((cause) => toDispatchCommandError(cause, "Failed to dispatch orchestration command")));
106817
107114
  };
106818
- /**
106819
- * The thread snapshot a subscription falls back to, windowed when the
106820
- * subscriber asked for a window. A missing thread is a failure rather
106821
- * than an empty snapshot: the subscriber asked about a thread this server
106822
- * does not have, and saying so is the only honest answer.
106823
- */
106824
- const loadThreadDetailSnapshot = (threadId, turnLimit) => require_Schema$1.gen(function* () {
106825
- const [threadDetail, snapshotSequence] = yield* require_Schema$1.all([projectionSnapshotQuery.getThreadDetailById(threadId).pipe(require_Schema$1.mapError((cause) => new OrchestrationGetSnapshotError({
106826
- message: `Failed to load thread ${threadId}`,
106827
- cause
106828
- }))), orchestrationEngine.getReadModel().pipe(require_Schema$1.map((readModel) => readModel.snapshotSequence))]);
106829
- if (require_Schema$1.isNone(threadDetail)) return yield* new OrchestrationGetSnapshotError({
106830
- message: `Thread ${threadId} was not found`,
106831
- cause: threadId
106832
- });
106833
- const windowed = windowThreadDetail({
106834
- thread: threadDetail.value,
106835
- window: turnLimit === void 0 ? {} : { turnLimit },
106836
- snapshotSequence
106837
- });
106838
- return {
106839
- snapshotSequence,
106840
- thread: windowed.thread,
106841
- ...windowed.page === void 0 ? {} : { page: windowed.page }
106842
- };
106843
- });
106844
107115
  const loadServerConfig = require_Schema$1.gen(function* () {
106845
107116
  const keybindingsConfig = yield* keybindings.loadConfigState;
106846
107117
  const providers = yield* providerRegistry.getProviders;
@@ -106864,6 +107135,7 @@ const makeWsRpcLayer = (currentSessionId, currentUserId) => WsRpcGroup.toLayer(r
106864
107135
  },
106865
107136
  threadResumeCompletionMarker: true,
106866
107137
  threadSnapshotPagination: true,
107138
+ threadDetailPageRpc: true,
106867
107139
  observability: {
106868
107140
  logsDirectoryPath: config.logsDir,
106869
107141
  localTracingEnabled: true,
@@ -106964,12 +107236,26 @@ const makeWsRpcLayer = (currentSessionId, currentUserId) => WsRpcGroup.toLayer(r
106964
107236
  [ORCHESTRATION_WS_METHODS.subscribeThread]: (input) => observeRpcStreamEffect(ORCHESTRATION_WS_METHODS.subscribeThread, require_Schema$1.succeed(makeThreadSubscriptionStream(input, {
106965
107237
  subscribeLiveEvents: orchestrationEngine.subscribeDomainEvents().pipe(require_Schema$1.map((events) => events.pipe(require_Path.filter(isThreadDetailEventFor(input.threadId))))),
106966
107238
  currentSequence: orchestrationEngine.getReadModel().pipe(require_Schema$1.map((readModel) => readModel.snapshotSequence)),
106967
- loadSnapshot: loadThreadDetailSnapshot(input.threadId, input.turnLimit),
107239
+ loadSnapshot: loadThreadDetailSnapshot({
107240
+ threadId: input.threadId,
107241
+ window: input.turnLimit === void 0 ? {} : { turnLimit: input.turnLimit },
107242
+ projectionSnapshotQuery,
107243
+ orchestrationEngine
107244
+ }),
106968
107245
  replayEvents: (afterSequence) => orchestrationEngine.readEvents(afterSequence).pipe(require_Path.filter(isThreadDetailEventFor(input.threadId)), require_Path.mapError((cause) => new OrchestrationGetSnapshotError({
106969
107246
  message: `Failed to replay thread ${input.threadId}`,
106970
107247
  cause
106971
107248
  })))
106972
107249
  })), { "rpc.aggregate": "orchestration" }),
107250
+ [ORCHESTRATION_WS_METHODS.getThreadDetailPage]: (input) => observeRpcEffect(ORCHESTRATION_WS_METHODS.getThreadDetailPage, loadThreadDetailSnapshot({
107251
+ threadId: input.threadId,
107252
+ window: {
107253
+ ...input.turnLimit === void 0 ? {} : { turnLimit: input.turnLimit },
107254
+ ...input.beforeCursor === void 0 ? {} : { beforeCursor: input.beforeCursor }
107255
+ },
107256
+ projectionSnapshotQuery,
107257
+ orchestrationEngine
107258
+ }), { "rpc.aggregate": "orchestration" }),
106973
107259
  [WS_METHODS.serverGetConfig]: (_input) => observeRpcEffect(WS_METHODS.serverGetConfig, loadServerConfig, { "rpc.aggregate": "server" }),
106974
107260
  [WS_METHODS.serverRefreshProviders]: (_input) => observeRpcEffect(WS_METHODS.serverRefreshProviders, providerRegistry.refresh().pipe(require_Schema$1.map((providers) => ({ providers }))), { "rpc.aggregate": "server" }),
106975
107261
  [WS_METHODS.serverStartProviderAuth]: (input) => observeRpcEffect(WS_METHODS.serverStartProviderAuth, require_Schema$1.gen(function* () {
@@ -213039,6 +213325,8 @@ const PUBLIC_PROVIDER_LEAK_PATTERNS = [
213039
213325
  /\bCURSOR_API_KEY\b/,
213040
213326
  /\bOPENCODE_API_KEY\b/,
213041
213327
  /\bforge_proxy_[A-Za-z0-9._-]+\b/,
213328
+ /\bProviderValidationError\b/,
213329
+ /\/forge-server\/node_modules\//,
213042
213330
  /https?:\/\/[^\s'"<>)]*\/api\/provider-egress\/[^\s'"<>)]*/i,
213043
213331
  /https?:\/\/[^\s'"<>)]*(?:apex|deepseek|moonshot|kimi)[^\s'"<>)]*/i
213044
213332
  ];
@@ -213048,6 +213336,12 @@ function isProviderRateOrQuotaError(message) {
213048
213336
  const normalized = message.toLowerCase();
213049
213337
  return /\b(rate[\s-]?limit|too\s+many\s+requests|usage[ _-]limit|usage_limit_reached|quota|insufficient_quota)\b/.test(normalized) || /\b429\b/.test(normalized);
213050
213338
  }
213339
+ function providerLifecycleOrAccountCopy(message) {
213340
+ if (/\bbound to provider account\b/.test(message) && /\bcannot use provider account\b/.test(message)) return "Forge could not switch this conversation to the selected AI account. Retry the account switch.";
213341
+ if (/\bforge_runtime_repairing\b/.test(message) || /\bthis turn was interrupted because the environment is being repaired\b/.test(message)) return "This turn was interrupted because the environment is being repaired. Retry after the repair finishes.";
213342
+ if (/\bforge_runtime_stopped\b/.test(message) || /\bthis turn was interrupted because the environment was stopped\b/.test(message)) return "This turn was interrupted because the environment was stopped. Start the environment and retry.";
213343
+ return null;
213344
+ }
213051
213345
  /**
213052
213346
  * Infer a SAFE, clearer failure category from a (possibly leaky) provider error.
213053
213347
  *
@@ -213060,7 +213354,8 @@ function isProviderRateOrQuotaError(message) {
213060
213354
  function categorizePublicProviderError(message) {
213061
213355
  if (!message) return null;
213062
213356
  const m = message.toLowerCase();
213063
- if (/\bforge_runtime_stopped\b/.test(m) || /\bthis turn was interrupted because the environment was stopped\b/.test(m)) return "This turn was interrupted because the environment was stopped. Start the environment and retry.";
213357
+ const lifecycleOrAccountCopy = providerLifecycleOrAccountCopy(m);
213358
+ if (lifecycleOrAccountCopy !== null) return lifecycleOrAccountCopy;
213064
213359
  if (/\bforge_provider_model_not_allowed\b/.test(m) || /\bnot\s+scoped\s+to\s+the\s+requested\s+model\b/.test(m)) return "The workspace AI credential does not cover the model this turn asked for. Retry, or pick that model for the thread.";
213065
213360
  if (/\bmodel\b[\s\S]*\b(not\s+(found|available|supported)|does\s+not\s+exist|unsupported|invalid|unknown)\b/.test(m) || /\b(unsupported|unknown|invalid)\b[\s\S]*\bmodel\b/.test(m) || /\b(model_not_found|invalid_model|model_not_supported)\b/.test(m)) return "The selected model isn't available right now. Pick a different model and retry.";
213066
213361
  if (isProviderRateOrQuotaError(m)) return "The AI service is rate-limited or out of quota. Wait a moment and retry.";
@@ -213240,13 +213535,20 @@ function claudeConfigDirectory(env) {
213240
213535
  return null;
213241
213536
  }
213242
213537
  }
213243
- /** The fallback is allowed only for the exact credential bytes CMMD signed. */
213244
- function primaryClaudeCredentialMatchesRevision(env, revision) {
213538
+ /**
213539
+ * The fallback is allowed only when the primary on-disk credential is
213540
+ * byte-identical to what CMMD prepared for the selected account. A ticket's
213541
+ * `credentialRevisionHash` cannot express that: it hashes the encrypted row
213542
+ * CMMD stores, not the decrypted bytes this process ever sees. Comparing
213543
+ * files directly is the only check that means what the fallback needs.
213544
+ */
213545
+ function primaryClaudeCredentialMatchesPrepared(env, preparedDirectory) {
213245
213546
  const directory = claudeConfigDirectory(env);
213246
- if (!directory || !/^[a-f0-9]{64}$/.test(revision)) return false;
213547
+ if (!directory) return false;
213247
213548
  try {
213248
- const credential = (0, node_fs.readFileSync)((0, node_path.join)(directory, CLAUDE_CREDENTIAL_FILE_NAME), "utf8");
213249
- return (0, node_crypto.createHash)("sha256").update(credential).digest("hex") === revision && hasAccessToken(JSON.parse(credential));
213549
+ const primary = (0, node_fs.readFileSync)((0, node_path.join)(directory, CLAUDE_CREDENTIAL_FILE_NAME), "utf8");
213550
+ const prepared = (0, node_fs.readFileSync)((0, node_path.join)(preparedDirectory, CLAUDE_CREDENTIAL_FILE_NAME), "utf8");
213551
+ return hasAccessToken(JSON.parse(primary)) && (0, node_crypto.createHash)("sha256").update(primary).digest("hex") === (0, node_crypto.createHash)("sha256").update(prepared).digest("hex");
213250
213552
  } catch {
213251
213553
  return false;
213252
213554
  }
@@ -217775,7 +218077,7 @@ const makeClaudeAdapter = require_Schema$1.fn("makeClaudeAdapter")(function* (op
217775
218077
  baseDir: serverConfig.baseDir,
217776
218078
  accountIdentity: selectedAccountIdentity,
217777
218079
  probeSupport: claudeCredentialDirectoryProbeFor(claudeBinaryPath).support,
217778
- primaryCredentialMatchesSelected: preparedCredential?.accountKind === "primary" && primaryClaudeCredentialMatchesRevision(process.env, preparedCredential.credentialRevisionHash)
218080
+ primaryCredentialMatchesSelected: preparedCredential?.accountKind === "primary" && primaryClaudeCredentialMatchesPrepared(process.env, preparedCredential.directory)
217779
218081
  });
217780
218082
  if (credentialDirectoryDecision.outcome === "refused") return yield* new ProviderAdapterValidationError({
217781
218083
  provider: PROVIDER$11,
@@ -232944,7 +233246,7 @@ async function acquireSelectedClaudeCredential(expected, transport) {
232944
233246
  const claims = token === null ? null : verifyCmmdClaudeSelectedTicket(token, expected);
232945
233247
  if (!token || !claims) throw new Error("invalid_ticket");
232946
233248
  const credentialJson = await transport.readCredential(token);
232947
- if (Buffer.byteLength(credentialJson, "utf8") > 512e3 || (0, node_crypto.createHash)("sha256").update(credentialJson).digest("hex") !== claims.credentialRevisionHash || !hasUsableClaudeCredentialBlob(credentialJson)) throw new Error("invalid_credential");
233249
+ if (Buffer.byteLength(credentialJson, "utf8") > 512e3 || !hasUsableClaudeCredentialBlob(credentialJson)) throw new Error("invalid_credential");
232948
233250
  return {
232949
233251
  claims,
232950
233252
  credentialJson
@@ -292168,6 +292470,10 @@ const make$8 = require_Schema$1.gen(function* () {
292168
292470
  shouldRestartForAccountChange,
292169
292471
  hasResumeCursor: resumeCursor !== void 0
292170
292472
  });
292473
+ if (shouldRestartForAccountChange) yield* providerService.stopSession({
292474
+ threadId,
292475
+ preserveResumeState: true
292476
+ });
292171
292477
  const restartedSession = yield* startProviderSession(resumeCursor !== void 0 ? { resumeCursor } : void 0);
292172
292478
  yield* require_Schema$1.logInfo("provider command reactor restarted provider session", {
292173
292479
  threadId,
@@ -298152,7 +298458,7 @@ function resolveBuildCommitFromEnv(env) {
298152
298458
  * environment descriptor down instead of reporting an honest "unknown".
298153
298459
  */
298154
298460
  function readBakedBuildCommit() {
298155
- return "e51896589531a57a458ca5c2430dfd54cdc16476";
298461
+ return "81ce9360f406bcb5ae9fbdc9f90dd55d33f25f0c";
298156
298462
  }
298157
298463
  async function resolveServerBuildCommit(input) {
298158
298464
  if (isFullCommitSha(input.baked)) return input.baked;
@@ -299577,20 +299883,20 @@ const generatePairingToken = () => {
299577
299883
  const randomBytes = crypto.getRandomValues(new Uint8Array(PAIRING_TOKEN_LENGTH));
299578
299884
  return Array.from(randomBytes, (value) => PAIRING_TOKEN_ALPHABET[value & 31]).join("");
299579
299885
  };
299886
+ const invalidBootstrapCredentialError = (message) => new BootstrapCredentialError({
299887
+ message,
299888
+ status: 401
299889
+ });
299890
+ const internalBootstrapCredentialError = (message, cause) => new BootstrapCredentialError({
299891
+ message,
299892
+ status: 500,
299893
+ cause
299894
+ });
299580
299895
  const makeBootstrapCredentialService = require_Schema$1.gen(function* () {
299581
299896
  const config = yield* ServerConfig;
299582
299897
  const pairingLinks = yield* AuthPairingLinkRepository;
299583
299898
  const seededGrantsRef = yield* require_HttpServer.make$3(/* @__PURE__ */ new Map());
299584
299899
  const changesPubSub = yield* require_Path.unbounded$1();
299585
- const invalidBootstrapCredentialError = (message) => new BootstrapCredentialError({
299586
- message,
299587
- status: 401
299588
- });
299589
- const internalBootstrapCredentialError = (message, cause) => new BootstrapCredentialError({
299590
- message,
299591
- status: 500,
299592
- cause
299593
- });
299594
299900
  const seedGrant = (credential, grant) => require_HttpServer.update(seededGrantsRef, (current) => {
299595
299901
  const next = new Map(current);
299596
299902
  next.set(credential, grant);
@@ -299605,19 +299911,19 @@ const makeBootstrapCredentialService = require_Schema$1.gen(function* () {
299605
299911
  id
299606
299912
  }).pipe(require_Schema$1.asVoid);
299607
299913
  if (config.desktopBootstrapToken) {
299608
- const now$2 = yield* require_Schema$1.now;
299914
+ const now$3 = yield* require_Schema$1.now;
299609
299915
  yield* seedGrant(config.desktopBootstrapToken, {
299610
299916
  method: "desktop-bootstrap",
299611
299917
  role: "owner",
299612
299918
  subject: "desktop-bootstrap",
299613
- expiresAt: require_Schema$1.add(now$2, { milliseconds: require_Schema$1.toMillis(DESKTOP_BOOTSTRAP_TOKEN_TTL) }),
299919
+ expiresAt: require_Schema$1.add(now$3, { milliseconds: require_Schema$1.toMillis(DESKTOP_BOOTSTRAP_TOKEN_TTL) }),
299614
299920
  remainingUses: "unbounded"
299615
299921
  });
299616
299922
  }
299617
299923
  const toBootstrapCredentialError = (message) => (cause) => internalBootstrapCredentialError(message, cause);
299618
299924
  const listActive = () => require_Schema$1.gen(function* () {
299619
- const now$5 = yield* require_Schema$1.now;
299620
- return (yield* pairingLinks.listActive({ now: now$5 })).map((row) => row.label ? {
299925
+ const now$2 = yield* require_Schema$1.now;
299926
+ return (yield* pairingLinks.listActive({ now: now$2 })).map((row) => row.label ? {
299621
299927
  id: row.id,
299622
299928
  credential: row.credential,
299623
299929
  role: row.role,
@@ -299647,8 +299953,8 @@ const makeBootstrapCredentialService = require_Schema$1.gen(function* () {
299647
299953
  const id = crypto.randomUUID();
299648
299954
  const credential = generatePairingToken();
299649
299955
  const ttl = input?.ttl ?? DEFAULT_ONE_TIME_TOKEN_TTL_MINUTES;
299650
- const now$4 = yield* require_Schema$1.now;
299651
- const expiresAt = require_Schema$1.add(now$4, { milliseconds: require_Schema$1.toMillis(ttl) });
299956
+ const now$5 = yield* require_Schema$1.now;
299957
+ const expiresAt = require_Schema$1.add(now$5, { milliseconds: require_Schema$1.toMillis(ttl) });
299652
299958
  const issued = {
299653
299959
  id,
299654
299960
  credential,
@@ -299662,7 +299968,7 @@ const makeBootstrapCredentialService = require_Schema$1.gen(function* () {
299662
299968
  role: input?.role ?? "client",
299663
299969
  subject: input?.subject ?? "one-time-token",
299664
299970
  label: input?.label ?? null,
299665
- createdAt: now$4,
299971
+ createdAt: now$5,
299666
299972
  expiresAt
299667
299973
  });
299668
299974
  yield* emitUpsert({
@@ -299671,13 +299977,13 @@ const makeBootstrapCredentialService = require_Schema$1.gen(function* () {
299671
299977
  role: input?.role ?? "client",
299672
299978
  subject: input?.subject ?? "one-time-token",
299673
299979
  ...input?.label ? { label: input.label } : {},
299674
- createdAt: now$4,
299980
+ createdAt: now$5,
299675
299981
  expiresAt
299676
299982
  });
299677
299983
  return issued;
299678
299984
  }).pipe(require_Schema$1.mapError(toBootstrapCredentialError("Failed to issue pairing credential.")));
299679
299985
  const consume = (credential) => require_Schema$1.gen(function* () {
299680
- const now$3 = yield* require_Schema$1.now;
299986
+ const now$4 = yield* require_Schema$1.now;
299681
299987
  const seededResult = yield* require_HttpServer.modify(seededGrantsRef, (current) => {
299682
299988
  const grant = current.get(credential);
299683
299989
  if (!grant) return [{
@@ -299686,7 +299992,7 @@ const makeBootstrapCredentialService = require_Schema$1.gen(function* () {
299686
299992
  error: invalidBootstrapCredentialError("Unknown bootstrap credential.")
299687
299993
  }, current];
299688
299994
  const next = new Map(current);
299689
- if (require_Schema$1.isGreaterThanOrEqualTo$1(now$3, grant.expiresAt)) {
299995
+ if (require_Schema$1.isGreaterThanOrEqualTo$1(now$4, grant.expiresAt)) {
299690
299996
  next.delete(credential);
299691
299997
  return [{
299692
299998
  _tag: "error",
@@ -299715,8 +300021,8 @@ const makeBootstrapCredentialService = require_Schema$1.gen(function* () {
299715
300021
  if (seededResult.reason !== "not-found") return yield* seededResult.error;
299716
300022
  const consumed = yield* pairingLinks.consumeAvailable({
299717
300023
  credential,
299718
- consumedAt: now$3,
299719
- now: now$3
300024
+ consumedAt: now$4,
300025
+ now: now$4
299720
300026
  });
299721
300027
  if (require_Schema$1.isSome(consumed)) {
299722
300028
  yield* emitRemoved(consumed.value.id);
@@ -299732,9 +300038,9 @@ const makeBootstrapCredentialService = require_Schema$1.gen(function* () {
299732
300038
  if (require_Schema$1.isNone(matching)) return yield* invalidBootstrapCredentialError("Unknown bootstrap credential.");
299733
300039
  if (matching.value.revokedAt !== null) return yield* invalidBootstrapCredentialError("Bootstrap credential is no longer available.");
299734
300040
  if (matching.value.consumedAt !== null) return yield* invalidBootstrapCredentialError("Unknown bootstrap credential.");
299735
- if (require_Schema$1.isGreaterThanOrEqualTo$1(now$3, matching.value.expiresAt)) return yield* invalidBootstrapCredentialError("Bootstrap credential expired.");
300041
+ if (require_Schema$1.isGreaterThanOrEqualTo$1(now$4, matching.value.expiresAt)) return yield* invalidBootstrapCredentialError("Bootstrap credential expired.");
299736
300042
  return yield* invalidBootstrapCredentialError("Bootstrap credential is no longer available.");
299737
- }).pipe(require_Schema$1.mapError((cause) => cause instanceof BootstrapCredentialError ? cause : internalBootstrapCredentialError("Failed to consume bootstrap credential.", cause)));
300043
+ }).pipe(require_Schema$1.mapError((cause) => cause instanceof BootstrapCredentialError ? cause : internalBootstrapCredentialError("Failed to consume bootstrap credential.", cause)), require_Schema$1.tapError((error) => error.status === 401 ? require_Schema$1.logWarning("Rejected bootstrap credential exchange.", { reason: error.message }) : require_Schema$1.void_));
299738
300044
  return {
299739
300045
  issueOneTimeToken,
299740
300046
  listActive,
@@ -299790,6 +300096,10 @@ const SetAuthSessionLastConnectedAtInput = require_Schema$1.Struct({
299790
300096
  sessionId: AuthSessionId,
299791
300097
  lastConnectedAt: require_Schema$1.DateTimeUtcFromString
299792
300098
  });
300099
+ const ExtendAuthSessionExpiryInput = require_Schema$1.Struct({
300100
+ sessionId: AuthSessionId,
300101
+ expiresAt: require_Schema$1.DateTimeUtcFromString
300102
+ });
299793
300103
  var AuthSessionRepository = class extends require_Schema$1.Service()("forge/persistence/Services/AuthSessions/AuthSessionRepository") {};
299794
300104
  //#endregion
299795
300105
  //#region src/persistence/Layers/AuthSessions.ts
@@ -299941,6 +300251,17 @@ const makeAuthSessionRepository = require_Schema$1.gen(function* () {
299941
300251
  WHERE session_id = ${sessionId}
299942
300252
  AND revoked_at IS NULL
299943
300253
  RETURNING session_id AS "sessionId"
300254
+ `
300255
+ });
300256
+ const extendSessionExpiryRows = findAll({
300257
+ Request: ExtendAuthSessionExpiryInput,
300258
+ Result: require_Schema$1.Struct({ sessionId: AuthSessionId }),
300259
+ execute: ({ sessionId, expiresAt }) => sql`
300260
+ UPDATE auth_sessions
300261
+ SET expires_at = ${expiresAt}
300262
+ WHERE session_id = ${sessionId}
300263
+ AND revoked_at IS NULL
300264
+ RETURNING session_id AS "sessionId"
299944
300265
  `
299945
300266
  });
299946
300267
  const revokeOtherSessionRows = findAll({
@@ -299963,13 +300284,15 @@ const makeAuthSessionRepository = require_Schema$1.gen(function* () {
299963
300284
  const revoke = (input) => revokeSessionRows(input).pipe(require_Schema$1.mapError(toPersistenceSqlOrDecodeError$3("AuthSessionRepository.revoke:query", "AuthSessionRepository.revoke:decodeRows")), require_Schema$1.map((rows) => rows.length > 0));
299964
300285
  const revokeAllExcept = (input) => revokeOtherSessionRows(input).pipe(require_Schema$1.mapError(toPersistenceSqlOrDecodeError$3("AuthSessionRepository.revokeAllExcept:query", "AuthSessionRepository.revokeAllExcept:decodeRows")), require_Schema$1.map((rows) => rows.map((row) => row.sessionId)));
299965
300286
  const setLastConnectedAt = (input) => setLastConnectedAtRow(input).pipe(require_Schema$1.mapError(toPersistenceSqlOrDecodeError$3("AuthSessionRepository.setLastConnectedAt:query", "AuthSessionRepository.setLastConnectedAt:encodeRequest")));
300287
+ const extendExpiry = (input) => extendSessionExpiryRows(input).pipe(require_Schema$1.mapError(toPersistenceSqlOrDecodeError$3("AuthSessionRepository.extendExpiry:query", "AuthSessionRepository.extendExpiry:decodeRows")), require_Schema$1.map((rows) => rows.length > 0));
299966
300288
  return {
299967
300289
  create,
299968
300290
  getById,
299969
300291
  listActive,
299970
300292
  revoke,
299971
300293
  revokeAllExcept,
299972
- setLastConnectedAt
300294
+ setLastConnectedAt,
300295
+ extendExpiry
299973
300296
  };
299974
300297
  });
299975
300298
  const AuthSessionRepositoryLive = require_Schema$1.effect(AuthSessionRepository, makeAuthSessionRepository);
@@ -299978,6 +300301,20 @@ const AuthSessionRepositoryLive = require_Schema$1.effect(AuthSessionRepository,
299978
300301
  const SIGNING_SECRET_NAME = "server-signing-key";
299979
300302
  const DEFAULT_SESSION_TTL = require_Schema$1.days(30);
299980
300303
  const DEFAULT_WEBSOCKET_TOKEN_TTL = require_Schema$1.minutes(5);
300304
+ /**
300305
+ * Whether a session presented at `nowMs` has used up half its own lifetime.
300306
+ *
300307
+ * A phone that opens the app daily should never hit the 30-day wall, but a
300308
+ * lost phone's session should still die on schedule if it is never presented
300309
+ * again. Sliding the expiry forward only when the session is already more
300310
+ * than half spent gives an active phone many chances to renew before expiry
300311
+ * while keeping a dormant session's remaining lifetime bounded by the last
300312
+ * time it was actually used.
300313
+ */
300314
+ function isPastHalfLife(input) {
300315
+ const halfLifeMarkMs = input.issuedAtMs + (input.expiresAtMs - input.issuedAtMs) / 2;
300316
+ return input.nowMs >= halfLifeMarkMs;
300317
+ }
299981
300318
  const toSessionCredentialError = (message) => (cause) => new SessionCredentialError({
299982
300319
  message,
299983
300320
  cause
@@ -300138,6 +300475,42 @@ const makeSessionCredentialService = require_Schema$1.gen(function* () {
300138
300475
  role: claims.role
300139
300476
  };
300140
300477
  }).pipe(require_Schema$1.mapError(toSessionCredentialError("Failed to issue session credential.")));
300478
+ /**
300479
+ * Slide a session's expiry forward when the token it just verified is past
300480
+ * half its own lifetime, so a phone that keeps using the app never runs
300481
+ * into the 30-day wall.
300482
+ *
300483
+ * The session id, subject, role and method carry over unchanged — only the
300484
+ * signature-bearing `iat`/`exp` differ — which is what lets the DB row be
300485
+ * extended in place (`extendExpiry`) rather than replaced: everything keyed
300486
+ * by session id, most importantly the `command_access_tokens` row holding
300487
+ * this session's CMMD credential, needs no migration.
300488
+ */
300489
+ const renewIfPastHalfLife = (input) => require_Schema$1.gen(function* () {
300490
+ const nowMs = yield* require_Schema$1.currentTimeMillis;
300491
+ if (!isPastHalfLife({
300492
+ issuedAtMs: input.row.issuedAt.epochMilliseconds,
300493
+ expiresAtMs: input.row.expiresAt.epochMilliseconds,
300494
+ nowMs
300495
+ })) return void 0;
300496
+ const newIssuedAt = yield* require_Schema$1.now;
300497
+ const newExpiresAt = require_Schema$1.add(newIssuedAt, { milliseconds: require_Schema$1.toMillis(DEFAULT_SESSION_TTL) });
300498
+ const newClaims = {
300499
+ ...input.claims,
300500
+ iat: newIssuedAt.epochMilliseconds,
300501
+ exp: newExpiresAt.epochMilliseconds
300502
+ };
300503
+ const newEncodedPayload = base64UrlEncode(JSON.stringify(newClaims));
300504
+ const newSignature = signPayload(newEncodedPayload, signingSecret);
300505
+ if (!(yield* authSessions.extendExpiry({
300506
+ sessionId: input.claims.sid,
300507
+ expiresAt: newExpiresAt
300508
+ }).pipe(require_Schema$1.orElseSucceed(() => false)))) return void 0;
300509
+ return {
300510
+ token: `${newEncodedPayload}.${newSignature}`,
300511
+ expiresAt: newExpiresAt
300512
+ };
300513
+ });
300141
300514
  const verify = (token) => require_Schema$1.gen(function* () {
300142
300515
  const [encodedPayload, signature] = token.split(".");
300143
300516
  if (!encodedPayload || !signature) return yield* new SessionCredentialError({ message: "Malformed session token." });
@@ -300152,14 +300525,19 @@ const makeSessionCredentialService = require_Schema$1.gen(function* () {
300152
300525
  if (require_Schema$1.isSome(row)) {
300153
300526
  if (row.value.revokedAt !== null) return yield* new SessionCredentialError({ message: "Session token revoked." });
300154
300527
  } else if (!(statelessCookieSessions && claims.method === "browser-session-cookie")) return yield* new SessionCredentialError({ message: "Unknown session token." });
300528
+ const renewed = require_Schema$1.isSome(row) ? yield* renewIfPastHalfLife({
300529
+ claims,
300530
+ row: row.value
300531
+ }) : void 0;
300155
300532
  return {
300156
300533
  sessionId: claims.sid,
300157
300534
  token,
300158
300535
  method: claims.method,
300159
300536
  client: require_Schema$1.isSome(row) ? toClientMetadata(row.value.client) : createDefaultClientMetadata(),
300160
- expiresAt: require_Schema$1.makeUnsafe(claims.exp),
300537
+ expiresAt: renewed?.expiresAt ?? require_Schema$1.makeUnsafe(claims.exp),
300161
300538
  subject: claims.sub,
300162
- role: claims.role
300539
+ role: claims.role,
300540
+ ...renewed ? { renewed } : {}
300163
300541
  };
300164
300542
  }).pipe(require_Schema$1.mapError((cause) => cause instanceof SessionCredentialError ? cause : new SessionCredentialError({
300165
300543
  message: "Failed to verify session credential.",
@@ -300652,7 +301030,10 @@ const makeServerAuth = require_Schema$1.gen(function* () {
300652
301030
  subject: session.subject,
300653
301031
  method: session.method,
300654
301032
  role: session.role,
300655
- ...session.expiresAt ? { expiresAt: session.expiresAt } : {}
301033
+ ...session.renewed ? {
301034
+ expiresAt: session.renewed.expiresAt,
301035
+ renewedSessionToken: session.renewed.token
301036
+ } : session.expiresAt ? { expiresAt: session.expiresAt } : {}
300656
301037
  })), require_Schema$1.mapError((cause) => new AuthError({
300657
301038
  message: "Unauthorized request.",
300658
301039
  status: 401,
@@ -300687,6 +301068,7 @@ const makeServerAuth = require_Schema$1.gen(function* () {
300687
301068
  role: session.role,
300688
301069
  sessionMethod: session.method,
300689
301070
  ...session.expiresAt ? { expiresAt: require_Schema$1.toUtc(session.expiresAt) } : {},
301071
+ ...session.renewedSessionToken ? { renewedSessionToken: session.renewedSessionToken } : {},
300690
301072
  cmmdIdentity
300691
301073
  })))), require_Schema$1.tapError((e) => {
300692
301074
  if (e._tag === "AuthError" && (e.status ?? 500) < 500) return require_Schema$1.void_;
@@ -319052,7 +319434,7 @@ function makeProjectLeadControllerClientLive(options = {}) {
319052
319434
  const sql = yield* require_SqlError.SqlClient;
319053
319435
  const fetchFn = options.fetchFn ?? fetch;
319054
319436
  const baseUrl = options.baseUrl ?? resolveCommandAppUrl();
319055
- const resolveStoredJwt = (forceRefresh) => require_Schema$1.gen(function* () {
319437
+ const resolveStoredJwt = (forceRefresh, staleJwt) => require_Schema$1.gen(function* () {
319056
319438
  const owner = yield* readMostRecentOwnerCommandAuthSession.pipe(require_Schema$1.provideService(require_SqlError.SqlClient, sql), require_Schema$1.mapError((cause) => new ProjectLeadControllerClientError({
319057
319439
  kind: "unavailable",
319058
319440
  operation: "resolve-owner-session",
@@ -319066,7 +319448,8 @@ function makeProjectLeadControllerClientLive(options = {}) {
319066
319448
  sessionId: owner.sessionId,
319067
319449
  subject: owner.subject,
319068
319450
  logPrefix: "project-lead-scheduler",
319069
- forceRefresh
319451
+ forceRefresh,
319452
+ staleAccessToken: staleJwt
319070
319453
  }).pipe(require_Schema$1.provideService(require_SqlError.SqlClient, sql));
319071
319454
  if (!jwt) return yield* new ProjectLeadControllerClientError({
319072
319455
  kind: "unauthenticated",
@@ -319109,7 +319492,7 @@ function makeProjectLeadControllerClientLive(options = {}) {
319109
319492
  }));
319110
319493
  if (first._tag === "Success") return first.success;
319111
319494
  if (first.failure.kind !== "unauthenticated") return yield* first.failure;
319112
- const refreshed = yield* resolveJwt(true);
319495
+ const refreshed = yield* resolveJwt(true, jwt);
319113
319496
  return yield* requestAttempt({
319114
319497
  ...input,
319115
319498
  jwt: refreshed