@gajae-code/coding-agent 0.18.11 → 0.18.12
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 +293 -0
- package/dist/types/async/job-manager.d.ts +7 -3
- package/dist/types/async/result-formatting.d.ts +2 -0
- package/dist/types/autoresearch/git.d.ts +1 -1
- package/dist/types/capability/fs.d.ts +2 -2
- package/dist/types/cli/accounts-cli.d.ts +2 -2
- package/dist/types/cli/doctor/install-repairs.d.ts +4 -1
- package/dist/types/cli/install-activation.d.ts +10 -4
- package/dist/types/cli/public-command-errors.d.ts +1 -0
- package/dist/types/cli/update-cli.d.ts +14 -1
- package/dist/types/cli-ordinary.d.ts +2 -2
- package/dist/types/commands/accounts.d.ts +2 -1
- package/dist/types/commands/migrate.d.ts +1 -1
- package/dist/types/commands/sdk.d.ts +5 -0
- package/dist/types/commit/changelog/index.d.ts +1 -0
- package/dist/types/config/autorouting-tier-map.d.ts +30 -0
- package/dist/types/config/config-hot-reload-trust.d.ts +12 -0
- package/dist/types/config/config-hot-reload.d.ts +39 -0
- package/dist/types/config/file-lock.d.ts +8 -1
- package/dist/types/config/model-discovery-manager.d.ts +2 -0
- package/dist/types/config/model-profile-activation.d.ts +62 -6
- package/dist/types/config/model-registry.d.ts +56 -5
- package/dist/types/config/model-resolver.d.ts +2 -0
- package/dist/types/config/models-config-schema.d.ts +10 -0
- package/dist/types/config/settings-schema.d.ts +46 -3
- package/dist/types/config/settings.d.ts +33 -1
- package/dist/types/dap/config.d.ts +8 -4
- package/dist/types/debug/system-info.d.ts +6 -1
- package/dist/types/discovery/mcp-compat.d.ts +3 -1
- package/dist/types/edit/modes/patch.d.ts +5 -0
- package/dist/types/edit/modes/replace.d.ts +3 -0
- package/dist/types/exec/bash-executor.d.ts +12 -0
- package/dist/types/gjc-runtime/managed-owner-admission.d.ts +3 -0
- package/dist/types/gjc-runtime/session-state-lock.d.ts +2 -0
- package/dist/types/gjc-runtime/state-runtime.d.ts +0 -2
- package/dist/types/migrate/adapters/codex.d.ts +2 -1
- package/dist/types/migrate/adapters/index.d.ts +10 -1
- package/dist/types/migrate/executor.d.ts +3 -1
- package/dist/types/migrate/types.d.ts +15 -3
- package/dist/types/modes/components/settings-selector.d.ts +6 -0
- package/dist/types/modes/controllers/selector-controller.d.ts +2 -0
- package/dist/types/modes/theme/theme.d.ts +1 -0
- package/dist/types/runtime-mcp/content-limits.d.ts +4 -0
- package/dist/types/runtime-mcp/manager.d.ts +7 -0
- package/dist/types/runtime-mcp/plugin-mcp-name-filter.d.ts +22 -0
- package/dist/types/runtime-mcp/tool-bridge.d.ts +4 -0
- package/dist/types/sdk/broker/broker-exit.d.ts +2 -2
- package/dist/types/sdk/broker/broker.d.ts +7 -1
- package/dist/types/sdk/broker/daemon-entry.d.ts +11 -1
- package/dist/types/sdk/broker/doctor-restart.d.ts +1 -1
- package/dist/types/sdk/broker/ensure.d.ts +136 -7
- package/dist/types/sdk/broker/hop.d.ts +36 -0
- package/dist/types/sdk/broker/lifecycle-ledger.d.ts +18 -10
- package/dist/types/sdk/broker/lock-host-identity.d.ts +5 -0
- package/dist/types/sdk/broker/session-index.d.ts +4 -1
- package/dist/types/sdk/bus/chat-command-policy.d.ts +2 -1
- package/dist/types/sdk/bus/index.d.ts +23 -0
- package/dist/types/sdk/bus/reconciliation-store.d.ts +10 -0
- package/dist/types/sdk/bus/telegram-daemon-contract.d.ts +3 -1
- package/dist/types/sdk/cli/session-cli.d.ts +0 -1
- package/dist/types/sdk/host/session-runtime.d.ts +32 -0
- package/dist/types/sdk/prompt-deadline-flush.d.ts +42 -4
- package/dist/types/sdk/prompt-deadline-manager.d.ts +2 -0
- package/dist/types/sdk/protocol/adapter-validation.d.ts +10 -4
- package/dist/types/sdk/reconciliation-extensions.d.ts +1 -1
- package/dist/types/sdk/session-list.d.ts +1 -1
- package/dist/types/session/agent-session.d.ts +58 -2
- package/dist/types/session/internal/managed-session-scope.d.ts +2 -2
- package/dist/types/session/internal/managed-session-storage.d.ts +1 -1
- package/dist/types/session/internal/native-publish-outcome.d.ts +1 -1
- package/dist/types/session/messages.d.ts +5 -1
- package/dist/types/session/session-manager.d.ts +13 -3
- package/dist/types/session/startup-auth-config.d.ts +24 -2
- package/dist/types/session/streaming-output.d.ts +11 -0
- package/dist/types/session/terminal-abort.d.ts +3 -1
- package/dist/types/session-import/service.d.ts +1 -1
- package/dist/types/skill-state/workflow-mutation-guard.d.ts +7 -3
- package/dist/types/slash-commands/helpers/provider-auth-status.d.ts +8 -0
- package/dist/types/task/executor.d.ts +19 -2
- package/dist/types/tools/ast-edit-write-path.d.ts +10 -0
- package/dist/types/tools/atomic-file-write.d.ts +9 -7
- package/dist/types/tools/bash-allowed-prefixes.d.ts +11 -0
- package/dist/types/tools/descriptors.d.ts +2 -0
- package/dist/types/tools/fetch.d.ts +1 -1
- package/dist/types/tools/index.d.ts +2 -0
- package/dist/types/tools/python.d.ts +1 -1
- package/dist/types/tools/report-issue.d.ts +41 -0
- package/dist/types/tools/subagent.d.ts +23 -0
- package/dist/types/web/scrapers/types.d.ts +8 -1
- package/dist/types/web/search/providers/insane.d.ts +7 -1
- package/dist/types/web/search/providers/searxng.d.ts +5 -0
- package/package.json +8 -8
- package/scripts/build-binary.ts +2 -1
- package/scripts/capture-subagent-compact-panel-showcase.ts +105 -0
- package/scripts/generate-docs-index.ts +8 -1
- package/scripts/generate-sdk-operation-inventory.ts +22 -4
- package/src/async/job-manager.ts +356 -56
- package/src/async/result-formatting.ts +40 -0
- package/src/autoresearch/git.ts +9 -2
- package/src/autoresearch/prompts/prompt-setup.md +1 -1
- package/src/capability/fs.ts +27 -19
- package/src/capability/index.ts +2 -2
- package/src/cli/accounts-cli.ts +176 -8
- package/src/cli/auth-broker-cli.ts +21 -11
- package/src/cli/doctor/install-repairs.ts +35 -5
- package/src/cli/fast-help.ts +1 -1
- package/src/cli/install-activation.ts +47 -4
- package/src/cli/migrate-cli.ts +7 -5
- package/src/cli/public-command-errors.ts +4 -0
- package/src/cli/root-flags.ts +2 -2
- package/src/cli/stats-cli.ts +9 -5
- package/src/cli/update-cli.ts +224 -7
- package/src/cli-ordinary.ts +7 -2
- package/src/commands/accounts.ts +10 -5
- package/src/commands/migrate.ts +3 -3
- package/src/commands/sdk.ts +199 -42
- package/src/commands/ultragoal.ts +16 -8
- package/src/commit/changelog/index.ts +39 -1
- package/src/commit/message.ts +10 -3
- package/src/config/autorouting-tier-map.ts +30 -0
- package/src/config/config-hot-reload-trust.ts +148 -0
- package/src/config/config-hot-reload.ts +734 -0
- package/src/config/file-lock.ts +216 -56
- package/src/config/model-bindings-applier.ts +85 -47
- package/src/config/model-discovery-manager.ts +10 -0
- package/src/config/model-profile-activation.ts +716 -113
- package/src/config/model-registry.ts +1334 -158
- package/src/config/model-resolver.ts +3 -1
- package/src/config/models-config-schema.ts +6 -0
- package/src/config/settings-schema.ts +60 -3
- package/src/config/settings.ts +458 -24
- package/src/coordinator-mcp/policy.ts +26 -1
- package/src/crash/sanitize.ts +4 -0
- package/src/customization/import.ts +3 -1
- package/src/dap/config.ts +31 -11
- package/src/dap/defaults.json +1 -1
- package/src/debug/crash-diagnostics.ts +2 -1
- package/src/debug/report-bundle.ts +19 -0
- package/src/debug/system-info.ts +8 -3
- package/src/defaults/gjc/extensions/grok-cli-vendor/src/shared/base-url.ts +21 -7
- package/src/discovery/agents-md.ts +1 -1
- package/src/discovery/helpers.ts +1 -2
- package/src/discovery/mcp-compat.ts +120 -17
- package/src/edit/apply-patch/index.ts +12 -1
- package/src/edit/index.ts +13 -17
- package/src/edit/modes/patch.ts +187 -8
- package/src/edit/modes/replace.ts +136 -0
- package/src/eval/js/context-manager.ts +29 -3
- package/src/eval/js/worker-entry.ts +51 -18
- package/src/eval/py/executor.ts +14 -4
- package/src/eval/py/runner.py +144 -5
- package/src/exec/bash-executor.ts +81 -4
- package/src/exec/bash-shell-guardian.ts +4 -1
- package/src/exec/bash-shell-supervisor.ts +8 -4
- package/src/exec/isolated-shell.ts +5 -1
- package/src/export/html/index.ts +46 -6
- package/src/extensibility/custom-tools/wrapper.ts +3 -1
- package/src/extensibility/extensions/loader.ts +8 -1
- package/src/extensibility/plugins/marketplace/fetcher.ts +2 -2
- package/src/gjc-runtime/gc-runtime.ts +33 -6
- package/src/gjc-runtime/launch-tmux.ts +7 -2
- package/src/gjc-runtime/launch-worktree.test.ts +148 -0
- package/src/gjc-runtime/launch-worktree.ts +101 -14
- package/src/gjc-runtime/managed-owner-admission.ts +46 -2
- package/src/gjc-runtime/session-resolution.ts +38 -6
- package/src/gjc-runtime/session-state-lock.ts +59 -14
- package/src/gjc-runtime/state-runtime.ts +23 -19
- package/src/hooks/native-skill-hook.ts +14 -11
- package/src/internal-urls/docs-index.generated.ts +14 -13
- package/src/internal-urls/local-protocol.ts +142 -8
- package/src/lsp/index.ts +29 -0
- package/src/lsp/workspace-path.ts +23 -0
- package/src/migrate/action-planner.ts +57 -0
- package/src/migrate/adapters/claude-code.ts +23 -4
- package/src/migrate/adapters/codex.ts +22 -4
- package/src/migrate/adapters/index.ts +22 -4
- package/src/migrate/adapters/opencode.ts +6 -1
- package/src/migrate/executor.ts +84 -7
- package/src/migrate/report.ts +19 -2
- package/src/migrate/types.ts +14 -4
- package/src/modes/DESIGN.md +51 -0
- package/src/modes/acp/permission-mode.ts +18 -1
- package/src/modes/components/assistant-message.ts +55 -1
- package/src/modes/components/settings-selector.ts +103 -37
- package/src/modes/controllers/command-controller.ts +38 -2
- package/src/modes/controllers/plan-mode-controller.ts +17 -7
- package/src/modes/controllers/selector-controller.ts +62 -6
- package/src/modes/interactive-mode.ts +68 -0
- package/src/modes/theme/theme.ts +15 -0
- package/src/prompts/tools/debug.md +1 -1
- package/src/prompts/tools/read.md +1 -1
- package/src/prompts/tools/report-issue.md +13 -0
- package/src/prompts/tools/task.md +1 -1
- package/src/runtime-credential-selector.ts +2 -2
- package/src/runtime-mcp/content-limits.ts +7 -0
- package/src/runtime-mcp/manager-pool.test.ts +5 -4
- package/src/runtime-mcp/manager.ts +32 -0
- package/src/runtime-mcp/oauth-flow.ts +32 -6
- package/src/runtime-mcp/plugin-mcp-name-filter.ts +62 -0
- package/src/runtime-mcp/render.ts +16 -1
- package/src/runtime-mcp/tool-bridge.ts +126 -17
- package/src/sdk/broker/broker-exit.ts +8 -5
- package/src/sdk/broker/broker.ts +342 -82
- package/src/sdk/broker/daemon-entry.ts +338 -116
- package/src/sdk/broker/doctor-restart.ts +2 -1
- package/src/sdk/broker/ensure.ts +1143 -265
- package/src/sdk/broker/hop.ts +274 -0
- package/src/sdk/broker/lifecycle-ledger.ts +35 -11
- package/src/sdk/broker/lock-host-identity.ts +29 -0
- package/src/sdk/broker/session-index.ts +62 -12
- package/src/sdk/bus/chat-command-policy.ts +16 -3
- package/src/sdk/bus/index.ts +52 -21
- package/src/sdk/bus/reconciliation-store.ts +201 -8
- package/src/sdk/bus/telegram-daemon-contract.ts +3 -1
- package/src/sdk/cli/session-cli.ts +21 -6
- package/src/sdk/host/session-runtime.test.ts +118 -23
- package/src/sdk/host/session-runtime.ts +352 -47
- package/src/sdk/lifecycle/service.ts +1 -0
- package/src/sdk/prompt-deadline-flush.ts +78 -5
- package/src/sdk/prompt-deadline-manager.ts +68 -14
- package/src/sdk/protocol/adapter-validation.ts +53 -11
- package/src/sdk/protocol/operation-inventory.generated.json +101 -2
- package/src/sdk/reconciliation-extensions.ts +1 -0
- package/src/sdk/session-list.ts +1 -0
- package/src/sdk/session.ts +114 -61
- package/src/session/agent-session.ts +1043 -81
- package/src/session/internal/managed-session-scope.ts +126 -39
- package/src/session/internal/managed-session-storage.ts +14 -11
- package/src/session/internal/native-publish-outcome.ts +5 -1
- package/src/session/messages.ts +47 -6
- package/src/session/session-manager.ts +236 -14
- package/src/session/startup-auth-config.ts +116 -8
- package/src/session/streaming-output.ts +262 -4
- package/src/session/terminal-abort.ts +14 -2
- package/src/session-import/service.ts +1 -0
- package/src/skill-state/workflow-mutation-guard.ts +491 -32
- package/src/slash-commands/builtin-registry.ts +15 -3
- package/src/slash-commands/helpers/credential-switch.ts +10 -5
- package/src/slash-commands/helpers/provider-auth-status.ts +456 -0
- package/src/task/executor.ts +103 -12
- package/src/task/index.ts +389 -175
- package/src/tools/ast-edit-write-path.ts +64 -0
- package/src/tools/ast-edit.ts +18 -1
- package/src/tools/atomic-file-write.ts +46 -15
- package/src/tools/auto-generated-guard.ts +3 -0
- package/src/tools/bash-allowed-prefixes.ts +153 -5
- package/src/tools/bash-interactive.ts +14 -2
- package/src/tools/bash.ts +14 -0
- package/src/tools/debug.ts +7 -6
- package/src/tools/descriptors.test.ts +44 -0
- package/src/tools/descriptors.ts +34 -2
- package/src/tools/fetch.ts +3 -2
- package/src/tools/index.ts +2 -0
- package/src/tools/output-meta.ts +23 -2
- package/src/tools/python.ts +1 -1
- package/src/tools/read.ts +17 -35
- package/src/tools/report-issue.ts +184 -0
- package/src/tools/subagent-render.ts +114 -12
- package/src/tools/subagent.ts +108 -1
- package/src/tools/todo-write.ts +41 -3
- package/src/tools/tool-catalog.generated.ts +31 -3
- package/src/tools/write.ts +35 -3
- package/src/utils/commit-message-generator.ts +9 -2
- package/src/utils/file-mentions.ts +29 -1
- package/src/utils/title-generator.ts +7 -2
- package/src/web/scrapers/index.ts +3 -1
- package/src/web/scrapers/types.ts +8 -0
- package/src/web/scrapers/youtube.ts +3 -2
- package/src/web/search/providers/insane.ts +91 -28
- package/src/web/search/providers/searxng.ts +62 -10
|
@@ -9,6 +9,7 @@ import {
|
|
|
9
9
|
ensureLaunchWorktree,
|
|
10
10
|
evacuatePreWorktreeTarget,
|
|
11
11
|
isReplaceableWorktreeTarget,
|
|
12
|
+
removeOwnedLaunchWorktree,
|
|
12
13
|
restorePreWorktreeGjc,
|
|
13
14
|
} from "./launch-worktree";
|
|
14
15
|
|
|
@@ -246,3 +247,150 @@ describe("acquireTargetLock", () => {
|
|
|
246
247
|
expect(fs.existsSync(path.join(worktreePath, ".git"))).toBe(true);
|
|
247
248
|
});
|
|
248
249
|
});
|
|
250
|
+
|
|
251
|
+
function ownedReceipt(): { created: true; reused: false; createdBranch: false } {
|
|
252
|
+
return { created: true, reused: false, createdBranch: false };
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
function detachedPlan(repoRoot: string, worktreePath: string): GjcLaunchWorktreePlan {
|
|
256
|
+
return {
|
|
257
|
+
enabled: true,
|
|
258
|
+
repoRoot,
|
|
259
|
+
worktreePath,
|
|
260
|
+
detached: true,
|
|
261
|
+
baseRef: "HEAD",
|
|
262
|
+
branchName: null,
|
|
263
|
+
};
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
/** Replace a real worktree directory with a symlink to a directory that copies its `.git` file. */
|
|
267
|
+
function swapWorktreeForForgedGitdir(worktreePath: string, forgeDir: string): void {
|
|
268
|
+
const gitFile = fs.readFileSync(path.join(worktreePath, ".git"), "utf8");
|
|
269
|
+
fs.mkdirSync(forgeDir, { recursive: true });
|
|
270
|
+
fs.writeFileSync(path.join(forgeDir, ".git"), gitFile.endsWith("\n") ? gitFile : `${gitFile}\n`);
|
|
271
|
+
fs.writeFileSync(path.join(forgeDir, "needle.txt"), "KEEP-NEEDLE\n");
|
|
272
|
+
fs.rmSync(worktreePath, { recursive: true, force: true });
|
|
273
|
+
fs.symlinkSync(forgeDir, worktreePath, "dir");
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
describe("removeOwnedLaunchWorktree", () => {
|
|
277
|
+
test("still removes a real directory worktree", () => {
|
|
278
|
+
const repo = initRepo("gjc-rm-real-repo-");
|
|
279
|
+
const bucket = makeTempDir("gjc-rm-real-bucket-");
|
|
280
|
+
const worktreePath = path.join(bucket, "wt");
|
|
281
|
+
git(repo, ["worktree", "add", "-q", "--detach", worktreePath, "HEAD"]);
|
|
282
|
+
fs.writeFileSync(path.join(worktreePath, "needle.txt"), "inside\n");
|
|
283
|
+
|
|
284
|
+
removeOwnedLaunchWorktree(detachedPlan(repo, worktreePath), ownedReceipt());
|
|
285
|
+
|
|
286
|
+
expect(fs.existsSync(worktreePath)).toBe(false);
|
|
287
|
+
expect(countRegisteredWorktrees(repo)).toBe(1);
|
|
288
|
+
});
|
|
289
|
+
|
|
290
|
+
test("still removes a worktree reached through an in-repo directory symlink", () => {
|
|
291
|
+
const repo = initRepo("gjc-rm-inside-link-repo-");
|
|
292
|
+
const inside = path.join(repo, "inside");
|
|
293
|
+
fs.mkdirSync(inside);
|
|
294
|
+
fs.symlinkSync(inside, path.join(repo, ".worktrees"), "dir");
|
|
295
|
+
const worktreePath = path.join(repo, ".worktrees", "wt");
|
|
296
|
+
git(repo, ["worktree", "add", "-q", "--detach", worktreePath, "HEAD"]);
|
|
297
|
+
|
|
298
|
+
removeOwnedLaunchWorktree(detachedPlan(repo, worktreePath), ownedReceipt());
|
|
299
|
+
|
|
300
|
+
expect(fs.existsSync(path.join(inside, "wt"))).toBe(false);
|
|
301
|
+
expect(fs.lstatSync(path.join(repo, ".worktrees")).isSymbolicLink()).toBe(true);
|
|
302
|
+
expect(countRegisteredWorktrees(repo)).toBe(1);
|
|
303
|
+
});
|
|
304
|
+
|
|
305
|
+
test("does not delete the referent when the worktree path is a symlink to a forged gitdir", () => {
|
|
306
|
+
const repo = initRepo("gjc-rm-forge-repo-");
|
|
307
|
+
const bucket = makeTempDir("gjc-rm-forge-bucket-");
|
|
308
|
+
const worktreePath = path.join(bucket, "wt");
|
|
309
|
+
git(repo, ["worktree", "add", "-q", "--detach", worktreePath, "HEAD"]);
|
|
310
|
+
const forge = makeTempDir("gjc-rm-forge-target-");
|
|
311
|
+
swapWorktreeForForgedGitdir(worktreePath, forge);
|
|
312
|
+
|
|
313
|
+
removeOwnedLaunchWorktree(detachedPlan(repo, worktreePath), ownedReceipt());
|
|
314
|
+
|
|
315
|
+
expect(fs.readFileSync(path.join(forge, "needle.txt"), "utf8")).toBe("KEEP-NEEDLE\n");
|
|
316
|
+
expect(fs.lstatSync(path.join(forge, ".git")).isFile()).toBe(true);
|
|
317
|
+
expect(fs.existsSync(worktreePath)).toBe(false);
|
|
318
|
+
});
|
|
319
|
+
|
|
320
|
+
test("still leaves an outside symlink when an in-repo bucket is retargeted", () => {
|
|
321
|
+
const repo = initRepo("gjc-rm-outlink-repo-");
|
|
322
|
+
const bucket = path.join(repo, ".worktrees");
|
|
323
|
+
fs.mkdirSync(bucket);
|
|
324
|
+
const worktreePath = path.join(bucket, "wt");
|
|
325
|
+
git(repo, ["worktree", "add", "-q", "--detach", worktreePath, "HEAD"]);
|
|
326
|
+
fs.rmSync(bucket, { recursive: true, force: true });
|
|
327
|
+
const outside = makeTempDir("gjc-rm-outlink-outside-");
|
|
328
|
+
const vault = makeTempDir("gjc-rm-outlink-vault-");
|
|
329
|
+
fs.writeFileSync(path.join(vault, "needle.txt"), "KEEP-NEEDLE\n");
|
|
330
|
+
const outsideLink = path.join(outside, "wt");
|
|
331
|
+
fs.symlinkSync(vault, outsideLink, "dir");
|
|
332
|
+
fs.symlinkSync(outside, bucket, "dir");
|
|
333
|
+
|
|
334
|
+
removeOwnedLaunchWorktree(detachedPlan(repo, worktreePath), ownedReceipt());
|
|
335
|
+
|
|
336
|
+
expect(fs.lstatSync(outsideLink).isSymbolicLink()).toBe(true);
|
|
337
|
+
expect(fs.readlinkSync(outsideLink)).toBe(vault);
|
|
338
|
+
expect(fs.readFileSync(path.join(vault, "needle.txt"), "utf8")).toBe("KEEP-NEEDLE\n");
|
|
339
|
+
expect(fs.lstatSync(bucket).isSymbolicLink()).toBe(true);
|
|
340
|
+
});
|
|
341
|
+
|
|
342
|
+
test("does not delete a directory outside the repo when an in-repo bucket symlink is retargeted", () => {
|
|
343
|
+
const repo = initRepo("gjc-rm-retarget-repo-");
|
|
344
|
+
const bucket = path.join(repo, ".worktrees");
|
|
345
|
+
fs.mkdirSync(bucket);
|
|
346
|
+
const worktreePath = path.join(bucket, "wt");
|
|
347
|
+
git(repo, ["worktree", "add", "-q", "--detach", worktreePath, "HEAD"]);
|
|
348
|
+
const gitFile = fs.readFileSync(path.join(worktreePath, ".git"), "utf8");
|
|
349
|
+
fs.rmSync(bucket, { recursive: true, force: true });
|
|
350
|
+
const outside = makeTempDir("gjc-rm-retarget-outside-");
|
|
351
|
+
const outsideWt = path.join(outside, "wt");
|
|
352
|
+
fs.mkdirSync(outsideWt);
|
|
353
|
+
fs.writeFileSync(path.join(outsideWt, ".git"), gitFile.endsWith("\n") ? gitFile : `${gitFile}\n`);
|
|
354
|
+
fs.writeFileSync(path.join(outsideWt, "needle.txt"), "KEEP-NEEDLE\n");
|
|
355
|
+
fs.symlinkSync(outside, bucket, "dir");
|
|
356
|
+
|
|
357
|
+
removeOwnedLaunchWorktree(detachedPlan(repo, worktreePath), ownedReceipt());
|
|
358
|
+
|
|
359
|
+
expect(fs.readFileSync(path.join(outsideWt, "needle.txt"), "utf8")).toBe("KEEP-NEEDLE\n");
|
|
360
|
+
expect(fs.lstatSync(bucket).isSymbolicLink()).toBe(true);
|
|
361
|
+
});
|
|
362
|
+
});
|
|
363
|
+
|
|
364
|
+
describe("commitPreWorktreeRestore symlink rollback", () => {
|
|
365
|
+
test("rolls back a symlinked worktree without deleting the forged referent", () => {
|
|
366
|
+
const repo = initRepo("gjc-rb-forge-repo-");
|
|
367
|
+
const bucket = makeTempDir("gjc-rb-forge-bucket-");
|
|
368
|
+
const worktreePath = path.join(bucket, "wt");
|
|
369
|
+
git(repo, ["worktree", "add", "-q", "--detach", worktreePath, "HEAD"]);
|
|
370
|
+
const forge = makeTempDir("gjc-rb-forge-target-");
|
|
371
|
+
swapWorktreeForForgedGitdir(worktreePath, forge);
|
|
372
|
+
const stashPath = path.join(bucket, ".gjc-pre-wt-wt");
|
|
373
|
+
fs.mkdirSync(stashPath, { recursive: true });
|
|
374
|
+
fs.writeFileSync(path.join(stashPath, "launcher.txt"), "config");
|
|
375
|
+
|
|
376
|
+
let message = "";
|
|
377
|
+
try {
|
|
378
|
+
commitPreWorktreeRestore(
|
|
379
|
+
{
|
|
380
|
+
restore: () => {
|
|
381
|
+
throw new Error("overlay exploded");
|
|
382
|
+
},
|
|
383
|
+
stashPath,
|
|
384
|
+
},
|
|
385
|
+
detachedPlan(repo, worktreePath),
|
|
386
|
+
);
|
|
387
|
+
throw new Error("expected commitPreWorktreeRestore to throw");
|
|
388
|
+
} catch (error) {
|
|
389
|
+
message = error instanceof Error ? error.message : String(error);
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
expect(message).toMatch(/worktree_gjc_restore_failed/);
|
|
393
|
+
expect(fs.readFileSync(path.join(forge, "needle.txt"), "utf8")).toBe("KEEP-NEEDLE\n");
|
|
394
|
+
expect(fs.existsSync(path.join(stashPath, "launcher.txt"))).toBe(true);
|
|
395
|
+
});
|
|
396
|
+
});
|
|
@@ -244,9 +244,34 @@ function resolveWorktreeBucket(repoRoot: string): string {
|
|
|
244
244
|
return resolveWorktreeBucketForPath(repoRoot, trustedWorktreeBucketEnv(process.env), os.homedir(), path);
|
|
245
245
|
}
|
|
246
246
|
|
|
247
|
+
function throwIfBucketRealpathEscapesRepository(repoRoot: string, bucketPath: string): void {
|
|
248
|
+
let realBucket: string;
|
|
249
|
+
try {
|
|
250
|
+
realBucket = fs.realpathSync(bucketPath);
|
|
251
|
+
} catch (error) {
|
|
252
|
+
// A missing bucket is created later. A dangling link is classified by inspectBucketDir.
|
|
253
|
+
if (fileSystemErrorCode(error) === "ENOENT") return;
|
|
254
|
+
throw error;
|
|
255
|
+
}
|
|
256
|
+
const relativeReal = path.relative(fs.realpathSync(repoRoot), realBucket);
|
|
257
|
+
if (relativeReal.startsWith(`..${path.sep}`) || relativeReal === ".." || path.isAbsolute(relativeReal)) {
|
|
258
|
+
throw new Error(
|
|
259
|
+
[
|
|
260
|
+
"worktree_bucket_escapes_repository",
|
|
261
|
+
"The GJC launch worktree bucket resolves outside the repository.",
|
|
262
|
+
`Path: ${formatBucketPath(bucketPath)}`,
|
|
263
|
+
"Safe remediation: replace the path with a real directory inside the repository, or set GJC_WORKTREE_DIR to a directory outside the repository. GJC did not create a worktree.",
|
|
264
|
+
].join("\n"),
|
|
265
|
+
);
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
|
|
247
269
|
function ensureRepositoryBucketIgnored(repoRoot: string, bucketPath: string): void {
|
|
248
270
|
const relativeBucket = path.relative(repoRoot, bucketPath);
|
|
271
|
+
// Lexical paths outside the repo are the operator override and stay allowed.
|
|
249
272
|
if (relativeBucket.startsWith(`..${path.sep}`) || relativeBucket === ".." || path.isAbsolute(relativeBucket)) return;
|
|
273
|
+
// git check-ignore accepts a gitignored symlink without looking at its target.
|
|
274
|
+
throwIfBucketRealpathEscapesRepository(repoRoot, bucketPath);
|
|
250
275
|
const ignoreProbe = path.join(relativeBucket, ".gjc-worktree-probe");
|
|
251
276
|
for (const candidate of [relativeBucket, ignoreProbe]) {
|
|
252
277
|
const result = Bun.spawnSync(["git", "check-ignore", "--quiet", "--", candidate], {
|
|
@@ -702,17 +727,87 @@ function tryRestore(restore: () => void): void {
|
|
|
702
727
|
}
|
|
703
728
|
}
|
|
704
729
|
|
|
705
|
-
|
|
706
|
-
|
|
730
|
+
function lexicalPathInsideRepository(repoRoot: string, targetPath: string): boolean {
|
|
731
|
+
const relative = path.relative(path.resolve(repoRoot), path.resolve(targetPath));
|
|
732
|
+
return !(relative.startsWith(`..${path.sep}`) || relative === ".." || path.isAbsolute(relative));
|
|
733
|
+
}
|
|
734
|
+
|
|
735
|
+
function realpathEscapesRepository(repoRoot: string, targetPath: string): boolean {
|
|
736
|
+
let realTarget: string;
|
|
737
|
+
try {
|
|
738
|
+
realTarget = fs.realpathSync(targetPath);
|
|
739
|
+
} catch (error) {
|
|
740
|
+
// A missing path is not an escape. Anything else is not safe to hand to remove --force.
|
|
741
|
+
if (fileSystemErrorCode(error) === "ENOENT") return false;
|
|
742
|
+
return true;
|
|
743
|
+
}
|
|
744
|
+
const relativeReal = path.relative(fs.realpathSync(repoRoot), realTarget);
|
|
745
|
+
return relativeReal.startsWith(`..${path.sep}`) || relativeReal === ".." || path.isAbsolute(relativeReal);
|
|
746
|
+
}
|
|
747
|
+
|
|
748
|
+
function pruneWorktreeRegistration(repoRoot: string): void {
|
|
707
749
|
try {
|
|
708
|
-
runGit(
|
|
750
|
+
runGit(repoRoot, ["worktree", "prune"]);
|
|
709
751
|
} catch {
|
|
752
|
+
// Best-effort. Callers report their own failure.
|
|
753
|
+
}
|
|
754
|
+
}
|
|
755
|
+
|
|
756
|
+
/**
|
|
757
|
+
* Remove a launch worktree this process just created.
|
|
758
|
+
*
|
|
759
|
+
* `git worktree remove --force` follows a symlink and deletes the referent when a
|
|
760
|
+
* copied `.git` gitdir file passes validation. Unlink that link only when its
|
|
761
|
+
* parent stays inside the repository, or when the path is an explicit external
|
|
762
|
+
* worktree. An in-repo path whose parent resolves outside the repository is
|
|
763
|
+
* left untouched. A real directory is still removed with `--force`.
|
|
764
|
+
*/
|
|
765
|
+
function removeCreatedWorktree(repoRoot: string, worktreePath: string): void {
|
|
766
|
+
let stat: fs.Stats | undefined;
|
|
767
|
+
try {
|
|
768
|
+
stat = fs.lstatSync(worktreePath);
|
|
769
|
+
} catch (error) {
|
|
770
|
+
if (fileSystemErrorCode(error) !== "ENOENT") {
|
|
771
|
+
pruneWorktreeRegistration(repoRoot);
|
|
772
|
+
return;
|
|
773
|
+
}
|
|
774
|
+
}
|
|
775
|
+
// A retargeted in-repo bucket can place this path's parent outside the repository.
|
|
776
|
+
// Do not unlink that outside entry. An explicit external worktree is not this case.
|
|
777
|
+
if (
|
|
778
|
+
lexicalPathInsideRepository(repoRoot, worktreePath) &&
|
|
779
|
+
realpathEscapesRepository(repoRoot, path.dirname(worktreePath))
|
|
780
|
+
) {
|
|
781
|
+
pruneWorktreeRegistration(repoRoot);
|
|
782
|
+
return;
|
|
783
|
+
}
|
|
784
|
+
if (stat?.isSymbolicLink()) {
|
|
710
785
|
try {
|
|
711
|
-
|
|
786
|
+
fs.unlinkSync(worktreePath);
|
|
712
787
|
} catch {
|
|
713
|
-
//
|
|
788
|
+
// The link may already be gone.
|
|
714
789
|
}
|
|
790
|
+
pruneWorktreeRegistration(repoRoot);
|
|
791
|
+
return;
|
|
792
|
+
}
|
|
793
|
+
if (
|
|
794
|
+
stat?.isDirectory() &&
|
|
795
|
+
lexicalPathInsideRepository(repoRoot, worktreePath) &&
|
|
796
|
+
realpathEscapesRepository(repoRoot, worktreePath)
|
|
797
|
+
) {
|
|
798
|
+
pruneWorktreeRegistration(repoRoot);
|
|
799
|
+
return;
|
|
715
800
|
}
|
|
801
|
+
try {
|
|
802
|
+
runGit(repoRoot, ["worktree", "remove", "--force", worktreePath]);
|
|
803
|
+
} catch {
|
|
804
|
+
pruneWorktreeRegistration(repoRoot);
|
|
805
|
+
}
|
|
806
|
+
}
|
|
807
|
+
|
|
808
|
+
/** Best-effort removal of a worktree GJC just created, used when a later step must roll it back. */
|
|
809
|
+
function rollbackCreatedWorktree(plan: GjcLaunchWorktreePlan): void {
|
|
810
|
+
removeCreatedWorktree(plan.repoRoot, plan.worktreePath);
|
|
716
811
|
}
|
|
717
812
|
|
|
718
813
|
/**
|
|
@@ -1205,15 +1300,7 @@ export function removeOwnedLaunchWorktree(
|
|
|
1205
1300
|
receipt: { created: boolean; reused: boolean; createdBranch: boolean },
|
|
1206
1301
|
): void {
|
|
1207
1302
|
if (!receipt.created || receipt.reused) return;
|
|
1208
|
-
|
|
1209
|
-
runGit(plan.repoRoot, ["worktree", "remove", "--force", plan.worktreePath]);
|
|
1210
|
-
} catch {
|
|
1211
|
-
try {
|
|
1212
|
-
runGit(plan.repoRoot, ["worktree", "prune"]);
|
|
1213
|
-
} catch {
|
|
1214
|
-
// Proof stays on the ledger; leftover classify is best-effort after abort.
|
|
1215
|
-
}
|
|
1216
|
-
}
|
|
1303
|
+
removeCreatedWorktree(plan.repoRoot, plan.worktreePath);
|
|
1217
1304
|
if (receipt.createdBranch && plan.branchName) {
|
|
1218
1305
|
try {
|
|
1219
1306
|
runGit(plan.repoRoot, ["branch", "-D", plan.branchName]);
|
|
@@ -2,8 +2,10 @@ import { Buffer } from "node:buffer";
|
|
|
2
2
|
import * as crypto from "node:crypto";
|
|
3
3
|
import * as fs from "node:fs/promises";
|
|
4
4
|
import * as path from "node:path";
|
|
5
|
+
import { canonicalEnvKey, type ProjectEnvSnapshot, projectEnvSnapshot } from "@gajae-code/utils/env-file";
|
|
5
6
|
import type { ManagedOwnerBinding, ManagedOwnerSigabrtReceipt } from "./managed-owner-supervisor";
|
|
6
7
|
import { assertSafePathComponent } from "./session-layout";
|
|
8
|
+
import { trustedCoordinatorEnv } from "./sidecar-bootstrap-env";
|
|
7
9
|
import { lifecyclePaths } from "./tmux-owner-isolation";
|
|
8
10
|
import {
|
|
9
11
|
persistUltragoalRecoveryDecision,
|
|
@@ -23,6 +25,48 @@ export const MANAGED_OWNER_PREDECESSOR_GENERATION_ENV = "GJC_MANAGED_OWNER_PREDE
|
|
|
23
25
|
export const MANAGED_OWNER_PREDECESSOR_RUN_ID_ENV = "GJC_MANAGED_OWNER_PREDECESSOR_RUN_ID";
|
|
24
26
|
export const MANAGED_OWNER_PREDECESSOR_INCARNATION_ENV = "GJC_MANAGED_OWNER_PREDECESSOR_INCARNATION";
|
|
25
27
|
export const MANAGED_OWNER_TRANSCRIPT_PATH_ENV = "GJC_MANAGED_OWNER_TRANSCRIPT_PATH";
|
|
28
|
+
const MANAGED_OWNER_SESSION_FILE_ENV = "GJC_SESSION_FILE";
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Operator environment only. A project dotenv value, including a dynamic
|
|
32
|
+
* declaration, must not choose the recovery transcript. A static
|
|
33
|
+
* `GJC_SESSION_FILE` declaration is the same source: the launcher copies it
|
|
34
|
+
* into the transcript variable. The operator bytes are kept, including
|
|
35
|
+
* surrounding whitespace; provenance is not path normalization.
|
|
36
|
+
*/
|
|
37
|
+
function operatorOwnedEnvValue(name: string, raw: string | undefined, snapshot: ProjectEnvSnapshot): string {
|
|
38
|
+
if (!raw) return "";
|
|
39
|
+
const key = canonicalEnvKey(name);
|
|
40
|
+
const declared = snapshot.values[key];
|
|
41
|
+
if (declared !== undefined && (snapshot.dynamic.has(key) || declared === raw)) return "";
|
|
42
|
+
const verdict = trustedCoordinatorEnv(name, { [name]: raw }, snapshot);
|
|
43
|
+
if (verdict === undefined && raw.trim().length > 0) return "";
|
|
44
|
+
return raw;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function trustedManagedOwnerTranscriptPath(env: NodeJS.ProcessEnv = process.env, cwd = process.cwd()): string {
|
|
48
|
+
const snapshot = projectEnvSnapshot(cwd);
|
|
49
|
+
const raw = operatorOwnedEnvValue(
|
|
50
|
+
MANAGED_OWNER_TRANSCRIPT_PATH_ENV,
|
|
51
|
+
env[MANAGED_OWNER_TRANSCRIPT_PATH_ENV],
|
|
52
|
+
snapshot,
|
|
53
|
+
);
|
|
54
|
+
if (!raw) return "";
|
|
55
|
+
const sessionKey = canonicalEnvKey(MANAGED_OWNER_SESSION_FILE_ENV);
|
|
56
|
+
if (snapshot.dynamic.has(sessionKey)) return raw;
|
|
57
|
+
const declared = snapshot.values[sessionKey];
|
|
58
|
+
if (declared !== undefined && raw === declared) return "";
|
|
59
|
+
return raw;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** Session file forwarded to a replacement child. Same provenance rule. */
|
|
63
|
+
export function trustedManagedOwnerLaunchSessionFile(env: NodeJS.ProcessEnv, cwd: string): string {
|
|
64
|
+
return operatorOwnedEnvValue(
|
|
65
|
+
MANAGED_OWNER_SESSION_FILE_ENV,
|
|
66
|
+
env[MANAGED_OWNER_SESSION_FILE_ENV],
|
|
67
|
+
projectEnvSnapshot(cwd),
|
|
68
|
+
);
|
|
69
|
+
}
|
|
26
70
|
|
|
27
71
|
export interface ManagedOwnerRecoveryContext {
|
|
28
72
|
root: string;
|
|
@@ -295,7 +339,7 @@ export async function admitManagedOwnerBeforeCli(): Promise<ManagedOwnerAdmissio
|
|
|
295
339
|
},
|
|
296
340
|
receipt,
|
|
297
341
|
admission,
|
|
298
|
-
transcriptPath:
|
|
342
|
+
transcriptPath: trustedManagedOwnerTranscriptPath(),
|
|
299
343
|
});
|
|
300
344
|
await persistUltragoalRecoveryDecision({
|
|
301
345
|
cwd: process.cwd(),
|
|
@@ -330,7 +374,7 @@ export async function completeManagedOwnerRecovery(
|
|
|
330
374
|
binding: recoveryBinding,
|
|
331
375
|
receipt: context.receipt,
|
|
332
376
|
admission: context.admission,
|
|
333
|
-
transcriptPath:
|
|
377
|
+
transcriptPath: trustedManagedOwnerTranscriptPath(),
|
|
334
378
|
protectedPaths: context.decision.snapshot?.protectedPaths,
|
|
335
379
|
sanctionedDeltas: context.decision.snapshot?.sanctionedDeltas,
|
|
336
380
|
absentArtifacts: context.decision.snapshot?.absentArtifacts,
|
|
@@ -29,6 +29,14 @@ import {
|
|
|
29
29
|
/** Window within which two activity timestamps are treated as an ambiguous tie. */
|
|
30
30
|
export const LATEST_SESSION_TIE_WINDOW_MS = 1000;
|
|
31
31
|
|
|
32
|
+
/**
|
|
33
|
+
* Local writers stamp `Date.now()`. A parsed `updated_at` further ahead than this
|
|
34
|
+
* is planted marker content, not activity, and is not a candidate. Marker mtime
|
|
35
|
+
* is not used in that case: a checkout or touch refreshes mtime without rewriting
|
|
36
|
+
* a future timestamp.
|
|
37
|
+
*/
|
|
38
|
+
const ACTIVITY_FUTURE_SKEW_MS = 2 * 60 * 1000;
|
|
39
|
+
|
|
32
40
|
export interface SessionIdSources {
|
|
33
41
|
/** Raw `--session-id` value: `undefined` = flag absent; `""` = present-but-blank (invalid). */
|
|
34
42
|
flagValue?: string | undefined;
|
|
@@ -46,8 +54,12 @@ export class SessionResolutionError extends Error {
|
|
|
46
54
|
}
|
|
47
55
|
}
|
|
48
56
|
|
|
57
|
+
function isUnsafeResolvedSessionId(sessionId: string): boolean {
|
|
58
|
+
return sessionId === "." || sessionId === ".." || /[/\\]/.test(sessionId);
|
|
59
|
+
}
|
|
60
|
+
|
|
49
61
|
function assertSafeResolvedSessionId(sessionId: string): void {
|
|
50
|
-
if (
|
|
62
|
+
if (isUnsafeResolvedSessionId(sessionId)) {
|
|
51
63
|
throw new SessionResolutionError(
|
|
52
64
|
"session id must be a single path component (no separators or traversal)",
|
|
53
65
|
"unsafe_session",
|
|
@@ -135,8 +147,17 @@ interface SessionCandidate {
|
|
|
135
147
|
* or an ambiguous tie.
|
|
136
148
|
*/
|
|
137
149
|
export async function detectLatestSession(cwd: string): Promise<GjcSessionContext> {
|
|
138
|
-
const
|
|
150
|
+
const found = await collectActiveSessionCandidates(cwd);
|
|
151
|
+
const candidates = found.candidates;
|
|
139
152
|
if (candidates.length === 0) {
|
|
153
|
+
// No safe session was selectable. Keep the unsafe-id error when a crafted
|
|
154
|
+
// directory was the reason nothing could be chosen, matching explicit ids.
|
|
155
|
+
if (found.sawUnsafe) {
|
|
156
|
+
throw new SessionResolutionError(
|
|
157
|
+
"session id must be a single path component (no separators or traversal)",
|
|
158
|
+
"unsafe_session",
|
|
159
|
+
);
|
|
160
|
+
}
|
|
140
161
|
throw new SessionResolutionError(
|
|
141
162
|
"no active GJC session found: pass --session-id or set GJC_SESSION_ID",
|
|
142
163
|
"no_session",
|
|
@@ -156,19 +177,27 @@ export async function detectLatestSession(cwd: string): Promise<GjcSessionContex
|
|
|
156
177
|
return { gjcSessionId: first.gjcSessionId, sessionRoot: first.sessionRoot, source: "latest" };
|
|
157
178
|
}
|
|
158
179
|
|
|
159
|
-
async function collectActiveSessionCandidates(
|
|
180
|
+
async function collectActiveSessionCandidates(
|
|
181
|
+
cwd: string,
|
|
182
|
+
): Promise<{ candidates: SessionCandidate[]; sawUnsafe: boolean }> {
|
|
160
183
|
const root = gjcRoot(cwd);
|
|
161
184
|
let entries: import("node:fs").Dirent[];
|
|
162
185
|
try {
|
|
163
186
|
entries = await fs.readdir(root, { withFileTypes: true });
|
|
164
187
|
} catch {
|
|
165
|
-
return [];
|
|
188
|
+
return { candidates: [], sawUnsafe: false };
|
|
166
189
|
}
|
|
167
190
|
const candidates: SessionCandidate[] = [];
|
|
191
|
+
let sawUnsafe = false;
|
|
168
192
|
for (const entry of entries) {
|
|
169
193
|
if (!entry.isDirectory()) continue;
|
|
170
194
|
const gjcSessionId = sessionIdFromDirName(entry.name);
|
|
171
195
|
if (!gjcSessionId) continue;
|
|
196
|
+
// Skip; throwing here would stop discovery for every other session.
|
|
197
|
+
if (isUnsafeResolvedSessionId(gjcSessionId)) {
|
|
198
|
+
sawUnsafe = true;
|
|
199
|
+
continue;
|
|
200
|
+
}
|
|
172
201
|
assertSafeResolvedSessionId(gjcSessionId);
|
|
173
202
|
const dir = path.join(root, entry.name);
|
|
174
203
|
const activityMs = await readActivityMs(path.join(dir, GJC_SESSION_ACTIVITY_FILE));
|
|
@@ -177,7 +206,7 @@ async function collectActiveSessionCandidates(cwd: string): Promise<SessionCandi
|
|
|
177
206
|
if (activityMs === undefined) continue;
|
|
178
207
|
candidates.push({ gjcSessionId, sessionRoot: dir, activityMs });
|
|
179
208
|
}
|
|
180
|
-
return candidates;
|
|
209
|
+
return { candidates, sawUnsafe };
|
|
181
210
|
}
|
|
182
211
|
|
|
183
212
|
async function readActivityMs(markerPath: string): Promise<number | undefined> {
|
|
@@ -191,7 +220,10 @@ async function readActivityMs(markerPath: string): Promise<number | undefined> {
|
|
|
191
220
|
const parsed = JSON.parse(raw) as { updated_at?: unknown };
|
|
192
221
|
if (typeof parsed.updated_at === "string") {
|
|
193
222
|
const ms = Date.parse(parsed.updated_at);
|
|
194
|
-
if (!Number.isNaN(ms))
|
|
223
|
+
if (!Number.isNaN(ms)) {
|
|
224
|
+
if (ms > Date.now() + ACTIVITY_FUTURE_SKEW_MS) return undefined;
|
|
225
|
+
return ms;
|
|
226
|
+
}
|
|
195
227
|
}
|
|
196
228
|
} catch {
|
|
197
229
|
// fall through to mtime
|
|
@@ -300,9 +300,12 @@ async function repairPendingOwnerRecord(pending: PendingOwnerRepair): Promise<bo
|
|
|
300
300
|
const handle = pending.repairHandle;
|
|
301
301
|
if (!handle || pending.repairBytes === undefined) return true;
|
|
302
302
|
try {
|
|
303
|
-
await writeOwnerBytes(handle, Buffer.from(pending.repairBytes, "utf8"));
|
|
303
|
+
const written = await writeOwnerBytes(handle, Buffer.from(pending.repairBytes, "utf8"), undefined);
|
|
304
304
|
const stat = await handle.stat({ bigint: true });
|
|
305
|
-
|
|
305
|
+
// The rewritten record may have been padded back to its previous length, so the
|
|
306
|
+
// identity that authorizes later cleanup is the bytes that landed, not the bytes
|
|
307
|
+
// requested.
|
|
308
|
+
pending.repairSnapshot = ownerSnapshotFrom(stat, written);
|
|
306
309
|
await handle.close();
|
|
307
310
|
pending.repairHandle = undefined;
|
|
308
311
|
return true;
|
|
@@ -533,6 +536,8 @@ export const SessionStateLockTestHooks: {
|
|
|
533
536
|
afterCurrentOwnerValidation?: (file: string) => void | Promise<void>;
|
|
534
537
|
/** @internal Fault seam immediately before a held owner record rewrite mutates bytes. */
|
|
535
538
|
beforeOwnerRecordRewrite?: (file: string) => void | Promise<void>;
|
|
539
|
+
/** @internal Fault seam immediately before the pre-rewrite fstat on the owner record handle. */
|
|
540
|
+
beforeOwnerRecordPrewriteStat?: (file: string) => void | Promise<void>;
|
|
536
541
|
/** @internal Fault seam immediately before final transition-generation validation. */
|
|
537
542
|
beforeTransitionReleaseLstat?: (transitionDir: string) => void | Promise<void>;
|
|
538
543
|
/** @internal Fault seam immediately before setup generation capture. */
|
|
@@ -1491,13 +1496,21 @@ async function createOwnerLock(
|
|
|
1491
1496
|
// pair names the record this call brought into existence and nothing else.
|
|
1492
1497
|
let created: OpenOwnerIdentity | null = null;
|
|
1493
1498
|
try {
|
|
1494
|
-
await handle.chmod(0o600);
|
|
1495
1499
|
created = await openOwnerIdentity(handle);
|
|
1496
1500
|
await SessionStateLockTestHooks.ownerRecordWriteFault?.(file);
|
|
1501
|
+
// The record's bytes land before anything else is asked of this descriptor. A
|
|
1502
|
+
// freshly created record is empty until they do, and an empty record carries no
|
|
1503
|
+
// `owner_host_id`, so every syscall placed in front of this write is a window in
|
|
1504
|
+
// which a kill leaves debris no later contender may reclaim. `chmod` is the one
|
|
1505
|
+
// that can actually stall — it is a metadata round-trip that a File Provider,
|
|
1506
|
+
// NFS, or CIFS mount may hold for seconds — so it moves behind the write. The
|
|
1507
|
+
// unrestricted mode it corrected for is already excluded by the create's own
|
|
1508
|
+
// mode argument, so the descriptor is never more permissive than 0o600 here.
|
|
1497
1509
|
await handle.writeFile(bytes);
|
|
1498
1510
|
const stat = await handle.stat({ bigint: true });
|
|
1499
1511
|
if (!stat.isFile() || stat.size !== BigInt(bytes.byteLength))
|
|
1500
1512
|
throw new SessionStateLockUnavailableError(new Error("Owner record did not land as a regular file."));
|
|
1513
|
+
await handle.chmod(0o600);
|
|
1501
1514
|
return ownerSnapshotFrom(stat, bytes);
|
|
1502
1515
|
} catch (error) {
|
|
1503
1516
|
// Deliberately BEFORE any close: releasing the descriptor first would free the
|
|
@@ -1520,16 +1533,48 @@ const POSIX_OWNER_REWRITE_FLAGS = POSIX_NOFOLLOW_AVAILABLE
|
|
|
1520
1533
|
? fsSync.constants.O_RDWR | fsSync.constants.O_NONBLOCK | fsSync.constants.O_NOFOLLOW
|
|
1521
1534
|
: undefined;
|
|
1522
1535
|
|
|
1523
|
-
|
|
1524
|
-
|
|
1536
|
+
/**
|
|
1537
|
+
* The one writer of an owner record's bytes: overwrite in place, never shorter.
|
|
1538
|
+
*
|
|
1539
|
+
* `truncate(0)` followed by a write looks atomic and is not. The bytes a truncate
|
|
1540
|
+
* publishes are EMPTY, and an empty record carries no `owner_host_id` at all, so a
|
|
1541
|
+
* process that dies inside that window (a repeated Ctrl-C reaches `postmortem.quit`,
|
|
1542
|
+
* which deliberately performs no lock release) leaves a record with no attributable
|
|
1543
|
+
* owner. `malformedOwnerProvenance` classifies it `unprovenanced`, the reclaim refuses
|
|
1544
|
+
* it, and every later contender fails closed forever: `transition_claim_timeout` with
|
|
1545
|
+
* `claim_or_owner_unreadable`, and coordinator state persistence for that session is
|
|
1546
|
+
* lost for good — the failure class #5159 and #5216 both land on.
|
|
1547
|
+
*
|
|
1548
|
+
* So the replacement is written over the HEAD of the record and the previous byte
|
|
1549
|
+
* length is kept instead of discarded. A JSON document ignores trailing whitespace, so
|
|
1550
|
+
* a padded record is the same record to every reader — `JSON.parse`, `validLockOwner`,
|
|
1551
|
+
* `malformedOwnerProvenance`, and the identity-bound unlink all see exactly the
|
|
1552
|
+
* replacement — while the bytes on disk always carry the full ownership preamble. That
|
|
1553
|
+
* leaves a torn single write (which needs the kernel to short-write a ~200 byte payload)
|
|
1554
|
+
* as the only state the record cannot be attributed from, instead of the guaranteed
|
|
1555
|
+
* empty window between two syscalls.
|
|
1556
|
+
*
|
|
1557
|
+
* @returns the exact bytes now on disk. A padded record's size and SHA-256 are the
|
|
1558
|
+
* padded bytes', so this — not the requested payload — is what identifies the record
|
|
1559
|
+
* for every later capture, comparison, and replay.
|
|
1560
|
+
*/
|
|
1561
|
+
async function writeOwnerBytes(handle: fs.FileHandle, bytes: Buffer, file?: string): Promise<Buffer> {
|
|
1562
|
+
if (file) await SessionStateLockTestHooks.beforeOwnerRecordPrewriteStat?.(file);
|
|
1563
|
+
const previous = await handle.stat({ bigint: true });
|
|
1564
|
+
const previousSize = previous.isFile() === true ? Number(previous.size) : 0;
|
|
1565
|
+
const padding = Math.max(0, previousSize - bytes.byteLength);
|
|
1566
|
+
const payload = padding === 0 ? bytes : Buffer.concat([bytes, Buffer.alloc(padding, 0x20)]);
|
|
1525
1567
|
let offset = 0;
|
|
1526
|
-
while (offset <
|
|
1527
|
-
const { bytesWritten } = await handle.write(
|
|
1568
|
+
while (offset < payload.byteLength) {
|
|
1569
|
+
const { bytesWritten } = await handle.write(payload, offset, payload.byteLength - offset, offset);
|
|
1528
1570
|
if (bytesWritten <= 0) throw new Error("Owner record rewrite made no progress.");
|
|
1529
1571
|
offset += bytesWritten;
|
|
1530
1572
|
}
|
|
1531
|
-
|
|
1573
|
+
// `payload` is never shorter than the record already was, so this can only remove a
|
|
1574
|
+
// tail that outgrew the bytes just written; it can never cut the record itself.
|
|
1575
|
+
await handle.truncate(payload.byteLength);
|
|
1532
1576
|
await handle.sync();
|
|
1577
|
+
return payload;
|
|
1533
1578
|
}
|
|
1534
1579
|
|
|
1535
1580
|
async function rewriteHeldOwnerRecord(
|
|
@@ -1581,15 +1626,15 @@ async function rewriteHeldOwnerRecord(
|
|
|
1581
1626
|
mutationStarted = true;
|
|
1582
1627
|
await SessionStateLockTestHooks.beforeOwnerRecordRewrite?.(file);
|
|
1583
1628
|
rewriteHookPassed = true;
|
|
1584
|
-
await writeOwnerBytes(handle, replacementBytes);
|
|
1629
|
+
const writtenBytes = await writeOwnerBytes(handle, replacementBytes, file);
|
|
1585
1630
|
const canonical = await captureRegularLockOwner(file);
|
|
1586
|
-
// On Windows, an in-place
|
|
1631
|
+
// On Windows, an in-place rewrite can expose refreshed descriptor
|
|
1587
1632
|
// metadata even though the file object is unchanged. The descriptor opened
|
|
1588
1633
|
// before mutation is the stable identity bracket; the path capture still
|
|
1589
1634
|
// proves that the pathname resolves to that same object after the rewrite.
|
|
1590
1635
|
if (!canonical || canonical.dev !== opened.dev || canonical.ino !== opened.ino)
|
|
1591
1636
|
throw new SessionStateLockUnavailableError(new Error("Owner pathname changed after rewrite."));
|
|
1592
|
-
if (canonical.bytes !==
|
|
1637
|
+
if (canonical.bytes !== writtenBytes.toString("utf8")) {
|
|
1593
1638
|
let successor: unknown;
|
|
1594
1639
|
try {
|
|
1595
1640
|
successor = JSON.parse(canonical.bytes);
|
|
@@ -1602,7 +1647,7 @@ async function rewriteHeldOwnerRecord(
|
|
|
1602
1647
|
return canonical;
|
|
1603
1648
|
} catch (error) {
|
|
1604
1649
|
// Keep the descriptor as the repair authority until the held bytes have
|
|
1605
|
-
// been restored. A
|
|
1650
|
+
// been restored. A write/fsync fault may leave a malformed or
|
|
1606
1651
|
// partially committed record; closing first would force recovery to trust
|
|
1607
1652
|
// an unproven pathname and can wedge a successor behind the transition.
|
|
1608
1653
|
let repairNeeded = mutationStarted;
|
|
@@ -1616,9 +1661,9 @@ async function rewriteHeldOwnerRecord(
|
|
|
1616
1661
|
if (repairNeeded) {
|
|
1617
1662
|
let repaired: LockOwnerSnapshot;
|
|
1618
1663
|
try {
|
|
1619
|
-
await writeOwnerBytes(handle, Buffer.from(held.bytes, "utf8"));
|
|
1664
|
+
const restored = await writeOwnerBytes(handle, Buffer.from(held.bytes, "utf8"), file);
|
|
1620
1665
|
const repairedStat = await handle.stat({ bigint: true });
|
|
1621
|
-
repaired = ownerSnapshotFrom(repairedStat,
|
|
1666
|
+
repaired = ownerSnapshotFrom(repairedStat, restored);
|
|
1622
1667
|
} catch (repairError) {
|
|
1623
1668
|
const aggregate = new AggregateError(
|
|
1624
1669
|
[error, repairError],
|