@cmmd-center/forge 0.11.0 → 0.11.1

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 CHANGED
@@ -15773,7 +15773,7 @@ const get$1 = (self) => modify$1(self, (current) => [current, current]);
15773
15773
  * })
15774
15774
  * ```
15775
15775
  */
15776
- const set$1 = /* @__PURE__ */ require_Schema$1.dual(2, (self, value) => update$1(self, () => value));
15776
+ const set$2 = /* @__PURE__ */ require_Schema$1.dual(2, (self, value) => update$1(self, () => value));
15777
15777
  //#endregion
15778
15778
  //#region ../../node_modules/.bun/effect@4.0.0-beta.51/node_modules/effect/dist/TxChunk.js
15779
15779
  /**
@@ -15962,7 +15962,7 @@ const get = (self) => get$1(self.ref);
15962
15962
  * @since 4.0.0
15963
15963
  * @category combinators
15964
15964
  */
15965
- const set = /* @__PURE__ */ require_Schema$1.dual(2, (self, chunk) => set$1(self.ref, chunk));
15965
+ const set$1 = /* @__PURE__ */ require_Schema$1.dual(2, (self, chunk) => set$2(self.ref, chunk));
15966
15966
  /**
15967
15967
  * Appends an element to the end of the `TxChunk`.
15968
15968
  *
@@ -16211,7 +16211,7 @@ const take = (self) => require_Schema$1.gen(function* () {
16211
16211
  const head$3 = require_Schema$1.head(chunk);
16212
16212
  if (require_Schema$1.isNone(head$3)) return yield* require_Schema$1.txRetry;
16213
16213
  yield* drop(self.items, 1);
16214
- if (state._tag === "Closing" && (yield* isEmpty(self))) yield* set$1(self.stateRef, {
16214
+ if (state._tag === "Closing" && (yield* isEmpty(self))) yield* set$2(self.stateRef, {
16215
16215
  _tag: "Done",
16216
16216
  cause: state.cause
16217
16217
  });
@@ -16309,11 +16309,11 @@ const interrupt = (self) => require_Schema$1.withFiber((fiber) => failCause(self
16309
16309
  */
16310
16310
  const failCause = /* @__PURE__ */ require_Schema$1.dual(2, (self, cause) => require_Schema$1.gen(function* () {
16311
16311
  if ((yield* get$1(self.stateRef))._tag !== "Open") return false;
16312
- if (yield* isEmpty(self)) yield* set$1(self.stateRef, {
16312
+ if (yield* isEmpty(self)) yield* set$2(self.stateRef, {
16313
16313
  _tag: "Done",
16314
16314
  cause
16315
16315
  });
16316
- else yield* set$1(self.stateRef, {
16316
+ else yield* set$2(self.stateRef, {
16317
16317
  _tag: "Closing",
16318
16318
  cause
16319
16319
  });
@@ -16355,7 +16355,7 @@ const clear = (self) => require_Schema$1.gen(function* () {
16355
16355
  return yield* require_Schema$1.failCause(state.cause);
16356
16356
  }
16357
16357
  const chunk = yield* get(self.items);
16358
- yield* set(self.items, require_Schema$1.empty());
16358
+ yield* set$1(self.items, require_Schema$1.empty());
16359
16359
  return require_Schema$1.toArray(chunk);
16360
16360
  }).pipe(require_Schema$1.tx);
16361
16361
  /**
@@ -26678,6 +26678,29 @@ function encodeCmmdRuntimeThreadLifecycleMetadata(lifecycle) {
26678
26678
  } };
26679
26679
  }
26680
26680
  //#endregion
26681
+ //#region ../../packages/contracts/src/cmmdRuntimeThreadWorkspace.ts
26682
+ function encodeCmmdRuntimeThreadWorkspaceMetadata(workspace) {
26683
+ return { forgeWorkspace: {
26684
+ schemaVersion: 1,
26685
+ worktreePath: workspace.worktreePath
26686
+ } };
26687
+ }
26688
+ //#endregion
26689
+ //#region ../../packages/contracts/src/cmmdRuntimeToolActivity.ts
26690
+ function isRecord$22(value) {
26691
+ return value !== null && typeof value === "object" && !Array.isArray(value);
26692
+ }
26693
+ function encodeCmmdRuntimeToolActivityPayload(kind, payload) {
26694
+ return {
26695
+ ...isRecord$22(payload) ? payload : { sourcePayload: payload },
26696
+ forgeToolActivity: {
26697
+ schemaVersion: 1,
26698
+ source: "native",
26699
+ kind
26700
+ }
26701
+ };
26702
+ }
26703
+ //#endregion
26681
26704
  //#region ../../packages/contracts/src/cmmdProjectPatch.ts
26682
26705
  const CMMD_PROJECT_PATCH_FIELDS = {
26683
26706
  title: "requiredText",
@@ -65775,7 +65798,7 @@ function normalizeNumberish(value) {
65775
65798
  }
65776
65799
  //#endregion
65777
65800
  //#region package.json
65778
- var version$1 = "0.11.0";
65801
+ var version$1 = "0.11.1";
65779
65802
  //#endregion
65780
65803
  //#region src/sentry.ts
65781
65804
  const SERVER_APP_NAME = "forge-server";
@@ -69563,6 +69586,17 @@ const browserApiCorsLayer = middleware(require_HttpServer.cors({
69563
69586
  credentials: true,
69564
69587
  maxAge: 600
69565
69588
  }), { global: true });
69589
+ /**
69590
+ * An /api path that reaches the static catch-all is not implemented by this
69591
+ * server: every real API route is registered ahead of it. Serving the web app's
69592
+ * index.html for those made API clients decode an HTML document as JSON, which
69593
+ * is how the phone's shell snapshot fetch failed with a JSON parse error on
69594
+ * every connect (FOR-36611). Callers can act on a 404; they cannot act on a
69595
+ * document that claims to be their payload.
69596
+ */
69597
+ function isApiRequestPath(pathname) {
69598
+ return pathname === "/api" || pathname.startsWith("/api/");
69599
+ }
69566
69600
  function resolveDevRedirectUrl(devUrl, requestUrl) {
69567
69601
  const redirectUrl = new URL(devUrl.toString());
69568
69602
  redirectUrl.pathname = requestUrl.pathname;
@@ -69687,6 +69721,10 @@ const staticAndDevRouteLayer = add$1("GET", "*", require_Schema$1.gen(function*
69687
69721
  const request = yield* require_HttpServer.HttpServerRequest;
69688
69722
  const url = require_HttpServer.toURL(request);
69689
69723
  if (require_Schema$1.isNone(url)) return require_HttpServer.text("Bad Request", { status: 400 });
69724
+ if (isApiRequestPath(url.value.pathname)) return require_HttpServer.jsonUnsafe({
69725
+ error: "not_found",
69726
+ message: `This server does not serve ${url.value.pathname}.`
69727
+ }, { status: 404 });
69690
69728
  const config = yield* ServerConfig;
69691
69729
  if (config.devUrl && isLoopbackHostname(url.value.hostname)) return require_HttpServer.redirect(resolveDevRedirectUrl(config.devUrl, url.value), { status: 302 });
69692
69730
  const staticDir = config.staticDir ?? (config.devUrl ? yield* resolveStaticDir() : void 0);
@@ -95993,10 +96031,13 @@ function serializeRuntimeInventory(snapshot, providerInventory) {
95993
96031
  workerRole: thread.workerRole,
95994
96032
  anchorKind: thread.anchorKind,
95995
96033
  anchorCmmdId: thread.anchorCmmdId,
95996
- metadata: encodeCmmdRuntimeThreadLifecycleMetadata({
95997
- settledAt: thread.settledAt ?? null,
95998
- settledOverride: thread.settledOverride ?? null
95999
- })
96034
+ metadata: {
96035
+ ...encodeCmmdRuntimeThreadLifecycleMetadata({
96036
+ settledAt: thread.settledAt ?? null,
96037
+ settledOverride: thread.settledOverride ?? null
96038
+ }),
96039
+ ...encodeCmmdRuntimeThreadWorkspaceMetadata({ worktreePath: thread.worktreePath })
96040
+ }
96000
96041
  })),
96001
96042
  metadata: {
96002
96043
  source: "forge-server-projection",
@@ -96063,7 +96104,7 @@ function serializeNativeToolActivity(activity) {
96063
96104
  kind: "tool_call",
96064
96105
  summary,
96065
96106
  status: activityStatus(activity),
96066
- ...activity.payload === void 0 ? {} : { payload: activity.payload }
96107
+ payload: encodeCmmdRuntimeToolActivityPayload(activity.kind, activity.payload)
96067
96108
  }
96068
96109
  };
96069
96110
  }
@@ -98633,413 +98674,23 @@ function browseDirectory(input) {
98633
98674
  });
98634
98675
  }
98635
98676
  //#endregion
98636
- //#region src/workspace/Services/WorkspaceEntries.ts
98677
+ //#region src/pathWithinRoot.ts
98637
98678
  /**
98638
- * WorkspaceEntries - Effect service contract for cached workspace entry search.
98679
+ * True when `candidate` is `root` itself or lies below it, judged on the path
98680
+ * strings alone. The path from `root` to `candidate` is taken with
98681
+ * `path.relative` and normalised to forward slashes, and the candidate is
98682
+ * outside when that path climbs out (`..` or `../...`) or is absolute, which is
98683
+ * what `relative` returns across Windows drives. A child whose name only starts
98684
+ * with two dots, such as `..cache`, is inside. Symlinks are not followed, so a
98685
+ * caller that cares where a link points has to compare resolved paths.
98639
98686
  *
98640
- * Owns indexed workspace entry search plus cache invalidation for workspace
98641
- * roots when the underlying filesystem changes.
98642
- *
98643
- * @module WorkspaceEntries
98644
- */
98645
- var WorkspaceEntriesError = class extends require_Schema$1.TaggedErrorClass()("WorkspaceEntriesError", {
98646
- cwd: require_Schema$1.String,
98647
- operation: require_Schema$1.String,
98648
- detail: require_Schema$1.String,
98649
- cause: require_Schema$1.optional(require_Schema$1.Defect)
98650
- }) {};
98651
- /**
98652
- * WorkspaceEntries - Service tag for cached workspace entry search.
98653
- */
98654
- var WorkspaceEntries = class extends require_Schema$1.Service()("forge/workspace/Services/WorkspaceEntries") {};
98655
- //#endregion
98656
- //#region src/workspace/Services/WorkspaceBulkTransfer.ts
98657
- var WorkspaceBulkTransfer = class extends require_Schema$1.Service()("forge/workspace/Services/WorkspaceBulkTransfer") {};
98658
- //#endregion
98659
- //#region src/workspace/Layers/WorkspaceBulkTransfer.ts
98660
- const TRANSFER_TTL_MS = 1440 * 60 * 1e3;
98661
- const TRANSFER_LOCK_STRIPE_COUNT = 64;
98662
- function transferError(input) {
98663
- return new BulkTransferError({
98664
- code: input.code,
98665
- message: input.message,
98666
- transferId: input.transferId ?? null,
98667
- retryable: input.retryable ?? false,
98668
- ...input.cause === void 0 ? {} : { cause: input.cause }
98669
- });
98670
- }
98671
- function mapFailure(operation, transferId) {
98672
- return (cause) => require_Schema$1.is(BulkTransferError)(cause) ? cause : transferError({
98673
- code: "io_error",
98674
- message: `Workspace bulk transfer failed during ${operation}.`,
98675
- ...transferId === void 0 ? {} : { transferId },
98676
- retryable: true,
98677
- cause
98678
- });
98679
- }
98680
- /**
98681
- * Exported so the unary file read in `projectFile/readProjectFile.ts` uses this
98682
- * containment check rather than a second copy of it. Two copies of a security
98683
- * rule in one repo is how they drift, and the drift is silent.
98687
+ * Only a type import, so a module that has to stay free of Effect services can
98688
+ * use it without importing a Layer.
98684
98689
  */
98685
98690
  function isPathWithinRoot(path, root, candidate) {
98686
98691
  const relative = path.relative(root, candidate).replaceAll("\\", "/");
98687
98692
  return relative === "" || !relative.startsWith("../") && relative !== ".." && !path.isAbsolute(relative);
98688
98693
  }
98689
- function transferLockStripe(transferId) {
98690
- let hash = 2166136261;
98691
- for (let index = 0; index < transferId.length; index += 1) hash = Math.imul(hash ^ transferId.charCodeAt(index), 16777619);
98692
- return (hash >>> 0) % TRANSFER_LOCK_STRIPE_COUNT;
98693
- }
98694
- const makeWorkspaceBulkTransfer = (options = {}) => require_Schema$1.gen(function* () {
98695
- const fileSystem = yield* require_Path.FileSystem;
98696
- const path = yield* require_Path.Path;
98697
- const sql = yield* require_SqlError.SqlClient;
98698
- const workspacePaths = yield* WorkspacePaths;
98699
- const workspaceEntries = yield* WorkspaceEntries;
98700
- const fault = options.fault ?? (() => require_Schema$1.void_);
98701
- const transferLocks = yield* require_Schema$1.forEach(Array.from({ length: TRANSFER_LOCK_STRIPE_COUNT }), () => require_Path.make$5(1));
98702
- const withTransferLock = (transferId, effect) => transferLocks[transferLockStripe(transferId)].withPermit(effect);
98703
- const findTransfer = (transferId) => sql`
98704
- SELECT transfer_id AS "transferId", workspace_root AS "workspaceRoot",
98705
- relative_path AS "relativePath", target_path AS "targetPath", temp_path AS "tempPath",
98706
- total_bytes AS "totalBytes", whole_sha256 AS "wholeSha256",
98707
- next_offset AS "nextOffset", state, expires_at AS "expiresAt"
98708
- FROM workspace_bulk_transfers WHERE transfer_id = ${transferId} LIMIT 1
98709
- `.pipe(require_Schema$1.map((rows) => rows[0] ?? null));
98710
- const requireTransfer = (transferId) => findTransfer(transferId).pipe(require_Schema$1.flatMap((row) => row === null ? require_Schema$1.fail(transferError({
98711
- code: "transfer_not_found",
98712
- message: "Bulk transfer does not exist.",
98713
- transferId
98714
- })) : require_Schema$1.succeed(row)));
98715
- const requireOpenTransfer = (transferId) => requireTransfer(transferId).pipe(require_Schema$1.flatMap((row) => {
98716
- if (row.state === "cancelled") return require_Schema$1.fail(transferError({
98717
- code: "transfer_cancelled",
98718
- message: "Bulk transfer was cancelled.",
98719
- transferId
98720
- }));
98721
- if (row.state === "expired" || Date.parse(row.expiresAt) <= Date.now()) return require_Schema$1.fail(transferError({
98722
- code: "transfer_expired",
98723
- message: "Bulk transfer expired.",
98724
- transferId
98725
- }));
98726
- return require_Schema$1.succeed(row);
98727
- }));
98728
- const resolveTarget = require_Schema$1.fn("WorkspaceBulkTransfer.resolveTarget")(function* (input) {
98729
- const workspaceRoot = yield* workspacePaths.normalizeWorkspaceRoot(input.cwd);
98730
- const resolved = yield* workspacePaths.resolveRelativePathWithinRoot({
98731
- workspaceRoot,
98732
- relativePath: input.relativePath
98733
- });
98734
- const canonicalRoot = yield* fileSystem.realPath(workspaceRoot);
98735
- const parentSegments = resolved.relativePath.split("/").slice(0, -1);
98736
- let canonicalParent = canonicalRoot;
98737
- for (const segment of parentSegments) {
98738
- const candidate = path.join(canonicalParent, segment);
98739
- const canonicalCandidate = yield* fileSystem.realPath(candidate).pipe(require_Schema$1.catch_(() => input.createParent ? fileSystem.makeDirectory(candidate).pipe(require_Schema$1.flatMap(() => fileSystem.realPath(candidate))) : fileSystem.realPath(candidate)));
98740
- if (!isPathWithinRoot(path, canonicalRoot, canonicalCandidate)) return yield* transferError({
98741
- code: "symlink_escape",
98742
- message: "Workspace file path resolves outside the project root."
98743
- });
98744
- canonicalParent = canonicalCandidate;
98745
- }
98746
- const targetPath = path.join(canonicalParent, path.basename(resolved.relativePath));
98747
- const targetRealPath = yield* fileSystem.realPath(targetPath).pipe(require_Schema$1.catch_(() => require_Schema$1.succeed(null)));
98748
- if (targetRealPath !== null && !isPathWithinRoot(path, canonicalRoot, targetRealPath)) return yield* transferError({
98749
- code: "symlink_escape",
98750
- message: "Workspace file target resolves outside the project root."
98751
- });
98752
- return {
98753
- workspaceRoot: canonicalRoot,
98754
- relativePath: resolved.relativePath,
98755
- targetPath,
98756
- canonicalParent
98757
- };
98758
- });
98759
- const readFile = (input) => require_Path.unwrap(require_Schema$1.gen(function* () {
98760
- const target = yield* resolveTarget({
98761
- ...input,
98762
- createParent: false
98763
- });
98764
- const info = yield* fileSystem.stat(target.targetPath);
98765
- const maxBytes = input.maxBytes ?? 16777216;
98766
- if (Number(info.size) > maxBytes) return yield* transferError({
98767
- code: "size_limit_exceeded",
98768
- message: `Workspace file exceeds the ${maxBytes}-byte read limit.`
98769
- });
98770
- const bytes = yield* fileSystem.readFile(target.targetPath);
98771
- const transfer = yield* require_Schema$1.promise(() => chunkBulkTransfer(bytes));
98772
- const transferId = transfer.chunks[0]?.transferId ?? crypto.randomUUID().replaceAll("-", "_");
98773
- return require_Path.fromIterable([
98774
- {
98775
- kind: "started",
98776
- transferId,
98777
- totalBytes: transfer.totalBytes,
98778
- sha256: transfer.sha256
98779
- },
98780
- ...transfer.chunks.map((chunk) => ({
98781
- ...chunk,
98782
- transferId
98783
- })),
98784
- {
98785
- kind: "completed",
98786
- transferId,
98787
- totalBytes: transfer.totalBytes,
98788
- sha256: transfer.sha256
98789
- }
98790
- ]);
98791
- }).pipe(require_Schema$1.mapError(mapFailure("read file"))));
98792
- const resumeWrite = require_Schema$1.fn("WorkspaceBulkTransfer.resumeWrite")(function* (input, existing) {
98793
- const target = yield* resolveTarget({
98794
- ...input,
98795
- createParent: true
98796
- });
98797
- if (!(existing.workspaceRoot === target.workspaceRoot && existing.relativePath === target.relativePath && existing.totalBytes === input.totalBytes && existing.wholeSha256 === input.sha256)) return yield* transferError({
98798
- code: "transfer_identity_conflict",
98799
- message: "Bulk transfer ID is already bound to different content or a different path.",
98800
- transferId: input.transferId
98801
- });
98802
- if (existing.state === "cancelled") return yield* transferError({
98803
- code: "transfer_cancelled",
98804
- message: "Bulk transfer was cancelled.",
98805
- transferId: input.transferId
98806
- });
98807
- if (existing.state === "expired" || Date.parse(existing.expiresAt) <= Date.now()) return yield* transferError({
98808
- code: "transfer_expired",
98809
- message: "Bulk transfer expired.",
98810
- transferId: input.transferId
98811
- });
98812
- return {
98813
- transferId: input.transferId,
98814
- nextOffset: existing.nextOffset,
98815
- state: existing.state === "completed" ? "completed" : "open"
98816
- };
98817
- });
98818
- const createWrite = require_Schema$1.fn("WorkspaceBulkTransfer.createWrite")(function* (input) {
98819
- const target = yield* resolveTarget({
98820
- ...input,
98821
- createParent: true
98822
- }).pipe(require_Schema$1.mapError((cause) => require_Schema$1.is(WorkspacePathOutsideRootError)(cause) ? transferError({
98823
- code: "path_outside_workspace",
98824
- message: "Workspace file path must stay within the project root.",
98825
- transferId: input.transferId,
98826
- cause
98827
- }) : cause));
98828
- const tempPath = path.join(target.canonicalParent, `.forge-transfer-${input.transferId}.part`);
98829
- yield* require_Schema$1.scoped(fileSystem.open(tempPath, {
98830
- flag: "wx",
98831
- mode: 384
98832
- }).pipe(require_Schema$1.flatMap((file) => file.sync), require_Schema$1.catch_(() => fileSystem.stat(tempPath).pipe(require_Schema$1.flatMap((stat) => Number(stat.size) === 0 ? require_Schema$1.void_ : require_Schema$1.fail(transferError({
98833
- code: "transfer_identity_conflict",
98834
- message: "Bulk transfer temporary path already contains data.",
98835
- transferId: input.transferId
98836
- })))))));
98837
- const now = /* @__PURE__ */ new Date();
98838
- yield* sql`
98839
- INSERT INTO workspace_bulk_transfers (
98840
- transfer_id, workspace_root, relative_path, target_path, temp_path, total_bytes,
98841
- whole_sha256, next_offset, state, expires_at, created_at, updated_at
98842
- ) VALUES (
98843
- ${input.transferId}, ${target.workspaceRoot}, ${target.relativePath}, ${target.targetPath},
98844
- ${tempPath}, ${input.totalBytes}, ${input.sha256}, 0, 'open',
98845
- ${new Date(now.getTime() + TRANSFER_TTL_MS).toISOString()}, ${now.toISOString()},
98846
- ${now.toISOString()}
98847
- )
98848
- `;
98849
- return {
98850
- transferId: input.transferId,
98851
- nextOffset: 0,
98852
- state: "open"
98853
- };
98854
- });
98855
- const beginWriteEffect = require_Schema$1.fn("WorkspaceBulkTransfer.beginWrite")(function* (input) {
98856
- const existing = yield* findTransfer(input.transferId);
98857
- return yield* existing === null ? createWrite(input) : resumeWrite(input, existing);
98858
- });
98859
- const beginWrite = (input) => withTransferLock(input.transferId, beginWriteEffect(input)).pipe(require_Schema$1.mapError(mapFailure("begin write", input.transferId)));
98860
- const acceptRetriedChunk = require_Schema$1.fn("WorkspaceBulkTransfer.acceptRetriedChunk")(function* (input, row) {
98861
- const accepted = (yield* sql`
98862
- SELECT chunk_offset AS "chunkOffset", chunk_bytes AS "chunkBytes",
98863
- chunk_sha256 AS "chunkSha256"
98864
- FROM workspace_bulk_transfer_chunks
98865
- WHERE transfer_id = ${input.transferId} AND chunk_offset = ${input.offset}
98866
- LIMIT 1
98867
- `)[0];
98868
- if (!(accepted !== void 0 && accepted.chunkBytes === input.bytes && accepted.chunkSha256 === input.sha256)) return yield* transferError({
98869
- code: "integrity_mismatch",
98870
- message: "Retried bulk transfer chunk differs from the accepted chunk.",
98871
- transferId: input.transferId
98872
- });
98873
- return {
98874
- transferId: input.transferId,
98875
- nextOffset: row.nextOffset,
98876
- duplicate: true
98877
- };
98878
- });
98879
- const validateChunkRange = require_Schema$1.fn("WorkspaceBulkTransfer.validateChunkRange")(function* (input, row, byteLength) {
98880
- if (input.offset !== row.nextOffset) return yield* transferError({
98881
- code: "offset_mismatch",
98882
- message: `Expected chunk offset ${row.nextOffset}, received ${input.offset}.`,
98883
- transferId: input.transferId,
98884
- retryable: true
98885
- });
98886
- const nextOffset = input.offset + byteLength;
98887
- if (nextOffset > row.totalBytes) return yield* transferError({
98888
- code: "size_limit_exceeded",
98889
- message: "Bulk transfer chunk exceeds the declared total byte count.",
98890
- transferId: input.transferId
98891
- });
98892
- return nextOffset;
98893
- });
98894
- const reconcileChunkBytes = require_Schema$1.fn("WorkspaceBulkTransfer.reconcileChunkBytes")(function* (input, row, bytes, nextOffset) {
98895
- const tempInfo = yield* fileSystem.stat(row.tempPath);
98896
- if (Number(tempInfo.size) > row.nextOffset) {
98897
- if (Number(tempInfo.size) !== nextOffset) return yield* transferError({
98898
- code: "integrity_mismatch",
98899
- message: "Bulk transfer temporary file cannot be reconciled with the next chunk.",
98900
- transferId: input.transferId
98901
- });
98902
- const recovered = (yield* fileSystem.readFile(row.tempPath)).slice(input.offset, nextOffset);
98903
- if ((yield* require_Schema$1.promise(() => sha256Hex(recovered))) !== input.sha256) return yield* transferError({
98904
- code: "integrity_mismatch",
98905
- message: "Bulk transfer temporary bytes differ from the retried chunk.",
98906
- transferId: input.transferId
98907
- });
98908
- return;
98909
- }
98910
- yield* fault("before-byte-write");
98911
- yield* require_Schema$1.scoped(fileSystem.open(row.tempPath, { flag: "r+" }).pipe(require_Schema$1.flatMap((file) => file.seek(input.offset, "start").pipe(require_Schema$1.flatMap(() => file.writeAll(bytes)), require_Schema$1.flatMap(() => file.sync)))));
98912
- yield* fault("after-byte-write-before-metadata");
98913
- });
98914
- const persistAcceptedChunk = require_Schema$1.fn("WorkspaceBulkTransfer.persistAcceptedChunk")(function* (input, nextOffset) {
98915
- const updatedAt = (/* @__PURE__ */ new Date()).toISOString();
98916
- yield* sql.withTransaction(sql`
98917
- INSERT INTO workspace_bulk_transfer_chunks (
98918
- transfer_id, chunk_offset, chunk_bytes, chunk_sha256
98919
- ) VALUES (${input.transferId}, ${input.offset}, ${input.bytes}, ${input.sha256})
98920
- ON CONFLICT DO NOTHING
98921
- `.pipe(require_Schema$1.flatMap(() => sql`
98922
- UPDATE workspace_bulk_transfers
98923
- SET next_offset = ${nextOffset}, updated_at = ${updatedAt}
98924
- WHERE transfer_id = ${input.transferId} AND next_offset = ${input.offset} AND state = 'open'
98925
- `)));
98926
- yield* fault("after-metadata-advance");
98927
- });
98928
- const writeChunkEffect = require_Schema$1.fn("WorkspaceBulkTransfer.writeChunk")(function* (input) {
98929
- const bytes = yield* require_Schema$1.promise(() => verifyBulkTransferChunk({
98930
- ...input,
98931
- kind: "chunk"
98932
- }));
98933
- const row = yield* requireOpenTransfer(input.transferId);
98934
- if (row.state === "completed") return yield* transferError({
98935
- code: "offset_mismatch",
98936
- message: "Bulk transfer is already complete.",
98937
- transferId: input.transferId
98938
- });
98939
- if (input.offset < row.nextOffset) return yield* acceptRetriedChunk(input, row);
98940
- const nextOffset = yield* validateChunkRange(input, row, bytes.byteLength);
98941
- yield* reconcileChunkBytes(input, row, bytes, nextOffset);
98942
- yield* persistAcceptedChunk(input, nextOffset);
98943
- return {
98944
- transferId: input.transferId,
98945
- nextOffset,
98946
- duplicate: false
98947
- };
98948
- });
98949
- const writeChunk = (input) => withTransferLock(input.transferId, writeChunkEffect(input)).pipe(require_Schema$1.mapError(mapFailure("write chunk", input.transferId)));
98950
- const commitWriteEffect = require_Schema$1.fn("WorkspaceBulkTransfer.commitWrite")(function* (input) {
98951
- const row = yield* requireOpenTransfer(input.transferId);
98952
- if (row.state === "completed") return {
98953
- transferId: row.transferId,
98954
- relativePath: row.relativePath,
98955
- totalBytes: row.totalBytes,
98956
- sha256: row.wholeSha256
98957
- };
98958
- if (row.nextOffset !== row.totalBytes) return yield* transferError({
98959
- code: "offset_mismatch",
98960
- message: `Bulk transfer is incomplete at byte ${row.nextOffset} of ${row.totalBytes}.`,
98961
- transferId: row.transferId,
98962
- retryable: true
98963
- });
98964
- if ((yield* resolveTarget({
98965
- cwd: row.workspaceRoot,
98966
- relativePath: row.relativePath,
98967
- createParent: false
98968
- })).targetPath !== row.targetPath) return yield* transferError({
98969
- code: "symlink_escape",
98970
- message: "Workspace file destination changed during transfer.",
98971
- transferId: row.transferId
98972
- });
98973
- const tempInfo = yield* fileSystem.stat(row.tempPath).pipe(require_Schema$1.catch_(() => require_Schema$1.succeed(null)));
98974
- const candidatePath = tempInfo === null ? row.targetPath : row.tempPath;
98975
- const candidate = yield* fileSystem.readFile(candidatePath);
98976
- if (candidate.byteLength !== row.totalBytes || (yield* require_Schema$1.promise(() => sha256Hex(candidate))) !== row.wholeSha256) return yield* transferError({
98977
- code: "integrity_mismatch",
98978
- message: "Bulk transfer payload does not match its declared size and digest.",
98979
- transferId: row.transferId
98980
- });
98981
- if (tempInfo !== null) {
98982
- yield* fileSystem.rename(row.tempPath, row.targetPath);
98983
- yield* fault("after-atomic-rename");
98984
- }
98985
- yield* sql`
98986
- UPDATE workspace_bulk_transfers SET state = 'completed', updated_at = ${(/* @__PURE__ */ new Date()).toISOString()}
98987
- WHERE transfer_id = ${row.transferId}
98988
- `;
98989
- yield* workspaceEntries.invalidate(row.workspaceRoot);
98990
- return {
98991
- transferId: row.transferId,
98992
- relativePath: row.relativePath,
98993
- totalBytes: row.totalBytes,
98994
- sha256: row.wholeSha256
98995
- };
98996
- });
98997
- const commitWrite = (input) => withTransferLock(input.transferId, commitWriteEffect(input)).pipe(require_Schema$1.mapError(mapFailure("commit write", input.transferId)));
98998
- const cancelWriteEffect = require_Schema$1.fn("WorkspaceBulkTransfer.cancelWrite")(function* (input) {
98999
- const row = yield* requireTransfer(input.transferId);
99000
- if (row.state !== "completed") {
99001
- yield* fileSystem.remove(row.tempPath, { force: true });
99002
- yield* sql`
99003
- UPDATE workspace_bulk_transfers SET state = 'cancelled', updated_at = ${(/* @__PURE__ */ new Date()).toISOString()}
99004
- WHERE transfer_id = ${input.transferId}
99005
- `;
99006
- }
99007
- return {
99008
- transferId: input.transferId,
99009
- state: row.state === "completed" ? "completed" : "cancelled"
99010
- };
99011
- });
99012
- const cancelWrite = (input) => withTransferLock(input.transferId, cancelWriteEffect(input)).pipe(require_Schema$1.mapError(mapFailure("cancel write", input.transferId)));
99013
- const cleanupExpiredEffect = require_Schema$1.fn("WorkspaceBulkTransfer.cleanupExpired")(function* () {
99014
- const now = (/* @__PURE__ */ new Date()).toISOString();
99015
- const rows = yield* sql`
99016
- SELECT transfer_id AS "transferId", temp_path AS "tempPath"
99017
- FROM workspace_bulk_transfers WHERE state = 'open' AND expires_at <= ${now}
99018
- `;
99019
- let expiredCount = 0;
99020
- for (const row of rows) if (yield* withTransferLock(row.transferId, require_Schema$1.gen(function* () {
99021
- const current = yield* findTransfer(row.transferId);
99022
- if (current === null || current.state !== "open" || Date.parse(current.expiresAt) > Date.parse(now)) return false;
99023
- yield* fileSystem.remove(current.tempPath, { force: true });
99024
- yield* sql`
99025
- UPDATE workspace_bulk_transfers SET state = 'expired', updated_at = ${now}
99026
- WHERE transfer_id = ${row.transferId} AND state = 'open' AND expires_at <= ${now}
99027
- `;
99028
- return true;
99029
- }))) expiredCount += 1;
99030
- return expiredCount;
99031
- });
99032
- const cleanupExpired = () => cleanupExpiredEffect().pipe(require_Schema$1.mapError(mapFailure("expiry cleanup")));
99033
- return {
99034
- readFile,
99035
- beginWrite,
99036
- writeChunk,
99037
- commitWrite,
99038
- cancelWrite,
99039
- cleanupExpired
99040
- };
99041
- });
99042
- const WorkspaceBulkTransferLive = require_Schema$1.effect(WorkspaceBulkTransfer, makeWorkspaceBulkTransfer());
99043
98694
  //#endregion
99044
98695
  //#region src/projectFile/readProjectFile.ts
99045
98696
  /**
@@ -99843,6 +99494,26 @@ const DevTunnelLive = require_Schema$1.effect(DevTunnel, require_Schema$1.gen(fu
99843
99494
  };
99844
99495
  }));
99845
99496
  //#endregion
99497
+ //#region src/workspace/Services/WorkspaceEntries.ts
99498
+ /**
99499
+ * WorkspaceEntries - Effect service contract for cached workspace entry search.
99500
+ *
99501
+ * Owns indexed workspace entry search plus cache invalidation for workspace
99502
+ * roots when the underlying filesystem changes.
99503
+ *
99504
+ * @module WorkspaceEntries
99505
+ */
99506
+ var WorkspaceEntriesError = class extends require_Schema$1.TaggedErrorClass()("WorkspaceEntriesError", {
99507
+ cwd: require_Schema$1.String,
99508
+ operation: require_Schema$1.String,
99509
+ detail: require_Schema$1.String,
99510
+ cause: require_Schema$1.optional(require_Schema$1.Defect)
99511
+ }) {};
99512
+ /**
99513
+ * WorkspaceEntries - Service tag for cached workspace entry search.
99514
+ */
99515
+ var WorkspaceEntries = class extends require_Schema$1.Service()("forge/workspace/Services/WorkspaceEntries") {};
99516
+ //#endregion
99846
99517
  //#region src/workspace/Services/WorkspaceFileSystem.ts
99847
99518
  /**
99848
99519
  * WorkspaceFileSystem - Effect service contract for workspace file mutations.
@@ -99864,6 +99535,9 @@ var WorkspaceFileSystemError = class extends require_Schema$1.TaggedErrorClass()
99864
99535
  */
99865
99536
  var WorkspaceFileSystem = class extends require_Schema$1.Service()("forge/workspace/Services/WorkspaceFileSystem") {};
99866
99537
  //#endregion
99538
+ //#region src/workspace/Services/WorkspaceBulkTransfer.ts
99539
+ var WorkspaceBulkTransfer = class extends require_Schema$1.Service()("forge/workspace/Services/WorkspaceBulkTransfer") {};
99540
+ //#endregion
99867
99541
  //#region src/workspace/Services/WorkspaceReadinessState.ts
99868
99542
  var WorkspaceReadinessState = class extends require_Schema$1.Service()("forge/workspace/Services/WorkspaceReadinessState") {};
99869
99543
  //#endregion
@@ -211344,56 +211018,169 @@ function hasConfiguredApexCredential(profiles) {
211344
211018
  }));
211345
211019
  }
211346
211020
  //#endregion
211347
- //#region src/provider/claudeCredentialContainment.ts
211021
+ //#region src/provider/claudeCredentialDirectory.ts
211348
211022
  /**
211349
- * Containment for provider credentials inherited by the Claude Agent SDK child.
211023
+ * Per-account credential directories for Claude turns.
211350
211024
  *
211351
- * Pure and dependency-free on purpose: ClaudeAdapter pulls in Effect platform
211352
- * services, the browser manager, and the server config, so a unit test that
211353
- * imports it cannot run in a worktree. This rule is cost-critical and must stay
211354
- * directly testable.
211025
+ * Claude Code keeps conversation transcripts under
211026
+ * `${CLAUDE_CONFIG_DIR}/projects/<cwd-slug>/<sessionId>.jsonl` and its login
211027
+ * under `.credentials.json`. `CLAUDE_SECURESTORAGE_CONFIG_DIR` moves only the
211028
+ * credential file, so each Provider Account can hold its own login while every
211029
+ * session file stays in one place and `--resume` keeps finding its target. See
211030
+ * `docs/adr/claude-accounts-split-credentials-not-sessions.md`.
211355
211031
  *
211356
- * Audit finding S1 (2026-07-25). The Claude Agent SDK reads ANTHROPIC_API_KEY /
211357
- * ANTHROPIC_AUTH_TOKEN from its own environment, and those TAKE PRECEDENCE over
211358
- * the user's claude.ai OAuth login — that precedence is exactly what the
211359
- * adapter relies on to redirect billing at the proxy.
211032
+ * That variable is not documented. Under
211033
+ * `docs/adr/unpublished-provider-surface-needs-a-fallback.md` the dependency is
211034
+ * only allowed because absence degrades to a named behaviour that already
211035
+ * works: a thread with no account runs on the Environment's own login. This
211036
+ * module sets the variable only when the resolved directory holds a credential
211037
+ * the CLI can use, because a missing file, or one whose access token a refusal
211038
+ * has blanked, makes the CLI report logged out and a live turn would fail.
211360
211039
  *
211361
- * So on a plain Claude turn (no endpoint profile, no proxy credential), which
211362
- * is supposed to run on the personal subscription, an ANTHROPIC_* key inherited
211363
- * from the server's environment would send the turn straight to
211364
- * api.anthropic.com on a metered account — never reaching
211365
- * /api/provider-egress/*, leaving the fail-closed billing gate inert.
211040
+ * Nothing writes these directories yet. #3049 adds the writer and keys each
211041
+ * directory on the account identity CMMD writes on the delivery receipt it
211042
+ * signs (`docs/adr/provider-account-identity-is-issued-by-cmmd.md`). Until then
211043
+ * every runtime takes the `no-account` skip.
211366
211044
  *
211367
- * Forge Cloud sprites were only incidentally safe (no Anthropic key present on
211368
- * the box). This makes containment an enforced property rather than an
211369
- * assumption about what the deployment environment happens to contain.
211045
+ * Free of Effect services on purpose, for the same reason as
211046
+ * `claudeCredentialContainment.ts`: ClaudeAdapter drags in Effect platform
211047
+ * services, the browser manager and the server config, so a unit test that
211048
+ * imports it cannot run in a worktree. This rule decides which account pays for
211049
+ * a turn, so it has to stay directly testable.
211370
211050
  */
211371
- /** Credentials the Claude Agent SDK will prefer over the user's OAuth login. */
211372
- const INHERITABLE_ANTHROPIC_CREDENTIAL_KEYS = ["ANTHROPIC_API_KEY", "ANTHROPIC_AUTH_TOKEN"];
211051
+ /** Repoints `.credentials.json` without moving the transcripts. */
211052
+ const CLAUDE_SECURESTORAGE_CONFIG_DIR_ENV = "CLAUDE_SECURESTORAGE_CONFIG_DIR";
211053
+ /** The one file Claude Code writes into the credential directory. */
211054
+ const CLAUDE_CREDENTIAL_FILE_NAME = ".credentials.json";
211373
211055
  /**
211374
- * Routing override the SDK would obey instead of the login's default endpoint.
211375
- * A CMMD cloud runtime exports it to point at the workspace provider-egress
211376
- * proxy; on a PERSONAL-Claude-in-VM runtime that must be dropped so the turn
211377
- * spends the on-box ~/.claude subscription (card 1359 Phase 2). Stripped ONLY
211378
- * when the runtime signals in-VM delivery — otherwise existing behavior (the
211379
- * inherited base URL survives) is preserved exactly.
211056
+ * Runtime env var naming the Provider Account this runtime's Claude turns
211057
+ * belong to. Nothing in Forge or CMMD sets it, so no runtime names an account
211058
+ * and each keeps its one login.
211059
+ *
211060
+ * It is a bridge that will not get a writer. #3073 settled the key in
211061
+ * `docs/adr/provider-account-identity-is-issued-by-cmmd.md`: the directory name
211062
+ * comes from the identity CMMD issues and writes on the delivery receipt it
211063
+ * signs, and this variable goes away. #3049 makes that change. The credential
211064
+ * generation was ruled out as a key: it rotates on every reconnect, and CMMD
211065
+ * uses it as the primary account's id, so keying on it would strand every
211066
+ * thread pinned to that account on a directory that stops receiving deliveries.
211067
+ *
211068
+ * Tests reach the `credentialDirectory` parameter on
211069
+ * `ClaudeAdapter.buildClaudeQueryEnv` directly.
211380
211070
  */
211381
- const ANTHROPIC_ENDPOINT_OVERRIDE_KEY = "ANTHROPIC_BASE_URL";
211071
+ const CLAUDE_PROVIDER_ACCOUNT_IDENTITY_ENV = "FORGE_CLAUDE_PROVIDER_ACCOUNT_IDENTITY";
211072
+ /** Directory under the server's base directory that holds one directory per account. */
211073
+ const CREDENTIAL_ROOT_DIRECTORY_NAME = "claude-credentials";
211074
+ const DEFAULT_CLAUDE_CONFIG_DIRECTORY_NAME = ".claude";
211075
+ const MAX_ACCOUNT_IDENTITY_LENGTH = 256;
211076
+ const DIRECTORY_NAME_HASH_LENGTH = 32;
211382
211077
  /**
211383
- * Remove inherited Anthropic credentials so the child falls back to the user's
211384
- * personal claude.ai login. Call this ONLY on the path where no base URL
211385
- * resolved; when the adapter has a profile or proxy credential it sets these
211386
- * variables deliberately, and that assignment must win.
211078
+ * Directory name for an account. A hash, so no email or display name reaches a
211079
+ * filesystem path and no path-illegal character can appear in one either.
211080
+ */
211081
+ function claudeCredentialDirectoryName(accountIdentity) {
211082
+ return `account-${(0, node_crypto.createHash)("sha256").update(accountIdentity, "utf8").digest("hex").slice(0, DIRECTORY_NAME_HASH_LENGTH)}`;
211083
+ }
211084
+ /** The account this runtime's Claude turns belong to, or null while the variable is unset. */
211085
+ function resolveClaudeProviderAccountIdentity(env) {
211086
+ const identity = env[CLAUDE_PROVIDER_ACCOUNT_IDENTITY_ENV]?.trim();
211087
+ if (!identity || identity.length > MAX_ACCOUNT_IDENTITY_LENGTH) return null;
211088
+ return identity;
211089
+ }
211090
+ /**
211091
+ * Where per-account credential directories live: `claude-credentials` under the
211092
+ * server's base directory, `ServerConfig.baseDir`, which is `FORGE_HOME` or
211093
+ * `~/.forge`. A delivery writer gets the same `ServerConfig`, so reader and
211094
+ * writer cannot disagree about the root. Always outside `CLAUDE_CONFIG_DIR`, so
211095
+ * one of these can never be mistaken for the main credential store, and a stray
211096
+ * read of the config directory cannot walk into another account's login.
211097
+ */
211098
+ function claudeCredentialDirectoryRoot(env, baseDir) {
211099
+ if (!(0, node_path.isAbsolute)(baseDir)) return null;
211100
+ const root = (0, node_path.resolve)(baseDir, CREDENTIAL_ROOT_DIRECTORY_NAME);
211101
+ const configDirectory = claudeConfigDirectory(env);
211102
+ if (configDirectory && isPathWithinRoot({
211103
+ relative: node_path.relative,
211104
+ isAbsolute: node_path.isAbsolute
211105
+ }, configDirectory, root)) return null;
211106
+ return root;
211107
+ }
211108
+ /** Absolute credential directory for one account, or null when either half is missing. */
211109
+ function resolveClaudeCredentialDirectory(input) {
211110
+ if (!input.accountIdentity) return null;
211111
+ const root = claudeCredentialDirectoryRoot(input.env, input.baseDir);
211112
+ if (!root) return null;
211113
+ return (0, node_path.join)(root, claudeCredentialDirectoryName(input.accountIdentity));
211114
+ }
211115
+ /**
211116
+ * Decide whether this turn may point at a per-account credential directory.
211387
211117
  *
211388
- * `stripEndpointOverride` additionally drops ANTHROPIC_BASE_URL — pass it only on
211389
- * a personal-Claude-in-VM runtime (gated), never by default, so existing turns
211390
- * that rely on an inherited base URL are untouched.
211118
+ * The cheap checks run first and the capability probe runs last, so a runtime
211119
+ * with no usable credential never spawns the CLI. The probe's error channel is
211120
+ * empty, so nothing here catches.
211121
+ *
211122
+ * A skip is not a fallback onto the default account. With no account the turn
211123
+ * runs on the Environment's own login, unchanged. A thread pinned to an account
211124
+ * whose credential is missing is refused by name under
211125
+ * `docs/adr/provider-account-identity-is-issued-by-cmmd.md`, and #3049 turns
211126
+ * `no-usable-credential`, and `capability-not-supported` for a pinned account,
211127
+ * into those refusals. Until #3049 nothing names an account, so `no-account` is
211128
+ * the only skip a runtime sees.
211129
+ */
211130
+ function decideClaudeCredentialDirectory(input) {
211131
+ return require_Schema$1.gen(function* () {
211132
+ if (!input.accountIdentity) return skip("no-account");
211133
+ if (input.env["CLAUDE_SECURESTORAGE_CONFIG_DIR"]?.trim()) return skip("operator-override");
211134
+ const directory = resolveClaudeCredentialDirectory(input);
211135
+ if (!directory) return skip("no-root");
211136
+ if (!holdsUsableCredential(directory)) return skip("no-usable-credential");
211137
+ return (yield* input.probeSupport) === "supported" ? set(directory) : skip("capability-not-supported");
211138
+ });
211139
+ }
211140
+ /**
211141
+ * True when `.credentials.json` parses and carries a non-empty access token.
211142
+ *
211143
+ * File presence is not readiness. A refused credential is rewritten in place
211144
+ * with `accessToken: ""`, so a file that exists can still leave the CLI logged
211145
+ * out. Only the token's length is checked. The value is never logged, returned
211146
+ * or kept.
211391
211147
  */
211392
- function withoutInheritedAnthropicCredentials(env, options) {
211393
- const contained = { ...env };
211394
- for (const key of INHERITABLE_ANTHROPIC_CREDENTIAL_KEYS) delete contained[key];
211395
- if (options?.stripEndpointOverride) delete contained[ANTHROPIC_ENDPOINT_OVERRIDE_KEY];
211396
- return contained;
211148
+ function holdsUsableCredential(directory) {
211149
+ const file = (0, node_path.join)(directory, CLAUDE_CREDENTIAL_FILE_NAME);
211150
+ try {
211151
+ if (!(0, node_fs.statSync)(file).isFile()) return false;
211152
+ return hasAccessToken(JSON.parse((0, node_fs.readFileSync)(file, "utf8")));
211153
+ } catch {
211154
+ return false;
211155
+ }
211156
+ }
211157
+ function hasAccessToken(credential) {
211158
+ if (typeof credential !== "object" || credential === null) return false;
211159
+ const oauth = credential.claudeAiOauth;
211160
+ if (typeof oauth !== "object" || oauth === null) return false;
211161
+ const token = oauth.accessToken;
211162
+ return typeof token === "string" && token.length > 0;
211163
+ }
211164
+ function set(directory) {
211165
+ return {
211166
+ outcome: "set",
211167
+ directory
211168
+ };
211169
+ }
211170
+ function skip(reason) {
211171
+ return {
211172
+ outcome: "skipped",
211173
+ reason
211174
+ };
211175
+ }
211176
+ function claudeConfigDirectory(env) {
211177
+ const configured = env.CLAUDE_CONFIG_DIR?.trim();
211178
+ if (configured) return configured;
211179
+ try {
211180
+ return (0, node_path.join)((0, node_os.homedir)(), DEFAULT_CLAUDE_CONFIG_DIRECTORY_NAME);
211181
+ } catch {
211182
+ return null;
211183
+ }
211397
211184
  }
211398
211185
  //#endregion
211399
211186
  //#region src/provider/personalClaudeInVm.ts
@@ -211463,160 +211250,76 @@ function personalClaudeGenerationHash(env = process.env) {
211463
211250
  }
211464
211251
  }
211465
211252
  //#endregion
211466
- //#region src/provider/claudeCredentialDirectory.ts
211253
+ //#region src/provider/claudeCredentialContainment.ts
211467
211254
  /**
211468
- * Per-account credential directories for Claude turns.
211469
- *
211470
- * Claude Code keeps conversation transcripts under
211471
- * `${CLAUDE_CONFIG_DIR}/projects/<cwd-slug>/<sessionId>.jsonl` and its login
211472
- * under `.credentials.json`. `CLAUDE_SECURESTORAGE_CONFIG_DIR` moves only the
211473
- * credential file, so each Provider Account can hold its own login while every
211474
- * session file stays in one place and `--resume` keeps finding its target. See
211475
- * `docs/adr/claude-accounts-split-credentials-not-sessions.md`.
211255
+ * Containment for provider credentials inherited by the Claude Agent SDK child.
211476
211256
  *
211477
- * That variable is not documented. Under
211478
- * `docs/adr/unpublished-provider-surface-needs-a-fallback.md` the dependency is
211479
- * only allowed because absence degrades to a named behaviour that already
211480
- * works: one account per Environment, exactly what runs today. This module is
211481
- * the gate that keeps the degrade honest. It sets the variable only when a
211482
- * readable `.credentials.json` is already sitting in the resolved directory,
211483
- * because a missing or empty directory makes the CLI report logged out and a
211484
- * live turn would fail. Nothing writes these directories yet, so every runtime
211485
- * today takes the skip path.
211257
+ * Free of Effect services on purpose: ClaudeAdapter pulls in Effect platform
211258
+ * services, the browser manager, and the server config, so a unit test that
211259
+ * imports it cannot run in a worktree. This rule is cost-critical and must stay
211260
+ * directly testable.
211486
211261
  *
211487
- * Pure and dependency-free on purpose, for the same reason as
211488
- * `claudeCredentialContainment.ts`: ClaudeAdapter drags in Effect platform
211489
- * services, the browser manager and the server config, so a unit test that
211490
- * imports it cannot run in a worktree. This rule decides which account pays for
211491
- * a turn, so it has to stay directly testable.
211492
- */
211493
- /** Repoints `.credentials.json` without moving the transcripts. */
211494
- const CLAUDE_SECURESTORAGE_CONFIG_DIR_ENV = "CLAUDE_SECURESTORAGE_CONFIG_DIR";
211495
- /** The one file Claude Code writes into the credential directory. */
211496
- const CLAUDE_CREDENTIAL_FILE_NAME = ".credentials.json";
211497
- /**
211498
- * Runtime env var naming the Provider Account this runtime's Claude turns
211499
- * belong to.
211262
+ * Audit finding S1 (2026-07-25). The Claude Agent SDK reads ANTHROPIC_API_KEY /
211263
+ * ANTHROPIC_AUTH_TOKEN from its own environment, and those TAKE PRECEDENCE over
211264
+ * the user's claude.ai OAuth login — that precedence is exactly what the
211265
+ * adapter relies on to redirect billing at the proxy.
211500
211266
  *
211501
- * NOTHING SETS THIS. It has no writer anywhere in Forge or CMMD, so as shipped
211502
- * the per-account lane can never turn on and every runtime keeps today's single
211503
- * account. The writer waits on issue 3073, which decides which identity keys
211504
- * the directory. That has to be settled first: the credential generation
211505
- * rotates on every re-delivery, so keying on it would give a new directory
211506
- * each time and defeat the point.
211267
+ * So on a plain Claude turn (no endpoint profile, no proxy credential), which
211268
+ * is supposed to run on the personal subscription, an ANTHROPIC_* key inherited
211269
+ * from the server's environment would send the turn straight to
211270
+ * api.anthropic.com on a metered account — never reaching
211271
+ * /api/provider-egress/*, leaving the fail-closed billing gate inert.
211507
211272
  *
211508
- * It is also not the seam. The seam is the `credentialDirectory` parameter on
211509
- * `ClaudeAdapter.buildClaudeQueryEnv`, which is what tests and callers drive.
211510
- * This variable is one bridge into that parameter, and the delivery step is
211511
- * free to replace it with something better rather than adopt it.
211273
+ * Forge Cloud sprites were only incidentally safe (no Anthropic key present on
211274
+ * the box). This makes containment an enforced property rather than an
211275
+ * assumption about what the deployment environment happens to contain.
211512
211276
  */
211513
- const CLAUDE_PROVIDER_ACCOUNT_IDENTITY_ENV = "FORGE_CLAUDE_PROVIDER_ACCOUNT_IDENTITY";
211514
- /** Operator escape hatch for the directory root, mostly for local debugging. */
211515
- const CLAUDE_CREDENTIAL_ROOT_ENV = "FORGE_CLAUDE_CREDENTIAL_ROOT";
211516
- const CREDENTIAL_ROOT_DIRECTORY_NAME = "claude-credentials";
211517
- const FALLBACK_ROOT_DIRECTORY_NAME = ".forge-claude-credentials";
211518
- const DEFAULT_CLAUDE_CONFIG_DIRECTORY_NAME = ".claude";
211519
- const MAX_ACCOUNT_IDENTITY_LENGTH = 256;
211520
- const DIRECTORY_NAME_HASH_LENGTH = 32;
211277
+ /** Credentials the Claude Agent SDK will prefer over the user's OAuth login. */
211278
+ const INHERITABLE_ANTHROPIC_CREDENTIAL_KEYS = ["ANTHROPIC_API_KEY", "ANTHROPIC_AUTH_TOKEN"];
211521
211279
  /**
211522
- * Directory name for an account. A hash, so no email or display name reaches a
211523
- * filesystem path and no path-illegal character can appear in one either.
211280
+ * Routing override the SDK would obey instead of the login's default endpoint.
211281
+ * A CMMD cloud runtime exports it to point at the workspace provider-egress
211282
+ * proxy; on a PERSONAL-Claude-in-VM runtime that must be dropped so the turn
211283
+ * spends the on-box ~/.claude subscription (card 1359 Phase 2). Stripped ONLY
211284
+ * when the runtime signals in-VM delivery — otherwise existing behavior (the
211285
+ * inherited base URL survives) is preserved exactly.
211524
211286
  */
211525
- function claudeCredentialDirectoryName(accountIdentity) {
211526
- return `account-${(0, node_crypto.createHash)("sha256").update(accountIdentity, "utf8").digest("hex").slice(0, DIRECTORY_NAME_HASH_LENGTH)}`;
211527
- }
211528
- /** The account this runtime's Claude turns belong to, or null on every runtime today. */
211529
- function resolveClaudeProviderAccountIdentity(env) {
211530
- const identity = env[CLAUDE_PROVIDER_ACCOUNT_IDENTITY_ENV]?.trim();
211531
- if (!identity || identity.length > MAX_ACCOUNT_IDENTITY_LENGTH) return null;
211532
- return identity;
211533
- }
211287
+ const ANTHROPIC_ENDPOINT_OVERRIDE_KEY = "ANTHROPIC_BASE_URL";
211534
211288
  /**
211535
- * Where per-account credential directories live. Always outside
211536
- * `CLAUDE_CONFIG_DIR`, so one of them can never be mistaken for the main
211537
- * credential store, and so a stray read of the config directory cannot walk
211538
- * into another account's login.
211289
+ * Remove inherited Anthropic credentials so the child falls back to the user's
211290
+ * personal claude.ai login. Call this ONLY on the path where no base URL
211291
+ * resolved; when the adapter has a profile or proxy credential it sets these
211292
+ * variables deliberately, and that assignment must win.
211293
+ *
211294
+ * `stripEndpointOverride` additionally drops ANTHROPIC_BASE_URL — pass it only on
211295
+ * a personal-Claude-in-VM runtime (gated), never by default, so existing turns
211296
+ * that rely on an inherited base URL are untouched.
211539
211297
  */
211540
- function claudeCredentialDirectoryRoot(env) {
211541
- const root = credentialRootCandidate(env);
211542
- if (!root || !(0, node_path.isAbsolute)(root)) return null;
211543
- return isInside(root, claudeConfigDirectory(env)) ? null : (0, node_path.resolve)(root);
211544
- }
211545
- /** Absolute credential directory for one account, or null when either half is missing. */
211546
- function resolveClaudeCredentialDirectory(input) {
211547
- if (!input.accountIdentity) return null;
211548
- const root = claudeCredentialDirectoryRoot(input.env);
211549
- if (!root) return null;
211550
- return (0, node_path.join)(root, claudeCredentialDirectoryName(input.accountIdentity));
211298
+ function withoutInheritedAnthropicCredentials(env, options) {
211299
+ const contained = { ...env };
211300
+ for (const key of INHERITABLE_ANTHROPIC_CREDENTIAL_KEYS) delete contained[key];
211301
+ if (options?.stripEndpointOverride) delete contained[ANTHROPIC_ENDPOINT_OVERRIDE_KEY];
211302
+ return contained;
211551
211303
  }
211552
211304
  /**
211553
- * Decide whether this turn may point at a per-account credential directory.
211305
+ * The environment a Claude child gets when it runs on the personal
211306
+ * subscription. Server-only secrets are scrubbed, inherited Anthropic keys are
211307
+ * removed so they cannot outrank the login, and the credential directory is set
211308
+ * only when one was decided. The turn's personal-subscription branch and the
211309
+ * capability probe both build their environment here, so the probe asks about
211310
+ * the setup the turn will run with.
211554
211311
  *
211555
- * The cheap checks run first and the capability probe runs last, so a runtime
211556
- * with no delivered credential never spawns the CLI. Any failure is a skip, so
211557
- * the worst outcome is today's behaviour.
211558
- */
211559
- async function decideClaudeCredentialDirectory(input) {
211560
- if (!input.accountIdentity) return skip("no-account");
211561
- if (input.env["CLAUDE_SECURESTORAGE_CONFIG_DIR"]?.trim()) return skip("operator-override");
211562
- const directory = resolveClaudeCredentialDirectory(input);
211563
- if (!directory) return skip("no-root");
211564
- if (!(input.credentialFilePresent ?? hasReadableCredentialFile)(directory)) return skip("no-credential-file");
211565
- return await probeSupportSafely(input.probeSupport) === "supported" ? {
211566
- outcome: "set",
211567
- directory
211568
- } : skip("capability-not-supported");
211569
- }
211570
- /** True when `.credentials.json` is in the directory and readable right now. */
211571
- function hasReadableCredentialFile(directory) {
211572
- const file = (0, node_path.join)(directory, CLAUDE_CREDENTIAL_FILE_NAME);
211573
- try {
211574
- if (!(0, node_fs.statSync)(file).isFile()) return false;
211575
- (0, node_fs.accessSync)(file, node_fs.constants.R_OK);
211576
- return true;
211577
- } catch {
211578
- return false;
211579
- }
211580
- }
211581
- function skip(reason) {
211312
+ * The endpoint override goes only when `personalClaudeInVmEnabled` says this
211313
+ * runtime is on the personal Claude lane. That check also reads the activation
211314
+ * marker CMMD writes on disk (#3081), so a Sprite switched on in place, which
211315
+ * has the marker and no env var until it restarts, drops the override too.
211316
+ */
211317
+ function claudeSubscriptionChildEnv(processEnv, credentialDirectory) {
211582
211318
  return {
211583
- outcome: "skipped",
211584
- reason
211319
+ ...withoutInheritedAnthropicCredentials(scrubForgeProviderChildEnvSecrets(processEnv), { stripEndpointOverride: personalClaudeInVmEnabled(processEnv) }),
211320
+ ...credentialDirectory ? { [CLAUDE_SECURESTORAGE_CONFIG_DIR_ENV]: credentialDirectory } : {}
211585
211321
  };
211586
211322
  }
211587
- /** `inconclusive` is treated as `unsupported`, and so is a probe that throws. */
211588
- async function probeSupportSafely(probeSupport) {
211589
- try {
211590
- return await probeSupport();
211591
- } catch {
211592
- return "inconclusive";
211593
- }
211594
- }
211595
- function credentialRootCandidate(env) {
211596
- const explicit = env[CLAUDE_CREDENTIAL_ROOT_ENV]?.trim();
211597
- if (explicit) return explicit;
211598
- const forgeHome = env.FORGE_HOME?.trim();
211599
- if (forgeHome) return (0, node_path.join)(forgeHome, CREDENTIAL_ROOT_DIRECTORY_NAME);
211600
- try {
211601
- return (0, node_path.join)((0, node_os.homedir)(), FALLBACK_ROOT_DIRECTORY_NAME);
211602
- } catch {
211603
- return null;
211604
- }
211605
- }
211606
- function claudeConfigDirectory(env) {
211607
- const configured = env.CLAUDE_CONFIG_DIR?.trim();
211608
- if (configured) return configured;
211609
- try {
211610
- return (0, node_path.join)((0, node_os.homedir)(), DEFAULT_CLAUDE_CONFIG_DIRECTORY_NAME);
211611
- } catch {
211612
- return null;
211613
- }
211614
- }
211615
- function isInside(candidate, parent) {
211616
- if (!parent) return false;
211617
- const step = (0, node_path.relative)((0, node_path.resolve)(parent), (0, node_path.resolve)(candidate));
211618
- return step === "" || !step.startsWith("..") && !(0, node_path.isAbsolute)(step);
211619
- }
211620
211323
  //#endregion
211621
211324
  //#region src/provider/claudeCredentialDirectoryProbe.ts
211622
211325
  /**
@@ -211629,6 +211332,11 @@ function isInside(candidate, parent) {
211629
211332
  * A runtime with no credential at all is logged out either way and cannot
211630
211333
  * discriminate, which is `inconclusive`, and callers read that as unsupported.
211631
211334
  *
211335
+ * An answer only counts for the binary and environment the turn will use. The
211336
+ * probe spawns the `binaryPath` the turn hands the SDK, and builds its
211337
+ * environment with `claudeSubscriptionChildEnv`, the same builder as the turn's
211338
+ * personal-subscription branch.
211339
+ *
211632
211340
  * `claude auth status` is a pure read. It sends no prompt and leaves
211633
211341
  * `.credentials.json` byte-identical, so probing spends no subscription usage.
211634
211342
  * It prints JSON on stdout and exits 1 when logged out, so the exit code is not
@@ -211644,38 +211352,62 @@ const VERSION_ARGS = ["--version"];
211644
211352
  * hung or unkillable process costs a few seconds and an `inconclusive`.
211645
211353
  */
211646
211354
  const PROBE_TIMEOUT_MS = 5e3;
211355
+ const PROBE_OUTPUT_LIMIT_BYTES = 1024 * 1024;
211647
211356
  const PROBE_DIRECTORY_PREFIX = "forge-claude-capability-";
211648
211357
  const VERSION_PATTERN = /\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?/;
211649
211358
  function makeClaudeCredentialDirectoryProbe(deps) {
211650
211359
  const answers = /* @__PURE__ */ new Map();
211651
- return { support: async () => {
211360
+ const answer = async () => {
211652
211361
  const version = await readVersionSafely(deps);
211653
211362
  if (version === null) return runProbe(deps);
211654
211363
  const cached = answers.get(version);
211655
211364
  if (cached) return cached;
211656
211365
  const pending = runProbe(deps);
211657
211366
  answers.set(version, pending);
211367
+ const forget = () => {
211368
+ if (answers.get(version) === pending) answers.delete(version);
211369
+ };
211370
+ pending.then((result) => {
211371
+ if (result === "inconclusive") forget();
211372
+ }, forget);
211658
211373
  return pending;
211659
- } };
211374
+ };
211375
+ return { support: require_Schema$1.tryPromise(answer).pipe(require_Schema$1.orElseSucceed(() => "inconclusive")) };
211660
211376
  }
211661
211377
  /**
211662
- * One probe per binary path for the life of the process. The probe spawns the
211663
- * CLI twice, and every Claude session start would otherwise pay for it.
211378
+ * One probe per binary path for the life of the process. A conclusive answer is
211379
+ * cached per binary version, so the two `auth status` spawns run once per path
211380
+ * and version. `claude --version` still spawns on every call, which is how an
211381
+ * in-place CLI upgrade gets a fresh answer.
211664
211382
  */
211665
211383
  const probesByBinaryPath = /* @__PURE__ */ new Map();
211384
+ const INCONCLUSIVE_PROBE = { support: require_Schema$1.succeed("inconclusive") };
211385
+ /**
211386
+ * The probe for the binary a turn with this `binaryPath` setting runs.
211387
+ *
211388
+ * `buildClaudeQueryOptions` hands the setting to the SDK as
211389
+ * `pathToClaudeCodeExecutable`, and the SDK spawns it as given, so the default
211390
+ * `claude` goes through PATH for the probe exactly as for the turn. The SDK
211391
+ * picks a bundled build of its own only when the path is empty, and the
211392
+ * settings schema turns an empty value into `claude`. Should one arrive empty
211393
+ * anyway, the answer is `inconclusive` rather than an answer about another
211394
+ * binary.
211395
+ */
211666
211396
  function claudeCredentialDirectoryProbeFor(binaryPath) {
211667
- const binary = binaryPath?.trim() || "claude";
211668
- const existing = probesByBinaryPath.get(binary);
211397
+ if (!binaryPath) return INCONCLUSIVE_PROBE;
211398
+ const existing = probesByBinaryPath.get(binaryPath);
211669
211399
  if (existing) return existing;
211670
- const probe = makeClaudeCredentialDirectoryProbe(claudeCredentialDirectoryProbeDeps(binary));
211671
- probesByBinaryPath.set(binary, probe);
211400
+ const probe = makeClaudeCredentialDirectoryProbe(claudeCredentialDirectoryProbeDeps(binaryPath));
211401
+ probesByBinaryPath.set(binaryPath, probe);
211672
211402
  return probe;
211673
211403
  }
211674
- function claudeCredentialDirectoryProbeDeps(binaryPath) {
211404
+ const spawnClaude = (file, args, options) => execFileAsync$3(file, [...args], { ...options });
211405
+ function claudeCredentialDirectoryProbeDeps(binaryPath, options = {}) {
211406
+ const run = (args, credentialDirectory) => runClaudeCommand$1(options.execFile ?? spawnClaude, binaryPath, args, claudeSubscriptionChildEnv(options.baseEnv ?? process.env, credentialDirectory));
211675
211407
  return {
211676
- runAuthStatus: ({ credentialDirectory }) => runClaudeCommand$1(binaryPath, AUTH_STATUS_ARGS, credentialDirectory),
211408
+ runAuthStatus: ({ credentialDirectory }) => run(AUTH_STATUS_ARGS, credentialDirectory),
211677
211409
  readBinaryVersion: async () => {
211678
- const { stdout } = await runClaudeCommand$1(binaryPath, VERSION_ARGS, null);
211410
+ const { stdout } = await run(VERSION_ARGS, null);
211679
211411
  return VERSION_PATTERN.exec(stdout)?.[0] ?? null;
211680
211412
  },
211681
211413
  makeProbeDirectory: () => (0, node_fs_promises.mkdtemp)((0, node_path.join)((0, node_os.tmpdir)(), PROBE_DIRECTORY_PREFIX)),
@@ -211685,6 +211417,7 @@ function claudeCredentialDirectoryProbeDeps(binaryPath) {
211685
211417
  })
211686
211418
  };
211687
211419
  }
211420
+ /** Rejects when the probe could not tell. `support` turns that into `inconclusive`. */
211688
211421
  async function runProbe(deps) {
211689
211422
  let probeDirectory = null;
211690
211423
  try {
@@ -211694,8 +211427,6 @@ async function runProbe(deps) {
211694
211427
  const redirected = parseLoggedIn(await runBounded(deps, () => deps.runAuthStatus({ credentialDirectory: directory })));
211695
211428
  if (redirected === false) return "supported";
211696
211429
  return redirected === true ? "unsupported" : "inconclusive";
211697
- } catch {
211698
- return "inconclusive";
211699
211430
  } finally {
211700
211431
  if (probeDirectory !== null) await deps.removeProbeDirectory(probeDirectory).catch(() => void 0);
211701
211432
  }
@@ -211717,15 +211448,15 @@ async function runBounded(deps, operation) {
211717
211448
  let timer;
211718
211449
  try {
211719
211450
  return await Promise.race([operation(), new Promise((_resolve, reject) => {
211720
- timer = setTimeout(() => reject(/* @__PURE__ */ new Error("claude auth status timed out")), timeoutMs);
211451
+ timer = setTimeout(() => reject(/* @__PURE__ */ new Error("claude probe call timed out")), timeoutMs);
211721
211452
  })]);
211722
211453
  } finally {
211723
211454
  if (timer !== void 0) clearTimeout(timer);
211724
211455
  }
211725
211456
  }
211726
211457
  /** The `loggedIn` field, or null when the output does not carry one. */
211727
- function parseLoggedIn(run) {
211728
- const parsed = parseJsonObject$1(run.stdout);
211458
+ function parseLoggedIn(output) {
211459
+ const parsed = parseJsonObject$1(output.stdout);
211729
211460
  return typeof parsed?.loggedIn === "boolean" ? parsed.loggedIn : null;
211730
211461
  }
211731
211462
  function parseJsonObject$1(output) {
@@ -211740,27 +211471,18 @@ function parseJsonObject$1(output) {
211740
211471
  return null;
211741
211472
  }
211742
211473
  }
211743
- async function runClaudeCommand$1(binaryPath, args, credentialDirectory) {
211474
+ async function runClaudeCommand$1(execFileImpl, binaryPath, args, env) {
211744
211475
  try {
211745
- const { stdout } = await execFileAsync$3(binaryPath, [...args], {
211746
- env: {
211747
- ...process.env,
211748
- ...credentialDirectory ? { [CLAUDE_SECURESTORAGE_CONFIG_DIR_ENV]: credentialDirectory } : {}
211749
- },
211750
- maxBuffer: 1024 * 1024,
211476
+ const { stdout } = await execFileImpl(binaryPath, args, {
211477
+ env,
211478
+ maxBuffer: PROBE_OUTPUT_LIMIT_BYTES,
211751
211479
  timeout: PROBE_TIMEOUT_MS
211752
211480
  });
211753
- return {
211754
- stdout,
211755
- exitCode: 0
211756
- };
211481
+ return { stdout };
211757
211482
  } catch (cause) {
211758
211483
  const failure = cause;
211759
211484
  if (typeof failure.stdout !== "string") throw cause;
211760
- return {
211761
- stdout: failure.stdout,
211762
- exitCode: typeof failure.code === "number" ? failure.code : null
211763
- };
211485
+ return { stdout: failure.stdout };
211764
211486
  }
211765
211487
  }
211766
211488
  //#endregion
@@ -213277,10 +212999,12 @@ function claudeForgeAttributionPrompt() {
213277
212999
  * `credentialDirectory` points this turn at one Provider Account's own
213278
213000
  * `.credentials.json` without moving the transcripts, which is what lets an
213279
213001
  * account switch keep `--resume` working
213280
- * (`docs/adr/claude-accounts-split-credentials-not-sessions.md`). It is null on
213281
- * every runtime today: `decideClaudeCredentialDirectory` only hands back a
213282
- * directory that already holds a readable credential file, and nothing writes
213283
- * one yet, so the environment stays byte-for-byte what it was.
213002
+ * (`docs/adr/claude-accounts-split-credentials-not-sessions.md`). It stays null
213003
+ * until #3049 gives the directories a writer, keyed on the account identity on
213004
+ * the delivery receipt CMMD signs. `decideClaudeCredentialDirectory` only hands
213005
+ * back a directory that already holds a usable credential, and while none
213006
+ * exists the environment is byte-for-byte what it would be without this
213007
+ * parameter.
213284
213008
  *
213285
213009
  * It applies to the personal-subscription branch below and to nothing else. On
213286
213010
  * the profile / proxy branch the credential file is not what authenticates the
@@ -213290,10 +213014,7 @@ function claudeForgeAttributionPrompt() {
213290
213014
  function buildClaudeQueryEnv(baseProcessEnv, endpointProfile, claudeSettings, providerProxyCredential, credentialDirectory) {
213291
213015
  const baseEnv = scrubForgeProviderChildEnvSecrets(baseProcessEnv);
213292
213016
  const effectiveBaseUrl = resolveClaudeQueryBaseUrl(endpointProfile, claudeSettings, providerProxyCredential);
213293
- if (!effectiveBaseUrl) return {
213294
- ...withoutInheritedAnthropicCredentials(baseEnv, { stripEndpointOverride: personalClaudeInVmEnabled(baseProcessEnv) }),
213295
- ...credentialDirectory ? { [CLAUDE_SECURESTORAGE_CONFIG_DIR_ENV]: credentialDirectory } : {}
213296
- };
213017
+ if (!effectiveBaseUrl) return claudeSubscriptionChildEnv(baseProcessEnv, credentialDirectory);
213297
213018
  const effectiveApiKey = resolveClaudeQueryApiKey(endpointProfile, claudeSettings, providerProxyCredential?.token ?? null);
213298
213019
  return {
213299
213020
  ...baseEnv,
@@ -215562,11 +215283,17 @@ const makeClaudeAdapter = require_Schema$1.fn("makeClaudeAdapter")(function* (op
215562
215283
  })));
215563
215284
  const claudeSettings = serverSettings.providers.claudeAgent;
215564
215285
  const claudeBinaryPath = claudeSettings.binaryPath;
215565
- const claudeCredentialDirectory = yield* require_Schema$1.tryPromise(() => decideClaudeCredentialDirectory({
215286
+ const credentialDirectoryDecision = yield* decideClaudeCredentialDirectory({
215566
215287
  env: process.env,
215288
+ baseDir: serverConfig.baseDir,
215567
215289
  accountIdentity: resolveClaudeProviderAccountIdentity(process.env),
215568
- probeSupport: () => claudeCredentialDirectoryProbeFor(claudeBinaryPath).support()
215569
- })).pipe(require_Schema$1.map((decision) => decision.outcome === "set" ? decision.directory : null), require_Schema$1.orElseSucceed(() => null));
215290
+ probeSupport: claudeCredentialDirectoryProbeFor(claudeBinaryPath).support
215291
+ });
215292
+ if (credentialDirectoryDecision.outcome === "skipped") {
215293
+ const skipLine = `Claude credential directory skipped: ${credentialDirectoryDecision.reason}`;
215294
+ yield* credentialDirectoryDecision.reason === "no-account" ? require_Schema$1.logDebug(skipLine) : require_Schema$1.logWarning(skipLine);
215295
+ }
215296
+ const claudeCredentialDirectory = credentialDirectoryDecision.outcome === "set" ? credentialDirectoryDecision.directory : null;
215570
215297
  const modelConfig = resolveClaudeSessionModelConfig(input.modelSelection, claudeSettings.endpointProfiles);
215571
215298
  const providerProxyCredential = yield* issueClaudeProviderProxyCredential(modelConfig.endpointProfile, modelConfig.modelSlug ?? modelConfig.effectiveApiModelId ?? "unknown", threadId);
215572
215299
  const permissionMode = permissionModeForRuntimeMode(input.runtimeMode);
@@ -293213,6 +292940,382 @@ const makeWorkspaceFileSystem = require_Schema$1.gen(function* () {
293213
292940
  });
293214
292941
  const WorkspaceFileSystemLive = require_Schema$1.effect(WorkspaceFileSystem, makeWorkspaceFileSystem);
293215
292942
  //#endregion
292943
+ //#region src/workspace/Layers/WorkspaceBulkTransfer.ts
292944
+ const TRANSFER_TTL_MS = 1440 * 60 * 1e3;
292945
+ const TRANSFER_LOCK_STRIPE_COUNT = 64;
292946
+ function transferError(input) {
292947
+ return new BulkTransferError({
292948
+ code: input.code,
292949
+ message: input.message,
292950
+ transferId: input.transferId ?? null,
292951
+ retryable: input.retryable ?? false,
292952
+ ...input.cause === void 0 ? {} : { cause: input.cause }
292953
+ });
292954
+ }
292955
+ function mapFailure(operation, transferId) {
292956
+ return (cause) => require_Schema$1.is(BulkTransferError)(cause) ? cause : transferError({
292957
+ code: "io_error",
292958
+ message: `Workspace bulk transfer failed during ${operation}.`,
292959
+ ...transferId === void 0 ? {} : { transferId },
292960
+ retryable: true,
292961
+ cause
292962
+ });
292963
+ }
292964
+ function transferLockStripe(transferId) {
292965
+ let hash = 2166136261;
292966
+ for (let index = 0; index < transferId.length; index += 1) hash = Math.imul(hash ^ transferId.charCodeAt(index), 16777619);
292967
+ return (hash >>> 0) % TRANSFER_LOCK_STRIPE_COUNT;
292968
+ }
292969
+ const makeWorkspaceBulkTransfer = (options = {}) => require_Schema$1.gen(function* () {
292970
+ const fileSystem = yield* require_Path.FileSystem;
292971
+ const path = yield* require_Path.Path;
292972
+ const sql = yield* require_SqlError.SqlClient;
292973
+ const workspacePaths = yield* WorkspacePaths;
292974
+ const workspaceEntries = yield* WorkspaceEntries;
292975
+ const fault = options.fault ?? (() => require_Schema$1.void_);
292976
+ const transferLocks = yield* require_Schema$1.forEach(Array.from({ length: TRANSFER_LOCK_STRIPE_COUNT }), () => require_Path.make$5(1));
292977
+ const withTransferLock = (transferId, effect) => transferLocks[transferLockStripe(transferId)].withPermit(effect);
292978
+ const findTransfer = (transferId) => sql`
292979
+ SELECT transfer_id AS "transferId", workspace_root AS "workspaceRoot",
292980
+ relative_path AS "relativePath", target_path AS "targetPath", temp_path AS "tempPath",
292981
+ total_bytes AS "totalBytes", whole_sha256 AS "wholeSha256",
292982
+ next_offset AS "nextOffset", state, expires_at AS "expiresAt"
292983
+ FROM workspace_bulk_transfers WHERE transfer_id = ${transferId} LIMIT 1
292984
+ `.pipe(require_Schema$1.map((rows) => rows[0] ?? null));
292985
+ const requireTransfer = (transferId) => findTransfer(transferId).pipe(require_Schema$1.flatMap((row) => row === null ? require_Schema$1.fail(transferError({
292986
+ code: "transfer_not_found",
292987
+ message: "Bulk transfer does not exist.",
292988
+ transferId
292989
+ })) : require_Schema$1.succeed(row)));
292990
+ const requireOpenTransfer = (transferId) => requireTransfer(transferId).pipe(require_Schema$1.flatMap((row) => {
292991
+ if (row.state === "cancelled") return require_Schema$1.fail(transferError({
292992
+ code: "transfer_cancelled",
292993
+ message: "Bulk transfer was cancelled.",
292994
+ transferId
292995
+ }));
292996
+ if (row.state === "expired" || Date.parse(row.expiresAt) <= Date.now()) return require_Schema$1.fail(transferError({
292997
+ code: "transfer_expired",
292998
+ message: "Bulk transfer expired.",
292999
+ transferId
293000
+ }));
293001
+ return require_Schema$1.succeed(row);
293002
+ }));
293003
+ const resolveTarget = require_Schema$1.fn("WorkspaceBulkTransfer.resolveTarget")(function* (input) {
293004
+ const workspaceRoot = yield* workspacePaths.normalizeWorkspaceRoot(input.cwd);
293005
+ const resolved = yield* workspacePaths.resolveRelativePathWithinRoot({
293006
+ workspaceRoot,
293007
+ relativePath: input.relativePath
293008
+ });
293009
+ const canonicalRoot = yield* fileSystem.realPath(workspaceRoot);
293010
+ const parentSegments = resolved.relativePath.split("/").slice(0, -1);
293011
+ let canonicalParent = canonicalRoot;
293012
+ for (const segment of parentSegments) {
293013
+ const candidate = path.join(canonicalParent, segment);
293014
+ const canonicalCandidate = yield* fileSystem.realPath(candidate).pipe(require_Schema$1.catch_(() => input.createParent ? fileSystem.makeDirectory(candidate).pipe(require_Schema$1.flatMap(() => fileSystem.realPath(candidate))) : fileSystem.realPath(candidate)));
293015
+ if (!isPathWithinRoot(path, canonicalRoot, canonicalCandidate)) return yield* transferError({
293016
+ code: "symlink_escape",
293017
+ message: "Workspace file path resolves outside the project root."
293018
+ });
293019
+ canonicalParent = canonicalCandidate;
293020
+ }
293021
+ const targetPath = path.join(canonicalParent, path.basename(resolved.relativePath));
293022
+ const targetRealPath = yield* fileSystem.realPath(targetPath).pipe(require_Schema$1.catch_(() => require_Schema$1.succeed(null)));
293023
+ if (targetRealPath !== null && !isPathWithinRoot(path, canonicalRoot, targetRealPath)) return yield* transferError({
293024
+ code: "symlink_escape",
293025
+ message: "Workspace file target resolves outside the project root."
293026
+ });
293027
+ return {
293028
+ workspaceRoot: canonicalRoot,
293029
+ relativePath: resolved.relativePath,
293030
+ targetPath,
293031
+ canonicalParent
293032
+ };
293033
+ });
293034
+ const readFile = (input) => require_Path.unwrap(require_Schema$1.gen(function* () {
293035
+ const target = yield* resolveTarget({
293036
+ ...input,
293037
+ createParent: false
293038
+ });
293039
+ const info = yield* fileSystem.stat(target.targetPath);
293040
+ const maxBytes = input.maxBytes ?? 16777216;
293041
+ if (Number(info.size) > maxBytes) return yield* transferError({
293042
+ code: "size_limit_exceeded",
293043
+ message: `Workspace file exceeds the ${maxBytes}-byte read limit.`
293044
+ });
293045
+ const bytes = yield* fileSystem.readFile(target.targetPath);
293046
+ const transfer = yield* require_Schema$1.promise(() => chunkBulkTransfer(bytes));
293047
+ const transferId = transfer.chunks[0]?.transferId ?? crypto.randomUUID().replaceAll("-", "_");
293048
+ return require_Path.fromIterable([
293049
+ {
293050
+ kind: "started",
293051
+ transferId,
293052
+ totalBytes: transfer.totalBytes,
293053
+ sha256: transfer.sha256
293054
+ },
293055
+ ...transfer.chunks.map((chunk) => ({
293056
+ ...chunk,
293057
+ transferId
293058
+ })),
293059
+ {
293060
+ kind: "completed",
293061
+ transferId,
293062
+ totalBytes: transfer.totalBytes,
293063
+ sha256: transfer.sha256
293064
+ }
293065
+ ]);
293066
+ }).pipe(require_Schema$1.mapError(mapFailure("read file"))));
293067
+ const resumeWrite = require_Schema$1.fn("WorkspaceBulkTransfer.resumeWrite")(function* (input, existing) {
293068
+ const target = yield* resolveTarget({
293069
+ ...input,
293070
+ createParent: true
293071
+ });
293072
+ if (!(existing.workspaceRoot === target.workspaceRoot && existing.relativePath === target.relativePath && existing.totalBytes === input.totalBytes && existing.wholeSha256 === input.sha256)) return yield* transferError({
293073
+ code: "transfer_identity_conflict",
293074
+ message: "Bulk transfer ID is already bound to different content or a different path.",
293075
+ transferId: input.transferId
293076
+ });
293077
+ if (existing.state === "cancelled") return yield* transferError({
293078
+ code: "transfer_cancelled",
293079
+ message: "Bulk transfer was cancelled.",
293080
+ transferId: input.transferId
293081
+ });
293082
+ if (existing.state === "expired" || Date.parse(existing.expiresAt) <= Date.now()) return yield* transferError({
293083
+ code: "transfer_expired",
293084
+ message: "Bulk transfer expired.",
293085
+ transferId: input.transferId
293086
+ });
293087
+ return {
293088
+ transferId: input.transferId,
293089
+ nextOffset: existing.nextOffset,
293090
+ state: existing.state === "completed" ? "completed" : "open"
293091
+ };
293092
+ });
293093
+ const createWrite = require_Schema$1.fn("WorkspaceBulkTransfer.createWrite")(function* (input) {
293094
+ const target = yield* resolveTarget({
293095
+ ...input,
293096
+ createParent: true
293097
+ }).pipe(require_Schema$1.mapError((cause) => require_Schema$1.is(WorkspacePathOutsideRootError)(cause) ? transferError({
293098
+ code: "path_outside_workspace",
293099
+ message: "Workspace file path must stay within the project root.",
293100
+ transferId: input.transferId,
293101
+ cause
293102
+ }) : cause));
293103
+ const tempPath = path.join(target.canonicalParent, `.forge-transfer-${input.transferId}.part`);
293104
+ yield* require_Schema$1.scoped(fileSystem.open(tempPath, {
293105
+ flag: "wx",
293106
+ mode: 384
293107
+ }).pipe(require_Schema$1.flatMap((file) => file.sync), require_Schema$1.catch_(() => fileSystem.stat(tempPath).pipe(require_Schema$1.flatMap((stat) => Number(stat.size) === 0 ? require_Schema$1.void_ : require_Schema$1.fail(transferError({
293108
+ code: "transfer_identity_conflict",
293109
+ message: "Bulk transfer temporary path already contains data.",
293110
+ transferId: input.transferId
293111
+ })))))));
293112
+ const now = /* @__PURE__ */ new Date();
293113
+ yield* sql`
293114
+ INSERT INTO workspace_bulk_transfers (
293115
+ transfer_id, workspace_root, relative_path, target_path, temp_path, total_bytes,
293116
+ whole_sha256, next_offset, state, expires_at, created_at, updated_at
293117
+ ) VALUES (
293118
+ ${input.transferId}, ${target.workspaceRoot}, ${target.relativePath}, ${target.targetPath},
293119
+ ${tempPath}, ${input.totalBytes}, ${input.sha256}, 0, 'open',
293120
+ ${new Date(now.getTime() + TRANSFER_TTL_MS).toISOString()}, ${now.toISOString()},
293121
+ ${now.toISOString()}
293122
+ )
293123
+ `;
293124
+ return {
293125
+ transferId: input.transferId,
293126
+ nextOffset: 0,
293127
+ state: "open"
293128
+ };
293129
+ });
293130
+ const beginWriteEffect = require_Schema$1.fn("WorkspaceBulkTransfer.beginWrite")(function* (input) {
293131
+ const existing = yield* findTransfer(input.transferId);
293132
+ return yield* existing === null ? createWrite(input) : resumeWrite(input, existing);
293133
+ });
293134
+ const beginWrite = (input) => withTransferLock(input.transferId, beginWriteEffect(input)).pipe(require_Schema$1.mapError(mapFailure("begin write", input.transferId)));
293135
+ const acceptRetriedChunk = require_Schema$1.fn("WorkspaceBulkTransfer.acceptRetriedChunk")(function* (input, row) {
293136
+ const accepted = (yield* sql`
293137
+ SELECT chunk_offset AS "chunkOffset", chunk_bytes AS "chunkBytes",
293138
+ chunk_sha256 AS "chunkSha256"
293139
+ FROM workspace_bulk_transfer_chunks
293140
+ WHERE transfer_id = ${input.transferId} AND chunk_offset = ${input.offset}
293141
+ LIMIT 1
293142
+ `)[0];
293143
+ if (!(accepted !== void 0 && accepted.chunkBytes === input.bytes && accepted.chunkSha256 === input.sha256)) return yield* transferError({
293144
+ code: "integrity_mismatch",
293145
+ message: "Retried bulk transfer chunk differs from the accepted chunk.",
293146
+ transferId: input.transferId
293147
+ });
293148
+ return {
293149
+ transferId: input.transferId,
293150
+ nextOffset: row.nextOffset,
293151
+ duplicate: true
293152
+ };
293153
+ });
293154
+ const validateChunkRange = require_Schema$1.fn("WorkspaceBulkTransfer.validateChunkRange")(function* (input, row, byteLength) {
293155
+ if (input.offset !== row.nextOffset) return yield* transferError({
293156
+ code: "offset_mismatch",
293157
+ message: `Expected chunk offset ${row.nextOffset}, received ${input.offset}.`,
293158
+ transferId: input.transferId,
293159
+ retryable: true
293160
+ });
293161
+ const nextOffset = input.offset + byteLength;
293162
+ if (nextOffset > row.totalBytes) return yield* transferError({
293163
+ code: "size_limit_exceeded",
293164
+ message: "Bulk transfer chunk exceeds the declared total byte count.",
293165
+ transferId: input.transferId
293166
+ });
293167
+ return nextOffset;
293168
+ });
293169
+ const reconcileChunkBytes = require_Schema$1.fn("WorkspaceBulkTransfer.reconcileChunkBytes")(function* (input, row, bytes, nextOffset) {
293170
+ const tempInfo = yield* fileSystem.stat(row.tempPath);
293171
+ if (Number(tempInfo.size) > row.nextOffset) {
293172
+ if (Number(tempInfo.size) !== nextOffset) return yield* transferError({
293173
+ code: "integrity_mismatch",
293174
+ message: "Bulk transfer temporary file cannot be reconciled with the next chunk.",
293175
+ transferId: input.transferId
293176
+ });
293177
+ const recovered = (yield* fileSystem.readFile(row.tempPath)).slice(input.offset, nextOffset);
293178
+ if ((yield* require_Schema$1.promise(() => sha256Hex(recovered))) !== input.sha256) return yield* transferError({
293179
+ code: "integrity_mismatch",
293180
+ message: "Bulk transfer temporary bytes differ from the retried chunk.",
293181
+ transferId: input.transferId
293182
+ });
293183
+ return;
293184
+ }
293185
+ yield* fault("before-byte-write");
293186
+ yield* require_Schema$1.scoped(fileSystem.open(row.tempPath, { flag: "r+" }).pipe(require_Schema$1.flatMap((file) => file.seek(input.offset, "start").pipe(require_Schema$1.flatMap(() => file.writeAll(bytes)), require_Schema$1.flatMap(() => file.sync)))));
293187
+ yield* fault("after-byte-write-before-metadata");
293188
+ });
293189
+ const persistAcceptedChunk = require_Schema$1.fn("WorkspaceBulkTransfer.persistAcceptedChunk")(function* (input, nextOffset) {
293190
+ const updatedAt = (/* @__PURE__ */ new Date()).toISOString();
293191
+ yield* sql.withTransaction(sql`
293192
+ INSERT INTO workspace_bulk_transfer_chunks (
293193
+ transfer_id, chunk_offset, chunk_bytes, chunk_sha256
293194
+ ) VALUES (${input.transferId}, ${input.offset}, ${input.bytes}, ${input.sha256})
293195
+ ON CONFLICT DO NOTHING
293196
+ `.pipe(require_Schema$1.flatMap(() => sql`
293197
+ UPDATE workspace_bulk_transfers
293198
+ SET next_offset = ${nextOffset}, updated_at = ${updatedAt}
293199
+ WHERE transfer_id = ${input.transferId} AND next_offset = ${input.offset} AND state = 'open'
293200
+ `)));
293201
+ yield* fault("after-metadata-advance");
293202
+ });
293203
+ const writeChunkEffect = require_Schema$1.fn("WorkspaceBulkTransfer.writeChunk")(function* (input) {
293204
+ const bytes = yield* require_Schema$1.promise(() => verifyBulkTransferChunk({
293205
+ ...input,
293206
+ kind: "chunk"
293207
+ }));
293208
+ const row = yield* requireOpenTransfer(input.transferId);
293209
+ if (row.state === "completed") return yield* transferError({
293210
+ code: "offset_mismatch",
293211
+ message: "Bulk transfer is already complete.",
293212
+ transferId: input.transferId
293213
+ });
293214
+ if (input.offset < row.nextOffset) return yield* acceptRetriedChunk(input, row);
293215
+ const nextOffset = yield* validateChunkRange(input, row, bytes.byteLength);
293216
+ yield* reconcileChunkBytes(input, row, bytes, nextOffset);
293217
+ yield* persistAcceptedChunk(input, nextOffset);
293218
+ return {
293219
+ transferId: input.transferId,
293220
+ nextOffset,
293221
+ duplicate: false
293222
+ };
293223
+ });
293224
+ const writeChunk = (input) => withTransferLock(input.transferId, writeChunkEffect(input)).pipe(require_Schema$1.mapError(mapFailure("write chunk", input.transferId)));
293225
+ const commitWriteEffect = require_Schema$1.fn("WorkspaceBulkTransfer.commitWrite")(function* (input) {
293226
+ const row = yield* requireOpenTransfer(input.transferId);
293227
+ if (row.state === "completed") return {
293228
+ transferId: row.transferId,
293229
+ relativePath: row.relativePath,
293230
+ totalBytes: row.totalBytes,
293231
+ sha256: row.wholeSha256
293232
+ };
293233
+ if (row.nextOffset !== row.totalBytes) return yield* transferError({
293234
+ code: "offset_mismatch",
293235
+ message: `Bulk transfer is incomplete at byte ${row.nextOffset} of ${row.totalBytes}.`,
293236
+ transferId: row.transferId,
293237
+ retryable: true
293238
+ });
293239
+ if ((yield* resolveTarget({
293240
+ cwd: row.workspaceRoot,
293241
+ relativePath: row.relativePath,
293242
+ createParent: false
293243
+ })).targetPath !== row.targetPath) return yield* transferError({
293244
+ code: "symlink_escape",
293245
+ message: "Workspace file destination changed during transfer.",
293246
+ transferId: row.transferId
293247
+ });
293248
+ const tempInfo = yield* fileSystem.stat(row.tempPath).pipe(require_Schema$1.catch_(() => require_Schema$1.succeed(null)));
293249
+ const candidatePath = tempInfo === null ? row.targetPath : row.tempPath;
293250
+ const candidate = yield* fileSystem.readFile(candidatePath);
293251
+ if (candidate.byteLength !== row.totalBytes || (yield* require_Schema$1.promise(() => sha256Hex(candidate))) !== row.wholeSha256) return yield* transferError({
293252
+ code: "integrity_mismatch",
293253
+ message: "Bulk transfer payload does not match its declared size and digest.",
293254
+ transferId: row.transferId
293255
+ });
293256
+ if (tempInfo !== null) {
293257
+ yield* fileSystem.rename(row.tempPath, row.targetPath);
293258
+ yield* fault("after-atomic-rename");
293259
+ }
293260
+ yield* sql`
293261
+ UPDATE workspace_bulk_transfers SET state = 'completed', updated_at = ${(/* @__PURE__ */ new Date()).toISOString()}
293262
+ WHERE transfer_id = ${row.transferId}
293263
+ `;
293264
+ yield* workspaceEntries.invalidate(row.workspaceRoot);
293265
+ return {
293266
+ transferId: row.transferId,
293267
+ relativePath: row.relativePath,
293268
+ totalBytes: row.totalBytes,
293269
+ sha256: row.wholeSha256
293270
+ };
293271
+ });
293272
+ const commitWrite = (input) => withTransferLock(input.transferId, commitWriteEffect(input)).pipe(require_Schema$1.mapError(mapFailure("commit write", input.transferId)));
293273
+ const cancelWriteEffect = require_Schema$1.fn("WorkspaceBulkTransfer.cancelWrite")(function* (input) {
293274
+ const row = yield* requireTransfer(input.transferId);
293275
+ if (row.state !== "completed") {
293276
+ yield* fileSystem.remove(row.tempPath, { force: true });
293277
+ yield* sql`
293278
+ UPDATE workspace_bulk_transfers SET state = 'cancelled', updated_at = ${(/* @__PURE__ */ new Date()).toISOString()}
293279
+ WHERE transfer_id = ${input.transferId}
293280
+ `;
293281
+ }
293282
+ return {
293283
+ transferId: input.transferId,
293284
+ state: row.state === "completed" ? "completed" : "cancelled"
293285
+ };
293286
+ });
293287
+ const cancelWrite = (input) => withTransferLock(input.transferId, cancelWriteEffect(input)).pipe(require_Schema$1.mapError(mapFailure("cancel write", input.transferId)));
293288
+ const cleanupExpiredEffect = require_Schema$1.fn("WorkspaceBulkTransfer.cleanupExpired")(function* () {
293289
+ const now = (/* @__PURE__ */ new Date()).toISOString();
293290
+ const rows = yield* sql`
293291
+ SELECT transfer_id AS "transferId", temp_path AS "tempPath"
293292
+ FROM workspace_bulk_transfers WHERE state = 'open' AND expires_at <= ${now}
293293
+ `;
293294
+ let expiredCount = 0;
293295
+ for (const row of rows) if (yield* withTransferLock(row.transferId, require_Schema$1.gen(function* () {
293296
+ const current = yield* findTransfer(row.transferId);
293297
+ if (current === null || current.state !== "open" || Date.parse(current.expiresAt) > Date.parse(now)) return false;
293298
+ yield* fileSystem.remove(current.tempPath, { force: true });
293299
+ yield* sql`
293300
+ UPDATE workspace_bulk_transfers SET state = 'expired', updated_at = ${now}
293301
+ WHERE transfer_id = ${row.transferId} AND state = 'open' AND expires_at <= ${now}
293302
+ `;
293303
+ return true;
293304
+ }))) expiredCount += 1;
293305
+ return expiredCount;
293306
+ });
293307
+ const cleanupExpired = () => cleanupExpiredEffect().pipe(require_Schema$1.mapError(mapFailure("expiry cleanup")));
293308
+ return {
293309
+ readFile,
293310
+ beginWrite,
293311
+ writeChunk,
293312
+ commitWrite,
293313
+ cancelWrite,
293314
+ cleanupExpired
293315
+ };
293316
+ });
293317
+ const WorkspaceBulkTransferLive = require_Schema$1.effect(WorkspaceBulkTransfer, makeWorkspaceBulkTransfer());
293318
+ //#endregion
293216
293319
  //#region src/workspace/Layers/WorkspacePaths.ts
293217
293320
  function toPosixRelativePath(input) {
293218
293321
  return input.replaceAll("\\", "/");
@@ -294825,7 +294928,7 @@ function resolveBuildCommitFromEnv(env) {
294825
294928
  * environment descriptor down instead of reporting an honest "unknown".
294826
294929
  */
294827
294930
  function readBakedBuildCommit() {
294828
- return "8cafbb87b6204b98e93c1c69253c1c590af5f8a1";
294931
+ return "b79da36c868d5629c76dcec88a3326634a02c2c7";
294829
294932
  }
294830
294933
  async function resolveServerBuildCommit(input) {
294831
294934
  if (isFullCommitSha(input.baked)) return input.baked;