@gajae-code/coding-agent 0.11.6 → 0.11.8
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 +92 -0
- package/dist/types/capability/index.d.ts +10 -20
- package/dist/types/capability/types.d.ts +3 -0
- package/dist/types/cli/args.d.ts +0 -2
- package/dist/types/commands/daemon.d.ts +2 -2
- package/dist/types/commands/sdk.d.ts +20 -2
- package/dist/types/config/keybindings.d.ts +34 -4
- package/dist/types/config/model-profile-activation.d.ts +4 -3
- package/dist/types/config/model-profile-contract.d.ts +38 -0
- package/dist/types/config/settings-schema.d.ts +70 -7
- package/dist/types/edit/modes/patch.d.ts +13 -0
- package/dist/types/edit/path-mutation-lock.d.ts +15 -0
- package/dist/types/eval/py/executor.d.ts +1 -1
- package/dist/types/eval/py/runner-artifact.d.ts +8 -0
- package/dist/types/eval/py/tool-bridge.d.ts +1 -2
- package/dist/types/gjc-runtime/repository-binding.d.ts +63 -0
- package/dist/types/gjc-runtime/ultragoal-guard.d.ts +6 -5
- package/dist/types/gjc-runtime/ultragoal-receipt-freshness.d.ts +20 -0
- package/dist/types/gjc-runtime/ultragoal-runtime.d.ts +43 -5
- package/dist/types/main.d.ts +32 -1
- package/dist/types/modes/components/btw-panel.d.ts +1 -0
- package/dist/types/modes/components/model-selector.d.ts +2 -0
- package/dist/types/modes/components/runtime-mcp-add-wizard.d.ts +2 -1
- package/dist/types/modes/components/tool-status-header.d.ts +4 -2
- package/dist/types/modes/components/welcome.d.ts +2 -0
- package/dist/types/modes/controllers/btw-controller.d.ts +5 -0
- package/dist/types/modes/controllers/command-controller.d.ts +2 -0
- package/dist/types/modes/controllers/selector-controller.d.ts +2 -1
- package/dist/types/modes/interactive-mode.d.ts +7 -2
- package/dist/types/modes/prompt-action-autocomplete.d.ts +6 -1
- package/dist/types/modes/prompt-suggestion-controller.d.ts +35 -0
- package/dist/types/modes/shared/agent-wire/workflow-gate-broker.d.ts +2 -2
- package/dist/types/modes/types.d.ts +4 -0
- package/dist/types/modes/utils/hotkeys-markdown.d.ts +2 -1
- package/dist/types/runtime-mcp/content-limits.d.ts +7 -0
- package/dist/types/runtime-mcp/manager.d.ts +1 -1
- package/dist/types/runtime-mcp/oauth-flow.d.ts +0 -5
- package/dist/types/runtime-mcp/plugin-network-boundary.d.ts +14 -0
- package/dist/types/sdk/broker/broker.d.ts +2 -0
- package/dist/types/sdk/broker/lifecycle.d.ts +6 -0
- package/dist/types/sdk/bus/chat-daemon-control.d.ts +6 -1
- package/dist/types/sdk/bus/config-commands.d.ts +2 -2
- package/dist/types/sdk/bus/config.d.ts +2 -0
- package/dist/types/sdk/bus/conversation-store.d.ts +1 -0
- package/dist/types/sdk/bus/index.d.ts +26 -1
- package/dist/types/sdk/bus/lifecycle-commands.d.ts +5 -4
- package/dist/types/sdk/bus/notification-orchestration.d.ts +1 -0
- package/dist/types/sdk/bus/notification-service.d.ts +34 -0
- package/dist/types/sdk/bus/prompt-reconciliation.d.ts +105 -0
- package/dist/types/sdk/bus/telegram-daemon-contract.d.ts +18 -3
- package/dist/types/sdk/bus/telegram-daemon-control.d.ts +5 -1
- package/dist/types/sdk/bus/telegram-daemon.d.ts +189 -4
- package/dist/types/sdk/bus/telegram-reference.d.ts +2 -0
- package/dist/types/sdk/bus/topic-registry.d.ts +88 -2
- package/dist/types/sdk/client/discovery.d.ts +2 -0
- package/dist/types/sdk/client/liveness.d.ts +7 -0
- package/dist/types/sdk/host/control/operations.d.ts +1 -1
- package/dist/types/sdk/host/host.d.ts +1 -1
- package/dist/types/sdk/host/query/handlers.d.ts +9 -0
- package/dist/types/sdk/index.d.ts +2 -0
- package/dist/types/sdk/prompt-status.d.ts +88 -0
- package/dist/types/sdk/session-directory.d.ts +1 -1
- package/dist/types/sdk/startup-capability.d.ts +3 -0
- package/dist/types/sdk/transport/auth-preface.d.ts +9 -0
- package/dist/types/sdk/transport/index.d.ts +12 -0
- package/dist/types/sdk/transport/relay.d.ts +43 -0
- package/dist/types/sdk/transport/serve-cli.d.ts +4 -0
- package/dist/types/sdk/transport/socket.d.ts +7 -0
- package/dist/types/sdk/transport/stdio.d.ts +3 -0
- package/dist/types/session/agent-session.d.ts +30 -5
- package/dist/types/session/btw-contract.d.ts +16 -0
- package/dist/types/session/internal/managed-session-scope.d.ts +32 -9
- package/dist/types/session/internal/managed-session-storage.d.ts +27 -10
- package/dist/types/session/internal/native-publish-outcome.d.ts +48 -0
- package/dist/types/session/session-manager.d.ts +25 -4
- package/dist/types/session/session-storage.d.ts +18 -0
- package/dist/types/setup/credential-import.d.ts +5 -1
- package/dist/types/setup/provider-onboarding.d.ts +2 -0
- package/dist/types/ssh/utils.d.ts +1 -0
- package/dist/types/task/discovery.d.ts +3 -1
- package/dist/types/task/index.d.ts +3 -1
- package/dist/types/task/receipt.d.ts +2 -0
- package/dist/types/task/types.d.ts +94 -0
- package/dist/types/tools/ask.d.ts +454 -174
- package/dist/types/tools/fetch.d.ts +5 -8
- package/dist/types/tools/image-gen.d.ts +63 -1
- package/dist/types/tools/index.d.ts +4 -0
- package/dist/types/tools/skill-discovery.d.ts +7 -0
- package/dist/types/tools/sqlite-reader.d.ts +7 -0
- package/dist/types/utils/prompt-suggestion.d.ts +33 -0
- package/dist/types/utils/shell-snapshot.d.ts +12 -0
- package/dist/types/web/insane/bridge.d.ts +1 -0
- package/dist/types/web/insane/url-guard.d.ts +17 -0
- package/package.json +8 -8
- package/scripts/capture-platform-shortcut-labels-showcase.ts +342 -0
- package/scripts/dogfood-repository-binding.ts +209 -0
- package/scripts/generate-sdk-operation-inventory.ts +4 -0
- package/scripts/run-test-manifest.ts +4 -4
- package/src/capability/index.ts +50 -42
- package/src/capability/types.ts +3 -0
- package/src/cli/args.ts +2 -92
- package/src/cli/notify-cli.ts +5 -38
- package/src/cli/setup-cli.ts +10 -4
- package/src/cli.ts +52 -9
- package/src/commands/daemon.ts +22 -5
- package/src/commands/sdk.ts +44 -11
- package/src/config/keybindings.ts +158 -25
- package/src/config/model-profile-activation.ts +7 -18
- package/src/config/model-profile-contract.ts +161 -0
- package/src/config/model-profiles.ts +18 -0
- package/src/config/model-registry.ts +11 -1
- package/src/config/settings-schema.ts +73 -7
- package/src/coordinator-mcp/model-preset.ts +21 -74
- package/src/defaults/gjc/skills/deep-interview/SKILL.md +2 -2
- package/src/defaults/gjc/skills/ralplan/SKILL.md +3 -1
- package/src/defaults/gjc/skills/ultragoal/SKILL.md +42 -1
- package/src/discovery/ssh.ts +12 -1
- package/src/edit/index.ts +3 -6
- package/src/edit/modes/patch.ts +38 -1
- package/src/edit/modes/replace.ts +42 -0
- package/src/edit/path-mutation-lock.ts +112 -0
- package/src/eval/py/executor.ts +30 -11
- package/src/eval/py/kernel.ts +1 -21
- package/src/eval/py/prelude.py +2 -2
- package/src/eval/py/runner-artifact.ts +84 -0
- package/src/eval/py/tool-bridge.ts +34 -18
- package/src/extensibility/gjc-plugins/runtime-adapters.ts +2 -1
- package/src/extensibility/plugins/marketplace/fetcher.ts +191 -7
- package/src/gjc-runtime/launch-tmux.ts +1 -1
- package/src/gjc-runtime/managed-owner-supervisor.ts +83 -4
- package/src/gjc-runtime/ralplan-runtime.ts +75 -3
- package/src/gjc-runtime/repository-binding.ts +267 -0
- package/src/gjc-runtime/session-state-sidecar.ts +25 -9
- package/src/gjc-runtime/tmux-owner-isolation.ts +2 -1
- package/src/gjc-runtime/tmux-sessions.ts +45 -6
- package/src/gjc-runtime/ultragoal-guard.ts +93 -15
- package/src/gjc-runtime/ultragoal-receipt-freshness.ts +103 -0
- package/src/gjc-runtime/ultragoal-runtime.ts +456 -31
- package/src/gjc-runtime/workflow-manifest.generated.json +58 -0
- package/src/gjc-runtime/workflow-manifest.ts +23 -0
- package/src/internal-urls/docs-index.generated.ts +12 -10
- package/src/internal-urls/local-protocol.ts +64 -21
- package/src/internal-urls/mcp-protocol.ts +73 -11
- package/src/main.ts +111 -37
- package/src/modes/DESIGN.md +5 -0
- package/src/modes/acp/acp-agent.ts +50 -16
- package/src/modes/action-registry.ts +1 -1
- package/src/modes/components/btw-panel.ts +82 -16
- package/src/modes/components/model-selector.ts +73 -11
- package/src/modes/components/plan-preview-overlay.ts +1 -1
- package/src/modes/components/runtime-mcp-add-wizard.ts +5 -0
- package/src/modes/components/tool-status-header.ts +25 -4
- package/src/modes/components/welcome.ts +22 -18
- package/src/modes/controllers/btw-controller.ts +119 -36
- package/src/modes/controllers/command-controller.ts +97 -54
- package/src/modes/controllers/event-controller.ts +10 -0
- package/src/modes/controllers/input-controller.ts +38 -2
- package/src/modes/controllers/runtime-mcp-command-controller.ts +29 -6
- package/src/modes/controllers/selector-controller.ts +149 -63
- package/src/modes/interactive-mode.ts +57 -34
- package/src/modes/prompt-action-autocomplete.ts +32 -13
- package/src/modes/prompt-suggestion-controller.ts +96 -0
- package/src/modes/shared/agent-wire/workflow-gate-broker.ts +2 -2
- package/src/modes/types.ts +4 -0
- package/src/modes/utils/hotkeys-markdown.ts +64 -36
- package/src/modes/utils/ui-helpers.ts +5 -3
- package/src/prompts/agents/architect.md +0 -1
- package/src/prompts/agents/critic.md +0 -1
- package/src/prompts/system/btw-user.md +3 -8
- package/src/prompts/system/prompt-suggestion-system.md +32 -0
- package/src/prompts/system/system-prompt.md +6 -4
- package/src/prompts/tools/cron.md +3 -1
- package/src/prompts/tools/skill-discovery.md +1 -0
- package/src/runtime-mcp/client.ts +41 -55
- package/src/runtime-mcp/content-limits.ts +71 -0
- package/src/runtime-mcp/json-rpc.ts +23 -9
- package/src/runtime-mcp/manager.ts +51 -53
- package/src/runtime-mcp/oauth-flow.ts +0 -72
- package/src/runtime-mcp/plugin-network-boundary.ts +101 -0
- package/src/runtime-mcp/transports/http.ts +63 -22
- package/src/sdk/broker/broker.ts +34 -20
- package/src/sdk/broker/lifecycle.ts +185 -36
- package/src/sdk/bus/chat-daemon-control.ts +19 -5
- package/src/sdk/bus/config-commands.ts +2 -2
- package/src/sdk/bus/config.ts +9 -1
- package/src/sdk/bus/conversation-store.ts +40 -6
- package/src/sdk/bus/index.ts +244 -70
- package/src/sdk/bus/lifecycle-commands.ts +63 -10
- package/src/sdk/bus/lifecycle-control-runtime.ts +11 -2
- package/src/sdk/bus/lifecycle-orchestrator.ts +32 -1
- package/src/sdk/bus/notification-orchestration.ts +12 -0
- package/src/sdk/bus/notification-service.ts +68 -20
- package/src/sdk/bus/prompt-reconciliation.ts +239 -0
- package/src/sdk/bus/recent-activity.ts +90 -26
- package/src/sdk/bus/telegram-daemon-contract.ts +18 -3
- package/src/sdk/bus/telegram-daemon-control.ts +147 -35
- package/src/sdk/bus/telegram-daemon.ts +2463 -450
- package/src/sdk/bus/telegram-reference.ts +20 -2
- package/src/sdk/bus/topic-registry.ts +451 -12
- package/src/sdk/cli/session-cli.ts +15 -2
- package/src/sdk/client/discovery.ts +19 -3
- package/src/sdk/client/liveness.ts +49 -0
- package/src/sdk/host/control/dispatch.ts +1 -1
- package/src/sdk/host/control/operations.ts +1 -1
- package/src/sdk/host/host.ts +2 -6
- package/src/sdk/host/query/handlers.ts +70 -5
- package/src/sdk/index.ts +12 -0
- package/src/sdk/prompt-status.ts +97 -0
- package/src/sdk/protocol/operation-inventory.generated.json +58 -0
- package/src/sdk/protocol/operation-registry.ts +18 -4
- package/src/sdk/session-directory.ts +5 -1
- package/src/sdk/session.ts +31 -5
- package/src/sdk/startup-capability.ts +9 -1
- package/src/sdk/transport/auth-preface.ts +82 -0
- package/src/sdk/transport/index.ts +18 -0
- package/src/sdk/transport/relay.ts +252 -0
- package/src/sdk/transport/serve-cli.ts +105 -0
- package/src/sdk/transport/socket.ts +197 -0
- package/src/sdk/transport/stdio.ts +17 -0
- package/src/session/agent-session.ts +292 -154
- package/src/session/blob-store.ts +25 -7
- package/src/session/btw-contract.ts +59 -0
- package/src/session/internal/managed-session-scope.ts +1163 -137
- package/src/session/internal/managed-session-storage.ts +397 -234
- package/src/session/internal/native-publish-outcome.ts +266 -0
- package/src/session/session-manager.ts +620 -403
- package/src/session/session-storage.ts +128 -11
- package/src/setup/credential-import.ts +28 -3
- package/src/setup/provider-onboarding.ts +30 -1
- package/src/setup/provider-presets.json +2 -1
- package/src/slash-commands/builtin-registry.ts +1 -1
- package/src/ssh/utils.ts +14 -0
- package/src/task/discovery.ts +8 -2
- package/src/task/gjc-command.ts +2 -2
- package/src/task/index.ts +121 -15
- package/src/task/receipt.ts +3 -0
- package/src/task/types.ts +34 -3
- package/src/tools/ask.ts +199 -89
- package/src/tools/ast-edit.ts +2 -2
- package/src/tools/bash-pty-selection.ts +2 -2
- package/src/tools/browser/screenshot-format.ts +6 -1
- package/src/tools/cron.ts +1 -1
- package/src/tools/fetch.ts +13 -131
- package/src/tools/image-gen.ts +476 -23
- package/src/tools/index.ts +4 -0
- package/src/tools/read.ts +2 -0
- package/src/tools/render-utils.ts +7 -1
- package/src/tools/skill-discovery.ts +39 -3
- package/src/tools/sqlite-reader.ts +63 -5
- package/src/utils/edit-mode.ts +2 -2
- package/src/utils/pasted-image-path.ts +15 -6
- package/src/utils/prompt-suggestion.ts +265 -0
- package/src/utils/shell-snapshot.ts +173 -63
- package/src/utils/sixel.ts +3 -3
- package/src/web/insane/bridge.ts +5 -1
- package/src/web/insane/url-guard.ts +140 -27
- package/src/web/scrapers/docs-rs.ts +30 -5
- package/src/web/scrapers/types.ts +19 -49
- package/src/web/scrapers/utils.ts +12 -26
|
@@ -3,28 +3,39 @@ import * as fs from "node:fs";
|
|
|
3
3
|
import * as os from "node:os";
|
|
4
4
|
import * as path from "node:path";
|
|
5
5
|
import * as native from "@gajae-code/natives";
|
|
6
|
-
import {
|
|
6
|
+
import {
|
|
7
|
+
canonicalExistingDirectoryIdentity,
|
|
8
|
+
verifyOwnerOnlyPathSecurity,
|
|
9
|
+
verifyOwnerOnlyPathSecurityExpected,
|
|
10
|
+
} from "@gajae-code/natives";
|
|
7
11
|
import { pathIsWithin } from "@gajae-code/utils";
|
|
8
12
|
import type { ResumeSessionIdentity } from "../session-manager";
|
|
9
13
|
import {
|
|
10
14
|
FileSessionStorage,
|
|
11
15
|
type NativeDirectoryTreeSnapshot,
|
|
12
16
|
type SessionStorageFileIdentity,
|
|
17
|
+
type VerifiedSessionDeleteResult,
|
|
13
18
|
} from "../session-storage";
|
|
14
19
|
import {
|
|
15
20
|
acquireManagedLock,
|
|
21
|
+
assertManagedDirectoryRoot,
|
|
16
22
|
captureManagedFileNoFollow,
|
|
17
23
|
captureManagedFilePrefixNoFollow,
|
|
18
24
|
copyManagedFileNoReplace,
|
|
19
25
|
ensureManagedDirectory,
|
|
20
26
|
fsyncManagedArtifactTree,
|
|
27
|
+
type ManagedDirectoryRoot,
|
|
28
|
+
type ManagedFileSnapshot,
|
|
29
|
+
ManagedPublishError,
|
|
21
30
|
ManagedSessionDescendantStore,
|
|
31
|
+
type ManagedSessionSecurityPolicy,
|
|
22
32
|
type ManagedStorageLock,
|
|
23
|
-
|
|
33
|
+
prepareManagedDirectoryRoot,
|
|
24
34
|
publishManagedFileNoReplace,
|
|
25
35
|
publishManagedTombstone,
|
|
26
36
|
retainManagedDirectoryAuthority,
|
|
27
37
|
validateManagedArtifactTree,
|
|
38
|
+
validateNativeSecurityResult,
|
|
28
39
|
} from "./managed-session-storage";
|
|
29
40
|
|
|
30
41
|
export const MANAGED_SESSION_LAYOUT_VERSION = 2 as const;
|
|
@@ -44,9 +55,52 @@ export interface ManagedScope {
|
|
|
44
55
|
platform: "posix" | "win32";
|
|
45
56
|
}
|
|
46
57
|
|
|
47
|
-
|
|
58
|
+
/**
|
|
59
|
+
* Opaque managed writer authority captured by a trusted destination. The open
|
|
60
|
+
* transaction must use this authority rather than reacquiring its root from a
|
|
61
|
+
* pathname after resume inspection.
|
|
62
|
+
*/
|
|
63
|
+
export interface ManagedCandidateWriteAuthority {
|
|
64
|
+
readonly rootAuthority: ManagedDirectoryRoot;
|
|
65
|
+
readonly retainedAuthority?: native.RecoveryFsRoot;
|
|
66
|
+
readonly retainedDirectory?: string;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const managedRoots = new WeakMap<ManagedScope, ReturnType<typeof prepareManagedDirectoryRoot>>();
|
|
48
70
|
const managedDirectoryIdentities = new WeakMap<ManagedScope, { dev: bigint; ino: bigint }>();
|
|
49
71
|
const managedDirectoryAuthorities = new WeakMap<ManagedScope, native.RecoveryFsRoot | undefined>();
|
|
72
|
+
const boundManagedWriteAuthorities = new WeakMap<ManagedScope, ManagedCandidateWriteAuthority>();
|
|
73
|
+
|
|
74
|
+
function bindManagedWriteAuthority(scope: ManagedScope, authority: ManagedCandidateWriteAuthority): void {
|
|
75
|
+
if (
|
|
76
|
+
authority.retainedAuthority &&
|
|
77
|
+
authority.retainedDirectory !== undefined &&
|
|
78
|
+
path.resolve(authority.retainedDirectory) === path.resolve(scope.directoryPath)
|
|
79
|
+
) {
|
|
80
|
+
new ManagedSessionDescendantStore(authority.rootAuthority, scope.directoryPath, {
|
|
81
|
+
authority: authority.retainedAuthority,
|
|
82
|
+
authorityBaseDir: scope.directoryPath,
|
|
83
|
+
}).assertBound();
|
|
84
|
+
} else {
|
|
85
|
+
assertManagedDirectoryRoot(authority.rootAuthority);
|
|
86
|
+
}
|
|
87
|
+
managedRoots.set(scope, authority.rootAuthority);
|
|
88
|
+
boundManagedWriteAuthorities.set(scope, authority);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** A prepared scope is a retained authority boundary, not a path that may be re-adopted. */
|
|
92
|
+
function assertRetainedManagedDirectoryIdentity(scope: ManagedScope): void {
|
|
93
|
+
const expected = managedDirectoryIdentities.get(scope);
|
|
94
|
+
if (!expected) return;
|
|
95
|
+
const current = fs.lstatSync(scope.directoryPath, { bigint: true });
|
|
96
|
+
if (
|
|
97
|
+
!current.isDirectory() ||
|
|
98
|
+
current.isSymbolicLink() ||
|
|
99
|
+
current.dev !== expected.dev ||
|
|
100
|
+
current.ino !== expected.ino
|
|
101
|
+
)
|
|
102
|
+
throw new Error("Managed session directory changed");
|
|
103
|
+
}
|
|
50
104
|
|
|
51
105
|
export function managedDirectoryAuthorityForScope(scope: ManagedScope): native.RecoveryFsRoot | undefined {
|
|
52
106
|
if (!managedDirectoryAuthorities.has(scope)) throw new Error("Managed session directory authority was not prepared");
|
|
@@ -61,24 +115,32 @@ export function managedDirectoryIdentityForScope(scope: ManagedScope): { dev: bi
|
|
|
61
115
|
|
|
62
116
|
function configuredRootPath(scope: ManagedScope): string {
|
|
63
117
|
let candidate = pathIsWithin(scope.agentDir, scope.sessionsRoot) ? scope.agentDir : path.dirname(scope.sessionsRoot);
|
|
118
|
+
const suffix: string[] = [];
|
|
64
119
|
for (;;) {
|
|
65
120
|
try {
|
|
66
121
|
const stat = fs.lstatSync(candidate);
|
|
67
122
|
if (!stat.isDirectory() || stat.isSymbolicLink()) throw new Error(`Unsafe configured root: ${candidate}`);
|
|
68
|
-
|
|
123
|
+
const canonical = fs.realpathSync.native(candidate);
|
|
124
|
+
return suffix.length === 0 ? canonical : path.join(canonical, ...suffix);
|
|
69
125
|
} catch (error) {
|
|
70
126
|
if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
|
|
71
127
|
const parent = path.dirname(candidate);
|
|
72
128
|
if (parent === candidate) throw new Error("Configured managed root is unavailable.");
|
|
129
|
+
suffix.unshift(path.basename(candidate));
|
|
73
130
|
candidate = parent;
|
|
74
131
|
}
|
|
75
132
|
}
|
|
76
133
|
}
|
|
77
134
|
|
|
78
|
-
function scopeRoot(scope: ManagedScope) {
|
|
135
|
+
function scopeRoot(scope: ManagedScope, policy: ManagedSessionSecurityPolicy = "default") {
|
|
136
|
+
const bound = boundManagedWriteAuthorities.get(scope);
|
|
137
|
+
if (bound) {
|
|
138
|
+
bindManagedWriteAuthority(scope, bound);
|
|
139
|
+
return bound.rootAuthority;
|
|
140
|
+
}
|
|
79
141
|
const retained = managedRoots.get(scope);
|
|
80
142
|
if (retained) return retained;
|
|
81
|
-
const root =
|
|
143
|
+
const root = prepareManagedDirectoryRoot(configuredRootPath(scope), policy);
|
|
82
144
|
managedRoots.set(scope, root);
|
|
83
145
|
return root;
|
|
84
146
|
}
|
|
@@ -96,11 +158,20 @@ export type ManagedScopeErrorCode =
|
|
|
96
158
|
| "network_unsupported"
|
|
97
159
|
| "sessions_root_unavailable"
|
|
98
160
|
| "binding_conflict"
|
|
99
|
-
| "binding_invalid"
|
|
161
|
+
| "binding_invalid"
|
|
162
|
+
| "atomic_unavailable"
|
|
163
|
+
| "invalid_request"
|
|
164
|
+
| "durability_failed"
|
|
165
|
+
| "durability_not_provable";
|
|
100
166
|
|
|
101
167
|
export type ManagedScopeResolution =
|
|
102
168
|
| { kind: "resolved"; scope: ManagedScope }
|
|
103
|
-
| {
|
|
169
|
+
| {
|
|
170
|
+
kind: "error";
|
|
171
|
+
code: ManagedScopeErrorCode;
|
|
172
|
+
message: string;
|
|
173
|
+
cause?: { readonly classification: string; readonly diagnostic?: string };
|
|
174
|
+
};
|
|
104
175
|
|
|
105
176
|
export interface ManagedCandidate {
|
|
106
177
|
sessionId: string;
|
|
@@ -129,6 +200,9 @@ export type ManagedOpenFailure =
|
|
|
129
200
|
| "source_changed"
|
|
130
201
|
| "unsafe_artifacts"
|
|
131
202
|
| "durability_failed"
|
|
203
|
+
| "atomic_unavailable"
|
|
204
|
+
| "invalid_request"
|
|
205
|
+
| "durability_not_provable"
|
|
132
206
|
| "migration_retired"
|
|
133
207
|
| "legacy_migration_disabled"
|
|
134
208
|
| "managed_storage_unsupported";
|
|
@@ -140,6 +214,7 @@ export type ManagedOpenCandidateResult =
|
|
|
140
214
|
export type ManagedDeleteCandidateResult =
|
|
141
215
|
| { kind: "deleted"; tombstonePath: string }
|
|
142
216
|
| { kind: "already_deleted"; tombstonePath: string }
|
|
217
|
+
| { kind: "cleanup_pending"; tombstonePath: string; phase: "artifacts" | "transcript"; message: string }
|
|
143
218
|
| { kind: "error"; code: ManagedOpenFailure; message: string };
|
|
144
219
|
|
|
145
220
|
type NativeIdentity =
|
|
@@ -155,8 +230,6 @@ type NativeIdentityFailureCode =
|
|
|
155
230
|
| "identity_unavailable"
|
|
156
231
|
| "io_error";
|
|
157
232
|
|
|
158
|
-
type NativeSecurity = { ok: true } | { ok: false; code: string };
|
|
159
|
-
|
|
160
233
|
interface Binding {
|
|
161
234
|
schemaVersion: 1;
|
|
162
235
|
layoutVersion: 2;
|
|
@@ -196,6 +269,22 @@ function identityFor(cwd: string): NativeIdentity {
|
|
|
196
269
|
return canonicalExistingDirectoryIdentity(cwd) as NativeIdentity;
|
|
197
270
|
}
|
|
198
271
|
|
|
272
|
+
function verifyExistingManagedScopeDirectory(pathname: string) {
|
|
273
|
+
if (process.platform !== "win32") return verifyOwnerOnlyPathSecurity(pathname, "directory");
|
|
274
|
+
const expected = fs.lstatSync(pathname, { bigint: true });
|
|
275
|
+
if (!expected.isDirectory() || expected.isSymbolicLink()) throw new Error("Unsafe managed directory");
|
|
276
|
+
const verified = verifyOwnerOnlyPathSecurityExpected(pathname, "directory", expected.dev, expected.ino);
|
|
277
|
+
const current = fs.lstatSync(pathname, { bigint: true });
|
|
278
|
+
if (
|
|
279
|
+
!current.isDirectory() ||
|
|
280
|
+
current.isSymbolicLink() ||
|
|
281
|
+
current.dev !== expected.dev ||
|
|
282
|
+
current.ino !== expected.ino
|
|
283
|
+
)
|
|
284
|
+
throw new Error("Managed session directory changed");
|
|
285
|
+
return verified;
|
|
286
|
+
}
|
|
287
|
+
|
|
199
288
|
function canonicalExistingPathForIo(base: string, identity: CanonicalNativeIdentity): string {
|
|
200
289
|
if (identity.platform !== "win32") return identity.canonicalPath;
|
|
201
290
|
try {
|
|
@@ -317,11 +406,16 @@ function validateExistingBinding(scope: ManagedScope): ManagedScopeResolution |
|
|
|
317
406
|
return validateBindingRaw(scope, raw);
|
|
318
407
|
}
|
|
319
408
|
|
|
320
|
-
|
|
409
|
+
interface ManagedScopeInput {
|
|
321
410
|
cwd: string;
|
|
322
411
|
agentDir: string;
|
|
323
412
|
sessionsRoot: string;
|
|
324
|
-
}
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
function resolveManagedScopeInternal(
|
|
416
|
+
input: ManagedScopeInput,
|
|
417
|
+
allowRepairableAclFailure: boolean,
|
|
418
|
+
): ManagedScopeResolution {
|
|
325
419
|
const identity = identityFor(input.cwd);
|
|
326
420
|
if (!identity.ok) return nativeFailure(identity.code);
|
|
327
421
|
try {
|
|
@@ -379,13 +473,18 @@ export function resolveManagedScope(input: {
|
|
|
379
473
|
if (!directory.isDirectory() || directory.isSymbolicLink()) {
|
|
380
474
|
return { kind: "error", code: "binding_invalid", message: "The managed scope path is not a safe directory." };
|
|
381
475
|
}
|
|
382
|
-
const security =
|
|
383
|
-
|
|
476
|
+
const security = validateNativeSecurityResult(
|
|
477
|
+
verifyExistingManagedScopeDirectory(scope.directoryPath),
|
|
478
|
+
"verify",
|
|
479
|
+
"directory",
|
|
480
|
+
);
|
|
481
|
+
if (!security.ok && (!allowRepairableAclFailure || security.code !== "acl_verify_failed")) {
|
|
384
482
|
return {
|
|
385
483
|
kind: "error",
|
|
386
484
|
code: "binding_invalid",
|
|
387
485
|
message: "The managed scope security could not be verified.",
|
|
388
486
|
};
|
|
487
|
+
}
|
|
389
488
|
} catch (error) {
|
|
390
489
|
if ((error as NodeJS.ErrnoException).code !== "ENOENT") {
|
|
391
490
|
return { kind: "error", code: "binding_invalid", message: "The managed scope path could not be inspected." };
|
|
@@ -394,6 +493,15 @@ export function resolveManagedScope(input: {
|
|
|
394
493
|
return validateExistingBinding(scope) ?? { kind: "resolved", scope };
|
|
395
494
|
}
|
|
396
495
|
|
|
496
|
+
export function resolveManagedScope(input: ManagedScopeInput): ManagedScopeResolution {
|
|
497
|
+
return resolveManagedScopeInternal(input, false);
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
/** Resolve a scope for a synchronous write without mutating an existing ACL mismatch. */
|
|
501
|
+
export function resolveManagedScopeForWrite(input: ManagedScopeInput): ManagedScopeResolution {
|
|
502
|
+
return resolveManagedScopeInternal(input, true);
|
|
503
|
+
}
|
|
504
|
+
|
|
397
505
|
function legacyDirectoryNames(
|
|
398
506
|
platform: ManagedScope["platform"],
|
|
399
507
|
canonicalCwd: string,
|
|
@@ -461,6 +569,57 @@ function fsyncManagedParent(pathname: string): void {
|
|
|
461
569
|
}
|
|
462
570
|
}
|
|
463
571
|
|
|
572
|
+
function fsyncCanonicalBinding(bindingPath: string, expected: string): void {
|
|
573
|
+
let captured: ManagedFileSnapshot;
|
|
574
|
+
try {
|
|
575
|
+
captured = captureManagedFileNoFollow(bindingPath);
|
|
576
|
+
} catch {
|
|
577
|
+
throw new Error("binding_invalid");
|
|
578
|
+
}
|
|
579
|
+
if (captured.bytes.toString("utf8") !== expected) throw new Error("binding_invalid");
|
|
580
|
+
let descriptor: number | undefined;
|
|
581
|
+
try {
|
|
582
|
+
descriptor = fs.openSync(bindingPath, fs.constants.O_RDONLY | fs.constants.O_NOFOLLOW);
|
|
583
|
+
const before = fs.fstatSync(descriptor, { bigint: true });
|
|
584
|
+
if (
|
|
585
|
+
before.dev !== captured.identity.dev ||
|
|
586
|
+
before.ino !== captured.identity.ino ||
|
|
587
|
+
before.size !== BigInt(captured.identity.size) ||
|
|
588
|
+
before.mtimeNs !== captured.identity.mtimeNs
|
|
589
|
+
)
|
|
590
|
+
throw new Error("binding_invalid");
|
|
591
|
+
fs.fsyncSync(descriptor);
|
|
592
|
+
const after = fs.fstatSync(descriptor, { bigint: true });
|
|
593
|
+
if (
|
|
594
|
+
after.dev !== captured.identity.dev ||
|
|
595
|
+
after.ino !== captured.identity.ino ||
|
|
596
|
+
after.size !== BigInt(captured.identity.size) ||
|
|
597
|
+
after.mtimeNs !== captured.identity.mtimeNs
|
|
598
|
+
)
|
|
599
|
+
throw new Error("binding_invalid");
|
|
600
|
+
} catch (error) {
|
|
601
|
+
if (error instanceof Error && error.message === "binding_invalid") throw error;
|
|
602
|
+
throw new Error("durability_failed", { cause: error });
|
|
603
|
+
} finally {
|
|
604
|
+
if (descriptor !== undefined) fs.closeSync(descriptor);
|
|
605
|
+
}
|
|
606
|
+
fsyncManagedParent(bindingPath);
|
|
607
|
+
let recaptured: ManagedFileSnapshot;
|
|
608
|
+
try {
|
|
609
|
+
recaptured = captureManagedFileNoFollow(bindingPath);
|
|
610
|
+
} catch {
|
|
611
|
+
throw new Error("binding_invalid");
|
|
612
|
+
}
|
|
613
|
+
if (
|
|
614
|
+
recaptured.bytes.toString("utf8") !== expected ||
|
|
615
|
+
recaptured.identity.dev !== captured.identity.dev ||
|
|
616
|
+
recaptured.identity.ino !== captured.identity.ino ||
|
|
617
|
+
recaptured.identity.size !== captured.identity.size ||
|
|
618
|
+
recaptured.identity.mtimeNs !== captured.identity.mtimeNs
|
|
619
|
+
)
|
|
620
|
+
throw new Error("binding_invalid");
|
|
621
|
+
}
|
|
622
|
+
|
|
464
623
|
type CandidatePreflight =
|
|
465
624
|
| { kind: "capture"; identity: { dev: bigint; ino: bigint; size: number; mtimeNs: bigint } }
|
|
466
625
|
| {
|
|
@@ -594,46 +753,159 @@ function listDirectoryCandidates(
|
|
|
594
753
|
});
|
|
595
754
|
}
|
|
596
755
|
|
|
597
|
-
export async function ensureManagedScope(
|
|
756
|
+
export async function ensureManagedScope(
|
|
757
|
+
scope: ManagedScope,
|
|
758
|
+
policy: ManagedSessionSecurityPolicy = "default",
|
|
759
|
+
): Promise<ManagedScopeResolution> {
|
|
598
760
|
try {
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
ensureManagedDirectory(scope.
|
|
761
|
+
assertRetainedManagedDirectoryIdentity(scope);
|
|
762
|
+
const root = scopeRoot(scope, policy);
|
|
763
|
+
ensureManagedDirectory(scope.sessionsRoot, root, policy);
|
|
764
|
+
ensureManagedDirectory(scope.directoryPath, root, policy);
|
|
602
765
|
const bindingPath = path.join(scope.directoryPath, MANAGED_SESSION_BINDING_FILE);
|
|
603
766
|
const binding = `${JSON.stringify(bindingFor(scope))}\n`;
|
|
767
|
+
let bindingCollision = false;
|
|
604
768
|
try {
|
|
605
|
-
await publishManagedFileNoReplace(bindingPath, new TextEncoder().encode(binding), undefined, root);
|
|
769
|
+
await publishManagedFileNoReplace(bindingPath, new TextEncoder().encode(binding), undefined, root, policy);
|
|
606
770
|
} catch (error) {
|
|
607
771
|
if ((error as Error).message !== "destination_conflict") throw error;
|
|
772
|
+
bindingCollision = true;
|
|
608
773
|
}
|
|
609
|
-
|
|
774
|
+
const validated = validateExistingBinding(scope);
|
|
775
|
+
if (validated) return validated;
|
|
776
|
+
if (bindingCollision) fsyncCanonicalBinding(bindingPath, binding);
|
|
777
|
+
|
|
778
|
+
const preparedDirectory = fs.lstatSync(scope.directoryPath, { bigint: true });
|
|
779
|
+
if (!preparedDirectory.isDirectory() || preparedDirectory.isSymbolicLink())
|
|
780
|
+
throw new Error("Managed session directory changed");
|
|
781
|
+
managedDirectoryIdentities.set(scope, { dev: preparedDirectory.dev, ino: preparedDirectory.ino });
|
|
782
|
+
return { kind: "resolved", scope };
|
|
610
783
|
} catch (error) {
|
|
784
|
+
const publication = error instanceof ManagedPublishError ? error : undefined;
|
|
785
|
+
const message =
|
|
786
|
+
publication?.classification ??
|
|
787
|
+
(error instanceof Error ? error.message : "The managed scope could not be initialized.");
|
|
788
|
+
const code =
|
|
789
|
+
message === "atomic_unavailable" ||
|
|
790
|
+
message === "invalid_request" ||
|
|
791
|
+
message === "durability_failed" ||
|
|
792
|
+
message === "durability_not_provable"
|
|
793
|
+
? message
|
|
794
|
+
: "binding_invalid";
|
|
795
|
+
|
|
611
796
|
return {
|
|
612
797
|
kind: "error",
|
|
613
|
-
code
|
|
614
|
-
message
|
|
798
|
+
code,
|
|
799
|
+
message,
|
|
800
|
+
...(publication
|
|
801
|
+
? { cause: { classification: publication.classification, diagnostic: publication.diagnostic } }
|
|
802
|
+
: { cause: { classification: code } }),
|
|
615
803
|
};
|
|
616
804
|
}
|
|
617
805
|
}
|
|
618
806
|
|
|
807
|
+
/**
|
|
808
|
+
* Re-apply owner-only security to every descendant of a managed scope directory.
|
|
809
|
+
*
|
|
810
|
+
* A managed scope can accumulate group/other-readable descendants when a
|
|
811
|
+
* different code path writes into it without the secured managed-storage
|
|
812
|
+
* helpers — notably the resident-cache `EphemeralBlobStore` created on the
|
|
813
|
+
* explicit session path. The managed-tree snapshot fails closed on the first
|
|
814
|
+
* such descendant (`mode_mismatch`), which would otherwise abort launch with an
|
|
815
|
+
* uncaught exception. Re-securing the tree in place lets a drifted scope
|
|
816
|
+
* recover on the next launch instead of trapping the user behind a fatal error.
|
|
817
|
+
*/
|
|
818
|
+
function reapplyOwnerOnlyManagedTree(directory: string): void {
|
|
819
|
+
let entries: fs.Dirent[];
|
|
820
|
+
try {
|
|
821
|
+
entries = fs.readdirSync(directory, { withFileTypes: true });
|
|
822
|
+
} catch {
|
|
823
|
+
return;
|
|
824
|
+
}
|
|
825
|
+
for (const entry of entries) {
|
|
826
|
+
const child = path.join(directory, entry.name);
|
|
827
|
+
let stat: fs.Stats;
|
|
828
|
+
try {
|
|
829
|
+
stat = fs.lstatSync(child);
|
|
830
|
+
} catch {
|
|
831
|
+
continue;
|
|
832
|
+
}
|
|
833
|
+
if (stat.isSymbolicLink()) continue;
|
|
834
|
+
if (stat.isDirectory()) {
|
|
835
|
+
reapplyOwnerOnlyManagedTree(child);
|
|
836
|
+
try {
|
|
837
|
+
native.applyOwnerOnlyPathSecurity(child, "directory");
|
|
838
|
+
} catch {
|
|
839
|
+
// Best-effort: the managed-tree snapshot re-verifies and reports genuine failures.
|
|
840
|
+
}
|
|
841
|
+
} else if (stat.isFile()) {
|
|
842
|
+
try {
|
|
843
|
+
native.applyOwnerOnlyPathSecurity(child, "file");
|
|
844
|
+
} catch {
|
|
845
|
+
// Best-effort, as above.
|
|
846
|
+
}
|
|
847
|
+
}
|
|
848
|
+
}
|
|
849
|
+
try {
|
|
850
|
+
native.applyOwnerOnlyPathSecurity(directory, "directory");
|
|
851
|
+
} catch {
|
|
852
|
+
// Best-effort, as above.
|
|
853
|
+
}
|
|
854
|
+
}
|
|
855
|
+
|
|
856
|
+
/**
|
|
857
|
+
* True when a managed setup error reflects a fixable owner-only *mode* drift
|
|
858
|
+
* (group/other permission bits) rather than an ownership or identity change.
|
|
859
|
+
* Only mode drift can be self-healed by re-applying owner-only permissions.
|
|
860
|
+
*/
|
|
861
|
+
function isRecoverableOwnerOnlyModeDrift(error: unknown): boolean {
|
|
862
|
+
const message = error instanceof Error ? error.message : "";
|
|
863
|
+
return message === "mode_mismatch" || message.endsWith(": mode_mismatch");
|
|
864
|
+
}
|
|
865
|
+
|
|
619
866
|
/** Synchronously create and validate the v2 binding before a default session writer exists. */
|
|
620
|
-
export function prepareManagedSessionScopeForWriteSync(
|
|
867
|
+
export function prepareManagedSessionScopeForWriteSync(
|
|
868
|
+
scope: ManagedScope,
|
|
869
|
+
policy: ManagedSessionSecurityPolicy = "default",
|
|
870
|
+
authority?: ManagedCandidateWriteAuthority,
|
|
871
|
+
): ManagedScopeResolution {
|
|
621
872
|
try {
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
873
|
+
assertRetainedManagedDirectoryIdentity(scope);
|
|
874
|
+
const root = authority?.rootAuthority ?? scopeRoot(scope, policy);
|
|
875
|
+
if (authority) bindManagedWriteAuthority(scope, authority);
|
|
876
|
+
ensureManagedDirectory(scope.sessionsRoot, root, policy);
|
|
877
|
+
ensureManagedDirectory(scope.directoryPath, root, policy);
|
|
625
878
|
const preparedDirectory = fs.lstatSync(scope.directoryPath, { bigint: true });
|
|
626
879
|
if (!preparedDirectory.isDirectory() || preparedDirectory.isSymbolicLink())
|
|
627
880
|
throw new Error("Managed session directory changed");
|
|
628
|
-
const retainedAuthority =
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
881
|
+
const retainedAuthority =
|
|
882
|
+
authority?.retainedAuthority &&
|
|
883
|
+
authority.retainedDirectory !== undefined &&
|
|
884
|
+
path.resolve(authority.retainedDirectory) === path.resolve(scope.directoryPath)
|
|
885
|
+
? authority.retainedAuthority
|
|
886
|
+
: retainManagedDirectoryAuthority(root, scope.directoryPath, {
|
|
887
|
+
dev: preparedDirectory.dev,
|
|
888
|
+
ino: preparedDirectory.ino,
|
|
889
|
+
});
|
|
890
|
+
const buildStore = () =>
|
|
891
|
+
new ManagedSessionDescendantStore(
|
|
892
|
+
root,
|
|
893
|
+
scope.directoryPath,
|
|
894
|
+
retainedAuthority ? { authority: retainedAuthority, authorityBaseDir: scope.directoryPath } : undefined,
|
|
895
|
+
policy,
|
|
896
|
+
);
|
|
897
|
+
let store: ManagedSessionDescendantStore;
|
|
898
|
+
try {
|
|
899
|
+
store = buildStore();
|
|
900
|
+
} catch (error) {
|
|
901
|
+
if (process.platform === "win32" && policy === "windows-existing-verify-first") throw error;
|
|
902
|
+
if (!isRecoverableOwnerOnlyModeDrift(error)) throw error;
|
|
903
|
+
// A prior writer left group/other-readable descendants under the scope
|
|
904
|
+
// (e.g. resident-cache blobs written on the explicit session path).
|
|
905
|
+
// Re-secure the tree in place and retry once before failing closed.
|
|
906
|
+
reapplyOwnerOnlyManagedTree(scope.directoryPath);
|
|
907
|
+
store = buildStore();
|
|
908
|
+
}
|
|
637
909
|
const binding = new TextEncoder().encode(`${JSON.stringify(bindingFor(scope))}\n`);
|
|
638
910
|
try {
|
|
639
911
|
store.publishNoReplaceSync(MANAGED_SESSION_BINDING_FILE, binding);
|
|
@@ -655,16 +927,32 @@ export function prepareManagedSessionScopeForWriteSync(scope: ManagedScope): Man
|
|
|
655
927
|
managedDirectoryIdentities.set(scope, { dev: preparedDirectory.dev, ino: preparedDirectory.ino });
|
|
656
928
|
if (validated) return validated;
|
|
657
929
|
const internal = managedInternalDirectory(scope);
|
|
658
|
-
ensureManagedDirectory(internal, root);
|
|
659
|
-
ensureManagedDirectory(path.join(internal, MANAGED_LOCKS_DIRECTORY), root);
|
|
660
|
-
ensureManagedDirectory(path.join(internal, MANAGED_RECEIPTS_DIRECTORY), root);
|
|
661
|
-
ensureManagedDirectory(path.join(internal, MANAGED_TOMBSTONES_DIRECTORY), root);
|
|
930
|
+
ensureManagedDirectory(internal, root, policy);
|
|
931
|
+
ensureManagedDirectory(path.join(internal, MANAGED_LOCKS_DIRECTORY), root, policy);
|
|
932
|
+
ensureManagedDirectory(path.join(internal, MANAGED_RECEIPTS_DIRECTORY), root, policy);
|
|
933
|
+
ensureManagedDirectory(path.join(internal, MANAGED_TOMBSTONES_DIRECTORY), root, policy);
|
|
662
934
|
return { kind: "resolved", scope };
|
|
663
935
|
} catch (error) {
|
|
936
|
+
const publication = error instanceof ManagedPublishError ? error : undefined;
|
|
937
|
+
const message =
|
|
938
|
+
publication?.classification ??
|
|
939
|
+
(error instanceof Error ? error.message : "Managed write protocol setup failed.");
|
|
940
|
+
const code =
|
|
941
|
+
message === "atomic_unavailable" ||
|
|
942
|
+
message === "invalid_request" ||
|
|
943
|
+
message === "durability_failed" ||
|
|
944
|
+
message === "durability_not_provable"
|
|
945
|
+
? message
|
|
946
|
+
: "binding_invalid";
|
|
664
947
|
return {
|
|
665
948
|
kind: "error",
|
|
666
|
-
code
|
|
667
|
-
message
|
|
949
|
+
code,
|
|
950
|
+
message,
|
|
951
|
+
...(publication
|
|
952
|
+
? { cause: { classification: publication.classification, diagnostic: publication.diagnostic } }
|
|
953
|
+
: code === "binding_invalid"
|
|
954
|
+
? {}
|
|
955
|
+
: { cause: { classification: code } }),
|
|
668
956
|
};
|
|
669
957
|
}
|
|
670
958
|
}
|
|
@@ -788,6 +1076,9 @@ function expectedFailure(error: unknown): ManagedOpenFailure {
|
|
|
788
1076
|
message === "source_changed" ||
|
|
789
1077
|
message === "unsafe_artifacts" ||
|
|
790
1078
|
message === "durability_failed" ||
|
|
1079
|
+
message === "atomic_unavailable" ||
|
|
1080
|
+
message === "invalid_request" ||
|
|
1081
|
+
message === "durability_not_provable" ||
|
|
791
1082
|
message === "migration_retired"
|
|
792
1083
|
? message
|
|
793
1084
|
: "managed_storage_unsupported";
|
|
@@ -804,10 +1095,33 @@ function sameCandidate(left: ManagedCandidate, right: ManagedCandidate): boolean
|
|
|
804
1095
|
);
|
|
805
1096
|
}
|
|
806
1097
|
|
|
1098
|
+
function matchesExpectedResumeIdentity(candidate: ManagedCandidate, expected: ResumeSessionIdentity): boolean {
|
|
1099
|
+
return (
|
|
1100
|
+
path.resolve(candidate.identity.canonicalPath) === path.resolve(expected.canonicalPath) &&
|
|
1101
|
+
candidate.identity.sessionId === expected.sessionId &&
|
|
1102
|
+
candidate.identity.dev === expected.dev &&
|
|
1103
|
+
candidate.identity.ino === expected.ino &&
|
|
1104
|
+
candidate.identity.size === expected.size &&
|
|
1105
|
+
candidate.identity.mtimeMs === expected.mtimeMs &&
|
|
1106
|
+
candidate.identity.mtimeNs === expected.mtimeNs &&
|
|
1107
|
+
candidate.identity.sha256 === expected.sha256
|
|
1108
|
+
);
|
|
1109
|
+
}
|
|
1110
|
+
|
|
1111
|
+
function revalidatePickerConsent(
|
|
1112
|
+
scope: ManagedScope,
|
|
1113
|
+
candidate: ManagedCandidate,
|
|
1114
|
+
expectedIdentity: ResumeSessionIdentity,
|
|
1115
|
+
): ManagedCandidate {
|
|
1116
|
+
const current = validateCandidateForScope(scope, candidate);
|
|
1117
|
+
if (!current || !matchesExpectedResumeIdentity(current, expectedIdentity)) throw new Error("source_changed");
|
|
1118
|
+
return current;
|
|
1119
|
+
}
|
|
1120
|
+
|
|
807
1121
|
function receiptPathFor(
|
|
808
1122
|
scope: ManagedScope,
|
|
809
1123
|
source: ManagedCandidate,
|
|
810
|
-
state: "prepared" | "published" | "committed" = "committed",
|
|
1124
|
+
state: "prepared" | "detached" | "published" | "committed" = "committed",
|
|
811
1125
|
): string {
|
|
812
1126
|
const suffix = state === "committed" ? "" : `.${state}`;
|
|
813
1127
|
return path.join(
|
|
@@ -846,6 +1160,14 @@ function receiptMatches(
|
|
|
846
1160
|
identity?: { dev?: unknown; ino?: unknown; size?: unknown; mtimeNs?: unknown };
|
|
847
1161
|
};
|
|
848
1162
|
artifactManifest?: unknown;
|
|
1163
|
+
sourceArtifactQuarantine?: { role?: unknown };
|
|
1164
|
+
sourceArtifactCleanup?: {
|
|
1165
|
+
state?: unknown;
|
|
1166
|
+
role?: unknown;
|
|
1167
|
+
retainedPath?: unknown;
|
|
1168
|
+
identity?: { dev?: unknown; ino?: unknown; size?: unknown; mtimeNs?: unknown };
|
|
1169
|
+
tree?: unknown;
|
|
1170
|
+
};
|
|
849
1171
|
};
|
|
850
1172
|
const exact = (
|
|
851
1173
|
recorded: { dev?: unknown; ino?: unknown; size?: unknown; mtimeNs?: unknown } | undefined,
|
|
@@ -886,12 +1208,43 @@ function receiptMatches(
|
|
|
886
1208
|
item.size >= 0
|
|
887
1209
|
);
|
|
888
1210
|
});
|
|
1211
|
+
const quarantine = record.sourceArtifactQuarantine;
|
|
1212
|
+
const cleanup = record.sourceArtifactCleanup;
|
|
1213
|
+
const cleanupIdentity = cleanup?.identity;
|
|
1214
|
+
const cleanupTree = artifactTreeSnapshot(cleanup?.tree);
|
|
1215
|
+
const validCleanup =
|
|
1216
|
+
cleanup === undefined ||
|
|
1217
|
+
(quarantine?.role === "detached_artifact_root" &&
|
|
1218
|
+
cleanup.state === "cleanup_pending" &&
|
|
1219
|
+
cleanup.role === "exchange_placeholder" &&
|
|
1220
|
+
typeof cleanup.retainedPath === "string" &&
|
|
1221
|
+
cleanupIdentity?.dev !== undefined &&
|
|
1222
|
+
cleanupIdentity.ino !== undefined &&
|
|
1223
|
+
cleanupIdentity.size !== undefined &&
|
|
1224
|
+
cleanupIdentity.mtimeNs !== undefined &&
|
|
1225
|
+
!!cleanupTree &&
|
|
1226
|
+
cleanupAuthorityMatches(
|
|
1227
|
+
{
|
|
1228
|
+
state: "cleanup_pending",
|
|
1229
|
+
role: "exchange_placeholder",
|
|
1230
|
+
retainedPath: cleanup.retainedPath,
|
|
1231
|
+
identity: {
|
|
1232
|
+
dev: BigInt(String(cleanupIdentity.dev)),
|
|
1233
|
+
ino: BigInt(String(cleanupIdentity.ino)),
|
|
1234
|
+
size: BigInt(String(cleanupIdentity.size)),
|
|
1235
|
+
mtimeNs: BigInt(String(cleanupIdentity.mtimeNs)),
|
|
1236
|
+
},
|
|
1237
|
+
tree: cleanupTree,
|
|
1238
|
+
},
|
|
1239
|
+
path.dirname(source.path),
|
|
1240
|
+
));
|
|
889
1241
|
return (
|
|
890
1242
|
record.schemaVersion === 2 &&
|
|
891
1243
|
record.state === "committed" &&
|
|
892
1244
|
record.policy === "copy-retain" &&
|
|
893
1245
|
record.scope === scopeDigest(scope.platform, scope.canonicalCwd) &&
|
|
894
1246
|
validManifest &&
|
|
1247
|
+
validCleanup &&
|
|
895
1248
|
manifestContains(destination.path, manifest as readonly ArtifactManifestEntry[]) &&
|
|
896
1249
|
record.source?.path === source.path &&
|
|
897
1250
|
record.source?.sessionId === source.sessionId &&
|
|
@@ -1034,6 +1387,65 @@ function retiredTargets(scope: ManagedScope, pathname: string): readonly Retired
|
|
|
1034
1387
|
* a returned partial result appends `pending(N + 1)` with the observed detached
|
|
1035
1388
|
* identity/path. Restart accepts only a contiguous, target-bound sequence.
|
|
1036
1389
|
*/
|
|
1390
|
+
type RetainedArtifactsRootReceipt = {
|
|
1391
|
+
path: string;
|
|
1392
|
+
identity: SessionStorageFileIdentity;
|
|
1393
|
+
tree: NativeDirectoryTreeSnapshot;
|
|
1394
|
+
};
|
|
1395
|
+
|
|
1396
|
+
function retainedArtifactsRootReceipt(receipt: CleanupReceipt): RetainedArtifactsRootReceipt | undefined {
|
|
1397
|
+
const pathname = deterministicRemovalRoot(receipt.plannedArtifactsPath);
|
|
1398
|
+
if (!fs.existsSync(pathname)) return undefined;
|
|
1399
|
+
if (!receipt.expectedArtifactsIdentity) throw new Error("durability_failed");
|
|
1400
|
+
const identity = artifactIdentityAt(pathname);
|
|
1401
|
+
if (!identity || !sameArtifactRootIdentity(identity, receipt.expectedArtifactsIdentity))
|
|
1402
|
+
throw new Error("durability_failed");
|
|
1403
|
+
const tree = snapshotArtifactTree(pathname);
|
|
1404
|
+
return { path: pathname, identity, tree };
|
|
1405
|
+
}
|
|
1406
|
+
|
|
1407
|
+
function retainedArtifactsRootMatches(record: Record<string, unknown>): boolean {
|
|
1408
|
+
if (record.retainedArtifactsRoot === undefined) return true;
|
|
1409
|
+
if (!record.retainedArtifactsRoot || typeof record.retainedArtifactsRoot !== "object")
|
|
1410
|
+
throw new Error("durability_failed");
|
|
1411
|
+
const retained = record.retainedArtifactsRoot as Record<string, unknown>;
|
|
1412
|
+
const identity = retained.identity;
|
|
1413
|
+
const tree = artifactTreeSnapshot(retained.tree);
|
|
1414
|
+
if (
|
|
1415
|
+
typeof record.plannedArtifactsPath !== "string" ||
|
|
1416
|
+
retained.path !== deterministicRemovalRoot(record.plannedArtifactsPath) ||
|
|
1417
|
+
!identity ||
|
|
1418
|
+
typeof identity !== "object" ||
|
|
1419
|
+
Array.isArray(identity) ||
|
|
1420
|
+
!tree
|
|
1421
|
+
)
|
|
1422
|
+
throw new Error("durability_failed");
|
|
1423
|
+
const artifactIdentity = identity as Record<string, unknown>;
|
|
1424
|
+
if (
|
|
1425
|
+
typeof artifactIdentity.dev !== "string" ||
|
|
1426
|
+
typeof artifactIdentity.ino !== "string" ||
|
|
1427
|
+
typeof artifactIdentity.size !== "number" ||
|
|
1428
|
+
typeof artifactIdentity.mtimeNs !== "string" ||
|
|
1429
|
+
typeof artifactIdentity.sha256 !== "string"
|
|
1430
|
+
)
|
|
1431
|
+
throw new Error("durability_failed");
|
|
1432
|
+
if (!fs.existsSync(retained.path)) return true;
|
|
1433
|
+
const observed = artifactIdentityAt(retained.path);
|
|
1434
|
+
if (
|
|
1435
|
+
!observed ||
|
|
1436
|
+
!sameArtifactRootIdentity(observed, {
|
|
1437
|
+
dev: BigInt(artifactIdentity.dev),
|
|
1438
|
+
ino: BigInt(artifactIdentity.ino),
|
|
1439
|
+
size: artifactIdentity.size,
|
|
1440
|
+
mtimeNs: BigInt(artifactIdentity.mtimeNs),
|
|
1441
|
+
sha256: artifactIdentity.sha256,
|
|
1442
|
+
}) ||
|
|
1443
|
+
JSON.stringify(snapshotArtifactTree(retained.path)) !== JSON.stringify(tree)
|
|
1444
|
+
)
|
|
1445
|
+
throw new Error("durability_failed");
|
|
1446
|
+
return true;
|
|
1447
|
+
}
|
|
1448
|
+
|
|
1037
1449
|
type CleanupReceipt = {
|
|
1038
1450
|
attempt: number;
|
|
1039
1451
|
target: RetiredTarget;
|
|
@@ -1041,6 +1453,13 @@ type CleanupReceipt = {
|
|
|
1041
1453
|
expectedArtifactsTree?: NativeDirectoryTreeSnapshot;
|
|
1042
1454
|
detachedArtifactsPath?: string;
|
|
1043
1455
|
detachedTranscriptPath?: string;
|
|
1456
|
+
retainedArtifactsSuccessorPath?: string;
|
|
1457
|
+
retainedArtifactsPlaceholderPath?: string;
|
|
1458
|
+
retainedArtifactsUnknownPath?: string;
|
|
1459
|
+
retainedTranscriptSuccessorPath?: string;
|
|
1460
|
+
retainedTranscriptPlaceholderPath?: string;
|
|
1461
|
+
retainedTranscriptUnknownPath?: string;
|
|
1462
|
+
|
|
1044
1463
|
plannedArtifactsPath: string;
|
|
1045
1464
|
plannedTranscriptPath: string;
|
|
1046
1465
|
};
|
|
@@ -1074,6 +1493,24 @@ function cleanupReceipt(scope: ManagedScope, tombstone: string, receipt: Cleanup
|
|
|
1074
1493
|
...(receipt.expectedArtifactsTree ? { expectedArtifactsTree: receipt.expectedArtifactsTree } : {}),
|
|
1075
1494
|
...(receipt.detachedArtifactsPath ? { detachedArtifactsPath: receipt.detachedArtifactsPath } : {}),
|
|
1076
1495
|
...(receipt.detachedTranscriptPath ? { detachedTranscriptPath: receipt.detachedTranscriptPath } : {}),
|
|
1496
|
+
...(receipt.retainedArtifactsSuccessorPath
|
|
1497
|
+
? { retainedArtifactsSuccessorPath: receipt.retainedArtifactsSuccessorPath }
|
|
1498
|
+
: {}),
|
|
1499
|
+
...(receipt.retainedArtifactsPlaceholderPath
|
|
1500
|
+
? { retainedArtifactsPlaceholderPath: receipt.retainedArtifactsPlaceholderPath }
|
|
1501
|
+
: {}),
|
|
1502
|
+
...(receipt.retainedArtifactsUnknownPath
|
|
1503
|
+
? { retainedArtifactsUnknownPath: receipt.retainedArtifactsUnknownPath }
|
|
1504
|
+
: {}),
|
|
1505
|
+
...(receipt.retainedTranscriptSuccessorPath
|
|
1506
|
+
? { retainedTranscriptSuccessorPath: receipt.retainedTranscriptSuccessorPath }
|
|
1507
|
+
: {}),
|
|
1508
|
+
...(receipt.retainedTranscriptPlaceholderPath
|
|
1509
|
+
? { retainedTranscriptPlaceholderPath: receipt.retainedTranscriptPlaceholderPath }
|
|
1510
|
+
: {}),
|
|
1511
|
+
...(receipt.retainedTranscriptUnknownPath
|
|
1512
|
+
? { retainedTranscriptUnknownPath: receipt.retainedTranscriptUnknownPath }
|
|
1513
|
+
: {}),
|
|
1077
1514
|
plannedArtifactsPath: receipt.plannedArtifactsPath,
|
|
1078
1515
|
plannedTranscriptPath: receipt.plannedTranscriptPath,
|
|
1079
1516
|
};
|
|
@@ -1094,6 +1531,7 @@ function cleanupArtifactsRemoved(
|
|
|
1094
1531
|
if (!value || typeof value !== "object" || Array.isArray(value)) return false;
|
|
1095
1532
|
const record = value as Record<string, unknown>;
|
|
1096
1533
|
const recorded = record.target as Record<string, unknown> | undefined;
|
|
1534
|
+
const identity = recorded?.identity as Record<string, unknown> | undefined;
|
|
1097
1535
|
return (
|
|
1098
1536
|
record.schemaVersion === 2 &&
|
|
1099
1537
|
record.state === "artifacts_removed" &&
|
|
@@ -1102,9 +1540,17 @@ function cleanupArtifactsRemoved(
|
|
|
1102
1540
|
record.attempt === attempt &&
|
|
1103
1541
|
recorded?.path === target.path &&
|
|
1104
1542
|
recorded.sessionId === target.sessionId &&
|
|
1105
|
-
recorded.cwd === target.cwd
|
|
1543
|
+
recorded.cwd === target.cwd &&
|
|
1544
|
+
identity?.canonicalPath === target.identity.canonicalPath &&
|
|
1545
|
+
identity.dev === String(target.identity.dev) &&
|
|
1546
|
+
identity.ino === String(target.identity.ino) &&
|
|
1547
|
+
identity.size === target.identity.size &&
|
|
1548
|
+
identity.mtimeNs === String(target.identity.mtimeNs) &&
|
|
1549
|
+
identity.sha256 === target.identity.sha256 &&
|
|
1550
|
+
retainedArtifactsRootMatches(record)
|
|
1106
1551
|
);
|
|
1107
|
-
} catch {
|
|
1552
|
+
} catch (error) {
|
|
1553
|
+
if ((error as Error).message === "durability_failed") throw error;
|
|
1108
1554
|
return false;
|
|
1109
1555
|
}
|
|
1110
1556
|
}
|
|
@@ -1115,9 +1561,14 @@ async function publishCleanupArtifactsRemoved(
|
|
|
1115
1561
|
receipt: CleanupReceipt,
|
|
1116
1562
|
lock: ManagedStorageLock,
|
|
1117
1563
|
): Promise<void> {
|
|
1564
|
+
const retainedArtifactsRoot = retainedArtifactsRootReceipt(receipt);
|
|
1118
1565
|
await publishManagedTombstone(
|
|
1119
1566
|
cleanupReceiptPath(tombstone, receipt.target, "artifacts_removed", receipt.attempt),
|
|
1120
|
-
{
|
|
1567
|
+
{
|
|
1568
|
+
...cleanupReceipt(scope, tombstone, receipt),
|
|
1569
|
+
state: "artifacts_removed",
|
|
1570
|
+
...(retainedArtifactsRoot ? { retainedArtifactsRoot } : {}),
|
|
1571
|
+
},
|
|
1121
1572
|
lock.assertOwned,
|
|
1122
1573
|
).catch(error => {
|
|
1123
1574
|
if ((error as Error).message !== "destination_conflict") throw error;
|
|
@@ -1134,6 +1585,14 @@ function isQuarantinePath(target: RetiredTarget, pathname: unknown): pathname is
|
|
|
1134
1585
|
);
|
|
1135
1586
|
}
|
|
1136
1587
|
|
|
1588
|
+
function isRetainedNativePath(target: RetiredTarget, pathname: unknown): pathname is string {
|
|
1589
|
+
return (
|
|
1590
|
+
typeof pathname === "string" &&
|
|
1591
|
+
path.dirname(pathname) === path.dirname(target.path) &&
|
|
1592
|
+
path.basename(pathname).startsWith(".gjc-")
|
|
1593
|
+
);
|
|
1594
|
+
}
|
|
1595
|
+
|
|
1137
1596
|
function deterministicRemovalRoot(plannedRoot: string): string {
|
|
1138
1597
|
return `${plannedRoot}.removing`;
|
|
1139
1598
|
}
|
|
@@ -1145,17 +1604,44 @@ function isAuthorizedArtifactRoot(target: RetiredTarget, plannedRoot: string, pa
|
|
|
1145
1604
|
);
|
|
1146
1605
|
}
|
|
1147
1606
|
|
|
1148
|
-
function
|
|
1149
|
-
|
|
1150
|
-
|
|
1151
|
-
|
|
1152
|
-
|
|
1153
|
-
|
|
1607
|
+
function transcriptRootMatchesTarget(pathname: string, target: RetiredTarget): boolean {
|
|
1608
|
+
try {
|
|
1609
|
+
const observed = captureManagedFileNoFollow(pathname);
|
|
1610
|
+
const digest = createHash("sha256").update(observed.bytes).digest("hex");
|
|
1611
|
+
return (
|
|
1612
|
+
observed.identity.dev === target.identity.dev &&
|
|
1613
|
+
observed.identity.ino === target.identity.ino &&
|
|
1614
|
+
observed.identity.size === target.identity.size &&
|
|
1615
|
+
observed.identity.mtimeNs === target.identity.mtimeNs &&
|
|
1616
|
+
digest === target.identity.sha256
|
|
1617
|
+
);
|
|
1618
|
+
} catch (error) {
|
|
1619
|
+
if ((error as NodeJS.ErrnoException).code === "ENOENT") return false;
|
|
1620
|
+
throw new Error("durability_failed");
|
|
1621
|
+
}
|
|
1622
|
+
}
|
|
1623
|
+
|
|
1624
|
+
function cleanupRootsAbsent(tombstone: string, target: RetiredTarget, pending: CleanupReceipt): boolean {
|
|
1154
1625
|
const prefix = `${path.basename(tombstone, ".json")}.${stableOperationName(target)}.cleanup-pending-`;
|
|
1626
|
+
const activeTranscriptRoots = new Set(
|
|
1627
|
+
[pending.plannedTranscriptPath, pending.detachedTranscriptPath].filter((pathname): pathname is string =>
|
|
1628
|
+
isQuarantinePath(target, pathname),
|
|
1629
|
+
),
|
|
1630
|
+
);
|
|
1155
1631
|
const roots = new Set<string>([
|
|
1632
|
+
target.path,
|
|
1633
|
+
target.path.slice(0, -6),
|
|
1156
1634
|
pending.plannedArtifactsPath,
|
|
1157
1635
|
deterministicRemovalRoot(pending.plannedArtifactsPath),
|
|
1158
1636
|
...(pending.detachedArtifactsPath ? [pending.detachedArtifactsPath] : []),
|
|
1637
|
+
...[
|
|
1638
|
+
pending.retainedArtifactsSuccessorPath,
|
|
1639
|
+
pending.retainedArtifactsPlaceholderPath,
|
|
1640
|
+
pending.retainedArtifactsUnknownPath,
|
|
1641
|
+
pending.retainedTranscriptSuccessorPath,
|
|
1642
|
+
pending.retainedTranscriptPlaceholderPath,
|
|
1643
|
+
pending.retainedTranscriptUnknownPath,
|
|
1644
|
+
].filter((pathname): pathname is string => isRetainedNativePath(target, pathname)),
|
|
1159
1645
|
]);
|
|
1160
1646
|
for (const name of fs.readdirSync(path.dirname(tombstone))) {
|
|
1161
1647
|
if (!name.startsWith(prefix) || !name.endsWith(".json")) continue;
|
|
@@ -1164,26 +1650,91 @@ function artifactRootsAbsent(
|
|
|
1164
1650
|
) as {
|
|
1165
1651
|
plannedArtifactsPath?: unknown;
|
|
1166
1652
|
detachedArtifactsPath?: unknown;
|
|
1653
|
+
plannedTranscriptPath?: unknown;
|
|
1654
|
+
detachedTranscriptPath?: unknown;
|
|
1655
|
+
retainedArtifactsSuccessorPath?: unknown;
|
|
1656
|
+
retainedArtifactsPlaceholderPath?: unknown;
|
|
1657
|
+
retainedArtifactsUnknownPath?: unknown;
|
|
1658
|
+
retainedTranscriptSuccessorPath?: unknown;
|
|
1659
|
+
retainedTranscriptPlaceholderPath?: unknown;
|
|
1660
|
+
retainedTranscriptUnknownPath?: unknown;
|
|
1167
1661
|
};
|
|
1168
1662
|
if (isQuarantinePath(target, record.plannedArtifactsPath)) {
|
|
1169
1663
|
roots.add(record.plannedArtifactsPath);
|
|
1170
1664
|
roots.add(deterministicRemovalRoot(record.plannedArtifactsPath));
|
|
1171
1665
|
}
|
|
1172
1666
|
if (isQuarantinePath(target, record.detachedArtifactsPath)) roots.add(record.detachedArtifactsPath);
|
|
1667
|
+
for (const pathname of [record.plannedTranscriptPath, record.detachedTranscriptPath]) {
|
|
1668
|
+
if (isQuarantinePath(target, pathname) && !activeTranscriptRoots.has(pathname)) roots.add(pathname);
|
|
1669
|
+
}
|
|
1670
|
+
for (const pathname of [
|
|
1671
|
+
record.retainedArtifactsSuccessorPath,
|
|
1672
|
+
record.retainedArtifactsPlaceholderPath,
|
|
1673
|
+
record.retainedArtifactsUnknownPath,
|
|
1674
|
+
record.retainedTranscriptSuccessorPath,
|
|
1675
|
+
record.retainedTranscriptPlaceholderPath,
|
|
1676
|
+
record.retainedTranscriptUnknownPath,
|
|
1677
|
+
]) {
|
|
1678
|
+
if (isRetainedNativePath(target, pathname)) roots.add(pathname);
|
|
1679
|
+
}
|
|
1173
1680
|
}
|
|
1174
|
-
|
|
1681
|
+
for (const pathname of roots) {
|
|
1682
|
+
try {
|
|
1683
|
+
fs.lstatSync(pathname);
|
|
1684
|
+
throw new Error("durability_failed");
|
|
1685
|
+
} catch (error) {
|
|
1686
|
+
if ((error as NodeJS.ErrnoException).code === "ENOENT") continue;
|
|
1687
|
+
throw error;
|
|
1688
|
+
}
|
|
1689
|
+
}
|
|
1690
|
+
for (const pathname of activeTranscriptRoots) {
|
|
1691
|
+
try {
|
|
1692
|
+
fs.lstatSync(pathname);
|
|
1693
|
+
} catch (error) {
|
|
1694
|
+
if ((error as NodeJS.ErrnoException).code === "ENOENT") continue;
|
|
1695
|
+
throw error;
|
|
1696
|
+
}
|
|
1697
|
+
if (!transcriptRootMatchesTarget(pathname, target)) throw new Error("durability_failed");
|
|
1698
|
+
return false;
|
|
1699
|
+
}
|
|
1700
|
+
return true;
|
|
1175
1701
|
}
|
|
1176
1702
|
|
|
1177
1703
|
function sameArtifactRootIdentity(left: SessionStorageFileIdentity, right: SessionStorageFileIdentity): boolean {
|
|
1178
1704
|
return left.dev === right.dev && left.ino === right.ino;
|
|
1179
1705
|
}
|
|
1180
1706
|
|
|
1181
|
-
|
|
1182
|
-
|
|
1183
|
-
|
|
1184
|
-
|
|
1185
|
-
|
|
1707
|
+
function sameArtifactTreeSnapshot(left: NativeDirectoryTreeSnapshot, right: NativeDirectoryTreeSnapshot): boolean {
|
|
1708
|
+
if (left.rootDev !== right.rootDev || left.rootIno !== right.rootIno || left.entries.length !== right.entries.length)
|
|
1709
|
+
return false;
|
|
1710
|
+
const entryKey = (entry: NativeDirectoryTreeSnapshot["entries"][number]): string =>
|
|
1711
|
+
JSON.stringify([entry.relativePath, entry.kind, entry.dev, entry.ino, entry.size, entry.mtimeNs, entry.sha256]);
|
|
1712
|
+
const leftEntries = left.entries.map(entryKey).sort();
|
|
1713
|
+
const rightEntries = right.entries.map(entryKey).sort();
|
|
1714
|
+
return leftEntries.every((entry, index) => entry === rightEntries[index]);
|
|
1715
|
+
}
|
|
1716
|
+
|
|
1717
|
+
function assertRetainedArtifactsAuthority(pending: CleanupReceipt): void {
|
|
1718
|
+
if (!pending.detachedArtifactsPath) return;
|
|
1719
|
+
if (!pending.expectedArtifactsIdentity || !pending.expectedArtifactsTree) throw new Error("durability_failed");
|
|
1720
|
+
try {
|
|
1721
|
+
fs.lstatSync(pending.detachedArtifactsPath);
|
|
1722
|
+
} catch (error) {
|
|
1723
|
+
if ((error as NodeJS.ErrnoException).code === "ENOENT") return;
|
|
1724
|
+
throw new Error("durability_failed");
|
|
1725
|
+
}
|
|
1726
|
+
const observed = artifactIdentityAt(pending.detachedArtifactsPath);
|
|
1727
|
+
if (
|
|
1728
|
+
!observed ||
|
|
1729
|
+
!sameArtifactRootIdentity(observed, pending.expectedArtifactsIdentity) ||
|
|
1730
|
+
!sameArtifactTreeSnapshot(snapshotArtifactTree(pending.detachedArtifactsPath), pending.expectedArtifactsTree)
|
|
1731
|
+
)
|
|
1732
|
+
throw new Error("binding_invalid");
|
|
1733
|
+
}
|
|
1734
|
+
|
|
1186
1735
|
function probePlannedCleanupDetach(target: RetiredTarget, pending: CleanupReceipt): CleanupReceipt {
|
|
1736
|
+
assertRetainedArtifactsAuthority(pending);
|
|
1737
|
+
|
|
1187
1738
|
let detachedArtifactsPath = pending.detachedArtifactsPath;
|
|
1188
1739
|
let detachedTranscriptPath = pending.detachedTranscriptPath;
|
|
1189
1740
|
for (const pathname of [pending.plannedArtifactsPath, deterministicRemovalRoot(pending.plannedArtifactsPath)]) {
|
|
@@ -1247,6 +1798,16 @@ function artifactTreeSnapshot(value: unknown): NativeDirectoryTreeSnapshot | und
|
|
|
1247
1798
|
})
|
|
1248
1799
|
)
|
|
1249
1800
|
return undefined;
|
|
1801
|
+
const roots = snapshot.entries.filter(entry => {
|
|
1802
|
+
const item = entry as Record<string, unknown>;
|
|
1803
|
+
return (
|
|
1804
|
+
item.relativePath === "" &&
|
|
1805
|
+
item.kind === "directory" &&
|
|
1806
|
+
item.dev === snapshot.rootDev &&
|
|
1807
|
+
item.ino === snapshot.rootIno
|
|
1808
|
+
);
|
|
1809
|
+
});
|
|
1810
|
+
if (roots.length !== 1) return undefined;
|
|
1250
1811
|
return snapshot as unknown as NativeDirectoryTreeSnapshot;
|
|
1251
1812
|
}
|
|
1252
1813
|
|
|
@@ -1298,7 +1859,19 @@ function pendingCleanupReceipt(
|
|
|
1298
1859
|
plannedPaths.has(record.plannedArtifactsPath as string) ||
|
|
1299
1860
|
plannedPaths.has(record.plannedTranscriptPath as string) ||
|
|
1300
1861
|
(record.detachedArtifactsPath !== undefined && !isQuarantinePath(target, record.detachedArtifactsPath)) ||
|
|
1301
|
-
(record.detachedTranscriptPath !== undefined && !isQuarantinePath(target, record.detachedTranscriptPath))
|
|
1862
|
+
(record.detachedTranscriptPath !== undefined && !isQuarantinePath(target, record.detachedTranscriptPath)) ||
|
|
1863
|
+
(record.retainedArtifactsSuccessorPath !== undefined &&
|
|
1864
|
+
!isRetainedNativePath(target, record.retainedArtifactsSuccessorPath)) ||
|
|
1865
|
+
(record.retainedArtifactsPlaceholderPath !== undefined &&
|
|
1866
|
+
!isRetainedNativePath(target, record.retainedArtifactsPlaceholderPath)) ||
|
|
1867
|
+
(record.retainedArtifactsUnknownPath !== undefined &&
|
|
1868
|
+
!isRetainedNativePath(target, record.retainedArtifactsUnknownPath)) ||
|
|
1869
|
+
(record.retainedTranscriptSuccessorPath !== undefined &&
|
|
1870
|
+
!isRetainedNativePath(target, record.retainedTranscriptSuccessorPath)) ||
|
|
1871
|
+
(record.retainedTranscriptPlaceholderPath !== undefined &&
|
|
1872
|
+
!isRetainedNativePath(target, record.retainedTranscriptPlaceholderPath)) ||
|
|
1873
|
+
(record.retainedTranscriptUnknownPath !== undefined &&
|
|
1874
|
+
!isRetainedNativePath(target, record.retainedTranscriptUnknownPath))
|
|
1302
1875
|
)
|
|
1303
1876
|
throw new Error("durability_failed");
|
|
1304
1877
|
const artifact = record.expectedArtifactsIdentity as Record<string, unknown> | undefined;
|
|
@@ -1321,7 +1894,7 @@ function pendingCleanupReceipt(
|
|
|
1321
1894
|
const expectedArtifactsTree =
|
|
1322
1895
|
record.expectedArtifactsTree === undefined ? undefined : artifactTreeSnapshot(record.expectedArtifactsTree);
|
|
1323
1896
|
if (record.expectedArtifactsTree !== undefined && !expectedArtifactsTree) throw new Error("durability_failed");
|
|
1324
|
-
|
|
1897
|
+
|
|
1325
1898
|
if (
|
|
1326
1899
|
latest &&
|
|
1327
1900
|
((record.detachedArtifactsPath !== undefined &&
|
|
@@ -1340,6 +1913,12 @@ function pendingCleanupReceipt(
|
|
|
1340
1913
|
expectedArtifactsTree,
|
|
1341
1914
|
detachedArtifactsPath: record.detachedArtifactsPath as string | undefined,
|
|
1342
1915
|
detachedTranscriptPath: record.detachedTranscriptPath as string | undefined,
|
|
1916
|
+
retainedArtifactsSuccessorPath: record.retainedArtifactsSuccessorPath as string | undefined,
|
|
1917
|
+
retainedArtifactsPlaceholderPath: record.retainedArtifactsPlaceholderPath as string | undefined,
|
|
1918
|
+
retainedArtifactsUnknownPath: record.retainedArtifactsUnknownPath as string | undefined,
|
|
1919
|
+
retainedTranscriptSuccessorPath: record.retainedTranscriptSuccessorPath as string | undefined,
|
|
1920
|
+
retainedTranscriptPlaceholderPath: record.retainedTranscriptPlaceholderPath as string | undefined,
|
|
1921
|
+
retainedTranscriptUnknownPath: record.retainedTranscriptUnknownPath as string | undefined,
|
|
1343
1922
|
plannedArtifactsPath: record.plannedArtifactsPath as string,
|
|
1344
1923
|
plannedTranscriptPath: record.plannedTranscriptPath as string,
|
|
1345
1924
|
};
|
|
@@ -1389,7 +1968,13 @@ function nextCleanupReceipt(target: RetiredTarget, pending: CleanupReceipt | und
|
|
|
1389
1968
|
expectedArtifactsIdentity,
|
|
1390
1969
|
expectedArtifactsTree,
|
|
1391
1970
|
detachedArtifactsPath: pending?.detachedArtifactsPath,
|
|
1971
|
+
retainedArtifactsSuccessorPath: pending?.retainedArtifactsSuccessorPath,
|
|
1972
|
+
retainedArtifactsPlaceholderPath: pending?.retainedArtifactsPlaceholderPath,
|
|
1973
|
+
retainedArtifactsUnknownPath: pending?.retainedArtifactsUnknownPath,
|
|
1392
1974
|
detachedTranscriptPath: pending?.detachedTranscriptPath,
|
|
1975
|
+
retainedTranscriptSuccessorPath: pending?.retainedTranscriptSuccessorPath,
|
|
1976
|
+
retainedTranscriptPlaceholderPath: pending?.retainedTranscriptPlaceholderPath,
|
|
1977
|
+
retainedTranscriptUnknownPath: pending?.retainedTranscriptUnknownPath,
|
|
1393
1978
|
plannedArtifactsPath: path.join(directory, `.gjc-delete-${operation}-artifacts-${attempt}`),
|
|
1394
1979
|
plannedTranscriptPath: path.join(directory, `.gjc-delete-${operation}-transcript-${attempt}`),
|
|
1395
1980
|
};
|
|
@@ -1402,6 +1987,36 @@ function requiresFreshCleanupPlan(pending: CleanupReceipt): boolean {
|
|
|
1402
1987
|
);
|
|
1403
1988
|
}
|
|
1404
1989
|
|
|
1990
|
+
/** Evidence-carrier for a verified `cleanup_pending` deletion, bound to the active plan. */
|
|
1991
|
+
function cleanupPendingEvidence(
|
|
1992
|
+
retry: CleanupReceipt,
|
|
1993
|
+
active: CleanupReceipt,
|
|
1994
|
+
deletion: Extract<VerifiedSessionDeleteResult, { kind: "cleanup_pending" }>,
|
|
1995
|
+
): CleanupReceipt {
|
|
1996
|
+
return {
|
|
1997
|
+
...retry,
|
|
1998
|
+
expectedArtifactsIdentity:
|
|
1999
|
+
deletion.phase === "artifacts" ? deletion.artifactsIdentity : active.expectedArtifactsIdentity,
|
|
2000
|
+
expectedArtifactsTree: deletion.phase === "artifacts" ? deletion.artifactsTree : active.expectedArtifactsTree,
|
|
2001
|
+
detachedArtifactsPath:
|
|
2002
|
+
deletion.phase === "artifacts" ? deletion.detachedArtifactsPath : active.detachedArtifactsPath,
|
|
2003
|
+
detachedTranscriptPath:
|
|
2004
|
+
deletion.phase === "transcript" ? deletion.detachedTranscriptPath : active.detachedTranscriptPath,
|
|
2005
|
+
retainedArtifactsSuccessorPath:
|
|
2006
|
+
deletion.phase === "artifacts" ? deletion.retainedSuccessorPath : active.retainedArtifactsSuccessorPath,
|
|
2007
|
+
retainedArtifactsPlaceholderPath:
|
|
2008
|
+
deletion.phase === "artifacts" ? deletion.retainedPlaceholderPath : active.retainedArtifactsPlaceholderPath,
|
|
2009
|
+
retainedArtifactsUnknownPath:
|
|
2010
|
+
deletion.phase === "artifacts" ? deletion.retainedUnknownPath : active.retainedArtifactsUnknownPath,
|
|
2011
|
+
retainedTranscriptSuccessorPath:
|
|
2012
|
+
deletion.phase === "transcript" ? deletion.retainedSuccessorPath : active.retainedTranscriptSuccessorPath,
|
|
2013
|
+
retainedTranscriptPlaceholderPath:
|
|
2014
|
+
deletion.phase === "transcript" ? deletion.retainedPlaceholderPath : active.retainedTranscriptPlaceholderPath,
|
|
2015
|
+
retainedTranscriptUnknownPath:
|
|
2016
|
+
deletion.phase === "transcript" ? deletion.retainedUnknownPath : active.retainedTranscriptUnknownPath,
|
|
2017
|
+
};
|
|
2018
|
+
}
|
|
2019
|
+
|
|
1405
2020
|
async function publishCleanupPending(
|
|
1406
2021
|
scope: ManagedScope,
|
|
1407
2022
|
tombstone: string,
|
|
@@ -1653,11 +2268,19 @@ function planArtifactRootForMigration(sourceTranscript: string, operation: strin
|
|
|
1653
2268
|
throw error;
|
|
1654
2269
|
}
|
|
1655
2270
|
if (!stat.isDirectory() || stat.isSymbolicLink()) throw new Error("unsafe_artifacts");
|
|
2271
|
+
const tree = snapshotArtifactTree(originalPath);
|
|
2272
|
+
const root = tree.entries.find(entry => entry.relativePath === "" && entry.kind === "directory");
|
|
2273
|
+
if (!root) throw new Error("unsafe_artifacts");
|
|
1656
2274
|
return {
|
|
1657
2275
|
originalPath,
|
|
1658
2276
|
detachedPath: path.join(path.dirname(originalPath), `.gjc-migrate-${operation}-artifacts`),
|
|
1659
|
-
identity: {
|
|
1660
|
-
|
|
2277
|
+
identity: {
|
|
2278
|
+
dev: stat.dev,
|
|
2279
|
+
ino: stat.ino,
|
|
2280
|
+
size: process.platform === "win32" ? BigInt(root.size) : stat.size,
|
|
2281
|
+
mtimeNs: stat.mtimeNs,
|
|
2282
|
+
},
|
|
2283
|
+
tree,
|
|
1661
2284
|
};
|
|
1662
2285
|
}
|
|
1663
2286
|
|
|
@@ -1681,13 +2304,17 @@ function sameDirectoryObject(leftPath: string, rightPath: string): boolean {
|
|
|
1681
2304
|
function matchesDetachedArtifactRoot(pathname: string, plan: DetachedArtifactRoot): boolean {
|
|
1682
2305
|
try {
|
|
1683
2306
|
const stat = fs.lstatSync(pathname, { bigint: true });
|
|
2307
|
+
const snapshot = native.snapshotDirectoryTree(pathname);
|
|
2308
|
+
const root = snapshot.snapshot?.entries.find(entry => entry.relativePath === "" && entry.kind === "directory");
|
|
1684
2309
|
return (
|
|
1685
2310
|
stat.isDirectory() &&
|
|
1686
2311
|
!stat.isSymbolicLink() &&
|
|
1687
2312
|
stat.dev === plan.identity.dev &&
|
|
1688
2313
|
stat.ino === plan.identity.ino &&
|
|
1689
|
-
stat.size === plan.identity.size &&
|
|
1690
2314
|
stat.mtimeNs === plan.identity.mtimeNs &&
|
|
2315
|
+
(process.platform === "win32"
|
|
2316
|
+
? snapshot.ok && !!root && BigInt(root.size) === plan.identity.size
|
|
2317
|
+
: stat.size === plan.identity.size) &&
|
|
1691
2318
|
sameDirectoryObject(path.dirname(pathname), path.dirname(plan.detachedPath))
|
|
1692
2319
|
);
|
|
1693
2320
|
} catch {
|
|
@@ -1695,26 +2322,130 @@ function matchesDetachedArtifactRoot(pathname: string, plan: DetachedArtifactRoo
|
|
|
1695
2322
|
}
|
|
1696
2323
|
}
|
|
1697
2324
|
|
|
1698
|
-
function
|
|
2325
|
+
function sameArtifactTree(left: NativeDirectoryTreeSnapshot, right: NativeDirectoryTreeSnapshot): boolean {
|
|
2326
|
+
const entryKey = (entry: NativeDirectoryTreeSnapshot["entries"][number]): string =>
|
|
2327
|
+
JSON.stringify([
|
|
2328
|
+
entry.relativePath,
|
|
2329
|
+
entry.kind,
|
|
2330
|
+
entry.dev,
|
|
2331
|
+
entry.ino,
|
|
2332
|
+
entry.size,
|
|
2333
|
+
entry.mtimeNs,
|
|
2334
|
+
entry.ctimeNs,
|
|
2335
|
+
entry.sha256,
|
|
2336
|
+
]);
|
|
2337
|
+
return (
|
|
2338
|
+
left.rootDev === right.rootDev &&
|
|
2339
|
+
left.rootIno === right.rootIno &&
|
|
2340
|
+
left.entries.length === right.entries.length &&
|
|
2341
|
+
left.entries
|
|
2342
|
+
.map(entryKey)
|
|
2343
|
+
.sort()
|
|
2344
|
+
.every((entry, index) => entry === right.entries.map(entryKey).sort()[index])
|
|
2345
|
+
);
|
|
2346
|
+
}
|
|
2347
|
+
|
|
2348
|
+
type SourceArtifactCleanup = {
|
|
2349
|
+
state: "cleanup_pending";
|
|
2350
|
+
role: "exchange_placeholder";
|
|
2351
|
+
retainedPath: string;
|
|
2352
|
+
identity: DetachedArtifactRoot["identity"];
|
|
2353
|
+
tree: NativeDirectoryTreeSnapshot;
|
|
2354
|
+
};
|
|
2355
|
+
|
|
2356
|
+
function cleanupAuthorityMatches(cleanup: SourceArtifactCleanup, parent: string): boolean {
|
|
2357
|
+
try {
|
|
2358
|
+
const stat = fs.lstatSync(cleanup.retainedPath, { bigint: true });
|
|
2359
|
+
if (
|
|
2360
|
+
path.dirname(cleanup.retainedPath) !== parent ||
|
|
2361
|
+
!stat.isDirectory() ||
|
|
2362
|
+
stat.isSymbolicLink() ||
|
|
2363
|
+
stat.dev !== cleanup.identity.dev ||
|
|
2364
|
+
stat.ino !== cleanup.identity.ino ||
|
|
2365
|
+
stat.size !== cleanup.identity.size ||
|
|
2366
|
+
stat.mtimeNs !== cleanup.identity.mtimeNs
|
|
2367
|
+
)
|
|
2368
|
+
return false;
|
|
2369
|
+
const snapshot = native.snapshotDirectoryTree(cleanup.retainedPath);
|
|
2370
|
+
return (
|
|
2371
|
+
!!snapshot.ok &&
|
|
2372
|
+
!!snapshot.snapshot &&
|
|
2373
|
+
sameArtifactTree(snapshot.snapshot, cleanup.tree) &&
|
|
2374
|
+
cleanup.tree.entries.length === 1 &&
|
|
2375
|
+
cleanup.tree.entries[0]?.relativePath === "" &&
|
|
2376
|
+
cleanup.tree.entries[0]?.kind === "directory"
|
|
2377
|
+
);
|
|
2378
|
+
} catch {
|
|
2379
|
+
return false;
|
|
2380
|
+
}
|
|
2381
|
+
}
|
|
2382
|
+
|
|
2383
|
+
function detachArtifactRootForMigration(plan: DetachedArtifactRoot): {
|
|
2384
|
+
detached: DetachedArtifactRoot;
|
|
2385
|
+
cleanup?: SourceArtifactCleanup;
|
|
2386
|
+
} {
|
|
1699
2387
|
const result = native.exactUnlink(plan.originalPath, {
|
|
1700
2388
|
...plan.identity,
|
|
1701
2389
|
directory: true,
|
|
1702
2390
|
detachOnly: true,
|
|
1703
2391
|
quarantineName: path.basename(plan.detachedPath),
|
|
1704
2392
|
});
|
|
1705
|
-
|
|
2393
|
+
const cleanSuccess =
|
|
2394
|
+
result.ok && !result.retainedSuccessorPath && !result.retainedPlaceholderPath && !result.retainedUnknownPath;
|
|
2395
|
+
const cleanupPending =
|
|
2396
|
+
!result.ok &&
|
|
2397
|
+
result.code === "cleanup_pending" &&
|
|
2398
|
+
!!result.detachedPath &&
|
|
2399
|
+
!!result.retainedPlaceholderPath &&
|
|
2400
|
+
!result.retainedSuccessorPath &&
|
|
2401
|
+
!result.retainedUnknownPath;
|
|
2402
|
+
if (
|
|
2403
|
+
(!cleanSuccess && !cleanupPending) ||
|
|
2404
|
+
!result.detachedPath ||
|
|
2405
|
+
!matchesDetachedArtifactRoot(result.detachedPath, plan)
|
|
2406
|
+
)
|
|
2407
|
+
throw new Error("durability_failed");
|
|
2408
|
+
|
|
2409
|
+
const detachedPath =
|
|
2410
|
+
process.platform === "win32" ? fs.realpathSync.native(result.detachedPath) : result.detachedPath;
|
|
2411
|
+
if (!matchesDetachedArtifactRoot(detachedPath, plan)) throw new Error("durability_failed");
|
|
2412
|
+
const detached = { ...plan, detachedPath };
|
|
2413
|
+
if (!cleanupPending) return { detached };
|
|
2414
|
+
const placeholder = result.retainedPlaceholderPath!;
|
|
2415
|
+
const stat = fs.lstatSync(placeholder, { bigint: true });
|
|
2416
|
+
if (!stat.isDirectory() || stat.isSymbolicLink() || path.dirname(placeholder) !== path.dirname(plan.originalPath))
|
|
1706
2417
|
throw new Error("durability_failed");
|
|
1707
|
-
|
|
2418
|
+
const snapshot = native.snapshotDirectoryTree(placeholder);
|
|
2419
|
+
const cleanup: SourceArtifactCleanup = {
|
|
2420
|
+
state: "cleanup_pending",
|
|
2421
|
+
role: "exchange_placeholder",
|
|
2422
|
+
retainedPath: placeholder,
|
|
2423
|
+
identity: { dev: stat.dev, ino: stat.ino, size: stat.size, mtimeNs: stat.mtimeNs },
|
|
2424
|
+
tree: snapshot.snapshot!,
|
|
2425
|
+
};
|
|
2426
|
+
if (!snapshot.ok || !snapshot.snapshot || !cleanupAuthorityMatches(cleanup, path.dirname(plan.originalPath)))
|
|
2427
|
+
throw new Error("durability_failed");
|
|
2428
|
+
return { detached, cleanup };
|
|
1708
2429
|
}
|
|
1709
2430
|
|
|
1710
2431
|
function restorePreparedArtifactRoot(scope: ManagedScope, source: ManagedCandidate): void {
|
|
1711
|
-
const
|
|
2432
|
+
const preparedReceipt = receiptPathFor(scope, source, "prepared");
|
|
2433
|
+
const detachedReceipt = receiptPathFor(scope, source, "detached");
|
|
2434
|
+
const receipt = fs.existsSync(detachedReceipt) ? detachedReceipt : preparedReceipt;
|
|
1712
2435
|
let record: {
|
|
1713
2436
|
sourceArtifactQuarantine?: {
|
|
1714
2437
|
path?: unknown;
|
|
1715
2438
|
detachedPath?: unknown;
|
|
1716
2439
|
identity?: Record<string, unknown>;
|
|
1717
2440
|
tree?: unknown;
|
|
2441
|
+
role?: unknown;
|
|
2442
|
+
};
|
|
2443
|
+
sourceArtifactCleanup?: {
|
|
2444
|
+
state?: unknown;
|
|
2445
|
+
role?: unknown;
|
|
2446
|
+
retainedPath?: unknown;
|
|
2447
|
+
identity?: Record<string, unknown>;
|
|
2448
|
+
tree?: unknown;
|
|
1718
2449
|
};
|
|
1719
2450
|
};
|
|
1720
2451
|
try {
|
|
@@ -1739,25 +2470,62 @@ function restorePreparedArtifactRoot(scope: ManagedScope, source: ManagedCandida
|
|
|
1739
2470
|
typeof identity.mtimeNs !== "string"
|
|
1740
2471
|
)
|
|
1741
2472
|
throw new Error("durability_failed");
|
|
2473
|
+
if (receipt === detachedReceipt && quarantine.role !== "detached_artifact_root")
|
|
2474
|
+
throw new Error("durability_failed");
|
|
2475
|
+
if (receipt === detachedReceipt) {
|
|
2476
|
+
const cleanup = record.sourceArtifactCleanup;
|
|
2477
|
+
const cleanupIdentity = cleanup?.identity;
|
|
2478
|
+
const cleanupTree = artifactTreeSnapshot(cleanup?.tree);
|
|
2479
|
+
if (
|
|
2480
|
+
cleanup?.state !== "cleanup_pending" ||
|
|
2481
|
+
cleanup.role !== "exchange_placeholder" ||
|
|
2482
|
+
typeof cleanup.retainedPath !== "string" ||
|
|
2483
|
+
!cleanupIdentity ||
|
|
2484
|
+
typeof cleanupIdentity.dev !== "string" ||
|
|
2485
|
+
typeof cleanupIdentity.ino !== "string" ||
|
|
2486
|
+
typeof cleanupIdentity.size !== "string" ||
|
|
2487
|
+
typeof cleanupIdentity.mtimeNs !== "string" ||
|
|
2488
|
+
!cleanupTree ||
|
|
2489
|
+
!cleanupAuthorityMatches(
|
|
2490
|
+
{
|
|
2491
|
+
state: "cleanup_pending",
|
|
2492
|
+
role: "exchange_placeholder",
|
|
2493
|
+
retainedPath: cleanup.retainedPath,
|
|
2494
|
+
identity: {
|
|
2495
|
+
dev: BigInt(cleanupIdentity.dev),
|
|
2496
|
+
ino: BigInt(cleanupIdentity.ino),
|
|
2497
|
+
size: BigInt(cleanupIdentity.size),
|
|
2498
|
+
mtimeNs: BigInt(cleanupIdentity.mtimeNs),
|
|
2499
|
+
},
|
|
2500
|
+
tree: cleanupTree,
|
|
2501
|
+
},
|
|
2502
|
+
path.dirname(source.path),
|
|
2503
|
+
)
|
|
2504
|
+
)
|
|
2505
|
+
throw new Error("durability_failed");
|
|
2506
|
+
}
|
|
2507
|
+
|
|
1742
2508
|
const expectedTree = artifactTreeSnapshot(quarantine.tree)!;
|
|
1743
2509
|
const assertPreparedTree = (pathname: string): void => {
|
|
1744
2510
|
validateManagedArtifactTree(pathname);
|
|
1745
2511
|
const observed = native.snapshotDirectoryTree(pathname);
|
|
1746
|
-
if (!observed.ok || !observed.snapshot ||
|
|
2512
|
+
if (!observed.ok || !observed.snapshot || !sameArtifactTree(observed.snapshot, expectedTree))
|
|
1747
2513
|
throw new Error("durability_failed");
|
|
1748
2514
|
};
|
|
1749
2515
|
if (fs.existsSync(quarantine.path)) {
|
|
1750
2516
|
const existing = fs.lstatSync(quarantine.path, { bigint: true });
|
|
1751
2517
|
if (
|
|
1752
|
-
existing.isSymbolicLink()
|
|
1753
|
-
|
|
1754
|
-
existing.dev
|
|
1755
|
-
existing.ino
|
|
1756
|
-
existing.size
|
|
1757
|
-
existing.mtimeNs
|
|
1758
|
-
)
|
|
1759
|
-
|
|
1760
|
-
|
|
2518
|
+
!existing.isSymbolicLink() &&
|
|
2519
|
+
existing.isDirectory() &&
|
|
2520
|
+
existing.dev === BigInt(identity.dev) &&
|
|
2521
|
+
existing.ino === BigInt(identity.ino) &&
|
|
2522
|
+
existing.size === BigInt(identity.size) &&
|
|
2523
|
+
existing.mtimeNs === BigInt(identity.mtimeNs)
|
|
2524
|
+
) {
|
|
2525
|
+
assertPreparedTree(quarantine.path);
|
|
2526
|
+
}
|
|
2527
|
+
// A changed source pathname is independent retained authority. Do not replace
|
|
2528
|
+
// it with the detached original; retain both roots for recovery.
|
|
1761
2529
|
return;
|
|
1762
2530
|
}
|
|
1763
2531
|
assertPreparedTree(quarantine.detachedPath);
|
|
@@ -1771,7 +2539,9 @@ function restorePreparedArtifactRoot(scope: ManagedScope, source: ManagedCandida
|
|
|
1771
2539
|
if (!result.ok) throw new Error("durability_failed");
|
|
1772
2540
|
}
|
|
1773
2541
|
|
|
1774
|
-
function restoreDetachedArtifactRoot(detached: DetachedArtifactRoot): void {
|
|
2542
|
+
function restoreDetachedArtifactRoot(detached: DetachedArtifactRoot, cleanup?: SourceArtifactCleanup): void {
|
|
2543
|
+
if (cleanup && !cleanupAuthorityMatches(cleanup, path.dirname(detached.originalPath)))
|
|
2544
|
+
throw new Error("durability_failed");
|
|
1775
2545
|
const result = native.exactRestore(detached.detachedPath, detached.originalPath, {
|
|
1776
2546
|
...detached.identity,
|
|
1777
2547
|
directory: true,
|
|
@@ -1785,6 +2555,8 @@ async function copyArtifacts(
|
|
|
1785
2555
|
destinationTranscript: string,
|
|
1786
2556
|
manifest: readonly ArtifactManifestEntry[],
|
|
1787
2557
|
lock: ManagedStorageLock,
|
|
2558
|
+
expectedCandidate: ManagedCandidate,
|
|
2559
|
+
expectedIdentity: ResumeSessionIdentity,
|
|
1788
2560
|
sourceRootOverride?: string,
|
|
1789
2561
|
): Promise<void> {
|
|
1790
2562
|
const root = scopeRoot(scope);
|
|
@@ -1793,12 +2565,18 @@ async function copyArtifacts(
|
|
|
1793
2565
|
if (!manifestMatches(sourceTranscript, manifest, sourceRoot)) throw new Error("source_changed");
|
|
1794
2566
|
const destinationRoot = destinationTranscript.slice(0, -6);
|
|
1795
2567
|
for (const entry of manifest) {
|
|
2568
|
+
revalidatePickerConsent(scope, expectedCandidate, expectedIdentity);
|
|
1796
2569
|
lock.assertOwned();
|
|
1797
2570
|
const source = path.join(sourceRoot, entry.path);
|
|
1798
2571
|
const destination = path.join(destinationRoot, entry.path);
|
|
1799
2572
|
if (entry.kind === "directory") {
|
|
1800
|
-
if (entry.path === "")
|
|
1801
|
-
|
|
2573
|
+
if (entry.path === "") {
|
|
2574
|
+
revalidatePickerConsent(scope, expectedCandidate, expectedIdentity);
|
|
2575
|
+
ensureManagedDirectory(destinationRoot, root);
|
|
2576
|
+
} else {
|
|
2577
|
+
revalidatePickerConsent(scope, expectedCandidate, expectedIdentity);
|
|
2578
|
+
ensureManagedDirectory(destination, root);
|
|
2579
|
+
}
|
|
1802
2580
|
continue;
|
|
1803
2581
|
}
|
|
1804
2582
|
const snapshot = captureManagedFileNoFollow(source);
|
|
@@ -1808,6 +2586,7 @@ async function copyArtifacts(
|
|
|
1808
2586
|
)
|
|
1809
2587
|
throw new Error("source_changed");
|
|
1810
2588
|
try {
|
|
2589
|
+
revalidatePickerConsent(scope, expectedCandidate, expectedIdentity);
|
|
1811
2590
|
await copyManagedFileNoReplace(source, destination, snapshot, root);
|
|
1812
2591
|
} catch (error) {
|
|
1813
2592
|
if ((error as Error).message !== "destination_conflict") throw error;
|
|
@@ -1827,7 +2606,7 @@ async function copyArtifacts(
|
|
|
1827
2606
|
function migrationReceipt(
|
|
1828
2607
|
scope: ManagedScope,
|
|
1829
2608
|
lock: ManagedStorageLock,
|
|
1830
|
-
state: "prepared" | "published" | "committed",
|
|
2609
|
+
state: "prepared" | "artifact_detached" | "published" | "committed",
|
|
1831
2610
|
source: ManagedCandidate,
|
|
1832
2611
|
destination: ManagedCandidate | { path: string; sessionId: string; cwd: string },
|
|
1833
2612
|
manifest: readonly ArtifactManifestEntry[],
|
|
@@ -1836,7 +2615,9 @@ function migrationReceipt(
|
|
|
1836
2615
|
detachedPath: string;
|
|
1837
2616
|
identity: DetachedArtifactRoot["identity"];
|
|
1838
2617
|
tree: NativeDirectoryTreeSnapshot;
|
|
2618
|
+
role?: "detached_artifact_root";
|
|
1839
2619
|
},
|
|
2620
|
+
sourceArtifactCleanup?: SourceArtifactCleanup,
|
|
1840
2621
|
): Uint8Array {
|
|
1841
2622
|
lock.assertOwned();
|
|
1842
2623
|
const destinationRecord =
|
|
@@ -1854,12 +2635,20 @@ function migrationReceipt(
|
|
|
1854
2635
|
header: { id: destination.sessionId, cwd: destination.cwd },
|
|
1855
2636
|
};
|
|
1856
2637
|
return new TextEncoder().encode(
|
|
1857
|
-
`${JSON.stringify({ schemaVersion: 2, state, policy: "copy-retain", attemptId: lock.attemptId, scope: scopeDigest(scope.platform, scope.canonicalCwd), source: { path: source.path, sessionId: source.sessionId, header: { id: source.sessionId, cwd: source.cwd }, identity: source.identity, sha256: source.identity.sha256 }, destination: destinationRecord, artifactManifest: manifest, ...(sourceArtifactQuarantine ? { sourceArtifactQuarantine } : {}) }, (_key, value: unknown) => (typeof value === "bigint" ? value.toString() : value))}\n`,
|
|
2638
|
+
`${JSON.stringify({ schemaVersion: 2, state, policy: "copy-retain", attemptId: lock.attemptId, scope: scopeDigest(scope.platform, scope.canonicalCwd), source: { path: source.path, sessionId: source.sessionId, header: { id: source.sessionId, cwd: source.cwd }, identity: source.identity, sha256: source.identity.sha256 }, destination: destinationRecord, artifactManifest: manifest, ...(sourceArtifactQuarantine ? { sourceArtifactQuarantine } : {}), ...(sourceArtifactCleanup ? { sourceArtifactCleanup } : {}) }, (_key, value: unknown) => (typeof value === "bigint" ? value.toString() : value))}\n`,
|
|
1858
2639
|
);
|
|
1859
2640
|
}
|
|
1860
2641
|
|
|
2642
|
+
function detachedReceiptMatches(receipt: string, expected: Uint8Array): boolean {
|
|
2643
|
+
try {
|
|
2644
|
+
return captureManagedFileNoFollow(receipt).bytes.equals(expected);
|
|
2645
|
+
} catch {
|
|
2646
|
+
return false;
|
|
2647
|
+
}
|
|
2648
|
+
}
|
|
2649
|
+
|
|
1861
2650
|
async function removeStagedReceipts(scope: ManagedScope, candidate: ManagedCandidate): Promise<void> {
|
|
1862
|
-
for (const state of ["prepared", "published"] as const) {
|
|
2651
|
+
for (const state of ["prepared", "detached", "published"] as const) {
|
|
1863
2652
|
const pathname = receiptPathFor(scope, candidate, state);
|
|
1864
2653
|
try {
|
|
1865
2654
|
const stat = fs.lstatSync(pathname);
|
|
@@ -1905,6 +2694,7 @@ function receiptPair(scope: ManagedScope, candidate: ManagedCandidate): ManagedC
|
|
|
1905
2694
|
}
|
|
1906
2695
|
|
|
1907
2696
|
function validateCandidateForScope(scope: ManagedScope, candidate: ManagedCandidate): ManagedCandidate | undefined {
|
|
2697
|
+
scopeRoot(scope);
|
|
1908
2698
|
const inspected = inspectCandidate(candidate.path, candidate.provenance);
|
|
1909
2699
|
if ("code" in inspected || !sameCandidate(inspected, candidate)) return undefined;
|
|
1910
2700
|
const identity = identityFor(inspected.cwd);
|
|
@@ -1941,8 +2731,7 @@ export async function reconcileManagedTombstones(scope: ManagedScope): Promise<v
|
|
|
1941
2731
|
if (!observedPending) continue;
|
|
1942
2732
|
if (
|
|
1943
2733
|
cleanupArtifactsRemoved(scope, tombstone, target, observedPending.attempt) &&
|
|
1944
|
-
|
|
1945
|
-
!fs.existsSync(observedPending.plannedTranscriptPath)
|
|
2734
|
+
cleanupRootsAbsent(tombstone, target, observedPending)
|
|
1946
2735
|
) {
|
|
1947
2736
|
fsyncManagedParent(target.path);
|
|
1948
2737
|
await publishCleanupCompleted(scope, tombstone, target, lock);
|
|
@@ -1981,6 +2770,12 @@ export async function reconcileManagedTombstones(scope: ManagedScope): Promise<v
|
|
|
1981
2770
|
? pending.plannedTranscriptPath
|
|
1982
2771
|
: undefined) ??
|
|
1983
2772
|
(fs.existsSync(active.plannedTranscriptPath) ? active.plannedTranscriptPath : undefined),
|
|
2773
|
+
retainedArtifactsSuccessorPath: active.retainedArtifactsSuccessorPath,
|
|
2774
|
+
retainedArtifactsPlaceholderPath: active.retainedArtifactsPlaceholderPath,
|
|
2775
|
+
retainedArtifactsUnknownPath: active.retainedArtifactsUnknownPath,
|
|
2776
|
+
retainedTranscriptSuccessorPath: active.retainedTranscriptSuccessorPath,
|
|
2777
|
+
retainedTranscriptPlaceholderPath: active.retainedTranscriptPlaceholderPath,
|
|
2778
|
+
retainedTranscriptUnknownPath: active.retainedTranscriptUnknownPath,
|
|
1984
2779
|
plannedArtifactsPath: active.plannedArtifactsPath,
|
|
1985
2780
|
plannedTranscriptPath: active.plannedTranscriptPath,
|
|
1986
2781
|
...(cleanupArtifactsRemoved(scope, tombstone, target, pending?.attempt ?? active.attempt)
|
|
@@ -1997,6 +2792,14 @@ export async function reconcileManagedTombstones(scope: ManagedScope): Promise<v
|
|
|
1997
2792
|
transcriptIdentity: target.identity,
|
|
1998
2793
|
plannedArtifactsPath: active.plannedArtifactsPath,
|
|
1999
2794
|
plannedTranscriptPath: active.plannedTranscriptPath,
|
|
2795
|
+
detachedTranscriptPath: active.detachedTranscriptPath ?? observedPending?.detachedTranscriptPath,
|
|
2796
|
+
|
|
2797
|
+
retainedArtifactsSuccessorPath: active.retainedArtifactsSuccessorPath,
|
|
2798
|
+
retainedArtifactsPlaceholderPath: active.retainedArtifactsPlaceholderPath,
|
|
2799
|
+
retainedArtifactsUnknownPath: active.retainedArtifactsUnknownPath,
|
|
2800
|
+
retainedTranscriptSuccessorPath: active.retainedTranscriptSuccessorPath,
|
|
2801
|
+
retainedTranscriptPlaceholderPath: active.retainedTranscriptPlaceholderPath,
|
|
2802
|
+
retainedTranscriptUnknownPath: active.retainedTranscriptUnknownPath,
|
|
2000
2803
|
artifactsRemoved: true,
|
|
2001
2804
|
});
|
|
2002
2805
|
}
|
|
@@ -2017,10 +2820,34 @@ export async function reconcileManagedTombstones(scope: ManagedScope): Promise<v
|
|
|
2017
2820
|
deletion.phase === "transcript"
|
|
2018
2821
|
? deletion.detachedTranscriptPath
|
|
2019
2822
|
: active.detachedTranscriptPath,
|
|
2823
|
+
retainedArtifactsSuccessorPath:
|
|
2824
|
+
deletion.phase === "artifacts"
|
|
2825
|
+
? deletion.retainedSuccessorPath
|
|
2826
|
+
: active.retainedArtifactsSuccessorPath,
|
|
2827
|
+
retainedArtifactsPlaceholderPath:
|
|
2828
|
+
deletion.phase === "artifacts"
|
|
2829
|
+
? deletion.retainedPlaceholderPath
|
|
2830
|
+
: active.retainedArtifactsPlaceholderPath,
|
|
2831
|
+
retainedArtifactsUnknownPath:
|
|
2832
|
+
deletion.phase === "artifacts"
|
|
2833
|
+
? deletion.retainedUnknownPath
|
|
2834
|
+
: active.retainedArtifactsUnknownPath,
|
|
2835
|
+
retainedTranscriptSuccessorPath:
|
|
2836
|
+
deletion.phase === "transcript"
|
|
2837
|
+
? deletion.retainedSuccessorPath
|
|
2838
|
+
: active.retainedTranscriptSuccessorPath,
|
|
2839
|
+
retainedTranscriptPlaceholderPath:
|
|
2840
|
+
deletion.phase === "transcript"
|
|
2841
|
+
? deletion.retainedPlaceholderPath
|
|
2842
|
+
: active.retainedTranscriptPlaceholderPath,
|
|
2843
|
+
retainedTranscriptUnknownPath:
|
|
2844
|
+
deletion.phase === "transcript"
|
|
2845
|
+
? deletion.retainedUnknownPath
|
|
2846
|
+
: active.retainedTranscriptUnknownPath,
|
|
2020
2847
|
},
|
|
2021
2848
|
lock,
|
|
2022
2849
|
);
|
|
2023
|
-
|
|
2850
|
+
continue;
|
|
2024
2851
|
}
|
|
2025
2852
|
fsyncManagedParent(target.path);
|
|
2026
2853
|
await publishCleanupCompleted(scope, tombstone, target, lock);
|
|
@@ -2032,24 +2859,45 @@ export async function reconcileManagedTombstones(scope: ManagedScope): Promise<v
|
|
|
2032
2859
|
}
|
|
2033
2860
|
|
|
2034
2861
|
/** Create the v2 binding and private write protocol directories before managed writes. */
|
|
2035
|
-
export async function prepareManagedSessionScopeForWrite(
|
|
2036
|
-
|
|
2862
|
+
export async function prepareManagedSessionScopeForWrite(
|
|
2863
|
+
scope: ManagedScope,
|
|
2864
|
+
policy: ManagedSessionSecurityPolicy = "default",
|
|
2865
|
+
authority?: ManagedCandidateWriteAuthority,
|
|
2866
|
+
expectedCandidate?: ManagedCandidate,
|
|
2867
|
+
expectedIdentity?: ResumeSessionIdentity,
|
|
2868
|
+
): Promise<ManagedScopeResolution> {
|
|
2869
|
+
if (expectedCandidate && expectedIdentity) revalidatePickerConsent(scope, expectedCandidate, expectedIdentity);
|
|
2870
|
+
if (authority) bindManagedWriteAuthority(scope, authority);
|
|
2871
|
+
const prepared = await ensureManagedScope(scope, policy);
|
|
2037
2872
|
if (prepared.kind === "error") return prepared;
|
|
2038
2873
|
try {
|
|
2039
2874
|
const internal = managedInternalDirectory(scope);
|
|
2040
2875
|
const root = scopeRoot(scope);
|
|
2041
|
-
ensureManagedDirectory(internal, root);
|
|
2042
|
-
ensureManagedDirectory(path.join(internal, MANAGED_LOCKS_DIRECTORY), root);
|
|
2043
|
-
ensureManagedDirectory(path.join(internal, MANAGED_RECEIPTS_DIRECTORY), root);
|
|
2044
|
-
ensureManagedDirectory(path.join(internal, MANAGED_TOMBSTONES_DIRECTORY), root);
|
|
2876
|
+
ensureManagedDirectory(internal, root, policy);
|
|
2877
|
+
ensureManagedDirectory(path.join(internal, MANAGED_LOCKS_DIRECTORY), root, policy);
|
|
2878
|
+
ensureManagedDirectory(path.join(internal, MANAGED_RECEIPTS_DIRECTORY), root, policy);
|
|
2879
|
+
ensureManagedDirectory(path.join(internal, MANAGED_TOMBSTONES_DIRECTORY), root, policy);
|
|
2045
2880
|
await reconcileManagedTombstones(scope);
|
|
2046
2881
|
return { kind: "resolved", scope };
|
|
2047
2882
|
} catch (error) {
|
|
2048
|
-
|
|
2883
|
+
const publication = error instanceof ManagedPublishError ? error : undefined;
|
|
2884
|
+
const message =
|
|
2885
|
+
publication?.classification ??
|
|
2886
|
+
(error instanceof Error ? error.message : "Managed write protocol setup failed.");
|
|
2887
|
+
const code =
|
|
2888
|
+
message === "atomic_unavailable" ||
|
|
2889
|
+
message === "invalid_request" ||
|
|
2890
|
+
message === "durability_failed" ||
|
|
2891
|
+
message === "durability_not_provable"
|
|
2892
|
+
? message
|
|
2893
|
+
: "binding_invalid";
|
|
2049
2894
|
return {
|
|
2050
2895
|
kind: "error",
|
|
2051
|
-
code
|
|
2052
|
-
message
|
|
2896
|
+
code,
|
|
2897
|
+
message,
|
|
2898
|
+
...(publication
|
|
2899
|
+
? { cause: { classification: publication.classification, diagnostic: publication.diagnostic } }
|
|
2900
|
+
: { cause: { classification: code } }),
|
|
2053
2901
|
};
|
|
2054
2902
|
}
|
|
2055
2903
|
}
|
|
@@ -2061,28 +2909,45 @@ export async function prepareManagedSessionScopeForWrite(scope: ManagedScope): P
|
|
|
2061
2909
|
export async function openManagedCandidateForWrite(
|
|
2062
2910
|
scope: ManagedScope,
|
|
2063
2911
|
candidate: ManagedCandidate,
|
|
2064
|
-
|
|
2912
|
+
expectedIdentityOrMigrationPolicy: ResumeSessionIdentity | ManagedMigrationPolicy = "copy-retain",
|
|
2913
|
+
migrationPolicy: ManagedMigrationPolicy = typeof expectedIdentityOrMigrationPolicy === "string"
|
|
2914
|
+
? expectedIdentityOrMigrationPolicy
|
|
2915
|
+
: "copy-retain",
|
|
2916
|
+
authority?: ManagedCandidateWriteAuthority,
|
|
2065
2917
|
): Promise<ManagedOpenCandidateResult> {
|
|
2918
|
+
const expectedIdentity =
|
|
2919
|
+
typeof expectedIdentityOrMigrationPolicy === "string" ? candidate.identity : expectedIdentityOrMigrationPolicy;
|
|
2066
2920
|
if (migrationPolicy === "disabled" && candidate.provenance === "legacy")
|
|
2067
2921
|
return {
|
|
2068
2922
|
kind: "error",
|
|
2069
2923
|
code: "legacy_migration_disabled",
|
|
2070
2924
|
message: "Legacy session migration is disabled for this workspace.",
|
|
2071
2925
|
};
|
|
2072
|
-
|
|
2073
|
-
|
|
2074
|
-
|
|
2075
|
-
|
|
2076
|
-
|
|
2077
|
-
|
|
2078
|
-
|
|
2079
|
-
|
|
2080
|
-
|
|
2926
|
+
let prepared: ManagedScopeResolution;
|
|
2927
|
+
let current: ManagedCandidate;
|
|
2928
|
+
try {
|
|
2929
|
+
prepared = await prepareManagedSessionScopeForWrite(
|
|
2930
|
+
scope,
|
|
2931
|
+
scope.platform === "win32" ? "windows-existing-verify-first" : "default",
|
|
2932
|
+
authority,
|
|
2933
|
+
candidate,
|
|
2934
|
+
expectedIdentity,
|
|
2935
|
+
);
|
|
2936
|
+
if (prepared.kind === "error")
|
|
2937
|
+
return {
|
|
2938
|
+
kind: "error",
|
|
2939
|
+
code: expectedFailure(new Error(prepared.code)),
|
|
2940
|
+
|
|
2941
|
+
message: prepared.message,
|
|
2942
|
+
};
|
|
2943
|
+
current = revalidatePickerConsent(scope, candidate, expectedIdentity);
|
|
2944
|
+
} catch (error) {
|
|
2081
2945
|
return {
|
|
2082
2946
|
kind: "error",
|
|
2083
|
-
code:
|
|
2084
|
-
message:
|
|
2947
|
+
code: expectedFailure(error),
|
|
2948
|
+
message: error instanceof Error ? error.message : "Managed migration failed.",
|
|
2085
2949
|
};
|
|
2950
|
+
}
|
|
2086
2951
|
if (isRetired(scope, current))
|
|
2087
2952
|
return { kind: "error", code: "migration_retired", message: "The managed session has been retired." };
|
|
2088
2953
|
if (current.provenance === "v2") return { kind: "opened", path: current.path, candidate: current, migrated: false };
|
|
@@ -2091,11 +2956,15 @@ export async function openManagedCandidateForWrite(
|
|
|
2091
2956
|
const internal = managedInternalDirectory(scope);
|
|
2092
2957
|
let lock: ManagedStorageLock | undefined;
|
|
2093
2958
|
let detachedArtifacts: DetachedArtifactRoot | undefined;
|
|
2959
|
+
let sourceArtifactCleanup: SourceArtifactCleanup | undefined;
|
|
2960
|
+
|
|
2094
2961
|
try {
|
|
2962
|
+
revalidatePickerConsent(scope, current, expectedIdentity);
|
|
2095
2963
|
lock = await acquireManagedLock(path.join(internal, MANAGED_LOCKS_DIRECTORY), operation, scopeRoot(scope));
|
|
2096
|
-
const
|
|
2097
|
-
|
|
2098
|
-
|
|
2964
|
+
const heldLock = lock;
|
|
2965
|
+
|
|
2966
|
+
const afterLock = revalidatePickerConsent(scope, current, expectedIdentity);
|
|
2967
|
+
|
|
2099
2968
|
const listing = listManagedCandidates(scope);
|
|
2100
2969
|
if (listing.kind === "error") return { kind: "error", code: "binding_invalid", message: listing.message };
|
|
2101
2970
|
const destination = path.join(scope.directoryPath, path.basename(afterLock.path));
|
|
@@ -2107,11 +2976,29 @@ export async function openManagedCandidateForWrite(
|
|
|
2107
2976
|
code: "destination_conflict",
|
|
2108
2977
|
message: "A distinct v2 transcript already owns this session id.",
|
|
2109
2978
|
};
|
|
2979
|
+
|
|
2980
|
+
scopeRoot(scope);
|
|
2981
|
+
revalidatePickerConsent(scope, afterLock, expectedIdentity);
|
|
2110
2982
|
restorePreparedArtifactRoot(scope, afterLock);
|
|
2111
|
-
|
|
2983
|
+
scopeRoot(scope);
|
|
2984
|
+
|
|
2985
|
+
let sourceSnapshot = captureManagedFileNoFollow(afterLock.path);
|
|
2986
|
+
if (
|
|
2987
|
+
sourceSnapshot.identity.dev !== afterLock.identity.dev ||
|
|
2988
|
+
sourceSnapshot.identity.ino !== afterLock.identity.ino ||
|
|
2989
|
+
sourceSnapshot.identity.size !== afterLock.identity.size ||
|
|
2990
|
+
sourceSnapshot.identity.mtimeNs !== afterLock.identity.mtimeNs ||
|
|
2991
|
+
sourceSnapshot.identity.sha256 !== afterLock.identity.sha256 ||
|
|
2992
|
+
sourceSnapshot.bytes.byteLength !== afterLock.identity.size
|
|
2993
|
+
)
|
|
2994
|
+
throw new Error("source_changed");
|
|
2112
2995
|
let manifest: readonly ArtifactManifestEntry[] = [];
|
|
2113
2996
|
const artifactPlan = planArtifactRootForMigration(afterLock.path, operation);
|
|
2114
2997
|
const intendedDestination = { path: destination, sessionId: afterLock.sessionId, cwd: afterLock.cwd };
|
|
2998
|
+
const assertPublicationConsent = (): void => {
|
|
2999
|
+
heldLock.assertOwned();
|
|
3000
|
+
revalidatePickerConsent(scope, afterLock, expectedIdentity);
|
|
3001
|
+
};
|
|
2115
3002
|
if (existing && existing.identity.sha256 !== afterLock.identity.sha256) {
|
|
2116
3003
|
return {
|
|
2117
3004
|
kind: "error",
|
|
@@ -2126,8 +3013,10 @@ export async function openManagedCandidateForWrite(
|
|
|
2126
3013
|
message: "The migration destination already exists without validated ownership.",
|
|
2127
3014
|
};
|
|
2128
3015
|
}
|
|
3016
|
+
|
|
2129
3017
|
const preparedReceipt = receiptPathFor(scope, afterLock, "prepared");
|
|
2130
3018
|
try {
|
|
3019
|
+
revalidatePickerConsent(scope, afterLock, expectedIdentity);
|
|
2131
3020
|
lock.assertOwned();
|
|
2132
3021
|
await publishManagedFileNoReplace(
|
|
2133
3022
|
preparedReceipt,
|
|
@@ -2147,27 +3036,96 @@ export async function openManagedCandidateForWrite(
|
|
|
2147
3036
|
}
|
|
2148
3037
|
: undefined,
|
|
2149
3038
|
),
|
|
2150
|
-
|
|
3039
|
+
assertPublicationConsent,
|
|
3040
|
+
scopeRoot(scope),
|
|
2151
3041
|
);
|
|
2152
3042
|
} catch (error) {
|
|
2153
3043
|
if ((error as Error).message !== "destination_conflict") throw error;
|
|
2154
3044
|
}
|
|
3045
|
+
|
|
3046
|
+
revalidatePickerConsent(scope, afterLock, expectedIdentity);
|
|
2155
3047
|
lock.assertOwned();
|
|
2156
3048
|
if (!preparedReceiptMatches(preparedReceipt, scope, afterLock, intendedDestination, artifactPlan))
|
|
2157
3049
|
throw new Error("durability_failed");
|
|
2158
3050
|
if (artifactPlan && fs.existsSync(artifactPlan.detachedPath)) throw new Error("destination_conflict");
|
|
2159
|
-
|
|
3051
|
+
|
|
3052
|
+
revalidatePickerConsent(scope, afterLock, expectedIdentity);
|
|
3053
|
+
|
|
3054
|
+
scopeRoot(scope);
|
|
3055
|
+
|
|
3056
|
+
if (artifactPlan) {
|
|
3057
|
+
const detached = detachArtifactRootForMigration(artifactPlan);
|
|
3058
|
+
detachedArtifacts = detached.detached;
|
|
3059
|
+
sourceArtifactCleanup = detached.cleanup;
|
|
3060
|
+
const detachedReceipt = receiptPathFor(scope, afterLock, "detached");
|
|
3061
|
+
const detachedRecord = migrationReceipt(
|
|
3062
|
+
scope,
|
|
3063
|
+
lock,
|
|
3064
|
+
"artifact_detached",
|
|
3065
|
+
afterLock,
|
|
3066
|
+
intendedDestination,
|
|
3067
|
+
[],
|
|
3068
|
+
{
|
|
3069
|
+
path: detachedArtifacts.originalPath,
|
|
3070
|
+
detachedPath: detachedArtifacts.detachedPath,
|
|
3071
|
+
identity: detachedArtifacts.identity,
|
|
3072
|
+
tree: detachedArtifacts.tree,
|
|
3073
|
+
role: "detached_artifact_root",
|
|
3074
|
+
},
|
|
3075
|
+
sourceArtifactCleanup,
|
|
3076
|
+
);
|
|
3077
|
+
try {
|
|
3078
|
+
await publishManagedFileNoReplace(
|
|
3079
|
+
detachedReceipt,
|
|
3080
|
+
detachedRecord,
|
|
3081
|
+
assertPublicationConsent,
|
|
3082
|
+
scopeRoot(scope),
|
|
3083
|
+
);
|
|
3084
|
+
} catch (error) {
|
|
3085
|
+
if ((error as Error).message !== "destination_conflict") throw error;
|
|
3086
|
+
}
|
|
3087
|
+
if (!detachedReceiptMatches(detachedReceipt, detachedRecord)) throw new Error("durability_failed");
|
|
3088
|
+
}
|
|
2160
3089
|
manifest = detachedArtifacts ? artifactManifestFromSnapshot(detachedArtifacts.tree) : [];
|
|
2161
|
-
|
|
3090
|
+
|
|
3091
|
+
await copyArtifacts(
|
|
3092
|
+
scope,
|
|
3093
|
+
afterLock.path,
|
|
3094
|
+
destination,
|
|
3095
|
+
manifest,
|
|
3096
|
+
lock,
|
|
3097
|
+
afterLock,
|
|
3098
|
+
expectedIdentity,
|
|
3099
|
+
detachedArtifacts?.detachedPath,
|
|
3100
|
+
);
|
|
3101
|
+
|
|
3102
|
+
if (!existing) {
|
|
3103
|
+
revalidatePickerConsent(scope, afterLock, expectedIdentity);
|
|
3104
|
+
lock.assertOwned();
|
|
3105
|
+
sourceSnapshot = captureManagedFileNoFollow(afterLock.path);
|
|
3106
|
+
if (
|
|
3107
|
+
sourceSnapshot.identity.dev !== afterLock.identity.dev ||
|
|
3108
|
+
sourceSnapshot.identity.ino !== afterLock.identity.ino ||
|
|
3109
|
+
sourceSnapshot.identity.size !== afterLock.identity.size ||
|
|
3110
|
+
sourceSnapshot.identity.mtimeNs !== afterLock.identity.mtimeNs ||
|
|
3111
|
+
sourceSnapshot.identity.sha256 !== afterLock.identity.sha256 ||
|
|
3112
|
+
sourceSnapshot.bytes.byteLength !== afterLock.identity.size
|
|
3113
|
+
)
|
|
3114
|
+
throw new Error("source_changed");
|
|
3115
|
+
}
|
|
3116
|
+
|
|
2162
3117
|
if (!existing) {
|
|
2163
3118
|
try {
|
|
3119
|
+
revalidatePickerConsent(scope, afterLock, expectedIdentity);
|
|
2164
3120
|
lock.assertOwned();
|
|
2165
3121
|
await copyManagedFileNoReplace(afterLock.path, destination, sourceSnapshot, scopeRoot(scope));
|
|
2166
3122
|
} catch (error) {
|
|
2167
3123
|
if ((error as Error).message !== "destination_conflict") throw error;
|
|
2168
3124
|
}
|
|
2169
3125
|
}
|
|
3126
|
+
|
|
2170
3127
|
// Artifact files, directories, and the transcript must be durable before a receipt can grant shadow authority.
|
|
3128
|
+
scopeRoot(scope);
|
|
2171
3129
|
if (manifest.length > 0) fsyncManagedArtifactTree(destination.slice(0, -6));
|
|
2172
3130
|
lock.assertOwned();
|
|
2173
3131
|
const migrated = inspectCandidate(destination, "v2");
|
|
@@ -2179,10 +3137,14 @@ export async function openManagedCandidateForWrite(
|
|
|
2179
3137
|
!manifestMatches(destination, manifest)
|
|
2180
3138
|
)
|
|
2181
3139
|
throw new Error("durability_failed");
|
|
3140
|
+
|
|
2182
3141
|
if (detachedArtifacts) {
|
|
2183
|
-
|
|
3142
|
+
revalidatePickerConsent(scope, afterLock, expectedIdentity);
|
|
3143
|
+
scopeRoot(scope);
|
|
3144
|
+
restoreDetachedArtifactRoot(detachedArtifacts, sourceArtifactCleanup);
|
|
2184
3145
|
detachedArtifacts = undefined;
|
|
2185
3146
|
}
|
|
3147
|
+
|
|
2186
3148
|
const latest = validateCandidateForScope(scope, afterLock);
|
|
2187
3149
|
if (!latest || !sameCandidate(latest, afterLock) || !manifestMatches(afterLock.path, manifest))
|
|
2188
3150
|
return {
|
|
@@ -2190,19 +3152,40 @@ export async function openManagedCandidateForWrite(
|
|
|
2190
3152
|
code: "source_changed",
|
|
2191
3153
|
message: "The legacy candidate or its artifacts changed before migration commit.",
|
|
2192
3154
|
};
|
|
3155
|
+
|
|
2193
3156
|
for (const state of ["published", "committed"] as const) {
|
|
2194
3157
|
const receipt = receiptPathFor(scope, afterLock, state);
|
|
2195
3158
|
try {
|
|
3159
|
+
revalidatePickerConsent(scope, afterLock, expectedIdentity);
|
|
2196
3160
|
lock.assertOwned();
|
|
2197
3161
|
await publishManagedFileNoReplace(
|
|
2198
3162
|
receipt,
|
|
2199
|
-
migrationReceipt(
|
|
2200
|
-
|
|
3163
|
+
migrationReceipt(
|
|
3164
|
+
scope,
|
|
3165
|
+
lock,
|
|
3166
|
+
state,
|
|
3167
|
+
afterLock,
|
|
3168
|
+
migrated,
|
|
3169
|
+
manifest,
|
|
3170
|
+
artifactPlan
|
|
3171
|
+
? {
|
|
3172
|
+
path: artifactPlan.originalPath,
|
|
3173
|
+
detachedPath: artifactPlan.detachedPath,
|
|
3174
|
+
identity: artifactPlan.identity,
|
|
3175
|
+
tree: artifactPlan.tree,
|
|
3176
|
+
role: "detached_artifact_root",
|
|
3177
|
+
}
|
|
3178
|
+
: undefined,
|
|
3179
|
+
sourceArtifactCleanup,
|
|
3180
|
+
),
|
|
3181
|
+
assertPublicationConsent,
|
|
3182
|
+
scopeRoot(scope),
|
|
2201
3183
|
);
|
|
2202
3184
|
} catch (error) {
|
|
2203
3185
|
if ((error as Error).message !== "destination_conflict") throw error;
|
|
2204
3186
|
}
|
|
2205
3187
|
}
|
|
3188
|
+
|
|
2206
3189
|
const receipt = receiptPathFor(scope, afterLock);
|
|
2207
3190
|
lock.assertOwned();
|
|
2208
3191
|
if (!receiptMatches(receipt, afterLock, migrated, scope))
|
|
@@ -2211,6 +3194,9 @@ export async function openManagedCandidateForWrite(
|
|
|
2211
3194
|
code: "durability_failed",
|
|
2212
3195
|
message: "The migration receipt does not bind the copied v2 transcript and artifacts.",
|
|
2213
3196
|
};
|
|
3197
|
+
|
|
3198
|
+
revalidatePickerConsent(scope, afterLock, expectedIdentity);
|
|
3199
|
+
scopeRoot(scope);
|
|
2214
3200
|
await removeStagedReceipts(scope, afterLock);
|
|
2215
3201
|
return {
|
|
2216
3202
|
kind: "opened",
|
|
@@ -2220,7 +3206,7 @@ export async function openManagedCandidateForWrite(
|
|
|
2220
3206
|
};
|
|
2221
3207
|
} catch (error) {
|
|
2222
3208
|
try {
|
|
2223
|
-
if (detachedArtifacts) restoreDetachedArtifactRoot(detachedArtifacts);
|
|
3209
|
+
if (detachedArtifacts) restoreDetachedArtifactRoot(detachedArtifacts, sourceArtifactCleanup);
|
|
2224
3210
|
} catch {
|
|
2225
3211
|
return {
|
|
2226
3212
|
kind: "error",
|
|
@@ -2244,7 +3230,7 @@ export async function deleteManagedSessionCandidate(
|
|
|
2244
3230
|
if (prepared.kind === "error")
|
|
2245
3231
|
return {
|
|
2246
3232
|
kind: "error",
|
|
2247
|
-
code: prepared.code
|
|
3233
|
+
code: expectedFailure(new Error(prepared.code)),
|
|
2248
3234
|
message: prepared.message,
|
|
2249
3235
|
};
|
|
2250
3236
|
const current = validateCandidateForScope(scope, candidate);
|
|
@@ -2304,8 +3290,7 @@ export async function deleteManagedSessionCandidate(
|
|
|
2304
3290
|
if (!observedPending) continue;
|
|
2305
3291
|
if (
|
|
2306
3292
|
cleanupArtifactsRemoved(scope, tombstone, target, observedPending.attempt) &&
|
|
2307
|
-
|
|
2308
|
-
!fs.existsSync(observedPending.plannedTranscriptPath)
|
|
3293
|
+
cleanupRootsAbsent(tombstone, target, observedPending)
|
|
2309
3294
|
) {
|
|
2310
3295
|
fsyncManagedParent(target.path);
|
|
2311
3296
|
await publishCleanupCompleted(scope, tombstone, target, lock);
|
|
@@ -2340,6 +3325,12 @@ export async function deleteManagedSessionCandidate(
|
|
|
2340
3325
|
active.detachedTranscriptPath ??
|
|
2341
3326
|
observedPending?.detachedTranscriptPath ??
|
|
2342
3327
|
(pending && fs.existsSync(pending.plannedTranscriptPath) ? pending.plannedTranscriptPath : undefined),
|
|
3328
|
+
retainedArtifactsSuccessorPath: active.retainedArtifactsSuccessorPath,
|
|
3329
|
+
retainedArtifactsPlaceholderPath: active.retainedArtifactsPlaceholderPath,
|
|
3330
|
+
retainedArtifactsUnknownPath: active.retainedArtifactsUnknownPath,
|
|
3331
|
+
retainedTranscriptSuccessorPath: active.retainedTranscriptSuccessorPath,
|
|
3332
|
+
retainedTranscriptPlaceholderPath: active.retainedTranscriptPlaceholderPath,
|
|
3333
|
+
retainedTranscriptUnknownPath: active.retainedTranscriptUnknownPath,
|
|
2343
3334
|
plannedArtifactsPath: active.plannedArtifactsPath,
|
|
2344
3335
|
plannedTranscriptPath: active.plannedTranscriptPath,
|
|
2345
3336
|
...(cleanupArtifactsRemoved(scope, tombstone, target, pending?.attempt ?? active.attempt)
|
|
@@ -2356,6 +3347,13 @@ export async function deleteManagedSessionCandidate(
|
|
|
2356
3347
|
transcriptIdentity: target.identity,
|
|
2357
3348
|
plannedArtifactsPath: active.plannedArtifactsPath,
|
|
2358
3349
|
plannedTranscriptPath: active.plannedTranscriptPath,
|
|
3350
|
+
detachedTranscriptPath: active.detachedTranscriptPath ?? observedPending?.detachedTranscriptPath,
|
|
3351
|
+
retainedArtifactsSuccessorPath: active.retainedArtifactsSuccessorPath,
|
|
3352
|
+
retainedArtifactsPlaceholderPath: active.retainedArtifactsPlaceholderPath,
|
|
3353
|
+
retainedArtifactsUnknownPath: active.retainedArtifactsUnknownPath,
|
|
3354
|
+
retainedTranscriptSuccessorPath: active.retainedTranscriptSuccessorPath,
|
|
3355
|
+
retainedTranscriptPlaceholderPath: active.retainedTranscriptPlaceholderPath,
|
|
3356
|
+
retainedTranscriptUnknownPath: active.retainedTranscriptUnknownPath,
|
|
2359
3357
|
artifactsRemoved: true,
|
|
2360
3358
|
});
|
|
2361
3359
|
}
|
|
@@ -2371,23 +3369,51 @@ export async function deleteManagedSessionCandidate(
|
|
|
2371
3369
|
)
|
|
2372
3370
|
throw new Error("durability_failed");
|
|
2373
3371
|
const retry = nextCleanupReceipt(target, active);
|
|
2374
|
-
await publishCleanupPending(
|
|
2375
|
-
|
|
2376
|
-
|
|
2377
|
-
|
|
2378
|
-
|
|
2379
|
-
|
|
2380
|
-
|
|
2381
|
-
|
|
2382
|
-
|
|
2383
|
-
|
|
2384
|
-
|
|
2385
|
-
|
|
2386
|
-
|
|
2387
|
-
|
|
2388
|
-
|
|
2389
|
-
|
|
2390
|
-
|
|
3372
|
+
await publishCleanupPending(scope, tombstone, cleanupPendingEvidence(retry, active, deletion), lock);
|
|
3373
|
+
if (deletion.phase === "artifacts") {
|
|
3374
|
+
// A typed retained artifact root proves durable canonical absence; advance
|
|
3375
|
+
// to the transcript phase instead of stalling on the retained evidence.
|
|
3376
|
+
await publishCleanupArtifactsRemoved(scope, tombstone, active, lock);
|
|
3377
|
+
deletion = await new FileSessionStorage().deleteSessionVerified({
|
|
3378
|
+
sessionsRoot: scope.sessionsRoot,
|
|
3379
|
+
transcriptPath: target.path,
|
|
3380
|
+
sessionId: target.sessionId,
|
|
3381
|
+
cwd: target.cwd,
|
|
3382
|
+
transcriptIdentity: target.identity,
|
|
3383
|
+
plannedArtifactsPath: active.plannedArtifactsPath,
|
|
3384
|
+
plannedTranscriptPath: active.plannedTranscriptPath,
|
|
3385
|
+
detachedTranscriptPath: active.detachedTranscriptPath ?? observedPending?.detachedTranscriptPath,
|
|
3386
|
+
retainedArtifactsSuccessorPath: active.retainedArtifactsSuccessorPath,
|
|
3387
|
+
retainedArtifactsPlaceholderPath: active.retainedArtifactsPlaceholderPath,
|
|
3388
|
+
retainedArtifactsUnknownPath: active.retainedArtifactsUnknownPath,
|
|
3389
|
+
retainedTranscriptSuccessorPath: active.retainedTranscriptSuccessorPath,
|
|
3390
|
+
retainedTranscriptPlaceholderPath: active.retainedTranscriptPlaceholderPath,
|
|
3391
|
+
retainedTranscriptUnknownPath: active.retainedTranscriptUnknownPath,
|
|
3392
|
+
artifactsRemoved: true,
|
|
3393
|
+
});
|
|
3394
|
+
if (deletion.kind === "cleanup_pending") {
|
|
3395
|
+
const followup = nextCleanupReceipt(target, active);
|
|
3396
|
+
await publishCleanupPending(
|
|
3397
|
+
scope,
|
|
3398
|
+
tombstone,
|
|
3399
|
+
cleanupPendingEvidence(followup, active, deletion),
|
|
3400
|
+
lock,
|
|
3401
|
+
);
|
|
3402
|
+
if (
|
|
3403
|
+
deletion.phase !== "transcript" ||
|
|
3404
|
+
deletion.detachedTranscriptPath !== active.plannedTranscriptPath
|
|
3405
|
+
)
|
|
3406
|
+
return {
|
|
3407
|
+
kind: "cleanup_pending",
|
|
3408
|
+
tombstonePath: tombstone,
|
|
3409
|
+
phase: deletion.phase,
|
|
3410
|
+
message:
|
|
3411
|
+
"Exact cleanup remains pending because descriptor-bound final deletion is unavailable.",
|
|
3412
|
+
};
|
|
3413
|
+
}
|
|
3414
|
+
}
|
|
3415
|
+
// A typed retained transcript quarantine is durable canonical-absence
|
|
3416
|
+
// evidence — never a terminal byte-deletion claim; complete the receipt.
|
|
2391
3417
|
}
|
|
2392
3418
|
fsyncManagedParent(target.path);
|
|
2393
3419
|
await publishCleanupCompleted(scope, tombstone, target, lock);
|