@gajae-code/coding-agent 0.12.4 → 0.12.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.
- package/CHANGELOG.md +36 -0
- package/README.md +4 -1
- package/dist/types/async/job-manager.d.ts +9 -1
- package/dist/types/eval/backend.d.ts +2 -2
- package/dist/types/exec/bash-executor.d.ts +10 -8
- package/dist/types/extensibility/extensions/runner.d.ts +9 -7
- package/dist/types/gjc-runtime/launch-tmux.d.ts +22 -3
- package/dist/types/gjc-runtime/psmux-detect.d.ts +3 -0
- package/dist/types/gjc-runtime/team-launch.d.ts +3 -1
- package/dist/types/gjc-runtime/team-runtime.d.ts +14 -2
- package/dist/types/gjc-runtime/team-store.d.ts +0 -4
- package/dist/types/gjc-runtime/team-workers.d.ts +5 -0
- package/dist/types/gjc-runtime/tmux-common.d.ts +1 -0
- package/dist/types/gjc-runtime/tmux-provider-context.d.ts +57 -0
- package/dist/types/gjc-runtime/tmux-sessions.d.ts +8 -1
- package/dist/types/gjc-runtime/ultragoal-change-set.d.ts +3 -0
- package/dist/types/gjc-runtime/ultragoal-review-source.d.ts +83 -0
- package/dist/types/gjc-runtime/ultragoal-runtime.d.ts +57 -4
- package/dist/types/internal-urls/registry-helpers.d.ts +18 -0
- package/dist/types/modes/components/gjc-bundle-settings.d.ts +1 -0
- package/dist/types/modes/components/plugin-settings.d.ts +2 -0
- package/dist/types/modes/components/settings-selector.d.ts +2 -0
- package/dist/types/sdk/broker/broker.d.ts +18 -0
- package/dist/types/sdk/broker/lifecycle-ledger.d.ts +13 -0
- package/dist/types/sdk/broker/lifecycle.d.ts +1 -0
- package/dist/types/sdk/bus/chat-daemon-control.d.ts +5 -2
- package/dist/types/sdk/bus/index.d.ts +4 -0
- package/dist/types/sdk/bus/telegram-daemon-contract.d.ts +7 -3
- package/dist/types/sdk/bus/telegram-daemon.d.ts +25 -3
- package/dist/types/sdk/bus/topic-registry.d.ts +63 -3
- package/dist/types/sdk/host/host.d.ts +23 -2
- package/dist/types/sdk/host/reverse-leases.d.ts +7 -4
- package/dist/types/session/agent-session.d.ts +5 -4
- package/dist/types/session/artifacts.d.ts +8 -5
- package/dist/types/session/attempt-record-store.d.ts +19 -0
- package/dist/types/session/client-bridge.d.ts +14 -0
- package/dist/types/session/internal/managed-session-storage.d.ts +7 -23
- package/dist/types/session/messages.d.ts +4 -1
- package/dist/types/session/session-manager.d.ts +7 -5
- package/dist/types/session/session-storage.d.ts +8 -0
- package/dist/types/session/streaming-output.d.ts +32 -0
- package/dist/types/task/executor.d.ts +1 -1
- package/dist/types/task/index.d.ts +2 -1
- package/dist/types/task/receipt.d.ts +3 -0
- package/dist/types/task/types.d.ts +194 -0
- package/dist/types/task/worktree.d.ts +13 -6
- package/dist/types/tools/bash.d.ts +1 -1
- package/dist/types/tools/fetch.d.ts +3 -0
- package/dist/types/tools/output-meta.d.ts +37 -5
- package/dist/types/tools/subagent.d.ts +2 -0
- package/dist/types/tools/tool-result.d.ts +1 -0
- package/dist/types/utils/artifact-id.d.ts +2 -0
- package/dist/types/utils/git.d.ts +1 -0
- package/package.json +8 -8
- package/scripts/capture-sticky-viewport-showcase.ts +131 -5
- package/scripts/verify-gjc-sdk-canonicalization.ts +128 -183
- package/scripts/verify-sticky-viewport-showcase.ts +620 -93
- package/src/async/job-manager.ts +13 -5
- package/src/config/model-profiles.ts +8 -0
- package/src/config/settings-schema.ts +17 -3
- package/src/cursor.ts +20 -13
- package/src/defaults/gjc/skills/ultragoal/SKILL.md +5 -2
- package/src/eval/backend.ts +2 -2
- package/src/exec/bash-executor.ts +425 -151
- package/src/extensibility/extensions/runner.ts +80 -6
- package/src/extensibility/extensions/wrapper.ts +22 -15
- package/src/extensibility/runtime-skill-discovery.ts +4 -6
- package/src/gjc-runtime/launch-tmux.ts +309 -293
- package/src/gjc-runtime/managed-owner-supervisor.ts +6 -2
- package/src/gjc-runtime/psmux-detect.ts +52 -6
- package/src/gjc-runtime/team-launch.ts +50 -2
- package/src/gjc-runtime/team-runtime.ts +716 -251
- package/src/gjc-runtime/team-store.ts +25 -7
- package/src/gjc-runtime/team-workers.ts +161 -1
- package/src/gjc-runtime/tmux-common.ts +16 -4
- package/src/gjc-runtime/tmux-gc.ts +37 -5
- package/src/gjc-runtime/tmux-owner-isolation.ts +2 -0
- package/src/gjc-runtime/tmux-provider-context.ts +451 -0
- package/src/gjc-runtime/tmux-sessions.ts +409 -156
- package/src/gjc-runtime/ultragoal-change-set.ts +90 -44
- package/src/gjc-runtime/ultragoal-receipt-freshness.ts +7 -0
- package/src/gjc-runtime/ultragoal-review-source.ts +378 -0
- package/src/gjc-runtime/ultragoal-runtime.ts +640 -159
- package/src/gjc-runtime/workflow-manifest.generated.json +40 -1
- package/src/gjc-runtime/workflow-manifest.ts +12 -0
- package/src/internal-urls/agent-protocol.ts +98 -38
- package/src/internal-urls/artifact-protocol.ts +28 -17
- package/src/internal-urls/docs-index.generated.ts +6 -6
- package/src/internal-urls/local-protocol.ts +39 -18
- package/src/internal-urls/registry-helpers.ts +62 -0
- package/src/modes/acp/acp-agent.ts +51 -13
- package/src/modes/acp/acp-client-bridge.ts +154 -11
- package/src/modes/components/gjc-bundle-settings.ts +2 -0
- package/src/modes/components/plugin-settings.ts +5 -0
- package/src/modes/components/settings-selector.ts +4 -0
- package/src/modes/controllers/selector-controller.ts +1 -0
- package/src/prompts/tools/bash.md +2 -2
- package/src/prompts/tools/monitor.md +4 -4
- package/src/sdk/acp/adapter.ts +200 -9
- package/src/sdk/broker/broker.ts +93 -10
- package/src/sdk/broker/lifecycle-ledger.ts +227 -11
- package/src/sdk/broker/lifecycle.ts +692 -98
- package/src/sdk/bus/chat-daemon-control.ts +33 -5
- package/src/sdk/bus/index.ts +347 -14
- package/src/sdk/bus/telegram-daemon-contract.ts +7 -3
- package/src/sdk/bus/telegram-daemon.ts +283 -44
- package/src/sdk/bus/topic-registry.ts +133 -10
- package/src/sdk/host/host.ts +187 -9
- package/src/sdk/host/reverse-leases.ts +249 -42
- package/src/sdk/session.ts +8 -6
- package/src/session/agent-session.ts +930 -354
- package/src/session/artifacts.ts +211 -74
- package/src/session/attempt-record-store.ts +106 -0
- package/src/session/client-bridge.ts +15 -0
- package/src/session/internal/managed-session-scope.ts +332 -198
- package/src/session/internal/managed-session-storage.ts +162 -450
- package/src/session/messages.ts +32 -7
- package/src/session/session-manager.ts +31 -28
- package/src/session/session-storage.ts +190 -53
- package/src/session/streaming-output.ts +198 -40
- package/src/task/executor.ts +5 -2
- package/src/task/index.ts +277 -64
- package/src/task/provider-retry-status.ts +4 -1
- package/src/task/receipt.ts +26 -2
- package/src/task/render.ts +42 -1
- package/src/task/types.ts +52 -0
- package/src/task/worktree.ts +122 -43
- package/src/tools/bash.ts +818 -239
- package/src/tools/eval.ts +99 -4
- package/src/tools/fetch.ts +77 -15
- package/src/tools/find.ts +7 -3
- package/src/tools/monitor.ts +7 -1
- package/src/tools/output-meta.ts +706 -116
- package/src/tools/search.ts +9 -4
- package/src/tools/subagent-render.ts +5 -2
- package/src/tools/subagent.ts +8 -0
- package/src/tools/tool-result.ts +1 -0
- package/src/utils/artifact-id.ts +6 -0
- package/src/utils/git.ts +2 -0
|
@@ -6,10 +6,8 @@ import {
|
|
|
6
6
|
applyOwnerOnlyFdSecurity,
|
|
7
7
|
applyOwnerOnlyPathSecurity,
|
|
8
8
|
exactRemoveDirectoryTree,
|
|
9
|
-
exactReplacePath,
|
|
10
9
|
exactUnlink,
|
|
11
10
|
type NativeDirectoryTreeSnapshot,
|
|
12
|
-
type NativeExactUnlinkResult,
|
|
13
11
|
type NativeOwnerOnlySecurityResult,
|
|
14
12
|
openRecoveryFsRoot,
|
|
15
13
|
type RecoveryFsRoot,
|
|
@@ -36,6 +34,7 @@ const LOCK_LEASE_MS = 60_000;
|
|
|
36
34
|
const LOCK_HEARTBEAT_MS = 10_000;
|
|
37
35
|
const LOCK_WAIT_MS = 5_000;
|
|
38
36
|
|
|
37
|
+
const LOCK_STALE_RECHECK_MS = 100;
|
|
39
38
|
export class ManagedPublishError extends Error {
|
|
40
39
|
readonly classification:
|
|
41
40
|
| "destination_conflict"
|
|
@@ -45,14 +44,12 @@ export class ManagedPublishError extends Error {
|
|
|
45
44
|
| "identity_mismatch"
|
|
46
45
|
| "io_error"
|
|
47
46
|
| "durability_failed";
|
|
48
|
-
readonly code: ManagedPublishError["classification"];
|
|
49
47
|
readonly diagnostic: string;
|
|
50
48
|
|
|
51
49
|
constructor(classification: ManagedPublishError["classification"], outcome: NativePublishOutcome) {
|
|
52
50
|
super(classification);
|
|
53
51
|
this.name = "ManagedPublishError";
|
|
54
52
|
this.classification = classification;
|
|
55
|
-
this.code = classification;
|
|
56
53
|
this.diagnostic = formatNativePublishDiagnostic(outcome);
|
|
57
54
|
}
|
|
58
55
|
}
|
|
@@ -92,6 +89,7 @@ function sameDirectoryTreeSnapshotAfterRename(
|
|
|
92
89
|
entry.kind === other.kind &&
|
|
93
90
|
entry.dev === other.dev &&
|
|
94
91
|
entry.ino === other.ino &&
|
|
92
|
+
entry.nlink === other.nlink &&
|
|
95
93
|
entry.size === other.size &&
|
|
96
94
|
entry.mtimeNs === other.mtimeNs &&
|
|
97
95
|
(entry.relativePath === "" || entry.ctimeNs === other.ctimeNs) &&
|
|
@@ -137,24 +135,15 @@ export interface ManagedStorageLock {
|
|
|
137
135
|
assertOwned(): void;
|
|
138
136
|
release(): Promise<void>;
|
|
139
137
|
}
|
|
140
|
-
export interface ManagedStorageLockSync {
|
|
141
|
-
path: string;
|
|
142
|
-
attemptId: string;
|
|
143
|
-
assertOwned(): void;
|
|
144
|
-
release(): void;
|
|
145
|
-
}
|
|
146
138
|
|
|
147
139
|
export interface ManagedFileSnapshot {
|
|
148
140
|
bytes: Buffer;
|
|
149
141
|
identity: { dev: bigint; ino: bigint; size: number; mtimeNs: bigint; ctimeNs: bigint; sha256: string };
|
|
150
142
|
}
|
|
151
143
|
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
"acl_present",
|
|
156
|
-
"acl_malformed",
|
|
157
|
-
"acl_unknown",
|
|
144
|
+
const ACL_FAILURE_CODES = new Set(["acl_denied", "acl_io_error", "acl_present", "acl_malformed", "acl_unknown"]);
|
|
145
|
+
const ACL_CLEAR_EVIDENCE = new Set(["cleared", "already_absent", "unsupported", "not_run"]);
|
|
146
|
+
const GENERAL_FAILURE_CODES = new Set([
|
|
158
147
|
"acl_unavailable",
|
|
159
148
|
"acl_apply_failed",
|
|
160
149
|
"acl_verify_failed",
|
|
@@ -167,52 +156,7 @@ export const MANAGED_SESSION_SECURITY_CODES = [
|
|
|
167
156
|
"owner_mismatch",
|
|
168
157
|
"mode_mismatch",
|
|
169
158
|
"io_error",
|
|
170
|
-
] as const;
|
|
171
|
-
export type ManagedSessionSecurityCode = (typeof MANAGED_SESSION_SECURITY_CODES)[number];
|
|
172
|
-
const ACL_FAILURE_CODES = new Set<ManagedSessionSecurityCode>([
|
|
173
|
-
"acl_denied",
|
|
174
|
-
"acl_io_error",
|
|
175
|
-
"acl_present",
|
|
176
|
-
"acl_malformed",
|
|
177
|
-
"acl_unknown",
|
|
178
159
|
]);
|
|
179
|
-
const ACL_CLEAR_EVIDENCE = new Set(["cleared", "already_absent", "unsupported", "not_run"]);
|
|
180
|
-
const GENERAL_FAILURE_CODES = new Set<ManagedSessionSecurityCode>(MANAGED_SESSION_SECURITY_CODES);
|
|
181
|
-
const LOCK_OWNERSHIP_LOSS_CODES = new Set<ManagedSessionSecurityCode>([
|
|
182
|
-
"not_found",
|
|
183
|
-
"not_directory",
|
|
184
|
-
"reparse_point",
|
|
185
|
-
"identity_mismatch",
|
|
186
|
-
]);
|
|
187
|
-
|
|
188
|
-
function lockDescriptorOwnershipLost(error: unknown): boolean {
|
|
189
|
-
const code = managedSecurityFailureClassification(error);
|
|
190
|
-
return code !== undefined && LOCK_OWNERSHIP_LOSS_CODES.has(code as ManagedSessionSecurityCode);
|
|
191
|
-
}
|
|
192
|
-
|
|
193
|
-
export class ManagedSessionSecurityError extends Error {
|
|
194
|
-
readonly code: ManagedSessionSecurityCode;
|
|
195
|
-
|
|
196
|
-
constructor(code: ManagedSessionSecurityCode) {
|
|
197
|
-
super(code);
|
|
198
|
-
this.name = "ManagedSessionSecurityError";
|
|
199
|
-
this.code = code;
|
|
200
|
-
}
|
|
201
|
-
}
|
|
202
|
-
|
|
203
|
-
export function managedSessionSecurityCode(error: unknown): ManagedSessionSecurityCode | undefined {
|
|
204
|
-
if (!(error instanceof Error) || typeof (error as unknown as { code?: unknown }).code !== "string") return undefined;
|
|
205
|
-
const code = (error as unknown as { code: string }).code;
|
|
206
|
-
return GENERAL_FAILURE_CODES.has(code as ManagedSessionSecurityCode)
|
|
207
|
-
? (code as ManagedSessionSecurityCode)
|
|
208
|
-
: undefined;
|
|
209
|
-
}
|
|
210
|
-
|
|
211
|
-
function managedSessionSecurityErrorForCode(code: string | undefined): ManagedSessionSecurityError {
|
|
212
|
-
return new ManagedSessionSecurityError(
|
|
213
|
-
GENERAL_FAILURE_CODES.has(code as ManagedSessionSecurityCode) ? (code as ManagedSessionSecurityCode) : "io_error",
|
|
214
|
-
);
|
|
215
|
-
}
|
|
216
160
|
|
|
217
161
|
function hasOnlyKeys(value: Record<string, unknown>, allowed: readonly string[]): boolean {
|
|
218
162
|
return Object.keys(value).every(key => allowed.includes(key));
|
|
@@ -230,11 +174,10 @@ export function validateNativeSecurityResult(
|
|
|
230
174
|
const result = value as Record<string, unknown>;
|
|
231
175
|
if (result.ok === false) {
|
|
232
176
|
if (typeof result.code !== "string") throw new Error("Malformed owner-only security failure");
|
|
233
|
-
|
|
234
|
-
if (!ACL_FAILURE_CODES.has(code) && !GENERAL_FAILURE_CODES.has(code)) {
|
|
177
|
+
if (!ACL_FAILURE_CODES.has(result.code) && !GENERAL_FAILURE_CODES.has(result.code)) {
|
|
235
178
|
throw new Error("Unknown owner-only security failure code");
|
|
236
179
|
}
|
|
237
|
-
if (ACL_FAILURE_CODES.has(code)) {
|
|
180
|
+
if (ACL_FAILURE_CODES.has(result.code)) {
|
|
238
181
|
if (
|
|
239
182
|
(result.operation !== "clear" && result.operation !== "query") ||
|
|
240
183
|
(result.attribute !== "access" && result.attribute !== "default")
|
|
@@ -455,7 +398,7 @@ class ManagedSecurityError extends Error {
|
|
|
455
398
|
}
|
|
456
399
|
|
|
457
400
|
export function managedSecurityFailureClassification(error: unknown): string | undefined {
|
|
458
|
-
return error instanceof ManagedSecurityError ? error.getClassification() :
|
|
401
|
+
return error instanceof ManagedSecurityError ? error.getClassification() : undefined;
|
|
459
402
|
}
|
|
460
403
|
|
|
461
404
|
function securityError(pathname: string, result: NativeSecurity): Error {
|
|
@@ -475,11 +418,10 @@ function windowsExistingVerifyFirst(policy: ManagedSessionSecurityPolicy): boole
|
|
|
475
418
|
|
|
476
419
|
function assertManagedPathIdentity(pathname: string, kind: "directory" | "file", expected: fs.BigIntStats): void {
|
|
477
420
|
const current = fs.lstatSync(pathname, { bigint: true });
|
|
478
|
-
if (current.isSymbolicLink()) throw new
|
|
421
|
+
if (current.isSymbolicLink()) throw new Error("reparse_point");
|
|
479
422
|
const expectedKind = kind === "directory" ? current.isDirectory() : current.isFile();
|
|
480
|
-
if (!expectedKind) throw new
|
|
481
|
-
if (current.dev !== expected.dev || current.ino !== expected.ino)
|
|
482
|
-
throw new ManagedSessionSecurityError("identity_mismatch");
|
|
423
|
+
if (!expectedKind) throw new Error(kind === "directory" ? "not_directory" : "not_file");
|
|
424
|
+
if (current.dev !== expected.dev || current.ino !== expected.ino) throw new Error("identity_mismatch");
|
|
483
425
|
}
|
|
484
426
|
|
|
485
427
|
function verifyExistingManagedPathSecurity(
|
|
@@ -499,7 +441,7 @@ function verifyExistingManagedPathSecurity(
|
|
|
499
441
|
function secureExistingManagedDirectory(pathname: string, kind: "directory" | "file"): void {
|
|
500
442
|
const named = fs.lstatSync(pathname, { bigint: true });
|
|
501
443
|
const safeKind = kind === "directory" ? named.isDirectory() : named.isFile();
|
|
502
|
-
if (!safeKind || named.isSymbolicLink()) throw new
|
|
444
|
+
if (!safeKind || named.isSymbolicLink()) throw new Error(`Unsafe managed ${kind}: ${pathname}`);
|
|
503
445
|
const verified = validateNativeSecurityResult(
|
|
504
446
|
verifyOwnerOnlyPathSecurityExpected(pathname, kind, named.dev, named.ino),
|
|
505
447
|
"verify",
|
|
@@ -537,6 +479,7 @@ export class ManagedSessionDescendantStore {
|
|
|
537
479
|
/** Logical profile root inherited by nested managed session destinations. */
|
|
538
480
|
readonly #profileAgentDir: string;
|
|
539
481
|
readonly #subtreeRoot: ManagedDirectoryRoot;
|
|
482
|
+
#portableMutationFence?: ManagedStorageLock;
|
|
540
483
|
|
|
541
484
|
constructor(
|
|
542
485
|
root: ManagedDirectoryRoot,
|
|
@@ -605,6 +548,40 @@ export class ManagedSessionDescendantStore {
|
|
|
605
548
|
return this.#policy;
|
|
606
549
|
}
|
|
607
550
|
|
|
551
|
+
get hasRetainedAuthority(): boolean {
|
|
552
|
+
return this.#authority !== undefined;
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
async acquireMutationFence(name: string): Promise<ManagedStorageLock | undefined> {
|
|
556
|
+
if (this.#authority) return undefined;
|
|
557
|
+
if (!/^[a-zA-Z0-9_.-]+$/.test(name)) throw new Error("invalid_mutation_fence");
|
|
558
|
+
this.#assertBound();
|
|
559
|
+
if (this.#portableMutationFence) throw new Error("migration_busy");
|
|
560
|
+
const lock = await acquireManagedLock(this.#baseDir, `.gjc-${name}`, this.#subtreeRoot, this.#policy);
|
|
561
|
+
try {
|
|
562
|
+
this.#assertBound();
|
|
563
|
+
lock.assertOwned();
|
|
564
|
+
} catch (error) {
|
|
565
|
+
await lock.release();
|
|
566
|
+
throw error;
|
|
567
|
+
}
|
|
568
|
+
this.#portableMutationFence = lock;
|
|
569
|
+
return {
|
|
570
|
+
path: lock.path,
|
|
571
|
+
attemptId: lock.attemptId,
|
|
572
|
+
assertOwned: () => {
|
|
573
|
+
this.#assertBound();
|
|
574
|
+
lock.assertOwned();
|
|
575
|
+
},
|
|
576
|
+
release: async () => {
|
|
577
|
+
try {
|
|
578
|
+
await lock.release();
|
|
579
|
+
} finally {
|
|
580
|
+
if (this.#portableMutationFence === lock) this.#portableMutationFence = undefined;
|
|
581
|
+
}
|
|
582
|
+
},
|
|
583
|
+
};
|
|
584
|
+
}
|
|
608
585
|
get profileAgentDir(): string {
|
|
609
586
|
return this.#profileAgentDir;
|
|
610
587
|
}
|
|
@@ -659,14 +636,15 @@ export class ManagedSessionDescendantStore {
|
|
|
659
636
|
if (this.#authority) {
|
|
660
637
|
if (this.#authorityBaseDir === this.#baseDir) {
|
|
661
638
|
const verified = this.#authority.verifyOwnerOnlyDirectory();
|
|
662
|
-
if (!verified.ok) throw
|
|
639
|
+
if (!verified.ok) throw new Error(verified.code ?? "acl_verify_failed");
|
|
663
640
|
} else {
|
|
664
641
|
this.#assertBound();
|
|
665
642
|
return;
|
|
666
643
|
}
|
|
667
644
|
} else {
|
|
668
645
|
const named = fs.lstatSync(this.#baseDir, { bigint: true });
|
|
669
|
-
if (!named.isDirectory() || named.isSymbolicLink())
|
|
646
|
+
if (!named.isDirectory() || named.isSymbolicLink())
|
|
647
|
+
throw new Error(`Unsafe managed directory: ${this.#baseDir}`);
|
|
670
648
|
if (windowsExistingVerifyFirst(this.#policy))
|
|
671
649
|
verifyExistingManagedPathSecurity(this.#baseDir, "directory", named);
|
|
672
650
|
else {
|
|
@@ -681,7 +659,18 @@ export class ManagedSessionDescendantStore {
|
|
|
681
659
|
this.#assertBound();
|
|
682
660
|
}
|
|
683
661
|
#assertBound(): void {
|
|
684
|
-
if (!this.#authority)
|
|
662
|
+
if (!this.#authority) {
|
|
663
|
+
const named = fs.lstatSync(this.#baseDir, { bigint: true });
|
|
664
|
+
if (
|
|
665
|
+
!named.isDirectory() ||
|
|
666
|
+
named.isSymbolicLink() ||
|
|
667
|
+
named.dev !== this.#subtreeRoot.dev ||
|
|
668
|
+
named.ino !== this.#subtreeRoot.ino
|
|
669
|
+
) {
|
|
670
|
+
throw new Error("Managed descendant root binding changed");
|
|
671
|
+
}
|
|
672
|
+
return;
|
|
673
|
+
}
|
|
685
674
|
const named = fs.lstatSync(this.#baseDir, { bigint: true });
|
|
686
675
|
const retained =
|
|
687
676
|
this.#authorityBaseDir === this.#baseDir
|
|
@@ -727,24 +716,41 @@ export class ManagedSessionDescendantStore {
|
|
|
727
716
|
}
|
|
728
717
|
|
|
729
718
|
async publishNoReplace(relativePath: string, bytes: Uint8Array): Promise<void> {
|
|
719
|
+
this.#assertBound();
|
|
720
|
+
this.#portableMutationFence?.assertOwned();
|
|
730
721
|
const resolved = this.#resolve(relativePath);
|
|
731
722
|
if (this.#authority) {
|
|
732
|
-
this.#assertBound();
|
|
733
723
|
this.#publishRetainedNoReplace(this.#relative(resolved), bytes);
|
|
734
724
|
this.#assertBound();
|
|
735
725
|
return;
|
|
736
726
|
}
|
|
737
|
-
await publishManagedFileNoReplace(
|
|
727
|
+
await publishManagedFileNoReplace(
|
|
728
|
+
resolved,
|
|
729
|
+
bytes,
|
|
730
|
+
this.#portableMutationFence ? () => this.#portableMutationFence?.assertOwned() : undefined,
|
|
731
|
+
this.#subtreeRoot,
|
|
732
|
+
this.#policy,
|
|
733
|
+
);
|
|
734
|
+
this.#assertBound();
|
|
735
|
+
this.#portableMutationFence?.assertOwned();
|
|
738
736
|
}
|
|
739
737
|
|
|
740
738
|
publishNoReplaceSync(relativePath: string, bytes: Uint8Array): void {
|
|
739
|
+
this.#assertBound();
|
|
740
|
+
this.#portableMutationFence?.assertOwned();
|
|
741
741
|
const resolved = this.#resolve(relativePath);
|
|
742
742
|
if (!this.#authority) {
|
|
743
|
-
publishManagedFileNoReplaceSync(
|
|
743
|
+
publishManagedFileNoReplaceSync(
|
|
744
|
+
resolved,
|
|
745
|
+
bytes,
|
|
746
|
+
this.#subtreeRoot,
|
|
747
|
+
this.#policy,
|
|
748
|
+
this.#portableMutationFence ? () => this.#portableMutationFence?.assertOwned() : undefined,
|
|
749
|
+
);
|
|
744
750
|
this.#assertBound();
|
|
751
|
+
this.#portableMutationFence?.assertOwned();
|
|
745
752
|
return;
|
|
746
753
|
}
|
|
747
|
-
this.#assertBound();
|
|
748
754
|
this.#publishRetainedNoReplace(this.#relative(resolved), bytes);
|
|
749
755
|
this.#assertBound();
|
|
750
756
|
}
|
|
@@ -753,13 +759,7 @@ export class ManagedSessionDescendantStore {
|
|
|
753
759
|
this.#assertBound();
|
|
754
760
|
const resolved = this.#resolve(relativePath);
|
|
755
761
|
if (!this.#authority) {
|
|
756
|
-
|
|
757
|
-
const existing = this.readExpected(relativePath);
|
|
758
|
-
if (existing) replaceManagedFileExactSync(resolved, bytes, existing, this.#subtreeRoot);
|
|
759
|
-
else await publishManagedFileNoReplace(resolved, bytes, undefined, this.#subtreeRoot, this.#policy);
|
|
760
|
-
} else {
|
|
761
|
-
await replaceManagedFile(resolved, bytes, this.#subtreeRoot, this.#policy);
|
|
762
|
-
}
|
|
762
|
+
await replaceManagedFile(resolved, bytes, this.#subtreeRoot, this.#policy);
|
|
763
763
|
this.#assertBound();
|
|
764
764
|
return;
|
|
765
765
|
}
|
|
@@ -768,17 +768,39 @@ export class ManagedSessionDescendantStore {
|
|
|
768
768
|
this.#assertBound();
|
|
769
769
|
}
|
|
770
770
|
|
|
771
|
+
replaceExpected(relativePath: string, bytes: Uint8Array, expected: ManagedFileSnapshot): void {
|
|
772
|
+
this.#assertBound();
|
|
773
|
+
const resolved = this.#resolve(relativePath);
|
|
774
|
+
if (!this.#authority) {
|
|
775
|
+
const fence = this.#portableMutationFence;
|
|
776
|
+
if (!fence) throw new Error("managed_replace_exact_unavailable");
|
|
777
|
+
fence.assertOwned();
|
|
778
|
+
const current = captureManagedFileNoFollow(resolved);
|
|
779
|
+
if (!sameIdentity(current.identity, expected.identity) || current.identity.sha256 !== expected.identity.sha256)
|
|
780
|
+
throw new Error("managed_replace_identity_mismatch");
|
|
781
|
+
replaceManagedFileSync(resolved, bytes, this.#subtreeRoot, this.#policy, () => fence.assertOwned());
|
|
782
|
+
fence.assertOwned();
|
|
783
|
+
return;
|
|
784
|
+
}
|
|
785
|
+
const replaced = (this.#authority as RecoveryFsRoot & RetainedManagedReplacer).replaceManaged(
|
|
786
|
+
this.#relative(resolved),
|
|
787
|
+
bytes,
|
|
788
|
+
expected.identity.dev.toString(),
|
|
789
|
+
expected.identity.ino.toString(),
|
|
790
|
+
String(expected.identity.size),
|
|
791
|
+
expected.identity.mtimeNs.toString(),
|
|
792
|
+
expected.identity.ctimeNs.toString(),
|
|
793
|
+
expected.identity.sha256,
|
|
794
|
+
);
|
|
795
|
+
if (!replaced.ok) throw new Error(replaced.code ?? "managed_replace_failed");
|
|
796
|
+
this.#assertBound();
|
|
797
|
+
}
|
|
798
|
+
|
|
771
799
|
replaceSync(relativePath: string, bytes: Uint8Array): void {
|
|
772
800
|
this.#assertBound();
|
|
773
801
|
const resolved = this.#resolve(relativePath);
|
|
774
802
|
if (!this.#authority) {
|
|
775
|
-
|
|
776
|
-
const existing = this.readExpected(relativePath);
|
|
777
|
-
if (existing) replaceManagedFileExactSync(resolved, bytes, existing, this.#subtreeRoot);
|
|
778
|
-
else publishManagedFileNoReplaceSync(resolved, bytes, this.#subtreeRoot, this.#policy);
|
|
779
|
-
} else {
|
|
780
|
-
replaceManagedFileSync(resolved, bytes, this.#subtreeRoot, this.#policy);
|
|
781
|
-
}
|
|
803
|
+
replaceManagedFileSync(resolved, bytes, this.#subtreeRoot, this.#policy);
|
|
782
804
|
this.#assertBound();
|
|
783
805
|
return;
|
|
784
806
|
}
|
|
@@ -1225,7 +1247,7 @@ export class ManagedSessionDescendantStore {
|
|
|
1225
1247
|
}
|
|
1226
1248
|
|
|
1227
1249
|
function secureFileDescriptor(pathname: string, fd: number, operation: "apply" | "verify"): void {
|
|
1228
|
-
if (process.platform !== "linux"
|
|
1250
|
+
if (process.platform !== "linux") {
|
|
1229
1251
|
if (operation === "apply") secure(pathname, "file");
|
|
1230
1252
|
else {
|
|
1231
1253
|
const verified = validateNativeSecurityResult(verifyOwnerOnlyPathSecurity(pathname, "file"), "verify", "file");
|
|
@@ -1309,9 +1331,11 @@ function isCtimeOnlyIdentityMismatch(
|
|
|
1309
1331
|
return sameStableIdentityIgnoringCtime(observed, expected) && observed.ctimeNs !== expected.ctimeNs;
|
|
1310
1332
|
}
|
|
1311
1333
|
|
|
1312
|
-
function
|
|
1334
|
+
function parseLock(pathname: string): LockRecord | undefined {
|
|
1313
1335
|
try {
|
|
1314
|
-
const
|
|
1336
|
+
const stat = fs.lstatSync(pathname);
|
|
1337
|
+
if (!stat.isFile() || stat.isSymbolicLink()) return undefined;
|
|
1338
|
+
const value: unknown = JSON.parse(fs.readFileSync(pathname, "utf8"));
|
|
1315
1339
|
if (!value || typeof value !== "object") return undefined;
|
|
1316
1340
|
const record = value as Partial<LockRecord>;
|
|
1317
1341
|
return typeof record.attemptId === "string" &&
|
|
@@ -1327,16 +1351,6 @@ function parseLockBytes(bytes: Uint8Array): LockRecord | undefined {
|
|
|
1327
1351
|
}
|
|
1328
1352
|
}
|
|
1329
1353
|
|
|
1330
|
-
function parseLock(pathname: string): LockRecord | undefined {
|
|
1331
|
-
try {
|
|
1332
|
-
const stat = fs.lstatSync(pathname);
|
|
1333
|
-
if (!stat.isFile() || stat.isSymbolicLink()) return undefined;
|
|
1334
|
-
return parseLockBytes(fs.readFileSync(pathname));
|
|
1335
|
-
} catch {
|
|
1336
|
-
return undefined;
|
|
1337
|
-
}
|
|
1338
|
-
}
|
|
1339
|
-
|
|
1340
1354
|
function ownerDefinitelyGone(record: LockRecord): boolean {
|
|
1341
1355
|
if (record.bootId && bootId() && record.bootId !== bootId()) return true;
|
|
1342
1356
|
try {
|
|
@@ -1357,82 +1371,6 @@ function writeLockDescriptor(fd: number, record: LockRecord): void {
|
|
|
1357
1371
|
function sameFileIdentity(left: fs.BigIntStats, right: fs.BigIntStats): boolean {
|
|
1358
1372
|
return left.dev === right.dev && left.ino === right.ino;
|
|
1359
1373
|
}
|
|
1360
|
-
function exactLockRetirementAccepted(lockPath: string, removed: NativeExactUnlinkResult): boolean {
|
|
1361
|
-
if (removed.ok) return true;
|
|
1362
|
-
if (removed.code !== "cleanup_pending") return false;
|
|
1363
|
-
const lockDirectory = path.dirname(path.resolve(lockPath));
|
|
1364
|
-
const authority = [
|
|
1365
|
-
removed.detachedPath,
|
|
1366
|
-
removed.retainedSuccessorPath,
|
|
1367
|
-
removed.retainedPlaceholderPath,
|
|
1368
|
-
removed.retainedUnknownPath,
|
|
1369
|
-
].find(pathname => {
|
|
1370
|
-
if (typeof pathname !== "string" || pathname.length === 0 || !path.isAbsolute(pathname)) return false;
|
|
1371
|
-
const resolved = path.resolve(pathname);
|
|
1372
|
-
if (resolved === path.resolve(lockPath) || path.dirname(resolved) !== lockDirectory) return false;
|
|
1373
|
-
if (!path.basename(resolved).startsWith(".gjc-")) return false;
|
|
1374
|
-
try {
|
|
1375
|
-
return !fs.lstatSync(resolved).isSymbolicLink();
|
|
1376
|
-
} catch {
|
|
1377
|
-
return false;
|
|
1378
|
-
}
|
|
1379
|
-
});
|
|
1380
|
-
try {
|
|
1381
|
-
fs.lstatSync(lockPath);
|
|
1382
|
-
return false;
|
|
1383
|
-
} catch (error) {
|
|
1384
|
-
return !!authority && (error as NodeJS.ErrnoException).code === "ENOENT";
|
|
1385
|
-
}
|
|
1386
|
-
}
|
|
1387
|
-
|
|
1388
|
-
function unlinkManagedLockSnapshotExact(lockPath: string, snapshot: ManagedFileSnapshot): void {
|
|
1389
|
-
const removed = exactUnlink(lockPath, {
|
|
1390
|
-
dev: snapshot.identity.dev,
|
|
1391
|
-
ino: snapshot.identity.ino,
|
|
1392
|
-
size: BigInt(snapshot.identity.size),
|
|
1393
|
-
mtimeNs: snapshot.identity.mtimeNs,
|
|
1394
|
-
sha256: snapshot.identity.sha256,
|
|
1395
|
-
quarantineName: `.gjc-lock-retire-${process.pid}-${randomUUID()}`,
|
|
1396
|
-
});
|
|
1397
|
-
if (!exactLockRetirementAccepted(lockPath, removed)) throw new Error("migration_busy");
|
|
1398
|
-
fsyncDirectory(path.dirname(lockPath));
|
|
1399
|
-
}
|
|
1400
|
-
|
|
1401
|
-
function retireCreatedManagedLockExact(lockPath: string, fd: number): void {
|
|
1402
|
-
const opened = fs.fstatSync(fd, { bigint: true });
|
|
1403
|
-
const snapshot = captureManagedFileNoFollow(lockPath);
|
|
1404
|
-
if (snapshot.identity.dev !== opened.dev || snapshot.identity.ino !== opened.ino) throw new Error("migration_busy");
|
|
1405
|
-
unlinkManagedLockSnapshotExact(lockPath, snapshot);
|
|
1406
|
-
}
|
|
1407
|
-
|
|
1408
|
-
/**
|
|
1409
|
-
* Retire a lock only after exact identity and attempt fencing. A typed
|
|
1410
|
-
* `cleanup_pending` result is terminal lock-authority retirement, not a claim
|
|
1411
|
-
* that retained bytes were deleted: accept it only when native exact-unlink
|
|
1412
|
-
* supplies concrete internal authority in this lock directory and the canonical
|
|
1413
|
-
* lock name is absent, so a successor may acquire while quarantine evidence stays
|
|
1414
|
-
* retained for recovery.
|
|
1415
|
-
*/
|
|
1416
|
-
function retireManagedLockExact(
|
|
1417
|
-
lockPath: string,
|
|
1418
|
-
expected: { lockIdentity?: fs.BigIntStats; attemptId: string },
|
|
1419
|
-
): void {
|
|
1420
|
-
let snapshot: ManagedFileSnapshot;
|
|
1421
|
-
try {
|
|
1422
|
-
snapshot = captureManagedFileNoFollow(lockPath);
|
|
1423
|
-
} catch {
|
|
1424
|
-
throw new Error("migration_busy");
|
|
1425
|
-
}
|
|
1426
|
-
const record = parseLockBytes(snapshot.bytes);
|
|
1427
|
-
if (
|
|
1428
|
-
!record ||
|
|
1429
|
-
record.attemptId !== expected.attemptId ||
|
|
1430
|
-
(expected.lockIdentity &&
|
|
1431
|
-
(snapshot.identity.dev !== expected.lockIdentity.dev || snapshot.identity.ino !== expected.lockIdentity.ino))
|
|
1432
|
-
)
|
|
1433
|
-
throw new Error("migration_busy");
|
|
1434
|
-
unlinkManagedLockSnapshotExact(lockPath, snapshot);
|
|
1435
|
-
}
|
|
1436
1374
|
|
|
1437
1375
|
/** Create a managed directory and fail closed unless its owner-only mode/ACL verifies. */
|
|
1438
1376
|
export function ensureManagedDirectory(
|
|
@@ -1587,6 +1525,7 @@ export function publishManagedFileNoReplaceSync(
|
|
|
1587
1525
|
bytes: Uint8Array,
|
|
1588
1526
|
root?: ManagedDirectoryRoot,
|
|
1589
1527
|
policy: ManagedSessionSecurityPolicy = "default",
|
|
1528
|
+
assertOwned?: () => void,
|
|
1590
1529
|
): void {
|
|
1591
1530
|
const parent = path.dirname(destination);
|
|
1592
1531
|
ensureManagedDirectory(parent, root, policy);
|
|
@@ -1597,6 +1536,7 @@ export function publishManagedFileNoReplaceSync(
|
|
|
1597
1536
|
let outcome: NativePublishOutcome | undefined;
|
|
1598
1537
|
|
|
1599
1538
|
try {
|
|
1539
|
+
assertOwned?.();
|
|
1600
1540
|
fd = fs.openSync(
|
|
1601
1541
|
staging,
|
|
1602
1542
|
fs.constants.O_CREAT | fs.constants.O_EXCL | fs.constants.O_WRONLY | fs.constants.O_NOFOLLOW,
|
|
@@ -1610,7 +1550,9 @@ export function publishManagedFileNoReplaceSync(
|
|
|
1610
1550
|
const staged = fs.fstatSync(fd, { bigint: true });
|
|
1611
1551
|
stagingIdentity = { dev: staged.dev, ino: staged.ino };
|
|
1612
1552
|
|
|
1553
|
+
assertOwned?.();
|
|
1613
1554
|
outcome = classifyNativePublishOutcome(renameNoReplacePath(staging, destination));
|
|
1555
|
+
assertOwned?.();
|
|
1614
1556
|
if (!outcome.ok) throw publishFailure(outcome);
|
|
1615
1557
|
|
|
1616
1558
|
const named = fs.lstatSync(destination, { bigint: true });
|
|
@@ -1640,130 +1582,13 @@ export function publishManagedFileNoReplaceSync(
|
|
|
1640
1582
|
if (failure !== undefined) throw failure;
|
|
1641
1583
|
}
|
|
1642
1584
|
|
|
1643
|
-
/**
|
|
1644
|
-
* Atomically replace a managed file only when the destination is still the
|
|
1645
|
-
* exact captured object. Unlike `replaceManagedFileSync`, this never performs
|
|
1646
|
-
* an unconditional pathname rename.
|
|
1647
|
-
*/
|
|
1648
|
-
export function replaceManagedFileExactSync(
|
|
1649
|
-
destination: string,
|
|
1650
|
-
bytes: Uint8Array,
|
|
1651
|
-
expected: Pick<ManagedFileSnapshot, "identity">,
|
|
1652
|
-
root: ManagedDirectoryRoot,
|
|
1653
|
-
): void {
|
|
1654
|
-
assertManagedDirectoryRoot(root);
|
|
1655
|
-
managedRelativePath(root, destination);
|
|
1656
|
-
if (process.platform === "win32") {
|
|
1657
|
-
const parent = path.dirname(destination);
|
|
1658
|
-
const lock = acquireManagedLockSync(
|
|
1659
|
-
parent,
|
|
1660
|
-
`.${path.basename(destination)}.replace`,
|
|
1661
|
-
root,
|
|
1662
|
-
"windows-existing-verify-first",
|
|
1663
|
-
);
|
|
1664
|
-
const staging = path.join(parent, `.${path.basename(destination)}.${randomUUID()}.replacement`);
|
|
1665
|
-
let fd: number | undefined;
|
|
1666
|
-
let stagedIdentity: { dev: bigint; ino: bigint } | undefined;
|
|
1667
|
-
let publicationUncertain = false;
|
|
1668
|
-
try {
|
|
1669
|
-
lock.assertOwned();
|
|
1670
|
-
const current = captureManagedFileNoFollow(destination);
|
|
1671
|
-
if (!sameIdentity(current.identity, expected.identity) || current.identity.sha256 !== expected.identity.sha256)
|
|
1672
|
-
throw new Error("managed_replace_identity_mismatch");
|
|
1673
|
-
fd = fs.openSync(
|
|
1674
|
-
staging,
|
|
1675
|
-
fs.constants.O_CREAT | fs.constants.O_EXCL | fs.constants.O_WRONLY | fs.constants.O_NOFOLLOW,
|
|
1676
|
-
0o600,
|
|
1677
|
-
);
|
|
1678
|
-
secureFileDescriptor(staging, fd, "apply");
|
|
1679
|
-
let offset = 0;
|
|
1680
|
-
while (offset < bytes.byteLength) offset += fs.writeSync(fd, bytes, offset, bytes.byteLength - offset);
|
|
1681
|
-
fs.fsyncSync(fd);
|
|
1682
|
-
secureFileDescriptor(staging, fd, "verify");
|
|
1683
|
-
const staged = fs.fstatSync(fd, { bigint: true });
|
|
1684
|
-
stagedIdentity = { dev: staged.dev, ino: staged.ino };
|
|
1685
|
-
fs.closeSync(fd);
|
|
1686
|
-
fd = undefined;
|
|
1687
|
-
lock.assertOwned();
|
|
1688
|
-
const outcome = exactReplacePath(staging, destination, {
|
|
1689
|
-
dev: expected.identity.dev,
|
|
1690
|
-
ino: expected.identity.ino,
|
|
1691
|
-
size: BigInt(expected.identity.size),
|
|
1692
|
-
mtimeNs: expected.identity.mtimeNs,
|
|
1693
|
-
sha256: expected.identity.sha256,
|
|
1694
|
-
});
|
|
1695
|
-
if (!outcome.ok) {
|
|
1696
|
-
publicationUncertain = true;
|
|
1697
|
-
throw new Error(
|
|
1698
|
-
outcome.detachedPath === staging
|
|
1699
|
-
? "managed_replace_publication_pending"
|
|
1700
|
-
: (outcome.code ?? "managed_replace_identity_mismatch"),
|
|
1701
|
-
);
|
|
1702
|
-
}
|
|
1703
|
-
lock.assertOwned();
|
|
1704
|
-
const published = fs.lstatSync(destination, { bigint: true });
|
|
1705
|
-
if (
|
|
1706
|
-
!published.isFile() ||
|
|
1707
|
-
published.isSymbolicLink() ||
|
|
1708
|
-
published.dev !== staged.dev ||
|
|
1709
|
-
published.ino !== staged.ino
|
|
1710
|
-
) {
|
|
1711
|
-
publicationUncertain = true;
|
|
1712
|
-
throw new Error("destination_identity_changed");
|
|
1713
|
-
}
|
|
1714
|
-
secureExistingManagedDirectory(destination, "file");
|
|
1715
|
-
} finally {
|
|
1716
|
-
if (fd !== undefined) fs.closeSync(fd);
|
|
1717
|
-
if (stagedIdentity && !publicationUncertain) {
|
|
1718
|
-
try {
|
|
1719
|
-
const named = fs.lstatSync(staging, { bigint: true });
|
|
1720
|
-
if (named.dev === stagedIdentity.dev && named.ino === stagedIdentity.ino) fs.unlinkSync(staging);
|
|
1721
|
-
} catch (error) {
|
|
1722
|
-
if ((error as NodeJS.ErrnoException).code !== "ENOENT") publicationUncertain = true;
|
|
1723
|
-
}
|
|
1724
|
-
}
|
|
1725
|
-
lock.release();
|
|
1726
|
-
}
|
|
1727
|
-
return;
|
|
1728
|
-
}
|
|
1729
|
-
const parent = path.dirname(destination);
|
|
1730
|
-
const parentStat = fs.lstatSync(parent, { bigint: true });
|
|
1731
|
-
if (!parentStat.isDirectory() || parentStat.isSymbolicLink()) throw new Error("managed_replace_parent_unavailable");
|
|
1732
|
-
const authority = openRecoveryFsRoot(parent);
|
|
1733
|
-
try {
|
|
1734
|
-
const retained = authority.identity();
|
|
1735
|
-
if (
|
|
1736
|
-
!retained.ok ||
|
|
1737
|
-
!retained.identity ||
|
|
1738
|
-
retained.identity.dev !== parentStat.dev.toString() ||
|
|
1739
|
-
retained.identity.ino !== parentStat.ino.toString()
|
|
1740
|
-
)
|
|
1741
|
-
throw new Error("managed_replace_parent_identity_changed");
|
|
1742
|
-
const verified = authority.verifyOwnerOnlyDirectory();
|
|
1743
|
-
if (!verified.ok) throw new Error(verified.code ?? "managed_replace_parent_unavailable");
|
|
1744
|
-
const replaced = authority.replaceManaged(
|
|
1745
|
-
path.basename(destination),
|
|
1746
|
-
bytes,
|
|
1747
|
-
expected.identity.dev.toString(),
|
|
1748
|
-
expected.identity.ino.toString(),
|
|
1749
|
-
expected.identity.size.toString(),
|
|
1750
|
-
expected.identity.mtimeNs.toString(),
|
|
1751
|
-
expected.identity.ctimeNs.toString(),
|
|
1752
|
-
expected.identity.sha256,
|
|
1753
|
-
);
|
|
1754
|
-
if (!replaced.ok) throw new Error(replaced.code ?? "managed_replace_identity_mismatch");
|
|
1755
|
-
const synced = authority.fsync();
|
|
1756
|
-
if (!synced.ok) throw new Error(synced.code ?? "managed_replace_fsync_failed");
|
|
1757
|
-
} finally {
|
|
1758
|
-
authority.close();
|
|
1759
|
-
}
|
|
1760
|
-
}
|
|
1761
1585
|
/** Replace one managed regular file while retaining the secured staging fd through publication. */
|
|
1762
|
-
function replaceManagedFileSync(
|
|
1586
|
+
export function replaceManagedFileSync(
|
|
1763
1587
|
destination: string,
|
|
1764
1588
|
bytes: Uint8Array,
|
|
1765
1589
|
root: ManagedDirectoryRoot,
|
|
1766
1590
|
policy: ManagedSessionSecurityPolicy = "default",
|
|
1591
|
+
assertFence?: () => void,
|
|
1767
1592
|
): void {
|
|
1768
1593
|
const parent = path.dirname(destination);
|
|
1769
1594
|
ensureManagedDirectory(parent, root, policy);
|
|
@@ -1771,6 +1596,7 @@ function replaceManagedFileSync(
|
|
|
1771
1596
|
let fd: number | undefined;
|
|
1772
1597
|
let stagedIdentity: { dev: bigint; ino: bigint } | undefined;
|
|
1773
1598
|
try {
|
|
1599
|
+
assertFence?.();
|
|
1774
1600
|
fd = fs.openSync(
|
|
1775
1601
|
staging,
|
|
1776
1602
|
fs.constants.O_CREAT | fs.constants.O_EXCL | fs.constants.O_WRONLY | fs.constants.O_NOFOLLOW,
|
|
@@ -1784,7 +1610,9 @@ function replaceManagedFileSync(
|
|
|
1784
1610
|
const staged = fs.fstatSync(fd, { bigint: true });
|
|
1785
1611
|
stagedIdentity = { dev: staged.dev, ino: staged.ino };
|
|
1786
1612
|
assertManagedDirectoryRoot(root);
|
|
1613
|
+
assertFence?.();
|
|
1787
1614
|
fs.renameSync(staging, destination);
|
|
1615
|
+
assertFence?.();
|
|
1788
1616
|
assertManagedDirectoryRoot(root);
|
|
1789
1617
|
const named = fs.lstatSync(destination, { bigint: true });
|
|
1790
1618
|
if (!named.isFile() || named.isSymbolicLink() || named.dev !== staged.dev || named.ino !== staged.ino) {
|
|
@@ -1807,7 +1635,7 @@ function replaceManagedFileSync(
|
|
|
1807
1635
|
}
|
|
1808
1636
|
}
|
|
1809
1637
|
|
|
1810
|
-
async function replaceManagedFile(
|
|
1638
|
+
export async function replaceManagedFile(
|
|
1811
1639
|
destination: string,
|
|
1812
1640
|
bytes: Uint8Array,
|
|
1813
1641
|
root: ManagedDirectoryRoot,
|
|
@@ -1842,6 +1670,7 @@ export async function acquireManagedLock(
|
|
|
1842
1670
|
ensureManagedDirectory(locksDirectory, root, policy);
|
|
1843
1671
|
const lockPath = path.join(locksDirectory, `${name}.lock`);
|
|
1844
1672
|
const deadline = Date.now() + LOCK_WAIT_MS;
|
|
1673
|
+
let staleObservedAt: number | undefined;
|
|
1845
1674
|
while (true) {
|
|
1846
1675
|
const attemptId = randomUUID();
|
|
1847
1676
|
const now = Date.now();
|
|
@@ -1866,11 +1695,7 @@ export async function acquireManagedLock(
|
|
|
1866
1695
|
fs.fsyncSync(fd);
|
|
1867
1696
|
fsyncDirectory(locksDirectory);
|
|
1868
1697
|
} catch (error) {
|
|
1869
|
-
|
|
1870
|
-
retireCreatedManagedLockExact(lockPath, fd);
|
|
1871
|
-
} finally {
|
|
1872
|
-
fs.closeSync(fd);
|
|
1873
|
-
}
|
|
1698
|
+
fs.closeSync(fd);
|
|
1874
1699
|
throw error;
|
|
1875
1700
|
}
|
|
1876
1701
|
const lockIdentity = fs.fstatSync(fd, { bigint: true });
|
|
@@ -1878,8 +1703,12 @@ export async function acquireManagedLock(
|
|
|
1878
1703
|
let descriptorClosed = false;
|
|
1879
1704
|
const closeDescriptor = (): void => {
|
|
1880
1705
|
if (descriptorClosed) return;
|
|
1881
|
-
|
|
1882
|
-
|
|
1706
|
+
try {
|
|
1707
|
+
secureFileDescriptor(lockPath, fd, "verify");
|
|
1708
|
+
} finally {
|
|
1709
|
+
fs.closeSync(fd);
|
|
1710
|
+
descriptorClosed = true;
|
|
1711
|
+
}
|
|
1883
1712
|
};
|
|
1884
1713
|
const assertOwned = (): void => {
|
|
1885
1714
|
const current = parseLock(lockPath);
|
|
@@ -1894,7 +1723,8 @@ export async function acquireManagedLock(
|
|
|
1894
1723
|
descriptorClosed ||
|
|
1895
1724
|
!current ||
|
|
1896
1725
|
!sameFileIdentity(lockIdentity, named) ||
|
|
1897
|
-
current.attemptId !== attemptId
|
|
1726
|
+
current.attemptId !== attemptId ||
|
|
1727
|
+
current.leaseExpiresAt < Date.now()
|
|
1898
1728
|
)
|
|
1899
1729
|
throw new Error("migration_busy");
|
|
1900
1730
|
};
|
|
@@ -1912,160 +1742,42 @@ export async function acquireManagedLock(
|
|
|
1912
1742
|
attemptId,
|
|
1913
1743
|
assertOwned,
|
|
1914
1744
|
async release(): Promise<void> {
|
|
1915
|
-
|
|
1916
|
-
clearInterval(heartbeat);
|
|
1917
|
-
released = true;
|
|
1918
|
-
closeDescriptor();
|
|
1919
|
-
};
|
|
1745
|
+
clearInterval(heartbeat);
|
|
1920
1746
|
try {
|
|
1921
1747
|
assertOwned();
|
|
1922
|
-
|
|
1923
|
-
|
|
1924
|
-
|
|
1925
|
-
|
|
1926
|
-
|
|
1927
|
-
|
|
1928
|
-
|
|
1929
|
-
|
|
1930
|
-
throw error;
|
|
1931
|
-
}
|
|
1932
|
-
try {
|
|
1933
|
-
retireManagedLockExact(lockPath, { lockIdentity, attemptId });
|
|
1934
|
-
} catch (error) {
|
|
1935
|
-
try {
|
|
1936
|
-
assertOwned();
|
|
1937
|
-
} catch {
|
|
1938
|
-
finalize();
|
|
1939
|
-
}
|
|
1940
|
-
throw error;
|
|
1748
|
+
const now = Date.now();
|
|
1749
|
+
// Do not unlink by pathname: a stale owner could otherwise remove a successor.
|
|
1750
|
+
// The lease is retired through the verified inode-bound descriptor instead.
|
|
1751
|
+
writeLockDescriptor(fd, { ...record, heartbeatAt: now, leaseExpiresAt: now });
|
|
1752
|
+
fsyncDirectory(locksDirectory);
|
|
1753
|
+
} finally {
|
|
1754
|
+
released = true;
|
|
1755
|
+
closeDescriptor();
|
|
1941
1756
|
}
|
|
1942
|
-
finalize();
|
|
1943
1757
|
},
|
|
1944
1758
|
};
|
|
1945
1759
|
} catch (error) {
|
|
1946
1760
|
if ((error as NodeJS.ErrnoException).code !== "EEXIST") throw error;
|
|
1947
1761
|
const owner = parseLock(lockPath);
|
|
1948
|
-
if (owner && owner.leaseExpiresAt < Date.now()
|
|
1949
|
-
|
|
1950
|
-
|
|
1951
|
-
|
|
1952
|
-
|
|
1953
|
-
}
|
|
1954
|
-
}
|
|
1955
|
-
if (Date.now() >= deadline) throw new Error("migration_busy");
|
|
1956
|
-
await new Promise<void>(resolve => setTimeout(resolve, 50));
|
|
1957
|
-
}
|
|
1958
|
-
}
|
|
1959
|
-
}
|
|
1960
|
-
/** Acquire a synchronous lease lock for a short cross-process critical section. */
|
|
1961
|
-
export function acquireManagedLockSync(
|
|
1962
|
-
locksDirectory: string,
|
|
1963
|
-
name: string,
|
|
1964
|
-
root?: ManagedDirectoryRoot,
|
|
1965
|
-
policy: ManagedSessionSecurityPolicy = "default",
|
|
1966
|
-
): ManagedStorageLockSync {
|
|
1967
|
-
ensureManagedDirectory(locksDirectory, root, policy);
|
|
1968
|
-
const lockPath = path.join(locksDirectory, `${name}.lock`);
|
|
1969
|
-
const deadline = Date.now() + LOCK_WAIT_MS;
|
|
1970
|
-
while (true) {
|
|
1971
|
-
const attemptId = randomUUID();
|
|
1972
|
-
const now = Date.now();
|
|
1973
|
-
const record: LockRecord = {
|
|
1974
|
-
attemptId,
|
|
1975
|
-
pid: process.pid,
|
|
1976
|
-
bootId: bootId(),
|
|
1977
|
-
processStartId: PROCESS_START_ID,
|
|
1978
|
-
createdAt: now,
|
|
1979
|
-
heartbeatAt: now,
|
|
1980
|
-
leaseExpiresAt: now + LOCK_LEASE_MS,
|
|
1981
|
-
};
|
|
1982
|
-
try {
|
|
1983
|
-
const fd = fs.openSync(
|
|
1984
|
-
lockPath,
|
|
1985
|
-
fs.constants.O_CREAT | fs.constants.O_EXCL | fs.constants.O_WRONLY | fs.constants.O_NOFOLLOW,
|
|
1986
|
-
0o600,
|
|
1987
|
-
);
|
|
1988
|
-
try {
|
|
1989
|
-
secureFileDescriptor(lockPath, fd, "apply");
|
|
1990
|
-
writeLockDescriptor(fd, record);
|
|
1991
|
-
fsyncDirectory(locksDirectory);
|
|
1992
|
-
} catch (error) {
|
|
1993
|
-
try {
|
|
1994
|
-
retireCreatedManagedLockExact(lockPath, fd);
|
|
1995
|
-
} finally {
|
|
1996
|
-
fs.closeSync(fd);
|
|
1997
|
-
}
|
|
1998
|
-
throw error;
|
|
1999
|
-
}
|
|
2000
|
-
const lockIdentity = fs.fstatSync(fd, { bigint: true });
|
|
2001
|
-
let released = false;
|
|
2002
|
-
let descriptorClosed = false;
|
|
2003
|
-
const assertOwned = (): void => {
|
|
2004
|
-
const current = parseLock(lockPath);
|
|
2005
|
-
let named: fs.BigIntStats;
|
|
2006
|
-
try {
|
|
2007
|
-
named = fs.lstatSync(lockPath, { bigint: true });
|
|
2008
|
-
} catch {
|
|
2009
|
-
throw new Error("migration_busy");
|
|
2010
|
-
}
|
|
2011
|
-
if (
|
|
2012
|
-
released ||
|
|
2013
|
-
descriptorClosed ||
|
|
2014
|
-
!current ||
|
|
2015
|
-
!sameFileIdentity(lockIdentity, named) ||
|
|
2016
|
-
current.attemptId !== attemptId
|
|
2017
|
-
)
|
|
2018
|
-
throw new Error("migration_busy");
|
|
2019
|
-
};
|
|
2020
|
-
return {
|
|
2021
|
-
path: lockPath,
|
|
2022
|
-
attemptId,
|
|
2023
|
-
assertOwned,
|
|
2024
|
-
release(): void {
|
|
2025
|
-
const finalize = (): void => {
|
|
2026
|
-
released = true;
|
|
2027
|
-
if (!descriptorClosed) {
|
|
2028
|
-
fs.closeSync(fd);
|
|
2029
|
-
descriptorClosed = true;
|
|
2030
|
-
}
|
|
2031
|
-
};
|
|
2032
|
-
try {
|
|
2033
|
-
assertOwned();
|
|
2034
|
-
} catch (error) {
|
|
2035
|
-
finalize();
|
|
2036
|
-
throw error;
|
|
2037
|
-
}
|
|
2038
|
-
try {
|
|
2039
|
-
secureFileDescriptor(lockPath, fd, "verify");
|
|
2040
|
-
} catch (error) {
|
|
2041
|
-
if (lockDescriptorOwnershipLost(error)) finalize();
|
|
2042
|
-
throw error;
|
|
2043
|
-
}
|
|
1762
|
+
if (owner && owner.leaseExpiresAt < Date.now()) {
|
|
1763
|
+
const ownerGone = ownerDefinitelyGone(owner);
|
|
1764
|
+
if (staleObservedAt === undefined) staleObservedAt = Date.now();
|
|
1765
|
+
if (ownerGone || Date.now() - staleObservedAt >= LOCK_STALE_RECHECK_MS) {
|
|
1766
|
+
const quarantine = `${lockPath}.${randomUUID()}.stale`;
|
|
2044
1767
|
try {
|
|
2045
|
-
|
|
2046
|
-
|
|
2047
|
-
|
|
2048
|
-
|
|
2049
|
-
|
|
2050
|
-
|
|
2051
|
-
|
|
2052
|
-
throw error;
|
|
1768
|
+
fs.renameSync(lockPath, quarantine);
|
|
1769
|
+
const quarantined = parseLock(quarantine);
|
|
1770
|
+
if (!quarantined || quarantined.attemptId !== owner.attemptId) throw new Error("migration_busy");
|
|
1771
|
+
fs.unlinkSync(quarantine);
|
|
1772
|
+
fsyncDirectory(locksDirectory);
|
|
1773
|
+
} catch {
|
|
1774
|
+
/* retry owner observation */
|
|
2053
1775
|
}
|
|
2054
|
-
|
|
2055
|
-
},
|
|
2056
|
-
};
|
|
2057
|
-
} catch (error) {
|
|
2058
|
-
if ((error as NodeJS.ErrnoException).code !== "EEXIST") throw error;
|
|
2059
|
-
const owner = parseLock(lockPath);
|
|
2060
|
-
if (owner && owner.leaseExpiresAt < Date.now() && ownerDefinitelyGone(owner)) {
|
|
2061
|
-
try {
|
|
2062
|
-
retireManagedLockExact(lockPath, { attemptId: owner.attemptId });
|
|
2063
|
-
} catch {
|
|
2064
|
-
/* Retry owner observation. */
|
|
1776
|
+
staleObservedAt = undefined;
|
|
2065
1777
|
}
|
|
2066
|
-
}
|
|
1778
|
+
} else staleObservedAt = undefined;
|
|
2067
1779
|
if (Date.now() >= deadline) throw new Error("migration_busy");
|
|
2068
|
-
|
|
1780
|
+
await new Promise<void>(resolve => setTimeout(resolve, 50));
|
|
2069
1781
|
}
|
|
2070
1782
|
}
|
|
2071
1783
|
}
|