@bitkyc08/opencodex 2.7.42 → 2.7.43

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.
Files changed (143) hide show
  1. package/README.md +8 -1
  2. package/bin/ocx.mjs +13 -14
  3. package/gui/dist/assets/index-Czw-jpTU.css +1 -0
  4. package/gui/dist/assets/index-cmds12BG.js +67 -0
  5. package/gui/dist/index.html +2 -2
  6. package/package.json +1 -1
  7. package/src/AGENTS.md +28 -0
  8. package/src/adapters/anthropic.ts +15 -6
  9. package/src/adapters/cursor/native-exec-shell.ts +18 -6
  10. package/src/adapters/cursor/protobuf-events.ts +24 -2
  11. package/src/adapters/cursor/protobuf-request.ts +1 -2
  12. package/src/adapters/cursor/tool-definitions.ts +68 -29
  13. package/src/adapters/google-wire-compiler.ts +4 -0
  14. package/src/adapters/google.ts +128 -2
  15. package/src/adapters/identity.ts +12 -2
  16. package/src/adapters/kiro.ts +49 -6
  17. package/src/adapters/mimo-free.ts +2 -0
  18. package/src/adapters/openai-responses.ts +246 -59
  19. package/src/claude/agents-inject.ts +5 -0
  20. package/src/claude/inbound.ts +26 -9
  21. package/src/cli/account-auth.ts +1 -1
  22. package/src/cli/agent-driven.ts +37 -0
  23. package/src/cli/claude.ts +3 -3
  24. package/src/cli/doctor.ts +23 -18
  25. package/src/cli/help.ts +42 -6
  26. package/src/cli/index.ts +88 -19
  27. package/src/cli/interactive-confirm.ts +129 -0
  28. package/src/cli/opencode.ts +701 -0
  29. package/src/cli/provider-runtime.ts +3 -0
  30. package/src/cli/provider.ts +31 -10
  31. package/src/cli/star-prompt.ts +55 -16
  32. package/src/cli/status.ts +47 -13
  33. package/src/codex/account-id.ts +34 -0
  34. package/src/codex/account-lifecycle.ts +4 -1
  35. package/src/codex/account-namespace-match.ts +63 -0
  36. package/src/codex/account-namespaces.ts +149 -0
  37. package/src/codex/account-pause.ts +20 -0
  38. package/src/codex/account-usability.ts +6 -1
  39. package/src/codex/app-server-processes.ts +511 -0
  40. package/src/codex/auth-api.ts +293 -34
  41. package/src/codex/auth-collision.ts +2 -1
  42. package/src/codex/auth-context.ts +60 -17
  43. package/src/codex/catalog/parsing.ts +16 -1
  44. package/src/codex/catalog/provider-fetch.ts +48 -12
  45. package/src/codex/catalog/sync.ts +18 -3
  46. package/src/codex/catalog.ts +1 -1
  47. package/src/codex/history-provider.ts +145 -1
  48. package/src/codex/inject.ts +114 -14
  49. package/src/codex/main-account.ts +2 -8
  50. package/src/codex/pool-rotation.ts +186 -0
  51. package/src/codex/quota.ts +15 -0
  52. package/src/codex/routing.ts +695 -106
  53. package/src/codex/shim.ts +4 -1
  54. package/src/codex/subagent-defaults.ts +550 -0
  55. package/src/codex/subagent-model-fallback.ts +2 -0
  56. package/src/codex/sync.ts +3 -0
  57. package/src/config.ts +566 -25
  58. package/src/images/artifacts.ts +516 -0
  59. package/src/images/fulfill-video.ts +163 -0
  60. package/src/images/fulfill.ts +111 -0
  61. package/src/images/index.ts +4 -0
  62. package/src/images/loop.ts +789 -0
  63. package/src/images/plan.ts +133 -0
  64. package/src/images/synthetic-tool.ts +133 -0
  65. package/src/images/types.ts +41 -0
  66. package/src/images/xai-client.ts +141 -0
  67. package/src/images/xai-video-client.ts +163 -0
  68. package/src/lib/admin-secrets.ts +25 -0
  69. package/src/lib/config-ownership.ts +327 -0
  70. package/src/lib/crash-guard.ts +2 -0
  71. package/src/lib/destination-policy.ts +122 -4
  72. package/src/lib/pinned-http.ts +151 -0
  73. package/src/lib/process-control.ts +2 -2
  74. package/src/lib/provider-outbound.ts +164 -0
  75. package/src/lib/provider-url.ts +14 -0
  76. package/src/lib/proxy-env.ts +18 -0
  77. package/src/lib/windows-elevation.ts +81 -3
  78. package/src/lib/windows-secret-acl.ts +189 -12
  79. package/src/lib/winsw.ts +2 -0
  80. package/src/oauth/anthropic-routing.ts +570 -0
  81. package/src/oauth/health.ts +6 -0
  82. package/src/oauth/index.ts +283 -72
  83. package/src/oauth/key-providers.ts +17 -6
  84. package/src/oauth/kimi.ts +2 -0
  85. package/src/oauth/kiro-credentials.ts +322 -10
  86. package/src/oauth/kiro.ts +423 -43
  87. package/src/oauth/login-cli.ts +32 -5
  88. package/src/oauth/store.ts +54 -4
  89. package/src/oauth/types.ts +11 -0
  90. package/src/providers/alibaba-region-migration.ts +16 -3
  91. package/src/providers/antigravity-models.ts +3 -0
  92. package/src/providers/api-keys.ts +13 -6
  93. package/src/providers/derive.ts +6 -0
  94. package/src/providers/key-failover.ts +24 -4
  95. package/src/providers/quota.ts +233 -29
  96. package/src/providers/registry.ts +11 -3
  97. package/src/responses/parser.ts +11 -0
  98. package/src/responses/state.ts +22 -8
  99. package/src/responses/tool-groups.ts +19 -0
  100. package/src/router.ts +14 -4
  101. package/src/server/auth-cors.ts +110 -22
  102. package/src/server/claude-messages.ts +8 -1
  103. package/src/server/gui-static.ts +30 -6
  104. package/src/server/images.ts +303 -9
  105. package/src/server/index.ts +77 -9
  106. package/src/server/lifecycle.ts +25 -1
  107. package/src/server/management/agent-settings-routes.ts +28 -4
  108. package/src/server/management/combo-routes.ts +7 -0
  109. package/src/server/management/config-routes.ts +5 -2
  110. package/src/server/management/logs-usage-routes.ts +167 -3
  111. package/src/server/management/oauth-account-routes.ts +163 -17
  112. package/src/server/management/provider-routes.ts +35 -7
  113. package/src/server/management/system-restart.ts +172 -0
  114. package/src/server/management/system-routes.ts +33 -10
  115. package/src/server/management-api.ts +2 -2
  116. package/src/server/management-auth.ts +216 -0
  117. package/src/server/proxy-liveness.ts +14 -3
  118. package/src/server/responses/compact.ts +21 -13
  119. package/src/server/responses/core.ts +583 -152
  120. package/src/server/responses-image-gen-repair.ts +118 -0
  121. package/src/server/responses-item-id-repair.ts +10 -85
  122. package/src/server/sse-payload-rewrite.ts +116 -0
  123. package/src/server/system-env.ts +28 -10
  124. package/src/service.ts +49 -2
  125. package/src/storage/cleanup-job.ts +57 -0
  126. package/src/storage/cleanup.ts +1504 -28
  127. package/src/storage/policy-job.ts +366 -0
  128. package/src/storage/policy-scheduler.ts +40 -0
  129. package/src/storage/policy-worker.ts +53 -0
  130. package/src/storage/policy.ts +522 -0
  131. package/src/storage/restore-job.ts +242 -0
  132. package/src/storage/restore-worker.ts +52 -0
  133. package/src/storage/storage-mutation-coordinator.ts +109 -0
  134. package/src/tray/windows.ts +2 -0
  135. package/src/types.ts +96 -1
  136. package/src/update/index.ts +36 -18
  137. package/src/update/job.ts +103 -12
  138. package/src/update/npm-invocation.d.mts +23 -0
  139. package/src/update/npm-invocation.mjs +94 -0
  140. package/src/usage/debug.ts +2 -0
  141. package/src/usage/log.ts +4 -0
  142. package/gui/dist/assets/index-Bl_VBGoI.js +0 -65
  143. package/gui/dist/assets/index-DfVGuN88.css +0 -1
@@ -0,0 +1,164 @@
1
+ import type { OcxProviderConfig } from "../types";
2
+ import {
3
+ assessUrlDestination,
4
+ DestinationDnsResolutionError,
5
+ providerDestinationConfigError,
6
+ resolvePublicAddresses,
7
+ } from "./destination-policy";
8
+ import { pinnedHttpGet } from "./pinned-http";
9
+ import { outboundProxyConfigured } from "./proxy-env";
10
+ import { publicProviderBaseUrl } from "./provider-url";
11
+
12
+ type ProviderGetInit = Omit<RequestInit, "body" | "method" | "redirect">;
13
+ type ProviderOutboundConfig = Pick<OcxProviderConfig, "baseUrl" | "allowPrivateNetwork"> & {
14
+ fetch?: typeof globalThis.fetch;
15
+ };
16
+ export interface ProviderOutboundDependencies {
17
+ resolveAddresses?: typeof resolvePublicAddresses;
18
+ pinnedGet?: typeof pinnedHttpGet;
19
+ }
20
+
21
+ export class ProviderOutboundPolicyError extends Error {
22
+ override readonly name = "ProviderOutboundPolicyError";
23
+ }
24
+
25
+ function pickPinnedAddress(addresses: Array<{ address: string; family: number }>): { address: string; family: number } {
26
+ return addresses.find(address => address.family === 4) ?? addresses[0]!;
27
+ }
28
+
29
+ function configuredProxyFor(): boolean {
30
+ return outboundProxyConfigured();
31
+ }
32
+
33
+ function normalizeProxyHostname(hostname: string): string {
34
+ const normalized = hostname.trim().toLowerCase().replace(/\.+$/, "");
35
+ return normalized.startsWith("[") && normalized.endsWith("]")
36
+ ? normalized.slice(1, -1)
37
+ : normalized;
38
+ }
39
+
40
+ function noProxyMatches(url: URL): boolean {
41
+ const raw = process.env.NO_PROXY ?? process.env.no_proxy ?? "";
42
+ const hostname = normalizeProxyHostname(url.hostname);
43
+ const port = url.port || (url.protocol === "https:" ? "443" : "80");
44
+ for (const rawEntry of raw.split(",")) {
45
+ let entry = rawEntry.trim().toLowerCase();
46
+ if (!entry) continue;
47
+ if (entry === "*") return true;
48
+ entry = entry.replace(/^https?:\/\//, "").split("/", 1)[0]!;
49
+
50
+ let entryHost = entry;
51
+ let entryPort = "";
52
+ const bracketed = /^\[([^\]]+)](?::(\d+))?$/.exec(entry);
53
+ if (bracketed) {
54
+ entryHost = bracketed[1]!;
55
+ entryPort = bracketed[2] ?? "";
56
+ } else if ((entry.match(/:/g)?.length ?? 0) === 1) {
57
+ const separator = entry.lastIndexOf(":");
58
+ const possiblePort = entry.slice(separator + 1);
59
+ if (/^\d+$/.test(possiblePort)) {
60
+ entryHost = entry.slice(0, separator);
61
+ entryPort = possiblePort;
62
+ }
63
+ }
64
+ if (entryPort && entryPort !== port) continue;
65
+ entryHost = normalizeProxyHostname(entryHost.replace(/^\*?\./, ""));
66
+ if (!entryHost) continue;
67
+ if (hostname === entryHost || hostname.endsWith(`.${entryHost}`)) return true;
68
+ }
69
+ return false;
70
+ }
71
+
72
+ let proxyBoundaryWarned = false;
73
+ let proxyDnsDegradationWarned = false;
74
+
75
+ function warnProxyBoundaryOnce(): void {
76
+ if (proxyBoundaryWarned) return;
77
+ proxyBoundaryWarned = true;
78
+ console.warn(
79
+ "[opencodex] Provider outbound proxy mode preserves Bun proxy/NO_PROXY routing and validates "
80
+ + "the URL plus available local DNS results; the final route and peer cannot be pinned locally.",
81
+ );
82
+ }
83
+
84
+ function warnProxyDnsDegradationOnce(): void {
85
+ if (proxyDnsDegradationWarned) return;
86
+ proxyDnsDegradationWarned = true;
87
+ console.warn(
88
+ "[opencodex] Local DNS could not resolve a proxied provider hostname; continuing after URL/literal checks. "
89
+ + "The proxy-selected peer cannot be verified or pinned locally.",
90
+ );
91
+ }
92
+
93
+ export async function providerRedirectError(response: Response, requestUrl: string): Promise<string | null> {
94
+ if (response.status < 300 || response.status >= 400) return null;
95
+ try { await response.body?.cancel(); } catch { /* ignore cancellation failures */ }
96
+ const location = response.headers.get("location");
97
+ let target = "the final upstream URL";
98
+ if (location) {
99
+ try { target = publicProviderBaseUrl(new URL(location, requestUrl).toString()); } catch { /* keep fallback */ }
100
+ }
101
+ return `provider returned ${response.status} redirect to ${target}; configure the final provider URL directly`;
102
+ }
103
+
104
+ export async function providerOutboundGet(
105
+ name: string,
106
+ provider: ProviderOutboundConfig,
107
+ url: string,
108
+ init: ProviderGetInit = {},
109
+ dependencies: ProviderOutboundDependencies = {},
110
+ ): Promise<Response> {
111
+ if (provider.fetch) {
112
+ // A caller-owned executor cannot be peer-pinned here. This branch keeps literal/config
113
+ // checks and redirect blocking, but does not provide the resolved-address guarantees of
114
+ // the built-in transport. Main-request migration must define that executor contract first.
115
+ const assessment = assessUrlDestination(url);
116
+ if (assessment?.kind === "metadata" || assessment?.kind === "link-local" || assessment?.kind === "unspecified") {
117
+ throw new ProviderOutboundPolicyError(`provider URL targets ${assessment.detail}`);
118
+ }
119
+ if (!provider.allowPrivateNetwork) {
120
+ const destinationError = providerDestinationConfigError(name, {
121
+ baseUrl: url,
122
+ allowPrivateNetwork: false,
123
+ });
124
+ if (destinationError) throw new ProviderOutboundPolicyError(destinationError);
125
+ }
126
+ return provider.fetch(url, { ...init, method: "GET", redirect: "manual" });
127
+ }
128
+ const parsed = new URL(url);
129
+ const proxyConfigured = configuredProxyFor();
130
+ const resolveAddresses = dependencies.resolveAddresses ?? resolvePublicAddresses;
131
+ const pinnedGet = dependencies.pinnedGet ?? pinnedHttpGet;
132
+ let resolved: Awaited<ReturnType<typeof resolvePublicAddresses>>;
133
+ try {
134
+ resolved = await resolveAddresses(url, {
135
+ context: "provider URL",
136
+ allowPrivateNetwork: provider.allowPrivateNetwork,
137
+ });
138
+ } catch (error) {
139
+ const dnsResolutionFailed = error instanceof DestinationDnsResolutionError
140
+ || (error instanceof Error && error.name === "DestinationDnsResolutionError");
141
+ if (!dnsResolutionFailed) {
142
+ throw new ProviderOutboundPolicyError(error instanceof Error ? error.message : "provider destination was blocked");
143
+ }
144
+ if (!proxyConfigured) throw error;
145
+ warnProxyBoundaryOnce();
146
+ warnProxyDnsDegradationOnce();
147
+ return globalThis.fetch(url, { ...init, method: "GET", redirect: "manual" });
148
+ }
149
+ if (proxyConfigured && !resolved.privateNetwork) {
150
+ warnProxyBoundaryOnce();
151
+ return globalThis.fetch(url, { ...init, method: "GET", redirect: "manual" });
152
+ }
153
+ if (proxyConfigured && resolved.privateNetwork && !noProxyMatches(parsed)) {
154
+ const hostname = normalizeProxyHostname(parsed.hostname);
155
+ throw new Error(
156
+ `provider URL resolves to a private-network destination; add ${hostname} to NO_PROXY before using allowPrivateNetwork with an outbound proxy`,
157
+ );
158
+ }
159
+ return pinnedGet(url, pickPinnedAddress(resolved.addresses), init.signal ?? undefined, {
160
+ headers: init.headers,
161
+ rejectUnauthorized: true,
162
+ context: "provider response",
163
+ });
164
+ }
@@ -0,0 +1,14 @@
1
+ /** Strip credentials and non-routing URL components before displaying a provider URL. */
2
+ export function publicProviderBaseUrl(baseUrl: string): string {
3
+ try {
4
+ const parsed = new URL(baseUrl.trim());
5
+ if (parsed.protocol !== "http:" && parsed.protocol !== "https:") return "(invalid URL)";
6
+ parsed.username = "";
7
+ parsed.password = "";
8
+ parsed.search = "";
9
+ parsed.hash = "";
10
+ return parsed.toString().replace(/\/$/, baseUrl.endsWith("/") ? "/" : "");
11
+ } catch {
12
+ return "(invalid URL)";
13
+ }
14
+ }
@@ -0,0 +1,18 @@
1
+ export const OUTBOUND_PROXY_ENV_KEYS = ["HTTP_PROXY", "HTTPS_PROXY", "ALL_PROXY"] as const;
2
+ export const PROXY_ENV_KEYS = [...OUTBOUND_PROXY_ENV_KEYS, "NO_PROXY"] as const;
3
+
4
+ export type ProxyEnvKey = typeof PROXY_ENV_KEYS[number];
5
+ export type ProxyEnvMap = Record<string, string | undefined>;
6
+
7
+ export function proxyEnvPresent(
8
+ key: ProxyEnvKey,
9
+ env: ProxyEnvMap = process.env,
10
+ ): boolean {
11
+ return Boolean(env[key]?.trim() || env[key.toLowerCase()]?.trim());
12
+ }
13
+
14
+ export function outboundProxyConfigured(
15
+ env: ProxyEnvMap = process.env,
16
+ ): boolean {
17
+ return OUTBOUND_PROXY_ENV_KEYS.some(key => proxyEnvPresent(key, env));
18
+ }
@@ -18,9 +18,13 @@ export function setWindowsElevationSpawnForTests(next: ElevationSpawn | null): v
18
18
 
19
19
  type GetSystemDirectoryW = (buffer: Pointer, size: number) => number;
20
20
  type TrustedSystemDirectoryResolver = () => string;
21
+ type IsUserAnAdmin = () => number;
22
+ type WindowsElevationProbe = () => boolean | null;
21
23
 
22
24
  let getSystemDirectoryWFn: GetSystemDirectoryW | null | undefined;
23
25
  let trustedSystemDirectoryResolverForTests: TrustedSystemDirectoryResolver | null = null;
26
+ let isUserAnAdminFn: (() => boolean) | null | undefined;
27
+ let windowsElevationProbeForTests: WindowsElevationProbe | null = null;
24
28
 
25
29
  /** Test-only seam to replace GetSystemDirectoryW-backed resolution. */
26
30
  export function setTrustedWindowsSystemDirectoryResolverForTests(
@@ -29,6 +33,49 @@ export function setTrustedWindowsSystemDirectoryResolverForTests(
29
33
  trustedSystemDirectoryResolverForTests = next;
30
34
  }
31
35
 
36
+ /** Test-only seam for the locale-independent current-token elevation probe. */
37
+ export function setWindowsElevationProbeForTests(next: WindowsElevationProbe | null): void {
38
+ windowsElevationProbeForTests = next;
39
+ }
40
+
41
+ function loadIsUserAnAdmin(): (() => boolean) | null {
42
+ if (isUserAnAdminFn !== undefined) return isUserAnAdminFn;
43
+ if (process.platform !== "win32") {
44
+ isUserAnAdminFn = null;
45
+ return null;
46
+ }
47
+ try {
48
+ const lib = dlopen("shell32.dll", {
49
+ IsUserAnAdmin: {
50
+ args: [],
51
+ // Win32 BOOL is a signed 32-bit integer, not C/C++ bool.
52
+ returns: "i32",
53
+ },
54
+ });
55
+ const isUserAnAdmin = lib.symbols.IsUserAnAdmin as IsUserAnAdmin;
56
+ isUserAnAdminFn = () => isUserAnAdmin() !== 0;
57
+ } catch {
58
+ isUserAnAdminFn = null;
59
+ }
60
+ return isUserAnAdminFn;
61
+ }
62
+
63
+ /**
64
+ * Probe the effective Windows token without parsing localized command output.
65
+ * `null` fails closed: callers must retain the original scheduler error rather
66
+ * than infer that an unknown token state needs elevation.
67
+ */
68
+ export function isCurrentWindowsProcessElevated(): boolean | null {
69
+ if (windowsElevationProbeForTests) return windowsElevationProbeForTests();
70
+ const isUserAnAdmin = loadIsUserAnAdmin();
71
+ if (!isUserAnAdmin) return null;
72
+ try {
73
+ return isUserAnAdmin();
74
+ } catch {
75
+ return null;
76
+ }
77
+ }
78
+
32
79
  function loadGetSystemDirectoryW(): GetSystemDirectoryW | null {
33
80
  if (getSystemDirectoryWFn !== undefined) return getSystemDirectoryWFn;
34
81
  if (process.platform !== "win32") {
@@ -252,6 +299,34 @@ export function schtasksOperationFromArgs(args: string[]): WindowsSchtasksOperat
252
299
  return "other";
253
300
  }
254
301
 
302
+ function schedulerExitStatus(error: unknown): number | null {
303
+ if (!error || typeof error !== "object") return null;
304
+ const status = (error as { status?: unknown }).status;
305
+ return typeof status === "number" && Number.isInteger(status) ? status : null;
306
+ }
307
+
308
+ /**
309
+ * The owned scheduler install has a fixed shape. Restrict the locale-independent
310
+ * fallback to that shape so unrelated schtasks failures cannot request elevation.
311
+ */
312
+ function isOwnedSchedulerCreate(args: string[]): boolean {
313
+ const normalized = args.map(arg => arg.toLowerCase());
314
+ const taskIndex = normalized.indexOf("/tn");
315
+ const xmlIndex = normalized.indexOf("/xml");
316
+ return normalized[0] === "/create"
317
+ && normalized[taskIndex + 1] === "opencodex-proxy"
318
+ && taskIndex > 0
319
+ && xmlIndex > 0
320
+ && Boolean(args[xmlIndex + 1])
321
+ && normalized.includes("/f");
322
+ }
323
+
324
+ function isWindowsSchtasksAccessDeniedError(error: unknown, args: string[]): boolean {
325
+ if (!isOwnedSchedulerCreate(args)) return false;
326
+ return isWindowsAccessDeniedError(error)
327
+ || (schedulerExitStatus(error) === 1 && isCurrentWindowsProcessElevated() === false);
328
+ }
329
+
255
330
  /** Structured Task Scheduler failure that survives formatting and process boundaries. */
256
331
  export class WindowsSchtasksError extends Error {
257
332
  readonly code = "WINDOWS_SCHTASKS_ERROR" as const;
@@ -287,7 +362,8 @@ export class WindowsElevationError extends Error {
287
362
  /** Replace raw schtasks access-denied output with dashboard-friendly guidance. */
288
363
  export function formatWindowsSchtasksError(error: unknown, args: string[]): string {
289
364
  const operation = schtasksOperationFromArgs(args);
290
- const accessDenied = isWindowsAccessDeniedError(error);
365
+ const ownedCreateAccessDenied = isWindowsSchtasksAccessDeniedError(error, args);
366
+ const accessDenied = ownedCreateAccessDenied || isWindowsAccessDeniedError(error);
291
367
  if (!accessDenied) {
292
368
  return error instanceof Error ? error.message : String(error);
293
369
  }
@@ -297,7 +373,7 @@ export function formatWindowsSchtasksError(error: unknown, args: string[]): stri
297
373
  `Command: schtasks ${argsText}`,
298
374
  "Approve the Windows UAC prompt to install the background service, or run `ocx service install` from an elevated PowerShell window.",
299
375
  ].join(" ");
300
- if (operation === "create") {
376
+ if (operation === "create" && ownedCreateAccessDenied) {
301
377
  return `${guidance}\n${WINDOWS_SCHTASKS_CREATE_ACCESS_DENIED_MARKER}`;
302
378
  }
303
379
  return guidance;
@@ -306,7 +382,9 @@ export function formatWindowsSchtasksError(error: unknown, args: string[]): stri
306
382
  export function toWindowsSchtasksError(error: unknown, args: string[]): WindowsSchtasksError {
307
383
  if (error instanceof WindowsSchtasksError) return error;
308
384
  const operation = schtasksOperationFromArgs(args);
309
- const reason: WindowsSchtasksFailureReason = isWindowsAccessDeniedError(error) ? "access-denied" : "other";
385
+ const reason: WindowsSchtasksFailureReason = isWindowsSchtasksAccessDeniedError(error, args)
386
+ ? "access-denied"
387
+ : "other";
310
388
  return new WindowsSchtasksError(operation, reason, formatWindowsSchtasksError(error, args));
311
389
  }
312
390
 
@@ -3,9 +3,14 @@
3
3
  *
4
4
  * On Windows, `chmod` only controls POSIX-style bits in the ACE list and does NOT remove
5
5
  * inherited permissions from other users. Real per-user isolation requires icacls to:
6
- * 1. Disable inheritance (icacls path /inheritance:r)
7
- * 2. Strip broad explicit grants by SID (Everyone, Users, Authenticated Users)
8
- * 3. Grant the current user full control (icacls path /grant:r "CURRENTUSER:(F)")
6
+ * 1. Grant the current user full control (icacls path /grant:r "CURRENTUSER:(F)")
7
+ * 2. Disable inheritance (icacls path /inheritance:r)
8
+ * 3. Strip broad explicit grants by SID (Everyone, Users, Authenticated Users)
9
+ *
10
+ * Owner grant MUST precede `/inheritance:r`. That flag is destructive: it drops inherited
11
+ * ACEs immediately. If a later step times out after inheritance is removed but before an
12
+ * explicit owner ACE exists, the temp is left with a protected empty DACL — owned by the
13
+ * current user yet unreadable/ununlinkable until Full Control is restored (issue #596).
9
14
  *
10
15
  * On non-Windows platforms the helpers fall through to the caller's existing chmod-based
11
16
  * behaviour: they return ok:true without invoking any external process.
@@ -19,6 +24,10 @@
19
24
  * icacls cannot block OAuth logins or token refresh (field report: Kimi auth
20
25
  * stuck behind ETIMEDOUT). Real EPERM/EACCES/exit-code failures still throw:
21
26
  * availability never silently overrides confidentiality for those.
27
+ * hardenSecretPathAsync / hardenSecretDirAsync — same policy, async icacls
28
+ * runner so the event loop is not held for the child lifetime (#612).
29
+ * HardenOptions.timeoutMemoKey — optional destination-path key for the
30
+ * timeout memo (atomic writers mint unique temps; never a parent directory).
22
31
  * hardenSecretDir — same contract for directories.
23
32
  */
24
33
 
@@ -37,6 +46,13 @@ export interface HardenResult {
37
46
 
38
47
  export interface HardenOptions {
39
48
  required: boolean;
49
+ /**
50
+ * Optional timeout-memo key distinct from `targetPath` (issue #612).
51
+ * Atomic writers mint a fresh `.tmp` path per write; keying the timeout cache by the
52
+ * final destination path prevents re-stalling the event loop on every subsequent temp.
53
+ * Must NOT be a parent directory — directory ACLs are not authoritative for new files.
54
+ */
55
+ timeoutMemoKey?: string;
40
56
  }
41
57
 
42
58
  /**
@@ -67,6 +83,7 @@ export interface IcaclsResult {
67
83
  }
68
84
 
69
85
  type IcaclsRunner = (args: string[], timeoutMs: number) => IcaclsResult;
86
+ type AsyncIcaclsRunner = (args: string[], timeoutMs: number) => Promise<IcaclsResult>;
70
87
 
71
88
  function defaultIcaclsRunner(args: string[], timeoutMs: number): IcaclsResult {
72
89
  // Bun.spawnSync with windowsHide: Node execFileSync has hung under the GUI/proxy even
@@ -86,7 +103,43 @@ function defaultIcaclsRunner(args: string[], timeoutMs: number): IcaclsResult {
86
103
  };
87
104
  }
88
105
 
106
+ /**
107
+ * Async icacls runner (#612): yields the event loop while waiting for the child.
108
+ * Timeout provenance is recorded by our timer (async Subprocess has no exitedDueToTimeout);
109
+ * we still await process exit before classifying so settlement is confirmed.
110
+ */
111
+ async function defaultAsyncIcaclsRunner(args: string[], timeoutMs: number): Promise<IcaclsResult> {
112
+ const proc = Bun.spawn(["icacls.exe", ...args], {
113
+ stdin: "ignore",
114
+ stdout: "pipe",
115
+ stderr: "ignore",
116
+ windowsHide: true,
117
+ });
118
+ let timedOutByUs = false;
119
+ const timer = setTimeout(() => {
120
+ timedOutByUs = true;
121
+ try { proc.kill(); } catch { /* already exited */ }
122
+ }, Math.max(1, timeoutMs));
123
+ let exitCode: number | null = null;
124
+ try {
125
+ exitCode = await proc.exited;
126
+ } finally {
127
+ clearTimeout(timer);
128
+ }
129
+ const stdout = proc.stdout
130
+ ? await new Response(proc.stdout).text().catch(() => "")
131
+ : "";
132
+ const timedOut = timedOutByUs;
133
+ return {
134
+ success: !timedOut && exitCode === 0,
135
+ exitCode: timedOut ? null : exitCode,
136
+ timedOut,
137
+ stdout,
138
+ };
139
+ }
140
+
89
141
  let icaclsRunner: IcaclsRunner = defaultIcaclsRunner;
142
+ let asyncIcaclsRunner: AsyncIcaclsRunner = defaultAsyncIcaclsRunner;
90
143
  let platformOverride: string | null = null;
91
144
  let nowFn: () => number = Date.now;
92
145
 
@@ -95,6 +148,11 @@ export function setIcaclsRunnerForTests(runner: IcaclsRunner | null): void {
95
148
  icaclsRunner = runner ?? defaultIcaclsRunner;
96
149
  }
97
150
 
151
+ /** Test seam: replace the async icacls runner. Pass null to restore the default. */
152
+ export function setAsyncIcaclsRunnerForTests(runner: AsyncIcaclsRunner | null): void {
153
+ asyncIcaclsRunner = runner ?? defaultAsyncIcaclsRunner;
154
+ }
155
+
98
156
  /** Test seam: force the platform gate (e.g. "win32") so CI on POSIX reaches the runner. */
99
157
  export function setPlatformForTests(value: string | null): void {
100
158
  platformOverride = value;
@@ -141,8 +199,8 @@ function currentWindowsUser(): string | undefined {
141
199
 
142
200
  /**
143
201
  * Run icacls to harden a single file system entry.
144
- * - Disables inheritance (keeps nothing: /inheritance:r)
145
- * - Grants the current user Full Control
202
+ * Order is intentional (issue #596): grant the owner ACE first so a later
203
+ * `/inheritance:r` or `/remove:g` timeout cannot strand a protected zero-ACE DACL.
146
204
  *
147
205
  * We do NOT use a shell string; all arguments are passed as an array so no
148
206
  * shell injection is possible even for paths with unusual characters.
@@ -151,6 +209,10 @@ function currentWindowsUser(): string | undefined {
151
209
  */
152
210
  const BROAD_SIDS = ["*S-1-1-0", "*S-1-5-11", "*S-1-5-32-545"] as const;
153
211
 
212
+ function grantAce(user: string, directory: boolean): string {
213
+ return directory ? `${user}:(OI)(CI)(F)` : `${user}:(F)`;
214
+ }
215
+
154
216
  function runIcacls(targetPath: string, directory: boolean, deadline: number): void {
155
217
  const user = currentWindowsUser();
156
218
  if (!user) {
@@ -170,13 +232,19 @@ function runIcacls(targetPath: string, directory: boolean, deadline: number): vo
170
232
  if (!result.success) throw icaclsError(step, result);
171
233
  };
172
234
 
173
- // Step 1: disable inheritance and remove inherited ACEs
235
+ // Step 1: grant current user full control BEFORE any destructive ACL change.
236
+ // If this fails, inheritance is untouched and the writer keeps inherited access.
237
+ runOrThrow("/grant:r", [targetPath, "/grant:r", grantAce(user, directory)]);
238
+
239
+ // Step 2: disable inheritance and remove inherited ACEs. The explicit owner ACE
240
+ // from step 1 survives this transition, so a later failure still leaves cleanup access.
174
241
  runOrThrow("/inheritance:r", [targetPath, "/inheritance:r"]);
175
242
 
176
- // Step 2: remove broad explicit grants using stable SIDs (not localized names).
243
+ // Step 3: remove broad explicit grants using stable SIDs (not localized names).
177
244
  // Missing ACEs can yield a non-zero exit; verify with locale-independent /findsid
178
245
  // before accepting the failure as harmless — a swallowed real failure would leave
179
246
  // Everyone/Users/Authenticated Users grants while reporting hardened.
247
+ // `/remove:g` cannot remove the explicit current-user ACE installed in step 1.
180
248
  const removal = run("/remove:g", [targetPath, "/remove:g", ...BROAD_SIDS]);
181
249
  if (!removal.success) {
182
250
  if (removal.timedOut) throw icaclsError("/remove:g", removal);
@@ -191,10 +259,41 @@ function runIcacls(targetPath: string, directory: boolean, deadline: number): vo
191
259
  }
192
260
  }
193
261
  }
262
+ }
263
+
264
+ /** Async counterpart of runIcacls — same step order and timeout/error classification (#612). */
265
+ async function runIcaclsAsync(targetPath: string, directory: boolean, deadline: number): Promise<void> {
266
+ const user = currentWindowsUser();
267
+ if (!user) {
268
+ throw new Error("Cannot determine current Windows user for ACL hardening");
269
+ }
194
270
 
195
- // Step 3: grant current user full control.
196
- const grant = directory ? `${user}:(OI)(CI)(F)` : `${user}:(F)`;
197
- runOrThrow("/grant:r", [targetPath, "/grant:r", grant]);
271
+ const run = async (step: string, args: string[]): Promise<IcaclsResult> => {
272
+ const remaining = deadline - nowFn();
273
+ if (remaining <= 0) {
274
+ throw icaclsError(step, { success: false, exitCode: null, timedOut: true, stdout: "" });
275
+ }
276
+ return asyncIcaclsRunner(args, remaining);
277
+ };
278
+ const runOrThrow = async (step: string, args: string[]): Promise<void> => {
279
+ const result = await run(step, args);
280
+ if (!result.success) throw icaclsError(step, result);
281
+ };
282
+
283
+ await runOrThrow("/grant:r", [targetPath, "/grant:r", grantAce(user, directory)]);
284
+ await runOrThrow("/inheritance:r", [targetPath, "/inheritance:r"]);
285
+
286
+ const removal = await run("/remove:g", [targetPath, "/remove:g", ...BROAD_SIDS]);
287
+ if (!removal.success) {
288
+ if (removal.timedOut) throw icaclsError("/remove:g", removal);
289
+ for (const sid of BROAD_SIDS) {
290
+ const found = await run("/findsid", [targetPath, "/findsid", sid]);
291
+ if (!found.success) throw icaclsError("/findsid", found);
292
+ if (found.stdout.includes(targetPath)) {
293
+ throw icaclsError("/remove:g", removal);
294
+ }
295
+ }
296
+ }
198
297
  }
199
298
 
200
299
  /**
@@ -246,6 +345,27 @@ function describeAclStateAfterTimeout(targetPath: string, deadline: number): str
246
345
  }
247
346
  }
248
347
 
348
+ async function describeAclStateAfterTimeoutAsync(targetPath: string, deadline: number): Promise<string> {
349
+ try {
350
+ for (const sid of BROAD_SIDS) {
351
+ const remaining = deadline - nowFn();
352
+ if (remaining <= 0) return "ACL state unverified (budget exhausted)";
353
+ const found = await asyncIcaclsRunner([targetPath, "/findsid", sid], remaining);
354
+ if (!found.success) return "ACL state unverified (probe failed)";
355
+ if (found.stdout.includes(targetPath)) return "broad ACL grants still present";
356
+ }
357
+ return "no broad ACL grants detected (hardening still incomplete)";
358
+ } catch {
359
+ return "ACL state unverified (probe failed)";
360
+ }
361
+ }
362
+
363
+ function timeoutMemoKey(targetPath: string, opts: HardenOptions): string {
364
+ // Destination-path memo only (issue #612). Never a parent directory — directory ACLs
365
+ // are not authoritative for newly created temps.
366
+ return opts.timeoutMemoKey ?? targetPath;
367
+ }
368
+
249
369
  /**
250
370
  * Shared harden flow for files and directories: one total budget (env-configurable)
251
371
  * covering the initial attempt, ONE timeout retry, and the diagnostic verification.
@@ -261,7 +381,8 @@ function hardenEntry(
261
381
  if (!existsSync(targetPath)) return { ok: true };
262
382
  if (effectivePlatform() !== "win32") return { ok: true };
263
383
  if (cache.has(targetPath)) return { ok: true };
264
- if (timedOutPaths.has(targetPath)) {
384
+ const memoKey = timeoutMemoKey(targetPath, opts);
385
+ if (timedOutPaths.has(memoKey)) {
265
386
  return { ok: false, diagnostics: "ACL hardening skipped — previous attempt timed out" };
266
387
  }
267
388
 
@@ -281,7 +402,7 @@ function hardenEntry(
281
402
 
282
403
  const diagnostics = sanitizeDiagnostics(lastErr);
283
404
  if (isTimeoutError(lastErr)) {
284
- timedOutPaths.add(targetPath);
405
+ timedOutPaths.add(memoKey);
285
406
  const state = describeAclStateAfterTimeout(targetPath, deadline);
286
407
  const annotated = `${diagnostics}; ${state}`;
287
408
  // Timeout-only soft-fail: a hung icacls must not block OAuth/token writes.
@@ -293,6 +414,47 @@ function hardenEntry(
293
414
  return { ok: false, diagnostics };
294
415
  }
295
416
 
417
+ /** Async counterpart of hardenEntry — yields while waiting on icacls (#612). */
418
+ async function hardenEntryAsync(
419
+ targetPath: string,
420
+ directory: boolean,
421
+ opts: HardenOptions,
422
+ cache: Set<string>,
423
+ ): Promise<HardenResult> {
424
+ if (!existsSync(targetPath)) return { ok: true };
425
+ if (effectivePlatform() !== "win32") return { ok: true };
426
+ if (cache.has(targetPath)) return { ok: true };
427
+ const memoKey = timeoutMemoKey(targetPath, opts);
428
+ if (timedOutPaths.has(memoKey)) {
429
+ return { ok: false, diagnostics: "ACL hardening skipped — previous attempt timed out" };
430
+ }
431
+
432
+ const deadline = nowFn() + resolveHardenDeadlineMs();
433
+ let lastErr: unknown;
434
+ for (let attempt = 0; attempt < 2; attempt++) {
435
+ if (attempt > 0 && deadline - nowFn() <= 0) break;
436
+ try {
437
+ await runIcaclsAsync(targetPath, directory, deadline);
438
+ cache.add(targetPath);
439
+ return { ok: true };
440
+ } catch (err) {
441
+ lastErr = err;
442
+ if (!isTimeoutError(err)) break;
443
+ }
444
+ }
445
+
446
+ const diagnostics = sanitizeDiagnostics(lastErr);
447
+ if (isTimeoutError(lastErr)) {
448
+ timedOutPaths.add(memoKey);
449
+ const state = await describeAclStateAfterTimeoutAsync(targetPath, deadline);
450
+ const annotated = `${diagnostics}; ${state}`;
451
+ console.warn(`[opencodex] ${annotated} — continuing without NTFS ACL harden`);
452
+ return { ok: false, diagnostics: annotated };
453
+ }
454
+ if (opts.required) throw new Error(diagnostics);
455
+ return { ok: false, diagnostics };
456
+ }
457
+
296
458
  /**
297
459
  * Harden a single file path with per-user NTFS ACLs on Windows.
298
460
  * On non-Windows platforms, returns ok:true immediately (caller owns chmod).
@@ -304,6 +466,14 @@ export function hardenSecretPath(targetPath: string, opts: HardenOptions): Harde
304
466
  return hardenEntry(targetPath, false, opts, hardenedPaths);
305
467
  }
306
468
 
469
+ /**
470
+ * Async harden for write paths that must not block the event loop (#612).
471
+ * Same success/timeout/error policy as hardenSecretPath.
472
+ */
473
+ export function hardenSecretPathAsync(targetPath: string, opts: HardenOptions): Promise<HardenResult> {
474
+ return hardenEntryAsync(targetPath, false, opts, hardenedPaths);
475
+ }
476
+
307
477
  /**
308
478
  * Harden a directory path with per-user NTFS ACLs on Windows.
309
479
  * On non-Windows platforms, returns ok:true immediately (caller owns chmod).
@@ -314,3 +484,10 @@ export function hardenSecretPath(targetPath: string, opts: HardenOptions): Harde
314
484
  export function hardenSecretDir(targetPath: string, opts: HardenOptions): HardenResult {
315
485
  return hardenEntry(targetPath, true, opts, hardenedDirectories);
316
486
  }
487
+
488
+ /**
489
+ * Async directory harden (#612). Same policy as hardenSecretDir.
490
+ */
491
+ export function hardenSecretDirAsync(targetPath: string, opts: HardenOptions): Promise<HardenResult> {
492
+ return hardenEntryAsync(targetPath, true, opts, hardenedDirectories);
493
+ }
package/src/lib/winsw.ts CHANGED
@@ -19,6 +19,7 @@ import { existsSync, mkdirSync, readFileSync, unlinkSync, writeFileSync } from "
19
19
  import { homedir } from "node:os";
20
20
  import { join, resolve } from "node:path";
21
21
  import { expandUserPath, getConfigDir, loadConfig } from "../config";
22
+ import { recordOwnedConfigPath } from "./config-ownership";
22
23
  import { durableBunPath } from "./bun-runtime";
23
24
  import { serviceApiTokenFilePath } from "./service-secrets";
24
25
 
@@ -133,6 +134,7 @@ export async function ensureWinswBinary(fetchImpl: typeof fetch = fetch): Promis
133
134
  unlinkSync(exe);
134
135
  console.warn("⚠️ Existing WinSW binary failed hash verification; re-downloading.");
135
136
  }
137
+ recordOwnedConfigPath(getConfigDir(), winswDir());
136
138
  if (!existsSync(winswDir())) mkdirSync(winswDir(), { recursive: true });
137
139
  let body: ArrayBuffer;
138
140
  try {