@bitkyc08/opencodex 2.50.0 → 2.51.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.
Files changed (95) hide show
  1. package/bin/ocx.mjs +222 -71
  2. package/gui/dist/assets/{index-C39tnjXO.js → index-D7BdZpZm.js} +1 -1
  3. package/gui/dist/index.html +1 -1
  4. package/package.json +1 -1
  5. package/src/adapters/qoder/adapter.ts +69 -1
  6. package/src/adapters/qoder/scaffold-guard.ts +233 -0
  7. package/src/claude/agents-inject.ts +29 -5
  8. package/src/claude/desktop-3p.ts +31 -3
  9. package/src/claude/gateway-cache.ts +12 -21
  10. package/src/cli/capabilities.ts +28 -0
  11. package/src/cli/claude-agent-startup-sync.ts +26 -1
  12. package/src/cli/claude.ts +138 -20
  13. package/src/cli/config-command.ts +67 -1
  14. package/src/cli/connect.ts +181 -14
  15. package/src/cli/dispatch.ts +53 -9
  16. package/src/cli/doctor.ts +9 -2
  17. package/src/cli/ensure-desired-integrations.ts +10 -0
  18. package/src/cli/gui-pair-client.ts +1 -12
  19. package/src/cli/help.ts +4 -1
  20. package/src/cli/hub.ts +367 -0
  21. package/src/cli/index.ts +94 -30
  22. package/src/cli/launcher-context.ts +1 -1
  23. package/src/cli/registry.ts +43 -3
  24. package/src/cli/status.ts +325 -5
  25. package/src/cli/version-skew.ts +4 -1
  26. package/src/cli.ts +2 -2
  27. package/src/client/catalog-compatibility.ts +192 -0
  28. package/src/client/connect.ts +31 -0
  29. package/src/client/hub-client.ts +52 -0
  30. package/src/client/hub-state.ts +214 -0
  31. package/src/codex/account-usability.ts +48 -12
  32. package/src/codex/auth-api.ts +49 -5
  33. package/src/codex/catalog/effort.ts +67 -8
  34. package/src/codex/catalog/sync.ts +85 -0
  35. package/src/codex/codex-write-lock.ts +11 -2
  36. package/src/codex/desired-state.ts +47 -1
  37. package/src/codex/inject-coordination.ts +10 -5
  38. package/src/codex/inject.ts +26 -10
  39. package/src/codex/loopback-target.ts +45 -0
  40. package/src/codex/routing.ts +48 -1
  41. package/src/codex/runtime.ts +37 -3
  42. package/src/codex/sync.ts +29 -9
  43. package/src/codex/warmup.ts +21 -4
  44. package/src/config/pending-teardown.ts +1 -1
  45. package/src/config.ts +126 -12
  46. package/src/generated/compatibility-version.json +136 -76
  47. package/src/grok/status.ts +9 -1
  48. package/src/integrations/config-io.ts +54 -1
  49. package/src/lib/bun-runtime.ts +1 -1
  50. package/src/lib/gui-pair-capability.ts +27 -0
  51. package/src/lib/local-destinations.ts +162 -0
  52. package/src/lib/package-tree-integrity.ts +1 -1
  53. package/src/lib/process-control.ts +130 -20
  54. package/src/lib/service-secrets.ts +28 -0
  55. package/src/lib/test-home-guard.ts +49 -0
  56. package/src/providers/opencode-go-transport.ts +9 -1
  57. package/src/providers/quota.ts +5 -1
  58. package/src/providers/registry.ts +34 -5
  59. package/src/remote/hub-state.ts +182 -0
  60. package/src/server/auth-cors.ts +5 -0
  61. package/src/server/chat-completions.ts +6 -3
  62. package/src/server/claude-messages.ts +7 -1
  63. package/src/server/hub-state.ts +98 -0
  64. package/src/server/index.ts +124 -6
  65. package/src/server/management/api-access.ts +14 -3
  66. package/src/server/management/config-routes.ts +2 -2
  67. package/src/server/management/cursor-integration-routes.ts +13 -4
  68. package/src/server/proxy-liveness.ts +7 -1
  69. package/src/server/request-log-conversation.ts +41 -1
  70. package/src/server/responses/codex-auth-error.ts +18 -1
  71. package/src/server/responses/codex-ws-exchange.ts +36 -4
  72. package/src/server/responses/codex-ws-wire.ts +75 -4
  73. package/src/server/responses/compact.ts +20 -9
  74. package/src/server/responses/core.ts +57 -10
  75. package/src/server/responses/policy-fallback.ts +7 -1
  76. package/src/server/system-env-shell.ts +14 -2
  77. package/src/server/system-env.ts +106 -14
  78. package/src/service.ts +906 -94
  79. package/src/types/config.ts +57 -4
  80. package/src/update/badge.ts +3 -2
  81. package/src/update/index.ts +317 -64
  82. package/src/update/install-detection.d.mts +6 -0
  83. package/src/update/install-detection.mjs +73 -0
  84. package/src/update/job.ts +101 -49
  85. package/src/update/pnpm-global-install.d.mts +144 -0
  86. package/src/update/pnpm-global-install.mjs +591 -0
  87. package/src/update/pnpm-invocation.d.mts +43 -0
  88. package/src/update/pnpm-invocation.mjs +141 -0
  89. package/src/update/registry-integrity.d.mts +16 -0
  90. package/src/update/registry-integrity.mjs +37 -0
  91. package/src/update/transactional-install.d.mts +1 -1
  92. package/src/update/transactional-install.mjs +101 -7
  93. package/src/update/tray-update-plan.mjs +1 -1
  94. package/src/vision/plan.ts +13 -3
  95. package/src/vision/routed-describe.ts +51 -20
@@ -111,11 +111,18 @@ export function readGrokStatus(opts: { grokHome?: string } = {}): GrokStatus {
111
111
  * `ocx status` is for.
112
112
  *
113
113
  * Returns null when there is nothing to say: no fence, an unparsable endpoint, or a
114
- * fence that already agrees with the live listener.
114
+ * fence that already agrees with a port we are actually listening on.
115
+ *
116
+ * "Listening on" is a SET, not one number (#4236). A hub with an unauthenticated loopback
117
+ * listener answers on the public port and on the listener's port; a fence pointing at the
118
+ * latter is exactly what `ocx sync` wrote, so reporting it as drift told the operator their
119
+ * working config was broken. `loopbackPort` is that second reachable port, already resolved
120
+ * through `effectiveLoopbackListenerPort`, or null when no such listener is configured.
115
121
  */
116
122
  export function grokFenceEndpointDrift(
117
123
  status: Pick<GrokStatus, "present" | "baseUrl">,
118
124
  livePort: number | undefined,
125
+ loopbackPort?: number | null,
119
126
  ): { fencePort: number; livePort: number } | null {
120
127
  if (!status.present || !status.baseUrl) return null;
121
128
  if (typeof livePort !== "number" || !Number.isFinite(livePort) || livePort <= 0) return null;
@@ -130,5 +137,6 @@ export function grokFenceEndpointDrift(
130
137
  return null;
131
138
  }
132
139
  if (!Number.isFinite(fencePort) || fencePort === livePort) return null;
140
+ if (typeof loopbackPort === "number" && fencePort === loopbackPort) return null;
133
141
  return { fencePort, livePort };
134
142
  }
@@ -256,7 +256,10 @@ export function fileIO(): Omit<IntegrationIO, "appendJournal" | "putRecord" | "d
256
256
  return (error as NodeJS.ErrnoException).code === "ENOENT" ? "missing" : "failed";
257
257
  }
258
258
  },
259
- writeText: (path, text) => atomicWriteFile(path, text),
259
+ writeText: (path, text) => {
260
+ assertIntegrationWriteOwnership(path);
261
+ atomicWriteFile(path, text);
262
+ },
260
263
  removeFile: path => rmSync(path, { force: true }),
261
264
  mkdirp: path => mkdirSync(path, { recursive: true, mode: 0o700 }),
262
265
  now: () => Date.now(),
@@ -282,3 +285,53 @@ export function defaultIntegrationIO(store: {
282
285
  dropRecord: clientId => store.dropRecord(clientId),
283
286
  };
284
287
  }
288
+
289
+ /**
290
+ * Refuse to replace an integration file this process does not own.
291
+ *
292
+ * `atomicWriteFile` writes a private temp file and renames it over the target. That is the right
293
+ * shape for a secret — the replacement is atomic and the result is owner-only `0600` — but it also
294
+ * means the surviving inode belongs to whoever runs opencodex. When the target is another product's
295
+ * configuration on a shared mount, the replace quietly takes the file away from its owner. #4197 is
296
+ * that case: opencodex at uid 1000 replaces a DSH `settings.yaml` owned by uid 987, and DSH dies with
297
+ * `EACCES` on its next read while the restore call reports success.
298
+ *
299
+ * Preserving the previous uid would need a `chown` capability we usually do not have, and relaxing
300
+ * `0600` would weaken every integration to fix one. So refuse before writing, and say what is wrong,
301
+ * rather than succeeding into a broken state.
302
+ *
303
+ * Windows has no uid model here; `hardenSecretPath` owns that platform, so the check is skipped when
304
+ * the runtime exposes no effective uid.
305
+ */
306
+ export function assertIntegrationWriteOwnership(
307
+ path: string,
308
+ deps: {
309
+ effectiveUid?: () => number | undefined;
310
+ ownerUid?: (target: string) => number | undefined;
311
+ } = {},
312
+ ): void {
313
+ const effectiveUid = deps.effectiveUid ?? (() =>
314
+ typeof process.geteuid === "function" ? process.geteuid() : undefined);
315
+ const euid = effectiveUid();
316
+ if (euid === undefined) return;
317
+
318
+ const ownerUid = deps.ownerUid ?? ((target: string) => {
319
+ try {
320
+ return statSync(target).uid;
321
+ } catch {
322
+ // An absent or unreadable target has no owner to dispossess; the write itself will report
323
+ // any real failure.
324
+ return undefined;
325
+ }
326
+ });
327
+ const owner = ownerUid(path);
328
+ if (owner === undefined || owner === euid) return;
329
+
330
+ throw new Error(
331
+ `refusing to replace ${path}: it belongs to uid ${owner} while opencodex runs as uid ${euid}. `
332
+ + "An atomic replace would transfer ownership of that file and leave its owner unable to read "
333
+ + "its own configuration. Run both under the same user, or give each one its own copy instead "
334
+ + "of sharing the mount.",
335
+ );
336
+ }
337
+
@@ -176,7 +176,7 @@ export function durableBunRuntime(): DurableBunRuntime {
176
176
  /**
177
177
  * Bun path to bake into durable artifacts (launchd/systemd/Task Scheduler and
178
178
  * the Codex auto-start shim). Prefer the bundled binary — it lives under the
179
- * npm global prefix and survives across `ocx update` — and fall back to the
179
+ * manager-owned global package directory and survives across `ocx update` — and fall back to the
180
180
  * current runtime, which is Bun when launched normally.
181
181
  */
182
182
  export function durableBunPath(): string {
@@ -38,6 +38,33 @@ export function canonicalGuiBrowserOrigin(value: unknown): string | null {
38
38
  }
39
39
  }
40
40
 
41
+ /**
42
+ * A bare http(s) origin, or null.
43
+ *
44
+ * Stricter than {@link canonicalGuiBrowserOrigin}: that one also accepts non-HTTP schemes
45
+ * (a packaged app's custom scheme can be a browser origin), while this is the rule for an
46
+ * origin that will be DIALLED — the hub's management and data origins, and the `serverOrigin`
47
+ * a pairing grant is bound to. Credentials, a path, a query or a fragment are all rejected
48
+ * rather than silently dropped, because every caller goes on to print or compare the result.
49
+ *
50
+ * Exported here, beside the browser-origin canonicaliser, because this file is the only
51
+ * module the CLI pairing path and the hub command already share. `src/config.ts` and
52
+ * `src/server/gui-session.ts` still hold byte-identical private copies; folding those in
53
+ * would pull a heavy config import into a server security-boundary file, so it is its own
54
+ * change rather than a drive-by in a token-UX PR.
55
+ */
56
+ export function canonicalHttpOrigin(value: unknown): string | null {
57
+ if (typeof value !== "string") return null;
58
+ try {
59
+ const parsed = new URL(value);
60
+ if (parsed.protocol !== "http:" && parsed.protocol !== "https:") return null;
61
+ if (parsed.username || parsed.password || parsed.pathname !== "/" || parsed.search || parsed.hash) return null;
62
+ return parsed.origin;
63
+ } catch {
64
+ return null;
65
+ }
66
+ }
67
+
41
68
  function capabilityPayload(
42
69
  nonce: string,
43
70
  method: string,
@@ -0,0 +1,162 @@
1
+ /**
2
+ * Where a process running ON THE HUB ITSELF dials the hub (#4236).
3
+ *
4
+ * There are TWO destinations here, not one base URL substituted everywhere, and conflating
5
+ * them is what broke every local integration on a tailnet-bound hub:
6
+ *
7
+ * 1. `localManagementOrigin` — authenticated management discovery/state (`/api/*`). It is
8
+ * served by the public listener and, on a hub, additionally by the loopback-only
9
+ * `hub.managementIngress`. Callers must still send a management credential: management
10
+ * authentication has no loopback bypass (structure/05), and the unauthenticated loopback
11
+ * listener deliberately does not serve `/api/*` at all.
12
+ * 2. `localInferenceDestination` — the data plane a client wire actually speaks.
13
+ *
14
+ * Both resolvers have the SAME three-branch shape, because the bind address is the thing that
15
+ * decides. The first review of this module got that wrong for inference: it returned
16
+ * `http://127.0.0.1:<public port>` unconditionally whenever the loopback listener was off, so a
17
+ * hub with `hostname: <tailnet IP>` and no listener handed all eight call sites a socket that
18
+ * does not exist. The bind address has to be the fallback, exactly as it already was for
19
+ * management:
20
+ *
21
+ * loopback listener enabled → `127.0.0.1:<effective listener port>`, no credential
22
+ * loopback/absent `hostname` → `127.0.0.1:<public port>`, no credential
23
+ * wildcard `hostname` → `127.0.0.1:<public port>`, ADMISSION CREDENTIAL REQUIRED
24
+ * anything else → `<probeHostname(hostname)>:<public port>`, credential REQUIRED
25
+ *
26
+ * A wildcard bind does answer on 127.0.0.1, which is why its origin stays loopback, but the
27
+ * public listener demands data-plane admission regardless of which address received the
28
+ * request — so it lands in the same credential bucket as a tailnet bind. That is why the
29
+ * resolver returns a STRUCT rather than a string: a caller that cannot see
30
+ * `requiresAdmissionToken` cannot tell a free socket from one that will 401, and the only
31
+ * honest answers are "attach the data-plane credential" or "say so in a log line".
32
+ *
33
+ * The credential in question is the DATA-PLANE one — `OPENCODEX_API_AUTH_TOKEN`, the hardened
34
+ * service token file, or a configured `apiKeys` entry, the same ladder
35
+ * `standaloneCodexRoutingTarget` / the Codex provider table already uses. Never the admin
36
+ * token: no exported client configuration may carry management authority (reviewer constraint
37
+ * on #4236).
38
+ */
39
+ import { effectiveLoopbackListenerPort, isLoopbackHostname, isWildcardHostname, shouldInjectApiAuthHeader } from "../codex/loopback-target";
40
+ import { probeHostname } from "../server/proxy-liveness";
41
+ import { loadServiceTokenFromFile, serviceApiTokenFilePath } from "./service-secrets";
42
+ import type { OcxConfig } from "../types";
43
+
44
+ export type LocalInferenceConfig = Pick<OcxConfig, "hostname" | "unauthenticatedLoopbackListener">;
45
+ export type LocalManagementConfig = Pick<OcxConfig, "hostname" | "runtimeRole" | "hub">;
46
+
47
+ export interface LocalInferenceDestination {
48
+ /** Origin a local client wire dials, e.g. `http://127.0.0.1:10104`. */
49
+ origin: string;
50
+ /** Port component of `origin`. */
51
+ port: number;
52
+ /**
53
+ * Does the listener at `origin` demand `x-opencodex-api-key`?
54
+ *
55
+ * False only for the unauthenticated loopback listener and a genuinely loopback public bind.
56
+ * A caller that cannot attach a credential must log that it is degrading rather than write a
57
+ * destination that answers 401.
58
+ */
59
+ requiresAdmissionToken: boolean;
60
+ }
61
+
62
+ /**
63
+ * The one answer for "where does a local client send inference, and does it need a key?".
64
+ *
65
+ * Kept in one place so the day the resolution changes a forgotten site cannot point a client
66
+ * config at a closed socket — which is precisely the defect this module exists to close.
67
+ */
68
+ export function localInferenceDestination(
69
+ config: LocalInferenceConfig | undefined,
70
+ publicPort: number,
71
+ ): LocalInferenceDestination {
72
+ const listenerPort = effectiveLoopbackListenerPort(config, publicPort);
73
+ if (listenerPort !== null) {
74
+ // Always bound to 127.0.0.1 and admits local callers with no credential, which is what
75
+ // lets an exported client configuration stay credential-free.
76
+ return { origin: `http://127.0.0.1:${listenerPort}`, port: listenerPort, requiresAdmissionToken: false };
77
+ }
78
+ const hostname = config?.hostname;
79
+ // A loopback bind keeps the byte-identical string every call site wrote before this module
80
+ // existed, including the `localhost`/`::1` spellings that `probeHostname` would preserve.
81
+ if (isLoopbackHostname(hostname)) {
82
+ return { origin: `http://127.0.0.1:${publicPort}`, port: publicPort, requiresAdmissionToken: false };
83
+ }
84
+ // `probeHostname` turns every all-zero spelling into 127.0.0.1 and brackets a bare IPv6
85
+ // literal; `shouldInjectApiAuthHeader` is the existing encoding of "this bind demands a
86
+ // data-plane credential", so the two stay in agreement by construction.
87
+ return {
88
+ origin: `http://${probeHostname(hostname)}:${publicPort}`,
89
+ port: publicPort,
90
+ requiresAdmissionToken: shouldInjectApiAuthHeader(config),
91
+ };
92
+ }
93
+
94
+ /**
95
+ * Every port this proxy's data plane answers on at 127.0.0.1 — the set an inherited
96
+ * `http://127.0.0.1:<port>` base URL must hit to count as one of OURS.
97
+ *
98
+ * On a tailnet bind with no loopback listener the set is EMPTY, and that is the point: a
99
+ * leftover `http://127.0.0.1:10100` from a previous loopback-bound install is a dead socket
100
+ * there, so `ocx claude` must replace it rather than preserve it as its own destination.
101
+ */
102
+ export function localLoopbackInferencePorts(
103
+ config: LocalInferenceConfig | undefined,
104
+ publicPort: number,
105
+ ): number[] {
106
+ const ports: number[] = [];
107
+ // A wildcard bind owns loopback on the public port too, so a URL naming it is still ours.
108
+ if (isLoopbackHostname(config?.hostname) || isWildcardHostname(config?.hostname)) ports.push(publicPort);
109
+ const listenerPort = effectiveLoopbackListenerPort(config, publicPort);
110
+ if (listenerPort !== null && !ports.includes(listenerPort)) ports.push(listenerPort);
111
+ return ports;
112
+ }
113
+
114
+ /**
115
+ * The DATA-PLANE admission credential this host can present to its own public listener.
116
+ *
117
+ * Same ladder the Codex provider table and `ocx opencode` already use — environment token,
118
+ * hardened service token file, first configured `apiKeys` entry — and deliberately NOT the
119
+ * admin token, which must never leave the management surface. `undefined` means the caller has
120
+ * nothing to attach and has to degrade loudly.
121
+ */
122
+ export function localAdmissionToken(
123
+ config: Pick<OcxConfig, "apiKeys"> | undefined,
124
+ env: NodeJS.ProcessEnv = process.env,
125
+ ): string | undefined {
126
+ const envToken = env.OPENCODEX_API_AUTH_TOKEN?.trim();
127
+ if (envToken) return envToken;
128
+ const lookup = env.OCX_API_TOKEN_FILE?.trim()
129
+ ? env
130
+ : { ...env, OCX_API_TOKEN_FILE: serviceApiTokenFilePath() };
131
+ const fileToken = loadServiceTokenFromFile(lookup as Record<string, string | undefined>)?.trim();
132
+ // Shape-check the FILE candidate only. The env var and `apiKeys` are values an operator set
133
+ // deliberately and pass through verbatim; a path, by contrast, can be pointed at or replaced
134
+ // by something that is not a credential at all, and sending that as one leaks file contents
135
+ // into a request header. Configured keys are never checked, so no existing key can be broken.
136
+ if (fileToken && ADMISSION_TOKEN_SHAPE.test(fileToken)) return fileToken;
137
+ const configured = config?.apiKeys?.find(entry => entry.key.trim().length > 0)?.key.trim();
138
+ return configured || undefined;
139
+ }
140
+
141
+ /** An admission credential is an opaque printable token — never JSON, a path, or multi-line. */
142
+ const ADMISSION_TOKEN_SHAPE = /^[A-Za-z0-9._~+/=-]{8,4096}$/;
143
+
144
+ /**
145
+ * The origin a local CLI dials for `/api/*`.
146
+ *
147
+ * A hub's management ingress is loopback-only and exists precisely so the operator's own
148
+ * machine has a management address when the proxy listener is bound elsewhere. Everything else
149
+ * keeps dialing the public listener on the bind address it can actually reach — `probeHostname`
150
+ * turns a wildcard bind into 127.0.0.1 and brackets a bare IPv6 literal.
151
+ *
152
+ * The caller still supplies the management credential. Never write that credential into an
153
+ * exported client configuration.
154
+ */
155
+ export function localManagementOrigin(
156
+ config: LocalManagementConfig | undefined,
157
+ publicPort: number,
158
+ ): string {
159
+ const ingress = config?.runtimeRole === "hub" ? config.hub?.managementIngress : undefined;
160
+ if (ingress?.enabled) return `http://127.0.0.1:${ingress.port}`;
161
+ return `http://${probeHostname(config?.hostname)}:${publicPort}`;
162
+ }
@@ -16,7 +16,7 @@ export interface PackageTreeIntegrityGuard {
16
16
  }
17
17
 
18
18
  type ObservePackageTree = () => PackageTreeObservation | null;
19
- type PackageTreeRuntimeInstall = "bun" | "npm" | "source";
19
+ type PackageTreeRuntimeInstall = "bun" | "npm" | "pnpm" | "source";
20
20
 
21
21
  const packageManifestUrl = new URL("../../package.json", import.meta.url);
22
22
 
@@ -80,17 +80,96 @@ export type GracefulStopResult = boolean | "refused" | "teardown-unconfirmed";
80
80
  */
81
81
  let lastRefusalMessage: string | null = null;
82
82
 
83
+ /**
84
+ * The server's machine-readable reason for the most recent 409, captured alongside the
85
+ * message so a refusal that arrives without a body still names the right cause. Without it
86
+ * the fallback has to guess, and guessing "ownership" sent operators to re-check
87
+ * CODEX_HOME for a refusal the scheduler wrapper had issued (#4169).
88
+ */
89
+ let lastRefusalCode: string | null = null;
90
+
83
91
  /** The server's explanation for the most recent 409, or `null` when it sent none. */
84
92
  export function lastStopRefusalMessage(): string | null {
85
93
  return lastRefusalMessage;
86
94
  }
87
95
 
96
+ /** The server's `code` for the most recent 409, or `null` when it sent none. */
97
+ export function lastStopRefusalCode(): string | null {
98
+ return lastRefusalCode;
99
+ }
100
+
101
+ /**
102
+ * Wording for a refusal whose body carried no message. Each branch mirrors a refusal the
103
+ * management API can return from `POST /api/stop`; the default stays cause-neutral because
104
+ * naming the wrong cause is worse than naming none — it costs the operator the time they
105
+ * spend acting on it.
106
+ *
107
+ * These name the cause only. The command belongs to {@link refusalNextStep}, because the
108
+ * only callers of `stopProxy` are `ocx stop` and the service manager's own cleanup, and a
109
+ * message that told either of them to run `ocx stop` would be the #4169 loop again.
110
+ */
111
+ function refusalFallbackMessage(code: string | null): string {
112
+ switch (code) {
113
+ case "respawnable_service":
114
+ return "The running proxy refused to stop: a service manager that can respawn it owns "
115
+ + "the process.";
116
+ case "self_unload_service":
117
+ return "The running proxy refused to stop: it is the installed service itself, so "
118
+ + "stopping the manager from inside it would end the process before native Codex is "
119
+ + "restored.";
120
+ case "service_state_unknown":
121
+ return "The running proxy refused to stop: the service manager state could not be read, "
122
+ + "so it cannot tell whether a wrapper would respawn it.";
123
+ default:
124
+ return "The running proxy refused to stop and sent no reason.";
125
+ }
126
+ }
127
+
128
+ /**
129
+ * What is actually left to do when `ocx stop` is the command that received the refusal.
130
+ *
131
+ * Every refusal `POST /api/stop` produces is written for an API client, so it recommends
132
+ * `ocx stop` — which is the command already running when the CLI prints it. That is the
133
+ * loop #4169 reports: the endpoint points at `ocx stop`, `ocx stop` repeats the endpoint,
134
+ * and neither names the wrapper that is refusing. `ocx stop` has already asked the service
135
+ * manager to stop by the time this is reached, so the remaining question is always what the
136
+ * service manager is doing, and no branch may answer with the command that just failed.
137
+ */
138
+ export function refusalNextStep(code: string | null): string {
139
+ switch (code) {
140
+ case "respawnable_service":
141
+ return "This stop already asked the service manager to stop, so running `ocx stop` "
142
+ + "again is not the missing step. Run `ocx service status` to see whether a wrapper "
143
+ + "is still installed and able to respawn the proxy.";
144
+ case "self_unload_service":
145
+ return "This stop already asked the service manager to stop, so running `ocx stop` "
146
+ + "again is not the missing step. Run `ocx service status` to see whether the service "
147
+ + "is still registered.";
148
+ case "service_state_unknown":
149
+ return "Run `ocx service status` to see the query error, repair the service manager "
150
+ + "access, then retry.";
151
+ default:
152
+ return "Run `ocx service status` to inspect the service state.";
153
+ }
154
+ }
155
+
88
156
  /**
89
157
  * A proxy declined shutdown (HTTP 409). There is more than one reason it can say no — a
90
158
  * scheduler wrapper under another home, or the proxy being the installed service itself
91
159
  * (#4023) — so the server's own message is carried through rather than guessed at.
160
+ *
161
+ * The refusal's `code` travels on the error because the reporting caller has to act on the
162
+ * cause, not re-parse prose: the message is the server's, and it recommends a command the
163
+ * CLI has already run.
92
164
  */
93
- export class ProxyOwnershipRefusedError extends Error {}
165
+ export class ProxyOwnershipRefusedError extends Error {
166
+ readonly code: string | null;
167
+
168
+ constructor(message: string, code: string | null = null) {
169
+ super(message);
170
+ this.code = code;
171
+ }
172
+ }
94
173
 
95
174
  /**
96
175
  * Ask a running proxy to stop itself via the management API (`POST /api/stop`), which
@@ -104,9 +183,28 @@ export class ProxyOwnershipRefusedError extends Error {}
104
183
  * attest the process exit code or completion of every drain/shutdown hook.
105
184
  */
106
185
  export async function stopProxyGracefully(pid: number, io: GracefulStopIo = {}): Promise<GracefulStopResult> {
186
+ return (await stopProxyGracefullyDetailed(pid, io)).result;
187
+ }
188
+
189
+ /**
190
+ * The refusal a single stop attempt received, carried back to that attempt's caller.
191
+ *
192
+ * Module-scoped state cannot do this job: two overlapping stops race, and the first would
193
+ * report the second's cause. The exported accessors stay as observational state for callers
194
+ * that only want the last refusal, but the error text is built from this per-call value.
195
+ */
196
+ type StopRefusal = { message: string | null; code: string | null };
197
+
198
+ async function stopProxyGracefullyDetailed(
199
+ pid: number,
200
+ io: GracefulStopIo = {},
201
+ ): Promise<{ result: GracefulStopResult; refusal: StopRefusal }> {
202
+ const refusal: StopRefusal = { message: null, code: null };
203
+ const done = (result: GracefulStopResult): { result: GracefulStopResult; refusal: StopRefusal } =>
204
+ ({ result, refusal });
107
205
  const readRuntime = io.readRuntime ?? readRuntimePort;
108
206
  const runtime = io.runtimeEndpoint ?? readRuntime(pid);
109
- if (!runtime?.port) return false;
207
+ if (!runtime?.port) return done(false);
110
208
  const env = io.env ?? process.env;
111
209
  const headers: Record<string, string> = {};
112
210
  const token = configuredAdminToken(env.OPENCODEX_HOME?.trim() || undefined, env as NodeJS.ProcessEnv);
@@ -128,20 +226,31 @@ export async function stopProxyGracefully(pid: number, io: GracefulStopIo = {}):
128
226
  // longer than a health poll so we prefer drain over taskkill /F.
129
227
  signal: AbortSignal.timeout(io.exitTimeoutMs ? Math.min(io.exitTimeoutMs, 10_000) : 10_000),
130
228
  });
131
- // 409 is the proxy REFUSING to stop (a service installed under another home owns it and
132
- // would respawn it anyway). That is a policy answer, not a dead endpoint — escalating to
133
- // SIGTERM here would run the daemon's cleanup and strip shared config out from under the
229
+ // 409 is the proxy REFUSING to stop. There is more than one reason it can say no — a
230
+ // respawning service manager, the proxy being the installed service itself, or an
231
+ // unreadable scheduler state — so both the message and the code are captured rather
232
+ // than assumed. That is a policy answer, not a dead endpoint — escalating to SIGTERM
233
+ // here would run the daemon's cleanup and strip shared config out from under the
134
234
  // still-running service. Report the refusal instead of forcing.
135
235
  if (res.status === 409) {
136
- lastRefusalMessage = await res.json()
236
+ const parsed = await res.json()
137
237
  .then(body => {
138
- const message = (body as { message?: unknown } | null)?.message;
139
- return typeof message === "string" && message.trim() ? message.trim() : null;
238
+ const record = body as { message?: unknown; code?: unknown } | null;
239
+ const message = record?.message;
240
+ const code = record?.code;
241
+ return {
242
+ message: typeof message === "string" && message.trim() ? message.trim() : null,
243
+ code: typeof code === "string" && code.trim() ? code.trim() : null,
244
+ };
140
245
  })
141
- .catch(() => null);
142
- return "refused";
246
+ .catch(() => ({ message: null, code: null }));
247
+ refusal.message = parsed.message;
248
+ refusal.code = parsed.code;
249
+ lastRefusalMessage = parsed.message;
250
+ lastRefusalCode = parsed.code;
251
+ return done("refused");
143
252
  }
144
- if (!res.ok) return false;
253
+ if (!res.ok) return done(false);
145
254
  const body: unknown = await res.json().catch(() => null);
146
255
  const expectedTeardown = io.deferSharedTeardownNonce ? "deferred" : "performed";
147
256
  sharedTeardownConfirmed = body !== null
@@ -150,14 +259,14 @@ export async function stopProxyGracefully(pid: number, io: GracefulStopIo = {}):
150
259
  && "success" in body && body.success === true
151
260
  && "sharedTeardown" in body && body.sharedTeardown === expectedTeardown;
152
261
  } catch {
153
- return false;
262
+ return done(false);
154
263
  }
155
264
  const waitExit = io.waitExit ?? waitForExit;
156
265
  // Honor the server's own drain window: /api/stop answers 200 first, then drains for
157
266
  // config.shutdownTimeoutMs. Waiting less than that hard-kills mid-drain.
158
267
  const exitTimeoutMs = io.exitTimeoutMs ?? drainDeadlineMs();
159
- if (!waitExit(pid, exitTimeoutMs)) return false;
160
- return sharedTeardownConfirmed ? true : "teardown-unconfirmed";
268
+ if (!waitExit(pid, exitTimeoutMs)) return done(false);
269
+ return done(sharedTeardownConfirmed ? true : "teardown-unconfirmed");
161
270
  }
162
271
 
163
272
  function drainDeadlineMs(): number {
@@ -172,14 +281,15 @@ function drainDeadlineMs(): number {
172
281
  export async function stopProxy(pid: number, io: GracefulStopIo = {}): Promise<boolean> {
173
282
  if (!isProcessAlive(pid)) return false;
174
283
  const runtime = io.runtimeEndpoint ?? readRuntimePort(pid);
175
- const graceful = await stopProxyGracefully(pid, io);
284
+ const { result: graceful, refusal } = await stopProxyGracefullyDetailed(pid, io);
176
285
  if (graceful === "refused") {
177
- // The proxy refused on purpose (foreign service owns it). Forcing would strip shared
178
- // config while that service keeps the proxy alive.
286
+ // The proxy refused on purpose. Forcing would strip shared config while whatever owns
287
+ // the process keeps it alive. The server's own message is preferred; the fallback is
288
+ // selected from its code so an empty body still names the right cause. Both come from
289
+ // THIS attempt, so an overlapping stop cannot lend it the wrong reason.
179
290
  throw new ProxyOwnershipRefusedError(
180
- lastRefusalMessage
181
- ?? "The running proxy refused to stop: a service installed under a different "
182
- + "CODEX_HOME/OPENCODEX_HOME owns it. Run the stop from that home.",
291
+ refusal.message ?? refusalFallbackMessage(refusal.code),
292
+ refusal.code,
183
293
  );
184
294
  }
185
295
  if (graceful === "teardown-unconfirmed") {
@@ -184,6 +184,34 @@ export function loadServiceTokenFromFile(env: Record<string, string | undefined>
184
184
  }
185
185
  }
186
186
 
187
+ /**
188
+ * The data-plane token a boot should export, or null when the environment already has one
189
+ * (or there is nothing to export).
190
+ *
191
+ * The launchd plist and the systemd unit `cat` the token file into the environment before
192
+ * exec'ing the proxy, and WinSW native mode names it through `OCX_API_TOKEN_FILE` — so under
193
+ * a service the server has always seen `OPENCODEX_API_AUTH_TOKEN` regardless of the calling
194
+ * shell. A FOREGROUND `ocx start` on the same machine had neither, so `assertServerAuthConfig`
195
+ * refused to bind a non-loopback hostname that the installed service was serving happily.
196
+ * This closes that gap with the same precedence the wrappers use, in one place.
197
+ *
198
+ * `authRequired` is the caller's admission decision (`isApiAuthRequired`), passed in rather
199
+ * than recomputed: this module must not load config, and the installed file is deliberately
200
+ * NOT consulted on a loopback bind — on a machine connected to a hub it holds that hub's
201
+ * issued client key, which is not this proxy's admission secret.
202
+ */
203
+ export function startupDataPlaneToken(
204
+ env: Record<string, string | undefined>,
205
+ options: { authRequired: boolean },
206
+ ): string | null {
207
+ if (env.OPENCODEX_API_AUTH_TOKEN?.trim()) return null;
208
+ const named = loadServiceTokenFromFile(env);
209
+ if (named) return named;
210
+ if (!options.authRequired) return null;
211
+ const state = readServiceApiTokenState();
212
+ return state.kind === "present" ? state.token : null;
213
+ }
214
+
187
215
  /**
188
216
  * Contents of the installed service token file. The launch wrapper always re-exports
189
217
  * this file as OPENCODEX_API_AUTH_TOKEN, so doctor and start must inspect it even
@@ -61,6 +61,16 @@ function canonicalize(path: string): string {
61
61
  const REAL_HOME = process.env[REAL_HOME_ENV]?.trim() || homedir();
62
62
  const PROTECTED_HOME = canonicalize(join(REAL_HOME, ".opencodex"));
63
63
  const PROTECTED_CODEX_HOME = canonicalize(join(REAL_HOME, ".codex"));
64
+ /**
65
+ * `~/Library/LaunchAgents` needs its own entry because HOME isolation does not reach it:
66
+ * `os.homedir()` reads the password database, not `$HOME`, so a macOS test that rewrites
67
+ * HOME still resolves `plistPath()` to the developer's real LaunchAgents directory. The
68
+ * launchd install tests were doing exactly that — replacing the live
69
+ * `com.opencodex.proxy.plist` with one whose token file, log path and Bun paths all point
70
+ * into a temp sandbox, for as long as the case ran. launchd holds its own parsed copy, so
71
+ * nothing broke until the job next restarted.
72
+ */
73
+ const PROTECTED_LAUNCH_AGENTS = canonicalize(join(REAL_HOME, "Library", "LaunchAgents"));
64
74
 
65
75
  /** The production home this process protects. Exported for the guard's own tests. */
66
76
  export function protectedHomeForTests(): string {
@@ -76,6 +86,23 @@ export function isTestHomeGuardArmed(): boolean {
76
86
  return process.env[GUARD_ENV] === "1";
77
87
  }
78
88
 
89
+ /**
90
+ * Whether `dir` IS the protected production home, decided with the SAME canonicalization as
91
+ * {@link assertNotRealHomeUnderTest}.
92
+ *
93
+ * For the caller that must FILTER the real home out of a candidate list instead of refusing
94
+ * one write: `serviceStatePaths()` in `src/service.ts` keeps a legacy
95
+ * `~/.opencodex/service-state.json` entry so an install made before OPENCODEX_HOME existed
96
+ * can still be found, and under an armed test process that entry is the developer's live
97
+ * record. Exported so that filter cannot drift onto a weaker comparison — `resolve()` alone
98
+ * calls `/var/folders/...` and `/private/var/folders/...` different paths, which is exactly
99
+ * how a macOS sandbox path slips past a string compare.
100
+ */
101
+ export function isProtectedHomeUnderTest(dir: string): boolean {
102
+ if (!isTestHomeGuardArmed()) return false;
103
+ return canonicalize(dir) === PROTECTED_HOME;
104
+ }
105
+
79
106
  /**
80
107
  * Throw when an armed test process is about to write the real OpenCodex home.
81
108
  *
@@ -94,6 +121,28 @@ export function assertNotRealHomeUnderTest(dir: string): void {
94
121
  );
95
122
  }
96
123
 
124
+ /** The production LaunchAgents directory this process protects. Exported for its tests. */
125
+ export function protectedLaunchAgentsDirForTests(): string {
126
+ return PROTECTED_LAUNCH_AGENTS;
127
+ }
128
+
129
+ /**
130
+ * Throw when an armed test process is about to write the real `~/Library/LaunchAgents`.
131
+ *
132
+ * Same contract as {@link assertNotRealHomeUnderTest}: call before any mkdir/write, and
133
+ * pass a DIRECTORY. A launchd test gives `installLaunchd` an explicit plist path inside its
134
+ * own fixture directory instead.
135
+ */
136
+ export function assertNotRealLaunchAgentsUnderTest(dir: string): void {
137
+ if (!isTestHomeGuardArmed()) return;
138
+ if (canonicalize(dir) !== PROTECTED_LAUNCH_AGENTS) return;
139
+ throw new Error(
140
+ `refusing to write the real LaunchAgents directory (${PROTECTED_LAUNCH_AGENTS}) from a test `
141
+ + "process: os.homedir() ignores HOME, so rewriting HOME does not move this path. Pass an "
142
+ + "explicit plist path inside the test's own fixture directory instead.",
143
+ );
144
+ }
145
+
97
146
  /** Throw when an armed test process is about to write the real native Codex home. */
98
147
  export function assertNotRealCodexHomeUnderTest(dir: string): void {
99
148
  if (!isTestHomeGuardArmed()) return;
@@ -22,7 +22,15 @@ export function deriveOpenCodeGoSessionId(sessionLane: string): string {
22
22
  return `ocx_${digest}`;
23
23
  }
24
24
 
25
- /** Add per-conversation Go affinity only to the canonical fixed-key destination. */
25
+ /**
26
+ * Add Go affinity only to the canonical fixed-key destination.
27
+ *
28
+ * Callers on the request path resolve the lane with `getOrAllocateRequestSessionLane`, which returns
29
+ * real conversation identity when the client supplied it and a per-request value otherwise, so a
30
+ * request reaching this helper from the proxy always carries a lane. The `!sessionLane` guard stays
31
+ * for direct callers that have no request context; it is not a per-request identity of its own, and
32
+ * minting one here would hand each retry a different value.
33
+ */
26
34
  export function resolveOpenCodeGoTransport<T extends OcxProviderConfig>(
27
35
  provider: T,
28
36
  sessionLane: string | undefined,
@@ -2906,7 +2906,11 @@ function keyQuotaReaderForProvider(name: string, provider: OcxProviderConfig): K
2906
2906
  if (name === "deepseek" && isCanonicalDeepSeekBaseUrl(provider.baseUrl)) return fetchDeepSeekQuota;
2907
2907
  if (name === "cline-pass" && isCanonicalClineBaseUrl(provider.baseUrl)) return fetchClineQuota;
2908
2908
  if (isCanonicalOllamaCloudBaseUrl(provider.baseUrl ?? getProviderRegistryEntry(name)?.baseUrl)) return fetchOllamaCloudQuota;
2909
- if (["zai", "glm", "glm-cn", "zhipu-bigmodel-coding"].includes(name) && isCanonicalZaiBaseUrl(provider.baseUrl)) return fetchZaiQuota;
2909
+ // #4201: the Responses preset is the same domestic GLM Coding Plan subscription on the OpenAI
2910
+ // Responses wire, so it reads the same monitor endpoint. Eligibility stays a name list AND the
2911
+ // canonical-URL guard: the guard is what keeps BigModel's bare-key Authorization from reaching a
2912
+ // lookalike host, so a same-named custom destination still dispatches nothing.
2913
+ if (["zai", "glm", "glm-cn", "zhipu-bigmodel-coding", "zhipu-bigmodel-responses"].includes(name) && isCanonicalZaiBaseUrl(provider.baseUrl)) return fetchZaiQuota;
2910
2914
  if (["minimax", "minimax-cn"].includes(name) && isCanonicalMinimaxBaseUrl(provider.baseUrl)) return fetchMinimaxQuota;
2911
2915
  if (name === "moonshot" && isCanonicalMoonshotBaseUrl(provider.baseUrl)) return fetchMoonshotQuota;
2912
2916
  if (name === "venice" && isCanonicalVeniceBaseUrl(provider.baseUrl)) return fetchVeniceQuota;