@bitkyc08/opencodex 2.82.0 → 2.84.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/gui/dist/assets/App-CZtqiGcw.js +56 -0
- package/gui/dist/assets/Tray-C9TGtwad.js +1 -0
- package/gui/dist/assets/{index-BAbcj51J.css → index-BB6uK6tU.css} +1 -1
- package/gui/dist/assets/index-CTm4l-HQ.js +94 -0
- package/gui/dist/assets/{quota-summary-DTBC8n7-.js → quota-summary-BbEeRfPY.js} +1 -1
- package/gui/dist/index.html +2 -2
- package/package.json +2 -2
- package/src/adapters/coding-agent/protocol.ts +15 -8
- package/src/adapters/cursor/catalog.ts +2 -2
- package/src/adapters/cursor/effort-map.ts +1 -1
- package/src/adapters/devin/live-models.ts +2 -2
- package/src/adapters/google-truncation.ts +22 -0
- package/src/adapters/google.ts +7 -3
- package/src/adapters/ollama-native.ts +47 -15
- package/src/adapters/openai-chat/messages.ts +8 -4
- package/src/adapters/openai-responses/empty-assistant.ts +32 -0
- package/src/adapters/openai-responses/passthrough.ts +5 -0
- package/src/adapters/openai-responses/plaintext-reasoning-notice.ts +85 -0
- package/src/adapters/openai-responses/reasoning.ts +11 -1
- package/src/adapters/openai-responses/request-strips.ts +10 -2
- package/src/adapters/qoder/adapter.ts +16 -14
- package/src/bridge/errors.ts +26 -1
- package/src/bridge.ts +1 -1
- package/src/claude/context-windows.ts +57 -4
- package/src/claude/desktop-picker.ts +1 -1
- package/src/claude/intercept/connect-proxy.ts +10 -3
- package/src/claude/intercept/picker-ca-persistence.ts +3 -1
- package/src/claude/intercept/picker-listener.ts +49 -1
- package/src/claude/intercept/settings-no-proxy.ts +146 -0
- package/src/claude/intercept/settings.ts +101 -9
- package/src/cli/account-api.ts +65 -0
- package/src/cli/account-auth.ts +44 -12
- package/src/cli/account.ts +41 -5
- package/src/cli/capabilities-access-remote.ts +8 -0
- package/src/cli/capabilities-base.ts +13 -1
- package/src/cli/catalog-command-result.ts +2 -1
- package/src/cli/claude.ts +15 -6
- package/src/cli/config-command.ts +8 -2
- package/src/cli/debug.ts +8 -0
- package/src/cli/dispatch.ts +16 -3
- package/src/cli/export-command.ts +14 -1
- package/src/cli/hub-disable.ts +99 -0
- package/src/cli/hub.ts +4 -2
- package/src/cli/index.ts +13 -4
- package/src/cli/link-child.ts +104 -0
- package/src/cli/link-enrollment.ts +96 -0
- package/src/cli/link.ts +39 -24
- package/src/cli/observe-stream.ts +6 -1
- package/src/cli/observe.ts +10 -2
- package/src/cli/provider.ts +12 -0
- package/src/cli/registry.ts +5 -4
- package/src/cli/resolve.ts +1 -1
- package/src/cli/runtime-api.ts +27 -2
- package/src/cli/status.ts +104 -20
- package/src/client/connect.ts +39 -4
- package/src/client/hub-client.ts +14 -7
- package/src/client/hub-state.ts +25 -14
- package/src/client/link-home.ts +248 -0
- package/src/client/link-management-relay.ts +61 -0
- package/src/client/link-relay.ts +6 -1
- package/src/client/link-status.ts +14 -0
- package/src/client/machine-api.ts +14 -7
- package/src/client/machine-listener.ts +29 -4
- package/src/clients/config-export/model-metadata.ts +16 -0
- package/src/clients/config-export/zcode-store.ts +46 -11
- package/src/clients/config-export/zcode.ts +3 -5
- package/src/clients/config-export.ts +47 -17
- package/src/clients/omo-role-models.ts +6 -3
- package/src/codex/agent-role-models.ts +26 -8
- package/src/codex/auth-api/account-list.ts +17 -2
- package/src/codex/auth-api/account-selection.ts +17 -7
- package/src/codex/auth-api/pool-mode-gate.ts +4 -5
- package/src/codex/auth-api/routes.ts +5 -1
- package/src/codex/auth-context.ts +8 -3
- package/src/codex/autostart-health.ts +3 -1
- package/src/codex/catalog/bundled.ts +5 -1
- package/src/codex/catalog/effort.ts +2 -2
- package/src/codex/catalog/metadata.ts +4 -2
- package/src/codex/catalog/native-models.ts +17 -3
- package/src/codex/catalog/provider-models.ts +25 -0
- package/src/codex/desktop-app/darwin.ts +1 -1
- package/src/codex/desktop-app/handoff.ts +1 -1
- package/src/codex/desktop-app/linux.ts +1 -1
- package/src/codex/desktop-app/lock.ts +1 -1
- package/src/codex/desktop-app/types.ts +1 -1
- package/src/codex/desktop-app/windows.ts +1 -1
- package/src/codex/desktop-app-restart.ts +1 -1
- package/src/codex/main-account.ts +6 -7
- package/src/codex/native-profile-startup.ts +4 -1
- package/src/codex/prompt-journal.ts +20 -2
- package/src/codex/prompt-layers/encoding.ts +49 -0
- package/src/codex/prompt-layers/import-source.ts +30 -0
- package/src/codex/prompt-layers/toml-edit.ts +269 -37
- package/src/codex/prompt-layers/toml-read.ts +52 -4
- package/src/codex/prompt-layers.ts +383 -20
- package/src/codex/prompt-lock-claim.ts +13 -3
- package/src/codex/prompt-lock-evidence.ts +3 -3
- package/src/codex/prompt-lock-owner.ts +14 -6
- package/src/codex/prompt-text-probe.ts +34 -5
- package/src/codex/quota.ts +13 -6
- package/src/codex/role-auto-assign.ts +2 -5
- package/src/codex/routing/active-account.ts +21 -0
- package/src/codex/routing.ts +1 -1
- package/src/codex/runtime.ts +196 -8
- package/src/codex/shim-overlay.ts +8 -4
- package/src/codex/shim.ts +1 -1
- package/src/combos/failover.ts +3 -0
- package/src/config/api-key-reconcile.ts +34 -0
- package/src/config/diagnostics.ts +16 -0
- package/src/config/feature-flags.ts +5 -0
- package/src/config/live-reconcile.ts +43 -29
- package/src/config/live-topology.ts +29 -0
- package/src/config/load-degrade.ts +2 -1
- package/src/config/paths.ts +3 -0
- package/src/config/salvage.ts +3 -1
- package/src/config/schema/config-schema.ts +2 -0
- package/src/generated/compatibility-version.json +373 -217
- package/src/grok/grpc-web.ts +23 -1
- package/src/grok/inject.ts +245 -221
- package/src/grok/reset-coupon-ledger.ts +293 -57
- package/src/grok/reset-coupons.ts +15 -6
- package/src/integrations/cursor-detect.ts +1 -1
- package/src/integrations/mutation-plan.ts +13 -7
- package/src/integrations/registry.ts +45 -11
- package/src/integrations/state.ts +19 -7
- package/src/integrations/target.ts +42 -5
- package/src/integrations/writer.ts +14 -6
- package/src/lib/app-owned-memory-stores.ts +4 -0
- package/src/lib/app-owned-memory.ts +11 -0
- package/src/lib/errors.ts +25 -0
- package/src/lib/json-byte-size.ts +31 -0
- package/src/lib/optional-shutdown-hooks.ts +35 -0
- package/src/lib/outbound-credential-registry.ts +210 -0
- package/src/lib/passive-read.ts +35 -0
- package/src/lib/redact.ts +2 -2
- package/src/lib/shadow-call.ts +44 -5
- package/src/link/codex-settings.ts +155 -0
- package/src/link/management-routes.ts +89 -0
- package/src/link/remote-probe.ts +38 -0
- package/src/link/ssh-argv.ts +26 -5
- package/src/link/ssh-runner.ts +58 -27
- package/src/link/status-projection.ts +22 -4
- package/src/link/supervisor.ts +144 -49
- package/src/link/tunnel-state.ts +11 -4
- package/src/oauth/anthropic-continuity.ts +4 -3
- package/src/oauth/anthropic-identity.ts +13 -2
- package/src/oauth/anthropic-model-quota.ts +13 -1
- package/src/oauth/anthropic-routing.ts +230 -23
- package/src/oauth/anthropic.ts +18 -4
- package/src/oauth/google-antigravity.ts +24 -7
- package/src/oauth/index.ts +25 -16
- package/src/oauth/kiro-account-identity.ts +106 -0
- package/src/oauth/kiro.ts +48 -5
- package/src/oauth/meta-muse-device.ts +1 -1
- package/src/oauth/plan.ts +29 -0
- package/src/oauth/store.ts +40 -3
- package/src/oauth/types.ts +3 -1
- package/src/plugins/loader.ts +11 -0
- package/src/providers/anthropic-billing-ownership.ts +74 -0
- package/src/providers/anthropic-billing-recovery.ts +31 -0
- package/src/providers/anthropic-billing-refusal.ts +55 -0
- package/src/providers/anthropic-fast.ts +1 -1
- package/src/providers/anthropic-reset-admission.ts +62 -0
- package/src/providers/anthropic-reset-grant-ledger.ts +29 -7
- package/src/providers/api-key-resolve.ts +17 -2
- package/src/providers/devin-cli-authmode-migration.ts +1 -1
- package/src/providers/devin-provider-merge-migration.ts +1 -1
- package/src/providers/github-copilot-auto.ts +169 -0
- package/src/providers/key-failover.ts +52 -5
- package/src/providers/model-presets.ts +1 -1
- package/src/providers/model-rename-startup.ts +5 -3
- package/src/providers/muse-subscription-usage.ts +1 -1
- package/src/providers/quota/account-cache.ts +11 -0
- package/src/providers/quota/anthropic-account-quota.ts +4 -2
- package/src/providers/quota/report-cache.ts +14 -0
- package/src/providers/quota/vendor-probes-oauth.ts +3 -3
- package/src/providers/quota.ts +4 -1
- package/src/providers/registry/entries-core.ts +20 -20
- package/src/providers/registry/entries-extended.ts +4 -1
- package/src/providers/registry/model-seeds.ts +20 -3
- package/src/providers/request-pacing.ts +9 -0
- package/src/providers/stale-no-vision-seed-migration.ts +72 -0
- package/src/providers/xai-fast-model.ts +1 -1
- package/src/responses/compaction.ts +14 -0
- package/src/responses/parser.ts +7 -1
- package/src/server/auth-cors.ts +42 -0
- package/src/server/configured-credentials.ts +194 -0
- package/src/server/inbound-body-admission.ts +24 -7
- package/src/server/index/link-listener.ts +30 -17
- package/src/server/index/link-management.ts +58 -0
- package/src/server/index/optional-listeners.ts +5 -1
- package/src/server/index/serve-options.ts +21 -2
- package/src/server/index.ts +1 -1
- package/src/server/lifecycle.ts +33 -5
- package/src/server/management/agent-settings-routes.ts +28 -10
- package/src/server/management/anthropic-reset-grant-routes.ts +17 -1
- package/src/server/management/codex-agent-role-routes.ts +19 -6
- package/src/server/management/codex-prompt-routes.ts +136 -5
- package/src/server/management/codex-role-auto-assign.ts +1 -1
- package/src/server/management/grok-coupon-routes.ts +333 -57
- package/src/server/management/link-enrollment.ts +192 -0
- package/src/server/management/link-route-state.ts +185 -0
- package/src/server/management/link-routes.ts +92 -390
- package/src/server/management/model-rows.ts +3 -1
- package/src/server/management/provider-overwrite-carry.ts +10 -0
- package/src/server/management/provider-routes.ts +1 -0
- package/src/server/management/route-registry.ts +7 -5
- package/src/server/management/shared.ts +3 -0
- package/src/server/management-api.ts +3 -1
- package/src/server/messages-native.ts +68 -42
- package/src/server/relay-eager.ts +4 -0
- package/src/server/relay.ts +18 -7
- package/src/server/request-log-failure-attribution.ts +3 -0
- package/src/server/request-log-filter.ts +6 -1
- package/src/server/request-log-terminal-status.ts +243 -0
- package/src/server/request-log.ts +15 -118
- package/src/server/responses/adapter-continuation.ts +9 -1
- package/src/server/responses/adapter-dispatch.ts +293 -81
- package/src/server/responses/code-mode-collab-surface.ts +205 -0
- package/src/server/responses/codex-ws-exchange.ts +147 -6
- package/src/server/responses/codex-ws-wire.ts +203 -0
- package/src/server/responses/collaboration.ts +36 -7
- package/src/server/responses/compact.ts +129 -50
- package/src/server/responses/context-overflow.ts +13 -1
- package/src/server/responses/core-codex-account.ts +105 -1
- package/src/server/responses/core-combo-failure.ts +14 -0
- package/src/server/responses/core-combo.ts +7 -2
- package/src/server/responses/core-errors.ts +29 -1
- package/src/server/responses/core-opaque-recovery.ts +5 -5
- package/src/server/responses/core-options.ts +2 -0
- package/src/server/responses/core.ts +6 -6
- package/src/server/responses/deferred-collab-surface.ts +158 -0
- package/src/server/responses/deferred-send-reporter.ts +25 -0
- package/src/server/responses/empty-completion-guard.ts +18 -2
- package/src/server/responses/fetch-helpers.ts +12 -1
- package/src/server/responses/jev-model-invoke.ts +1 -1
- package/src/server/responses/passthrough-delivery.ts +4 -1
- package/src/server/responses/passthrough-dispatch.ts +130 -64
- package/src/server/responses/passthrough-execution.ts +3 -3
- package/src/server/responses/request-prepare.ts +6 -4
- package/src/server/responses/request-transport.ts +131 -13
- package/src/server/responses/run-turn-execution.ts +13 -5
- package/src/server/responses/sidecar-execution.ts +16 -2
- package/src/server/responses/spend-storage-error.ts +33 -0
- package/src/server/responses/ws-upstream.ts +9 -1
- package/src/server/startup-action-control.ts +22 -2
- package/src/server/startup-health-cache.ts +4 -0
- package/src/server/system-env-shell.ts +7 -2
- package/src/server/system-env.ts +11 -1
- package/src/service/cli.ts +5 -1
- package/src/service/desktop-startup.ts +147 -34
- package/src/service/desktop-supervision.d.mts +27 -1
- package/src/service/desktop-supervision.mjs +111 -5
- package/src/service/health-sentinel-launch.ts +176 -0
- package/src/service/health-sentinel-main.ts +447 -0
- package/src/service/health-sentinel-policy.ts +241 -0
- package/src/service/health-sentinel-probe.ts +54 -0
- package/src/service/health-sentinel-windows.ts +87 -0
- package/src/service/linux-linger.ts +20 -0
- package/src/types/config.ts +5 -0
- package/src/types/request.ts +5 -0
- package/src/usage/expected-prices.ts +5 -1
- package/src/usage/log.ts +19 -1
- package/gui/dist/assets/App-Ds_qL7o8.js +0 -54
- package/gui/dist/assets/Tray-BqHF5Trd.js +0 -1
- package/gui/dist/assets/index-D3FBCNiP.js +0 -94
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { OCX_SECTION_MARKER } from "../injected-marker";
|
|
2
|
-
import { decodeBasicString } from "./encoding";
|
|
2
|
+
import { decodeBasicString, lexicalLineStarts, matchKeyHead } from "./encoding";
|
|
3
3
|
|
|
4
4
|
/**
|
|
5
5
|
* Decoded string entries of a root-scope TOML array.
|
|
@@ -118,6 +118,25 @@ export function rootLines(content: string): string[] {
|
|
|
118
118
|
return first === -1 ? lines : lines.slice(0, first);
|
|
119
119
|
}
|
|
120
120
|
|
|
121
|
+
/**
|
|
122
|
+
* Root-scope lines that start outside every string and composite span. Used by
|
|
123
|
+
* fallbacks that scan lines because the parser refused the file (for example a
|
|
124
|
+
* Codex-valid i64 Bun cannot represent): prose inside a multiline value must not
|
|
125
|
+
* be read as a setting. A file whose spans never close keeps the plain scan.
|
|
126
|
+
*/
|
|
127
|
+
export function scopedRootLines(content: string): string[] {
|
|
128
|
+
const lines = content.split("\n");
|
|
129
|
+
const starts = lexicalLineStarts(lines);
|
|
130
|
+
if (starts === null) return rootLines(content);
|
|
131
|
+
const out: string[] = [];
|
|
132
|
+
for (let i = 0; i < lines.length; i += 1) {
|
|
133
|
+
if (!starts[i]) continue;
|
|
134
|
+
if (TABLE_HEADER.test(lines[i]!)) break;
|
|
135
|
+
out.push(lines[i]!);
|
|
136
|
+
}
|
|
137
|
+
return out;
|
|
138
|
+
}
|
|
139
|
+
|
|
121
140
|
/** Lines of `[header]`'s body, up to the next table header. */
|
|
122
141
|
export function tableLines(content: string, header: string): string[] | null {
|
|
123
142
|
const lines = content.split("\n");
|
|
@@ -129,11 +148,40 @@ export function tableLines(content: string, header: string): string[] | null {
|
|
|
129
148
|
return end === -1 ? rest : rest.slice(0, end);
|
|
130
149
|
}
|
|
131
150
|
|
|
151
|
+
/**
|
|
152
|
+
* A root-scope (`table === null`) or `[table]` boolean, reading only lines that
|
|
153
|
+
* start outside every string and composite span, so assignment-shaped prose in a
|
|
154
|
+
* multiline value is never reported as the setting. A file whose spans never
|
|
155
|
+
* close falls back to the plain line scan; Codex cannot load it either way.
|
|
156
|
+
*/
|
|
157
|
+
export function scopedBool(content: string, table: string | null, key: string): boolean | null {
|
|
158
|
+
const lines = content.split("\n");
|
|
159
|
+
const starts = lexicalLineStarts(lines);
|
|
160
|
+
if (starts === null) {
|
|
161
|
+
const scope = table === null ? rootLines(content) : tableLines(content, table);
|
|
162
|
+
return scope === null ? null : boolInLines(scope, key);
|
|
163
|
+
}
|
|
164
|
+
const top = (i: number) => starts[i] === true;
|
|
165
|
+
let from = 0;
|
|
166
|
+
if (table !== null) {
|
|
167
|
+
const escaped = table.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
168
|
+
const header = new RegExp(`^\\s*\\[${escaped}\\]\\s*(?:#.*)?$`);
|
|
169
|
+
from = lines.findIndex((l, i) => top(i) && header.test(l)) + 1;
|
|
170
|
+
if (from === 0) return null;
|
|
171
|
+
}
|
|
172
|
+
for (let i = from; i < lines.length; i += 1) {
|
|
173
|
+
if (!top(i)) continue;
|
|
174
|
+
if (TABLE_HEADER.test(lines[i]!)) break;
|
|
175
|
+
const value = boolInLines([lines[i]!], key);
|
|
176
|
+
if (value !== null) return value;
|
|
177
|
+
}
|
|
178
|
+
return null;
|
|
179
|
+
}
|
|
180
|
+
|
|
132
181
|
export function boolInLines(lines: string[], key: string): boolean | null {
|
|
133
|
-
const escaped = key.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
134
|
-
const pattern = new RegExp(`^\\s*${escaped}\\s*=\\s*(true|false)\\s*(?:#.*)?$`);
|
|
135
182
|
for (const line of lines) {
|
|
136
|
-
const
|
|
183
|
+
const head = matchKeyHead(line, key);
|
|
184
|
+
const m = head === null ? null : /^(true|false)\s*(?:#.*)?$/.exec(head.rest);
|
|
137
185
|
if (m) return m[1] === "true";
|
|
138
186
|
}
|
|
139
187
|
return null;
|
|
@@ -26,7 +26,7 @@
|
|
|
26
26
|
* CODEX_HOME is resolved at CALL time (the `features.ts:58-67` pattern) so tests
|
|
27
27
|
* can point fixtures via env or an explicit path.
|
|
28
28
|
*/
|
|
29
|
-
import { existsSync, readdirSync } from "node:fs";
|
|
29
|
+
import { existsSync, readdirSync, realpathSync } from "node:fs";
|
|
30
30
|
import { dirname, join, resolve } from "node:path";
|
|
31
31
|
import { createHash, randomBytes } from "node:crypto";
|
|
32
32
|
import { expandUserPath } from "../config";
|
|
@@ -41,7 +41,9 @@ import {
|
|
|
41
41
|
hashBytes,
|
|
42
42
|
recoverIfNeeded as recoverJournal,
|
|
43
43
|
type JournalRecord,
|
|
44
|
+
expectingImage,
|
|
44
45
|
} from "./prompt-journal";
|
|
46
|
+
import { readBaseImportSource } from "./prompt-layers/import-source";
|
|
45
47
|
import { release, stillHeld, tryAcquire } from "./prompt-lock";
|
|
46
48
|
import { configWriteLockFailureMessage, withConfigWriteLockHeld, publishConfigWrite, publishConfigWriteTarget, watchConfigWriteTargets, type LockHandle } from "./config-write-lock";
|
|
47
49
|
|
|
@@ -152,8 +154,11 @@ export type { Ownership } from "./prompt-layers/toml-read";
|
|
|
152
154
|
import { activeConfigPath, activeStorePath, activeBaseVariantDir, journalPathFor, lockPathFor, type Paths } from "./prompt-layers/paths";
|
|
153
155
|
import { readFileOrNull, computeRevision, updateFingerprintField } from "./prompt-layers/revision";
|
|
154
156
|
import { normalizeBody, findInvalidCharacter, decodeBasicString, decodeTomlBasicString } from "./prompt-layers/encoding";
|
|
155
|
-
import { rootArrayEntries, hasRootKey, rootLines, rootValue,
|
|
156
|
-
import {
|
|
157
|
+
import { rootArrayEntries, hasRootKey, rootLines, rootValue, scopedBool, scopedRootLines, inspectOwnership } from "./prompt-layers/toml-read";
|
|
158
|
+
import {
|
|
159
|
+
setRootBool, setRootString, setTableBool, setProjection, removeUnownedProjection,
|
|
160
|
+
rootKeyValueForm, UnsupportedTomlForm,
|
|
161
|
+
} from "./prompt-layers/toml-edit";
|
|
157
162
|
|
|
158
163
|
/**
|
|
159
164
|
* Instruction documents the prompt probe renders out of CODEX_HOME, in the
|
|
@@ -355,8 +360,7 @@ function readToggle(configBytes: string | null, id: ToggleId): ToggleState {
|
|
|
355
360
|
const key = spec.table ? `${spec.table}.${spec.key}` : spec.key;
|
|
356
361
|
let value: boolean | null = null;
|
|
357
362
|
if (configBytes !== null) {
|
|
358
|
-
|
|
359
|
-
value = scope === null ? null : boolInLines(scope, spec.key);
|
|
363
|
+
value = scopedBool(configBytes, spec.table ?? null, spec.key);
|
|
360
364
|
}
|
|
361
365
|
return {
|
|
362
366
|
id,
|
|
@@ -372,7 +376,7 @@ function readModelInstructionsFile(configBytes: string | null): string | null {
|
|
|
372
376
|
const parsed = rootValue(configBytes, "model_instructions_file");
|
|
373
377
|
if (typeof parsed === "string") return parsed;
|
|
374
378
|
if (parsed === undefined) return null;
|
|
375
|
-
for (const line of
|
|
379
|
+
for (const line of scopedRootLines(configBytes)) {
|
|
376
380
|
// Capture the whole literal INCLUDING its quotes and decode it, rather than
|
|
377
381
|
// returning the raw inner text. `setRootString` writes this key through
|
|
378
382
|
// `encodeBasicString`, which escapes backslashes, so on Windows the stored
|
|
@@ -380,7 +384,7 @@ function readModelInstructionsFile(configBytes: string | null): string | null {
|
|
|
380
384
|
//
|
|
381
385
|
// Bun may reject an unrelated safe-for-Codex integer. Decode the standard
|
|
382
386
|
// TOML escapes here, and never mistake an undecodable literal for a path.
|
|
383
|
-
const m = /^\s*model_instructions_file\s*=\s*("(?:[^"\\]|\\.)*")\s*(?:#.*)?$/.exec(line);
|
|
387
|
+
const m = /^\s*model_instructions_file\s*=\s*("(?:[^"\\]|\\.)*")\s*(?:#.*)?$/.exec(line.replace(/\r$/, ""));
|
|
384
388
|
if (m) return decodeTomlBasicString(m[1]!) ?? "<unreadable model_instructions_file>";
|
|
385
389
|
}
|
|
386
390
|
// A present non-string value or unrecognised spelling fails closed. Only
|
|
@@ -448,7 +452,7 @@ export function resolveBaseSelection(
|
|
|
448
452
|
const dir = activeBaseVariantDir(opts);
|
|
449
453
|
let resolved: string;
|
|
450
454
|
try {
|
|
451
|
-
resolved = resolve(expandUserPath(raw));
|
|
455
|
+
resolved = resolve(dirname(activeConfigPath(opts)), expandUserPath(raw));
|
|
452
456
|
} catch {
|
|
453
457
|
return { kind: "external", path: raw };
|
|
454
458
|
}
|
|
@@ -640,6 +644,7 @@ export type WriteError =
|
|
|
640
644
|
| "unknown_layer"
|
|
641
645
|
| "store_unreadable"
|
|
642
646
|
| "invalid_characters"
|
|
647
|
+
| "body_too_large"
|
|
643
648
|
| "write_superseded"
|
|
644
649
|
// The filesystem refused a rename that passed every precondition: a directory on
|
|
645
650
|
// the store path, a mode change, a full disk. Distinct from write_superseded,
|
|
@@ -647,7 +652,14 @@ export type WriteError =
|
|
|
647
652
|
| "write_failed"
|
|
648
653
|
| "recovery_required"
|
|
649
654
|
| "locked"
|
|
650
|
-
| "unsafe"
|
|
655
|
+
| "unsafe"
|
|
656
|
+
| "import_body_changed"
|
|
657
|
+
/**
|
|
658
|
+
* A `key =` line exists but its value is a form the line editor cannot
|
|
659
|
+
* replace or remove safely (a multi-line string span, an unterminated
|
|
660
|
+
* quote). Refused before the transaction wrote anything.
|
|
661
|
+
*/
|
|
662
|
+
| "unsupported_form";
|
|
651
663
|
|
|
652
664
|
export type WriteResult =
|
|
653
665
|
| { ok: true; changed: boolean; snapshot: PromptLayerSnapshot }
|
|
@@ -712,7 +724,18 @@ function commitPrepared(
|
|
|
712
724
|
}
|
|
713
725
|
|
|
714
726
|
const snapshot = readPromptLayers({ ...opts, configPath, storePath });
|
|
715
|
-
|
|
727
|
+
let built: Mutation | { error: WriteError; detail?: string };
|
|
728
|
+
try {
|
|
729
|
+
built = build(snapshot, configBytes, storeBytes);
|
|
730
|
+
} catch (error) {
|
|
731
|
+
// A line editor that cannot see the end of the value span refuses BEFORE
|
|
732
|
+
// the journal exists — nothing has been written, so a plain error result
|
|
733
|
+
// is the whole answer.
|
|
734
|
+
if (error instanceof UnsupportedTomlForm) {
|
|
735
|
+
return { ok: false, error: "unsupported_form", detail: error.message };
|
|
736
|
+
}
|
|
737
|
+
throw error;
|
|
738
|
+
}
|
|
716
739
|
if ("error" in built) return { ok: false, error: built.error, detail: built.detail };
|
|
717
740
|
|
|
718
741
|
const { nextConfig, nextStore } = built;
|
|
@@ -756,8 +779,8 @@ function commitPrepared(
|
|
|
756
779
|
return rollback(record, journalPath, "stale_revision", configHandle);
|
|
757
780
|
}
|
|
758
781
|
publishConfigWrite(configPath, configHandle, (destination, hooks) => {
|
|
759
|
-
if (nextConfig === null) durableDelete(destination, hooks);
|
|
760
|
-
else durableWrite(destination, nextConfig, hooks);
|
|
782
|
+
if (nextConfig === null) durableDelete(destination, expectingImage(hooks, record.preConfig));
|
|
783
|
+
else durableWrite(destination, nextConfig, expectingImage(hooks, record.preConfig));
|
|
761
784
|
});
|
|
762
785
|
}
|
|
763
786
|
if (storeChanged) {
|
|
@@ -765,8 +788,8 @@ function commitPrepared(
|
|
|
765
788
|
return rollback(record, journalPath, "stale_revision", configHandle);
|
|
766
789
|
}
|
|
767
790
|
publishConfigWriteTarget(configPath, configHandle, storePath, (destination, hooks) => {
|
|
768
|
-
if (nextStore === null) durableDelete(destination, hooks);
|
|
769
|
-
else durableWrite(destination, nextStore, hooks);
|
|
791
|
+
if (nextStore === null) durableDelete(destination, expectingImage(hooks, record.preStore));
|
|
792
|
+
else durableWrite(destination, nextStore, expectingImage(hooks, record.preStore));
|
|
770
793
|
});
|
|
771
794
|
}
|
|
772
795
|
} catch (error) {
|
|
@@ -810,14 +833,14 @@ function rollback(record: JournalRecord, journalPath: string, error: WriteError,
|
|
|
810
833
|
}
|
|
811
834
|
if (configNow === record.postConfig) {
|
|
812
835
|
publishConfigWrite(record.configPath, held, (destination, hooks) => {
|
|
813
|
-
if (record.preConfigBytes === null) durableDelete(destination, hooks);
|
|
814
|
-
else durableWrite(destination, record.preConfigBytes, hooks);
|
|
836
|
+
if (record.preConfigBytes === null) durableDelete(destination, expectingImage(hooks, record.postConfig));
|
|
837
|
+
else durableWrite(destination, record.preConfigBytes, expectingImage(hooks, record.postConfig));
|
|
815
838
|
});
|
|
816
839
|
}
|
|
817
840
|
if (storeNow === record.postStore) {
|
|
818
841
|
publishConfigWriteTarget(record.configPath, held, record.storePath, (destination, hooks) => {
|
|
819
|
-
if (record.preStoreBytes === null) durableDelete(destination, hooks);
|
|
820
|
-
else durableWrite(destination, record.preStoreBytes, hooks);
|
|
842
|
+
if (record.preStoreBytes === null) durableDelete(destination, expectingImage(hooks, record.postStore));
|
|
843
|
+
else durableWrite(destination, record.preStoreBytes, expectingImage(hooks, record.postStore));
|
|
821
844
|
});
|
|
822
845
|
}
|
|
823
846
|
publishConfigWriteTarget(record.configPath, held, journalPath, (destination, hooks) => durableDelete(destination, hooks));
|
|
@@ -825,8 +848,16 @@ function rollback(record: JournalRecord, journalPath: string, error: WriteError,
|
|
|
825
848
|
} catch { return { ok: false, error: "recovery_required" }; }
|
|
826
849
|
}
|
|
827
850
|
|
|
828
|
-
/**
|
|
829
|
-
|
|
851
|
+
/**
|
|
852
|
+
* Flip one of the five prompt toggles, or REMOVE its key with `enabled: null`.
|
|
853
|
+
*
|
|
854
|
+
* Removal is the "restore default" verb. A toggle's documented default lives in
|
|
855
|
+
* Codex, not in this file: writing `key = true` for a layer whose default is
|
|
856
|
+
* true produces the same prompt today but freezes the override - an upstream
|
|
857
|
+
* change to the default would then never reach this user. Deleting the line is
|
|
858
|
+
* the only state that keeps following it.
|
|
859
|
+
*/
|
|
860
|
+
export function setToggle(id: string, enabled: boolean | null, revision: string, opts?: Paths): WriteResult {
|
|
830
861
|
if (!isToggleId(id)) return { ok: false, error: "unknown_layer" };
|
|
831
862
|
const spec = TOGGLE_KEYS[id];
|
|
832
863
|
return commit(opts, revision, (_snapshot, configBytes, storeBytes) => ({
|
|
@@ -976,6 +1007,295 @@ function newBaseVariantId(existing: readonly BaseVariant[]): string {
|
|
|
976
1007
|
}
|
|
977
1008
|
}
|
|
978
1009
|
|
|
1010
|
+
// ---------------------------------------------------------------------------
|
|
1011
|
+
// Import — taking ownership of an externally authored model_instructions_file.
|
|
1012
|
+
//
|
|
1013
|
+
// The picker refuses the `external` state outright, which made it a dead end:
|
|
1014
|
+
// the only way to manage that prompt was to delete the line by hand and retype
|
|
1015
|
+
// the file. Import copies the external file's body into the variant directory
|
|
1016
|
+
// and points the key at our copy — one file, so nothing upstream can edit out
|
|
1017
|
+
// from under a stored variant.
|
|
1018
|
+
// ---------------------------------------------------------------------------
|
|
1019
|
+
|
|
1020
|
+
/**
|
|
1021
|
+
* The single-line cap on a stored variant title — the same 80 characters the
|
|
1022
|
+
* route policy applies to custom-layer titles. It bounds the filename-derived
|
|
1023
|
+
* suggestion as well as any caller-supplied title, so the `# ` heading line
|
|
1024
|
+
* the serialized variant starts with stays a bounded prefix and never swallows
|
|
1025
|
+
* the body's byte budget silently.
|
|
1026
|
+
*/
|
|
1027
|
+
export const MAX_BASE_TITLE_CHARS = 80;
|
|
1028
|
+
|
|
1029
|
+
const IMPORT_FALLBACK_TITLE = "Imported base prompt";
|
|
1030
|
+
|
|
1031
|
+
/**
|
|
1032
|
+
* Flatten a title to one line and bound it. Newlines become spaces rather than
|
|
1033
|
+
* throwing because `readBaseVariants` reads the first line back as the title —
|
|
1034
|
+
* a `\n` in the file would mint a second prompt line nobody asked for.
|
|
1035
|
+
* The bound is by UTF-16 units (the same measure the route limit uses); a cut
|
|
1036
|
+
* that lands mid-surrogate drops the dangling half so no unpaired surrogate is
|
|
1037
|
+
* ever written.
|
|
1038
|
+
*/
|
|
1039
|
+
function boundTitle(raw: string | null | undefined): string | null {
|
|
1040
|
+
if (typeof raw !== "string") return null;
|
|
1041
|
+
const flat = raw.replace(/[\r\n]+/g, " ").trim();
|
|
1042
|
+
if (!flat) return null;
|
|
1043
|
+
let bounded = flat.length > MAX_BASE_TITLE_CHARS ? flat.slice(0, MAX_BASE_TITLE_CHARS) : flat;
|
|
1044
|
+
if (/[\uD800-\uDBFF]$/.test(bounded)) bounded = bounded.slice(0, -1);
|
|
1045
|
+
return bounded || null;
|
|
1046
|
+
}
|
|
1047
|
+
|
|
1048
|
+
export interface BaseImportPreview {
|
|
1049
|
+
/** The literal value of `model_instructions_file`, as configured. */
|
|
1050
|
+
rawPath: string | null;
|
|
1051
|
+
/** The absolute file the key resolves to, when it could be resolved. */
|
|
1052
|
+
resolvedPath: string | null;
|
|
1053
|
+
/** The normalized body — the file text after CRLF/tab normalization. */
|
|
1054
|
+
body: string | null;
|
|
1055
|
+
/**
|
|
1056
|
+
* The EXACT text a confirm would write to the variant file: the effective
|
|
1057
|
+
* title as a `# ` heading line followed by the normalized body. What the
|
|
1058
|
+
* user previews is what Codex reads — there is no second description.
|
|
1059
|
+
*/
|
|
1060
|
+
serialized: string | null;
|
|
1061
|
+
/**
|
|
1062
|
+
* sha256 of `serialized`, and REQUIRED on confirm. The hash covers the title
|
|
1063
|
+
* line as well as the body, so a confirmation is bound to the exact text —
|
|
1064
|
+
* including the selected title — the caller previewed.
|
|
1065
|
+
*/
|
|
1066
|
+
previewSha256: string | null;
|
|
1067
|
+
/**
|
|
1068
|
+
* The filename-derived title, already bounded to {@link MAX_BASE_TITLE_CHARS}
|
|
1069
|
+
* so it is displayable and storable as-is. `null` when the path names no
|
|
1070
|
+
* markdown file worth naming after.
|
|
1071
|
+
*/
|
|
1072
|
+
suggestedTitle: string | null;
|
|
1073
|
+
/** The title `serialized` was built with: requested, suggested, or fallback. */
|
|
1074
|
+
effectiveTitle: string | null;
|
|
1075
|
+
reason: "ok" | "nothing_to_import" | "file_unreadable" | "invalid_characters"
|
|
1076
|
+
| "slots_full" | "unsupported_form" | "body_too_large";
|
|
1077
|
+
detail?: string;
|
|
1078
|
+
}
|
|
1079
|
+
|
|
1080
|
+
/**
|
|
1081
|
+
* Read-only. Runs the same steps a confirm would — resolve, read, normalize,
|
|
1082
|
+
* title, serialize — so `serialized` is byte-identical to the stored file and
|
|
1083
|
+
* `previewSha256` binds a confirm to exactly it.
|
|
1084
|
+
*
|
|
1085
|
+
* `title` is the caller's choice for the variant title; omitting it previews
|
|
1086
|
+
* the suggested (or fallback) title. Re-preview with the edited title to get a
|
|
1087
|
+
* hash bound to that spelling.
|
|
1088
|
+
*
|
|
1089
|
+
* `slots_full` still returns the serialized text: the preview is the place to
|
|
1090
|
+
* say "this would be imported if a slot were free", and refusing to show the
|
|
1091
|
+
* text at all would make the cap look like a read failure.
|
|
1092
|
+
*/
|
|
1093
|
+
export function previewBaseImport(opts?: Paths, title?: string): BaseImportPreview {
|
|
1094
|
+
const configPath = activeConfigPath(opts);
|
|
1095
|
+
const configBytes = readFileOrNull(configPath);
|
|
1096
|
+
const variants = readBaseVariants(opts);
|
|
1097
|
+
const selection = resolveBaseSelection(configBytes, variants, opts);
|
|
1098
|
+
const empty: Omit<BaseImportPreview, "reason"> = {
|
|
1099
|
+
rawPath: selection.kind === "external" ? selection.path : null,
|
|
1100
|
+
resolvedPath: null,
|
|
1101
|
+
body: null,
|
|
1102
|
+
serialized: null,
|
|
1103
|
+
previewSha256: null,
|
|
1104
|
+
suggestedTitle: null,
|
|
1105
|
+
effectiveTitle: null,
|
|
1106
|
+
};
|
|
1107
|
+
if (selection.kind !== "external") {
|
|
1108
|
+
return { ...empty, reason: "nothing_to_import" };
|
|
1109
|
+
}
|
|
1110
|
+
// A key whose value is a form the line editor cannot replace (a multi-line
|
|
1111
|
+
// string span, an unterminated quote) can never be retargeted — say so in the
|
|
1112
|
+
// preview rather than let a confirm discover it inside the transaction.
|
|
1113
|
+
if (rootKeyValueForm(configBytes ?? "", "model_instructions_file") === "unsupported") {
|
|
1114
|
+
return {
|
|
1115
|
+
...empty,
|
|
1116
|
+
reason: "unsupported_form",
|
|
1117
|
+
detail: "the model_instructions_file value is a TOML form this editor cannot rewrite (for example a multi-line string); edit config.toml by hand",
|
|
1118
|
+
};
|
|
1119
|
+
}
|
|
1120
|
+
const dir = dirname(configPath);
|
|
1121
|
+
let resolved: string;
|
|
1122
|
+
try {
|
|
1123
|
+
resolved = resolve(dir, expandUserPath(selection.path));
|
|
1124
|
+
} catch {
|
|
1125
|
+
return { ...empty, reason: "file_unreadable", detail: `the path could not be resolved: ${selection.path}` };
|
|
1126
|
+
}
|
|
1127
|
+
let raw: string;
|
|
1128
|
+
try {
|
|
1129
|
+
raw = readBaseImportSource(resolved);
|
|
1130
|
+
} catch (error) {
|
|
1131
|
+
const oversized = (error as NodeJS.ErrnoException).code === "EFBIG";
|
|
1132
|
+
return { ...empty, resolvedPath: resolved,
|
|
1133
|
+
reason: oversized ? "body_too_large" : "file_unreadable",
|
|
1134
|
+
detail: oversized ? "the file body exceeds 65536 bytes" : `could not read ${resolved}` };
|
|
1135
|
+
}
|
|
1136
|
+
const normalized = normalizeBody(raw);
|
|
1137
|
+
if (Buffer.byteLength(normalized, "utf8") > 64 * 1024) {
|
|
1138
|
+
return { ...empty, resolvedPath: resolved, reason: "body_too_large", detail: "the file body exceeds 65536 bytes" };
|
|
1139
|
+
}
|
|
1140
|
+
const invalid = findInvalidCharacter(normalized);
|
|
1141
|
+
if (invalid !== null) {
|
|
1142
|
+
return {
|
|
1143
|
+
...empty,
|
|
1144
|
+
resolvedPath: resolved,
|
|
1145
|
+
reason: "invalid_characters",
|
|
1146
|
+
detail: `code point ${invalid.position} is a ${invalid.reason}`,
|
|
1147
|
+
};
|
|
1148
|
+
}
|
|
1149
|
+
const suggestedTitle = boundTitle(resolved.replace(/\\/g, "/").split("/").pop()!
|
|
1150
|
+
.replace(/\.(md|markdown|txt)$/i, ""));
|
|
1151
|
+
const effectiveTitle = boundTitle(title) ?? suggestedTitle ?? IMPORT_FALLBACK_TITLE;
|
|
1152
|
+
const serialized = `# ${effectiveTitle}\n${normalized}`;
|
|
1153
|
+
return {
|
|
1154
|
+
rawPath: selection.path,
|
|
1155
|
+
resolvedPath: resolved,
|
|
1156
|
+
body: normalized,
|
|
1157
|
+
serialized,
|
|
1158
|
+
previewSha256: createHash("sha256").update(serialized, "utf8").digest("hex"),
|
|
1159
|
+
suggestedTitle,
|
|
1160
|
+
effectiveTitle,
|
|
1161
|
+
reason: variants.length >= MAX_BASE_VARIANTS ? "slots_full" : "ok",
|
|
1162
|
+
};
|
|
1163
|
+
}
|
|
1164
|
+
|
|
1165
|
+
/**
|
|
1166
|
+
* True when the config still names this variant — or when nothing can be
|
|
1167
|
+
* proved either way. An unreadable config returns true because "the file is
|
|
1168
|
+
* unreferenced" must be ESTABLISHED, not assumed: deleting a variant a live
|
|
1169
|
+
* `model_instructions_file` points at strands the prompt in force.
|
|
1170
|
+
*/
|
|
1171
|
+
function variantStillReferenced(id: string, path: string, opts?: Paths): boolean {
|
|
1172
|
+
const configBytes = readFileOrNull(activeConfigPath(opts));
|
|
1173
|
+
if (configBytes === null) return true;
|
|
1174
|
+
const selection = resolveBaseSelection(configBytes, readBaseVariants(opts), opts);
|
|
1175
|
+
if (selection.kind === "variant") return selection.id === id;
|
|
1176
|
+
if (selection.kind !== "external") return false;
|
|
1177
|
+
let selected: string;
|
|
1178
|
+
try {
|
|
1179
|
+
selected = resolve(dirname(activeConfigPath(opts)), expandUserPath(selection.path));
|
|
1180
|
+
} catch {
|
|
1181
|
+
return true;
|
|
1182
|
+
}
|
|
1183
|
+
if (selected === resolve(path)) return true;
|
|
1184
|
+
// A lexical match is not the only way to name the file: a symlink (or a
|
|
1185
|
+
// symlinked directory) resolves to it too, and resolve() does not follow
|
|
1186
|
+
// links. Compare canonical destinations. Only a selection that provably does
|
|
1187
|
+
// not exist rules the copy out; any other failure keeps it.
|
|
1188
|
+
try {
|
|
1189
|
+
return realpathSync(selected) === realpathSync(path);
|
|
1190
|
+
} catch (error) {
|
|
1191
|
+
return (error as NodeJS.ErrnoException).code !== "ENOENT";
|
|
1192
|
+
}
|
|
1193
|
+
}
|
|
1194
|
+
|
|
1195
|
+
/**
|
|
1196
|
+
* Copy the external file into the variant directory and retarget the key at it.
|
|
1197
|
+
*
|
|
1198
|
+
* Same ordering as `writeBaseVariant` and for the same reason: the .md is
|
|
1199
|
+
* written and verified before `config.toml` is ever pointed at it, and if the
|
|
1200
|
+
* transaction refuses, the file is deleted rather than left behind for a write
|
|
1201
|
+
* the caller was told did not happen — but ONLY when the refusal proves no
|
|
1202
|
+
* config references it. `write_superseded` and `recovery_required` can return
|
|
1203
|
+
* after the post-write state was applied or while its shape is unknown; in
|
|
1204
|
+
* that uncertainty the file AND the recovery evidence (the journal commit()
|
|
1205
|
+
* left behind) are preserved.
|
|
1206
|
+
*
|
|
1207
|
+
* `previewSha256` is REQUIRED — the hash binds the exact serialized text the
|
|
1208
|
+
* caller previewed, title line included, and a confirm without it (or one the
|
|
1209
|
+
* current file no longer matches) is refused without writing.
|
|
1210
|
+
*
|
|
1211
|
+
* The `external` state is RE-CHECKED inside the transaction. Between the
|
|
1212
|
+
* preview and the commit another writer could have adopted or cleared the key,
|
|
1213
|
+
* and silently repointing then would claim an import nobody asked for.
|
|
1214
|
+
*/
|
|
1215
|
+
export function importBaseVariant(
|
|
1216
|
+
input: { title?: string; previewSha256?: string },
|
|
1217
|
+
revision: string,
|
|
1218
|
+
opts?: Paths,
|
|
1219
|
+
): WriteResult {
|
|
1220
|
+
const preview = previewBaseImport(opts, input.title);
|
|
1221
|
+
if (preview.body === null || preview.serialized === null || preview.previewSha256 === null) {
|
|
1222
|
+
return {
|
|
1223
|
+
ok: false,
|
|
1224
|
+
error: preview.reason === "invalid_characters" ? "invalid_characters"
|
|
1225
|
+
: preview.reason === "body_too_large" ? "body_too_large"
|
|
1226
|
+
: preview.reason === "unsupported_form" ? "unsupported_form"
|
|
1227
|
+
: "developer_instructions_not_owned",
|
|
1228
|
+
detail: preview.detail,
|
|
1229
|
+
};
|
|
1230
|
+
}
|
|
1231
|
+
if (typeof input.previewSha256 !== "string" || input.previewSha256 !== preview.previewSha256) {
|
|
1232
|
+
// The caller confirmed text they previewed; a different file, or a
|
|
1233
|
+
// different title, today is a different import they never saw — and a
|
|
1234
|
+
// missing hash means nothing was previewed at all. Refuse rather than
|
|
1235
|
+
// install a surprise.
|
|
1236
|
+
return { ok: false, error: "import_body_changed", detail: "the preview no longer matches; preview it again" };
|
|
1237
|
+
}
|
|
1238
|
+
// Even with a matching hash, refuse BEFORE writing the variant file when the
|
|
1239
|
+
// configured key is a form the line editor cannot retarget — the preview
|
|
1240
|
+
// reported the same reason, so a caller that confirmed anyway hears it too.
|
|
1241
|
+
if (rootKeyValueForm(readFileOrNull(activeConfigPath(opts)) ?? "", "model_instructions_file") === "unsupported") {
|
|
1242
|
+
return { ok: false, error: "unsupported_form", detail: preview.detail };
|
|
1243
|
+
}
|
|
1244
|
+
const dir = activeBaseVariantDir(opts);
|
|
1245
|
+
const existing = readBaseVariants(opts);
|
|
1246
|
+
if (existing.length >= MAX_BASE_VARIANTS) {
|
|
1247
|
+
return { ok: false, error: "unknown_layer", detail: `at most ${MAX_BASE_VARIANTS} variants` };
|
|
1248
|
+
}
|
|
1249
|
+
const targetId = newBaseVariantId(existing);
|
|
1250
|
+
const path = join(dir, `${targetId}.md`);
|
|
1251
|
+
ensureDir(path);
|
|
1252
|
+
try {
|
|
1253
|
+
// The file written is the previewed serialization byte-for-byte: the same
|
|
1254
|
+
// string that produced previewSha256 is what lands on disk.
|
|
1255
|
+
durableWrite(path, preview.serialized);
|
|
1256
|
+
} catch (error) {
|
|
1257
|
+
return { ok: false, error: "write_failed", detail: error instanceof Error ? error.message : String(error) };
|
|
1258
|
+
}
|
|
1259
|
+
|
|
1260
|
+
const result = commit(opts, revision, (snapshot, configBytes, storeBytes) => {
|
|
1261
|
+
if (snapshot.baseSelection.kind !== "external") {
|
|
1262
|
+
return { error: "developer_instructions_not_owned", detail: "model_instructions_file no longer points at an external file" };
|
|
1263
|
+
}
|
|
1264
|
+
// The hash check above ran before either write lock was held, so the source
|
|
1265
|
+
// could have been rewritten while the locks were being acquired. Re-read it
|
|
1266
|
+
// now, under both locks, and refuse unless it still serializes to exactly
|
|
1267
|
+
// what the caller previewed: activating an older copy of a file that has
|
|
1268
|
+
// since changed is the surprise the preview hash exists to prevent.
|
|
1269
|
+
const recheck = previewBaseImport(opts, input.title);
|
|
1270
|
+
if (recheck.previewSha256 !== preview.previewSha256 || recheck.resolvedPath !== preview.resolvedPath) {
|
|
1271
|
+
return { error: "import_body_changed", detail: "the source changed while the import was being confirmed; preview it again" };
|
|
1272
|
+
}
|
|
1273
|
+
return {
|
|
1274
|
+
nextConfig: setRootString(configBytes ?? "", "model_instructions_file", resolve(path)),
|
|
1275
|
+
nextStore: storeBytes,
|
|
1276
|
+
};
|
|
1277
|
+
});
|
|
1278
|
+
|
|
1279
|
+
if (!result.ok) {
|
|
1280
|
+
// The copy is deleted only once the live config is shown not to reference
|
|
1281
|
+
// it, whatever the refusal. `write_superseded`/`recovery_required` can
|
|
1282
|
+
// return after the post-write state was applied, and even a refusal that
|
|
1283
|
+
// wrote nothing (the locked recheck above, a stale revision) cannot rule
|
|
1284
|
+
// out a non-cooperating writer having selected the visible copy in the
|
|
1285
|
+
// meantime. Deleting a file a live config points at strands the prompt in
|
|
1286
|
+
// force, which is the data loss the refusal was protecting against.
|
|
1287
|
+
if (!variantStillReferenced(targetId, path, opts)) {
|
|
1288
|
+
try {
|
|
1289
|
+
durableDelete(path);
|
|
1290
|
+
} catch { /* the returned error already tells the caller to look */ }
|
|
1291
|
+
}
|
|
1292
|
+
return result;
|
|
1293
|
+
}
|
|
1294
|
+
// commit()'s success path already re-reads the full snapshot with the injected
|
|
1295
|
+
// variant directory, so the result lists the file just written.
|
|
1296
|
+
return result;
|
|
1297
|
+
}
|
|
1298
|
+
|
|
979
1299
|
/** Replace the whole custom-layer list; order is composition order. */
|
|
980
1300
|
export function writeCustomLayers(layers: readonly CustomLayer[], revision: string, opts?: Paths): WriteResult {
|
|
981
1301
|
for (const layer of layers) {
|
|
@@ -1171,3 +1491,46 @@ export function salvageProjection(revision: string, opts?: Paths): WriteResult {
|
|
|
1171
1491
|
};
|
|
1172
1492
|
});
|
|
1173
1493
|
}
|
|
1494
|
+
|
|
1495
|
+
/**
|
|
1496
|
+
* Recovery as a first-class operation, under the same file lock every mutation
|
|
1497
|
+
* takes. This is the journal-present repair: it replays the journal and writes
|
|
1498
|
+
* NOTHING else.
|
|
1499
|
+
*
|
|
1500
|
+
* A byte-identical PUT used to stand in for it, and it was not safe. When the
|
|
1501
|
+
* store file is missing, the snapshot's custom list reads [], so the "no-op"
|
|
1502
|
+
* write projected an empty developer_instructions over whatever the config
|
|
1503
|
+
* still carried — surviving instructions erased by the very repair that was
|
|
1504
|
+
* supposed to touch nothing. Recovery alone preserves exactly the bytes it can
|
|
1505
|
+
* account for — post where the pair proves the commit landed, pre where it
|
|
1506
|
+
* proves it did not — and refuses rather than guess when a target matches
|
|
1507
|
+
* neither image.
|
|
1508
|
+
*/
|
|
1509
|
+
export function recoverPromptJournal(opts?: Paths): WriteResult {
|
|
1510
|
+
const configPath = activeConfigPath(opts);
|
|
1511
|
+
const storePath = activeStorePath(opts);
|
|
1512
|
+
const journalPath = journalPathFor(storePath);
|
|
1513
|
+
|
|
1514
|
+
for (const path of [configPath, storePath]) prepareCodexHome(dirname(path), 0o700);
|
|
1515
|
+
|
|
1516
|
+
const acquired = tryAcquire(lockPathFor(storePath));
|
|
1517
|
+
if (!acquired.ok) return { ok: false, error: acquired.error, ...(acquired.error === "unsafe" ? { detail: configWriteLockFailureMessage(acquired) } : {}) };
|
|
1518
|
+
const handle = acquired.handle;
|
|
1519
|
+
|
|
1520
|
+
try {
|
|
1521
|
+
// Use the canonical destination witness and explicit held handle used by
|
|
1522
|
+
// normal commits; journal replay must not reacquire its own live lock.
|
|
1523
|
+
const configLocked = withConfigWriteLockHeld(configPath, undefined, configHandle => {
|
|
1524
|
+
watchConfigWriteTargets(configHandle, [storePath, journalPath]);
|
|
1525
|
+
const recovered = recoverJournal(journalPath, { configPath, storePath }, configHandle);
|
|
1526
|
+
if (!recovered.ok) return { ok: false, error: recovered.error, detail: recovered.detail };
|
|
1527
|
+
return {
|
|
1528
|
+
ok: true,
|
|
1529
|
+
// Action "none" means there was no journal to close or replay.
|
|
1530
|
+
changed: recovered.action !== "none",
|
|
1531
|
+
snapshot: readPromptLayers({ ...opts, configPath, storePath }),
|
|
1532
|
+
};
|
|
1533
|
+
});
|
|
1534
|
+
return configLocked.ok ? configLocked.value as WriteResult : { ok: false, error: configLocked.error === "locked" ? "locked" : "unsafe", ...(configLocked.error !== "locked" ? { detail: configWriteLockFailureMessage(configLocked) } : {}) };
|
|
1535
|
+
} finally { release(handle); }
|
|
1536
|
+
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { linkSync, mkdirSync, readdirSync, readFileSync, renameSync, rmdirSync, unlinkSync, writeFileSync } from "node:fs";
|
|
2
2
|
import { dirname, join } from "node:path";
|
|
3
|
-
import { ownEvidence, ownerState, safeNamespace, type OwnerDeps, type OwnerEvidence } from "./prompt-lock-owner";
|
|
3
|
+
import { ownEvidence, ownerState, safeNamespace, safeNamespaceStat, type OwnerDeps, type OwnerEvidence } from "./prompt-lock-owner";
|
|
4
4
|
import { ChangedLock as ChangedClaim, checkEntry, checkIdentity, guarded, type Entry, type Namespace } from "./prompt-lock-evidence";
|
|
5
5
|
import { LockFileBusy, lockFileOperation } from "./prompt-lock-io";
|
|
6
6
|
|
|
@@ -41,8 +41,18 @@ export function withLockClaim<T>(
|
|
|
41
41
|
if (error instanceof LockFileBusy) return { ok: false };
|
|
42
42
|
if ((error as NodeJS.ErrnoException).code !== "EEXIST") throw error;
|
|
43
43
|
}
|
|
44
|
-
|
|
45
|
-
|
|
44
|
+
// A peer's cleanup removes the empty claims directory, so it can vanish between our mkdir and
|
|
45
|
+
// this read. That is contention, not an unsafe namespace and not a crash: report it as busy.
|
|
46
|
+
// One observation is both validated and kept as the parent witness, so a replacement that
|
|
47
|
+
// appears after a missing read can never be trusted without its own ownership check.
|
|
48
|
+
let directoryStat: ReturnType<OwnerDeps["lstat"]>;
|
|
49
|
+
try { directoryStat = io(() => deps.lstat(directory)); }
|
|
50
|
+
catch (error) {
|
|
51
|
+
if (error instanceof LockFileBusy || (error as NodeJS.ErrnoException).code === "ENOENT") return { ok: false };
|
|
52
|
+
throw error;
|
|
53
|
+
}
|
|
54
|
+
if (!safeNamespaceStat(directoryStat, "directory", deps)) throw new UnsafeLockNamespace(directory);
|
|
55
|
+
const parents = [root, { path: directory, stat: directoryStat }];
|
|
46
56
|
const claimDirectoryGone = (): boolean => {
|
|
47
57
|
try { const now = deps.lstat(directory); return now.dev !== parents[1].stat.dev || now.ino !== parents[1].stat.ino; }
|
|
48
58
|
catch { return true; }
|
|
@@ -1,9 +1,9 @@
|
|
|
1
|
-
import { readFileSync
|
|
2
|
-
import { type OwnerDeps } from "./prompt-lock-owner";
|
|
1
|
+
import { readFileSync } from "node:fs";
|
|
2
|
+
import { type LockStat, type OwnerDeps } from "./prompt-lock-owner";
|
|
3
3
|
import { LockFileBusy, lockFileOperation } from "./prompt-lock-io";
|
|
4
4
|
|
|
5
5
|
export class ChangedLock extends LockFileBusy {}
|
|
6
|
-
export interface Namespace { path: string; stat:
|
|
6
|
+
export interface Namespace { path: string; stat: LockStat }
|
|
7
7
|
export interface Entry extends Namespace { body: string }
|
|
8
8
|
export function checkIdentity(saved: Namespace, deps: OwnerDeps): void {
|
|
9
9
|
const current = deps.lstat(saved.path);
|