@gajae-code/coding-agent 0.18.12 → 0.19.0
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 +467 -0
- package/dist/types/capability/rule.d.ts +7 -2
- package/dist/types/cli/append-system-prompt.d.ts +6 -0
- package/dist/types/cli/args.d.ts +7 -2
- package/dist/types/cli/customize-doctor-precedence.d.ts +16 -0
- package/dist/types/cli/customize-doctor.d.ts +4 -3
- package/dist/types/cli/public-command-errors.d.ts +1 -0
- package/dist/types/cli/root-flags.d.ts +0 -12
- package/dist/types/cli/update-cli.d.ts +7 -2
- package/dist/types/cli-main.d.ts +0 -6
- package/dist/types/commands/launch.d.ts +0 -6
- package/dist/types/commands/migrate.d.ts +1 -1
- package/dist/types/commands/sdk.d.ts +8 -0
- package/dist/types/commit/map-reduce/index.d.ts +4 -4
- package/dist/types/config/autorouting-tier-map.d.ts +23 -1
- package/dist/types/config/config-hot-reload.d.ts +5 -0
- package/dist/types/config/file-lock.d.ts +44 -2
- package/dist/types/config/model-profile-activation.d.ts +19 -5
- package/dist/types/config/model-registry.d.ts +16 -3
- package/dist/types/config/model-resolver.d.ts +5 -4
- package/dist/types/config/settings-schema.d.ts +11 -1
- package/dist/types/coordinator-mcp/server.d.ts +4 -0
- package/dist/types/customization/import.d.ts +2 -0
- package/dist/types/customization/types.d.ts +1 -1
- package/dist/types/dap/session.d.ts +11 -2
- package/dist/types/discovery/helpers.d.ts +7 -2
- package/dist/types/edit/index.d.ts +5 -0
- package/dist/types/edit/modes/patch.d.ts +9 -0
- package/dist/types/eval/py/runtime.d.ts +9 -0
- package/dist/types/export/ttsr.d.ts +19 -0
- package/dist/types/extensibility/extensions/types.d.ts +5 -1
- package/dist/types/extensibility/slash-commands.d.ts +1 -0
- package/dist/types/gjc-runtime/ralplan-current-final.d.ts +54 -0
- package/dist/types/gjc-runtime/ralplan-runtime.d.ts +5 -0
- package/dist/types/gjc-runtime/tmux-sessions.d.ts +24 -0
- package/dist/types/gjc-runtime/ultragoal-change-set.d.ts +6 -0
- package/dist/types/gjc-runtime/ultragoal-runtime.d.ts +1 -0
- package/dist/types/harness-control-plane/receipt-spool.d.ts +4 -0
- package/dist/types/hindsight/content.d.ts +5 -6
- package/dist/types/internal-urls/types.d.ts +2 -0
- package/dist/types/lsp/clients/swiftlint-client.d.ts +0 -4
- package/dist/types/main.d.ts +23 -18
- package/dist/types/migrate/action-planner.d.ts +10 -1
- package/dist/types/migrate/adapters/codex.d.ts +1 -1
- package/dist/types/migrate/adapters/index.d.ts +35 -3
- package/dist/types/migrate/hook-module.d.ts +23 -0
- package/dist/types/migrate/report.d.ts +2 -1
- package/dist/types/migrate/types.d.ts +69 -2
- package/dist/types/modes/components/agent-dashboard.d.ts +1 -1
- package/dist/types/modes/components/model-selector.d.ts +43 -0
- package/dist/types/modes/prompt-suggestion-controller.d.ts +1 -1
- package/dist/types/modes/utils/host-status.d.ts +15 -11
- package/dist/types/runtime-mcp/project-host-values.d.ts +11 -5
- package/dist/types/sdk/broker/broker-exit.d.ts +1 -1
- package/dist/types/sdk/broker/broker.d.ts +22 -1
- package/dist/types/sdk/broker/ensure.d.ts +5 -2
- package/dist/types/sdk/broker/hop.d.ts +9 -0
- package/dist/types/sdk/broker/lifecycle.d.ts +22 -0
- package/dist/types/sdk/broker/session-index.d.ts +2 -0
- package/dist/types/sdk/broker/transport.d.ts +2 -0
- package/dist/types/sdk/bus/index.d.ts +21 -0
- package/dist/types/sdk/bus/telegram-daemon-contract.d.ts +7 -1
- package/dist/types/sdk/bus/telegram-daemon.d.ts +2 -0
- package/dist/types/sdk/bus/threaded-render.d.ts +2 -1
- package/dist/types/sdk/host/control/operations.d.ts +5 -1
- package/dist/types/sdk/host/events.d.ts +4 -0
- package/dist/types/sdk/host/host.d.ts +2 -1
- package/dist/types/sdk/host/prompt-image-upload.d.ts +43 -0
- package/dist/types/sdk/host/session-runtime.d.ts +8 -8
- package/dist/types/sdk/host/surface-policy.d.ts +2 -1
- package/dist/types/sdk/lifecycle/service.d.ts +12 -0
- package/dist/types/sdk/prompt-status.d.ts +11 -4
- package/dist/types/session/agent-session.d.ts +50 -8
- package/dist/types/session/session-manager.d.ts +16 -0
- package/dist/types/skill-state/workflow-mutation-guard.d.ts +13 -1
- package/dist/types/slash-commands/helpers/mcp.d.ts +5 -0
- package/dist/types/ssh/ssh-executor.d.ts +8 -1
- package/dist/types/system-prompt.d.ts +3 -2
- package/dist/types/task/role-body.d.ts +11 -0
- package/dist/types/tools/awaiting-input.d.ts +28 -0
- package/dist/types/tools/bash-allowed-prefixes.d.ts +6 -0
- package/dist/types/tools/bash-skill-urls.d.ts +4 -0
- package/dist/types/tools/bash.d.ts +7 -0
- package/dist/types/tools/descriptors.d.ts +13 -2
- package/dist/types/tools/fetch.d.ts +6 -0
- package/dist/types/tools/irc.d.ts +5 -3
- package/dist/types/tools/tmux-self-injection-guard.d.ts +2 -0
- package/dist/types/tools/ultragoal-ask-guard.d.ts +1 -1
- package/dist/types/tools/write.d.ts +2 -0
- package/dist/types/utils/file-mentions.d.ts +5 -0
- package/dist/types/utils/image-limits.d.ts +7 -0
- package/dist/types/utils/image-loading.d.ts +0 -1
- package/dist/types/utils/open.d.ts +0 -1
- package/dist/types/utils/pasted-image-loading.d.ts +1 -5
- package/dist/types/utils/prompt-suggestion.d.ts +2 -2
- package/dist/types/utils/shell-snapshot.d.ts +2 -0
- package/dist/types/utils/title-generator.d.ts +13 -3
- package/dist/types/web/search/providers/codex.d.ts +6 -7
- package/package.json +9 -8
- package/scripts/acp-conformance-agent.ts +12 -2
- package/scripts/generate-sdk-operation-inventory.ts +16 -0
- package/src/capability/fs.ts +31 -3
- package/src/capability/rule.ts +11 -2
- package/src/cli/append-system-prompt.ts +12 -0
- package/src/cli/args.ts +11 -7
- package/src/cli/customize-doctor-precedence.ts +31 -0
- package/src/cli/customize-doctor.ts +49 -39
- package/src/cli/fast-help.ts +0 -2
- package/src/cli/migrate-cli.ts +14 -3
- package/src/cli/public-command-errors.ts +2 -0
- package/src/cli/root-flags.ts +0 -2
- package/src/cli/update-cli.ts +247 -6
- package/src/commands/harness.ts +25 -15
- package/src/commands/migrate.ts +3 -3
- package/src/commands/sdk.ts +81 -11
- package/src/commit/agentic/agent.ts +6 -6
- package/src/commit/analysis/conventional.ts +14 -5
- package/src/commit/map-reduce/index.ts +7 -7
- package/src/commit/pipeline.ts +12 -12
- package/src/config/autorouting-tier-map.ts +20 -1
- package/src/config/config-file.ts +106 -3
- package/src/config/config-hot-reload.ts +8 -0
- package/src/config/file-lock.ts +1431 -304
- package/src/config/keybindings.ts +154 -7
- package/src/config/model-profile-activation.ts +177 -39
- package/src/config/model-registry.ts +380 -55
- package/src/config/model-resolver.ts +49 -21
- package/src/config/settings-schema.ts +13 -1
- package/src/config/settings.ts +2 -0
- package/src/coordinator-mcp/server.ts +37 -2
- package/src/crash/sanitize.ts +3 -1
- package/src/customization/import.ts +276 -7
- package/src/customization/inventory.ts +24 -8
- package/src/customization/types.ts +1 -0
- package/src/dap/client.ts +3 -2
- package/src/dap/session.ts +161 -36
- package/src/debug/crash-diagnostics.ts +87 -5
- package/src/debug/report-bundle.ts +71 -2
- package/src/discovery/builtin.ts +4 -3
- package/src/discovery/helpers.ts +24 -14
- package/src/edit/index.ts +191 -5
- package/src/edit/modes/patch.ts +27 -0
- package/src/eval/py/runtime.ts +44 -4
- package/src/exec/non-interactive-env.ts +0 -1
- package/src/export/html/index.ts +60 -20
- package/src/export/ttsr.ts +176 -1
- package/src/extensibility/extensions/loader.ts +2 -0
- package/src/extensibility/extensions/ouroboros-ooo-bridge.ts +63 -1
- package/src/extensibility/extensions/types.ts +5 -1
- package/src/extensibility/hooks/loader.ts +10 -2
- package/src/extensibility/plugins/marketplace/manager.ts +36 -10
- package/src/extensibility/slash-commands.ts +5 -0
- package/src/gjc-runtime/gc-runtime.ts +4 -1
- package/src/gjc-runtime/launch-tmux.ts +23 -13
- package/src/gjc-runtime/ralplan-current-final.ts +294 -0
- package/src/gjc-runtime/ralplan-runtime.ts +95 -1
- package/src/gjc-runtime/state-writer.ts +109 -5
- package/src/gjc-runtime/tmux-sessions.ts +93 -13
- package/src/gjc-runtime/ultragoal-change-set.ts +90 -11
- package/src/gjc-runtime/ultragoal-guard.ts +9 -1
- package/src/gjc-runtime/ultragoal-runtime.ts +15 -5
- package/src/harness-control-plane/finalize.ts +11 -0
- package/src/harness-control-plane/receipt-spool.ts +105 -28
- package/src/harness-control-plane/storage.ts +124 -6
- package/src/hindsight/content.ts +116 -16
- package/src/hindsight/mental-models.ts +22 -10
- package/src/hindsight/state.ts +7 -4
- package/src/hooks/native-skill-hook.ts +3 -1
- package/src/internal-urls/artifact-protocol.ts +3 -1
- package/src/internal-urls/docs-index.generated.ts +14 -14
- package/src/internal-urls/local-protocol.ts +28 -3
- package/src/internal-urls/types.ts +2 -0
- package/src/lsp/client.ts +2 -1
- package/src/lsp/clients/biome-client.ts +2 -0
- package/src/lsp/clients/swiftlint-client.ts +2 -0
- package/src/main.ts +330 -92
- package/src/memories/index.ts +25 -2
- package/src/migrate/action-planner.ts +517 -2
- package/src/migrate/adapters/claude-code.ts +141 -3
- package/src/migrate/adapters/codex.ts +57 -5
- package/src/migrate/adapters/index.ts +240 -5
- package/src/migrate/adapters/opencode.ts +4 -0
- package/src/migrate/executor.ts +141 -3
- package/src/migrate/hook-module.ts +193 -0
- package/src/migrate/report.ts +45 -1
- package/src/migrate/types.ts +73 -3
- package/src/modes/acp/acp-agent.ts +10 -1
- package/src/modes/components/agent-dashboard.ts +20 -4
- package/src/modes/components/customization/customization-dashboard.ts +7 -0
- package/src/modes/components/customization/import-wizard.ts +29 -3
- package/src/modes/components/model-selector.ts +157 -103
- package/src/modes/components/welcome.ts +3 -1
- package/src/modes/controllers/command-controller.ts +271 -16
- package/src/modes/controllers/event-controller.ts +1 -1
- package/src/modes/controllers/extension-ui-controller.ts +31 -1
- package/src/modes/controllers/goal-mode-controller.ts +79 -10
- package/src/modes/controllers/input-controller.ts +2 -0
- package/src/modes/controllers/plan-mode-controller.ts +49 -4
- package/src/modes/controllers/runtime-mcp-command-controller.ts +7 -6
- package/src/modes/controllers/selector-controller.ts +14 -1
- package/src/modes/interactive-mode.ts +55 -4
- package/src/modes/prompt-suggestion-controller.ts +1 -1
- package/src/modes/runtime-init.ts +5 -1
- package/src/modes/utils/host-status.ts +21 -10
- package/src/prompts/agent-fragments/browser-aside.md +2 -2
- package/src/prompts/system/irc-incoming.md +3 -1
- package/src/prompts/tools/irc.md +1 -1
- package/src/runtime-mcp/manager.ts +8 -3
- package/src/runtime-mcp/project-host-values.ts +12 -8
- package/src/runtime-mcp/tool-bridge.ts +68 -4
- package/src/runtime-mcp/transports/stdio.ts +2 -1
- package/src/sdk/broker/broker-exit.ts +2 -0
- package/src/sdk/broker/broker.ts +143 -4
- package/src/sdk/broker/ensure.ts +74 -6
- package/src/sdk/broker/hop.ts +13 -1
- package/src/sdk/broker/lifecycle-ledger.ts +3 -0
- package/src/sdk/broker/lifecycle.ts +102 -6
- package/src/sdk/broker/session-index.ts +29 -4
- package/src/sdk/broker/transport.ts +17 -1
- package/src/sdk/bus/chat-daemon-control.ts +2 -2
- package/src/sdk/bus/index.ts +408 -14
- package/src/sdk/bus/session-control.ts +2 -0
- package/src/sdk/bus/slack-daemon.ts +105 -9
- package/src/sdk/bus/telegram-daemon-contract.ts +7 -1
- package/src/sdk/bus/telegram-daemon.ts +10 -2
- package/src/sdk/bus/threaded-render.ts +20 -1
- package/src/sdk/cli/session-cli.ts +2 -0
- package/src/sdk/host/control/dispatch.ts +13 -1
- package/src/sdk/host/control/operations.ts +10 -1
- package/src/sdk/host/events.ts +67 -4
- package/src/sdk/host/host.ts +8 -2
- package/src/sdk/host/prompt-image-upload.ts +438 -0
- package/src/sdk/host/sdk-surface-parity.test.ts +15 -0
- package/src/sdk/host/session-runtime.test.ts +1537 -24
- package/src/sdk/host/session-runtime.ts +332 -36
- package/src/sdk/host/surface-policy.ts +4 -0
- package/src/sdk/host/websocket-transport.lifecycle.test.ts +90 -2
- package/src/sdk/host/websocket-transport.ts +19 -7
- package/src/sdk/lifecycle/service.ts +14 -1
- package/src/sdk/prompt-status.ts +6 -4
- package/src/sdk/protocol/adapter-validation.ts +28 -1
- package/src/sdk/protocol/operation-inventory.generated.json +160 -0
- package/src/sdk/protocol/operation-registry.ts +33 -3
- package/src/sdk/session.ts +123 -36
- package/src/session/agent-session.ts +1614 -329
- package/src/session/history-storage.ts +182 -12
- package/src/session/internal/managed-session-scope.ts +47 -18
- package/src/session/internal/managed-session-storage.ts +71 -1
- package/src/session/session-manager.ts +90 -5
- package/src/session-import/redact.ts +53 -2
- package/src/skill-state/workflow-mutation-guard.ts +1498 -18
- package/src/slash-commands/builtin-registry.ts +6 -3
- package/src/slash-commands/helpers/mcp.ts +11 -3
- package/src/ssh/ssh-executor.ts +9 -2
- package/src/stt/recorder.ts +26 -9
- package/src/system-prompt.ts +61 -16
- package/src/task/executor.ts +12 -8
- package/src/task/role-body.ts +84 -0
- package/src/task/token-log-mode.test.ts +162 -0
- package/src/task/token-log.ts +91 -2
- package/src/tools/archive-reader.ts +650 -37
- package/src/tools/ask.ts +1 -0
- package/src/tools/ast-grep.ts +7 -2
- package/src/tools/auto-generated-guard.ts +12 -3
- package/src/tools/awaiting-input.ts +114 -0
- package/src/tools/bash-allowed-prefixes.ts +61 -1
- package/src/tools/bash-skill-urls.ts +58 -5
- package/src/tools/bash.ts +99 -26
- package/src/tools/browser/tab-worker.ts +39 -13
- package/src/tools/browser.ts +17 -7
- package/src/tools/cron.ts +10 -1
- package/src/tools/debug.ts +9 -2
- package/src/tools/descriptors.test.ts +4 -4
- package/src/tools/descriptors.ts +16 -4
- package/src/tools/eval.ts +15 -0
- package/src/tools/fetch.ts +90 -2
- package/src/tools/gh.ts +27 -12
- package/src/tools/image-gen.ts +20 -6
- package/src/tools/irc.ts +5 -3
- package/src/tools/monitor.ts +9 -1
- package/src/tools/output-meta.ts +51 -3
- package/src/tools/read.ts +9 -2
- package/src/tools/recipe/runner.ts +23 -12
- package/src/tools/render-utils.ts +2 -2
- package/src/tools/search.ts +3 -2
- package/src/tools/ssh.ts +274 -8
- package/src/tools/subagent.ts +10 -4
- package/src/tools/tmux-self-injection-guard.ts +3301 -122
- package/src/tools/tool-catalog.generated.ts +1 -1
- package/src/tools/ultragoal-ask-guard.ts +13 -1
- package/src/tools/write.ts +7 -0
- package/src/tui/tree-list.ts +15 -1
- package/src/utils/commit-message-generator.ts +3 -2
- package/src/utils/external-editor.ts +21 -7
- package/src/utils/file-mentions.ts +46 -6
- package/src/utils/git.ts +38 -7
- package/src/utils/image-limits.ts +8 -0
- package/src/utils/image-loading.ts +1 -1
- package/src/utils/open.ts +73 -4
- package/src/utils/pasted-image-loading.ts +9 -8
- package/src/utils/prompt-suggestion.ts +5 -4
- package/src/utils/shell-snapshot.ts +9 -1
- package/src/utils/title-generator.ts +65 -20
- package/src/web/search/index.ts +2 -1
- package/src/web/search/providers/codex.ts +29 -32
|
@@ -0,0 +1,294 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Validate the exact current ralplan final that an execution approval may target.
|
|
3
|
+
*
|
|
4
|
+
* The Ultragoal ask guard blocks every `ask` while a goal is incomplete or
|
|
5
|
+
* review-blocked, which is correct for ordinary questions but also deadlocks the
|
|
6
|
+
* one question that can legitimately unblock the work: the execution approval for
|
|
7
|
+
* an already-reviewed ralplan final (issue #6578). Granting that route needs proof
|
|
8
|
+
* that the final being approved is the current one and that its reviewers really
|
|
9
|
+
* passed it — never a caller-declared tag.
|
|
10
|
+
*
|
|
11
|
+
* The proof is reconstructed from what ralplan already persists:
|
|
12
|
+
*
|
|
13
|
+
* - `<session plans>/ralplan/<run-id>/index.jsonl` rows `{stage, stage_n, path, sha256}`
|
|
14
|
+
* - the artifact files those rows name, re-hashed here
|
|
15
|
+
* - the run state (`<session state>/ralplan-state.json`) for run id and phase
|
|
16
|
+
* - the resolved `autoHandoff` target, which must be `off`
|
|
17
|
+
*
|
|
18
|
+
* Anything missing, stale, rewritten, out of order, or outside this session's run
|
|
19
|
+
* directory is a typed refusal. This module only reads and hashes; it never
|
|
20
|
+
* mutates state and never grants anything by itself.
|
|
21
|
+
*/
|
|
22
|
+
import * as fs from "node:fs/promises";
|
|
23
|
+
import * as path from "node:path";
|
|
24
|
+
import { isEnoent } from "@gajae-code/utils";
|
|
25
|
+
import { resolveRalplanAutoHandoff } from "./ralplan-runtime";
|
|
26
|
+
import { modeStatePath, sessionPlansDir } from "./session-layout";
|
|
27
|
+
|
|
28
|
+
/** Stages that open a consensus iteration; a final is only valid after one. */
|
|
29
|
+
const ITERATION_OPENER_STAGES: ReadonlySet<string> = new Set(["planner", "revision"]);
|
|
30
|
+
|
|
31
|
+
/** Reviewer stages whose verdicts must join the same iteration as the final. */
|
|
32
|
+
const REVIEW_LANE_STAGES: readonly string[] = ["architect", "critic"];
|
|
33
|
+
|
|
34
|
+
export interface RalplanIndexRow {
|
|
35
|
+
stage: string;
|
|
36
|
+
stageN: number;
|
|
37
|
+
path: string;
|
|
38
|
+
sha256: string;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export interface RalplanCurrentFinalDescriptor {
|
|
42
|
+
/** Run id the final belongs to. */
|
|
43
|
+
runId: string;
|
|
44
|
+
/** Absolute path of the final artifact. */
|
|
45
|
+
finalPath: string;
|
|
46
|
+
/** sha256 recorded for the final and re-verified against its bytes. */
|
|
47
|
+
finalSha256: string;
|
|
48
|
+
/** sha256 of the iteration-opening planner (or revision) artifact. */
|
|
49
|
+
plannerSha256: string;
|
|
50
|
+
/** Reviewer verdict hashes that joined this iteration. */
|
|
51
|
+
reviewerSha256: Record<string, string>;
|
|
52
|
+
/** Session that owns the run; a foreign session can never be granted. */
|
|
53
|
+
sessionId: string;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export type RalplanCurrentFinalRefusalCode =
|
|
57
|
+
| "missing_state"
|
|
58
|
+
| "run_not_final"
|
|
59
|
+
| "auto_handoff_enabled"
|
|
60
|
+
| "missing_index"
|
|
61
|
+
| "malformed_index"
|
|
62
|
+
| "no_iteration_opener"
|
|
63
|
+
| "final_not_last"
|
|
64
|
+
| "missing_reviewers"
|
|
65
|
+
| "artifact_missing"
|
|
66
|
+
| "artifact_outside_run"
|
|
67
|
+
| "hash_mismatch";
|
|
68
|
+
|
|
69
|
+
export interface RalplanCurrentFinalRefusal {
|
|
70
|
+
code: RalplanCurrentFinalRefusalCode;
|
|
71
|
+
/** Human-readable reason suitable for a guard error message. */
|
|
72
|
+
reason: string;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export type RalplanCurrentFinalResolution =
|
|
76
|
+
| { ok: true; descriptor: RalplanCurrentFinalDescriptor }
|
|
77
|
+
| { ok: false; refusal: RalplanCurrentFinalRefusal };
|
|
78
|
+
|
|
79
|
+
export interface RalplanCurrentFinalInput {
|
|
80
|
+
cwd: string;
|
|
81
|
+
/** Caller session id; the run must belong to it. */
|
|
82
|
+
sessionId: string;
|
|
83
|
+
/** Optional agent dir for settings resolution. */
|
|
84
|
+
agentDir?: string;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Whether the pending `ask` payload selects the ralplan execution-approval gate.
|
|
89
|
+
*
|
|
90
|
+
* This is a selector, not authority: it only decides which validation runs. The
|
|
91
|
+
* descriptor it leads to is the proof.
|
|
92
|
+
*/
|
|
93
|
+
export function selectsRalplanApprovalGate(pendingQuestions: unknown): boolean {
|
|
94
|
+
const questions = (pendingQuestions as { questions?: unknown } | undefined)?.questions;
|
|
95
|
+
if (!Array.isArray(questions)) return false;
|
|
96
|
+
return questions.some(question => {
|
|
97
|
+
if (!question || typeof question !== "object" || Array.isArray(question)) return false;
|
|
98
|
+
const gate = (question as { workflowGate?: unknown }).workflowGate;
|
|
99
|
+
if (!gate || typeof gate !== "object" || Array.isArray(gate)) return false;
|
|
100
|
+
const record = gate as { stage?: unknown; kind?: unknown };
|
|
101
|
+
return record.stage === "ralplan" && record.kind === "approval";
|
|
102
|
+
});
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function refusal(code: RalplanCurrentFinalRefusalCode, reason: string): RalplanCurrentFinalRefusal {
|
|
106
|
+
return { code, reason };
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function parseIndexRow(line: string): RalplanIndexRow | undefined {
|
|
110
|
+
const trimmed = line.trim();
|
|
111
|
+
if (!trimmed) return undefined;
|
|
112
|
+
let parsed: unknown;
|
|
113
|
+
try {
|
|
114
|
+
parsed = JSON.parse(trimmed);
|
|
115
|
+
} catch {
|
|
116
|
+
return undefined;
|
|
117
|
+
}
|
|
118
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return undefined;
|
|
119
|
+
const record = parsed as Record<string, unknown>;
|
|
120
|
+
const { stage, path: artifactPath, sha256 } = record;
|
|
121
|
+
const stageN = record.stage_n;
|
|
122
|
+
if (typeof stage !== "string" || !stage.trim()) return undefined;
|
|
123
|
+
if (typeof stageN !== "number" || !Number.isInteger(stageN) || stageN <= 0) return undefined;
|
|
124
|
+
if (typeof artifactPath !== "string" || !artifactPath.trim()) return undefined;
|
|
125
|
+
if (typeof sha256 !== "string" || !/^[0-9a-f]{64}$/i.test(sha256)) return undefined;
|
|
126
|
+
return { stage: stage.trim(), stageN, path: artifactPath.trim(), sha256: sha256.toLowerCase() };
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
async function readRunState(statePath: string): Promise<{ runId: string; phase: string } | RalplanCurrentFinalRefusal> {
|
|
130
|
+
let text: string;
|
|
131
|
+
try {
|
|
132
|
+
text = await fs.readFile(statePath, "utf-8");
|
|
133
|
+
} catch (error) {
|
|
134
|
+
if (isEnoent(error)) return refusal("missing_state", `no ralplan run state at ${statePath}`);
|
|
135
|
+
return refusal("missing_state", `failed to read ralplan run state at ${statePath}`);
|
|
136
|
+
}
|
|
137
|
+
let parsed: unknown;
|
|
138
|
+
try {
|
|
139
|
+
parsed = JSON.parse(text);
|
|
140
|
+
} catch {
|
|
141
|
+
return refusal("malformed_index", `ralplan run state at ${statePath} is not valid JSON`);
|
|
142
|
+
}
|
|
143
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
|
|
144
|
+
return refusal("malformed_index", `ralplan run state at ${statePath} is not an object`);
|
|
145
|
+
}
|
|
146
|
+
const record = parsed as Record<string, unknown>;
|
|
147
|
+
const runId = typeof record.run_id === "string" ? record.run_id.trim() : "";
|
|
148
|
+
const phase = typeof record.current_phase === "string" ? record.current_phase.trim().toLowerCase() : "";
|
|
149
|
+
if (!runId) return refusal("malformed_index", `ralplan run state at ${statePath} has no run_id`);
|
|
150
|
+
return { runId, phase };
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* Resolve and verify the current ralplan final for `sessionId`.
|
|
155
|
+
*
|
|
156
|
+
* Fails closed: every check returns a refusal instead of degrading to a weaker
|
|
157
|
+
* proof, because the caller uses a success to lift the Ultragoal ask guard.
|
|
158
|
+
*/
|
|
159
|
+
export async function resolveRalplanCurrentFinal(
|
|
160
|
+
input: RalplanCurrentFinalInput,
|
|
161
|
+
): Promise<RalplanCurrentFinalResolution> {
|
|
162
|
+
const sessionId = input.sessionId.trim();
|
|
163
|
+
if (!sessionId) return { ok: false, refusal: refusal("missing_state", "no caller session id") };
|
|
164
|
+
|
|
165
|
+
const state = await readRunState(modeStatePath(input.cwd, sessionId, "ralplan"));
|
|
166
|
+
if (!("runId" in state)) return { ok: false, refusal: state };
|
|
167
|
+
if (state.phase !== "final" && state.phase !== "handoff") {
|
|
168
|
+
return {
|
|
169
|
+
ok: false,
|
|
170
|
+
refusal: refusal("run_not_final", `ralplan run ${state.runId} is in phase "${state.phase}", not final`),
|
|
171
|
+
};
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
// A final that would hand off automatically is not waiting for approval.
|
|
175
|
+
const autoHandoff = await resolveRalplanAutoHandoff(input.cwd, { agentDir: input.agentDir });
|
|
176
|
+
if (autoHandoff.effectiveTarget !== "off") {
|
|
177
|
+
return {
|
|
178
|
+
ok: false,
|
|
179
|
+
refusal: refusal(
|
|
180
|
+
"auto_handoff_enabled",
|
|
181
|
+
`ralplan autoHandoff is "${autoHandoff.effectiveTarget}"; a current final is only approvable while handoff is off`,
|
|
182
|
+
),
|
|
183
|
+
};
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
const runDir = path.join(sessionPlansDir(input.cwd, sessionId), "ralplan", state.runId);
|
|
187
|
+
const indexPath = path.join(runDir, "index.jsonl");
|
|
188
|
+
let indexText: string;
|
|
189
|
+
try {
|
|
190
|
+
indexText = await fs.readFile(indexPath, "utf-8");
|
|
191
|
+
} catch (error) {
|
|
192
|
+
if (isEnoent(error)) return { ok: false, refusal: refusal("missing_index", `no ralplan index at ${indexPath}`) };
|
|
193
|
+
return { ok: false, refusal: refusal("missing_index", `failed to read ralplan index at ${indexPath}`) };
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
const rows: RalplanIndexRow[] = [];
|
|
197
|
+
for (const line of indexText.split("\n")) {
|
|
198
|
+
if (!line.trim()) continue;
|
|
199
|
+
const row = parseIndexRow(line);
|
|
200
|
+
if (!row) {
|
|
201
|
+
return { ok: false, refusal: refusal("malformed_index", `malformed ralplan index row in ${indexPath}`) };
|
|
202
|
+
}
|
|
203
|
+
rows.push(row);
|
|
204
|
+
}
|
|
205
|
+
if (rows.length === 0)
|
|
206
|
+
return { ok: false, refusal: refusal("missing_index", `ralplan index ${indexPath} is empty`) };
|
|
207
|
+
|
|
208
|
+
const finalRow = rows.reduce((latest, row) => (row.stageN > latest.stageN ? row : latest));
|
|
209
|
+
if (finalRow.stage !== "final") {
|
|
210
|
+
return {
|
|
211
|
+
ok: false,
|
|
212
|
+
refusal: refusal("final_not_last", `the newest ralplan stage is "${finalRow.stage}", not "final"`),
|
|
213
|
+
};
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
const earlierRows = rows.filter(row => row.stageN < finalRow.stageN);
|
|
217
|
+
const opener = earlierRows
|
|
218
|
+
.filter(row => ITERATION_OPENER_STAGES.has(row.stage))
|
|
219
|
+
.reduce<RalplanIndexRow | undefined>(
|
|
220
|
+
(latest, row) => (!latest || row.stageN > latest.stageN ? row : latest),
|
|
221
|
+
undefined,
|
|
222
|
+
);
|
|
223
|
+
if (!opener) {
|
|
224
|
+
return {
|
|
225
|
+
ok: false,
|
|
226
|
+
refusal: refusal("no_iteration_opener", "the final has no planner or revision stage before it"),
|
|
227
|
+
};
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
const reviewers: Record<string, string> = {};
|
|
231
|
+
for (const lane of REVIEW_LANE_STAGES) {
|
|
232
|
+
const laneRow = earlierRows
|
|
233
|
+
.filter(row => row.stage === lane && row.stageN > opener.stageN)
|
|
234
|
+
.reduce<RalplanIndexRow | undefined>(
|
|
235
|
+
(latest, row) => (!latest || row.stageN > latest.stageN ? row : latest),
|
|
236
|
+
undefined,
|
|
237
|
+
);
|
|
238
|
+
if (!laneRow) {
|
|
239
|
+
return {
|
|
240
|
+
ok: false,
|
|
241
|
+
refusal: refusal(
|
|
242
|
+
"missing_reviewers",
|
|
243
|
+
`no ${lane} verdict between the ${opener.stage} stage and the final; the final is not reviewed`,
|
|
244
|
+
),
|
|
245
|
+
};
|
|
246
|
+
}
|
|
247
|
+
reviewers[lane] = laneRow.sha256;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
// Hash every referenced artifact and require it to live inside this run.
|
|
251
|
+
for (const row of rows) {
|
|
252
|
+
const resolved = path.resolve(row.path);
|
|
253
|
+
const relative = path.relative(runDir, resolved);
|
|
254
|
+
if (relative.startsWith("..") || path.isAbsolute(relative)) {
|
|
255
|
+
return {
|
|
256
|
+
ok: false,
|
|
257
|
+
refusal: refusal("artifact_outside_run", `ralplan stage artifact ${row.path} is outside ${runDir}`),
|
|
258
|
+
};
|
|
259
|
+
}
|
|
260
|
+
let bytes: Buffer;
|
|
261
|
+
try {
|
|
262
|
+
bytes = await Bun.file(resolved)
|
|
263
|
+
.arrayBuffer()
|
|
264
|
+
.then(buffer => Buffer.from(buffer));
|
|
265
|
+
} catch (error) {
|
|
266
|
+
if (isEnoent(error)) {
|
|
267
|
+
return { ok: false, refusal: refusal("artifact_missing", `ralplan stage artifact ${row.path} is missing`) };
|
|
268
|
+
}
|
|
269
|
+
return { ok: false, refusal: refusal("artifact_missing", `cannot read ralplan stage artifact ${row.path}`) };
|
|
270
|
+
}
|
|
271
|
+
const actual = new Bun.CryptoHasher("sha256").update(bytes).digest("hex");
|
|
272
|
+
if (actual !== row.sha256) {
|
|
273
|
+
return {
|
|
274
|
+
ok: false,
|
|
275
|
+
refusal: refusal(
|
|
276
|
+
"hash_mismatch",
|
|
277
|
+
`ralplan stage artifact ${path.basename(row.path)} does not match its recorded hash`,
|
|
278
|
+
),
|
|
279
|
+
};
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
return {
|
|
284
|
+
ok: true,
|
|
285
|
+
descriptor: {
|
|
286
|
+
runId: state.runId,
|
|
287
|
+
finalPath: path.resolve(finalRow.path),
|
|
288
|
+
finalSha256: finalRow.sha256,
|
|
289
|
+
plannerSha256: opener.sha256,
|
|
290
|
+
reviewerSha256: reviewers,
|
|
291
|
+
sessionId,
|
|
292
|
+
},
|
|
293
|
+
};
|
|
294
|
+
}
|
|
@@ -4,6 +4,7 @@ import * as fssync from "node:fs";
|
|
|
4
4
|
import * as fs from "node:fs/promises";
|
|
5
5
|
import * as path from "node:path";
|
|
6
6
|
import { isSettingsInitialized, Settings } from "../config/settings";
|
|
7
|
+
import { sanitizeImportedString } from "../session-import/redact";
|
|
7
8
|
import { syncSkillActiveState } from "../skill-state/active-state";
|
|
8
9
|
import { buildRalplanHudSummary } from "../skill-state/workflow-hud";
|
|
9
10
|
import { WORKFLOW_STATE_VERSION } from "../skill-state/workflow-state-contract";
|
|
@@ -2319,6 +2320,97 @@ function buildIndexedReviewArtifacts(indexText: string | undefined): Map<string,
|
|
|
2319
2320
|
return map;
|
|
2320
2321
|
}
|
|
2321
2322
|
|
|
2323
|
+
function scrubImported(value: string): string {
|
|
2324
|
+
return sanitizeImportedString(value);
|
|
2325
|
+
}
|
|
2326
|
+
|
|
2327
|
+
/** Scrub, then trim the same way the disposition parser does. An empty result is not an id. */
|
|
2328
|
+
function canonicalDispositionId(value: string): string {
|
|
2329
|
+
const canonical = scrubImported(value).trim();
|
|
2330
|
+
if (canonical === "") {
|
|
2331
|
+
throw new RalplanCommandError(
|
|
2332
|
+
2,
|
|
2333
|
+
"refusing to persist ralplan disposition: credential scrub removed an identifier",
|
|
2334
|
+
);
|
|
2335
|
+
}
|
|
2336
|
+
return canonical;
|
|
2337
|
+
}
|
|
2338
|
+
|
|
2339
|
+
/**
|
|
2340
|
+
* Scrub a validated disposition, then rebuild conflict ids from the scrubbed
|
|
2341
|
+
* finding and target ids. Only `sourceReceipt.path` and `sourceReceipt.sha256`
|
|
2342
|
+
* stay literal. A result that no longer parses is refused instead of stored.
|
|
2343
|
+
*/
|
|
2344
|
+
function scrubDispositionDocument(normalized: string): string {
|
|
2345
|
+
const doc = parseReviewConflictDocument(normalized);
|
|
2346
|
+
const findingIds = new Map(
|
|
2347
|
+
doc.findings.map(finding => [finding.findingId, canonicalDispositionId(finding.findingId)]),
|
|
2348
|
+
);
|
|
2349
|
+
const targetIds = new Map<string, string>();
|
|
2350
|
+
const originalsByScrubbedTarget = new Map<string, string>();
|
|
2351
|
+
for (const finding of doc.findings) {
|
|
2352
|
+
const canonical = canonicalDispositionId(finding.targetId);
|
|
2353
|
+
const prior = originalsByScrubbedTarget.get(canonical);
|
|
2354
|
+
if (prior !== undefined && prior !== finding.targetId) {
|
|
2355
|
+
throw new RalplanCommandError(
|
|
2356
|
+
2,
|
|
2357
|
+
"refusing to persist ralplan disposition: distinct target ids collapse after credential scrub",
|
|
2358
|
+
);
|
|
2359
|
+
}
|
|
2360
|
+
originalsByScrubbedTarget.set(canonical, finding.targetId);
|
|
2361
|
+
targetIds.set(finding.targetId, canonical);
|
|
2362
|
+
}
|
|
2363
|
+
const priorConflicts = doc.conflicts.map(conflict => ({
|
|
2364
|
+
conflictId: conflict.conflictId,
|
|
2365
|
+
targetId: conflict.targetId,
|
|
2366
|
+
findingIds: [...conflict.findingIds] as [string, string],
|
|
2367
|
+
}));
|
|
2368
|
+
for (const finding of doc.findings) {
|
|
2369
|
+
finding.findingId = findingIds.get(finding.findingId) ?? finding.findingId;
|
|
2370
|
+
finding.targetId = targetIds.get(finding.targetId) ?? finding.targetId;
|
|
2371
|
+
finding.evidence = scrubImported(finding.evidence);
|
|
2372
|
+
if (finding.proposedOwner !== undefined) finding.proposedOwner = scrubImported(finding.proposedOwner);
|
|
2373
|
+
}
|
|
2374
|
+
for (const disposition of doc.dispositions) {
|
|
2375
|
+
const prior = priorConflicts.find(conflict => conflict.conflictId === disposition.conflictId);
|
|
2376
|
+
if (!prior) {
|
|
2377
|
+
throw new RalplanCommandError(2, "refusing to persist ralplan disposition: conflict id did not survive scrub");
|
|
2378
|
+
}
|
|
2379
|
+
const targetId = targetIds.get(prior.targetId) ?? canonicalDispositionId(prior.targetId);
|
|
2380
|
+
const mapped = prior.findingIds.map(id => findingIds.get(id) ?? canonicalDispositionId(id));
|
|
2381
|
+
const [left, right] = mapped[0]! <= mapped[1]! ? [mapped[0]!, mapped[1]!] : [mapped[1]!, mapped[0]!];
|
|
2382
|
+
disposition.conflictId = `conflict:${targetId}:${left}:${right}`;
|
|
2383
|
+
disposition.rationale = scrubImported(disposition.rationale);
|
|
2384
|
+
disposition.decisionOwner = scrubImported(disposition.decisionOwner);
|
|
2385
|
+
disposition.affectedSections = disposition.affectedSections.map(section => scrubImported(section));
|
|
2386
|
+
if (disposition.dispositionedAt !== undefined)
|
|
2387
|
+
disposition.dispositionedAt = scrubImported(disposition.dispositionedAt);
|
|
2388
|
+
}
|
|
2389
|
+
const raw = JSON.parse(serializeReviewConflictDocument(doc)) as Record<string, unknown>;
|
|
2390
|
+
delete raw.conflicts;
|
|
2391
|
+
let rewritten: ReturnType<typeof parseReviewConflictDocument>;
|
|
2392
|
+
try {
|
|
2393
|
+
rewritten = parseReviewConflictDocument(`${JSON.stringify(raw, null, 2)}\n`);
|
|
2394
|
+
} catch (error) {
|
|
2395
|
+
throw new RalplanCommandError(
|
|
2396
|
+
2,
|
|
2397
|
+
`refusing to persist ralplan disposition: credential scrub broke the document (${error instanceof Error ? error.message : String(error)})`,
|
|
2398
|
+
);
|
|
2399
|
+
}
|
|
2400
|
+
return serializeReviewConflictDocument(rewritten);
|
|
2401
|
+
}
|
|
2402
|
+
|
|
2403
|
+
/**
|
|
2404
|
+
* Credential scrub for a ralplan stage body. The result is what
|
|
2405
|
+
* `gjc ralplan --write` hashes and stores.
|
|
2406
|
+
*/
|
|
2407
|
+
export function scrubRalplanPersistedBody(stage: string, content: string): string {
|
|
2408
|
+
const normalized = content.endsWith("\n") ? content : `${content}\n`;
|
|
2409
|
+
if (stage === "disposition") return scrubDispositionDocument(normalized);
|
|
2410
|
+
const scrubbed = sanitizeImportedString(normalized);
|
|
2411
|
+
return scrubbed.endsWith("\n") ? scrubbed : `${scrubbed}\n`;
|
|
2412
|
+
}
|
|
2413
|
+
|
|
2322
2414
|
async function handleArtifactWrite(
|
|
2323
2415
|
args: readonly string[],
|
|
2324
2416
|
cwd: string,
|
|
@@ -2380,7 +2472,9 @@ async function handleArtifactWrite(
|
|
|
2380
2472
|
resolved.stage === "disposition"
|
|
2381
2473
|
? normalizeDispositionArtifact(resolved.artifact, resolved.stageN, indexLoad.rawText)
|
|
2382
2474
|
: resolved.artifact;
|
|
2383
|
-
|
|
2475
|
+
// Scrub before the content hash. The ledger, dedupe, and pending-approval copy
|
|
2476
|
+
// all trust this digest, so it has to name the bytes that actually land on disk.
|
|
2477
|
+
const content = scrubRalplanPersistedBody(resolved.stage, normalizedArtifact);
|
|
2384
2478
|
const sha256 = createHash("sha256").update(content).digest("hex");
|
|
2385
2479
|
|
|
2386
2480
|
// Duplicate-write guard: a second `--write` for the same (stage, stage_n) must not
|
|
@@ -1158,10 +1158,113 @@ export async function updateJsonAtomic<T = unknown>(
|
|
|
1158
1158
|
);
|
|
1159
1159
|
}
|
|
1160
1160
|
|
|
1161
|
+
async function assertAppendRegularFile(filePath: string): Promise<void> {
|
|
1162
|
+
try {
|
|
1163
|
+
const stat = await fs.lstat(filePath);
|
|
1164
|
+
if (stat.isSymbolicLink() || !stat.isFile()) throw new Error("state append path is not a regular file");
|
|
1165
|
+
} catch (error) {
|
|
1166
|
+
if (!isErrno(error, "ENOENT")) throw error;
|
|
1167
|
+
}
|
|
1168
|
+
}
|
|
1169
|
+
|
|
1170
|
+
/**
|
|
1171
|
+
* Append bytes the same way `appendAuditEntry` opens its log: mode 0600, `O_NOFOLLOW`
|
|
1172
|
+
* when the path already exists, and `O_EXCL` when it does not. An existing mode 0666
|
|
1173
|
+
* file, and any other group or other permission bits, is tightened to 0600 before the write.
|
|
1174
|
+
*/
|
|
1175
|
+
async function tightenReadOnlyAppendMode(filePath: string): Promise<void> {
|
|
1176
|
+
if (process.platform === "win32") return;
|
|
1177
|
+
const noFollow = fsConstants.O_NOFOLLOW ?? 0;
|
|
1178
|
+
const initialStat = await fs.lstat(filePath, { bigint: true });
|
|
1179
|
+
if (initialStat.isSymbolicLink() || !initialStat.isFile())
|
|
1180
|
+
throw new Error("state append path is not a regular file");
|
|
1181
|
+
// A duplicate idempotent append must not require a writable file. Owner-only
|
|
1182
|
+
// modes such as 0400 stay as they are; only group or other bits are tightened.
|
|
1183
|
+
const handle = await fs.open(filePath, fsConstants.O_RDONLY | noFollow);
|
|
1184
|
+
try {
|
|
1185
|
+
const openedStat = await handle.stat({ bigint: true });
|
|
1186
|
+
const pathStat = await fs.lstat(filePath, { bigint: true });
|
|
1187
|
+
const sameObject = (left: nodeFs.BigIntStats, right: nodeFs.BigIntStats) =>
|
|
1188
|
+
left.dev === right.dev && left.ino === right.ino && left.nlink === right.nlink;
|
|
1189
|
+
if (
|
|
1190
|
+
openedStat.isSymbolicLink() ||
|
|
1191
|
+
!openedStat.isFile() ||
|
|
1192
|
+
pathStat.isSymbolicLink() ||
|
|
1193
|
+
!pathStat.isFile() ||
|
|
1194
|
+
!sameObject(initialStat, openedStat) ||
|
|
1195
|
+
!sameObject(openedStat, pathStat)
|
|
1196
|
+
)
|
|
1197
|
+
throw new Error("state append path identity changed before append");
|
|
1198
|
+
if ((Number(openedStat.mode) & 0o077) === 0) return;
|
|
1199
|
+
await handle.chmod(0o600);
|
|
1200
|
+
const tightened = await handle.stat({ bigint: true });
|
|
1201
|
+
if ((Number(tightened.mode) & 0o777) !== 0o600)
|
|
1202
|
+
throw new Error("state append path could not be tightened to mode 0600");
|
|
1203
|
+
} finally {
|
|
1204
|
+
await handle.close();
|
|
1205
|
+
}
|
|
1206
|
+
}
|
|
1207
|
+
|
|
1208
|
+
async function appendOwnerOnlyText(filePath: string, text: string): Promise<void> {
|
|
1209
|
+
await fs.mkdir(path.dirname(filePath), { recursive: true });
|
|
1210
|
+
await assertAppendRegularFile(filePath);
|
|
1211
|
+
const noFollow = process.platform === "win32" ? 0 : (fsConstants.O_NOFOLLOW ?? 0);
|
|
1212
|
+
let handle: fs.FileHandle | undefined;
|
|
1213
|
+
let initialStat: nodeFs.BigIntStats | undefined;
|
|
1214
|
+
// O_EXCL matches appendAuditEntry for a missing path. Concurrent appends can
|
|
1215
|
+
// create the same file together, so an EEXIST loser retries the existing-file
|
|
1216
|
+
// open, which carries O_NOFOLLOW and will not follow a symlink.
|
|
1217
|
+
for (let attempt = 0; attempt < 2; attempt++) {
|
|
1218
|
+
initialStat = undefined;
|
|
1219
|
+
try {
|
|
1220
|
+
initialStat = await fs.lstat(filePath, { bigint: true });
|
|
1221
|
+
if (initialStat.isSymbolicLink() || !initialStat.isFile())
|
|
1222
|
+
throw new Error("state append path is not a regular file");
|
|
1223
|
+
} catch (error) {
|
|
1224
|
+
if (!isErrno(error, "ENOENT")) throw error;
|
|
1225
|
+
}
|
|
1226
|
+
const flags = initialStat
|
|
1227
|
+
? fsConstants.O_WRONLY | fsConstants.O_APPEND | noFollow
|
|
1228
|
+
: fsConstants.O_WRONLY | fsConstants.O_APPEND | fsConstants.O_CREAT | fsConstants.O_EXCL;
|
|
1229
|
+
try {
|
|
1230
|
+
handle = await fs.open(filePath, flags, 0o600);
|
|
1231
|
+
break;
|
|
1232
|
+
} catch (error) {
|
|
1233
|
+
if (isErrno(error, "ELOOP")) throw new Error("state append path is not a regular file");
|
|
1234
|
+
if (initialStat === undefined && isErrno(error, "EEXIST") && attempt === 0) continue;
|
|
1235
|
+
throw error;
|
|
1236
|
+
}
|
|
1237
|
+
}
|
|
1238
|
+
if (!handle) throw new Error("state append path identity changed before append");
|
|
1239
|
+
try {
|
|
1240
|
+
const openedStat = await handle.stat({ bigint: true });
|
|
1241
|
+
const pathStat = await fs.lstat(filePath, { bigint: true });
|
|
1242
|
+
const sameObject = (left: nodeFs.BigIntStats, right: nodeFs.BigIntStats) =>
|
|
1243
|
+
left.dev === right.dev && left.ino === right.ino && left.nlink === right.nlink;
|
|
1244
|
+
if (
|
|
1245
|
+
openedStat.isSymbolicLink() ||
|
|
1246
|
+
!openedStat.isFile() ||
|
|
1247
|
+
pathStat.isSymbolicLink() ||
|
|
1248
|
+
!pathStat.isFile() ||
|
|
1249
|
+
(initialStat !== undefined && !sameObject(initialStat, openedStat)) ||
|
|
1250
|
+
!sameObject(openedStat, pathStat)
|
|
1251
|
+
)
|
|
1252
|
+
throw new Error("state append path identity changed before append");
|
|
1253
|
+
if (process.platform !== "win32" && (Number(openedStat.mode) & 0o077) !== 0) {
|
|
1254
|
+
await handle.chmod(0o600);
|
|
1255
|
+
const tightened = await handle.stat({ bigint: true });
|
|
1256
|
+
if ((Number(tightened.mode) & 0o777) !== 0o600)
|
|
1257
|
+
throw new Error("state append path could not be tightened to mode 0600");
|
|
1258
|
+
}
|
|
1259
|
+
await handle.writeFile(text, "utf-8");
|
|
1260
|
+
} finally {
|
|
1261
|
+
await handle?.close();
|
|
1262
|
+
}
|
|
1263
|
+
}
|
|
1264
|
+
|
|
1161
1265
|
export async function appendJsonl(targetPath: string, entry: unknown, options?: StateWriterOptions): Promise<string> {
|
|
1162
1266
|
const filePath = resolveGjcTarget(targetPath, cwdForOptions(options));
|
|
1163
|
-
await
|
|
1164
|
-
await fs.appendFile(filePath, `${JSON.stringify(entry)}\n`, "utf-8");
|
|
1267
|
+
await appendOwnerOnlyText(filePath, `${JSON.stringify(entry)}\n`);
|
|
1165
1268
|
await maybeAudit(filePath, options);
|
|
1166
1269
|
return filePath;
|
|
1167
1270
|
}
|
|
@@ -1259,12 +1362,14 @@ export async function appendJsonlIdempotent(
|
|
|
1259
1362
|
return lockResolvedWorkflowTarget(
|
|
1260
1363
|
filePath,
|
|
1261
1364
|
async () => {
|
|
1365
|
+
await assertAppendRegularFile(filePath);
|
|
1262
1366
|
const existing = await readJsonlEntries(filePath);
|
|
1263
1367
|
const duplicate = findJsonlDuplicate(existing, entry, options);
|
|
1264
1368
|
if (duplicate !== undefined) {
|
|
1369
|
+
await tightenReadOnlyAppendMode(filePath);
|
|
1265
1370
|
return { path: filePath, appended: false, duplicate };
|
|
1266
1371
|
}
|
|
1267
|
-
await
|
|
1372
|
+
await appendOwnerOnlyText(filePath, `${JSON.stringify(entry)}\n`);
|
|
1268
1373
|
await maybeAudit(filePath, options);
|
|
1269
1374
|
return { path: filePath, appended: true };
|
|
1270
1375
|
},
|
|
@@ -1274,8 +1379,7 @@ export async function appendJsonlIdempotent(
|
|
|
1274
1379
|
|
|
1275
1380
|
export async function appendText(targetPath: string, text: string, options?: StateWriterOptions): Promise<string> {
|
|
1276
1381
|
const filePath = resolveGjcTarget(targetPath, cwdForOptions(options));
|
|
1277
|
-
await
|
|
1278
|
-
await fs.appendFile(filePath, text, "utf-8");
|
|
1382
|
+
await appendOwnerOnlyText(filePath, text);
|
|
1279
1383
|
await maybeAudit(filePath, options);
|
|
1280
1384
|
return filePath;
|
|
1281
1385
|
}
|