@gajae-code/coding-agent 0.17.4 → 0.17.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (86) hide show
  1. package/CHANGELOG.md +82 -0
  2. package/dist/types/config/autorouting-tier-map.d.ts +1031 -3
  3. package/dist/types/config/file-lock.d.ts +4 -2
  4. package/dist/types/config/model-registry.d.ts +3 -1
  5. package/dist/types/config/model-resolver.d.ts +1 -1
  6. package/dist/types/exec/bash-executor.d.ts +2 -0
  7. package/dist/types/extensibility/gjc-plugins/runtime-adapters.d.ts +11 -2
  8. package/dist/types/gjc-runtime/state-writer.d.ts +14 -1
  9. package/dist/types/harness-control-plane/control-endpoint.d.ts +4 -1
  10. package/dist/types/harness-control-plane/finalize.d.ts +14 -3
  11. package/dist/types/main.d.ts +1 -1
  12. package/dist/types/runtime-mcp/loader.d.ts +2 -0
  13. package/dist/types/runtime-mcp/manager.d.ts +13 -6
  14. package/dist/types/sdk/broker/broker.d.ts +4 -2
  15. package/dist/types/sdk/broker/ensure.d.ts +3 -0
  16. package/dist/types/sdk/broker/identity.d.ts +2 -0
  17. package/dist/types/sdk/broker/lifecycle-ledger.d.ts +11 -3
  18. package/dist/types/sdk/broker/managed-task-dag.d.ts +1335 -0
  19. package/dist/types/sdk/broker/managed-task-verification.d.ts +37 -0
  20. package/dist/types/sdk/broker/spawn-authority.d.ts +8 -0
  21. package/dist/types/sdk/broker/startup-failure.d.ts +4 -0
  22. package/dist/types/sdk/bus/telegram-daemon-contract.d.ts +5 -1
  23. package/dist/types/sdk/prompt-deadline-flush.d.ts +2 -0
  24. package/dist/types/sdk/session.d.ts +3 -3
  25. package/dist/types/session/agent-session.d.ts +7 -2
  26. package/dist/types/task/types.d.ts +2 -0
  27. package/dist/types/tools/bash-interactive.d.ts +1 -0
  28. package/dist/types/tools/fetch.d.ts +10 -1
  29. package/dist/types/tools/subagent.d.ts +8 -1
  30. package/package.json +7 -7
  31. package/src/cli/public-command-registry.ts +8 -2
  32. package/src/cli/setup-cli.ts +1 -0
  33. package/src/cli/stats-cli.ts +11 -1
  34. package/src/config/autorouting-tier-map.ts +529 -3
  35. package/src/config/file-lock.ts +59 -14
  36. package/src/config/model-discovery-manager.ts +4 -4
  37. package/src/config/model-profile-activation.ts +83 -23
  38. package/src/config/model-registry.ts +72 -12
  39. package/src/config/model-resolver.ts +6 -1
  40. package/src/coordinator-mcp/question-state.ts +4 -1
  41. package/src/coordinator-mcp/server.ts +14 -6
  42. package/src/exec/bash-executor.ts +3 -0
  43. package/src/extensibility/gjc-plugins/runtime-adapters.ts +46 -8
  44. package/src/gjc-runtime/state-writer.ts +317 -6
  45. package/src/harness-control-plane/control-endpoint.ts +24 -6
  46. package/src/harness-control-plane/finalize.ts +312 -25
  47. package/src/harness-control-plane/owner.ts +323 -89
  48. package/src/internal-urls/docs-index.generated.ts +6 -4
  49. package/src/main.ts +69 -19
  50. package/src/modes/acp/acp-agent.ts +581 -37
  51. package/src/modes/components/gjc-bundle-settings.ts +21 -1
  52. package/src/modes/components/tool-status-header.ts +72 -6
  53. package/src/modes/controllers/runtime-mcp-command-controller.ts +1 -1
  54. package/src/runtime-mcp/loader.ts +6 -3
  55. package/src/runtime-mcp/manager.ts +177 -75
  56. package/src/sdk/broker/broker.ts +1058 -150
  57. package/src/sdk/broker/ensure.ts +65 -8
  58. package/src/sdk/broker/identity.ts +13 -0
  59. package/src/sdk/broker/lifecycle-ledger.ts +16 -4
  60. package/src/sdk/broker/managed-task-dag.ts +1927 -0
  61. package/src/sdk/broker/managed-task-verification.ts +642 -0
  62. package/src/sdk/broker/spawn-authority.ts +140 -7
  63. package/src/sdk/broker/startup-failure.ts +80 -1
  64. package/src/sdk/broker/transport.ts +1 -0
  65. package/src/sdk/bus/chat-daemon-control.ts +2 -2
  66. package/src/sdk/bus/index.ts +22 -6
  67. package/src/sdk/bus/telegram-daemon-contract.ts +5 -1
  68. package/src/sdk/cli/session-cli.ts +18 -0
  69. package/src/sdk/host/session-runtime.test.ts +46 -0
  70. package/src/sdk/host/session-runtime.ts +13 -6
  71. package/src/sdk/lifecycle/service.ts +2 -0
  72. package/src/sdk/prompt-deadline-flush.ts +26 -4
  73. package/src/sdk/session.ts +587 -115
  74. package/src/session/agent-session.ts +60 -14
  75. package/src/session/internal/managed-session-scope.ts +10 -0
  76. package/src/session/internal/managed-session-storage.ts +30 -1
  77. package/src/setup/provider-presets.json +2 -1
  78. package/src/slash-commands/builtin-registry.ts +1 -1
  79. package/src/task/executor.ts +46 -17
  80. package/src/task/types.ts +2 -0
  81. package/src/tools/bash-interactive.ts +2 -0
  82. package/src/tools/bash.ts +20 -0
  83. package/src/tools/fetch.ts +21 -5
  84. package/src/tools/subagent-render.ts +24 -1
  85. package/src/tools/subagent.ts +27 -1
  86. package/src/utils/mupdf.ts +11 -4
@@ -3,6 +3,7 @@ import type { Dirent } from "node:fs";
3
3
  import * as fs from "node:fs/promises";
4
4
  import * as os from "node:os";
5
5
  import * as path from "node:path";
6
+ import { redactCrashSecrets, safeErrorDescription } from "@gajae-code/utils";
6
7
  import { bindPluginMcpToPublicNetwork } from "../../runtime-mcp/plugin-network-boundary";
7
8
  import type { MCPStdioPreparedLaunch, MCPStdioSpawnLaunch } from "../../runtime-mcp/types";
8
9
  import { loadCustomTools } from "../custom-tools/loader";
@@ -20,7 +21,13 @@ import { isV2Tool } from "./migration";
20
21
  import { gjcPluginInstallRoot, resolveWithinRoot } from "./paths";
21
22
  import { loadEffectiveGjcPluginRegistry, registryPathForScope, registryRootForScope } from "./registry";
22
23
  import { type SessionQuarantine, type SessionValidationResult, validateSessionBundles } from "./session-validation";
23
- import type { GjcPluginRegistryEntry, GjcPluginScope, JsonSchema202012, NormalizedToolSurfaceV2 } from "./types";
24
+ import type {
25
+ GjcBundleIdentity,
26
+ GjcPluginRegistryEntry,
27
+ GjcPluginScope,
28
+ JsonSchema202012,
29
+ NormalizedToolSurfaceV2,
30
+ } from "./types";
24
31
 
25
32
  export interface AlwaysOnPluginTools {
26
33
  tools: CustomTool[];
@@ -32,6 +39,23 @@ export interface GjcPluginToolDeclaration extends NormalizedToolSurfaceV2 {
32
39
  scope: GjcPluginScope;
33
40
  }
34
41
 
42
+ export interface GjcPluginMcpServerProvenance {
43
+ identity: GjcBundleIdentity;
44
+ surfaceId: string;
45
+ }
46
+
47
+ // Plugin MCP manifests reject per-server timeout controls; generated transports
48
+ // share one bounded window so startup evidence reaches a terminal result.
49
+ const PLUGIN_MCP_STARTUP_TIMEOUT_MS = 5_000;
50
+ const MAX_PLUGIN_MCP_DIAGNOSTIC_LENGTH = 1_024;
51
+
52
+ /** Bound and scrub plugin-controlled MCP causes before they enter logs or runtime findings. */
53
+ export function safePluginMcpDiagnostic(value: unknown): string {
54
+ const message = safeErrorDescription(value).replace(/[\u0000-\u001f\u007f-\u009f]/gu, "");
55
+ const normalized = message.replace(/\b(Bearer|Basic|Token)(?=[A-Za-z0-9._~+/=-]{8,})/giu, "$1 ");
56
+ return redactCrashSecrets(normalized).slice(0, MAX_PLUGIN_MCP_DIAGNOSTIC_LENGTH);
57
+ }
58
+
35
59
  function isWithin(root: string, target: string): boolean {
36
60
  const rel = path.relative(root, target);
37
61
  return rel === "" || (!rel.startsWith("..") && !path.isAbsolute(rel));
@@ -557,7 +581,9 @@ async function prepareVerifiedStdioLaunch(input: {
557
581
  throw new Error("Authenticated plugin MCP stdio launch capsules are available only on Linux");
558
582
  }
559
583
  if (input.launcher === "bun") {
560
- throw new Error("Authenticated plugin MCP Bun launch capsules are unavailable");
584
+ throw new Error(
585
+ "Authenticated plugin MCP Bun launch capsules are unavailable: Bun's clearable runtime plugin hooks cannot enforce the authenticated module-loading boundary required to restrict plugin imports to verified capsule bytes",
586
+ );
561
587
  }
562
588
  const registerCleanup = input.registerCleanup;
563
589
  if (!registerCleanup) {
@@ -1045,18 +1071,22 @@ export async function renderSkillAdvertisement(input: {
1045
1071
  * Convert active plugin-bundle MCP surfaces into runtime MCPServerConfig entries,
1046
1072
  * applying install + runtime MCP policy (URL scheme/private-range deny, DNS
1047
1073
  * re-resolution for http/sse, stdio root-confinement) before connection. Servers
1048
- * failing policy are quarantined and excluded. Returns {} when none.
1074
+ * failing policy are quarantined and excluded. The provenance map contains
1075
+ * only servers present in `configs`, so connection errors can be attributed to
1076
+ * the authenticated bundle and stable surface that supplied each server.
1049
1077
  */
1050
1078
  export async function buildPluginMcpConfigs(input: { cwd: string }): Promise<{
1051
1079
  configs: Record<string, any>;
1052
1080
  quarantine: SessionQuarantine[];
1081
+ serverProvenance: ReadonlyMap<string, GjcPluginMcpServerProvenance>;
1053
1082
  }> {
1054
1083
  const { effective, active, quarantine } = await loadValidatedPluginRegistry(input.cwd, true);
1055
- if (effective.length === 0) return { configs: {}, quarantine: [] };
1084
+ if (effective.length === 0) return { configs: {}, quarantine: [], serverProvenance: new Map() };
1056
1085
 
1057
1086
  // A manifest-controlled MCP name such as "constructor" or "toString" must
1058
1087
  // remain an ordinary own key rather than interacting with Object.prototype.
1059
1088
  const configs: Record<string, any> = Object.create(null) as Record<string, any>;
1089
+ const serverProvenance = new Map<string, GjcPluginMcpServerProvenance>();
1060
1090
  for (const entry of active) {
1061
1091
  const disabled = new Set(entry.disabledSurfaceIds);
1062
1092
  let compiledMcps: Map<string, (typeof entry.surfaces.mcps)[number]> | undefined;
@@ -1119,7 +1149,7 @@ export async function buildPluginMcpConfigs(input: { cwd: string }): Promise<{
1119
1149
  command,
1120
1150
  args: initialArgs,
1121
1151
  cwd: invocation.cwd,
1122
- timeout: 5_000,
1152
+ timeout: PLUGIN_MCP_STARTUP_TIMEOUT_MS,
1123
1153
  // Third-party plugin MCP processes must not inherit host secrets;
1124
1154
  // only a minimal OS allowlist (PATH/HOME/temp/locale) is provided.
1125
1155
  noInheritEnv: true,
@@ -1207,18 +1237,26 @@ export async function buildPluginMcpConfigs(input: { cwd: string }): Promise<{
1207
1237
  // resolution path expands ${env:...}/shell templates, which would let
1208
1238
  // a third-party bundle exfiltrate host secrets. Plugin-bundle MCP
1209
1239
  // servers connect without bundle-declared headers.
1210
- configs[m.name] = bindPluginMcpToPublicNetwork({ type: cfg.transport, url: url.toString() });
1240
+ configs[m.name] = bindPluginMcpToPublicNetwork({
1241
+ type: cfg.transport,
1242
+ url: url.toString(),
1243
+ timeout: PLUGIN_MCP_STARTUP_TIMEOUT_MS,
1244
+ });
1211
1245
  }
1246
+ serverProvenance.set(m.name, {
1247
+ identity: bundleIdentity(entry.scope, entry.name),
1248
+ surfaceId: `mcp:${m.name}`,
1249
+ });
1212
1250
  } catch (error) {
1213
1251
  quarantine.push({
1214
1252
  identity: bundleIdentity(entry.scope, entry.name),
1215
1253
  plugin: entry.name,
1216
1254
  surfaceId: m.extensionId,
1217
1255
  code: "security_policy",
1218
- message: error instanceof Error ? error.message : String(error),
1256
+ message: safePluginMcpDiagnostic(error),
1219
1257
  });
1220
1258
  }
1221
1259
  }
1222
1260
  }
1223
- return { configs, quarantine };
1261
+ return { configs, quarantine, serverProvenance };
1224
1262
  }
@@ -1,5 +1,6 @@
1
1
  import { createHash, randomUUID } from "node:crypto";
2
- import type { Stats } from "node:fs";
2
+ import { constants as fsConstants, type Stats } from "node:fs";
3
+ import type { FileHandle } from "node:fs/promises";
3
4
  import * as fs from "node:fs/promises";
4
5
  import * as path from "node:path";
5
6
  import { type FileLockOptions, withFileLock } from "../config/file-lock";
@@ -114,6 +115,12 @@ export function guardedStateWriteReceipt(result: GuardedWriteResult): GuardedSta
114
115
 
115
116
  export interface StateWriterOptions {
116
117
  cwd?: string;
118
+ /**
119
+ * Linux-only private durable source publication. The supplied directory is
120
+ * the exact private subtree containing the target; existing paths are never
121
+ * chmodded or repaired.
122
+ */
123
+ privateDurable?: { directory: string };
117
124
  receipt?: StateWriterReceiptContext;
118
125
  audit?: StateWriterAuditContext;
119
126
  sourceRevision?: number;
@@ -149,6 +156,16 @@ export interface DeleteIfOwnedOptions extends StateWriterOptions {
149
156
  predicate?: (current: unknown) => boolean | Promise<boolean>;
150
157
  }
151
158
 
159
+ export class StatePublicationUncertainError extends Error {
160
+ constructor(
161
+ public readonly path: string,
162
+ public readonly cause: unknown,
163
+ ) {
164
+ super(`state publication requires authoritative reload: ${path}`);
165
+ this.name = "StatePublicationUncertainError";
166
+ }
167
+ }
168
+
152
169
  export interface DeleteResult {
153
170
  path: string;
154
171
  deleted: boolean;
@@ -528,13 +545,293 @@ async function atomicWrite(filePath: string, content: string): Promise<string> {
528
545
  return filePath;
529
546
  }
530
547
 
548
+ function currentUid(): number {
549
+ const uid = process.getuid?.();
550
+ if (uid === undefined) throw new Error("private durable publication requires a POSIX uid");
551
+ return uid;
552
+ }
553
+
554
+ interface PrivatePublicationDirectory {
555
+ handle: FileHandle;
556
+ parent?: PrivatePublicationDirectory;
557
+ name?: string;
558
+ dev: number;
559
+ ino: number;
560
+ privateSubtree: boolean;
561
+ }
562
+
563
+ interface PrivatePublicationContext {
564
+ root: string;
565
+ directories: PrivatePublicationDirectory[];
566
+ targetParent: PrivatePublicationDirectory;
567
+ anchoredTarget: string;
568
+ }
569
+
570
+ function assertSafePublicationDirectory(stat: Stats, directory: string, privateSubtree: boolean): void {
571
+ if (!stat.isDirectory() || stat.isSymbolicLink()) throw new Error("unsafe publication directory");
572
+ if ((stat.mode & 0o022) !== 0 || stat.uid !== currentUid()) {
573
+ const mode = (stat.mode & 0o777).toString(8);
574
+ throw new Error(
575
+ `publication parent is not exclusively owned (uid=${stat.uid}, mode=${mode}, directory=${path.basename(directory)})`,
576
+ );
577
+ }
578
+ if (privateSubtree && (stat.mode & 0o777) !== 0o700)
579
+ throw new Error("private publication directory is not owned mode 0700");
580
+ }
581
+
582
+ function procPath(handle: FileHandle): string {
583
+ return `/proc/self/fd/${handle.fd}`;
584
+ }
585
+
586
+ function directoryPath(directory: PrivatePublicationDirectory): string {
587
+ return procPath(directory.handle);
588
+ }
589
+
590
+ const LINUX_O_PATH = 0o10000000;
591
+
592
+ async function readPrivateExistingStateForMutation(filePath: string): Promise<StrictMutationReadResult> {
593
+ let handle: FileHandle | undefined;
594
+ try {
595
+ handle = await fs.open(filePath, fsConstants.O_RDONLY | fsConstants.O_NOFOLLOW | fsConstants.O_NONBLOCK);
596
+ const stat = await handle.stat();
597
+ if (!stat.isFile() || stat.nlink !== 1 || (stat.mode & 0o777) !== 0o600 || stat.uid !== currentUid())
598
+ throw new Error("unsafe private publication file");
599
+ const parsed = JSON.parse(await handle.readFile("utf-8"));
600
+ if (isPlainObject(parsed)) return { kind: "valid", value: parsed };
601
+ return { kind: "corrupt", error: "state file must contain a JSON object" };
602
+ } catch (error) {
603
+ if (isErrno(error, "ENOENT")) return { kind: "absent" };
604
+ return { kind: "corrupt", error: error instanceof Error ? error.message : String(error) };
605
+ } finally {
606
+ await handle?.close();
607
+ }
608
+ }
609
+
610
+ async function assertPrivatePublicationDirectories(context: PrivatePublicationContext): Promise<void> {
611
+ for (const expected of context.directories) {
612
+ const current = await expected.handle.stat();
613
+ const directory =
614
+ expected.parent && expected.name ? path.join(directoryPath(expected.parent), expected.name) : context.root;
615
+ assertSafePublicationDirectory(current, directory, expected.privateSubtree);
616
+ if (current.dev !== expected.dev || current.ino !== expected.ino)
617
+ throw new Error("private publication directory changed during publication");
618
+ if (expected.parent && expected.name) {
619
+ const linked = await fs.lstat(path.join(directoryPath(expected.parent), expected.name));
620
+ if (
621
+ !linked.isDirectory() ||
622
+ linked.isSymbolicLink() ||
623
+ linked.dev !== expected.dev ||
624
+ linked.ino !== expected.ino
625
+ )
626
+ throw new Error("private publication directory changed during publication");
627
+ }
628
+ }
629
+ const namedRoot = await fs.lstat(context.root);
630
+ const root = context.directories[0];
631
+ if (
632
+ !root ||
633
+ !namedRoot.isDirectory() ||
634
+ namedRoot.isSymbolicLink() ||
635
+ namedRoot.dev !== root.dev ||
636
+ namedRoot.ino !== root.ino
637
+ )
638
+ throw new Error("private publication root changed during publication");
639
+ }
640
+
641
+ async function closePrivatePublicationContext(context: PrivatePublicationContext): Promise<void> {
642
+ await Promise.all(
643
+ context.directories
644
+ .splice(0)
645
+ .reverse()
646
+ .map(directory => directory.handle.close()),
647
+ );
648
+ }
649
+
650
+ async function preparePrivateDirectory(
651
+ filePath: string,
652
+ options: StateWriterOptions,
653
+ ): Promise<PrivatePublicationContext> {
654
+ if (process.platform !== "linux") throw new Error("private durable publication requires Linux");
655
+ const boundary = resolveGjcTarget(options.privateDurable!.directory, cwdForOptions(options));
656
+ if (boundary !== path.dirname(filePath)) throw new Error("private publication directory must be the target parent");
657
+ const root = cwdForOptions(options);
658
+ if ((await fs.realpath(root)) !== root) throw new Error("private publication requires canonical cwd");
659
+ const directories: PrivatePublicationDirectory[] = [];
660
+ const openFlags = fsConstants.O_RDONLY | fsConstants.O_DIRECTORY | fsConstants.O_NOFOLLOW;
661
+ try {
662
+ const rootHandle = await fs.open(root, openFlags);
663
+ let rootStat: Stats;
664
+ try {
665
+ rootStat = await rootHandle.stat();
666
+ } catch (error) {
667
+ await rootHandle.close();
668
+ throw error;
669
+ }
670
+ directories.push({
671
+ handle: rootHandle,
672
+ dev: rootStat.dev,
673
+ ino: rootStat.ino,
674
+ privateSubtree: false,
675
+ });
676
+ assertSafePublicationDirectory(rootStat, root, false);
677
+ const namedRoot = await fs.lstat(root);
678
+ if (namedRoot.isSymbolicLink() || namedRoot.dev !== rootStat.dev || namedRoot.ino !== rootStat.ino)
679
+ throw new Error("private publication root changed during publication");
680
+
681
+ let parent = directories[0];
682
+ if (!parent) throw new Error("private publication root was not opened");
683
+ let directory = root;
684
+ for (const segment of path.relative(root, path.dirname(filePath)).split(path.sep)) {
685
+ const childPath = path.join(directoryPath(parent), segment);
686
+ directory = path.join(directory, segment);
687
+ let created = false;
688
+ try {
689
+ await fs.mkdir(childPath, { mode: 0o700 });
690
+ created = true;
691
+ } catch (error) {
692
+ if (!isErrno(error, "EEXIST")) throw error;
693
+ }
694
+ let createdIdentity: { dev: number; ino: number } | undefined;
695
+ if (created) {
696
+ const pathHandle = await fs.open(
697
+ childPath,
698
+ LINUX_O_PATH | fsConstants.O_DIRECTORY | fsConstants.O_NOFOLLOW,
699
+ );
700
+ try {
701
+ const beforeChmod = await pathHandle.stat();
702
+ if (!beforeChmod.isDirectory() || beforeChmod.uid !== currentUid() || (beforeChmod.mode & 0o022) !== 0)
703
+ throw new Error("unsafe newly created private publication directory");
704
+ await fs.chmod(procPath(pathHandle), 0o700);
705
+ const afterChmod = await pathHandle.stat();
706
+ if ((afterChmod.mode & 0o777) !== 0o700)
707
+ throw new Error("private publication directory is not owned mode 0700");
708
+ createdIdentity = { dev: afterChmod.dev, ino: afterChmod.ino };
709
+ } finally {
710
+ await pathHandle.close();
711
+ }
712
+ }
713
+ const childHandle = await fs.open(childPath, openFlags);
714
+ let childStat: Stats;
715
+ try {
716
+ childStat = await childHandle.stat();
717
+ } catch (error) {
718
+ await childHandle.close();
719
+ throw error;
720
+ }
721
+ if (createdIdentity && (childStat.dev !== createdIdentity.dev || childStat.ino !== createdIdentity.ino)) {
722
+ await childHandle.close();
723
+ throw new Error("private publication directory changed during creation");
724
+ }
725
+ const privateSubtree = directory === boundary || directory.startsWith(`${boundary}${path.sep}`);
726
+ const child: PrivatePublicationDirectory = {
727
+ handle: childHandle,
728
+ parent,
729
+ name: segment,
730
+ dev: childStat.dev,
731
+ ino: childStat.ino,
732
+ privateSubtree,
733
+ };
734
+ directories.push(child);
735
+ assertSafePublicationDirectory(childStat, directory, privateSubtree);
736
+ if (created || directory === boundary) {
737
+ // The newly linked entry is durable only after both the child and its
738
+ // parent have been synced. Newly created directories are chmodded first
739
+ // so umask cannot weaken their required mode.
740
+ await childHandle.sync();
741
+ await parent.handle.sync();
742
+ }
743
+ parent = child;
744
+ }
745
+ const targetParent = directories[directories.length - 1];
746
+ if (!targetParent) throw new Error("private publication target parent was not opened");
747
+ const context: PrivatePublicationContext = {
748
+ root,
749
+ directories,
750
+ targetParent,
751
+ anchoredTarget: path.join(directoryPath(targetParent), path.basename(filePath)),
752
+ };
753
+ await assertPrivatePublicationDirectories(context);
754
+ try {
755
+ const stat = await fs.lstat(context.anchoredTarget);
756
+ if (
757
+ !stat.isFile() ||
758
+ stat.isSymbolicLink() ||
759
+ stat.nlink !== 1 ||
760
+ (stat.mode & 0o777) !== 0o600 ||
761
+ stat.uid !== currentUid()
762
+ )
763
+ throw new Error("unsafe private publication file");
764
+ } catch (error) {
765
+ if (!isErrno(error, "ENOENT")) throw error;
766
+ }
767
+ return context;
768
+ } catch (error) {
769
+ await Promise.all(directories.reverse().map(directory => directory.handle.close().catch(() => undefined)));
770
+ throw error;
771
+ }
772
+ }
773
+
774
+ async function privateAtomicWrite(
775
+ filePath: string,
776
+ content: string,
777
+ options: StateWriterOptions,
778
+ context: PrivatePublicationContext,
779
+ ): Promise<void> {
780
+ await assertPrivatePublicationDirectories(context);
781
+ const temporary = tempPathFor(filePath);
782
+ const anchoredTemporary = path.join(directoryPath(context.targetParent), path.basename(temporary));
783
+ let renameAttempted = false;
784
+ try {
785
+ const handle = await fs.open(anchoredTemporary, "wx", 0o600);
786
+ try {
787
+ await handle.chmod(0o600);
788
+ const created = await handle.stat();
789
+ if (!created.isFile() || created.uid !== currentUid() || (created.mode & 0o777) !== 0o600)
790
+ throw new Error("unsafe private publication temporary file");
791
+ await handle.writeFile(content, "utf8");
792
+ await handle.sync();
793
+ } finally {
794
+ await handle.close();
795
+ }
796
+ await assertPrivatePublicationDirectories(context);
797
+ renameAttempted = true;
798
+ await fs.rename(anchoredTemporary, context.anchoredTarget);
799
+ await context.targetParent.handle.sync();
800
+ await assertPrivatePublicationDirectories(context);
801
+ await maybeAudit(filePath, options);
802
+ } catch (error) {
803
+ // A rename call or anything after it can have committed the replacement
804
+ // before returning an error. The caller must reload instead of retrying
805
+ // with a new authority record.
806
+ if (renameAttempted) throw new StatePublicationUncertainError(filePath, error);
807
+ await fs.rm(anchoredTemporary, { force: true }).catch(() => undefined);
808
+ throw error;
809
+ }
810
+ }
811
+
531
812
  async function writeGuardedResolvedJsonAtomic(
532
813
  filePath: string,
533
814
  value: unknown,
534
815
  options: GuardedStateWriterOptions,
535
816
  ): Promise<GuardedWriteResult> {
817
+ let privateContext: PrivatePublicationContext | undefined;
818
+ if (options.privateDurable) {
819
+ if (options.policy !== "source" || options.expectedRevision === undefined)
820
+ throw new Error("private durable publication requires source CAS");
821
+ privateContext = await preparePrivateDirectory(filePath, options);
822
+ }
536
823
  const write = async (): Promise<GuardedWriteResult> => {
537
- const current = await readJsonIfPresentTolerant(filePath);
824
+ if (privateContext) await assertPrivatePublicationDirectories(privateContext);
825
+ const strict = privateContext
826
+ ? await readPrivateExistingStateForMutation(privateContext.anchoredTarget)
827
+ : undefined;
828
+ if (strict?.kind === "corrupt") throw new Error(strict.error);
829
+ if (strict?.kind === "absent" && options.expectedRevision !== 0) throw new Error("established state missing");
830
+ const current = strict
831
+ ? strict.kind === "valid"
832
+ ? strict.value
833
+ : undefined
834
+ : await readJsonIfPresentTolerant(filePath);
538
835
  const currentRevision = persistedStateRevision(current);
539
836
 
540
837
  if (options.policy === "source") {
@@ -542,8 +839,13 @@ async function writeGuardedResolvedJsonAtomic(
542
839
  throw new StateWriteConflictError(filePath, options.expectedRevision, currentRevision);
543
840
  }
544
841
  const next = stampStateRevision(withWorkflowReceipt(value, buildReceipt(options)), currentRevision + 1);
545
- await atomicWrite(filePath, jsonText(next));
546
- await maybeAudit(filePath, options);
842
+ if (options.privateDurable) {
843
+ if (!privateContext) throw new Error("private publication directories were not prepared");
844
+ await privateAtomicWrite(filePath, jsonText(next), options, privateContext);
845
+ } else {
846
+ await atomicWrite(filePath, jsonText(next));
847
+ await maybeAudit(filePath, options);
848
+ }
547
849
  return { path: filePath, written: true, revision: currentRevision + 1, stamped: next };
548
850
  }
549
851
 
@@ -567,7 +869,11 @@ async function writeGuardedResolvedJsonAtomic(
567
869
  // that already holds this target's lock (via `withWorkflowStateLock`) must say
568
870
  // so rather than deadlocking against itself — the same `lockHeld` contract the
569
871
  // envelope writers already use.
570
- return options.lockHeld ? write() : lockResolvedWorkflowTarget(filePath, write, options.lock);
872
+ try {
873
+ return options.lockHeld ? await write() : await lockResolvedWorkflowTarget(filePath, write, options.lock);
874
+ } finally {
875
+ if (privateContext) await closePrivatePublicationContext(privateContext);
876
+ }
571
877
  }
572
878
 
573
879
  export async function writeGuardedJsonAtomic(
@@ -798,7 +1104,12 @@ export async function withWorkflowStateLock<T>(
798
1104
  options?: StateWriterOptions,
799
1105
  ): Promise<T> {
800
1106
  const filePath = resolveGjcTarget(targetPath, cwdForOptions(options));
801
- return lockResolvedWorkflowTarget(filePath, fn, options?.lock);
1107
+ const privateContext = options?.privateDurable ? await preparePrivateDirectory(filePath, options) : undefined;
1108
+ try {
1109
+ return await lockResolvedWorkflowTarget(filePath, fn, options?.lock);
1110
+ } finally {
1111
+ if (privateContext) await closePrivatePublicationContext(privateContext);
1112
+ }
802
1113
  }
803
1114
 
804
1115
  async function lockResolvedWorkflowTarget<T>(
@@ -20,7 +20,11 @@ export interface EndpointRequest {
20
20
  input: Record<string, unknown>;
21
21
  }
22
22
 
23
- export type EndpointHandler = (req: EndpointRequest) => Promise<unknown>;
23
+ export interface EndpointHandlerRequest extends EndpointRequest {
24
+ signal: AbortSignal;
25
+ }
26
+
27
+ export type EndpointHandler = (req: EndpointHandlerRequest) => Promise<unknown>;
24
28
 
25
29
  function frame(value: unknown): string {
26
30
  return `${JSON.stringify(value)}\n`;
@@ -54,6 +58,14 @@ export class ControlServer {
54
58
  socket.setEncoding("utf8");
55
59
  let buffer = "";
56
60
  let handled = false;
61
+ let responseSent = false;
62
+ const controller = new AbortController();
63
+ socket.once("close", () => {
64
+ if (!responseSent) controller.abort(new Error("endpoint_client_disconnected"));
65
+ });
66
+ socket.on("error", () => {
67
+ if (!responseSent) controller.abort(new Error("endpoint_client_disconnected"));
68
+ });
57
69
  socket.on("data", (chunk: string) => {
58
70
  if (handled) return;
59
71
  buffer += chunk;
@@ -61,20 +73,26 @@ export class ControlServer {
61
73
  if (idx < 0) return;
62
74
  handled = true;
63
75
  const line = buffer.slice(0, idx).trim();
64
- void this.#dispatch(line)
76
+ void this.#dispatch(line, controller.signal)
65
77
  .then(response => {
66
- socket.end(frame(response));
78
+ if (!socket.destroyed && !controller.signal.aborted) {
79
+ responseSent = true;
80
+ socket.end(frame(response));
81
+ }
67
82
  })
68
83
  .catch((error: unknown) => {
69
- socket.end(frame({ ok: false, error: error instanceof Error ? error.message : String(error) }));
84
+ if (!socket.destroyed && !controller.signal.aborted) {
85
+ responseSent = true;
86
+ socket.end(frame({ ok: false, error: error instanceof Error ? error.message : String(error) }));
87
+ }
70
88
  });
71
89
  });
72
90
  }
73
91
 
74
- async #dispatch(line: string): Promise<unknown> {
92
+ async #dispatch(line: string, signal: AbortSignal): Promise<unknown> {
75
93
  const req = JSON.parse(line) as EndpointRequest;
76
94
  if (!req || typeof req.verb !== "string") throw new Error("bad_request");
77
- return this.handler({ verb: req.verb, input: req.input ?? {} });
95
+ return this.handler({ verb: req.verb, input: req.input ?? {}, signal });
78
96
  }
79
97
 
80
98
  async close(): Promise<void> {