@gajae-code/coding-agent 0.12.1 → 0.12.2
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 +30 -0
- package/dist/types/config/settings-schema.d.ts +2 -2
- package/dist/types/gjc-runtime/ralplan-review-conflicts.d.ts +114 -0
- package/dist/types/gjc-runtime/ralplan-runtime.d.ts +5 -3
- package/dist/types/gjc-runtime/ultragoal-runtime.d.ts +6 -0
- package/dist/types/modes/components/irc-sidebar.d.ts +16 -0
- package/dist/types/modes/controllers/command-controller.d.ts +1 -1
- package/dist/types/modes/controllers/event-controller.d.ts +1 -1
- package/dist/types/modes/controllers/extension-ui-controller.d.ts +2 -1
- package/dist/types/modes/controllers/selector-controller.d.ts +1 -1
- package/dist/types/modes/interactive-mode.d.ts +9 -0
- package/dist/types/modes/theme/theme.d.ts +3 -1
- package/dist/types/modes/types.d.ts +32 -0
- package/dist/types/sdk/bus/chat-daemon-control.d.ts +2 -1
- package/dist/types/sdk/bus/telegram-daemon-contract.d.ts +3 -2
- package/dist/types/session/agent-session.d.ts +2 -0
- package/dist/types/session/internal/managed-session-storage.d.ts +22 -3
- package/dist/types/task/index.d.ts +4 -0
- package/dist/types/task/output-manager.d.ts +1 -0
- package/dist/types/task/receipt.d.ts +2 -0
- package/dist/types/tools/index.d.ts +2 -0
- package/package.json +8 -8
- package/scripts/dogfood-ralplan-review-conflicts.ts +272 -0
- package/scripts/generate-sdk-operation-inventory.ts +2 -0
- package/scripts/run-acp-conformance.ts +5 -0
- package/src/async/job-manager.ts +22 -20
- package/src/config/settings-schema.ts +2 -2
- package/src/debug/index.ts +42 -17
- package/src/defaults/gjc/skills/ralplan/SKILL.md +9 -7
- package/src/defaults/gjc/skills/ultragoal/SKILL.md +2 -2
- package/src/gjc-runtime/launch-worktree.ts +103 -1
- package/src/gjc-runtime/ledger-event-renderer.ts +1 -0
- package/src/gjc-runtime/ralplan-review-conflicts.ts +472 -0
- package/src/gjc-runtime/ralplan-runtime.ts +80 -10
- package/src/gjc-runtime/ultragoal-evidence.ts +6 -74
- package/src/gjc-runtime/ultragoal-runtime.ts +236 -3
- package/src/gjc-runtime/workflow-manifest.generated.json +29 -0
- package/src/gjc-runtime/workflow-manifest.ts +17 -2
- package/src/internal-urls/docs-index.generated.ts +1 -1
- package/src/modes/DESIGN.md +53 -0
- package/src/modes/components/irc-sidebar.ts +30 -1
- package/src/modes/components/tool-status-header.ts +0 -1
- package/src/modes/controllers/command-controller.ts +38 -34
- package/src/modes/controllers/event-controller.ts +27 -16
- package/src/modes/controllers/extension-ui-controller.ts +186 -34
- package/src/modes/controllers/selector-controller.ts +115 -22
- package/src/modes/interactive-mode.ts +189 -62
- package/src/modes/jobs-observer.ts +1 -1
- package/src/modes/theme/theme.ts +11 -9
- package/src/modes/types.ts +88 -0
- package/src/sdk/broker/lifecycle.ts +18 -13
- package/src/sdk/bus/chat-daemon-control.ts +4 -3
- package/src/sdk/bus/telegram-daemon-contract.ts +3 -2
- package/src/sdk/protocol/operation-inventory.generated.json +11 -0
- package/src/sdk/session.ts +1 -0
- package/src/session/agent-session.ts +22 -1
- package/src/session/internal/managed-session-scope.ts +158 -107
- package/src/session/internal/managed-session-storage.ts +445 -57
- package/src/session/session-storage.ts +29 -44
- package/src/task/index.ts +257 -31
- package/src/task/output-manager.ts +24 -14
- package/src/task/receipt.ts +11 -1
- package/src/tools/index.ts +2 -0
|
@@ -6,8 +6,10 @@ import {
|
|
|
6
6
|
applyOwnerOnlyFdSecurity,
|
|
7
7
|
applyOwnerOnlyPathSecurity,
|
|
8
8
|
exactRemoveDirectoryTree,
|
|
9
|
+
exactReplacePath,
|
|
9
10
|
exactUnlink,
|
|
10
11
|
type NativeDirectoryTreeSnapshot,
|
|
12
|
+
type NativeExactUnlinkResult,
|
|
11
13
|
type NativeOwnerOnlySecurityResult,
|
|
12
14
|
openRecoveryFsRoot,
|
|
13
15
|
type RecoveryFsRoot,
|
|
@@ -34,7 +36,6 @@ const LOCK_LEASE_MS = 60_000;
|
|
|
34
36
|
const LOCK_HEARTBEAT_MS = 10_000;
|
|
35
37
|
const LOCK_WAIT_MS = 5_000;
|
|
36
38
|
|
|
37
|
-
const LOCK_STALE_RECHECK_MS = 100;
|
|
38
39
|
export class ManagedPublishError extends Error {
|
|
39
40
|
readonly classification:
|
|
40
41
|
| "destination_conflict"
|
|
@@ -44,12 +45,14 @@ export class ManagedPublishError extends Error {
|
|
|
44
45
|
| "identity_mismatch"
|
|
45
46
|
| "io_error"
|
|
46
47
|
| "durability_failed";
|
|
48
|
+
readonly code: ManagedPublishError["classification"];
|
|
47
49
|
readonly diagnostic: string;
|
|
48
50
|
|
|
49
51
|
constructor(classification: ManagedPublishError["classification"], outcome: NativePublishOutcome) {
|
|
50
52
|
super(classification);
|
|
51
53
|
this.name = "ManagedPublishError";
|
|
52
54
|
this.classification = classification;
|
|
55
|
+
this.code = classification;
|
|
53
56
|
this.diagnostic = formatNativePublishDiagnostic(outcome);
|
|
54
57
|
}
|
|
55
58
|
}
|
|
@@ -134,15 +137,24 @@ export interface ManagedStorageLock {
|
|
|
134
137
|
assertOwned(): void;
|
|
135
138
|
release(): Promise<void>;
|
|
136
139
|
}
|
|
140
|
+
export interface ManagedStorageLockSync {
|
|
141
|
+
path: string;
|
|
142
|
+
attemptId: string;
|
|
143
|
+
assertOwned(): void;
|
|
144
|
+
release(): void;
|
|
145
|
+
}
|
|
137
146
|
|
|
138
147
|
export interface ManagedFileSnapshot {
|
|
139
148
|
bytes: Buffer;
|
|
140
149
|
identity: { dev: bigint; ino: bigint; size: number; mtimeNs: bigint; ctimeNs: bigint; sha256: string };
|
|
141
150
|
}
|
|
142
151
|
|
|
143
|
-
const
|
|
144
|
-
|
|
145
|
-
|
|
152
|
+
export const MANAGED_SESSION_SECURITY_CODES = [
|
|
153
|
+
"acl_denied",
|
|
154
|
+
"acl_io_error",
|
|
155
|
+
"acl_present",
|
|
156
|
+
"acl_malformed",
|
|
157
|
+
"acl_unknown",
|
|
146
158
|
"acl_unavailable",
|
|
147
159
|
"acl_apply_failed",
|
|
148
160
|
"acl_verify_failed",
|
|
@@ -155,7 +167,52 @@ const GENERAL_FAILURE_CODES = new Set([
|
|
|
155
167
|
"owner_mismatch",
|
|
156
168
|
"mode_mismatch",
|
|
157
169
|
"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",
|
|
158
178
|
]);
|
|
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
|
+
}
|
|
159
216
|
|
|
160
217
|
function hasOnlyKeys(value: Record<string, unknown>, allowed: readonly string[]): boolean {
|
|
161
218
|
return Object.keys(value).every(key => allowed.includes(key));
|
|
@@ -173,10 +230,11 @@ export function validateNativeSecurityResult(
|
|
|
173
230
|
const result = value as Record<string, unknown>;
|
|
174
231
|
if (result.ok === false) {
|
|
175
232
|
if (typeof result.code !== "string") throw new Error("Malformed owner-only security failure");
|
|
176
|
-
|
|
233
|
+
const code = result.code as ManagedSessionSecurityCode;
|
|
234
|
+
if (!ACL_FAILURE_CODES.has(code) && !GENERAL_FAILURE_CODES.has(code)) {
|
|
177
235
|
throw new Error("Unknown owner-only security failure code");
|
|
178
236
|
}
|
|
179
|
-
if (ACL_FAILURE_CODES.has(
|
|
237
|
+
if (ACL_FAILURE_CODES.has(code)) {
|
|
180
238
|
if (
|
|
181
239
|
(result.operation !== "clear" && result.operation !== "query") ||
|
|
182
240
|
(result.attribute !== "access" && result.attribute !== "default")
|
|
@@ -397,7 +455,7 @@ class ManagedSecurityError extends Error {
|
|
|
397
455
|
}
|
|
398
456
|
|
|
399
457
|
export function managedSecurityFailureClassification(error: unknown): string | undefined {
|
|
400
|
-
return error instanceof ManagedSecurityError ? error.getClassification() :
|
|
458
|
+
return error instanceof ManagedSecurityError ? error.getClassification() : managedSessionSecurityCode(error);
|
|
401
459
|
}
|
|
402
460
|
|
|
403
461
|
function securityError(pathname: string, result: NativeSecurity): Error {
|
|
@@ -417,10 +475,11 @@ function windowsExistingVerifyFirst(policy: ManagedSessionSecurityPolicy): boole
|
|
|
417
475
|
|
|
418
476
|
function assertManagedPathIdentity(pathname: string, kind: "directory" | "file", expected: fs.BigIntStats): void {
|
|
419
477
|
const current = fs.lstatSync(pathname, { bigint: true });
|
|
420
|
-
if (current.isSymbolicLink()) throw new
|
|
478
|
+
if (current.isSymbolicLink()) throw new ManagedSessionSecurityError("reparse_point");
|
|
421
479
|
const expectedKind = kind === "directory" ? current.isDirectory() : current.isFile();
|
|
422
|
-
if (!expectedKind) throw new
|
|
423
|
-
if (current.dev !== expected.dev || current.ino !== expected.ino)
|
|
480
|
+
if (!expectedKind) throw new ManagedSessionSecurityError("not_directory");
|
|
481
|
+
if (current.dev !== expected.dev || current.ino !== expected.ino)
|
|
482
|
+
throw new ManagedSessionSecurityError("identity_mismatch");
|
|
424
483
|
}
|
|
425
484
|
|
|
426
485
|
function verifyExistingManagedPathSecurity(
|
|
@@ -440,7 +499,7 @@ function verifyExistingManagedPathSecurity(
|
|
|
440
499
|
function secureExistingManagedDirectory(pathname: string, kind: "directory" | "file"): void {
|
|
441
500
|
const named = fs.lstatSync(pathname, { bigint: true });
|
|
442
501
|
const safeKind = kind === "directory" ? named.isDirectory() : named.isFile();
|
|
443
|
-
if (!safeKind || named.isSymbolicLink()) throw new
|
|
502
|
+
if (!safeKind || named.isSymbolicLink()) throw new ManagedSessionSecurityError("reparse_point");
|
|
444
503
|
const verified = validateNativeSecurityResult(
|
|
445
504
|
verifyOwnerOnlyPathSecurityExpected(pathname, kind, named.dev, named.ino),
|
|
446
505
|
"verify",
|
|
@@ -600,15 +659,14 @@ export class ManagedSessionDescendantStore {
|
|
|
600
659
|
if (this.#authority) {
|
|
601
660
|
if (this.#authorityBaseDir === this.#baseDir) {
|
|
602
661
|
const verified = this.#authority.verifyOwnerOnlyDirectory();
|
|
603
|
-
if (!verified.ok) throw
|
|
662
|
+
if (!verified.ok) throw managedSessionSecurityErrorForCode(verified.code);
|
|
604
663
|
} else {
|
|
605
664
|
this.#assertBound();
|
|
606
665
|
return;
|
|
607
666
|
}
|
|
608
667
|
} else {
|
|
609
668
|
const named = fs.lstatSync(this.#baseDir, { bigint: true });
|
|
610
|
-
if (!named.isDirectory() || named.isSymbolicLink())
|
|
611
|
-
throw new Error(`Unsafe managed directory: ${this.#baseDir}`);
|
|
669
|
+
if (!named.isDirectory() || named.isSymbolicLink()) throw new ManagedSessionSecurityError("reparse_point");
|
|
612
670
|
if (windowsExistingVerifyFirst(this.#policy))
|
|
613
671
|
verifyExistingManagedPathSecurity(this.#baseDir, "directory", named);
|
|
614
672
|
else {
|
|
@@ -695,7 +753,13 @@ export class ManagedSessionDescendantStore {
|
|
|
695
753
|
this.#assertBound();
|
|
696
754
|
const resolved = this.#resolve(relativePath);
|
|
697
755
|
if (!this.#authority) {
|
|
698
|
-
|
|
756
|
+
if (process.platform === "win32") {
|
|
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
|
+
}
|
|
699
763
|
this.#assertBound();
|
|
700
764
|
return;
|
|
701
765
|
}
|
|
@@ -708,7 +772,13 @@ export class ManagedSessionDescendantStore {
|
|
|
708
772
|
this.#assertBound();
|
|
709
773
|
const resolved = this.#resolve(relativePath);
|
|
710
774
|
if (!this.#authority) {
|
|
711
|
-
|
|
775
|
+
if (process.platform === "win32") {
|
|
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
|
+
}
|
|
712
782
|
this.#assertBound();
|
|
713
783
|
return;
|
|
714
784
|
}
|
|
@@ -1155,7 +1225,7 @@ export class ManagedSessionDescendantStore {
|
|
|
1155
1225
|
}
|
|
1156
1226
|
|
|
1157
1227
|
function secureFileDescriptor(pathname: string, fd: number, operation: "apply" | "verify"): void {
|
|
1158
|
-
if (process.platform !== "linux") {
|
|
1228
|
+
if (process.platform !== "linux" && process.platform !== "win32") {
|
|
1159
1229
|
if (operation === "apply") secure(pathname, "file");
|
|
1160
1230
|
else {
|
|
1161
1231
|
const verified = validateNativeSecurityResult(verifyOwnerOnlyPathSecurity(pathname, "file"), "verify", "file");
|
|
@@ -1239,11 +1309,9 @@ function isCtimeOnlyIdentityMismatch(
|
|
|
1239
1309
|
return sameStableIdentityIgnoringCtime(observed, expected) && observed.ctimeNs !== expected.ctimeNs;
|
|
1240
1310
|
}
|
|
1241
1311
|
|
|
1242
|
-
function
|
|
1312
|
+
function parseLockBytes(bytes: Uint8Array): LockRecord | undefined {
|
|
1243
1313
|
try {
|
|
1244
|
-
const
|
|
1245
|
-
if (!stat.isFile() || stat.isSymbolicLink()) return undefined;
|
|
1246
|
-
const value: unknown = JSON.parse(fs.readFileSync(pathname, "utf8"));
|
|
1314
|
+
const value: unknown = JSON.parse(Buffer.from(bytes).toString("utf8"));
|
|
1247
1315
|
if (!value || typeof value !== "object") return undefined;
|
|
1248
1316
|
const record = value as Partial<LockRecord>;
|
|
1249
1317
|
return typeof record.attemptId === "string" &&
|
|
@@ -1259,6 +1327,16 @@ function parseLock(pathname: string): LockRecord | undefined {
|
|
|
1259
1327
|
}
|
|
1260
1328
|
}
|
|
1261
1329
|
|
|
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
|
+
|
|
1262
1340
|
function ownerDefinitelyGone(record: LockRecord): boolean {
|
|
1263
1341
|
if (record.bootId && bootId() && record.bootId !== bootId()) return true;
|
|
1264
1342
|
try {
|
|
@@ -1279,6 +1357,82 @@ function writeLockDescriptor(fd: number, record: LockRecord): void {
|
|
|
1279
1357
|
function sameFileIdentity(left: fs.BigIntStats, right: fs.BigIntStats): boolean {
|
|
1280
1358
|
return left.dev === right.dev && left.ino === right.ino;
|
|
1281
1359
|
}
|
|
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
|
+
}
|
|
1282
1436
|
|
|
1283
1437
|
/** Create a managed directory and fail closed unless its owner-only mode/ACL verifies. */
|
|
1284
1438
|
export function ensureManagedDirectory(
|
|
@@ -1486,8 +1640,126 @@ export function publishManagedFileNoReplaceSync(
|
|
|
1486
1640
|
if (failure !== undefined) throw failure;
|
|
1487
1641
|
}
|
|
1488
1642
|
|
|
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
|
+
}
|
|
1489
1761
|
/** Replace one managed regular file while retaining the secured staging fd through publication. */
|
|
1490
|
-
|
|
1762
|
+
function replaceManagedFileSync(
|
|
1491
1763
|
destination: string,
|
|
1492
1764
|
bytes: Uint8Array,
|
|
1493
1765
|
root: ManagedDirectoryRoot,
|
|
@@ -1535,7 +1807,7 @@ export function replaceManagedFileSync(
|
|
|
1535
1807
|
}
|
|
1536
1808
|
}
|
|
1537
1809
|
|
|
1538
|
-
|
|
1810
|
+
async function replaceManagedFile(
|
|
1539
1811
|
destination: string,
|
|
1540
1812
|
bytes: Uint8Array,
|
|
1541
1813
|
root: ManagedDirectoryRoot,
|
|
@@ -1570,7 +1842,6 @@ export async function acquireManagedLock(
|
|
|
1570
1842
|
ensureManagedDirectory(locksDirectory, root, policy);
|
|
1571
1843
|
const lockPath = path.join(locksDirectory, `${name}.lock`);
|
|
1572
1844
|
const deadline = Date.now() + LOCK_WAIT_MS;
|
|
1573
|
-
let staleObservedAt: number | undefined;
|
|
1574
1845
|
while (true) {
|
|
1575
1846
|
const attemptId = randomUUID();
|
|
1576
1847
|
const now = Date.now();
|
|
@@ -1595,7 +1866,11 @@ export async function acquireManagedLock(
|
|
|
1595
1866
|
fs.fsyncSync(fd);
|
|
1596
1867
|
fsyncDirectory(locksDirectory);
|
|
1597
1868
|
} catch (error) {
|
|
1598
|
-
|
|
1869
|
+
try {
|
|
1870
|
+
retireCreatedManagedLockExact(lockPath, fd);
|
|
1871
|
+
} finally {
|
|
1872
|
+
fs.closeSync(fd);
|
|
1873
|
+
}
|
|
1599
1874
|
throw error;
|
|
1600
1875
|
}
|
|
1601
1876
|
const lockIdentity = fs.fstatSync(fd, { bigint: true });
|
|
@@ -1603,12 +1878,8 @@ export async function acquireManagedLock(
|
|
|
1603
1878
|
let descriptorClosed = false;
|
|
1604
1879
|
const closeDescriptor = (): void => {
|
|
1605
1880
|
if (descriptorClosed) return;
|
|
1606
|
-
|
|
1607
|
-
|
|
1608
|
-
} finally {
|
|
1609
|
-
fs.closeSync(fd);
|
|
1610
|
-
descriptorClosed = true;
|
|
1611
|
-
}
|
|
1881
|
+
fs.closeSync(fd);
|
|
1882
|
+
descriptorClosed = true;
|
|
1612
1883
|
};
|
|
1613
1884
|
const assertOwned = (): void => {
|
|
1614
1885
|
const current = parseLock(lockPath);
|
|
@@ -1623,8 +1894,7 @@ export async function acquireManagedLock(
|
|
|
1623
1894
|
descriptorClosed ||
|
|
1624
1895
|
!current ||
|
|
1625
1896
|
!sameFileIdentity(lockIdentity, named) ||
|
|
1626
|
-
current.attemptId !== attemptId
|
|
1627
|
-
current.leaseExpiresAt < Date.now()
|
|
1897
|
+
current.attemptId !== attemptId
|
|
1628
1898
|
)
|
|
1629
1899
|
throw new Error("migration_busy");
|
|
1630
1900
|
};
|
|
@@ -1642,42 +1912,160 @@ export async function acquireManagedLock(
|
|
|
1642
1912
|
attemptId,
|
|
1643
1913
|
assertOwned,
|
|
1644
1914
|
async release(): Promise<void> {
|
|
1645
|
-
|
|
1646
|
-
|
|
1647
|
-
assertOwned();
|
|
1648
|
-
const now = Date.now();
|
|
1649
|
-
// Do not unlink by pathname: a stale owner could otherwise remove a successor.
|
|
1650
|
-
// The lease is retired through the verified inode-bound descriptor instead.
|
|
1651
|
-
writeLockDescriptor(fd, { ...record, heartbeatAt: now, leaseExpiresAt: now });
|
|
1652
|
-
fsyncDirectory(locksDirectory);
|
|
1653
|
-
} finally {
|
|
1915
|
+
const finalize = (): void => {
|
|
1916
|
+
clearInterval(heartbeat);
|
|
1654
1917
|
released = true;
|
|
1655
1918
|
closeDescriptor();
|
|
1919
|
+
};
|
|
1920
|
+
try {
|
|
1921
|
+
assertOwned();
|
|
1922
|
+
} catch (error) {
|
|
1923
|
+
finalize();
|
|
1924
|
+
throw error;
|
|
1656
1925
|
}
|
|
1926
|
+
try {
|
|
1927
|
+
secureFileDescriptor(lockPath, fd, "verify");
|
|
1928
|
+
} catch (error) {
|
|
1929
|
+
if (lockDescriptorOwnershipLost(error)) finalize();
|
|
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;
|
|
1941
|
+
}
|
|
1942
|
+
finalize();
|
|
1657
1943
|
},
|
|
1658
1944
|
};
|
|
1659
1945
|
} catch (error) {
|
|
1660
1946
|
if ((error as NodeJS.ErrnoException).code !== "EEXIST") throw error;
|
|
1661
1947
|
const owner = parseLock(lockPath);
|
|
1662
|
-
if (owner && owner.leaseExpiresAt < Date.now()) {
|
|
1663
|
-
|
|
1664
|
-
|
|
1665
|
-
|
|
1666
|
-
|
|
1948
|
+
if (owner && owner.leaseExpiresAt < Date.now() && ownerDefinitelyGone(owner)) {
|
|
1949
|
+
try {
|
|
1950
|
+
retireManagedLockExact(lockPath, { attemptId: owner.attemptId });
|
|
1951
|
+
} catch {
|
|
1952
|
+
/* retry owner observation */
|
|
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
|
+
};
|
|
1667
2032
|
try {
|
|
1668
|
-
|
|
1669
|
-
|
|
1670
|
-
|
|
1671
|
-
|
|
1672
|
-
fsyncDirectory(locksDirectory);
|
|
1673
|
-
} catch {
|
|
1674
|
-
/* retry owner observation */
|
|
2033
|
+
assertOwned();
|
|
2034
|
+
} catch (error) {
|
|
2035
|
+
finalize();
|
|
2036
|
+
throw error;
|
|
1675
2037
|
}
|
|
1676
|
-
|
|
2038
|
+
try {
|
|
2039
|
+
secureFileDescriptor(lockPath, fd, "verify");
|
|
2040
|
+
} catch (error) {
|
|
2041
|
+
if (lockDescriptorOwnershipLost(error)) finalize();
|
|
2042
|
+
throw error;
|
|
2043
|
+
}
|
|
2044
|
+
try {
|
|
2045
|
+
retireManagedLockExact(lockPath, { lockIdentity, attemptId });
|
|
2046
|
+
} catch (error) {
|
|
2047
|
+
try {
|
|
2048
|
+
assertOwned();
|
|
2049
|
+
} catch {
|
|
2050
|
+
finalize();
|
|
2051
|
+
}
|
|
2052
|
+
throw error;
|
|
2053
|
+
}
|
|
2054
|
+
finalize();
|
|
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. */
|
|
1677
2065
|
}
|
|
1678
|
-
}
|
|
2066
|
+
}
|
|
1679
2067
|
if (Date.now() >= deadline) throw new Error("migration_busy");
|
|
1680
|
-
|
|
2068
|
+
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, Math.min(50, deadline - Date.now()));
|
|
1681
2069
|
}
|
|
1682
2070
|
}
|
|
1683
2071
|
}
|