@gajae-code/coding-agent 0.12.8 → 0.12.11
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/dist/types/cli/notify-cli.d.ts +1 -0
- package/dist/types/commands/sdk.d.ts +23 -0
- package/dist/types/discovery/agents-md.d.ts +11 -1
- package/dist/types/modes/types.d.ts +6 -0
- package/dist/types/sdk/bus/chat-daemon-control.d.ts +3 -1
- package/dist/types/sdk/bus/daemon-paths.d.ts +1 -0
- package/dist/types/sdk/bus/notification-service.d.ts +23 -0
- package/dist/types/sdk/bus/telegram-daemon-contract.d.ts +4 -3
- package/dist/types/session/internal/managed-session-storage.d.ts +20 -2
- package/dist/types/session/internal/native-publish-outcome.d.ts +2 -2
- package/dist/types/session/session-manager.d.ts +17 -0
- package/dist/types/session/session-storage.d.ts +1 -0
- package/dist/types/system-prompt.d.ts +12 -1
- package/dist/types/tools/composer-bash-policy.d.ts +9 -1
- package/dist/types/tools/computer.d.ts +35 -2
- package/dist/types/tools/todo-write.d.ts +2 -0
- package/package.json +8 -8
- package/scripts/verify-gjc-sdk-canonicalization.ts +14 -1
- package/src/cli/notify-cli.ts +12 -6
- package/src/commands/sdk.ts +54 -0
- package/src/config/model-profiles.ts +8 -0
- package/src/config/model-registry.ts +4 -1
- package/src/discovery/agents-md.ts +76 -19
- package/src/gjc-runtime/psmux-detect.ts +12 -16
- package/src/gjc-runtime/tmux-sessions.ts +8 -1
- package/src/internal-urls/docs-index.generated.ts +6 -5
- package/src/modes/components/extensions/inspector-panel.ts +9 -5
- package/src/modes/controllers/selector-controller.ts +61 -43
- package/src/modes/print-mode.ts +3 -0
- package/src/modes/types.ts +6 -0
- package/src/prompts/system/system-prompt.md +1 -0
- package/src/prompts/tools/computer.md +6 -1
- package/src/sdk/bus/chat-daemon-control.ts +5 -3
- package/src/sdk/bus/daemon-paths.ts +2 -0
- package/src/sdk/bus/notification-service.ts +140 -7
- package/src/sdk/bus/telegram-daemon-contract.ts +4 -3
- package/src/sdk/session.ts +24 -7
- package/src/session/agent-session.ts +176 -34
- package/src/session/internal/managed-session-storage.ts +280 -248
- package/src/session/internal/native-publish-outcome.ts +12 -2
- package/src/session/session-manager.ts +140 -6
- package/src/session/session-storage.ts +3 -0
- package/src/slash-commands/builtin-registry.ts +35 -11
- package/src/system-prompt.ts +28 -15
- package/src/tools/bash.ts +3 -1
- package/src/tools/composer-bash-policy.ts +21 -5
- package/src/tools/computer.ts +322 -45
- package/src/tools/todo-write.ts +29 -11
- package/src/utils/command-args.ts +19 -30
- package/src/utils/lang-from-path.ts +2 -4
|
@@ -7,9 +7,10 @@ import {
|
|
|
7
7
|
applyOwnerOnlyPathSecurity,
|
|
8
8
|
exactRemoveDirectoryTree,
|
|
9
9
|
exactReplacePath,
|
|
10
|
-
exactRestore,
|
|
11
10
|
exactUnlink,
|
|
11
|
+
linkNoReplacePath,
|
|
12
12
|
type NativeDirectoryTreeSnapshot,
|
|
13
|
+
type NativeExactUnlinkResult,
|
|
13
14
|
type NativeOwnerOnlySecurityResult,
|
|
14
15
|
openRecoveryFsRoot,
|
|
15
16
|
type RecoveryFsRoot,
|
|
@@ -54,6 +55,28 @@ export class ManagedPublishError extends Error {
|
|
|
54
55
|
this.diagnostic = formatNativePublishDiagnostic(outcome);
|
|
55
56
|
}
|
|
56
57
|
}
|
|
58
|
+
export class ManagedReplaceError extends Error {
|
|
59
|
+
readonly code: string;
|
|
60
|
+
readonly cleanupReceiptPath: string;
|
|
61
|
+
readonly detachedPath: string | undefined;
|
|
62
|
+
readonly retainedSuccessorPath: string | undefined;
|
|
63
|
+
readonly retainedPlaceholderPath: string | undefined;
|
|
64
|
+
readonly retainedUnknownPath: string | undefined;
|
|
65
|
+
readonly replacementCause: unknown;
|
|
66
|
+
|
|
67
|
+
constructor(result: NativeExactUnlinkResult, cleanupReceiptPath: string, replacementCause?: unknown) {
|
|
68
|
+
const code = result.code ?? "unknown";
|
|
69
|
+
super(`managed_replace_failed:${code}`, replacementCause === undefined ? undefined : { cause: replacementCause });
|
|
70
|
+
this.name = "ManagedReplaceError";
|
|
71
|
+
this.code = code;
|
|
72
|
+
this.cleanupReceiptPath = cleanupReceiptPath;
|
|
73
|
+
this.detachedPath = result.detachedPath;
|
|
74
|
+
this.retainedSuccessorPath = result.retainedSuccessorPath;
|
|
75
|
+
this.retainedPlaceholderPath = result.retainedPlaceholderPath;
|
|
76
|
+
this.retainedUnknownPath = result.retainedUnknownPath;
|
|
77
|
+
this.replacementCause = replacementCause;
|
|
78
|
+
}
|
|
79
|
+
}
|
|
57
80
|
|
|
58
81
|
function publishFailure(outcome: NativePublishOutcome): ManagedPublishError {
|
|
59
82
|
const classification =
|
|
@@ -73,6 +96,17 @@ function publishFailure(outcome: NativePublishOutcome): ManagedPublishError {
|
|
|
73
96
|
return new ManagedPublishError(classification, outcome);
|
|
74
97
|
}
|
|
75
98
|
|
|
99
|
+
function exactUnlinkCompleted(result: NativeExactUnlinkResult): boolean {
|
|
100
|
+
return (
|
|
101
|
+
result.ok ||
|
|
102
|
+
(result.code === "cleanup_pending" &&
|
|
103
|
+
result.payloadDurable === true &&
|
|
104
|
+
result.detachedPath !== undefined &&
|
|
105
|
+
result.retainedSuccessorPath === undefined &&
|
|
106
|
+
result.retainedUnknownPath === undefined)
|
|
107
|
+
);
|
|
108
|
+
}
|
|
109
|
+
|
|
76
110
|
/** A same-filesystem rename updates the moved root's ctime but no other tree identity. */
|
|
77
111
|
function sameDirectoryTreeSnapshotAfterRename(
|
|
78
112
|
left: NativeDirectoryTreeSnapshot,
|
|
@@ -114,6 +148,21 @@ export function mayCleanManagedTreeStaging(error: unknown): boolean {
|
|
|
114
148
|
return !(error instanceof ManagedTreeMoveOutcomeError) || error.stagingCleanupSafe;
|
|
115
149
|
}
|
|
116
150
|
|
|
151
|
+
/**
|
|
152
|
+
* A filesystem that implements no `renameat2` rename flag at all rejects the no-replace publish
|
|
153
|
+
* before mutating anything: NFS answers `EINVAL` (`invalid_request`) and kernels older than 3.15
|
|
154
|
+
* answer `ENOSYS` (`atomic_unavailable`). Only those two pre-mutation outcomes authorize the
|
|
155
|
+
* `linkat` stand-in; every other failure describes a publish that was actually attempted, and
|
|
156
|
+
* retrying it under a different primitive could publish twice.
|
|
157
|
+
*/
|
|
158
|
+
export function renameFlagsUnsupported(outcome: NativePublishOutcome): boolean {
|
|
159
|
+
return (
|
|
160
|
+
!outcome.ok &&
|
|
161
|
+
mayCleanCurrentStaging(outcome) &&
|
|
162
|
+
(outcome.reason === "atomic_unavailable" || outcome.reason === "invalid_request")
|
|
163
|
+
);
|
|
164
|
+
}
|
|
165
|
+
|
|
117
166
|
export type ManagedSessionSecurityPolicy = "default" | "windows-existing-verify-first";
|
|
118
167
|
|
|
119
168
|
export type ManagedStorageFailure =
|
|
@@ -150,27 +199,69 @@ export interface ManagedFileSnapshot {
|
|
|
150
199
|
};
|
|
151
200
|
}
|
|
152
201
|
|
|
202
|
+
type SerializedReplacementIdentity = {
|
|
203
|
+
dev: string;
|
|
204
|
+
ino: string;
|
|
205
|
+
nlink: string;
|
|
206
|
+
size: string;
|
|
207
|
+
mtimeNs: string;
|
|
208
|
+
ctimeNs: string;
|
|
209
|
+
sha256: string;
|
|
210
|
+
};
|
|
211
|
+
|
|
153
212
|
type ReplacementCleanupReceipt = {
|
|
154
|
-
version:
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
ino: string;
|
|
160
|
-
nlink: string;
|
|
161
|
-
size: string;
|
|
162
|
-
mtimeNs: string;
|
|
163
|
-
ctimeNs: string;
|
|
164
|
-
sha256: string;
|
|
165
|
-
};
|
|
213
|
+
version: 3;
|
|
214
|
+
staging: string;
|
|
215
|
+
destination: string;
|
|
216
|
+
successor: SerializedReplacementIdentity;
|
|
217
|
+
predecessor: SerializedReplacementIdentity;
|
|
166
218
|
};
|
|
167
219
|
|
|
168
220
|
const U64_MAX = 18_446_744_073_709_551_615n;
|
|
169
221
|
|
|
170
|
-
function
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
222
|
+
function replacementCleanupReceiptBinding(
|
|
223
|
+
name: string,
|
|
224
|
+
): { predecessor: { dev: bigint; ino: bigint }; receipt: { dev: bigint; ino: bigint } } | undefined {
|
|
225
|
+
const match =
|
|
226
|
+
/^\.gjc-replace-cleanup-(0|[1-9a-f][0-9a-f]*)-(0|[1-9a-f][0-9a-f]*)-receipt-(0|[1-9a-f][0-9a-f]*)-(0|[1-9a-f][0-9a-f]*)\.json$/.exec(
|
|
227
|
+
name,
|
|
228
|
+
);
|
|
229
|
+
if (!match?.[1] || !match[2] || !match[3] || !match[4]) return undefined;
|
|
230
|
+
const predecessor = { dev: BigInt(`0x${match[1]}`), ino: BigInt(`0x${match[2]}`) };
|
|
231
|
+
const receipt = { dev: BigInt(`0x${match[3]}`), ino: BigInt(`0x${match[4]}`) };
|
|
232
|
+
return predecessor.dev <= U64_MAX && predecessor.ino <= U64_MAX && receipt.dev <= U64_MAX && receipt.ino <= U64_MAX
|
|
233
|
+
? { predecessor, receipt }
|
|
234
|
+
: undefined;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
function serializeReplacementIdentity(identity: ManagedFileSnapshot["identity"]): SerializedReplacementIdentity {
|
|
238
|
+
return {
|
|
239
|
+
dev: identity.dev.toString(),
|
|
240
|
+
ino: identity.ino.toString(),
|
|
241
|
+
nlink: identity.nlink.toString(),
|
|
242
|
+
size: String(identity.size),
|
|
243
|
+
mtimeNs: identity.mtimeNs.toString(),
|
|
244
|
+
ctimeNs: identity.ctimeNs.toString(),
|
|
245
|
+
sha256: identity.sha256,
|
|
246
|
+
};
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
function replacementReceiptPath(
|
|
250
|
+
baseDir: string,
|
|
251
|
+
predecessor: ManagedFileSnapshot["identity"],
|
|
252
|
+
receipt: ManagedFileSnapshot["identity"],
|
|
253
|
+
): string {
|
|
254
|
+
return path.join(
|
|
255
|
+
baseDir,
|
|
256
|
+
`.gjc-replace-cleanup-${predecessor.dev.toString(16)}-${predecessor.ino.toString(16)}-receipt-${receipt.dev.toString(16)}-${receipt.ino.toString(16)}.json`,
|
|
257
|
+
);
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
function replacementReceiptRetirementName(
|
|
261
|
+
receipt: ManagedFileSnapshot["identity"],
|
|
262
|
+
predecessor: ManagedFileSnapshot["identity"],
|
|
263
|
+
): string {
|
|
264
|
+
return `.gjc-receipt-remove-${receipt.dev.toString(16)}-${receipt.ino.toString(16)}-${predecessor.dev.toString(16)}-${predecessor.ino.toString(16)}`;
|
|
174
265
|
}
|
|
175
266
|
|
|
176
267
|
const ACL_FAILURE_CODES = new Set(["acl_denied", "acl_io_error", "acl_present", "acl_malformed", "acl_unknown"]);
|
|
@@ -694,72 +785,44 @@ export class ManagedSessionDescendantStore {
|
|
|
694
785
|
}
|
|
695
786
|
}
|
|
696
787
|
|
|
788
|
+
#detachReplacementCleanupReceipt(receiptPath: string): void {
|
|
789
|
+
const binding = replacementCleanupReceiptBinding(path.basename(receiptPath));
|
|
790
|
+
if (!binding) throw new Error("managed_replace_cleanup_receipt_invalid");
|
|
791
|
+
const receipt = captureManagedFileNoFollow(receiptPath);
|
|
792
|
+
if (receipt.identity.dev !== binding.receipt.dev || receipt.identity.ino !== binding.receipt.ino)
|
|
793
|
+
throw new Error("managed_replace_cleanup_receipt_invalid");
|
|
794
|
+
const predecessor = binding.predecessor;
|
|
795
|
+
const quarantineName = `.gjc-receipt-remove-${receipt.identity.dev.toString(16)}-${receipt.identity.ino.toString(16)}-${predecessor.dev.toString(16)}-${predecessor.ino.toString(16)}`;
|
|
796
|
+
const detached = exactUnlink(receiptPath, {
|
|
797
|
+
dev: receipt.identity.dev,
|
|
798
|
+
ino: receipt.identity.ino,
|
|
799
|
+
nlink: receipt.identity.nlink,
|
|
800
|
+
parentDev: this.#subtreeRoot.dev,
|
|
801
|
+
parentIno: this.#subtreeRoot.ino,
|
|
802
|
+
size: BigInt(receipt.identity.size),
|
|
803
|
+
mtimeNs: receipt.identity.mtimeNs,
|
|
804
|
+
sha256: receipt.identity.sha256,
|
|
805
|
+
detachOnly: true,
|
|
806
|
+
quarantineName,
|
|
807
|
+
});
|
|
808
|
+
if (
|
|
809
|
+
(!detached.ok && detached.code !== "cleanup_pending") ||
|
|
810
|
+
detached.detachedPath !== path.join(this.#baseDir, quarantineName) ||
|
|
811
|
+
detached.retainedSuccessorPath ||
|
|
812
|
+
detached.retainedUnknownPath
|
|
813
|
+
)
|
|
814
|
+
throw new Error(`managed_replace_receipt_cleanup_pending:${detached.code ?? "unknown"}`);
|
|
815
|
+
fsyncDirectory(this.#baseDir);
|
|
816
|
+
}
|
|
817
|
+
|
|
697
818
|
#reconcileReplacementCleanupReceipts(): void {
|
|
698
819
|
if (this.#reconcilingReplacementCleanup) return;
|
|
699
820
|
this.#reconcilingReplacementCleanup = true;
|
|
700
821
|
try {
|
|
701
822
|
for (const name of fs.readdirSync(this.#baseDir)) {
|
|
702
|
-
|
|
703
|
-
if (!
|
|
704
|
-
|
|
705
|
-
const parsed = JSON.parse(
|
|
706
|
-
captureManagedFileNoFollow(receiptPath).bytes.toString("utf8"),
|
|
707
|
-
) as Partial<ReplacementCleanupReceipt>;
|
|
708
|
-
const identity = parsed.identity;
|
|
709
|
-
const dev = parseCanonicalU64(identity?.dev);
|
|
710
|
-
const ino = parseCanonicalU64(identity?.ino);
|
|
711
|
-
const nlink = parseCanonicalU64(identity?.nlink);
|
|
712
|
-
const size = parseCanonicalU64(identity?.size);
|
|
713
|
-
const mtimeNs = parseCanonicalU64(identity?.mtimeNs);
|
|
714
|
-
const ctimeNs = parseCanonicalU64(identity?.ctimeNs);
|
|
715
|
-
const expectedPredecessor =
|
|
716
|
-
dev !== undefined && ino !== undefined
|
|
717
|
-
? path.join(this.#baseDir, `.gjc-exact-replace-destination-${dev.toString(16)}-${ino.toString(16)}`)
|
|
718
|
-
: undefined;
|
|
719
|
-
if (
|
|
720
|
-
parsed.version !== 1 ||
|
|
721
|
-
dev === undefined ||
|
|
722
|
-
ino === undefined ||
|
|
723
|
-
nlink === undefined ||
|
|
724
|
-
size === undefined ||
|
|
725
|
-
mtimeNs === undefined ||
|
|
726
|
-
ctimeNs === undefined ||
|
|
727
|
-
typeof identity?.sha256 !== "string" ||
|
|
728
|
-
!match[1] ||
|
|
729
|
-
!match[2] ||
|
|
730
|
-
BigInt(`0x${match[1]}`) !== dev ||
|
|
731
|
-
BigInt(`0x${match[2]}`) !== ino ||
|
|
732
|
-
typeof parsed.predecessor !== "string" ||
|
|
733
|
-
path.resolve(parsed.predecessor) !== expectedPredecessor ||
|
|
734
|
-
typeof parsed.successor !== "string" ||
|
|
735
|
-
path.dirname(path.resolve(parsed.successor)) !== this.#baseDir
|
|
736
|
-
)
|
|
737
|
-
throw new Error("managed_replace_cleanup_receipt_invalid");
|
|
738
|
-
const retired = exactUnlink(expectedPredecessor, {
|
|
739
|
-
dev,
|
|
740
|
-
ino,
|
|
741
|
-
nlink,
|
|
742
|
-
parentDev: this.#subtreeRoot.dev,
|
|
743
|
-
parentIno: this.#subtreeRoot.ino,
|
|
744
|
-
size,
|
|
745
|
-
mtimeNs,
|
|
746
|
-
sha256: identity.sha256,
|
|
747
|
-
quarantineName: `.gjc-replace-retry-${dev.toString(16)}-${ino.toString(16)}`,
|
|
748
|
-
});
|
|
749
|
-
if (!retired.ok) throw new Error(`managed_replace_cleanup_pending:${retired.code ?? "unknown"}`);
|
|
750
|
-
const receipt = captureManagedFileNoFollow(receiptPath);
|
|
751
|
-
const removed = exactUnlink(receiptPath, {
|
|
752
|
-
dev: receipt.identity.dev,
|
|
753
|
-
ino: receipt.identity.ino,
|
|
754
|
-
nlink: receipt.identity.nlink,
|
|
755
|
-
parentDev: this.#subtreeRoot.dev,
|
|
756
|
-
parentIno: this.#subtreeRoot.ino,
|
|
757
|
-
size: BigInt(receipt.identity.size),
|
|
758
|
-
mtimeNs: receipt.identity.mtimeNs,
|
|
759
|
-
sha256: receipt.identity.sha256,
|
|
760
|
-
quarantineName: `.gjc-receipt-remove-${receipt.identity.dev.toString(16)}-${receipt.identity.ino.toString(16)}`,
|
|
761
|
-
});
|
|
762
|
-
if (!removed.ok) throw new Error(`managed_replace_receipt_cleanup_pending:${removed.code ?? "unknown"}`);
|
|
823
|
+
if (!name.startsWith(".gjc-replace-cleanup-")) continue;
|
|
824
|
+
if (!replacementCleanupReceiptBinding(name)) throw new Error("managed_replace_cleanup_receipt_invalid");
|
|
825
|
+
this.#detachReplacementCleanupReceipt(path.join(this.#baseDir, name));
|
|
763
826
|
}
|
|
764
827
|
} finally {
|
|
765
828
|
this.#reconcilingReplacementCleanup = false;
|
|
@@ -845,21 +908,7 @@ export class ManagedSessionDescendantStore {
|
|
|
845
908
|
const current = captureManagedFileNoFollow(resolved);
|
|
846
909
|
if (!sameIdentity(current.identity, expected.identity) || current.identity.sha256 !== expected.identity.sha256)
|
|
847
910
|
throw new Error("managed_replace_identity_mismatch");
|
|
848
|
-
replaceManagedFileSync(
|
|
849
|
-
resolved,
|
|
850
|
-
bytes,
|
|
851
|
-
this.#subtreeRoot,
|
|
852
|
-
this.#policy,
|
|
853
|
-
() => {
|
|
854
|
-
const currentAtCommit = captureManagedFileNoFollow(resolved);
|
|
855
|
-
if (
|
|
856
|
-
!sameIdentity(currentAtCommit.identity, expected.identity) ||
|
|
857
|
-
currentAtCommit.identity.sha256 !== expected.identity.sha256
|
|
858
|
-
)
|
|
859
|
-
throw new Error("managed_replace_identity_mismatch");
|
|
860
|
-
},
|
|
861
|
-
expected.identity,
|
|
862
|
-
);
|
|
911
|
+
replaceManagedFileSync(resolved, bytes, this.#subtreeRoot, this.#policy, undefined, expected.identity);
|
|
863
912
|
return;
|
|
864
913
|
}
|
|
865
914
|
const replaced = (this.#authority as RecoveryFsRoot & RetainedManagedReplacer).replaceManaged(
|
|
@@ -898,7 +947,7 @@ export class ManagedSessionDescendantStore {
|
|
|
898
947
|
this.#beforeMutation();
|
|
899
948
|
this.#assertBound();
|
|
900
949
|
const resolved = this.#resolve(relativePath);
|
|
901
|
-
|
|
950
|
+
const existing = this.readExpected(relativePath);
|
|
902
951
|
if (!existing) throw new Error("managed_append_missing");
|
|
903
952
|
if (this.#authority) {
|
|
904
953
|
const appended = this.#authority.appendManaged(
|
|
@@ -915,52 +964,18 @@ export class ManagedSessionDescendantStore {
|
|
|
915
964
|
this.#assertBound();
|
|
916
965
|
return;
|
|
917
966
|
}
|
|
918
|
-
//
|
|
919
|
-
//
|
|
920
|
-
//
|
|
921
|
-
//
|
|
922
|
-
|
|
923
|
-
|
|
924
|
-
|
|
925
|
-
|
|
926
|
-
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
|
|
930
|
-
process.platform === "darwin" &&
|
|
931
|
-
!darwinCtimeRefreshConsumed &&
|
|
932
|
-
isCtimeOnlyIdentityMismatch(before, existing.identity);
|
|
933
|
-
if (!allowDarwinCtimeRefresh) throw new Error("identity_mismatch");
|
|
934
|
-
fs.closeSync(fd);
|
|
935
|
-
fd = undefined;
|
|
936
|
-
darwinCtimeRefreshConsumed = true;
|
|
937
|
-
const original = existing;
|
|
938
|
-
const refreshed = this.readExpected(relativePath);
|
|
939
|
-
if (
|
|
940
|
-
!refreshed ||
|
|
941
|
-
!sameStableIdentityIgnoringCtime(refreshed.identity, original.identity) ||
|
|
942
|
-
refreshed.identity.sha256 !== original.identity.sha256 ||
|
|
943
|
-
!refreshed.bytes.equals(original.bytes)
|
|
944
|
-
) {
|
|
945
|
-
throw new Error("identity_mismatch");
|
|
946
|
-
}
|
|
947
|
-
existing = refreshed;
|
|
948
|
-
continue;
|
|
949
|
-
}
|
|
950
|
-
secureFileDescriptor(resolved, fd, "verify");
|
|
951
|
-
let offset = 0;
|
|
952
|
-
while (offset < bytes.byteLength) offset += fs.writeSync(fd, bytes, offset, bytes.byteLength - offset);
|
|
953
|
-
fs.fsyncSync(fd);
|
|
954
|
-
secureFileDescriptor(resolved, fd, "verify");
|
|
955
|
-
const after = identity(fs.fstatSync(fd, { bigint: true }));
|
|
956
|
-
const named = identity(fs.lstatSync(resolved, { bigint: true }));
|
|
957
|
-
if (!sameIdentity(after, named) || after.dev !== before.dev || after.ino !== before.ino)
|
|
958
|
-
throw new Error("identity_mismatch");
|
|
959
|
-
break;
|
|
960
|
-
}
|
|
961
|
-
} finally {
|
|
962
|
-
if (fd !== undefined) fs.closeSync(fd);
|
|
963
|
-
}
|
|
967
|
+
// Darwin has no retained native root authority. Do not append in place:
|
|
968
|
+
// a short write can leave a malformed JSONL tail with no safe recovery
|
|
969
|
+
// boundary. Replace the exact captured file instead so the old transcript
|
|
970
|
+
// or the complete successor remains recoverable after every reported error.
|
|
971
|
+
replaceManagedFileSync(
|
|
972
|
+
resolved,
|
|
973
|
+
Buffer.concat([existing.bytes, Buffer.from(bytes)]),
|
|
974
|
+
this.#subtreeRoot,
|
|
975
|
+
this.#policy,
|
|
976
|
+
undefined,
|
|
977
|
+
existing.identity,
|
|
978
|
+
);
|
|
964
979
|
this.#assertBound();
|
|
965
980
|
}
|
|
966
981
|
|
|
@@ -1409,25 +1424,19 @@ function sameIdentity(left: ManagedFileSnapshot["identity"], right: ManagedFileS
|
|
|
1409
1424
|
left.ctimeNs === right.ctimeNs
|
|
1410
1425
|
);
|
|
1411
1426
|
}
|
|
1412
|
-
|
|
1413
|
-
/** Content/path identity that remains meaningful across a Darwin write-open ctime-only transition. */
|
|
1414
|
-
function sameStableIdentityIgnoringCtime(
|
|
1427
|
+
function sameReplacementIdentity(
|
|
1415
1428
|
left: ManagedFileSnapshot["identity"],
|
|
1416
1429
|
right: ManagedFileSnapshot["identity"],
|
|
1417
1430
|
): boolean {
|
|
1418
1431
|
return (
|
|
1419
|
-
left.dev === right.dev &&
|
|
1432
|
+
left.dev === right.dev &&
|
|
1433
|
+
left.ino === right.ino &&
|
|
1434
|
+
left.nlink === right.nlink &&
|
|
1435
|
+
left.size === right.size &&
|
|
1436
|
+
left.mtimeNs === right.mtimeNs
|
|
1420
1437
|
);
|
|
1421
1438
|
}
|
|
1422
1439
|
|
|
1423
|
-
/** True when observed identity matches expected on every field except ctimeNs. */
|
|
1424
|
-
function isCtimeOnlyIdentityMismatch(
|
|
1425
|
-
observed: ManagedFileSnapshot["identity"],
|
|
1426
|
-
expected: ManagedFileSnapshot["identity"],
|
|
1427
|
-
): boolean {
|
|
1428
|
-
return sameStableIdentityIgnoringCtime(observed, expected) && observed.ctimeNs !== expected.ctimeNs;
|
|
1429
|
-
}
|
|
1430
|
-
|
|
1431
1440
|
function parseLockBytes(bytes: Uint8Array): LockRecord | undefined {
|
|
1432
1441
|
try {
|
|
1433
1442
|
const value: unknown = JSON.parse(Buffer.from(bytes).toString("utf8"));
|
|
@@ -1580,6 +1589,7 @@ export async function publishManagedFileNoReplace(
|
|
|
1580
1589
|
let failure: unknown;
|
|
1581
1590
|
let outcome: NativePublishOutcome | undefined;
|
|
1582
1591
|
let renameAttempted = false;
|
|
1592
|
+
let linkPublished = false;
|
|
1583
1593
|
|
|
1584
1594
|
try {
|
|
1585
1595
|
assertOwned?.();
|
|
@@ -1599,6 +1609,16 @@ export async function publishManagedFileNoReplace(
|
|
|
1599
1609
|
|
|
1600
1610
|
renameAttempted = true;
|
|
1601
1611
|
outcome = classifyNativePublishOutcome(renameNoReplacePath(staging, destination));
|
|
1612
|
+
if (renameFlagsUnsupported(outcome)) {
|
|
1613
|
+
// linkat publishes the destination without consuming the staging name, so the
|
|
1614
|
+
// secured staging descriptor stays authoritative across publication exactly as
|
|
1615
|
+
// it does across a rename. The staging link is removed in the finally block
|
|
1616
|
+
// below, after that descriptor is closed: unlinking a still-open name on NFS
|
|
1617
|
+
// silly-renames it instead of removing it, which would leave a second link on
|
|
1618
|
+
// the published inode.
|
|
1619
|
+
outcome = classifyNativePublishOutcome(linkNoReplacePath(staging, destination));
|
|
1620
|
+
linkPublished = outcome.ok;
|
|
1621
|
+
}
|
|
1602
1622
|
|
|
1603
1623
|
if (!outcome.ok) throw publishFailure(outcome);
|
|
1604
1624
|
|
|
@@ -1621,7 +1641,7 @@ export async function publishManagedFileNoReplace(
|
|
|
1621
1641
|
} finally {
|
|
1622
1642
|
if (fd !== undefined) fs.closeSync(fd);
|
|
1623
1643
|
|
|
1624
|
-
if (stagingIdentity && (!renameAttempted || (outcome && mayCleanCurrentStaging(outcome)))) {
|
|
1644
|
+
if (stagingIdentity && (linkPublished || !renameAttempted || (outcome && mayCleanCurrentStaging(outcome)))) {
|
|
1625
1645
|
await fsp
|
|
1626
1646
|
.lstat(staging, { bigint: true })
|
|
1627
1647
|
.then(stat => {
|
|
@@ -1641,14 +1661,16 @@ export function publishManagedFileNoReplaceSync(
|
|
|
1641
1661
|
bytes: Uint8Array,
|
|
1642
1662
|
root?: ManagedDirectoryRoot,
|
|
1643
1663
|
policy: ManagedSessionSecurityPolicy = "default",
|
|
1644
|
-
):
|
|
1664
|
+
): ManagedFileSnapshot["identity"] {
|
|
1645
1665
|
const parent = path.dirname(destination);
|
|
1646
1666
|
ensureManagedDirectory(parent, root, policy);
|
|
1647
1667
|
const staging = path.join(parent, `.${path.basename(destination)}.${randomUUID()}.staging`);
|
|
1648
1668
|
let fd: number | undefined;
|
|
1649
1669
|
let stagingIdentity: { dev: bigint; ino: bigint } | undefined;
|
|
1670
|
+
let publishedIdentity: ManagedFileSnapshot["identity"] | undefined;
|
|
1650
1671
|
let failure: unknown;
|
|
1651
1672
|
let outcome: NativePublishOutcome | undefined;
|
|
1673
|
+
let linkPublished = false;
|
|
1652
1674
|
|
|
1653
1675
|
try {
|
|
1654
1676
|
fd = fs.openSync(
|
|
@@ -1663,8 +1685,15 @@ export function publishManagedFileNoReplaceSync(
|
|
|
1663
1685
|
secureFileDescriptor(staging, fd, "verify");
|
|
1664
1686
|
const staged = fs.fstatSync(fd, { bigint: true });
|
|
1665
1687
|
stagingIdentity = { dev: staged.dev, ino: staged.ino };
|
|
1688
|
+
publishedIdentity = identity(staged, createHash("sha256").update(bytes).digest("hex"));
|
|
1666
1689
|
|
|
1667
1690
|
outcome = classifyNativePublishOutcome(renameNoReplacePath(staging, destination));
|
|
1691
|
+
if (renameFlagsUnsupported(outcome)) {
|
|
1692
|
+
// See publishManagedFileNoReplace: the staging link outlives this publication
|
|
1693
|
+
// and is removed only after the secured descriptor is closed.
|
|
1694
|
+
outcome = classifyNativePublishOutcome(linkNoReplacePath(staging, destination));
|
|
1695
|
+
linkPublished = outcome.ok;
|
|
1696
|
+
}
|
|
1668
1697
|
if (!outcome.ok) throw publishFailure(outcome);
|
|
1669
1698
|
|
|
1670
1699
|
const named = fs.lstatSync(destination, { bigint: true });
|
|
@@ -1680,7 +1709,7 @@ export function publishManagedFileNoReplaceSync(
|
|
|
1680
1709
|
} finally {
|
|
1681
1710
|
if (fd !== undefined) fs.closeSync(fd);
|
|
1682
1711
|
}
|
|
1683
|
-
if (stagingIdentity && outcome && mayCleanCurrentStaging(outcome)) {
|
|
1712
|
+
if (stagingIdentity && (linkPublished || (outcome && mayCleanCurrentStaging(outcome)))) {
|
|
1684
1713
|
try {
|
|
1685
1714
|
const named = fs.lstatSync(staging, { bigint: true });
|
|
1686
1715
|
if (named.dev !== stagingIdentity.dev || named.ino !== stagingIdentity.ino) {
|
|
@@ -1692,6 +1721,8 @@ export function publishManagedFileNoReplaceSync(
|
|
|
1692
1721
|
}
|
|
1693
1722
|
}
|
|
1694
1723
|
if (failure !== undefined) throw failure;
|
|
1724
|
+
if (!publishedIdentity) throw new Error("managed_publish_identity_unavailable");
|
|
1725
|
+
return publishedIdentity;
|
|
1695
1726
|
}
|
|
1696
1727
|
|
|
1697
1728
|
/** Replace one managed regular file while retaining the secured staging fd through publication. */
|
|
@@ -1709,6 +1740,16 @@ export function replaceManagedFileSync(
|
|
|
1709
1740
|
let fd: number | undefined;
|
|
1710
1741
|
let stagedIdentity: { dev: bigint; ino: bigint } | undefined;
|
|
1711
1742
|
let preserveStaging = false;
|
|
1743
|
+
let receiptCleanup:
|
|
1744
|
+
| {
|
|
1745
|
+
path: string;
|
|
1746
|
+
parentDev: bigint;
|
|
1747
|
+
parentIno: bigint;
|
|
1748
|
+
identity: ManagedFileSnapshot["identity"];
|
|
1749
|
+
predecessor: ManagedFileSnapshot["identity"];
|
|
1750
|
+
}
|
|
1751
|
+
| undefined;
|
|
1752
|
+
let failure: unknown;
|
|
1712
1753
|
try {
|
|
1713
1754
|
fd = fs.openSync(
|
|
1714
1755
|
staging,
|
|
@@ -1728,23 +1769,54 @@ export function replaceManagedFileSync(
|
|
|
1728
1769
|
}
|
|
1729
1770
|
assertManagedDirectoryRoot(root);
|
|
1730
1771
|
assertFence?.();
|
|
1731
|
-
if (
|
|
1732
|
-
throw new Error("managed_replace_exact_unavailable");
|
|
1733
|
-
}
|
|
1734
|
-
if (process.platform === "win32" && expectedDestination) {
|
|
1772
|
+
if (expectedDestination) {
|
|
1735
1773
|
const parentIdentity = fs.lstatSync(parent, { bigint: true });
|
|
1774
|
+
const successor = identity(staged, createHash("sha256").update(bytes).digest("hex"));
|
|
1775
|
+
const receiptStagingPath = path.join(parent, `.gjc-replace-receipt-pending-${randomUUID()}.json`);
|
|
1776
|
+
preserveStaging = true;
|
|
1777
|
+
const publishedReceiptIdentity = publishManagedFileNoReplaceSync(
|
|
1778
|
+
receiptStagingPath,
|
|
1779
|
+
Buffer.from(
|
|
1780
|
+
JSON.stringify({
|
|
1781
|
+
version: 3,
|
|
1782
|
+
staging,
|
|
1783
|
+
destination,
|
|
1784
|
+
successor: serializeReplacementIdentity(successor),
|
|
1785
|
+
predecessor: serializeReplacementIdentity(expectedDestination),
|
|
1786
|
+
} satisfies ReplacementCleanupReceipt),
|
|
1787
|
+
),
|
|
1788
|
+
root,
|
|
1789
|
+
policy,
|
|
1790
|
+
);
|
|
1791
|
+
const receiptPath = replacementReceiptPath(parent, expectedDestination, publishedReceiptIdentity);
|
|
1792
|
+
const receiptPublish = classifyNativePublishOutcome(renameNoReplacePath(receiptStagingPath, receiptPath));
|
|
1793
|
+
if (!receiptPublish.ok) throw publishFailure(receiptPublish);
|
|
1794
|
+
const namedReceipt = captureManagedFileNoFollow(receiptPath);
|
|
1795
|
+
if (
|
|
1796
|
+
namedReceipt.identity.dev !== publishedReceiptIdentity.dev ||
|
|
1797
|
+
namedReceipt.identity.ino !== publishedReceiptIdentity.ino
|
|
1798
|
+
)
|
|
1799
|
+
throw new Error("managed_replace_cleanup_receipt_identity_changed");
|
|
1800
|
+
fsyncDirectory(parent);
|
|
1801
|
+
receiptCleanup = {
|
|
1802
|
+
path: receiptPath,
|
|
1803
|
+
parentDev: parentIdentity.dev,
|
|
1804
|
+
parentIno: parentIdentity.ino,
|
|
1805
|
+
identity: publishedReceiptIdentity,
|
|
1806
|
+
predecessor: expectedDestination,
|
|
1807
|
+
};
|
|
1736
1808
|
const replaced = exactReplacePath(
|
|
1737
1809
|
staging,
|
|
1738
1810
|
destination,
|
|
1739
1811
|
{
|
|
1740
|
-
dev:
|
|
1741
|
-
ino:
|
|
1742
|
-
nlink:
|
|
1812
|
+
dev: successor.dev,
|
|
1813
|
+
ino: successor.ino,
|
|
1814
|
+
nlink: successor.nlink,
|
|
1743
1815
|
parentDev: parentIdentity.dev,
|
|
1744
1816
|
parentIno: parentIdentity.ino,
|
|
1745
|
-
size:
|
|
1746
|
-
mtimeNs:
|
|
1747
|
-
sha256:
|
|
1817
|
+
size: BigInt(successor.size),
|
|
1818
|
+
mtimeNs: successor.mtimeNs,
|
|
1819
|
+
sha256: successor.sha256,
|
|
1748
1820
|
},
|
|
1749
1821
|
{
|
|
1750
1822
|
dev: expectedDestination.dev,
|
|
@@ -1757,88 +1829,12 @@ export function replaceManagedFileSync(
|
|
|
1757
1829
|
sha256: expectedDestination.sha256,
|
|
1758
1830
|
},
|
|
1759
1831
|
);
|
|
1760
|
-
if (!replaced.ok)
|
|
1761
|
-
|
|
1762
|
-
|
|
1763
|
-
|
|
1764
|
-
|
|
1765
|
-
const retired = exactUnlink(replaced.retainedPlaceholderPath!, {
|
|
1766
|
-
dev: expectedDestination.dev,
|
|
1767
|
-
ino: expectedDestination.ino,
|
|
1768
|
-
nlink: expectedDestination.nlink,
|
|
1769
|
-
parentDev: parentIdentity.dev,
|
|
1770
|
-
parentIno: parentIdentity.ino,
|
|
1771
|
-
size: BigInt(expectedDestination.size),
|
|
1772
|
-
mtimeNs: expectedDestination.mtimeNs,
|
|
1773
|
-
sha256: expectedDestination.sha256,
|
|
1774
|
-
quarantineName: `.gjc-replace-cleanup-${expectedDestination.dev.toString(16)}-${expectedDestination.ino.toString(16)}`,
|
|
1775
|
-
});
|
|
1776
|
-
if (!retired.ok) {
|
|
1777
|
-
const predecessor =
|
|
1778
|
-
retired.detachedPath ?? retired.retainedPlaceholderPath ?? replaced.retainedPlaceholderPath;
|
|
1779
|
-
const receiptPath = path.join(
|
|
1780
|
-
parent,
|
|
1781
|
-
`.gjc-replace-cleanup-${expectedDestination.dev.toString(16)}-${expectedDestination.ino.toString(16)}.json`,
|
|
1782
|
-
);
|
|
1783
|
-
publishManagedFileNoReplaceSync(
|
|
1784
|
-
receiptPath,
|
|
1785
|
-
Buffer.from(
|
|
1786
|
-
JSON.stringify({
|
|
1787
|
-
version: 1,
|
|
1788
|
-
predecessor,
|
|
1789
|
-
successor: destination,
|
|
1790
|
-
identity: {
|
|
1791
|
-
dev: expectedDestination.dev.toString(),
|
|
1792
|
-
ino: expectedDestination.ino.toString(),
|
|
1793
|
-
nlink: expectedDestination.nlink.toString(),
|
|
1794
|
-
size: String(expectedDestination.size),
|
|
1795
|
-
mtimeNs: expectedDestination.mtimeNs.toString(),
|
|
1796
|
-
ctimeNs: expectedDestination.ctimeNs.toString(),
|
|
1797
|
-
sha256: expectedDestination.sha256,
|
|
1798
|
-
},
|
|
1799
|
-
}),
|
|
1800
|
-
),
|
|
1801
|
-
root,
|
|
1802
|
-
policy,
|
|
1803
|
-
);
|
|
1804
|
-
throw new Error(
|
|
1805
|
-
`managed_replace_cleanup_pending:receipt=${receiptPath}:predecessor=${predecessor}:successor=${destination}:code=${retired.code ?? "unknown"}`,
|
|
1806
|
-
);
|
|
1807
|
-
}
|
|
1808
|
-
}
|
|
1809
|
-
if (!committedWithPredecessor) {
|
|
1810
|
-
if (replaced.detachedPath) {
|
|
1811
|
-
const restored = exactRestore(replaced.detachedPath, staging, {
|
|
1812
|
-
dev: staged.dev,
|
|
1813
|
-
ino: staged.ino,
|
|
1814
|
-
nlink: staged.nlink,
|
|
1815
|
-
parentDev: parentIdentity.dev,
|
|
1816
|
-
parentIno: parentIdentity.ino,
|
|
1817
|
-
size: staged.size,
|
|
1818
|
-
mtimeNs: staged.mtimeNs,
|
|
1819
|
-
sha256: createHash("sha256").update(bytes).digest("hex"),
|
|
1820
|
-
});
|
|
1821
|
-
if (restored.ok) preserveStaging = true;
|
|
1822
|
-
if (!restored.ok)
|
|
1823
|
-
throw new Error(
|
|
1824
|
-
`managed_replace_failed:${replaced.code ?? "unknown"}:retained=${replaced.detachedPath}:restore=${restored.code ?? "unknown"}`,
|
|
1825
|
-
);
|
|
1826
|
-
}
|
|
1827
|
-
const retained = [
|
|
1828
|
-
replaced.detachedPath,
|
|
1829
|
-
replaced.retainedSuccessorPath,
|
|
1830
|
-
replaced.retainedPlaceholderPath,
|
|
1831
|
-
replaced.retainedUnknownPath,
|
|
1832
|
-
]
|
|
1833
|
-
.filter((pathname): pathname is string => typeof pathname === "string")
|
|
1834
|
-
.join(",");
|
|
1835
|
-
throw new Error(
|
|
1836
|
-
`managed_replace_failed:${replaced.code ?? "unknown"}${retained ? `:retained=${retained}` : ""}`,
|
|
1837
|
-
);
|
|
1838
|
-
}
|
|
1839
|
-
}
|
|
1832
|
+
if (!replaced.ok) throw new ManagedReplaceError(replaced, receiptCleanup.path);
|
|
1833
|
+
const named = captureManagedFileNoFollow(destination);
|
|
1834
|
+
if (!sameReplacementIdentity(named.identity, successor) || named.identity.sha256 !== successor.sha256)
|
|
1835
|
+
throw new Error("destination_identity_changed");
|
|
1836
|
+
fsyncDirectory(parent);
|
|
1840
1837
|
} else fs.renameSync(staging, destination);
|
|
1841
|
-
assertFence?.();
|
|
1842
1838
|
assertManagedDirectoryRoot(root);
|
|
1843
1839
|
const named = fs.lstatSync(destination, { bigint: true });
|
|
1844
1840
|
if (!named.isFile() || named.isSymbolicLink() || named.dev !== staged.dev || named.ino !== staged.ino) {
|
|
@@ -1850,17 +1846,53 @@ export function replaceManagedFileSync(
|
|
|
1850
1846
|
fd = undefined;
|
|
1851
1847
|
}
|
|
1852
1848
|
fsyncDirectory(parent);
|
|
1849
|
+
if (receiptCleanup) {
|
|
1850
|
+
try {
|
|
1851
|
+
const removed = exactUnlink(receiptCleanup.path, {
|
|
1852
|
+
dev: receiptCleanup.identity.dev,
|
|
1853
|
+
ino: receiptCleanup.identity.ino,
|
|
1854
|
+
nlink: receiptCleanup.identity.nlink,
|
|
1855
|
+
parentDev: receiptCleanup.parentDev,
|
|
1856
|
+
parentIno: receiptCleanup.parentIno,
|
|
1857
|
+
size: BigInt(receiptCleanup.identity.size),
|
|
1858
|
+
mtimeNs: receiptCleanup.identity.mtimeNs,
|
|
1859
|
+
sha256: receiptCleanup.identity.sha256,
|
|
1860
|
+
quarantineName: replacementReceiptRetirementName(receiptCleanup.identity, receiptCleanup.predecessor),
|
|
1861
|
+
});
|
|
1862
|
+
if (!exactUnlinkCompleted(removed) && removed.code !== "not_found")
|
|
1863
|
+
throw new ManagedReplaceError(removed, receiptCleanup.path);
|
|
1864
|
+
try {
|
|
1865
|
+
fsyncDirectory(parent);
|
|
1866
|
+
} catch (error) {
|
|
1867
|
+
throw new ManagedReplaceError(
|
|
1868
|
+
{ ...removed, ok: false, code: "durability_failed" },
|
|
1869
|
+
receiptCleanup.path,
|
|
1870
|
+
error,
|
|
1871
|
+
);
|
|
1872
|
+
}
|
|
1873
|
+
} catch (error) {
|
|
1874
|
+
if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
|
|
1875
|
+
}
|
|
1876
|
+
}
|
|
1877
|
+
} catch (error) {
|
|
1878
|
+
failure = error;
|
|
1853
1879
|
} finally {
|
|
1854
1880
|
if (fd !== undefined) fs.closeSync(fd);
|
|
1855
1881
|
if (stagedIdentity && !preserveStaging) {
|
|
1856
1882
|
try {
|
|
1857
1883
|
const named = fs.lstatSync(staging, { bigint: true });
|
|
1858
1884
|
if (named.dev === stagedIdentity.dev && named.ino === stagedIdentity.ino) fs.unlinkSync(staging);
|
|
1859
|
-
} catch {
|
|
1860
|
-
|
|
1885
|
+
} catch (error) {
|
|
1886
|
+
if ((error as NodeJS.ErrnoException).code !== "ENOENT") {
|
|
1887
|
+
failure =
|
|
1888
|
+
failure === undefined
|
|
1889
|
+
? error
|
|
1890
|
+
: new AggregateError([failure, error], "Managed replacement and staging cleanup both failed.");
|
|
1891
|
+
}
|
|
1861
1892
|
}
|
|
1862
1893
|
}
|
|
1863
1894
|
}
|
|
1895
|
+
if (failure !== undefined) throw failure;
|
|
1864
1896
|
}
|
|
1865
1897
|
|
|
1866
1898
|
export async function replaceManagedFile(
|