@cmmd-center/forge 0.13.12 → 0.13.14
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/dist/bin.cjs +543 -75
- package/dist/bin.mjs +543 -75
- package/dist/client/assets/{CanvasPanel-BjHxCGlb.js → CanvasPanel-RpD9Eh_S.js} +2 -2
- package/dist/client/assets/{CompositeItem-D4LER3__.js → CompositeItem-DEIBUHJR.js} +2 -2
- package/dist/client/assets/{DiffPanel-D3btZGjW.js → DiffPanel-P3t5Fq1q.js} +2 -2
- package/dist/client/assets/{DiffPanel.logic-DWcwE9Wj.js → DiffPanel.logic-BXQ1C0-e.js} +2 -2
- package/dist/client/assets/{DiffPanelShell-oXHh--ae.js → DiffPanelShell-3EpWEr7p.js} +103 -103
- package/dist/client/assets/{DiffWorkerPoolProvider-R9YPaaNy.js → DiffWorkerPoolProvider-3_hkcEDq.js} +2 -2
- package/dist/client/assets/{PullRequestCodePanel-DTCc2TBl.js → PullRequestCodePanel-B2T6MPcE.js} +2 -2
- package/dist/client/assets/{Virtualizer-arKGxq_z.js → Virtualizer-DCt36RGD.js} +2 -2
- package/dist/client/assets/{canvasStateStore-B5zd1u9r.js → canvasStateStore-NrAxDqvM.js} +2 -2
- package/dist/client/assets/{cmmdAuthRedirect-DeFAIQpj.js → cmmdAuthRedirect-BahlX0gF.js} +2 -2
- package/dist/client/assets/index-C3pC5t1K.css +1 -0
- package/dist/client/assets/index-DvwXD11B.js +154 -0
- package/dist/client/assets/{popover-DM7IA0zJ.js → popover-Z8Tvl-OD.js} +2 -2
- package/dist/client/assets/src-BU7yHzO9.js +3 -0
- package/dist/client/assets/{toggle-DVtS0BJJ.js → toggle-CWYdWYBn.js} +2 -2
- package/dist/client/index.html +11 -11
- package/dist/forge-build.json +1 -1
- package/package.json +1 -1
- package/dist/client/assets/index-Bn-ASQRG.css +0 -1
- package/dist/client/assets/index-C7YKDHIe.js +0 -154
- package/dist/client/assets/src-LDQtPKQt.js +0 -3
package/dist/bin.mjs
CHANGED
|
@@ -17358,7 +17358,8 @@ const ORCHESTRATION_WS_METHODS = {
|
|
|
17358
17358
|
getArchivedShellSnapshot: "orchestration.getArchivedShellSnapshot",
|
|
17359
17359
|
subscribeShell: "orchestration.subscribeShell",
|
|
17360
17360
|
subscribeThread: "orchestration.subscribeThread",
|
|
17361
|
-
searchThreads: "orchestration.searchThreads"
|
|
17361
|
+
searchThreads: "orchestration.searchThreads",
|
|
17362
|
+
getThreadDetailPage: "orchestration.getThreadDetailPage"
|
|
17362
17363
|
};
|
|
17363
17364
|
const ProviderKind = Literals([
|
|
17364
17365
|
"codex",
|
|
@@ -17878,6 +17879,21 @@ Struct({
|
|
|
17878
17879
|
turnLimit: optionalKey(PositiveInt),
|
|
17879
17880
|
beforeCursor: optionalKey(TrimmedNonEmptyString)
|
|
17880
17881
|
});
|
|
17882
|
+
/**
|
|
17883
|
+
* Reads one windowed page of a thread's detail directly over the RPC socket.
|
|
17884
|
+
*
|
|
17885
|
+
* The HTTP snapshot route and `subscribeThread`'s fallback snapshot both take
|
|
17886
|
+
* the same `turnLimit`/`beforeCursor` window, but neither can serve "load
|
|
17887
|
+
* earlier" on a connection with no HTTP endpoint: the fallback snapshot only
|
|
17888
|
+
* fires once, on subscribe. This method exists for exactly that case (a
|
|
17889
|
+
* relay-only connection), gated on the server's `threadDetailPageRpc`
|
|
17890
|
+
* capability so older servers are never sent a method they do not know.
|
|
17891
|
+
*/
|
|
17892
|
+
const OrchestrationGetThreadDetailPageInput = Struct({
|
|
17893
|
+
threadId: ThreadId,
|
|
17894
|
+
turnLimit: optionalKey(PositiveInt),
|
|
17895
|
+
beforeCursor: optionalKey(TrimmedNonEmptyString)
|
|
17896
|
+
});
|
|
17881
17897
|
const OrchestrationThreadDetailSnapshot = Struct({
|
|
17882
17898
|
snapshotSequence: NonNegativeInt,
|
|
17883
17899
|
thread: OrchestrationThread,
|
|
@@ -18842,6 +18858,10 @@ const OrchestrationRpcSchemas = {
|
|
|
18842
18858
|
searchThreads: {
|
|
18843
18859
|
input: OrchestrationSearchThreadsInput,
|
|
18844
18860
|
output: OrchestrationSearchThreadsResult
|
|
18861
|
+
},
|
|
18862
|
+
getThreadDetailPage: {
|
|
18863
|
+
input: OrchestrationGetThreadDetailPageInput,
|
|
18864
|
+
output: OrchestrationThreadDetailSnapshot
|
|
18845
18865
|
}
|
|
18846
18866
|
};
|
|
18847
18867
|
var OrchestrationGetSnapshotError = class extends TaggedErrorClass()("OrchestrationGetSnapshotError", {
|
|
@@ -22812,6 +22832,7 @@ const ServerConfig$1 = Struct({
|
|
|
22812
22832
|
shellRevealInFileManagerKind: optionalKey(FileManagerRevealKind),
|
|
22813
22833
|
threadResumeCompletionMarker: optionalKey(Boolean$1),
|
|
22814
22834
|
threadSnapshotPagination: optionalKey(Boolean$1),
|
|
22835
|
+
threadDetailPageRpc: optionalKey(Boolean$1),
|
|
22815
22836
|
observability: ServerObservability,
|
|
22816
22837
|
settings: ServerSettings
|
|
22817
22838
|
});
|
|
@@ -26678,6 +26699,17 @@ const WsOrchestrationSearchThreadsRpc = make$30(ORCHESTRATION_WS_METHODS.searchT
|
|
|
26678
26699
|
error: OrchestrationSearchThreadsError
|
|
26679
26700
|
});
|
|
26680
26701
|
/**
|
|
26702
|
+
* The socket-only path for "load earlier turns": a relay-only connection has
|
|
26703
|
+
* no HTTP endpoint, so this reads the same windowed page the HTTP snapshot
|
|
26704
|
+
* route and `subscribeThread`'s fallback snapshot serve, over the RPC socket
|
|
26705
|
+
* instead.
|
|
26706
|
+
*/
|
|
26707
|
+
const WsOrchestrationGetThreadDetailPageRpc = make$30(ORCHESTRATION_WS_METHODS.getThreadDetailPage, {
|
|
26708
|
+
payload: OrchestrationRpcSchemas.getThreadDetailPage.input,
|
|
26709
|
+
success: OrchestrationRpcSchemas.getThreadDetailPage.output,
|
|
26710
|
+
error: OrchestrationGetSnapshotError
|
|
26711
|
+
});
|
|
26712
|
+
/**
|
|
26681
26713
|
* Resolve `owner/repo` to the repository the caller is allowed to clone.
|
|
26682
26714
|
*
|
|
26683
26715
|
* Both of these run against the same CMMD-brokered GitHub credential the web
|
|
@@ -26790,7 +26822,7 @@ const WsRpcGroup = make$29(WsServerGetConfigRpc, WsServerRefreshProvidersRpc, Ws
|
|
|
26790
26822
|
payload: ProviderUploadFeedbackInput,
|
|
26791
26823
|
success: ProviderUploadFeedbackResult,
|
|
26792
26824
|
error: ProviderUploadFeedbackError
|
|
26793
|
-
}), WsOrchestrationSearchThreadsRpc, WsSourceControlLookupRepositoryRpc, WsSourceControlCloneRepositoryRpc);
|
|
26825
|
+
}), WsOrchestrationSearchThreadsRpc, WsSourceControlLookupRepositoryRpc, WsSourceControlCloneRepositoryRpc, WsOrchestrationGetThreadDetailPageRpc);
|
|
26794
26826
|
const RelayWsRpcGroup = WsRpcGroup.middleware(RelayAuthorizationMiddleware).middleware(RelayDeliveryMiddleware);
|
|
26795
26827
|
/**
|
|
26796
26828
|
* The direct-WebSocket mount of the same group, with per-method scope
|
|
@@ -26863,6 +26895,32 @@ function encodeCmmdRuntimeToolActivityPayload(kind, payload) {
|
|
|
26863
26895
|
}
|
|
26864
26896
|
};
|
|
26865
26897
|
}
|
|
26898
|
+
const WORKTREE_LAUNCH_ACTIVITY_KINDS = new Set([
|
|
26899
|
+
"worktree.requested",
|
|
26900
|
+
"worktree.stage",
|
|
26901
|
+
"worktree.output",
|
|
26902
|
+
"worktree.created",
|
|
26903
|
+
"worktree.failed",
|
|
26904
|
+
"setup-script.requested",
|
|
26905
|
+
"setup-script.started",
|
|
26906
|
+
"setup-script.skipped",
|
|
26907
|
+
"setup-script.failed",
|
|
26908
|
+
"launch.agent-starting",
|
|
26909
|
+
"launch.cancelled"
|
|
26910
|
+
]);
|
|
26911
|
+
function encodeCmmdRuntimeWorktreeLaunchActivityPayload(kind, payload) {
|
|
26912
|
+
return {
|
|
26913
|
+
...isRecord$23(payload) ? payload : { sourcePayload: payload },
|
|
26914
|
+
forgeWorktreeLaunchActivity: {
|
|
26915
|
+
schemaVersion: 1,
|
|
26916
|
+
source: "native",
|
|
26917
|
+
kind
|
|
26918
|
+
}
|
|
26919
|
+
};
|
|
26920
|
+
}
|
|
26921
|
+
function isCmmdRuntimeWorktreeLaunchActivityKind(kind) {
|
|
26922
|
+
return WORKTREE_LAUNCH_ACTIVITY_KINDS.has(kind);
|
|
26923
|
+
}
|
|
26866
26924
|
//#endregion
|
|
26867
26925
|
//#region ../../packages/contracts/src/cmmdProjectPatch.ts
|
|
26868
26926
|
const CMMD_PROJECT_PATCH_FIELDS = {
|
|
@@ -31965,6 +32023,11 @@ const RELAY_METHOD_POLICY_BASES = [
|
|
|
31965
32023
|
requiredScope: "thread.read",
|
|
31966
32024
|
deliveryClass: "subscription"
|
|
31967
32025
|
},
|
|
32026
|
+
{
|
|
32027
|
+
method: ORCHESTRATION_WS_METHODS.getThreadDetailPage,
|
|
32028
|
+
requiredScope: "thread.read",
|
|
32029
|
+
deliveryClass: "read"
|
|
32030
|
+
},
|
|
31968
32031
|
{
|
|
31969
32032
|
method: WS_METHODS.assetsCreateUrl,
|
|
31970
32033
|
requiredScope: "thread.read",
|
|
@@ -66022,7 +66085,7 @@ function normalizeNumberish(value) {
|
|
|
66022
66085
|
}
|
|
66023
66086
|
//#endregion
|
|
66024
66087
|
//#region package.json
|
|
66025
|
-
var version$1 = "0.13.
|
|
66088
|
+
var version$1 = "0.13.14";
|
|
66026
66089
|
//#endregion
|
|
66027
66090
|
//#region src/sentry.ts
|
|
66028
66091
|
const SERVER_APP_NAME = "forge-server";
|
|
@@ -66424,6 +66487,129 @@ const respondToAuthError = (error) => gen(function* () {
|
|
|
66424
66487
|
}));
|
|
66425
66488
|
return response;
|
|
66426
66489
|
});
|
|
66490
|
+
//#endregion
|
|
66491
|
+
//#region src/http/rateLimit.ts
|
|
66492
|
+
/**
|
|
66493
|
+
* Per-client, per-route-class rate limiting for Forge's own HTTP surface.
|
|
66494
|
+
*
|
|
66495
|
+
* Scoped to the routes a broken or looping client can hammer before it ever
|
|
66496
|
+
* has an authenticated session — the CMMD device-code poll, the bootstrap and
|
|
66497
|
+
* pairing-token exchanges — plus the authenticated /api/cmmd proxy, which
|
|
66498
|
+
* every desktop UI interaction runs through. None of these had any limit
|
|
66499
|
+
* before FOR-36706: a stuck pairing loop or a poll that never saw `slow_down`
|
|
66500
|
+
* could retry as fast as the network allowed, with nothing to stop it and
|
|
66501
|
+
* nothing telling the client to back off.
|
|
66502
|
+
*
|
|
66503
|
+
* Defaults are generous on purpose. This exists to bound a runaway client,
|
|
66504
|
+
* not to throttle a working one — see `defaultRateLimitPolicies` for the
|
|
66505
|
+
* reasoning behind each number and how to override it.
|
|
66506
|
+
*
|
|
66507
|
+
* Deliberately plain module state, not an Effect service: every route this
|
|
66508
|
+
* guards is a synchronous decision (count, compare, maybe reset a window),
|
|
66509
|
+
* and threading a new Context requirement through the whole server's layer
|
|
66510
|
+
* graph for that would touch every test file that stands up any of these
|
|
66511
|
+
* routes. `cmmdJwtResolver.ts`'s in-flight-refresh map is the same call.
|
|
66512
|
+
*/
|
|
66513
|
+
function envInt(name, fallback) {
|
|
66514
|
+
const raw = process.env[name];
|
|
66515
|
+
if (!raw) return fallback;
|
|
66516
|
+
const parsed = Number(raw);
|
|
66517
|
+
return Number.isFinite(parsed) && parsed > 0 ? Math.floor(parsed) : fallback;
|
|
66518
|
+
}
|
|
66519
|
+
/**
|
|
66520
|
+
* One reasoning line per class, so a future change to a default has to
|
|
66521
|
+
* confront why the old one was picked:
|
|
66522
|
+
*
|
|
66523
|
+
* - `auth-device-poll` (`/api/auth/cmmd-oauth/device` and `/token`): RFC 8628
|
|
66524
|
+
* polls every `interval` seconds, 5 by default, so a well-behaved client
|
|
66525
|
+
* sits under 12/min. 120/min leaves room for several concurrent sign-in
|
|
66526
|
+
* attempts and CMMD's own `slow_down` handling without ever tripping on
|
|
66527
|
+
* normal use, while still bounding a client that ignores `slow_down`.
|
|
66528
|
+
* - `auth-bootstrap` (`/api/auth/bootstrap*`, the bearer bootstrap routes):
|
|
66529
|
+
* a working client bootstraps once per sign-in. 30/min is generous headroom
|
|
66530
|
+
* for retries after a network blip, not a budget anyone should approach.
|
|
66531
|
+
* - `auth-token-exchange` (`/oauth/token`, the vendored pairing exchange):
|
|
66532
|
+
* same shape as bootstrap — one pairing attempt, occasionally retried.
|
|
66533
|
+
* - `cmmd-proxy` (every `/api/cmmd/*` route): this is the one real desktop
|
|
66534
|
+
* traffic actually leans on — task lists, project browsing, comments, all
|
|
66535
|
+
* day. 1200/min (20/sec) is set high enough that no normal session should
|
|
66536
|
+
* ever see it; it exists for a retry loop or a runaway resync, not for
|
|
66537
|
+
* ordinary use.
|
|
66538
|
+
*/
|
|
66539
|
+
function buildPoliciesFromEnv() {
|
|
66540
|
+
return {
|
|
66541
|
+
"auth-device-poll": {
|
|
66542
|
+
maxRequests: envInt("FORGE_RATE_LIMIT_AUTH_DEVICE_POLL_MAX", 120),
|
|
66543
|
+
windowMs: envInt("FORGE_RATE_LIMIT_AUTH_DEVICE_POLL_WINDOW_SECONDS", 60) * 1e3
|
|
66544
|
+
},
|
|
66545
|
+
"auth-bootstrap": {
|
|
66546
|
+
maxRequests: envInt("FORGE_RATE_LIMIT_AUTH_BOOTSTRAP_MAX", 30),
|
|
66547
|
+
windowMs: envInt("FORGE_RATE_LIMIT_AUTH_BOOTSTRAP_WINDOW_SECONDS", 60) * 1e3
|
|
66548
|
+
},
|
|
66549
|
+
"auth-token-exchange": {
|
|
66550
|
+
maxRequests: envInt("FORGE_RATE_LIMIT_AUTH_TOKEN_EXCHANGE_MAX", 30),
|
|
66551
|
+
windowMs: envInt("FORGE_RATE_LIMIT_AUTH_TOKEN_EXCHANGE_WINDOW_SECONDS", 60) * 1e3
|
|
66552
|
+
},
|
|
66553
|
+
"cmmd-proxy": {
|
|
66554
|
+
maxRequests: envInt("FORGE_RATE_LIMIT_CMMD_PROXY_MAX", 1200),
|
|
66555
|
+
windowMs: envInt("FORGE_RATE_LIMIT_CMMD_PROXY_WINDOW_SECONDS", 60) * 1e3
|
|
66556
|
+
}
|
|
66557
|
+
};
|
|
66558
|
+
}
|
|
66559
|
+
let policies = buildPoliciesFromEnv();
|
|
66560
|
+
const windows = /* @__PURE__ */ new Map();
|
|
66561
|
+
function windowKey(routeClass, clientKey) {
|
|
66562
|
+
return `${routeClass}\0${clientKey}`;
|
|
66563
|
+
}
|
|
66564
|
+
/**
|
|
66565
|
+
* Fixed-window counting: cheap, and precise enough for a loop-guard rather
|
|
66566
|
+
* than a security boundary. The one imprecision this trades away — up to 2x
|
|
66567
|
+
* the configured rate right at a window boundary — matters for an attacker
|
|
66568
|
+
* budgeting requests against the limiter's clock, not for the runaway
|
|
66569
|
+
* clients this exists to catch.
|
|
66570
|
+
*/
|
|
66571
|
+
function checkHttpRateLimit(input) {
|
|
66572
|
+
const policy = policies[input.routeClass];
|
|
66573
|
+
const nowMs = Date.now();
|
|
66574
|
+
const key = windowKey(input.routeClass, input.clientKey);
|
|
66575
|
+
const existing = windows.get(key);
|
|
66576
|
+
const windowIsLive = existing !== void 0 && nowMs - existing.windowStartMs < policy.windowMs;
|
|
66577
|
+
const windowStartMs = windowIsLive ? existing.windowStartMs : nowMs;
|
|
66578
|
+
const count = (windowIsLive ? existing.count : 0) + 1;
|
|
66579
|
+
windows.set(key, {
|
|
66580
|
+
windowStartMs,
|
|
66581
|
+
count
|
|
66582
|
+
});
|
|
66583
|
+
return {
|
|
66584
|
+
allowed: count <= policy.maxRequests,
|
|
66585
|
+
retryAfterSeconds: Math.max(0, Math.ceil((windowStartMs + policy.windowMs - nowMs) / 1e3))
|
|
66586
|
+
};
|
|
66587
|
+
}
|
|
66588
|
+
/**
|
|
66589
|
+
* Checks the limiter for one request and, when the caller is over budget,
|
|
66590
|
+
* returns the 429 to send instead of running the route — `null` means
|
|
66591
|
+
* proceed. Every route this guards is expected to call this first and return
|
|
66592
|
+
* early on a non-null result, before doing any other work.
|
|
66593
|
+
*/
|
|
66594
|
+
function rateLimitOrRespond(input) {
|
|
66595
|
+
const decision = checkHttpRateLimit(input);
|
|
66596
|
+
if (decision.allowed) return null;
|
|
66597
|
+
return jsonUnsafe({ error: "Too many requests. Try again later." }, {
|
|
66598
|
+
status: 429,
|
|
66599
|
+
headers: { "retry-after": String(decision.retryAfterSeconds) }
|
|
66600
|
+
});
|
|
66601
|
+
}
|
|
66602
|
+
/**
|
|
66603
|
+
* The client identity for a route with no session yet — the device-code
|
|
66604
|
+
* poll, bootstrap and pairing-token exchange all run before Forge has issued
|
|
66605
|
+
* one. IP address is the only stable-enough signal available at that point;
|
|
66606
|
+
* the authenticated `/api/cmmd` proxy keys on the session instead (see its
|
|
66607
|
+
* call site), so one desktop and one phone sharing a NAT never share a
|
|
66608
|
+
* budget there.
|
|
66609
|
+
*/
|
|
66610
|
+
function clientKeyFromRequest(request) {
|
|
66611
|
+
return normalizeIpAddress(getOrNull(request.remoteAddress)) ?? "unknown";
|
|
66612
|
+
}
|
|
66427
66613
|
const OAUTH_ACCESS_TOKEN_TYPE = "urn:ietf:params:oauth:token-type:access_token";
|
|
66428
66614
|
/**
|
|
66429
66615
|
* The one exchange this endpoint performs, or the reason it will not.
|
|
@@ -68271,6 +68457,11 @@ function commandOAuthProxyStatus(path, status, body) {
|
|
|
68271
68457
|
}
|
|
68272
68458
|
const authBootstrapRouteLayer = add$1("POST", "/api/auth/bootstrap", gen(function* () {
|
|
68273
68459
|
const request = yield* HttpServerRequest;
|
|
68460
|
+
const limited = rateLimitOrRespond({
|
|
68461
|
+
routeClass: "auth-bootstrap",
|
|
68462
|
+
clientKey: clientKeyFromRequest(request)
|
|
68463
|
+
});
|
|
68464
|
+
if (limited) return limited;
|
|
68274
68465
|
const serverAuth = yield* ServerAuth;
|
|
68275
68466
|
const sessions = yield* SessionCredentialService;
|
|
68276
68467
|
const payload = yield* schemaBodyJson(AuthBootstrapInput).pipe(mapError((cause) => new AuthError({
|
|
@@ -68288,6 +68479,11 @@ const authBootstrapRouteLayer = add$1("POST", "/api/auth/bootstrap", gen(functio
|
|
|
68288
68479
|
}).pipe(catchTag("AuthError", (error) => respondToAuthError(error))));
|
|
68289
68480
|
const authCommandBootstrapRouteLayer = add$1("POST", "/api/auth/bootstrap/command", gen(function* () {
|
|
68290
68481
|
const request = yield* HttpServerRequest;
|
|
68482
|
+
const limited = rateLimitOrRespond({
|
|
68483
|
+
routeClass: "auth-bootstrap",
|
|
68484
|
+
clientKey: clientKeyFromRequest(request)
|
|
68485
|
+
});
|
|
68486
|
+
if (limited) return limited;
|
|
68291
68487
|
const serverAuth = yield* ServerAuth;
|
|
68292
68488
|
const sessions = yield* SessionCredentialService;
|
|
68293
68489
|
if (serverAuth.isForgeCloudShell?.() === true) return yield* new AuthError({
|
|
@@ -68336,6 +68532,11 @@ const authCommandBootstrapRouteLayer = add$1("POST", "/api/auth/bootstrap/comman
|
|
|
68336
68532
|
}).pipe(catchTag("AuthError", (error) => respondToAuthError(error))));
|
|
68337
68533
|
const authCommandBearerBootstrapRouteLayer = add$1("POST", "/api/auth/bootstrap/command/bearer", gen(function* () {
|
|
68338
68534
|
const request = yield* HttpServerRequest;
|
|
68535
|
+
const limited = rateLimitOrRespond({
|
|
68536
|
+
routeClass: "auth-bootstrap",
|
|
68537
|
+
clientKey: clientKeyFromRequest(request)
|
|
68538
|
+
});
|
|
68539
|
+
if (limited) return limited;
|
|
68339
68540
|
const serverAuth = yield* ServerAuth;
|
|
68340
68541
|
const sessions = yield* SessionCredentialService;
|
|
68341
68542
|
if (serverAuth.isForgeCloudShell?.() === true) return yield* new AuthError({
|
|
@@ -68384,6 +68585,11 @@ const authCommandBearerBootstrapRouteLayer = add$1("POST", "/api/auth/bootstrap/
|
|
|
68384
68585
|
}, { status: 200 });
|
|
68385
68586
|
}).pipe(catchTag("AuthError", (error) => respondToAuthError(error))));
|
|
68386
68587
|
const authCmmdOAuthDeviceRouteLayer = add$1("POST", "/api/auth/cmmd-oauth/device", gen(function* () {
|
|
68588
|
+
const limited = rateLimitOrRespond({
|
|
68589
|
+
routeClass: "auth-device-poll",
|
|
68590
|
+
clientKey: clientKeyFromRequest(yield* HttpServerRequest)
|
|
68591
|
+
});
|
|
68592
|
+
if (limited) return limited;
|
|
68387
68593
|
const payload = yield* schemaBodyJson(CommandOAuthDeviceInput).pipe(mapError((cause) => new AuthError({
|
|
68388
68594
|
message: "Invalid CMMD OAuth device payload.",
|
|
68389
68595
|
status: 400,
|
|
@@ -68395,7 +68601,17 @@ const authCmmdOAuthDeviceRouteLayer = add$1("POST", "/api/auth/cmmd-oauth/device
|
|
|
68395
68601
|
...payload.scope ? { scope: payload.scope } : {}
|
|
68396
68602
|
});
|
|
68397
68603
|
}).pipe(catchTag("AuthError", (error) => respondToAuthError(error))));
|
|
68604
|
+
/**
|
|
68605
|
+
* The device-code poll. Called every `interval` seconds by design (RFC
|
|
68606
|
+
* 8628), which is exactly why FOR-36706 puts a limiter on it: a client that
|
|
68607
|
+
* ignores `slow_down` — a bug, not a person — has no other backstop here.
|
|
68608
|
+
*/
|
|
68398
68609
|
const authCmmdOAuthTokenRouteLayer = add$1("POST", "/api/auth/cmmd-oauth/token", gen(function* () {
|
|
68610
|
+
const limited = rateLimitOrRespond({
|
|
68611
|
+
routeClass: "auth-device-poll",
|
|
68612
|
+
clientKey: clientKeyFromRequest(yield* HttpServerRequest)
|
|
68613
|
+
});
|
|
68614
|
+
if (limited) return limited;
|
|
68399
68615
|
const payload = yield* schemaBodyJson(CommandOAuthTokenInput).pipe(mapError((cause) => new AuthError({
|
|
68400
68616
|
message: "Invalid CMMD OAuth token payload.",
|
|
68401
68617
|
status: 400,
|
|
@@ -68690,6 +68906,11 @@ const authWebCallbackRouteLayer = add$1("GET", "/api/auth/callback", gen(functio
|
|
|
68690
68906
|
}).pipe(catchTag("AuthError", (error) => respondToAuthError(error))));
|
|
68691
68907
|
const authBearerBootstrapRouteLayer = add$1("POST", "/api/auth/bootstrap/bearer", gen(function* () {
|
|
68692
68908
|
const request = yield* HttpServerRequest;
|
|
68909
|
+
const limited = rateLimitOrRespond({
|
|
68910
|
+
routeClass: "auth-bootstrap",
|
|
68911
|
+
clientKey: clientKeyFromRequest(request)
|
|
68912
|
+
});
|
|
68913
|
+
if (limited) return limited;
|
|
68693
68914
|
const serverAuth = yield* ServerAuth;
|
|
68694
68915
|
const payload = yield* schemaBodyJson(AuthBootstrapInput).pipe(mapError((cause) => new AuthError({
|
|
68695
68916
|
message: "Invalid bootstrap payload.",
|
|
@@ -68721,6 +68942,11 @@ const authBearerBootstrapRouteLayer = add$1("POST", "/api/auth/bootstrap/bearer"
|
|
|
68721
68942
|
*/
|
|
68722
68943
|
const authOAuthTokenExchangeRouteLayer = add$1("POST", "/oauth/token", gen(function* () {
|
|
68723
68944
|
const request = yield* HttpServerRequest;
|
|
68945
|
+
const limited = rateLimitOrRespond({
|
|
68946
|
+
routeClass: "auth-token-exchange",
|
|
68947
|
+
clientKey: clientKeyFromRequest(request)
|
|
68948
|
+
});
|
|
68949
|
+
if (limited) return limited;
|
|
68724
68950
|
const serverAuth = yield* ServerAuth;
|
|
68725
68951
|
const payload = yield* schemaBodyUrlParams(OAuthTokenExchangeInput).pipe(mapError((cause) => new AuthError({
|
|
68726
68952
|
message: "Invalid token exchange payload.",
|
|
@@ -70923,9 +71149,12 @@ function trySubjectFallback(sessionId, subject, logPrefix) {
|
|
|
70923
71149
|
* Fail-closed: never returns a non-user / malformed Bearer. Callers must map
|
|
70924
71150
|
* null to CMMD_AUTH_REQUIRED and must not call CMMD upstream with a bad token.
|
|
70925
71151
|
*/
|
|
70926
|
-
function resolveCmmdJwtForSession({ sessionId, subject, logPrefix = "cmmd-auth", forceRefresh = false }) {
|
|
71152
|
+
function resolveCmmdJwtForSession({ sessionId, subject, logPrefix = "cmmd-auth", forceRefresh = false, staleAccessToken = null }) {
|
|
70927
71153
|
const cached = usableCmmdUserJwt(readCommandJwtForSession(sessionId));
|
|
70928
|
-
if (
|
|
71154
|
+
if (cached && !isJwtExpiredForSession(sessionId)) {
|
|
71155
|
+
if (!forceRefresh) return succeed(cached);
|
|
71156
|
+
if (staleAccessToken && cached !== staleAccessToken) return succeed(cached);
|
|
71157
|
+
}
|
|
70929
71158
|
const normalizedSubject = subject?.trim() || null;
|
|
70930
71159
|
if (!normalizedSubject && !forceRefresh) return succeed(null);
|
|
70931
71160
|
return gen(function* () {
|
|
@@ -71762,6 +71991,11 @@ function buildProxyRouteLayer(route) {
|
|
|
71762
71991
|
const handler = gen(function* () {
|
|
71763
71992
|
const req = yield* HttpServerRequest;
|
|
71764
71993
|
const session = yield* (yield* ServerAuth).authenticateHttpRequest(req);
|
|
71994
|
+
const limited = rateLimitOrRespond({
|
|
71995
|
+
routeClass: "cmmd-proxy",
|
|
71996
|
+
clientKey: session.sessionId
|
|
71997
|
+
});
|
|
71998
|
+
if (limited) return limited;
|
|
71765
71999
|
const pathParams = yield* params;
|
|
71766
72000
|
const cmmdPath = substitutePath(route.cmmdPath, pathParams);
|
|
71767
72001
|
if (!cmmdPath) return jsonUnsafe({ error: "Missing required path parameter." }, { status: 400 });
|
|
@@ -71821,6 +72055,11 @@ function buildProxyHandlerEffect(route) {
|
|
|
71821
72055
|
return gen(function* () {
|
|
71822
72056
|
const req = yield* HttpServerRequest;
|
|
71823
72057
|
const session = yield* (yield* ServerAuth).authenticateHttpRequest(req);
|
|
72058
|
+
const limited = rateLimitOrRespond({
|
|
72059
|
+
routeClass: "cmmd-proxy",
|
|
72060
|
+
clientKey: session.sessionId
|
|
72061
|
+
});
|
|
72062
|
+
if (limited) return limited;
|
|
71824
72063
|
if (!readCommandAccessTokenForSession(session.sessionId)) return yield* respondCmmdConnectionRequired(session.sessionId);
|
|
71825
72064
|
const parsed = yield* route.parse(req);
|
|
71826
72065
|
if (!parsed.ok) return jsonUnsafe(parsed.body, { status: parsed.status });
|
|
@@ -97647,6 +97886,26 @@ function serializeNativeToolActivity(activity) {
|
|
|
97647
97886
|
}
|
|
97648
97887
|
};
|
|
97649
97888
|
}
|
|
97889
|
+
function serializeWorktreeLaunchActivity(activity) {
|
|
97890
|
+
if (decodeWorktreeLaunchActivityKind(activity.kind) === null) return null;
|
|
97891
|
+
const summary = text(activity.summary, 4e3);
|
|
97892
|
+
if (summary === null) return null;
|
|
97893
|
+
return {
|
|
97894
|
+
sourceSequence: null,
|
|
97895
|
+
event: {
|
|
97896
|
+
eventId: `activity:${activity.id}`,
|
|
97897
|
+
occurredAt: canonicalRuntimeEventTimestamp(activity.createdAt),
|
|
97898
|
+
...eventTurn(activity.turnId),
|
|
97899
|
+
kind: "tool_call",
|
|
97900
|
+
summary,
|
|
97901
|
+
status: "completed",
|
|
97902
|
+
payload: encodeCmmdRuntimeWorktreeLaunchActivityPayload(activity.kind, activity.payload)
|
|
97903
|
+
}
|
|
97904
|
+
};
|
|
97905
|
+
}
|
|
97906
|
+
function decodeWorktreeLaunchActivityKind(kind) {
|
|
97907
|
+
return isCmmdRuntimeWorktreeLaunchActivityKind(kind) ? kind : null;
|
|
97908
|
+
}
|
|
97650
97909
|
function isReplicatedFailureActivity(activity) {
|
|
97651
97910
|
return activity.kind === "runtime.error" || activity.kind.startsWith("provider.") && activity.kind.endsWith(".failed");
|
|
97652
97911
|
}
|
|
@@ -98027,12 +98286,17 @@ function serializeRuntimeThreadEvents(thread) {
|
|
|
98027
98286
|
const event = serializeNativeToolActivity(activity);
|
|
98028
98287
|
return event === null ? [] : [event];
|
|
98029
98288
|
});
|
|
98289
|
+
const worktreeLaunchActivities = thread.activities.flatMap((activity) => {
|
|
98290
|
+
const event = serializeWorktreeLaunchActivity(activity);
|
|
98291
|
+
return event === null ? [] : [event];
|
|
98292
|
+
});
|
|
98030
98293
|
const recordedTurns = serializeRecordedTurns(thread);
|
|
98031
98294
|
const textGenerations = serializeTextGenerations(thread);
|
|
98032
98295
|
return [
|
|
98033
98296
|
...messages,
|
|
98034
98297
|
...replicaActivities,
|
|
98035
98298
|
...toolActivities,
|
|
98299
|
+
...worktreeLaunchActivities,
|
|
98036
98300
|
...failureActivities,
|
|
98037
98301
|
...recordedTurns,
|
|
98038
98302
|
...textGenerations
|
|
@@ -106186,6 +106450,39 @@ function loadInitialShellSnapshot(input) {
|
|
|
106186
106450
|
return yield* input.projectionSnapshotQuery.getShellSnapshot();
|
|
106187
106451
|
});
|
|
106188
106452
|
}
|
|
106453
|
+
/**
|
|
106454
|
+
* The thread snapshot a subscription falls back to, windowed when the
|
|
106455
|
+
* subscriber asked for a window. A missing thread is a failure rather than an
|
|
106456
|
+
* empty snapshot: the subscriber asked about a thread this server does not
|
|
106457
|
+
* have, and saying so is the only honest answer.
|
|
106458
|
+
*
|
|
106459
|
+
* Also the read behind `getThreadDetailPage`, the socket RPC a relay-only
|
|
106460
|
+
* connection uses for "load earlier turns" in place of the HTTP snapshot
|
|
106461
|
+
* route: same window, same pure `windowThreadDetail`, so the two reads cannot
|
|
106462
|
+
* disagree about what a page is.
|
|
106463
|
+
*/
|
|
106464
|
+
function loadThreadDetailSnapshot(input) {
|
|
106465
|
+
return gen(function* () {
|
|
106466
|
+
const [threadDetail, snapshotSequence] = yield* all([input.projectionSnapshotQuery.getThreadDetailById(input.threadId).pipe(mapError((cause) => new OrchestrationGetSnapshotError({
|
|
106467
|
+
message: `Failed to load thread ${input.threadId}`,
|
|
106468
|
+
cause
|
|
106469
|
+
}))), input.orchestrationEngine.getReadModel().pipe(map$3((readModel) => readModel.snapshotSequence))]);
|
|
106470
|
+
if (isNone(threadDetail)) return yield* new OrchestrationGetSnapshotError({
|
|
106471
|
+
message: `Thread ${input.threadId} was not found`,
|
|
106472
|
+
cause: input.threadId
|
|
106473
|
+
});
|
|
106474
|
+
const windowed = windowThreadDetail({
|
|
106475
|
+
thread: threadDetail.value,
|
|
106476
|
+
window: input.window,
|
|
106477
|
+
snapshotSequence
|
|
106478
|
+
});
|
|
106479
|
+
return {
|
|
106480
|
+
snapshotSequence,
|
|
106481
|
+
thread: windowed.thread,
|
|
106482
|
+
...windowed.page === void 0 ? {} : { page: windowed.page }
|
|
106483
|
+
};
|
|
106484
|
+
});
|
|
106485
|
+
}
|
|
106189
106486
|
function makeSnapshotThenLiveStream(input) {
|
|
106190
106487
|
return unwrap$1(gen(function* () {
|
|
106191
106488
|
const liveEvents = yield* input.subscribeLiveEvents;
|
|
@@ -106411,32 +106708,6 @@ const makeWsRpcLayer = (currentSessionId, currentUserId) => WsRpcGroup.toLayer(g
|
|
|
106411
106708
|
}, normalizedCommand) : (normalizedCommand.type === "thread.turn.interrupt" ? interruptBootstrapTurnStart(orchestrationEngine, normalizedCommand) : orchestrationEngine.dispatch(normalizedCommand)).pipe(mapError((cause) => toDispatchCommandError(cause, "Failed to dispatch orchestration command")));
|
|
106412
106709
|
return startup.enqueueCommand(dispatchEffect).pipe(mapError((cause) => toDispatchCommandError(cause, "Failed to dispatch orchestration command")));
|
|
106413
106710
|
};
|
|
106414
|
-
/**
|
|
106415
|
-
* The thread snapshot a subscription falls back to, windowed when the
|
|
106416
|
-
* subscriber asked for a window. A missing thread is a failure rather
|
|
106417
|
-
* than an empty snapshot: the subscriber asked about a thread this server
|
|
106418
|
-
* does not have, and saying so is the only honest answer.
|
|
106419
|
-
*/
|
|
106420
|
-
const loadThreadDetailSnapshot = (threadId, turnLimit) => gen(function* () {
|
|
106421
|
-
const [threadDetail, snapshotSequence] = yield* all([projectionSnapshotQuery.getThreadDetailById(threadId).pipe(mapError((cause) => new OrchestrationGetSnapshotError({
|
|
106422
|
-
message: `Failed to load thread ${threadId}`,
|
|
106423
|
-
cause
|
|
106424
|
-
}))), orchestrationEngine.getReadModel().pipe(map$3((readModel) => readModel.snapshotSequence))]);
|
|
106425
|
-
if (isNone(threadDetail)) return yield* new OrchestrationGetSnapshotError({
|
|
106426
|
-
message: `Thread ${threadId} was not found`,
|
|
106427
|
-
cause: threadId
|
|
106428
|
-
});
|
|
106429
|
-
const windowed = windowThreadDetail({
|
|
106430
|
-
thread: threadDetail.value,
|
|
106431
|
-
window: turnLimit === void 0 ? {} : { turnLimit },
|
|
106432
|
-
snapshotSequence
|
|
106433
|
-
});
|
|
106434
|
-
return {
|
|
106435
|
-
snapshotSequence,
|
|
106436
|
-
thread: windowed.thread,
|
|
106437
|
-
...windowed.page === void 0 ? {} : { page: windowed.page }
|
|
106438
|
-
};
|
|
106439
|
-
});
|
|
106440
106711
|
const loadServerConfig = gen(function* () {
|
|
106441
106712
|
const keybindingsConfig = yield* keybindings.loadConfigState;
|
|
106442
106713
|
const providers = yield* providerRegistry.getProviders;
|
|
@@ -106460,6 +106731,7 @@ const makeWsRpcLayer = (currentSessionId, currentUserId) => WsRpcGroup.toLayer(g
|
|
|
106460
106731
|
},
|
|
106461
106732
|
threadResumeCompletionMarker: true,
|
|
106462
106733
|
threadSnapshotPagination: true,
|
|
106734
|
+
threadDetailPageRpc: true,
|
|
106463
106735
|
observability: {
|
|
106464
106736
|
logsDirectoryPath: config.logsDir,
|
|
106465
106737
|
localTracingEnabled: true,
|
|
@@ -106560,12 +106832,26 @@ const makeWsRpcLayer = (currentSessionId, currentUserId) => WsRpcGroup.toLayer(g
|
|
|
106560
106832
|
[ORCHESTRATION_WS_METHODS.subscribeThread]: (input) => observeRpcStreamEffect(ORCHESTRATION_WS_METHODS.subscribeThread, succeed(makeThreadSubscriptionStream(input, {
|
|
106561
106833
|
subscribeLiveEvents: orchestrationEngine.subscribeDomainEvents().pipe(map$3((events) => events.pipe(filter$2(isThreadDetailEventFor(input.threadId))))),
|
|
106562
106834
|
currentSequence: orchestrationEngine.getReadModel().pipe(map$3((readModel) => readModel.snapshotSequence)),
|
|
106563
|
-
loadSnapshot: loadThreadDetailSnapshot(
|
|
106835
|
+
loadSnapshot: loadThreadDetailSnapshot({
|
|
106836
|
+
threadId: input.threadId,
|
|
106837
|
+
window: input.turnLimit === void 0 ? {} : { turnLimit: input.turnLimit },
|
|
106838
|
+
projectionSnapshotQuery,
|
|
106839
|
+
orchestrationEngine
|
|
106840
|
+
}),
|
|
106564
106841
|
replayEvents: (afterSequence) => orchestrationEngine.readEvents(afterSequence).pipe(filter$2(isThreadDetailEventFor(input.threadId)), mapError$1((cause) => new OrchestrationGetSnapshotError({
|
|
106565
106842
|
message: `Failed to replay thread ${input.threadId}`,
|
|
106566
106843
|
cause
|
|
106567
106844
|
})))
|
|
106568
106845
|
})), { "rpc.aggregate": "orchestration" }),
|
|
106846
|
+
[ORCHESTRATION_WS_METHODS.getThreadDetailPage]: (input) => observeRpcEffect(ORCHESTRATION_WS_METHODS.getThreadDetailPage, loadThreadDetailSnapshot({
|
|
106847
|
+
threadId: input.threadId,
|
|
106848
|
+
window: {
|
|
106849
|
+
...input.turnLimit === void 0 ? {} : { turnLimit: input.turnLimit },
|
|
106850
|
+
...input.beforeCursor === void 0 ? {} : { beforeCursor: input.beforeCursor }
|
|
106851
|
+
},
|
|
106852
|
+
projectionSnapshotQuery,
|
|
106853
|
+
orchestrationEngine
|
|
106854
|
+
}), { "rpc.aggregate": "orchestration" }),
|
|
106569
106855
|
[WS_METHODS.serverGetConfig]: (_input) => observeRpcEffect(WS_METHODS.serverGetConfig, loadServerConfig, { "rpc.aggregate": "server" }),
|
|
106570
106856
|
[WS_METHODS.serverRefreshProviders]: (_input) => observeRpcEffect(WS_METHODS.serverRefreshProviders, providerRegistry.refresh().pipe(map$3((providers) => ({ providers }))), { "rpc.aggregate": "server" }),
|
|
106571
106857
|
[WS_METHODS.serverStartProviderAuth]: (input) => observeRpcEffect(WS_METHODS.serverStartProviderAuth, gen(function* () {
|
|
@@ -212612,6 +212898,8 @@ const PUBLIC_PROVIDER_LEAK_PATTERNS = [
|
|
|
212612
212898
|
/\bCURSOR_API_KEY\b/,
|
|
212613
212899
|
/\bOPENCODE_API_KEY\b/,
|
|
212614
212900
|
/\bforge_proxy_[A-Za-z0-9._-]+\b/,
|
|
212901
|
+
/\bProviderValidationError\b/,
|
|
212902
|
+
/\/forge-server\/node_modules\//,
|
|
212615
212903
|
/https?:\/\/[^\s'"<>)]*\/api\/provider-egress\/[^\s'"<>)]*/i,
|
|
212616
212904
|
/https?:\/\/[^\s'"<>)]*(?:apex|deepseek|moonshot|kimi)[^\s'"<>)]*/i
|
|
212617
212905
|
];
|
|
@@ -212621,6 +212909,12 @@ function isProviderRateOrQuotaError(message) {
|
|
|
212621
212909
|
const normalized = message.toLowerCase();
|
|
212622
212910
|
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);
|
|
212623
212911
|
}
|
|
212912
|
+
function providerLifecycleOrAccountCopy(message) {
|
|
212913
|
+
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.";
|
|
212914
|
+
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.";
|
|
212915
|
+
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.";
|
|
212916
|
+
return null;
|
|
212917
|
+
}
|
|
212624
212918
|
/**
|
|
212625
212919
|
* Infer a SAFE, clearer failure category from a (possibly leaky) provider error.
|
|
212626
212920
|
*
|
|
@@ -212633,7 +212927,8 @@ function isProviderRateOrQuotaError(message) {
|
|
|
212633
212927
|
function categorizePublicProviderError(message) {
|
|
212634
212928
|
if (!message) return null;
|
|
212635
212929
|
const m = message.toLowerCase();
|
|
212636
|
-
|
|
212930
|
+
const lifecycleOrAccountCopy = providerLifecycleOrAccountCopy(m);
|
|
212931
|
+
if (lifecycleOrAccountCopy !== null) return lifecycleOrAccountCopy;
|
|
212637
212932
|
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.";
|
|
212638
212933
|
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.";
|
|
212639
212934
|
if (isProviderRateOrQuotaError(m)) return "The AI service is rate-limited or out of quota. Wait a moment and retry.";
|
|
@@ -212813,13 +213108,20 @@ function claudeConfigDirectory(env) {
|
|
|
212813
213108
|
return null;
|
|
212814
213109
|
}
|
|
212815
213110
|
}
|
|
212816
|
-
/**
|
|
212817
|
-
|
|
213111
|
+
/**
|
|
213112
|
+
* The fallback is allowed only when the primary on-disk credential is
|
|
213113
|
+
* byte-identical to what CMMD prepared for the selected account. A ticket's
|
|
213114
|
+
* `credentialRevisionHash` cannot express that: it hashes the encrypted row
|
|
213115
|
+
* CMMD stores, not the decrypted bytes this process ever sees. Comparing
|
|
213116
|
+
* files directly is the only check that means what the fallback needs.
|
|
213117
|
+
*/
|
|
213118
|
+
function primaryClaudeCredentialMatchesPrepared(env, preparedDirectory) {
|
|
212818
213119
|
const directory = claudeConfigDirectory(env);
|
|
212819
|
-
if (!directory
|
|
213120
|
+
if (!directory) return false;
|
|
212820
213121
|
try {
|
|
212821
|
-
const
|
|
212822
|
-
|
|
213122
|
+
const primary = readFileSync(join(directory, CLAUDE_CREDENTIAL_FILE_NAME), "utf8");
|
|
213123
|
+
const prepared = readFileSync(join(preparedDirectory, CLAUDE_CREDENTIAL_FILE_NAME), "utf8");
|
|
213124
|
+
return hasAccessToken(JSON.parse(primary)) && createHash("sha256").update(primary).digest("hex") === createHash("sha256").update(prepared).digest("hex");
|
|
212823
213125
|
} catch {
|
|
212824
213126
|
return false;
|
|
212825
213127
|
}
|
|
@@ -215206,6 +215508,7 @@ function buildClaudeSessionContext(input) {
|
|
|
215206
215508
|
pendingUserInputs: input.pendingUserInputs,
|
|
215207
215509
|
turns: [],
|
|
215208
215510
|
inFlightTools: input.inFlightTools,
|
|
215511
|
+
taskPlan: makeClaudeTaskPlanState(),
|
|
215209
215512
|
turnState: void 0,
|
|
215210
215513
|
lastKnownContextWindow: void 0,
|
|
215211
215514
|
lastKnownTokenUsage: void 0,
|
|
@@ -215715,6 +216018,7 @@ function classifyToolItemType(toolName) {
|
|
|
215715
216018
|
if (normalized.includes("agent")) return "collab_agent_tool_call";
|
|
215716
216019
|
if (normalized === "task" || normalized === "agent" || normalized.includes("subagent") || normalized.includes("sub-agent")) return "collab_agent_tool_call";
|
|
215717
216020
|
if (normalized.includes("bash") || normalized.includes("command") || normalized.includes("shell") || normalized.includes("terminal")) return "command_execution";
|
|
216021
|
+
if (isTaskBookkeepingTool(normalized)) return "dynamic_tool_call";
|
|
215718
216022
|
if (normalized.includes("edit") || normalized.includes("write") || normalized.includes("file") || normalized.includes("patch") || normalized.includes("replace") || normalized.includes("create") || normalized.includes("delete")) return "file_change";
|
|
215719
216023
|
if (normalized.includes("browser")) return "browser_automation";
|
|
215720
216024
|
if (normalized.includes("mcp")) return "mcp_tool_call";
|
|
@@ -215734,6 +216038,88 @@ function classifyRequestType(toolName) {
|
|
|
215734
216038
|
function isTodoTool(toolName) {
|
|
215735
216039
|
return toolName.toLowerCase().includes("todowrite");
|
|
215736
216040
|
}
|
|
216041
|
+
function makeClaudeTaskPlanState() {
|
|
216042
|
+
return {
|
|
216043
|
+
entries: [],
|
|
216044
|
+
createsByToolUseId: /* @__PURE__ */ new Map()
|
|
216045
|
+
};
|
|
216046
|
+
}
|
|
216047
|
+
function taskPlanToolFor(toolName) {
|
|
216048
|
+
const normalized = toolName.toLowerCase();
|
|
216049
|
+
if (normalized === "taskcreate") return "TaskCreate";
|
|
216050
|
+
if (normalized === "taskupdate") return "TaskUpdate";
|
|
216051
|
+
}
|
|
216052
|
+
/** Task bookkeeping tools (create, update, list, get) are not file changes. */
|
|
216053
|
+
function isTaskBookkeepingTool(normalizedToolName) {
|
|
216054
|
+
return /^task(create|update|list|get)$/.test(normalizedToolName);
|
|
216055
|
+
}
|
|
216056
|
+
function taskPlanStatus(value) {
|
|
216057
|
+
return value === "completed" ? "completed" : value === "in_progress" ? "inProgress" : "pending";
|
|
216058
|
+
}
|
|
216059
|
+
function taskPlanSnapshot(state) {
|
|
216060
|
+
return state.entries.map((entry) => ({
|
|
216061
|
+
step: entry.step,
|
|
216062
|
+
status: entry.status
|
|
216063
|
+
}));
|
|
216064
|
+
}
|
|
216065
|
+
/**
|
|
216066
|
+
* Applies one TaskCreate or TaskUpdate input to the session's task list and
|
|
216067
|
+
* returns the plan to publish, or null when the input carries nothing yet.
|
|
216068
|
+
* Input JSON streams in pieces, so the same tool use can arrive more than once;
|
|
216069
|
+
* creates are keyed by tool use id so a repeat updates the entry in place.
|
|
216070
|
+
*/
|
|
216071
|
+
function applyTaskPlanToolInput(state, tool, toolUseId, input) {
|
|
216072
|
+
if (tool === "TaskCreate") {
|
|
216073
|
+
const subject = typeof input.subject === "string" ? input.subject.trim() : "";
|
|
216074
|
+
const existing = state.createsByToolUseId.get(toolUseId);
|
|
216075
|
+
if (existing) {
|
|
216076
|
+
if (subject.length > 0) existing.step = subject;
|
|
216077
|
+
return taskPlanSnapshot(state);
|
|
216078
|
+
}
|
|
216079
|
+
const entry = {
|
|
216080
|
+
id: void 0,
|
|
216081
|
+
step: subject.length > 0 ? subject : "Task",
|
|
216082
|
+
status: taskPlanStatus(input.status)
|
|
216083
|
+
};
|
|
216084
|
+
state.entries.push(entry);
|
|
216085
|
+
state.createsByToolUseId.set(toolUseId, entry);
|
|
216086
|
+
return taskPlanSnapshot(state);
|
|
216087
|
+
}
|
|
216088
|
+
const taskId = typeof input.taskId === "string" ? input.taskId.trim() : typeof input.taskId === "number" ? String(input.taskId) : "";
|
|
216089
|
+
if (taskId.length === 0) return null;
|
|
216090
|
+
let entry = state.entries.find((candidate) => candidate.id === taskId);
|
|
216091
|
+
if (!entry) {
|
|
216092
|
+
const position = Number.parseInt(taskId, 10);
|
|
216093
|
+
const candidate = Number.isInteger(position) ? state.entries[position - 1] : void 0;
|
|
216094
|
+
if (candidate && candidate.id === void 0) {
|
|
216095
|
+
candidate.id = taskId;
|
|
216096
|
+
entry = candidate;
|
|
216097
|
+
}
|
|
216098
|
+
}
|
|
216099
|
+
if (input.status === "deleted") {
|
|
216100
|
+
if (entry) state.entries.splice(state.entries.indexOf(entry), 1);
|
|
216101
|
+
return taskPlanSnapshot(state);
|
|
216102
|
+
}
|
|
216103
|
+
if (!entry) {
|
|
216104
|
+
entry = {
|
|
216105
|
+
id: taskId,
|
|
216106
|
+
step: `Task #${taskId}`,
|
|
216107
|
+
status: "pending"
|
|
216108
|
+
};
|
|
216109
|
+
state.entries.push(entry);
|
|
216110
|
+
}
|
|
216111
|
+
if (typeof input.subject === "string" && input.subject.trim().length > 0) entry.step = input.subject.trim();
|
|
216112
|
+
if (input.status !== void 0) entry.status = taskPlanStatus(input.status);
|
|
216113
|
+
return taskPlanSnapshot(state);
|
|
216114
|
+
}
|
|
216115
|
+
const TASK_CREATE_RESULT_ID = /Task #(\S+) created/;
|
|
216116
|
+
/** Binds the id Claude Code assigned in the TaskCreate result to the entry. */
|
|
216117
|
+
function bindTaskPlanCreateResult(state, toolUseId, resultText) {
|
|
216118
|
+
const entry = state.createsByToolUseId.get(toolUseId);
|
|
216119
|
+
if (!entry || entry.id !== void 0) return;
|
|
216120
|
+
const match = TASK_CREATE_RESULT_ID.exec(resultText);
|
|
216121
|
+
if (match?.[1]) entry.id = match[1];
|
|
216122
|
+
}
|
|
215737
216123
|
function extractPlanStepsFromTodoInput(input) {
|
|
215738
216124
|
const todos = input.todos;
|
|
215739
216125
|
if (!Array.isArray(todos) || todos.length === 0) return null;
|
|
@@ -216450,9 +216836,9 @@ const makeClaudeAdapter = fn$1("makeClaudeAdapter")(function* (options) {
|
|
|
216450
216836
|
}
|
|
216451
216837
|
});
|
|
216452
216838
|
});
|
|
216453
|
-
const emitPlanUpdateFromToolInput = fn$1("emitPlanUpdateFromToolInput")(function* (context, toolName, parsedInput) {
|
|
216454
|
-
|
|
216455
|
-
const planSteps = extractPlanStepsFromTodoInput(parsedInput);
|
|
216839
|
+
const emitPlanUpdateFromToolInput = fn$1("emitPlanUpdateFromToolInput")(function* (context, toolName, toolUseId, parsedInput) {
|
|
216840
|
+
const taskPlanTool = taskPlanToolFor(toolName);
|
|
216841
|
+
const planSteps = taskPlanTool ? applyTaskPlanToolInput(context.taskPlan, taskPlanTool, toolUseId, parsedInput) : isTodoTool(toolName) ? extractPlanStepsFromTodoInput(parsedInput) : null;
|
|
216456
216842
|
if (!planSteps || planSteps.length === 0) return;
|
|
216457
216843
|
const planStamp = yield* makeEventStamp();
|
|
216458
216844
|
yield* offerRuntimeEvent({
|
|
@@ -216510,7 +216896,7 @@ const makeClaudeAdapter = fn$1("makeClaudeAdapter")(function* (options) {
|
|
|
216510
216896
|
};
|
|
216511
216897
|
context.inFlightTools.set(event.index, nextTool);
|
|
216512
216898
|
yield* emitToolInputUpdated(context, message, nextTool);
|
|
216513
|
-
if (state.parsedInput) yield* emitPlanUpdateFromToolInput(context, nextTool.toolName, state.parsedInput);
|
|
216899
|
+
if (state.parsedInput) yield* emitPlanUpdateFromToolInput(context, nextTool.toolName, nextTool.itemId, state.parsedInput);
|
|
216514
216900
|
});
|
|
216515
216901
|
const handleContentBlockDeltaEvent = fn$1("handleContentBlockDeltaEvent")(function* (context, message, event) {
|
|
216516
216902
|
yield* handleTextOrThinkingDelta(context, message, event);
|
|
@@ -216563,6 +216949,7 @@ const makeClaudeAdapter = fn$1("makeClaudeAdapter")(function* (options) {
|
|
|
216563
216949
|
if (!toolEntry) continue;
|
|
216564
216950
|
const [index, tool] = toolEntry;
|
|
216565
216951
|
const itemStatus = toolResult.isError ? "failed" : "completed";
|
|
216952
|
+
if (taskPlanToolFor(tool.toolName) === "TaskCreate" && !toolResult.isError) bindTaskPlanCreateResult(context.taskPlan, tool.itemId, toolResult.text);
|
|
216566
216953
|
const toolData = {
|
|
216567
216954
|
toolName: tool.toolName,
|
|
216568
216955
|
input: tool.input,
|
|
@@ -217344,7 +217731,7 @@ const makeClaudeAdapter = fn$1("makeClaudeAdapter")(function* (options) {
|
|
|
217344
217731
|
baseDir: serverConfig.baseDir,
|
|
217345
217732
|
accountIdentity: selectedAccountIdentity,
|
|
217346
217733
|
probeSupport: claudeCredentialDirectoryProbeFor(claudeBinaryPath).support,
|
|
217347
|
-
primaryCredentialMatchesSelected: preparedCredential?.accountKind === "primary" &&
|
|
217734
|
+
primaryCredentialMatchesSelected: preparedCredential?.accountKind === "primary" && primaryClaudeCredentialMatchesPrepared(process.env, preparedCredential.directory)
|
|
217348
217735
|
});
|
|
217349
217736
|
if (credentialDirectoryDecision.outcome === "refused") return yield* new ProviderAdapterValidationError({
|
|
217350
217737
|
provider: PROVIDER$11,
|
|
@@ -232483,7 +232870,7 @@ async function acquireSelectedClaudeCredential(expected, transport) {
|
|
|
232483
232870
|
const claims = token === null ? null : verifyCmmdClaudeSelectedTicket(token, expected);
|
|
232484
232871
|
if (!token || !claims) throw new Error("invalid_ticket");
|
|
232485
232872
|
const credentialJson = await transport.readCredential(token);
|
|
232486
|
-
if (Buffer.byteLength(credentialJson, "utf8") > 512e3 ||
|
|
232873
|
+
if (Buffer.byteLength(credentialJson, "utf8") > 512e3 || !hasUsableClaudeCredentialBlob(credentialJson)) throw new Error("invalid_credential");
|
|
232487
232874
|
return {
|
|
232488
232875
|
claims,
|
|
232489
232876
|
credentialJson
|
|
@@ -291649,6 +292036,10 @@ const ProviderCommandReactorLive = effect(ProviderCommandReactor, gen(function*
|
|
|
291649
292036
|
shouldRestartForAccountChange,
|
|
291650
292037
|
hasResumeCursor: resumeCursor !== void 0
|
|
291651
292038
|
});
|
|
292039
|
+
if (shouldRestartForAccountChange) yield* providerService.stopSession({
|
|
292040
|
+
threadId,
|
|
292041
|
+
preserveResumeState: true
|
|
292042
|
+
});
|
|
291652
292043
|
const restartedSession = yield* startProviderSession(resumeCursor !== void 0 ? { resumeCursor } : void 0);
|
|
291653
292044
|
yield* logInfo("provider command reactor restarted provider session", {
|
|
291654
292045
|
threadId,
|
|
@@ -297584,7 +297975,7 @@ function resolveBuildCommitFromEnv(env) {
|
|
|
297584
297975
|
* environment descriptor down instead of reporting an honest "unknown".
|
|
297585
297976
|
*/
|
|
297586
297977
|
function readBakedBuildCommit() {
|
|
297587
|
-
return "
|
|
297978
|
+
return "dea91e954d3d2f23154802ae304412fabe90dbbd";
|
|
297588
297979
|
}
|
|
297589
297980
|
async function resolveServerBuildCommit(input) {
|
|
297590
297981
|
if (isFullCommitSha(input.baked)) return input.baked;
|
|
@@ -298997,20 +299388,20 @@ const generatePairingToken = () => {
|
|
|
298997
299388
|
const randomBytes = crypto.getRandomValues(new Uint8Array(PAIRING_TOKEN_LENGTH));
|
|
298998
299389
|
return Array.from(randomBytes, (value) => PAIRING_TOKEN_ALPHABET[value & 31]).join("");
|
|
298999
299390
|
};
|
|
299391
|
+
const invalidBootstrapCredentialError = (message) => new BootstrapCredentialError({
|
|
299392
|
+
message,
|
|
299393
|
+
status: 401
|
|
299394
|
+
});
|
|
299395
|
+
const internalBootstrapCredentialError = (message, cause) => new BootstrapCredentialError({
|
|
299396
|
+
message,
|
|
299397
|
+
status: 500,
|
|
299398
|
+
cause
|
|
299399
|
+
});
|
|
299000
299400
|
const BootstrapCredentialServiceLive = effect(BootstrapCredentialService, gen(function* () {
|
|
299001
299401
|
const config = yield* ServerConfig;
|
|
299002
299402
|
const pairingLinks = yield* AuthPairingLinkRepository;
|
|
299003
299403
|
const seededGrantsRef = yield* make$50(/* @__PURE__ */ new Map());
|
|
299004
299404
|
const changesPubSub = yield* unbounded$1();
|
|
299005
|
-
const invalidBootstrapCredentialError = (message) => new BootstrapCredentialError({
|
|
299006
|
-
message,
|
|
299007
|
-
status: 401
|
|
299008
|
-
});
|
|
299009
|
-
const internalBootstrapCredentialError = (message, cause) => new BootstrapCredentialError({
|
|
299010
|
-
message,
|
|
299011
|
-
status: 500,
|
|
299012
|
-
cause
|
|
299013
|
-
});
|
|
299014
299405
|
const seedGrant = (credential, grant) => update$4(seededGrantsRef, (current) => {
|
|
299015
299406
|
const next = new Map(current);
|
|
299016
299407
|
next.set(credential, grant);
|
|
@@ -299025,19 +299416,19 @@ const BootstrapCredentialServiceLive = effect(BootstrapCredentialService, gen(fu
|
|
|
299025
299416
|
id
|
|
299026
299417
|
}).pipe(asVoid);
|
|
299027
299418
|
if (config.desktopBootstrapToken) {
|
|
299028
|
-
const now$
|
|
299419
|
+
const now$3 = yield* now;
|
|
299029
299420
|
yield* seedGrant(config.desktopBootstrapToken, {
|
|
299030
299421
|
method: "desktop-bootstrap",
|
|
299031
299422
|
role: "owner",
|
|
299032
299423
|
subject: "desktop-bootstrap",
|
|
299033
|
-
expiresAt: add$3(now$
|
|
299424
|
+
expiresAt: add$3(now$3, { milliseconds: toMillis(DESKTOP_BOOTSTRAP_TOKEN_TTL) }),
|
|
299034
299425
|
remainingUses: "unbounded"
|
|
299035
299426
|
});
|
|
299036
299427
|
}
|
|
299037
299428
|
const toBootstrapCredentialError = (message) => (cause) => internalBootstrapCredentialError(message, cause);
|
|
299038
299429
|
const listActive = () => gen(function* () {
|
|
299039
|
-
const now$
|
|
299040
|
-
return (yield* pairingLinks.listActive({ now: now$
|
|
299430
|
+
const now$2 = yield* now;
|
|
299431
|
+
return (yield* pairingLinks.listActive({ now: now$2 })).map((row) => row.label ? {
|
|
299041
299432
|
id: row.id,
|
|
299042
299433
|
credential: row.credential,
|
|
299043
299434
|
role: row.role,
|
|
@@ -299067,8 +299458,8 @@ const BootstrapCredentialServiceLive = effect(BootstrapCredentialService, gen(fu
|
|
|
299067
299458
|
const id = crypto.randomUUID();
|
|
299068
299459
|
const credential = generatePairingToken();
|
|
299069
299460
|
const ttl = input?.ttl ?? DEFAULT_ONE_TIME_TOKEN_TTL_MINUTES;
|
|
299070
|
-
const now$
|
|
299071
|
-
const expiresAt = add$3(now$
|
|
299461
|
+
const now$5 = yield* now;
|
|
299462
|
+
const expiresAt = add$3(now$5, { milliseconds: toMillis(ttl) });
|
|
299072
299463
|
const issued = {
|
|
299073
299464
|
id,
|
|
299074
299465
|
credential,
|
|
@@ -299082,7 +299473,7 @@ const BootstrapCredentialServiceLive = effect(BootstrapCredentialService, gen(fu
|
|
|
299082
299473
|
role: input?.role ?? "client",
|
|
299083
299474
|
subject: input?.subject ?? "one-time-token",
|
|
299084
299475
|
label: input?.label ?? null,
|
|
299085
|
-
createdAt: now$
|
|
299476
|
+
createdAt: now$5,
|
|
299086
299477
|
expiresAt
|
|
299087
299478
|
});
|
|
299088
299479
|
yield* emitUpsert({
|
|
@@ -299091,13 +299482,13 @@ const BootstrapCredentialServiceLive = effect(BootstrapCredentialService, gen(fu
|
|
|
299091
299482
|
role: input?.role ?? "client",
|
|
299092
299483
|
subject: input?.subject ?? "one-time-token",
|
|
299093
299484
|
...input?.label ? { label: input.label } : {},
|
|
299094
|
-
createdAt: now$
|
|
299485
|
+
createdAt: now$5,
|
|
299095
299486
|
expiresAt
|
|
299096
299487
|
});
|
|
299097
299488
|
return issued;
|
|
299098
299489
|
}).pipe(mapError(toBootstrapCredentialError("Failed to issue pairing credential.")));
|
|
299099
299490
|
const consume = (credential) => gen(function* () {
|
|
299100
|
-
const now$
|
|
299491
|
+
const now$4 = yield* now;
|
|
299101
299492
|
const seededResult = yield* modify$3(seededGrantsRef, (current) => {
|
|
299102
299493
|
const grant = current.get(credential);
|
|
299103
299494
|
if (!grant) return [{
|
|
@@ -299106,7 +299497,7 @@ const BootstrapCredentialServiceLive = effect(BootstrapCredentialService, gen(fu
|
|
|
299106
299497
|
error: invalidBootstrapCredentialError("Unknown bootstrap credential.")
|
|
299107
299498
|
}, current];
|
|
299108
299499
|
const next = new Map(current);
|
|
299109
|
-
if (isGreaterThanOrEqualTo$1(now$
|
|
299500
|
+
if (isGreaterThanOrEqualTo$1(now$4, grant.expiresAt)) {
|
|
299110
299501
|
next.delete(credential);
|
|
299111
299502
|
return [{
|
|
299112
299503
|
_tag: "error",
|
|
@@ -299135,8 +299526,8 @@ const BootstrapCredentialServiceLive = effect(BootstrapCredentialService, gen(fu
|
|
|
299135
299526
|
if (seededResult.reason !== "not-found") return yield* seededResult.error;
|
|
299136
299527
|
const consumed = yield* pairingLinks.consumeAvailable({
|
|
299137
299528
|
credential,
|
|
299138
|
-
consumedAt: now$
|
|
299139
|
-
now: now$
|
|
299529
|
+
consumedAt: now$4,
|
|
299530
|
+
now: now$4
|
|
299140
299531
|
});
|
|
299141
299532
|
if (isSome(consumed)) {
|
|
299142
299533
|
yield* emitRemoved(consumed.value.id);
|
|
@@ -299152,9 +299543,9 @@ const BootstrapCredentialServiceLive = effect(BootstrapCredentialService, gen(fu
|
|
|
299152
299543
|
if (isNone(matching)) return yield* invalidBootstrapCredentialError("Unknown bootstrap credential.");
|
|
299153
299544
|
if (matching.value.revokedAt !== null) return yield* invalidBootstrapCredentialError("Bootstrap credential is no longer available.");
|
|
299154
299545
|
if (matching.value.consumedAt !== null) return yield* invalidBootstrapCredentialError("Unknown bootstrap credential.");
|
|
299155
|
-
if (isGreaterThanOrEqualTo$1(now$
|
|
299546
|
+
if (isGreaterThanOrEqualTo$1(now$4, matching.value.expiresAt)) return yield* invalidBootstrapCredentialError("Bootstrap credential expired.");
|
|
299156
299547
|
return yield* invalidBootstrapCredentialError("Bootstrap credential is no longer available.");
|
|
299157
|
-
}).pipe(mapError((cause) => cause instanceof BootstrapCredentialError ? cause : internalBootstrapCredentialError("Failed to consume bootstrap credential.", cause)));
|
|
299548
|
+
}).pipe(mapError((cause) => cause instanceof BootstrapCredentialError ? cause : internalBootstrapCredentialError("Failed to consume bootstrap credential.", cause)), tapError((error) => error.status === 401 ? logWarning$1("Rejected bootstrap credential exchange.", { reason: error.message }) : void_$1));
|
|
299158
299549
|
return {
|
|
299159
299550
|
issueOneTimeToken,
|
|
299160
299551
|
listActive,
|
|
@@ -299209,6 +299600,10 @@ const SetAuthSessionLastConnectedAtInput = Struct({
|
|
|
299209
299600
|
sessionId: AuthSessionId,
|
|
299210
299601
|
lastConnectedAt: DateTimeUtcFromString
|
|
299211
299602
|
});
|
|
299603
|
+
const ExtendAuthSessionExpiryInput = Struct({
|
|
299604
|
+
sessionId: AuthSessionId,
|
|
299605
|
+
expiresAt: DateTimeUtcFromString
|
|
299606
|
+
});
|
|
299212
299607
|
var AuthSessionRepository = class extends Service$1()("forge/persistence/Services/AuthSessions/AuthSessionRepository") {};
|
|
299213
299608
|
//#endregion
|
|
299214
299609
|
//#region src/persistence/Layers/AuthSessions.ts
|
|
@@ -299360,6 +299755,17 @@ const AuthSessionRepositoryLive = effect(AuthSessionRepository, gen(function* ()
|
|
|
299360
299755
|
WHERE session_id = ${sessionId}
|
|
299361
299756
|
AND revoked_at IS NULL
|
|
299362
299757
|
RETURNING session_id AS "sessionId"
|
|
299758
|
+
`
|
|
299759
|
+
});
|
|
299760
|
+
const extendSessionExpiryRows = findAll({
|
|
299761
|
+
Request: ExtendAuthSessionExpiryInput,
|
|
299762
|
+
Result: Struct({ sessionId: AuthSessionId }),
|
|
299763
|
+
execute: ({ sessionId, expiresAt }) => sql`
|
|
299764
|
+
UPDATE auth_sessions
|
|
299765
|
+
SET expires_at = ${expiresAt}
|
|
299766
|
+
WHERE session_id = ${sessionId}
|
|
299767
|
+
AND revoked_at IS NULL
|
|
299768
|
+
RETURNING session_id AS "sessionId"
|
|
299363
299769
|
`
|
|
299364
299770
|
});
|
|
299365
299771
|
const revokeOtherSessionRows = findAll({
|
|
@@ -299382,13 +299788,15 @@ const AuthSessionRepositoryLive = effect(AuthSessionRepository, gen(function* ()
|
|
|
299382
299788
|
const revoke = (input) => revokeSessionRows(input).pipe(mapError(toPersistenceSqlOrDecodeError$3("AuthSessionRepository.revoke:query", "AuthSessionRepository.revoke:decodeRows")), map$3((rows) => rows.length > 0));
|
|
299383
299789
|
const revokeAllExcept = (input) => revokeOtherSessionRows(input).pipe(mapError(toPersistenceSqlOrDecodeError$3("AuthSessionRepository.revokeAllExcept:query", "AuthSessionRepository.revokeAllExcept:decodeRows")), map$3((rows) => rows.map((row) => row.sessionId)));
|
|
299384
299790
|
const setLastConnectedAt = (input) => setLastConnectedAtRow(input).pipe(mapError(toPersistenceSqlOrDecodeError$3("AuthSessionRepository.setLastConnectedAt:query", "AuthSessionRepository.setLastConnectedAt:encodeRequest")));
|
|
299791
|
+
const extendExpiry = (input) => extendSessionExpiryRows(input).pipe(mapError(toPersistenceSqlOrDecodeError$3("AuthSessionRepository.extendExpiry:query", "AuthSessionRepository.extendExpiry:decodeRows")), map$3((rows) => rows.length > 0));
|
|
299385
299792
|
return {
|
|
299386
299793
|
create,
|
|
299387
299794
|
getById,
|
|
299388
299795
|
listActive,
|
|
299389
299796
|
revoke,
|
|
299390
299797
|
revokeAllExcept,
|
|
299391
|
-
setLastConnectedAt
|
|
299798
|
+
setLastConnectedAt,
|
|
299799
|
+
extendExpiry
|
|
299392
299800
|
};
|
|
299393
299801
|
}));
|
|
299394
299802
|
//#endregion
|
|
@@ -299396,6 +299804,20 @@ const AuthSessionRepositoryLive = effect(AuthSessionRepository, gen(function* ()
|
|
|
299396
299804
|
const SIGNING_SECRET_NAME = "server-signing-key";
|
|
299397
299805
|
const DEFAULT_SESSION_TTL = days(30);
|
|
299398
299806
|
const DEFAULT_WEBSOCKET_TOKEN_TTL = minutes(5);
|
|
299807
|
+
/**
|
|
299808
|
+
* Whether a session presented at `nowMs` has used up half its own lifetime.
|
|
299809
|
+
*
|
|
299810
|
+
* A phone that opens the app daily should never hit the 30-day wall, but a
|
|
299811
|
+
* lost phone's session should still die on schedule if it is never presented
|
|
299812
|
+
* again. Sliding the expiry forward only when the session is already more
|
|
299813
|
+
* than half spent gives an active phone many chances to renew before expiry
|
|
299814
|
+
* while keeping a dormant session's remaining lifetime bounded by the last
|
|
299815
|
+
* time it was actually used.
|
|
299816
|
+
*/
|
|
299817
|
+
function isPastHalfLife(input) {
|
|
299818
|
+
const halfLifeMarkMs = input.issuedAtMs + (input.expiresAtMs - input.issuedAtMs) / 2;
|
|
299819
|
+
return input.nowMs >= halfLifeMarkMs;
|
|
299820
|
+
}
|
|
299399
299821
|
const toSessionCredentialError = (message) => (cause) => new SessionCredentialError({
|
|
299400
299822
|
message,
|
|
299401
299823
|
cause
|
|
@@ -299556,6 +299978,42 @@ const SessionCredentialServiceLive = effect(SessionCredentialService, gen(functi
|
|
|
299556
299978
|
role: claims.role
|
|
299557
299979
|
};
|
|
299558
299980
|
}).pipe(mapError(toSessionCredentialError("Failed to issue session credential.")));
|
|
299981
|
+
/**
|
|
299982
|
+
* Slide a session's expiry forward when the token it just verified is past
|
|
299983
|
+
* half its own lifetime, so a phone that keeps using the app never runs
|
|
299984
|
+
* into the 30-day wall.
|
|
299985
|
+
*
|
|
299986
|
+
* The session id, subject, role and method carry over unchanged — only the
|
|
299987
|
+
* signature-bearing `iat`/`exp` differ — which is what lets the DB row be
|
|
299988
|
+
* extended in place (`extendExpiry`) rather than replaced: everything keyed
|
|
299989
|
+
* by session id, most importantly the `command_access_tokens` row holding
|
|
299990
|
+
* this session's CMMD credential, needs no migration.
|
|
299991
|
+
*/
|
|
299992
|
+
const renewIfPastHalfLife = (input) => gen(function* () {
|
|
299993
|
+
const nowMs = yield* currentTimeMillis;
|
|
299994
|
+
if (!isPastHalfLife({
|
|
299995
|
+
issuedAtMs: input.row.issuedAt.epochMilliseconds,
|
|
299996
|
+
expiresAtMs: input.row.expiresAt.epochMilliseconds,
|
|
299997
|
+
nowMs
|
|
299998
|
+
})) return void 0;
|
|
299999
|
+
const newIssuedAt = yield* now;
|
|
300000
|
+
const newExpiresAt = add$3(newIssuedAt, { milliseconds: toMillis(DEFAULT_SESSION_TTL) });
|
|
300001
|
+
const newClaims = {
|
|
300002
|
+
...input.claims,
|
|
300003
|
+
iat: newIssuedAt.epochMilliseconds,
|
|
300004
|
+
exp: newExpiresAt.epochMilliseconds
|
|
300005
|
+
};
|
|
300006
|
+
const newEncodedPayload = base64UrlEncode(JSON.stringify(newClaims));
|
|
300007
|
+
const newSignature = signPayload(newEncodedPayload, signingSecret);
|
|
300008
|
+
if (!(yield* authSessions.extendExpiry({
|
|
300009
|
+
sessionId: input.claims.sid,
|
|
300010
|
+
expiresAt: newExpiresAt
|
|
300011
|
+
}).pipe(orElseSucceed(() => false)))) return void 0;
|
|
300012
|
+
return {
|
|
300013
|
+
token: `${newEncodedPayload}.${newSignature}`,
|
|
300014
|
+
expiresAt: newExpiresAt
|
|
300015
|
+
};
|
|
300016
|
+
});
|
|
299559
300017
|
const verify = (token) => gen(function* () {
|
|
299560
300018
|
const [encodedPayload, signature] = token.split(".");
|
|
299561
300019
|
if (!encodedPayload || !signature) return yield* new SessionCredentialError({ message: "Malformed session token." });
|
|
@@ -299570,14 +300028,19 @@ const SessionCredentialServiceLive = effect(SessionCredentialService, gen(functi
|
|
|
299570
300028
|
if (isSome(row)) {
|
|
299571
300029
|
if (row.value.revokedAt !== null) return yield* new SessionCredentialError({ message: "Session token revoked." });
|
|
299572
300030
|
} else if (!(statelessCookieSessions && claims.method === "browser-session-cookie")) return yield* new SessionCredentialError({ message: "Unknown session token." });
|
|
300031
|
+
const renewed = isSome(row) ? yield* renewIfPastHalfLife({
|
|
300032
|
+
claims,
|
|
300033
|
+
row: row.value
|
|
300034
|
+
}) : void 0;
|
|
299573
300035
|
return {
|
|
299574
300036
|
sessionId: claims.sid,
|
|
299575
300037
|
token,
|
|
299576
300038
|
method: claims.method,
|
|
299577
300039
|
client: isSome(row) ? toClientMetadata(row.value.client) : createDefaultClientMetadata(),
|
|
299578
|
-
expiresAt: makeUnsafe$5(claims.exp),
|
|
300040
|
+
expiresAt: renewed?.expiresAt ?? makeUnsafe$5(claims.exp),
|
|
299579
300041
|
subject: claims.sub,
|
|
299580
|
-
role: claims.role
|
|
300042
|
+
role: claims.role,
|
|
300043
|
+
...renewed ? { renewed } : {}
|
|
299581
300044
|
};
|
|
299582
300045
|
}).pipe(mapError((cause) => cause instanceof SessionCredentialError ? cause : new SessionCredentialError({
|
|
299583
300046
|
message: "Failed to verify session credential.",
|
|
@@ -300069,7 +300532,10 @@ const ServerAuthLive = effect(ServerAuth, gen(function* () {
|
|
|
300069
300532
|
subject: session.subject,
|
|
300070
300533
|
method: session.method,
|
|
300071
300534
|
role: session.role,
|
|
300072
|
-
...session.
|
|
300535
|
+
...session.renewed ? {
|
|
300536
|
+
expiresAt: session.renewed.expiresAt,
|
|
300537
|
+
renewedSessionToken: session.renewed.token
|
|
300538
|
+
} : session.expiresAt ? { expiresAt: session.expiresAt } : {}
|
|
300073
300539
|
})), mapError((cause) => new AuthError({
|
|
300074
300540
|
message: "Unauthorized request.",
|
|
300075
300541
|
status: 401,
|
|
@@ -300104,6 +300570,7 @@ const ServerAuthLive = effect(ServerAuth, gen(function* () {
|
|
|
300104
300570
|
role: session.role,
|
|
300105
300571
|
sessionMethod: session.method,
|
|
300106
300572
|
...session.expiresAt ? { expiresAt: toUtc(session.expiresAt) } : {},
|
|
300573
|
+
...session.renewedSessionToken ? { renewedSessionToken: session.renewedSessionToken } : {},
|
|
300107
300574
|
cmmdIdentity
|
|
300108
300575
|
})))), tapError((e) => {
|
|
300109
300576
|
if (e._tag === "AuthError" && (e.status ?? 500) < 500) return void_$1;
|
|
@@ -318374,7 +318841,7 @@ function makeProjectLeadControllerClientLive(options = {}) {
|
|
|
318374
318841
|
const sql = yield* SqlClient;
|
|
318375
318842
|
const fetchFn = options.fetchFn ?? fetch;
|
|
318376
318843
|
const baseUrl = options.baseUrl ?? resolveCommandAppUrl();
|
|
318377
|
-
const resolveStoredJwt = (forceRefresh) => gen(function* () {
|
|
318844
|
+
const resolveStoredJwt = (forceRefresh, staleJwt) => gen(function* () {
|
|
318378
318845
|
const owner = yield* readMostRecentOwnerCommandAuthSession.pipe(provideService(SqlClient, sql), mapError((cause) => new ProjectLeadControllerClientError({
|
|
318379
318846
|
kind: "unavailable",
|
|
318380
318847
|
operation: "resolve-owner-session",
|
|
@@ -318388,7 +318855,8 @@ function makeProjectLeadControllerClientLive(options = {}) {
|
|
|
318388
318855
|
sessionId: owner.sessionId,
|
|
318389
318856
|
subject: owner.subject,
|
|
318390
318857
|
logPrefix: "project-lead-scheduler",
|
|
318391
|
-
forceRefresh
|
|
318858
|
+
forceRefresh,
|
|
318859
|
+
staleAccessToken: staleJwt
|
|
318392
318860
|
}).pipe(provideService(SqlClient, sql));
|
|
318393
318861
|
if (!jwt) return yield* new ProjectLeadControllerClientError({
|
|
318394
318862
|
kind: "unauthenticated",
|
|
@@ -318431,7 +318899,7 @@ function makeProjectLeadControllerClientLive(options = {}) {
|
|
|
318431
318899
|
}));
|
|
318432
318900
|
if (first._tag === "Success") return first.success;
|
|
318433
318901
|
if (first.failure.kind !== "unauthenticated") return yield* first.failure;
|
|
318434
|
-
const refreshed = yield* resolveJwt(true);
|
|
318902
|
+
const refreshed = yield* resolveJwt(true, jwt);
|
|
318435
318903
|
return yield* requestAttempt({
|
|
318436
318904
|
...input,
|
|
318437
318905
|
jwt: refreshed
|