@gajae-code/coding-agent 0.17.4 → 0.17.5
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/CHANGELOG.md +73 -0
- package/dist/types/config/autorouting-tier-map.d.ts +1031 -3
- package/dist/types/config/file-lock.d.ts +4 -2
- package/dist/types/config/model-registry.d.ts +3 -1
- package/dist/types/config/model-resolver.d.ts +1 -1
- package/dist/types/exec/bash-executor.d.ts +2 -0
- package/dist/types/extensibility/gjc-plugins/runtime-adapters.d.ts +11 -2
- package/dist/types/gjc-runtime/state-writer.d.ts +14 -1
- package/dist/types/harness-control-plane/control-endpoint.d.ts +4 -1
- package/dist/types/harness-control-plane/finalize.d.ts +14 -3
- package/dist/types/main.d.ts +1 -1
- package/dist/types/runtime-mcp/loader.d.ts +2 -0
- package/dist/types/runtime-mcp/manager.d.ts +13 -6
- package/dist/types/sdk/broker/broker.d.ts +4 -2
- package/dist/types/sdk/broker/ensure.d.ts +3 -0
- package/dist/types/sdk/broker/identity.d.ts +2 -0
- package/dist/types/sdk/broker/managed-task-dag.d.ts +1335 -0
- package/dist/types/sdk/broker/managed-task-verification.d.ts +37 -0
- package/dist/types/sdk/broker/spawn-authority.d.ts +8 -0
- package/dist/types/sdk/broker/startup-failure.d.ts +4 -0
- package/dist/types/sdk/bus/telegram-daemon-contract.d.ts +5 -1
- package/dist/types/sdk/prompt-deadline-flush.d.ts +2 -0
- package/dist/types/sdk/session.d.ts +3 -3
- package/dist/types/session/agent-session.d.ts +7 -2
- package/dist/types/task/types.d.ts +2 -0
- package/dist/types/tools/bash-interactive.d.ts +1 -0
- package/dist/types/tools/fetch.d.ts +10 -1
- package/dist/types/tools/subagent.d.ts +8 -1
- package/package.json +7 -7
- package/src/cli/public-command-registry.ts +8 -2
- package/src/cli/setup-cli.ts +1 -0
- package/src/cli/stats-cli.ts +11 -1
- package/src/config/autorouting-tier-map.ts +529 -3
- package/src/config/file-lock.ts +59 -14
- package/src/config/model-discovery-manager.ts +4 -4
- package/src/config/model-profile-activation.ts +83 -23
- package/src/config/model-registry.ts +72 -12
- package/src/config/model-resolver.ts +6 -1
- package/src/coordinator-mcp/question-state.ts +4 -1
- package/src/coordinator-mcp/server.ts +14 -6
- package/src/exec/bash-executor.ts +3 -0
- package/src/extensibility/gjc-plugins/runtime-adapters.ts +46 -8
- package/src/gjc-runtime/state-writer.ts +317 -6
- package/src/harness-control-plane/control-endpoint.ts +24 -6
- package/src/harness-control-plane/finalize.ts +312 -25
- package/src/harness-control-plane/owner.ts +323 -89
- package/src/internal-urls/docs-index.generated.ts +6 -4
- package/src/main.ts +69 -19
- package/src/modes/acp/acp-agent.ts +581 -37
- package/src/modes/components/gjc-bundle-settings.ts +21 -1
- package/src/modes/components/tool-status-header.ts +72 -6
- package/src/modes/controllers/runtime-mcp-command-controller.ts +1 -1
- package/src/runtime-mcp/loader.ts +6 -3
- package/src/runtime-mcp/manager.ts +177 -75
- package/src/sdk/broker/broker.ts +1057 -150
- package/src/sdk/broker/ensure.ts +21 -3
- package/src/sdk/broker/identity.ts +13 -0
- package/src/sdk/broker/managed-task-dag.ts +1927 -0
- package/src/sdk/broker/managed-task-verification.ts +642 -0
- package/src/sdk/broker/spawn-authority.ts +140 -7
- package/src/sdk/broker/startup-failure.ts +80 -1
- package/src/sdk/broker/transport.ts +1 -0
- package/src/sdk/bus/chat-daemon-control.ts +2 -2
- package/src/sdk/bus/index.ts +22 -6
- package/src/sdk/bus/telegram-daemon-contract.ts +5 -1
- package/src/sdk/cli/session-cli.ts +18 -0
- package/src/sdk/host/session-runtime.test.ts +46 -0
- package/src/sdk/host/session-runtime.ts +13 -6
- package/src/sdk/lifecycle/service.ts +2 -0
- package/src/sdk/prompt-deadline-flush.ts +26 -4
- package/src/sdk/session.ts +587 -115
- package/src/session/agent-session.ts +40 -12
- package/src/session/internal/managed-session-scope.ts +10 -0
- package/src/session/internal/managed-session-storage.ts +30 -1
- package/src/setup/provider-presets.json +2 -1
- package/src/slash-commands/builtin-registry.ts +1 -1
- package/src/task/executor.ts +46 -17
- package/src/task/types.ts +2 -0
- package/src/tools/bash-interactive.ts +2 -0
- package/src/tools/bash.ts +20 -0
- package/src/tools/fetch.ts +21 -5
- package/src/tools/subagent-render.ts +24 -1
- package/src/tools/subagent.ts +27 -1
- 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 {
|
|
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(
|
|
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.
|
|
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:
|
|
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({
|
|
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:
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
546
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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.
|
|
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.
|
|
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> {
|