@bitkyc08/opencodex 2.84.0 → 2.86.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/README.md +0 -4
- package/bin/ocx.mjs +61 -3
- package/gui/dist/assets/App-BjlttVLy.js +58 -0
- package/gui/dist/assets/{Tray-C9TGtwad.js → Tray-DyR9yBee.js} +1 -1
- package/gui/dist/assets/index-BMssP4k3.js +94 -0
- package/gui/dist/assets/quota-summary-D8M1bc_R.js +1 -0
- package/gui/dist/index.html +1 -1
- package/package.json +1 -2
- package/src/adapters/anthropic/beta-allowlist.ts +87 -8
- package/src/adapters/anthropic/client-identity.ts +1 -1
- package/src/adapters/anthropic/passthrough.ts +33 -4
- package/src/adapters/azure.ts +19 -1
- package/src/adapters/client-fingerprint.ts +24 -0
- package/src/adapters/coding-agent/turn.ts +22 -9
- package/src/adapters/cursor/arg-codec.ts +17 -5
- package/src/adapters/cursor/catalog.ts +8 -0
- package/src/adapters/cursor/effort-map.ts +3 -0
- package/src/adapters/cursor/freeform-args.ts +75 -0
- package/src/adapters/cursor/protobuf-events.ts +36 -23
- package/src/adapters/google-antigravity-wire.ts +22 -1
- package/src/adapters/google.ts +2 -2
- package/src/adapters/openai-chat/response-events.ts +59 -1
- package/src/adapters/openai-chat.ts +8 -5
- package/src/adapters/qoder/adapter.ts +40 -1
- package/src/advisor/consult.ts +220 -0
- package/src/advisor/context.ts +226 -0
- package/src/advisor/disclosure.ts +20 -0
- package/src/advisor/runtime.ts +305 -0
- package/src/advisor/settings.ts +135 -0
- package/src/advisor/state.ts +369 -0
- package/src/advisor/synthetic-tool.ts +33 -0
- package/src/bridge/errors.ts +5 -0
- package/src/chat/inbound.ts +30 -6
- package/src/chat/outbound.ts +5 -0
- package/src/claude/desktop-3p-library.ts +43 -3
- package/src/claude/desktop-3p.ts +22 -8
- package/src/claude/desktop-picker-profile.ts +11 -4
- package/src/claude/desktop-profile.ts +10 -1
- package/src/claude/desktop-remote-store-io.ts +5 -3
- package/src/claude/inbound.ts +121 -50
- package/src/claude/intercept/local-ca.ts +8 -1
- package/src/claude/model-default-settings.ts +154 -0
- package/src/claude/model-info.ts +7 -7
- package/src/claude/outbound.ts +13 -6
- package/src/cli/advisor.ts +151 -0
- package/src/cli/auxiliary-listener-preflight.ts +37 -0
- package/src/cli/capabilities-agents-routing.ts +18 -0
- package/src/cli/capabilities-base.ts +1 -1
- package/src/cli/chatgpt-command.ts +35 -14
- package/src/cli/claude.ts +46 -6
- package/src/cli/dispatch.ts +5 -1
- package/src/cli/doctor.ts +9 -0
- package/src/cli/ensure-desktop-cold-start.ts +329 -0
- package/src/cli/export-command.ts +1 -1
- package/src/cli/help.ts +2 -1
- package/src/cli/index.ts +13 -4
- package/src/cli/integration-plan-dto.ts +2 -0
- package/src/cli/registry.ts +15 -3
- package/src/cli/restart-scope.ts +3 -1
- package/src/clients/config-export/contracts.ts +1 -0
- package/src/clients/config-export/model-metadata.ts +3 -2
- package/src/clients/config-export/qoder.ts +93 -0
- package/src/clients/config-export.ts +17 -4
- package/src/codex/auth-api/pool-mode-gate.ts +5 -2
- package/src/codex/auth-context.ts +79 -5
- package/src/codex/autostart-health.ts +4 -0
- package/src/codex/catalog/build-entries.ts +11 -2
- package/src/codex/catalog/configured-tiers.ts +50 -0
- package/src/codex/catalog/effort.ts +4 -1
- package/src/codex/catalog/model-hints.ts +21 -3
- package/src/codex/catalog/native-ultrafast.ts +106 -0
- package/src/codex/catalog/parsing.ts +5 -0
- package/src/codex/catalog/provider-models.ts +4 -0
- package/src/codex/catalog/retained-sync.ts +2 -0
- package/src/codex/catalog/routed-gather.ts +22 -4
- package/src/codex/catalog/vendor-context.ts +82 -0
- package/src/codex/config-write-lock.ts +40 -2
- package/src/codex/convergence.ts +2 -0
- package/src/codex/desktop-app/types.ts +2 -0
- package/src/codex/desktop-app/windows.ts +29 -16
- package/src/codex/desktop-app-restart.ts +4 -2
- package/src/codex/desktop-compatibility/certificate-service.ts +99 -0
- package/src/codex/desktop-compatibility/certificate-store.ts +193 -0
- package/src/codex/desktop-compatibility/connection-store.ts +87 -0
- package/src/codex/desktop-compatibility/installed-build.ts +55 -0
- package/src/codex/desktop-compatibility/json-body.ts +29 -0
- package/src/codex/desktop-compatibility/native-identity.ts +62 -0
- package/src/codex/desktop-compatibility/relay-listener.ts +93 -0
- package/src/codex/desktop-compatibility/routing-binding.ts +10 -0
- package/src/codex/desktop-compatibility/routing-preflight.ts +69 -0
- package/src/codex/desktop-compatibility/runtime-ownership.ts +28 -0
- package/src/codex/desktop-compatibility/runtime.ts +173 -0
- package/src/codex/desktop-compatibility/service.ts +30 -0
- package/src/codex/desktop-compatibility/startup-settings.ts +50 -0
- package/src/codex/desktop-compatibility/usage-activation.ts +86 -0
- package/src/codex/desktop-compatibility/usage-controlled-fetch.ts +59 -0
- package/src/codex/desktop-compatibility/usage-controller.ts +107 -0
- package/src/codex/desktop-compatibility/usage-policy.ts +59 -0
- package/src/codex/desktop-compatibility/usage-refresh.ts +33 -0
- package/src/codex/desktop-compatibility/usage-sse-controller.ts +86 -0
- package/src/codex/desktop-compatibility/windows-activation-source.ts +44 -0
- package/src/codex/desktop-compatibility/windows-certificate-trust.ts +81 -0
- package/src/codex/desktop-compatibility/windows-key-protection.ts +59 -0
- package/src/codex/desktop-compatibility/windows-package-command.ts +86 -0
- package/src/codex/desktop-compatibility/windows-package-launch.ts +28 -0
- package/src/codex/history-provider.ts +1 -1
- package/src/codex/inject/config-write-section.ts +4 -1
- package/src/codex/inject/remove.ts +18 -6
- package/src/codex/journal.ts +16 -1
- package/src/codex/main-account.ts +99 -15
- package/src/codex/native-main-refresh-mode.ts +217 -0
- package/src/codex/routing/health-store.ts +1 -1
- package/src/codex/routing/pool-recovery.ts +79 -0
- package/src/codex/shim-diagnostics.ts +6 -0
- package/src/codex/shim-overlay.ts +7 -2
- package/src/config/diagnostics.ts +2 -1
- package/src/config/live-reconcile.ts +13 -0
- package/src/config/load-degrade.ts +6 -1
- package/src/config/paths.ts +10 -3
- package/src/config/schema/config-schema.ts +17 -0
- package/src/config/schema/desktop-compatibility.ts +9 -0
- package/src/config/schema/leaf-validators.ts +1 -0
- package/src/generated/compatibility-version.json +451 -163
- package/src/generated/model-metadata.ts +1 -1
- package/src/integrations/catalog-refresh.ts +1 -1
- package/src/integrations/config-io.ts +20 -2
- package/src/integrations/journal.ts +15 -1
- package/src/integrations/mutation-plan.ts +1 -0
- package/src/integrations/registry.ts +7 -0
- package/src/integrations/store.ts +3 -0
- package/src/integrations/writer.ts +10 -1
- package/src/lib/advisor-activation.ts +16 -0
- package/src/lib/background-work.ts +65 -0
- package/src/lib/bounded-file-read.ts +58 -0
- package/src/lib/bun-path-runtime.d.mts +10 -1
- package/src/lib/bun-path-runtime.mjs +62 -3
- package/src/lib/desktop-proxy-route.ts +30 -0
- package/src/lib/desktop-upstream-tunnel.ts +107 -0
- package/src/lib/errors.ts +7 -0
- package/src/lib/local-internal-call-capability.ts +55 -0
- package/src/lib/package-tree-integrity.ts +2 -2
- package/src/lib/socks5-fetch.ts +48 -71
- package/src/lib/socks5-handshake.ts +112 -0
- package/src/lib/translator-budget.ts +7 -1
- package/src/oauth/generic-account-failover.ts +29 -5
- package/src/oauth/index.ts +4 -2
- package/src/oauth/kiro-device-login.ts +2 -1
- package/src/oauth/kiro.ts +7 -1
- package/src/protocols/codecs/chat-ir.ts +467 -0
- package/src/protocols/codecs/chat.ts +10 -1
- package/src/protocols/codecs/messages-ir.ts +500 -0
- package/src/protocols/features.ts +10 -2
- package/src/protocols/path.ts +11 -1
- package/src/protocols/plan-snapshot.ts +39 -16
- package/src/protocols/plan.ts +9 -1
- package/src/protocols/settings.ts +8 -0
- package/src/providers/antigravity-account-roster.ts +102 -0
- package/src/providers/antigravity-models.ts +85 -7
- package/src/providers/model-rename-fields.ts +2 -0
- package/src/providers/model-rename-migration.ts +5 -3
- package/src/providers/registry/entries-core.ts +7 -6
- package/src/providers/registry/entries-extended.ts +19 -0
- package/src/responses/parser.ts +30 -19
- package/src/responses/state.ts +32 -29
- package/src/routing/history/build-file.ts +92 -0
- package/src/routing/history/indexer.ts +248 -216
- package/src/routing/history/ingest.ts +238 -0
- package/src/routing/history/rebuild-job.ts +111 -0
- package/src/routing/history/rebuild-protocol.ts +15 -0
- package/src/routing/history/rebuild-worker.ts +34 -0
- package/src/server/admission-model-scope.ts +4 -1
- package/src/server/antigravity-direct-eligibility.ts +208 -0
- package/src/server/auth-cors.ts +4 -0
- package/src/server/chat-completions.ts +75 -9
- package/src/server/claude-messages.ts +110 -20
- package/src/server/index/desktop-compatibility-startup.ts +56 -0
- package/src/server/index/serve-options.ts +2 -2
- package/src/server/index/startup-warnings.ts +9 -0
- package/src/server/index/websocket-handler.ts +9 -0
- package/src/server/index.ts +13 -10
- package/src/server/inference/client-encoder-delivery.ts +6 -5
- package/src/server/login-shell-path.ts +146 -0
- package/src/server/management/advisor-routes.ts +191 -0
- package/src/server/management/agent-settings-routes.ts +22 -4
- package/src/server/management/companion-routes.ts +4 -0
- package/src/server/management/config-routes.ts +2 -2
- package/src/server/management/context.ts +4 -0
- package/src/server/management/desktop-compatibility-routes.ts +46 -0
- package/src/server/management/desktop-compatibility-runtime-routes.ts +46 -0
- package/src/server/management/desktop-compatibility-settings-routes.ts +27 -0
- package/src/server/management/model-routes.ts +2 -2
- package/src/server/management/protocol-settings-patch.ts +1 -0
- package/src/server/management/route-registry.ts +11 -0
- package/src/server/management/sibling-guard.ts +1 -0
- package/src/server/management-api.ts +6 -0
- package/src/server/messages-antigravity-direct.ts +195 -0
- package/src/server/messages-native.ts +5 -1
- package/src/server/request-log.ts +7 -0
- package/src/server/responses/adapter-continuation.ts +35 -18
- package/src/server/responses/adapter-delivery.ts +24 -2
- package/src/server/responses/adapter-dispatch.ts +29 -2
- package/src/server/responses/advisor-plan-slot.ts +27 -0
- package/src/server/responses/advisor-slot.ts +336 -0
- package/src/server/responses/code-mode-collab-surface.ts +116 -8
- package/src/server/responses/core-combo-failure.ts +10 -1
- package/src/server/responses/core-combo.ts +21 -0
- package/src/server/responses/core-lifetime.ts +2 -0
- package/src/server/responses/core-normalize.ts +11 -0
- package/src/server/responses/core-options.ts +46 -0
- package/src/server/responses/core-replay.ts +7 -4
- package/src/server/responses/core.ts +1 -1
- package/src/server/responses/passthrough-delivery.ts +16 -10
- package/src/server/responses/passthrough-dispatch.ts +14 -3
- package/src/server/responses/request-prepare.ts +68 -5
- package/src/server/responses/request-transport.ts +16 -0
- package/src/server/responses/run-turn-execution.ts +70 -8
- package/src/server/responses/sidecar-execution.ts +78 -3
- package/src/server/responses/terminal-guard.ts +2 -1
- package/src/server/responses-undeclared-tool-guard.ts +30 -9
- package/src/server/startup-action-control.ts +16 -2
- package/src/server/startup-health-cache.ts +1 -0
- package/src/service/desktop-startup.ts +14 -0
- package/src/service/health-sentinel-main.ts +23 -4
- package/src/service/health-sentinel-probe.ts +24 -7
- package/src/service/logging.ts +72 -0
- package/src/tray/windows-tray.ps1 +43 -24
- package/src/types/config.ts +63 -6
- package/src/types/provider.ts +7 -0
- package/src/types/request.ts +15 -0
- package/src/types/tools.ts +2 -0
- package/src/update/async-check.ts +1 -1
- package/src/update/badge.ts +2 -2
- package/src/update/index.ts +88 -15
- package/src/update/job-supervised-restart.ts +107 -0
- package/src/update/job.ts +32 -32
- package/src/update/notify.ts +1 -1
- package/src/update/refresh-scheduler.ts +2 -2
- package/src/update/supervised-recovery.d.mts +27 -0
- package/src/update/supervised-recovery.mjs +157 -0
- package/src/usage/cache-diagnostic.ts +2 -0
- package/src/usage/expected-prices.ts +12 -0
- package/src/web-search/gemini-executor.ts +2 -2
- package/assets/sponsors/packycode.png +0 -0
- package/gui/dist/assets/App-CZtqiGcw.js +0 -56
- package/gui/dist/assets/index-CTm4l-HQ.js +0 -94
- package/gui/dist/assets/quota-summary-BbEeRfPY.js +0 -1
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
type Rewrite = (text: string) => Promise<string | null>;
|
|
2
|
+
|
|
3
|
+
async function rewriteRecord(bytes: Buffer, first: boolean, rewrite: Rewrite): Promise<Buffer> {
|
|
4
|
+
let text: string;
|
|
5
|
+
try { text = new TextDecoder('utf-8', { fatal: true, ignoreBOM: true }).decode(bytes); }
|
|
6
|
+
catch { return Buffer.from(bytes); }
|
|
7
|
+
const lines = [...text.matchAll(/([^\r\n]*)(\r\n|\r|\n|$)/g)].filter(m => m[0].length > 0);
|
|
8
|
+
const data: string[] = [], indexes = new Set<number>(); let event = '';
|
|
9
|
+
lines.forEach((line, i) => {
|
|
10
|
+
const content = first && i === 0 ? line[1]!.replace(/^\uFEFF/, '') : line[1]!;
|
|
11
|
+
if (content.startsWith(':')) return;
|
|
12
|
+
const colon = content.indexOf(':');
|
|
13
|
+
const key = colon < 0 ? content : content.slice(0, colon);
|
|
14
|
+
const value = colon < 0 ? '' : content.slice(colon + 1).replace(/^ /, '');
|
|
15
|
+
if (key === 'event') event = value;
|
|
16
|
+
if (key === 'data') { data.push(value); indexes.add(i); }
|
|
17
|
+
});
|
|
18
|
+
if (event !== 'usage.snapshot' || !data.length) return Buffer.from(bytes);
|
|
19
|
+
const changed = await rewrite(data.join('\n'));
|
|
20
|
+
if (changed === null) return Buffer.from(bytes);
|
|
21
|
+
let written = false;
|
|
22
|
+
return Buffer.from(lines.map((line, i) => {
|
|
23
|
+
if (!indexes.has(i)) return line[0];
|
|
24
|
+
if (written) return '';
|
|
25
|
+
written = true;
|
|
26
|
+
const bom = first && i === 0 && line[1]!.startsWith('\uFEFF') ? '\uFEFF' : '';
|
|
27
|
+
return `${bom}data: ${changed}${line[2]}`;
|
|
28
|
+
}).join(''));
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** Byte-bounded SSE framing; never fabricates events or changes stream sequence numbers. */
|
|
32
|
+
export function controlledUsageSse(rewrite: Rewrite, cap = 262144): TransformStream<Uint8Array, Uint8Array> {
|
|
33
|
+
if (!Number.isSafeInteger(cap) || cap < 1 || cap > 1048576) throw new Error('Invalid record limit');
|
|
34
|
+
const buffer = Buffer.alloc(cap);
|
|
35
|
+
let used = 0, lineHasBytes = false, pendingCR = false, first = true, passthrough = false;
|
|
36
|
+
return new TransformStream({
|
|
37
|
+
async transform(chunk, controller) {
|
|
38
|
+
let rawStart = passthrough ? 0 : -1;
|
|
39
|
+
const enterPassthrough = (start: number) => {
|
|
40
|
+
controller.enqueue(Buffer.from(buffer.subarray(0, used)));
|
|
41
|
+
used = 0; passthrough = true; rawStart = start;
|
|
42
|
+
};
|
|
43
|
+
const endRecord = async (rawEnd: number) => {
|
|
44
|
+
if (passthrough) {
|
|
45
|
+
if (rawEnd > rawStart) controller.enqueue(chunk.subarray(rawStart, rawEnd));
|
|
46
|
+
passthrough = false; rawStart = -1;
|
|
47
|
+
} else {
|
|
48
|
+
controller.enqueue(await rewriteRecord(buffer.subarray(0, used), first, rewrite));
|
|
49
|
+
}
|
|
50
|
+
first = false; used = 0; lineHasBytes = false;
|
|
51
|
+
};
|
|
52
|
+
for (let i = 0; i < chunk.length; i++) {
|
|
53
|
+
const byte = chunk[i]!;
|
|
54
|
+
if (pendingCR) {
|
|
55
|
+
pendingCR = false;
|
|
56
|
+
if (byte !== 10) {
|
|
57
|
+
if (!lineHasBytes) await endRecord(i);
|
|
58
|
+
lineHasBytes = false;
|
|
59
|
+
}
|
|
60
|
+
else {
|
|
61
|
+
if (!passthrough && used === cap) enterPassthrough(i);
|
|
62
|
+
if (!passthrough) buffer[used++] = byte;
|
|
63
|
+
const blankLine = !lineHasBytes;
|
|
64
|
+
lineHasBytes = false;
|
|
65
|
+
if (blankLine) await endRecord(i + 1);
|
|
66
|
+
continue;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
if (!passthrough && used === cap) enterPassthrough(i);
|
|
70
|
+
if (!passthrough) buffer[used++] = byte;
|
|
71
|
+
if (byte === 13) pendingCR = true;
|
|
72
|
+
else if (byte === 10) {
|
|
73
|
+
const blankLine = !lineHasBytes;
|
|
74
|
+
lineHasBytes = false;
|
|
75
|
+
if (blankLine) await endRecord(i + 1);
|
|
76
|
+
} else lineHasBytes = true;
|
|
77
|
+
}
|
|
78
|
+
if (passthrough && rawStart < chunk.length) controller.enqueue(chunk.subarray(rawStart));
|
|
79
|
+
},
|
|
80
|
+
async flush(controller) {
|
|
81
|
+
if (passthrough) return;
|
|
82
|
+
if (pendingCR && !lineHasBytes) { controller.enqueue(await rewriteRecord(buffer.subarray(0, used), first, rewrite)); used = 0; }
|
|
83
|
+
if (used > 0) controller.enqueue(Buffer.from(buffer.subarray(0, used)));
|
|
84
|
+
},
|
|
85
|
+
});
|
|
86
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
// Kept inline so source and standalone Bun distributions carry the same COM adapter.
|
|
2
|
+
export const WINDOWS_ACTIVATION_SOURCE = String.raw`using System;
|
|
3
|
+
using System.Runtime.InteropServices;
|
|
4
|
+
using System.Text;
|
|
5
|
+
|
|
6
|
+
// Windows.Launch preserves package identity and forwards explicit application arguments.
|
|
7
|
+
public static class OpenCodexPackageActivation {
|
|
8
|
+
[ComImport, Guid("2E941141-7F97-4756-BA1D-9DECDE894A3D"), InterfaceType(ComInterfaceType.InterfaceIsIUnknown)]
|
|
9
|
+
private interface IApplicationActivationManager {
|
|
10
|
+
[PreserveSig] int ActivateApplication([MarshalAs(UnmanagedType.LPWStr)] string appId, [MarshalAs(UnmanagedType.LPWStr)] string arguments, uint options, out uint processId);
|
|
11
|
+
[PreserveSig] int ActivateForFile([MarshalAs(UnmanagedType.LPWStr)] string appId, IntPtr items, [MarshalAs(UnmanagedType.LPWStr)] string verb, out uint processId);
|
|
12
|
+
[PreserveSig] int ActivateForProtocol([MarshalAs(UnmanagedType.LPWStr)] string appId, IntPtr items, out uint processId);
|
|
13
|
+
}
|
|
14
|
+
[DllImport("ole32.dll", PreserveSig=true)]
|
|
15
|
+
private static extern int CoCreateInstance(ref Guid clsid, IntPtr outer, uint context, ref Guid iid, [MarshalAs(UnmanagedType.Interface)] out IApplicationActivationManager manager);
|
|
16
|
+
[DllImport("kernel32.dll", SetLastError=true)] private static extern IntPtr OpenProcess(uint access, bool inherit, uint pid);
|
|
17
|
+
[DllImport("kernel32.dll")] private static extern bool CloseHandle(IntPtr handle);
|
|
18
|
+
[DllImport("kernel32.dll", CharSet=CharSet.Unicode)] private static extern int GetPackageFullName(IntPtr process, ref uint length, StringBuilder name);
|
|
19
|
+
private static IApplicationActivationManager Manager() {
|
|
20
|
+
var clsid=new Guid("45BA127D-10A8-46EA-8AB7-56EA9078943C");
|
|
21
|
+
var iid=new Guid("2E941141-7F97-4756-BA1D-9DECDE894A3D");
|
|
22
|
+
IApplicationActivationManager manager;
|
|
23
|
+
// LOCAL_SERVER preserves activation argument lifetime after the short launcher exits.
|
|
24
|
+
Marshal.ThrowExceptionForHR(CoCreateInstance(ref clsid,IntPtr.Zero,4,ref iid,out manager));
|
|
25
|
+
return manager;
|
|
26
|
+
}
|
|
27
|
+
public static void ValidateActivationService() {var manager=Manager();Marshal.FinalReleaseComObject(manager);}
|
|
28
|
+
public static uint Activate(string appId,string arguments) {
|
|
29
|
+
var manager=Manager();
|
|
30
|
+
try {uint pid;Marshal.ThrowExceptionForHR(manager.ActivateApplication(appId,arguments,0,out pid));return pid;}
|
|
31
|
+
finally {Marshal.FinalReleaseComObject(manager);}
|
|
32
|
+
}
|
|
33
|
+
public static string PackageOf(uint pid) {
|
|
34
|
+
var handle=OpenProcess(0x1000,false,pid);
|
|
35
|
+
if(handle==IntPtr.Zero)throw new System.ComponentModel.Win32Exception(Marshal.GetLastWin32Error());
|
|
36
|
+
try {uint length=0;int status=GetPackageFullName(handle,ref length,null);
|
|
37
|
+
if(status==15700)return null;
|
|
38
|
+
if(status!=122)throw new System.ComponentModel.Win32Exception(status);
|
|
39
|
+
var value=new StringBuilder((int)length);status=GetPackageFullName(handle,ref length,value);
|
|
40
|
+
if(status!=0)throw new System.ComponentModel.Win32Exception(status);
|
|
41
|
+
return value.ToString();
|
|
42
|
+
}finally{CloseHandle(handle);}
|
|
43
|
+
}
|
|
44
|
+
}`;
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import { execFile } from "node:child_process";
|
|
2
|
+
import { X509Certificate } from "node:crypto";
|
|
3
|
+
import { resolveTrustedWindowsPowerShellExe } from "../../lib/windows-elevation";
|
|
4
|
+
import type { StoredDesktopAuthority } from "./certificate-store";
|
|
5
|
+
import { isTestHomeGuardArmed } from "../../lib/test-home-guard";
|
|
6
|
+
import { isServerAuthOnlyCertificate } from "../../claude/intercept/local-ca";
|
|
7
|
+
|
|
8
|
+
export type DesktopCertificateTrust = "trusted" | "not-trusted" | "unknown";
|
|
9
|
+
export type TrustOperation = "inspect" | "trust" | "remove";
|
|
10
|
+
export type CertificateTrustRunner = (operation: TrustOperation, certificateDer: string, fingerprint: string) => Promise<DesktopCertificateTrust>;
|
|
11
|
+
|
|
12
|
+
const SCRIPT = [
|
|
13
|
+
"$ErrorActionPreference='Stop'",
|
|
14
|
+
"$inputData=[Console]::In.ReadToEnd()|ConvertFrom-Json",
|
|
15
|
+
"$bytes=[Convert]::FromBase64String($inputData.certificate)",
|
|
16
|
+
"$cert=[Security.Cryptography.X509Certificates.X509Certificate2]::new($bytes)",
|
|
17
|
+
"$sha=[Security.Cryptography.SHA256]::Create()",
|
|
18
|
+
"try {$fingerprint=([BitConverter]::ToString($sha.ComputeHash($bytes))).Replace('-','')}finally{$sha.Dispose()}",
|
|
19
|
+
"if($fingerprint -cne $inputData.fingerprint){throw 'Certificate changed'}",
|
|
20
|
+
"$store=[Security.Cryptography.X509Certificates.X509Store]::new('Root','CurrentUser')",
|
|
21
|
+
"$mode=if($inputData.operation -eq 'inspect'){[Security.Cryptography.X509Certificates.OpenFlags]::ReadOnly}else{[Security.Cryptography.X509Certificates.OpenFlags]::ReadWrite}",
|
|
22
|
+
"try {",
|
|
23
|
+
" $store.Open($mode)",
|
|
24
|
+
" $foundCertificates=@($store.Certificates.Find([Security.Cryptography.X509Certificates.X509FindType]::FindByThumbprint,$cert.Thumbprint,$false))",
|
|
25
|
+
" foreach($certificateMatch in $foundCertificates){if([Convert]::ToBase64String($certificateMatch.RawData) -cne [Convert]::ToBase64String($bytes)){throw 'Certificate mismatch'}}",
|
|
26
|
+
" if($inputData.operation -eq 'trust'){if($foundCertificates.Count -eq 0){$store.Add($cert)}}",
|
|
27
|
+
" elseif($inputData.operation -eq 'remove'){foreach($certificateMatch in $foundCertificates){$store.Remove($certificateMatch)}}",
|
|
28
|
+
" elseif($inputData.operation -ne 'inspect'){throw 'Unknown operation'}",
|
|
29
|
+
" $present=@($store.Certificates.Find([Security.Cryptography.X509Certificates.X509FindType]::FindByThumbprint,$cert.Thumbprint,$false)).Count -gt 0",
|
|
30
|
+
" if($present){[Console]::Out.Write('trusted')}else{[Console]::Out.Write('not-trusted')}",
|
|
31
|
+
"}finally{$store.Close();$store.Dispose();$cert.Dispose()}",
|
|
32
|
+
].join("\n");
|
|
33
|
+
|
|
34
|
+
const run: CertificateTrustRunner = (operation, certificateDer, fingerprint) => {
|
|
35
|
+
if (process.platform !== "win32") return Promise.resolve("unknown");
|
|
36
|
+
if (operation !== "inspect" && isTestHomeGuardArmed()) return Promise.resolve("unknown");
|
|
37
|
+
return new Promise(resolve => {
|
|
38
|
+
const child = execFile(resolveTrustedWindowsPowerShellExe(), ["-NoProfile", "-NonInteractive", "-Command", SCRIPT], {
|
|
39
|
+
timeout: operation === "inspect" ? 10_000 : 120_000, maxBuffer: 4096, windowsHide: true, encoding: "utf8",
|
|
40
|
+
}, (error, stdout) => resolve(!error && (stdout.trim() === "trusted" || stdout.trim() === "not-trusted")
|
|
41
|
+
? stdout.trim() as DesktopCertificateTrust : "unknown"));
|
|
42
|
+
child.stdin?.on("error", () => { /* completion and readback decide the result */ });
|
|
43
|
+
child.stdin?.end(JSON.stringify({ operation, certificate: certificateDer, fingerprint }));
|
|
44
|
+
});
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
/** Public status cannot create a certificate, decrypt a key or alter OS trust. */
|
|
48
|
+
export async function inspectWindowsCertificateTrust(certPem: string, expectedFingerprint: string, runner: CertificateTrustRunner = run): Promise<DesktopCertificateTrust> {
|
|
49
|
+
const certificate = new X509Certificate(certPem);
|
|
50
|
+
const fingerprint = certificate.fingerprint256.replaceAll(":", "");
|
|
51
|
+
if (!certificate.ca || fingerprint !== expectedFingerprint || !/^[A-F0-9]{64}$/.test(expectedFingerprint)) {
|
|
52
|
+
throw new Error("desktop_compatibility_certificate_mismatch");
|
|
53
|
+
}
|
|
54
|
+
return runner("inspect", certificate.raw.toString("base64"), fingerprint).catch(() => "unknown" as const);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** Mutations require the store-validated private key, not unverified public status metadata. */
|
|
58
|
+
export function createWindowsCertificateTrust(authority: StoredDesktopAuthority, expectedFingerprint: string, runner: CertificateTrustRunner = run) {
|
|
59
|
+
const certificate = new X509Certificate(authority.authority.certPem);
|
|
60
|
+
const fingerprint = certificate.fingerprint256.replaceAll(":", "");
|
|
61
|
+
if (fingerprint !== expectedFingerprint || !certificate.ca || !certificate.checkPrivateKey(authority.authority.privateKey)
|
|
62
|
+
|| !certificate.verify(authority.authority.publicKey) || !/^OpenCodex Codex Desktop [0-9a-f-]{36}$/.test(authority.commonName)
|
|
63
|
+
|| !certificate.subject.split("\n").includes(`CN=${authority.commonName}`)) throw new Error("desktop_compatibility_certificate_mismatch");
|
|
64
|
+
const encoded = certificate.raw.toString("base64");
|
|
65
|
+
const inspect = () => runner("inspect", encoded, fingerprint).catch(() => "unknown" as const);
|
|
66
|
+
let pending: Promise<DesktopCertificateTrust> | null = null;
|
|
67
|
+
const change = (operation: "trust" | "remove") => {
|
|
68
|
+
if (pending) return Promise.reject(new Error("desktop_compatibility_trust_busy"));
|
|
69
|
+
if (operation === "trust" && Date.parse(certificate.validTo) <= Date.now()) return Promise.reject(new Error("desktop_compatibility_authority_expired"));
|
|
70
|
+
if (operation === "trust" && !isServerAuthOnlyCertificate(certificate)) return Promise.reject(new Error("desktop_compatibility_authority_renewal_required"));
|
|
71
|
+
pending = (async () => {
|
|
72
|
+
const before = await inspect();
|
|
73
|
+
if (before === "unknown") return before;
|
|
74
|
+
if (before === (operation === "trust" ? "trusted" : "not-trusted")) return before;
|
|
75
|
+
try { await runner(operation, encoded, fingerprint); } catch { /* read the exact store after an uncertain action */ }
|
|
76
|
+
return inspect();
|
|
77
|
+
})().finally(() => { pending = null; });
|
|
78
|
+
return pending;
|
|
79
|
+
};
|
|
80
|
+
return { inspect, trust: () => change("trust"), remove: () => change("remove") };
|
|
81
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { execFile } from "node:child_process";
|
|
2
|
+
import { resolveTrustedWindowsPowerShellExe } from "../../lib/windows-elevation";
|
|
3
|
+
|
|
4
|
+
const MAX_INPUT = 65_536;
|
|
5
|
+
const MAX_OUTPUT = 131_072;
|
|
6
|
+
const PURPOSE = "opencodex/codex-desktop-compatibility/authority/v1";
|
|
7
|
+
// A cold Windows PowerShell that must also load System.Security has been measured past 10s on a
|
|
8
|
+
// loaded machine (Windows CI run 38591626343). The call is async and off the request path, so a
|
|
9
|
+
// wider bound costs nothing while healthy and turns a slow start into success instead of a refusal.
|
|
10
|
+
const DPAPI_TIMEOUT_MS = 20_000;
|
|
11
|
+
const SCRIPT = [
|
|
12
|
+
"$ErrorActionPreference='Stop'",
|
|
13
|
+
"Add-Type -AssemblyName System.Security",
|
|
14
|
+
"$r=[Console]::In.ReadToEnd()|ConvertFrom-Json",
|
|
15
|
+
"$data=[Convert]::FromBase64String($r.data)",
|
|
16
|
+
`if($data.Length -eq 0 -or $data.Length -gt ${MAX_INPUT}){throw 'Invalid data size'}`,
|
|
17
|
+
`$entropy=[Text.Encoding]::UTF8.GetBytes('${PURPOSE}')`,
|
|
18
|
+
"$scope=[Security.Cryptography.DataProtectionScope]::CurrentUser",
|
|
19
|
+
"if($r.operation -eq 'protect'){",
|
|
20
|
+
" $out=[Security.Cryptography.ProtectedData]::Protect($data,$entropy,$scope)",
|
|
21
|
+
"}elseif($r.operation -eq 'unprotect'){",
|
|
22
|
+
" $out=[Security.Cryptography.ProtectedData]::Unprotect($data,$entropy,$scope)",
|
|
23
|
+
"}else{throw 'Invalid operation'}",
|
|
24
|
+
"[Console]::Out.Write([Convert]::ToBase64String($out))",
|
|
25
|
+
].join("\n");
|
|
26
|
+
|
|
27
|
+
export interface AuthorityKeyProtection {
|
|
28
|
+
protect(cleartext: Uint8Array): Promise<Uint8Array>;
|
|
29
|
+
unprotect(ciphertext: Uint8Array): Promise<Uint8Array>;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** The payload travels on stdin, never in process arguments, environment or logs. */
|
|
33
|
+
function runDpapi(operation: "protect" | "unprotect", data: Uint8Array): Promise<Uint8Array> {
|
|
34
|
+
if (process.platform !== "win32") return Promise.reject(new Error("desktop_compatibility_windows_required"));
|
|
35
|
+
if (data.byteLength === 0 || data.byteLength > MAX_INPUT) return Promise.reject(new Error("desktop_compatibility_key_size"));
|
|
36
|
+
return new Promise((resolve, reject) => {
|
|
37
|
+
const child = execFile(resolveTrustedWindowsPowerShellExe(), ["-NoProfile", "-NonInteractive", "-Command", SCRIPT], {
|
|
38
|
+
timeout: DPAPI_TIMEOUT_MS, maxBuffer: MAX_OUTPUT, windowsHide: true, encoding: "utf8",
|
|
39
|
+
}, (error, stdout) => {
|
|
40
|
+
// Do not include PowerShell stderr, command output or the input in a propagated error.
|
|
41
|
+
if (error || !/^[A-Za-z0-9+/]+={0,2}$/.test(stdout.trim())) {
|
|
42
|
+
reject(new Error("desktop_compatibility_key_protection_failed")); return;
|
|
43
|
+
}
|
|
44
|
+
const bytes = Buffer.from(stdout.trim(), "base64");
|
|
45
|
+
if (bytes.length === 0 || bytes.length > MAX_INPUT) {
|
|
46
|
+
reject(new Error("desktop_compatibility_key_size")); return;
|
|
47
|
+
}
|
|
48
|
+
resolve(bytes);
|
|
49
|
+
});
|
|
50
|
+
child.stdin?.on("error", () => { /* execFile's completion reports the failed child. */ });
|
|
51
|
+
child.stdin?.end(JSON.stringify({ operation, data: Buffer.from(data).toString("base64") }));
|
|
52
|
+
});
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** CurrentUser protects against other OS identities, not other processes of the same user. */
|
|
56
|
+
export const windowsAuthorityKeyProtection: AuthorityKeyProtection = {
|
|
57
|
+
protect: bytes => runDpapi("protect", bytes),
|
|
58
|
+
unprotect: bytes => runDpapi("unprotect", bytes),
|
|
59
|
+
};
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import type { DesktopAppInstall, DesktopExec, DesktopProcess } from "../desktop-app/types";
|
|
2
|
+
import { resolveTrustedWindowsPowerShellExe } from "../../lib/windows-elevation";
|
|
3
|
+
import { WINDOWS_ACTIVATION_SOURCE } from "./windows-activation-source";
|
|
4
|
+
import { readDesktopCompatibilityLaunch } from "./runtime-ownership";
|
|
5
|
+
|
|
6
|
+
/** Only the feature's loopback PAC endpoint may become a launch argument. */
|
|
7
|
+
export function validatedCompatibilityPacUrl(value: string): string {
|
|
8
|
+
let url: URL;
|
|
9
|
+
try { url = new URL(value); } catch { throw new Error("desktop_compatibility_invalid_pac_url"); }
|
|
10
|
+
if (url.protocol !== "http:" || url.hostname !== "127.0.0.1" || !url.port || Number(url.port) < 1
|
|
11
|
+
|| url.username || url.password || url.search || url.hash
|
|
12
|
+
|| !/^\/[a-zA-Z0-9-]{16,80}\/proxy\.pac$/.test(url.pathname)
|
|
13
|
+
|| value !== url.href) throw new Error("desktop_compatibility_invalid_pac_url");
|
|
14
|
+
return url.href;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function literal(value: string): string { return `'${value.replaceAll("'", "''")}'`; }
|
|
18
|
+
|
|
19
|
+
export function compatibilityActivationScript(install: DesktopAppInstall, pacUrl: string): string {
|
|
20
|
+
const pac = validatedCompatibilityPacUrl(pacUrl);
|
|
21
|
+
if (!/^OpenAI\.Codex(?:Beta)?_[A-Za-z0-9]+$/.test(install.id) || install.relaunch !== `${install.id}!App`) {
|
|
22
|
+
throw new Error("desktop_compatibility_invalid_package");
|
|
23
|
+
}
|
|
24
|
+
return [
|
|
25
|
+
"$ErrorActionPreference='Stop'",
|
|
26
|
+
`$family=${literal(install.id)}; $root=${literal(install.root)}; $pac=${literal(pac)}`,
|
|
27
|
+
`$p=Get-AppxPackage -Name ${literal(install.id.split("_")[0]!)}`,
|
|
28
|
+
"$p=@($p|Where-Object {$_.PackageFamilyName -eq $family -and $_.InstallLocation -ieq $root})",
|
|
29
|
+
"if($p.Count -ne 1){throw 'Package changed'}; $p=$p[0]",
|
|
30
|
+
"$manifest=Get-AppxPackageManifest -Package $p.PackageFullName",
|
|
31
|
+
"$entry=@($manifest.Package.Applications.Application|Where-Object {$_.Id -eq 'App' -and $_.Executable.Replace('/','\\') -ieq 'app\\ChatGPT.exe'})",
|
|
32
|
+
"if($entry.Count -ne 1){throw 'Application entry changed'}",
|
|
33
|
+
"Add-Type -TypeDefinition @'", WINDOWS_ACTIVATION_SOURCE, "'@",
|
|
34
|
+
"$pidValue=[OpenCodexPackageActivation]::Activate($family+'!App','--proxy-pac-url='+$pac)",
|
|
35
|
+
"if([OpenCodexPackageActivation]::PackageOf($pidValue) -ne $p.PackageFullName){throw 'Package identity missing'}",
|
|
36
|
+
"$running=Get-CimInstance Win32_Process -Filter ('ProcessId='+$pidValue)",
|
|
37
|
+
"$expected=Join-Path $p.InstallLocation 'app\\ChatGPT.exe'",
|
|
38
|
+
"if(-not $running -or $running.ExecutablePath -ine $expected -or $running.CommandLine -notmatch ('(?:^|[\\s\"])--proxy-pac-url='+[regex]::Escape($pac)+'(?:$|[\\s\"])')){throw 'Launch arguments not observed'}",
|
|
39
|
+
"[pscustomobject]@{pid=[int]$pidValue;packageFullName=$p.PackageFullName;verified=$true}|ConvertTo-Json -Compress",
|
|
40
|
+
].join("\n");
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
/** Preserve only the current runtime's PAC and lifetime, never trust a URL shape alone. */
|
|
45
|
+
export function captureWindowsCompatibilityContext(processes: readonly DesktopProcess[]): Record<string, string> {
|
|
46
|
+
const members = new Set(processes.map(entry => entry.pid));
|
|
47
|
+
const urls = new Set<string>();
|
|
48
|
+
for (const entry of processes.filter(value => !members.has(value.parentPid))) {
|
|
49
|
+
if (entry.commandLine !== undefined && entry.commandLine.trim() === "") {
|
|
50
|
+
throw new Error("desktop_compatibility_launch_context_unavailable");
|
|
51
|
+
}
|
|
52
|
+
for (const match of (entry.commandLine ?? "").matchAll(/(?:^|\s)"?--proxy-pac-url=([^"\s]+)"?/g)) {
|
|
53
|
+
const url = match[1]!;
|
|
54
|
+
if (url.startsWith("http://127.0.0.1:")) urls.add(validatedCompatibilityPacUrl(url));
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
if (urls.size > 1) throw new Error("desktop_compatibility_conflicting_launch_context");
|
|
58
|
+
if (urls.size === 0) return {};
|
|
59
|
+
const pacUrl = [...urls][0]!, owner = readDesktopCompatibilityLaunch();
|
|
60
|
+
if (!owner || owner.pacUrl !== pacUrl) throw new Error("desktop_compatibility_launch_owner_unverified");
|
|
61
|
+
return { codexCompatibilityPacUrl: pacUrl, codexCompatibilityGeneration: owner.generation };
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** Recheck after the asynchronous stop ladder: identical endpoints can belong to a new runtime. */
|
|
65
|
+
export function assertWindowsCompatibilityContext(context: Record<string, string>): void {
|
|
66
|
+
const owner = readDesktopCompatibilityLaunch();
|
|
67
|
+
if (!owner || owner.pacUrl !== context.codexCompatibilityPacUrl || owner.generation !== context.codexCompatibilityGeneration) {
|
|
68
|
+
throw new Error("desktop_compatibility_launch_owner_unverified");
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export function activateWindowsCodexCompatibility(exec: DesktopExec, install: DesktopAppInstall, pacUrl: string): { pid: number; packageFullName: string } {
|
|
73
|
+
const script = compatibilityActivationScript(install, pacUrl);
|
|
74
|
+
let text: string;
|
|
75
|
+
try { text = exec(resolveTrustedWindowsPowerShellExe(), ["-NoProfile", "-NonInteractive", "-Command", script], { timeout: 15_000, windowsHide: true }); }
|
|
76
|
+
catch { throw new Error("desktop_compatibility_activation_unverified"); }
|
|
77
|
+
let result: unknown;
|
|
78
|
+
try { result = JSON.parse(text.trim().split(/\r?\n/).at(-1) ?? ""); }
|
|
79
|
+
catch { throw new Error("desktop_compatibility_activation_unverified"); }
|
|
80
|
+
if (!result || typeof result !== "object" || Array.isArray(result)) throw new Error("desktop_compatibility_activation_unverified");
|
|
81
|
+
const value = result as Record<string, unknown>;
|
|
82
|
+
if (value.verified !== true || !Number.isSafeInteger(value.pid) || Number(value.pid) <= 0
|
|
83
|
+
|| typeof value.packageFullName !== "string" || !value.packageFullName.startsWith(install.id.split("_")[0]! + "_")
|
|
84
|
+
|| !value.packageFullName.endsWith("_" + install.id.split("_")[1]!)) throw new Error("desktop_compatibility_activation_unverified");
|
|
85
|
+
return { pid: Number(value.pid), packageFullName: value.packageFullName };
|
|
86
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { windowsDefaultExec, windowsDesktopAppAdapter } from "../desktop-app/windows";
|
|
2
|
+
import type { DesktopExec } from "../desktop-app/types";
|
|
3
|
+
import { activateWindowsCodexCompatibility, validatedCompatibilityPacUrl } from "./windows-package-command";
|
|
4
|
+
export { compatibilityActivationScript, validatedCompatibilityPacUrl } from "./windows-package-command";
|
|
5
|
+
|
|
6
|
+
export interface CompatibilityLaunchIo {
|
|
7
|
+
exec?: DesktopExec;
|
|
8
|
+
platform?: string;
|
|
9
|
+
}
|
|
10
|
+
export type CompatibilityLaunchResult =
|
|
11
|
+
| { status: "started"; pid: number; packageFullName: string }
|
|
12
|
+
| { status: "refused"; reason: "unsupported_platform" | "test_environment" | "package_unavailable" | "app_running" | "process_probe_failed" | "activation_failed" };
|
|
13
|
+
|
|
14
|
+
/** Never quits an app, launches the raw EXE, changes login or installs a watcher. */
|
|
15
|
+
export function launchWindowsCodexCompatibility(pacUrl: string, io: CompatibilityLaunchIo = {}): CompatibilityLaunchResult {
|
|
16
|
+
validatedCompatibilityPacUrl(pacUrl);
|
|
17
|
+
if ((io.platform ?? process.platform) !== "win32") return { status: "refused", reason: "unsupported_platform" };
|
|
18
|
+
if (process.env.OCX_TEST_HOME_GUARD === "1" && !io.exec) return { status: "refused", reason: "test_environment" };
|
|
19
|
+
const exec = io.exec ?? windowsDefaultExec;
|
|
20
|
+
const install = windowsDesktopAppAdapter.discover(exec);
|
|
21
|
+
if (!install) return { status: "refused", reason: "package_unavailable" };
|
|
22
|
+
const processes = windowsDesktopAppAdapter.listProcesses(exec, install);
|
|
23
|
+
if (processes === null) return { status: "refused", reason: "process_probe_failed" };
|
|
24
|
+
if (processes.length > 0) return { status: "refused", reason: "app_running" };
|
|
25
|
+
try {
|
|
26
|
+
return { status: "started", ...activateWindowsCodexCompatibility(exec, install, pacUrl) };
|
|
27
|
+
} catch { return { status: "refused", reason: "activation_failed" }; }
|
|
28
|
+
}
|
|
@@ -1831,7 +1831,7 @@ export function countPendingOpencodexHistory(
|
|
|
1831
1831
|
: { pendingRows: 0, backupEntries };
|
|
1832
1832
|
}
|
|
1833
1833
|
try {
|
|
1834
|
-
const db =
|
|
1834
|
+
const db = openCodexStateForPreflight(stateDbPath);
|
|
1835
1835
|
try {
|
|
1836
1836
|
db.exec("PRAGMA busy_timeout = 100");
|
|
1837
1837
|
// Prove the expected history schema is readable without counting unowned routed rows.
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import type { AtomicWriteHooks } from "../../config/atomic-write";
|
|
2
2
|
import { existsSync, readFileSync } from "node:fs";
|
|
3
3
|
import { withConfigMutationLockSync } from "../../config";
|
|
4
|
-
import { ConfigWriteLockRefusal, assertConfigWriteDestination, publishConfigWriteTarget, watchConfigWriteTargets, withConfigWriteLockHeld, type LockHandle } from "../config-write-lock";
|
|
4
|
+
import { ConfigWriteLockRefusal, assertConfigWriteDestination, publishConfigWriteTarget, removeConfigWriteEntry, watchConfigWriteTargets, withConfigWriteLockHeld, type LockHandle } from "../config-write-lock";
|
|
5
5
|
import { CODEX_CONFIG_PATH, CODEX_PROFILE_PATH } from "../paths";
|
|
6
6
|
import { getCodexHome } from "../paths";
|
|
7
7
|
import { codexHomeIsAbsent } from "../codex-home-owner";
|
|
@@ -16,6 +16,9 @@ export function publishCodexArtifact<T>(path: string, held: LockHandle | undefin
|
|
|
16
16
|
if (!held) throw new Error("Codex artifact publication requires a held configuration lock");
|
|
17
17
|
return publishConfigWriteTarget(CODEX_CONFIG_PATH, held, path, run);
|
|
18
18
|
}
|
|
19
|
+
export function removeCodexProfileEntry(held: LockHandle): void {
|
|
20
|
+
removeConfigWriteEntry(CODEX_CONFIG_PATH, held, CODEX_PROFILE_PATH);
|
|
21
|
+
}
|
|
19
22
|
/** Courtesy cleanup re-reads provider ownership inside the same config section. */
|
|
20
23
|
export function cleanExternalProviderJournal(beforeClientWrite?: () => void): void {
|
|
21
24
|
if (codexHomeIsAbsent(getCodexHome())) return;
|
|
@@ -1,8 +1,8 @@
|
|
|
1
|
-
import { beginCodexWriteSection, publishCodexArtifact } from "./config-write-section";
|
|
2
|
-
import { existsSync,
|
|
1
|
+
import { beginCodexWriteSection, publishCodexArtifact, removeCodexProfileEntry } from "./config-write-section";
|
|
2
|
+
import { existsSync, lstatSync, readFileSync } from "node:fs";
|
|
3
3
|
import { normalizeStructuralWhitespace, rootAssignmentKey, rootSourceLines, sourceAssignment, sourceText } from "../toml-source-lines";
|
|
4
4
|
import { atomicWriteFile } from "../../config";
|
|
5
|
-
import { ConfigWriteLockRefusal, configWriteLockFailureMessage, withConfigWriteLockHeld } from "../config-write-lock";
|
|
5
|
+
import { ConfigWriteLockRefusal, configWriteLockFailureMessage, withConfigWriteLockHeld, withConfigWriteRecovery } from "../config-write-lock";
|
|
6
6
|
import type { LockHandle } from "../config-write-lock";
|
|
7
7
|
import {
|
|
8
8
|
REALTIME_WS_BASE_URL_KEY,
|
|
@@ -206,15 +206,26 @@ export function removeCodexConfig(
|
|
|
206
206
|
}
|
|
207
207
|
const locked = withConfigWriteLockHeld(CODEX_CONFIG_PATH, options.heldConfigWriteLock, held => {
|
|
208
208
|
beginCodexWriteSection(held);
|
|
209
|
+
if (!options.preserveProfile) {
|
|
210
|
+
try {
|
|
211
|
+
if (lstatSync(CODEX_PROFILE_PATH).isSymbolicLink()) {
|
|
212
|
+
return { success: false, message: "Codex profile is a symbolic link; configuration and recovery evidence were preserved." };
|
|
213
|
+
}
|
|
214
|
+
} catch (error) {
|
|
215
|
+
if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
|
|
216
|
+
}
|
|
217
|
+
}
|
|
209
218
|
if (!existsSync(CODEX_CONFIG_PATH)) {
|
|
210
219
|
if (!options.preserveProfile && existsSync(CODEX_PROFILE_PATH))
|
|
211
|
-
|
|
220
|
+
removeCodexProfileEntry(held);
|
|
212
221
|
return {
|
|
213
222
|
success: true,
|
|
214
223
|
message: `Codex config not found; no native restore was needed${options.preserveProfile ? "." : ", and the opencodex profile was removed if present."}`,
|
|
215
224
|
};
|
|
216
225
|
}
|
|
217
|
-
const
|
|
226
|
+
const preimage = readFileSync(CODEX_CONFIG_PATH);
|
|
227
|
+
return withConfigWriteRecovery(CODEX_CONFIG_PATH, held, () => {
|
|
228
|
+
const rawContent = preimage.toString("utf-8");
|
|
218
229
|
// Same EOL boundary as inject: strip in LF space, write back in the file's own ending.
|
|
219
230
|
// The unchanged fast path compares in LF space so an untouched file is never rewritten.
|
|
220
231
|
const eol = dominantEol(rawContent);
|
|
@@ -248,7 +259,7 @@ export function removeCodexConfig(
|
|
|
248
259
|
publishCodexArtifact(CODEX_CONFIG_PATH, held, (path, hooks) => atomicWriteFile(path, applyEol(finalContent, eol), undefined, hooks));
|
|
249
260
|
}
|
|
250
261
|
if (!options.preserveProfile && existsSync(CODEX_PROFILE_PATH))
|
|
251
|
-
|
|
262
|
+
removeCodexProfileEntry(held);
|
|
252
263
|
const retainedNote = retainedBlock === null
|
|
253
264
|
? ""
|
|
254
265
|
: " Kept [model_providers.opencodex] so conversations already tagged opencodex still open;"
|
|
@@ -272,6 +283,7 @@ export function removeCodexConfig(
|
|
|
272
283
|
message: removedMessage,
|
|
273
284
|
...(retainedBlock === null ? {} : { retainedProviderTable: retainedBlock.replace(/\n+$/, "").split("\n") }),
|
|
274
285
|
};
|
|
286
|
+
}, { preimage, requireMatchingPreimage: true });
|
|
275
287
|
});
|
|
276
288
|
if (!locked.ok) return { success: false, retryable: locked.error === "locked", message: configWriteLockFailureMessage(locked) };
|
|
277
289
|
return locked.value;
|
package/src/codex/journal.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { beginCodexWriteSection, publishCodexArtifact } from "./inject/config-write-section";
|
|
2
2
|
import { createHash } from "node:crypto";
|
|
3
3
|
import type { AtomicWriteHooks } from "../config/atomic-write";
|
|
4
|
-
import { existsSync, readFileSync, unlinkSync } from "node:fs";
|
|
4
|
+
import { existsSync, lstatSync, readFileSync, unlinkSync } from "node:fs";
|
|
5
5
|
import { join } from "node:path";
|
|
6
6
|
import { atomicWriteFile } from "../config";
|
|
7
7
|
import { assertCodexHomeOwner, codexHomeIsAbsent, CODEX_HOME_JOURNAL_FILE, CodexHomeOwnerRefusal, opencodexHomeForInjection, readCodexHomeJournal, type CodexHomeOwnerRefusalReason } from "./codex-home-owner";
|
|
@@ -347,6 +347,21 @@ export function restoreJournalState(
|
|
|
347
347
|
};
|
|
348
348
|
}
|
|
349
349
|
|
|
350
|
+
// A matching byte hash does not authorize following a newly substituted profile link.
|
|
351
|
+
// Refuse the complete pair before restoring config or deleting its recovery journal.
|
|
352
|
+
let profileIsSymlink = false;
|
|
353
|
+
if (profileUnchanged && (!comparison.profileAlreadyOriginal || comparison.originalProfile === null)) {
|
|
354
|
+
try { profileIsSymlink = lstatSync(CODEX_PROFILE_PATH).isSymbolicLink(); }
|
|
355
|
+
catch (error) { if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; }
|
|
356
|
+
}
|
|
357
|
+
if (profileIsSymlink) {
|
|
358
|
+
return {
|
|
359
|
+
configRestored: comparison.configAlreadyOriginal, profileRestored: false,
|
|
360
|
+
configRewritten: false, profileRewritten: false,
|
|
361
|
+
configChanged: !configUnchanged, profileChanged: false,
|
|
362
|
+
complete: false, unverified: false, profileRestoreFailed: true as const,
|
|
363
|
+
};
|
|
364
|
+
}
|
|
350
365
|
assertCodexHomeOwner(CODEX_HOME);
|
|
351
366
|
let configRestored = comparison.configAlreadyOriginal;
|
|
352
367
|
let profileRestored = comparison.profileAlreadyOriginal;
|