@bitkyc08/opencodex 2.90.0 → 2.91.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 (132) hide show
  1. package/gui/dist/assets/App-DMps4OLk.css +1 -0
  2. package/gui/dist/assets/{App-DgxkE6Md.js → App-nZ34cR5G.js} +18 -18
  3. package/gui/dist/assets/{Tray-DbTNc_88.js → Tray-BuLy7hL1.js} +1 -1
  4. package/gui/dist/assets/{index-TmaHa54u.js → index-DGXS51sp.js} +12 -12
  5. package/gui/dist/assets/{quota-summary-B16x5DZZ.js → quota-summary-DO1Sx5TM.js} +1 -1
  6. package/gui/dist/index.html +1 -1
  7. package/package.json +1 -1
  8. package/src/adapters/anthropic.ts +2 -5
  9. package/src/adapters/claude-cli/usage-admission.ts +14 -0
  10. package/src/adapters/openai-responses/passthrough.ts +2 -1
  11. package/src/adapters/openai-responses/request-strips.ts +32 -0
  12. package/src/adapters/openai-responses/transport.ts +6 -0
  13. package/src/chat/inbound.ts +20 -2
  14. package/src/chat/outbound.ts +36 -4
  15. package/src/chat/reasoning-details.ts +137 -0
  16. package/src/cli/capabilities-agents-routing.ts +50 -0
  17. package/src/cli/capabilities-base.ts +15 -2
  18. package/src/cli/capabilities-observe-system.ts +4 -4
  19. package/src/cli/codex-shim-autorestore.ts +5 -0
  20. package/src/cli/dispatch.ts +30 -0
  21. package/src/cli/fallback.ts +127 -0
  22. package/src/cli/help.ts +1 -0
  23. package/src/cli/index.ts +1 -2
  24. package/src/cli/link-apply-restart.ts +72 -0
  25. package/src/cli/link-child.ts +6 -2
  26. package/src/cli/link-cleanup.ts +95 -0
  27. package/src/cli/link-enrollment.ts +4 -0
  28. package/src/cli/link.ts +84 -9
  29. package/src/cli/provider.ts +44 -2
  30. package/src/cli/registry.ts +13 -1
  31. package/src/cli/restart-failure.ts +4 -0
  32. package/src/cli/runtime-api.ts +1 -1
  33. package/src/cli/status-codex-settings.ts +56 -0
  34. package/src/cli/status-fallback.ts +29 -0
  35. package/src/cli/status-install.ts +52 -0
  36. package/src/cli/status.ts +20 -1
  37. package/src/cli/system-restart-client.ts +32 -1
  38. package/src/cli/update-restart-child.ts +4 -3
  39. package/src/cli/update-restart-home.ts +20 -8
  40. package/src/client/connect.ts +82 -6
  41. package/src/client/home-unreachable.ts +16 -0
  42. package/src/client/hub-relay.ts +8 -2
  43. package/src/client/link-home.ts +119 -6
  44. package/src/client/link-parity-reporter.ts +107 -0
  45. package/src/client/link-relay-transport.ts +8 -1
  46. package/src/client/link-relay.ts +27 -3
  47. package/src/client/machine-listener.ts +83 -1
  48. package/src/client/runtime-restart.ts +127 -0
  49. package/src/client/runtime.ts +137 -25
  50. package/src/codex/auth-context.ts +10 -6
  51. package/src/codex/catalog/parsing.ts +4 -0
  52. package/src/codex/desktop-compatibility/windows-key-protection.ts +18 -6
  53. package/src/codex/quota-history.ts +7 -0
  54. package/src/codex/quota.ts +10 -0
  55. package/src/codex/subagent-model-fallback.ts +24 -1
  56. package/src/combos/index.ts +1 -0
  57. package/src/combos/resolve.ts +12 -0
  58. package/src/config/diagnostics.ts +5 -0
  59. package/src/config/load-degrade.ts +2 -0
  60. package/src/config/schema/config-schema.ts +3 -0
  61. package/src/config/schema/fallback-policy.ts +40 -0
  62. package/src/generated/compatibility-version.json +205 -97
  63. package/src/lib/authorization-scheme.ts +95 -0
  64. package/src/lib/provider-client-headers.ts +1 -0
  65. package/src/lib/redact.ts +2 -2
  66. package/src/lib/system-restart-contract.ts +24 -0
  67. package/src/lib/test-home-guard.ts +78 -11
  68. package/src/link/child-parity.ts +124 -0
  69. package/src/link/codex-settings.ts +37 -0
  70. package/src/link/compatibility.ts +119 -0
  71. package/src/link/management-routes.ts +2 -0
  72. package/src/link/peer-report.ts +100 -0
  73. package/src/link/remote-platform.ts +78 -0
  74. package/src/link/ssh-argv.ts +19 -0
  75. package/src/link/status-projection.ts +24 -2
  76. package/src/oauth/index.ts +1 -1
  77. package/src/oauth/kimi.ts +2 -0
  78. package/src/oauth/nous.ts +4 -1
  79. package/src/oauth/store.ts +2 -1
  80. package/src/protocols/codecs/chat.ts +3 -3
  81. package/src/protocols/encoders/adapter-events.ts +26 -2
  82. package/src/protocols/encoders/chat.ts +55 -3
  83. package/src/providers/mistral-transport.ts +37 -4
  84. package/src/providers/quota/antigravity.ts +2 -8
  85. package/src/quota-pool/assemble.ts +326 -0
  86. package/src/quota-pool/contract.ts +58 -0
  87. package/src/quota-pool/identity.ts +203 -0
  88. package/src/remote/features.ts +74 -0
  89. package/src/remote/protocol.ts +2 -0
  90. package/src/remote/served-by.ts +57 -0
  91. package/src/responses/parser.ts +7 -0
  92. package/src/responses/reasoning-envelope.ts +12 -0
  93. package/src/routing/fallback-policy.ts +290 -0
  94. package/src/routing/fallback-state.ts +179 -0
  95. package/src/server/audio-client.ts +2 -1
  96. package/src/server/audio-dictation.ts +2 -1
  97. package/src/server/audio-upstream.ts +2 -1
  98. package/src/server/auth-cors.ts +6 -5
  99. package/src/server/background-lifecycle.ts +4 -0
  100. package/src/server/chat-completions.ts +8 -1
  101. package/src/server/gui-session.ts +2 -1
  102. package/src/server/index/link-listener.ts +11 -1
  103. package/src/server/index/link-management.ts +47 -0
  104. package/src/server/index/link-relay-sessions.ts +11 -2
  105. package/src/server/index/optional-listeners.ts +7 -2
  106. package/src/server/index/serve-options.ts +19 -2
  107. package/src/server/live.ts +5 -0
  108. package/src/server/management/context.ts +1 -1
  109. package/src/server/management/fallback-routes.ts +75 -0
  110. package/src/server/management/link-enrollment.ts +21 -3
  111. package/src/server/management/link-route-state.ts +2 -0
  112. package/src/server/management/link-routes.ts +12 -1
  113. package/src/server/management/quota-pool-routes.ts +24 -0
  114. package/src/server/management/route-registry.ts +3 -0
  115. package/src/server/management-api.ts +16 -0
  116. package/src/server/management-auth.ts +2 -1
  117. package/src/server/responses/agent-task-recovery.ts +1 -1
  118. package/src/server/responses/compact.ts +2 -2
  119. package/src/server/responses/core-auth.ts +11 -1
  120. package/src/server/responses/core-combo.ts +72 -1
  121. package/src/server/responses/encrypted-payload.ts +3 -3
  122. package/src/server/responses/main-hard-lock-routing.ts +54 -2
  123. package/src/server/responses/policy-fallback.ts +44 -1
  124. package/src/server/responses/request-prepare.ts +9 -1
  125. package/src/server/restart-replacement.ts +50 -2
  126. package/src/server/ws-bridge.ts +5 -0
  127. package/src/service/cli.ts +25 -2
  128. package/src/service/diagnostics.ts +20 -6
  129. package/src/service/status-json.ts +116 -0
  130. package/src/types/config.ts +7 -0
  131. package/src/types/fallback.ts +38 -0
  132. package/gui/dist/assets/App-dnkJ85te.css +0 -1
@@ -0,0 +1,100 @@
1
+ /**
2
+ * What a Remote Link Child tells its Home about itself, and what the Home remembers of it.
3
+ *
4
+ * A Child sends its release version and its peer feature ids (`src/remote/features.ts`) as two
5
+ * headers on the credential-free relay-auth challenge it already sends before every relayed
6
+ * request (`src/client/link-relay-transport.ts`). The Home records them only after the caller
7
+ * proof verifies, so an observation is bound to the link key; it is still the Child's own claim
8
+ * and is used for display and compatibility verdicts, never for admission.
9
+ *
10
+ * A Home older than this report ignores the headers, and a Child older than it sends none: the
11
+ * Home then knows only the relay-auth version the challenge itself names. Observations live in
12
+ * the Home's memory and start empty after a restart; the next relayed request refills them.
13
+ */
14
+ export const LINK_PEER_REPORT_VERSION = "1";
15
+ export const LINK_PEER_VERSION_HEADER = "x-opencodex-link-peer-version";
16
+ export const LINK_PEER_FEATURES_HEADER = "x-opencodex-link-peer-features";
17
+
18
+ const RELEASE_VERSION = /^\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.+-]{1,128})?$/;
19
+ const FEATURE_ID = /^[A-Za-z0-9][A-Za-z0-9._/-]{0,79}$/;
20
+ const MAX_FEATURES = 64;
21
+ const MAX_LINKS = 256;
22
+
23
+ export interface LinkPeerReport {
24
+ /** True when the challenge carried either report header, valid or not; false for a pre-report Child. */
25
+ reported: boolean;
26
+ version: string | null;
27
+ features: string[] | null;
28
+ }
29
+
30
+ /** Header values a Child sends. Invalid input yields no header rather than a malformed one. */
31
+ export function linkPeerReportHeaders(version: string, features: readonly string[]): Record<string, string> {
32
+ const headers: Record<string, string> = {};
33
+ if (RELEASE_VERSION.test(version)) headers[LINK_PEER_VERSION_HEADER] = version;
34
+ const ids = parseFeatureList(features.join(","));
35
+ if (ids) headers[LINK_PEER_FEATURES_HEADER] = ids.join(",");
36
+ return headers;
37
+ }
38
+
39
+ function parseFeatureList(value: string | null): string[] | null {
40
+ if (value === null || value === "") return null;
41
+ const ids = value.split(",");
42
+ if (ids.length > MAX_FEATURES || new Set(ids).size !== ids.length || ids.some(id => !FEATURE_ID.test(id))) return null;
43
+ return ids;
44
+ }
45
+
46
+ /** Home side: read a report from challenge headers. Each field is dropped on its own when invalid. */
47
+ export function parseLinkPeerReport(headers: Headers): LinkPeerReport {
48
+ const version = headers.get(LINK_PEER_VERSION_HEADER);
49
+ const features = headers.get(LINK_PEER_FEATURES_HEADER);
50
+ return {
51
+ reported: version !== null || features !== null,
52
+ version: version !== null && RELEASE_VERSION.test(version) ? version : null,
53
+ features: parseFeatureList(features),
54
+ };
55
+ }
56
+
57
+ export interface LinkPeerObservation extends LinkPeerReport {
58
+ /** The relay-auth version of the verified challenge. */
59
+ relayAuth: string;
60
+ observedAt: string;
61
+ }
62
+
63
+ /** A challenge for a known link and key that named a relay-auth version this Home does not speak. */
64
+ export interface LinkPeerRefusal {
65
+ relayAuth: string;
66
+ refusedAt: string;
67
+ }
68
+
69
+ export interface LinkPeerEntry {
70
+ observed: LinkPeerObservation | null;
71
+ refused: LinkPeerRefusal | null;
72
+ }
73
+
74
+ export interface LinkPeerObservations {
75
+ observe(linkId: string, relayAuth: string, report: LinkPeerReport): void;
76
+ refuse(linkId: string, relayAuth: string): void;
77
+ get(linkId: string): LinkPeerEntry;
78
+ }
79
+
80
+ export function createLinkPeerObservations(now: () => Date = () => new Date()): LinkPeerObservations {
81
+ const entries = new Map<string, LinkPeerEntry>();
82
+ const update = (linkId: string, change: Partial<LinkPeerEntry>) => {
83
+ const current = entries.get(linkId) ?? { observed: null, refused: null };
84
+ entries.delete(linkId);
85
+ entries.set(linkId, { ...current, ...change });
86
+ // Only known link ids reach here; the cap bounds memory if the store churns for a long time.
87
+ if (entries.size > MAX_LINKS) entries.delete(entries.keys().next().value!);
88
+ };
89
+ return {
90
+ observe(linkId, relayAuth, report) {
91
+ update(linkId, { observed: { relayAuth, ...report, observedAt: now().toISOString() }, refused: null });
92
+ },
93
+ refuse(linkId, relayAuth) {
94
+ update(linkId, { refused: { relayAuth, refusedAt: now().toISOString() } });
95
+ },
96
+ get(linkId) {
97
+ return entries.get(linkId) ?? { observed: null, refused: null };
98
+ },
99
+ };
100
+ }
@@ -0,0 +1,78 @@
1
+ /**
2
+ * Windows recognition for Remote Link Children.
3
+ *
4
+ * Remote Link drives its Child through a POSIX `sh` prelude, so a Windows Child is refused
5
+ * before any link key is issued or tunnel started. A Windows OpenSSH server shows up in one of
6
+ * three ways, depending on its default shell:
7
+ * - Git Bash, MSYS2 or Cygwin: `uname -s` reports MINGW*, MSYS*, CYGWIN* (or Windows*), and the
8
+ * ocx prelude exits with REMOTE_PLATFORM_UNSUPPORTED.
9
+ * - cmd.exe: a POSIX command such as `true` or `sh` is "not recognized as an internal or
10
+ * external command".
11
+ * - PowerShell: the same command "is not recognized as the name of a cmdlet".
12
+ *
13
+ * Hub mode (`ocx hub invite` on a Hub, `ocx connect` on the client) supports Windows clients, so
14
+ * every refusal points there.
15
+ */
16
+ import { buildWindowsProbeArgv, REMOTE_COMMAND_NOT_FOUND, REMOTE_PLATFORM_UNSUPPORTED } from "./ssh-argv";
17
+ import type { SshRunner, SshRunResult } from "./ssh-runner";
18
+
19
+ export type RemotePlatform = "windows" | "posix" | "unknown";
20
+
21
+ const WINDOWS_UNAME = /^(?:Windows|MSYS|MINGW|CYGWIN)/i;
22
+ const POSIX_UNAME = /^[A-Za-z][A-Za-z0-9_.-]{0,63}$/;
23
+ // Windows PowerShell says "the name of a cmdlet", PowerShell 7 "a name of a cmdlet". Localized
24
+ // installs say neither, which is what confirmWindowsShell is for.
25
+ const WINDOWS_SHELL_REFUSAL = /is not recognized as (?:an internal or external command|(?:the|a) name of a cmdlet)/i;
26
+ /** ssh's own failure status: the remote shell never ran, so there is nothing to ask. */
27
+ const SSH_TRANSPORT_FAILURE = 255;
28
+
29
+ /** Remote argv that prints the kernel name; under cmd or PowerShell, `sh` itself is refused. */
30
+ export const REMOTE_UNAME_ARGV: readonly string[] = ["sh", "-c", "uname -s 2>/dev/null", "ocx-platform"];
31
+
32
+ /** Server-side refusal text, shared by the dashboard and the CLI's JSON consumers. */
33
+ export const WINDOWS_CHILD_REFUSAL = "Remote Link Children require macOS or Linux; Windows is unsupported. Connect a Windows computer in Hub mode: run ocx hub invite on a Hub, then the printed ocx connect command on the Windows computer.";
34
+
35
+ /** Does this output come from cmd.exe or PowerShell refusing a POSIX command? */
36
+ export function isWindowsShellRefusal(output: string): boolean {
37
+ return WINDOWS_SHELL_REFUSAL.test(output);
38
+ }
39
+
40
+ /** Classify one remote command result. Transport failures and odd output stay unknown. */
41
+ export function classifyRemotePlatform(result: SshRunResult): RemotePlatform {
42
+ if (result.code === REMOTE_PLATFORM_UNSUPPORTED) return "windows";
43
+ if (isWindowsShellRefusal(result.stderr) || isWindowsShellRefusal(result.stdout)) return "windows";
44
+ if (result.code !== 0) return "unknown";
45
+ const name = result.stdout.trim().split(/\r?\n/)[0] ?? "";
46
+ if (WINDOWS_UNAME.test(name)) return "windows";
47
+ return POSIX_UNAME.test(name) ? "posix" : "unknown";
48
+ }
49
+
50
+ /**
51
+ * Could this failure be a Windows shell whose refusal text is localized? Success, the prelude's own
52
+ * 125/127 answers and ssh transport failures are already explained, so they never cost another call.
53
+ */
54
+ export function mayBeWindowsShell(result: SshRunResult): boolean {
55
+ return result.code !== 0 && result.code !== REMOTE_PLATFORM_UNSUPPORTED
56
+ && result.code !== REMOTE_COMMAND_NOT_FOUND && result.code !== SSH_TRANSPORT_FAILURE;
57
+ }
58
+
59
+ /** Ask the host the fixed, locale-independent WINDOWS_OS_PROBE. Any failure answers false. */
60
+ export async function confirmWindowsShell(runner: SshRunner, alias: string, knownHostsFile: string, timeoutMs = 30_000): Promise<boolean> {
61
+ try {
62
+ const result = await runner.run(buildWindowsProbeArgv({ alias, knownHostsFile }), { timeoutMs });
63
+ return result.code === 0 && result.stdout.trim().split(/\r?\n/)[0]?.trim() === "Windows_NT";
64
+ } catch { return false; }
65
+ }
66
+
67
+ /** classifyRemotePlatform, plus the locale-independent follow-up when the answer could be Windows. */
68
+ export async function detectRemotePlatform(runner: SshRunner, alias: string, knownHostsFile: string, result: SshRunResult): Promise<RemotePlatform> {
69
+ const platform = classifyRemotePlatform(result);
70
+ if (platform !== "unknown" || !mayBeWindowsShell(result)) return platform;
71
+ return await confirmWindowsShell(runner, alias, knownHostsFile) ? "windows" : "unknown";
72
+ }
73
+
74
+ /** The CLI sentence for a refused Windows Child. */
75
+ export function windowsChildRefusal(alias: string): string {
76
+ return `${alias} is a Windows computer. Remote Link Children require macOS or Linux, so no link key was issued and no tunnel was started. `
77
+ + `Connect it in Hub mode instead: run 'ocx hub invite' on a Hub, then run the printed 'ocx connect' command on ${alias}.`;
78
+ }
@@ -191,6 +191,25 @@ export function buildProbeArgv(options: ProbeArgvOptions): string[] {
191
191
  ];
192
192
  }
193
193
 
194
+ /**
195
+ * Locale-independent Windows check for a host whose SSH shell is cmd.exe or PowerShell. Both run
196
+ * `cmd /c echo %OS%`, and cmd expands it to `Windows_NT` regardless of display language. The
197
+ * command is a fixed literal; a POSIX shell answers "command not found" and nothing else.
198
+ */
199
+ export const WINDOWS_OS_PROBE = "cmd /c echo %OS%";
200
+
201
+ /** Run WINDOWS_OS_PROBE against an already trusted (pinned or just-probed) host key only. */
202
+ export function buildWindowsProbeArgv(options: { alias: string; knownHostsFile: string }): string[] {
203
+ const alias = assertSshAlias(options.alias);
204
+ return [
205
+ "ssh", "-T",
206
+ ...commonOptions(alias, options.knownHostsFile, "yes"),
207
+ "-o", "ClearAllForwardings=yes",
208
+ "--", alias,
209
+ WINDOWS_OS_PROBE,
210
+ ];
211
+ }
212
+
194
213
  /** Read the fingerprint of the host key recorded by a probe. */
195
214
  export function buildFingerprintArgv(tempKnownHostsFile: string): string[] {
196
215
  return ["ssh-keygen", "-l", "-f", assertKnownHostsPath(tempKnownHostsFile)];
@@ -2,6 +2,9 @@ import type { LinkStore } from "./store";
2
2
  import type { LinkTunnelStatus } from "./supervisor";
3
3
  import type { TunnelState } from "./tunnel-state";
4
4
  import { readCompensation, type CompensationStore } from "./compensation";
5
+ import { EMPTY_LINK_CHILD_PARITY, type LinkChildParity } from "./child-parity";
6
+ import { linkPeerStatus, type LinkLocalPeer, type LinkPeerStatus } from "./compatibility";
7
+ import type { LinkPeerEntry } from "./peer-report";
5
8
  import type { OcxClientConnectionConfig } from "../types";
6
9
 
7
10
  export type LinkWireState = "connecting" | "connected" | "reconnecting" | "failed" | "idle";
@@ -9,7 +12,7 @@ export type LinkWireState = "connecting" | "connected" | "reconnecting" | "faile
9
12
  export interface LinkStatusDto {
10
13
  role: "standalone" | "home" | "child";
11
14
  listener: { state: "off" | "listening" | "failed"; port: number | null };
12
- links: Array<{
15
+ links: Array<LinkChildParity & {
13
16
  id: string;
14
17
  alias: string;
15
18
  direction: "hub-initiated" | "client-initiated";
@@ -17,6 +20,8 @@ export interface LinkStatusDto {
17
20
  since: string;
18
21
  reason: string | null;
19
22
  tunnelPort: number;
23
+ /** Home only: what the link carried from this Child, and the compatibility verdict. */
24
+ peer?: LinkPeerStatus;
20
25
  }>;
21
26
  child: null | {
22
27
  alias: string;
@@ -24,6 +29,17 @@ export interface LinkStatusDto {
24
29
  since: string;
25
30
  reason: string | null;
26
31
  };
32
+ /** This build's version and link protocol, the side every `peer` verdict compares against. */
33
+ local?: LinkLocalPeer;
34
+ }
35
+
36
+ /** Add this build's identity and, on a Home, each Child's observed peer status. */
37
+ export function attachLinkPeers(dto: LinkStatusDto, local: LinkLocalPeer, observation: (linkId: string) => LinkPeerEntry): LinkStatusDto {
38
+ if (dto.role === "home") {
39
+ for (const link of dto.links) link.peer = linkPeerStatus(local, observation(link.id));
40
+ }
41
+ dto.local = local;
42
+ return dto;
27
43
  }
28
44
 
29
45
  export interface ClientLinkIdentity { direction: "hub-initiated" | "client-initiated"; alias?: string }
@@ -75,6 +91,8 @@ export function projectLinkStatus(
75
91
  compensation: CompensationStore = readCompensation(),
76
92
  clientChild: LinkStatusDto["child"] = null,
77
93
  identity: ClientLinkIdentity | null = null,
94
+ /** The Home's view of each Child's last apply (OCX-6); every field is null without a report. */
95
+ parity: (linkId: string) => LinkChildParity = () => EMPTY_LINK_CHILD_PARITY,
78
96
  ): LinkStatusDto {
79
97
  const byId = new Map(supervisorStates.map(status => [status.linkId, status]));
80
98
  const links = store.links.map(record => ({
@@ -83,6 +101,7 @@ export function projectLinkStatus(
83
101
  direction: record.direction,
84
102
  ...stateDetails(record, byId.get(record.id), compensation),
85
103
  tunnelPort: record.tunnelPort,
104
+ ...parity(record.id),
86
105
  }));
87
106
  const childRecord = config.runtimeRole === "client"
88
107
  ? (config.client?.transport === "link" ? store.links.find(record => record.id === config.client?.link?.linkId) : store.links[0])
@@ -101,7 +120,10 @@ export function projectLinkStatus(
101
120
  : client?.link ? { alias: identity?.alias ?? client.serverUrl, state: "idle" as const, since: client.connectedAt, reason: null } : null);
102
121
  if (client?.link && child && identity) {
103
122
  const active = links.find(link => link.id === client.link!.linkId);
104
- const projected = { id: client.link.linkId, direction: identity.direction, tunnelPort: client.link.tunnelPort, ...child, alias: identity.alias ?? child.alias };
123
+ const projected = {
124
+ id: client.link.linkId, direction: identity.direction, tunnelPort: client.link.tunnelPort, ...child, alias: identity.alias ?? child.alias,
125
+ ...parity(client.link.linkId),
126
+ };
105
127
  if (active) Object.assign(active, projected, compensation.entries[active.id] && childRecord ? stateDetails(childRecord, byId.get(active.id), compensation) : {});
106
128
  else links.push(projected);
107
129
  }
@@ -1672,7 +1672,7 @@ export async function runLogin(
1672
1672
  : existing.accountId && cred.accountId
1673
1673
  ? existing.accountId === cred.accountId
1674
1674
  : existing.email && cred.email
1675
- ? existing.email.toLowerCase() === cred.email.toLowerCase()
1675
+ ? existing.email.trim().toLowerCase() === cred.email.trim().toLowerCase()
1676
1676
  : false;
1677
1677
  if (!identityMatches) {
1678
1678
  throw new OAuthReauthIdentityMismatchError();
package/src/oauth/kimi.ts CHANGED
@@ -4,6 +4,7 @@ import * as os from "node:os";
4
4
  import { join } from "node:path";
5
5
  import { randomUUID } from "node:crypto";
6
6
  import { recordOwnedConfigPath } from "../lib/config-ownership";
7
+ import { assertNotRealHomeUnderTest } from "../lib/test-home-guard";
7
8
  import { getConfigDir } from "../config";
8
9
  import type { OAuthController, OAuthCredentials } from "./types";
9
10
 
@@ -107,6 +108,7 @@ function getDeviceId(): string {
107
108
  if ((e as { code?: string })?.code !== "ENOENT") throw e;
108
109
  }
109
110
  const id = randomUUID().replace(/-/g, "");
111
+ assertNotRealHomeUnderTest(getConfigDir());
110
112
  recordOwnedConfigPath(getConfigDir(), p);
111
113
  if (!existsSync(getConfigDir())) mkdirSync(getConfigDir(), { recursive: true });
112
114
  writeFileSync(p, id + "\n", { mode: 0o600 });
package/src/oauth/nous.ts CHANGED
@@ -35,10 +35,11 @@
35
35
  */
36
36
  import { createHash } from "node:crypto";
37
37
  import { chmodSync, existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
38
- import { join } from "node:path";
38
+ import { dirname, join } from "node:path";
39
39
  import type { OAuthController, OAuthCredentials } from "./types";
40
40
  import { getAuthStorePath } from "./store";
41
41
  import { atomicWriteFile, hardenConfigDir, hardenExistingSecret } from "../config";
42
+ import { assertNotRealHomeUnderTest } from "../lib/test-home-guard";
42
43
  import { BOUNDED_BODY_MAX_BYTES, readBoundedResponseBytes } from "../lib/bounded-body";
43
44
 
44
45
  export const NOUS_PORTAL_BASE_URL = "https://portal.nousresearch.com";
@@ -220,6 +221,8 @@ function readRefreshIntent(refreshToken: string): RefreshIntent | undefined {
220
221
  }
221
222
 
222
223
  function writeRefreshIntent(refreshToken: string, status: RefreshIntentStatus): void {
224
+ // Before the mkdir/chmod below, which would otherwise touch the real home first.
225
+ assertNotRealHomeUnderTest(dirname(getAuthStorePath()));
223
226
  const dir = refreshIntentDir();
224
227
  // Hardened, owner-only directory + atomic (temp+rename) write. Throws on
225
228
  // failure so the caller can fail closed instead of refreshing blind.
@@ -203,6 +203,7 @@ export function peekOAuthRefreshIntent(provider: string, accountId: string): OAu
203
203
  }
204
204
  export function writeOAuthRefreshIntent(provider: string, accountId: string, generation: string, createdAt = Date.now(), flightId?: string): OAuthRefreshIntent {
205
205
  const dir = getConfigDir();
206
+ assertNotRealHomeUnderTest(dir);
206
207
  if (!existsSync(dir)) mkdirSync(dir, { recursive: true, mode: 0o700 });
207
208
  hardenConfigDir();
208
209
  const intent: OAuthRefreshIntent = {
@@ -1152,7 +1153,7 @@ export async function upsertCredentialByIdentity(
1152
1153
  return Boolean(
1153
1154
  safe.email
1154
1155
  && account.credential.email
1155
- && account.credential.email.toLowerCase() === safe.email.toLowerCase(),
1156
+ && account.credential.email.trim().toLowerCase() === safe.email.trim().toLowerCase(),
1156
1157
  );
1157
1158
  };
1158
1159
  const existing = safe.accountId
@@ -6,12 +6,12 @@
6
6
  * (`src/chat/inbound.ts`), including its validation and the fields it drops, which
7
7
  * `src/protocols/features.ts` declares.
8
8
  */
9
- import { chatCompletionsToResponsesBody, chatSettingsToResponsesBody } from "../../chat/inbound";
9
+ import { chatCompletionsToResponsesBody, chatSettingsToResponsesBody, type ChatResponsesProjectionOptions } from "../../chat/inbound";
10
10
  import { featuresFromChatBody } from "../features";
11
11
 
12
12
  /** Project a Chat Completions body onto the internal Responses bridge body. */
13
- export function chatToResponsesBody(body: unknown): Record<string, unknown> {
14
- return chatCompletionsToResponsesBody(body);
13
+ export function chatToResponsesBody(body: unknown, options?: ChatResponsesProjectionOptions): Record<string, unknown> {
14
+ return chatCompletionsToResponsesBody(body, options);
15
15
  }
16
16
 
17
17
  /**
@@ -109,7 +109,13 @@ export interface ClientWireWriter {
109
109
  text(delta: string): void;
110
110
  messageDone(): void;
111
111
  reasoning(delta: string, itemId: string, channel: "summary" | "raw"): void;
112
- reasoningDone(item: { itemId: string; signature?: string; redacted?: string[] }): void;
112
+ /** `hidden`: the signature covers thinking text the client was not shown (the bridge's `txt`). */
113
+ reasoningDone(item: { itemId: string; signature?: string; redacted?: string[]; hidden?: true }): void;
114
+ /**
115
+ * An Anthropic progress update: `text` is shown as content and is also the signed thinking
116
+ * block's text. Optional — a writer without it renders the update as ordinary text.
117
+ */
118
+ progressThinking?(text: string, signature: string): void;
113
119
  toolStart(call: ClientToolCall): void;
114
120
  toolArgsDelta(itemId: string, delta: string): void;
115
121
  toolArgsDone(itemId: string, args: string): void;
@@ -331,6 +337,8 @@ export function encodeAdapterEventStream(
331
337
  let currentRawReasoning: { itemId: string } | null = null;
332
338
  let pendingSignature: string | undefined;
333
339
  let pendingRedacted: string[] = [];
340
+ // The bridge's hiddenThinking: non-empty thinking text suppressed by hideThinkingSummary.
341
+ let hiddenThinkingSeen = false;
334
342
  let hiddenRawBytes = 0;
335
343
  let pendingKiroRedacted: string | undefined;
336
344
  let currentToolCall: OpenToolCall | null = null;
@@ -367,9 +375,11 @@ export function encodeAdapterEventStream(
367
375
  };
368
376
  // hideThinkingSummary: a signed or redacted block still round-trips as an envelope-only item.
369
377
  const flushHiddenReasoningEnvelope = () => {
378
+ const hidden = hiddenThinkingSeen;
379
+ hiddenThinkingSeen = false;
370
380
  const envelope = takeReasoningEnvelope();
371
381
  if (!envelope) return;
372
- writer.reasoningDone({ itemId: `rs_${uuid()}`, ...envelope });
382
+ writer.reasoningDone({ itemId: `rs_${uuid()}`, ...envelope, ...(hidden ? { hidden: true as const } : {}) });
373
383
  };
374
384
  const flushHiddenRawReasoning = () => {
375
385
  if (hiddenRawBytes === 0) return;
@@ -570,6 +580,19 @@ export function encodeAdapterEventStream(
570
580
  break;
571
581
  }
572
582
  case "text_delta": {
583
+ if (event.anthropicThinkingSignature && writer.progressThinking) {
584
+ // The bridge's progress items: close everything, the carrier, then the visible copy.
585
+ if (currentMsg) closeCurrentMessage();
586
+ if (currentReasoning) closeCurrentReasoning();
587
+ if (currentRawReasoning) closeCurrentRawReasoning();
588
+ flushHiddenRawReasoning();
589
+ flushHiddenReasoningEnvelope();
590
+ if (currentToolCall) closeCurrentToolCall();
591
+ writer.progressThinking(event.text, event.anthropicThinkingSignature);
592
+ writer.text(event.text);
593
+ writer.messageDone();
594
+ break;
595
+ }
573
596
  if (currentReasoning) closeCurrentReasoning();
574
597
  if (currentRawReasoning) closeCurrentRawReasoning();
575
598
  flushHiddenRawReasoning();
@@ -585,6 +608,7 @@ export function encodeAdapterEventStream(
585
608
  }
586
609
  case "thinking_delta": {
587
610
  if (options.hideThinkingSummary) {
611
+ if (event.thinking.length > 0) hiddenThinkingSeen = true;
588
612
  flushHiddenRawReasoning();
589
613
  break;
590
614
  }
@@ -13,6 +13,7 @@ import type { AdapterEvent } from "../../types";
13
13
  import type { TranslatorBudget } from "../../lib/translator-budget";
14
14
  import { responsesUsage } from "../../bridge/internal";
15
15
  import {
16
+ appendedUtf8Bytes,
16
17
  chatCompletionsErrorResponse,
17
18
  chatCompletionsFailedResponse,
18
19
  chatCompletionsIncompleteOutcome,
@@ -32,6 +33,7 @@ import {
32
33
  type ClientWireWriter,
33
34
  } from "./adapter-events";
34
35
  import type { RelayedEventObservation } from "../../usage/attempt-delivery";
36
+ import { reasoningDetailEntries } from "../../chat/reasoning-details";
35
37
 
36
38
  type Rec = Record<string, unknown>;
37
39
 
@@ -54,6 +56,41 @@ function createChatCompletionWriter(sink: ClientFrameSink, model: string): Clien
54
56
  const emittedToolIndexes = new Set<number>();
55
57
  let nextToolIndex = 0;
56
58
  let staged: { text: string; observation?: RelayedEventObservation }[] | undefined;
59
+ // #7429: the shown thinking text of the open reasoning item, which its signature pairs with.
60
+ const budget = sink.budget;
61
+ let reasoningItemId: string | undefined;
62
+ let reasoningText = "";
63
+ let reasoningTextBytes = 0;
64
+ let nextReasoningDetailIndex = 0;
65
+ const releaseReasoningText = () => {
66
+ budget.releaseRetained(reasoningTextBytes, { kind: "reasoning" });
67
+ reasoningItemId = undefined;
68
+ reasoningText = "";
69
+ reasoningTextBytes = 0;
70
+ };
71
+ const retainReasoningText = (itemId: string, delta: string) => {
72
+ if (reasoningItemId !== itemId) releaseReasoningText();
73
+ reasoningItemId = itemId;
74
+ const nextBytes = appendedUtf8Bytes(reasoningText, reasoningTextBytes, delta);
75
+ const reservation = budget.reserveTransient(nextBytes, { kind: "reasoning" });
76
+ try {
77
+ reasoningText += delta;
78
+ reservation.commitRetained();
79
+ budget.releaseRetained(reasoningTextBytes, { kind: "reasoning" });
80
+ reasoningTextBytes = nextBytes;
81
+ } catch (error) {
82
+ reservation.release();
83
+ throw error;
84
+ }
85
+ };
86
+ const emitReasoningDetails = (entries: Rec[]) => {
87
+ if (entries.length === 0) return;
88
+ nextReasoningDetailIndex += entries.length;
89
+ ensureRole();
90
+ const frame = chunkBase(id, model, created);
91
+ frame.choices = [{ index: 0, delta: { reasoning_details: entries }, finish_reason: null }];
92
+ emit(frame);
93
+ };
57
94
 
58
95
  const emit = (payload: Rec | "[DONE]", observation?: RelayedEventObservation) => {
59
96
  if (failed) return;
@@ -138,15 +175,30 @@ function createChatCompletionWriter(sink: ClientFrameSink, model: string): Clien
138
175
  emit(frame, { semanticBytes: Buffer.byteLength(delta) });
139
176
  },
140
177
  messageDone() { /* the converter reads nothing from a finished message */ },
141
- reasoning(delta) {
178
+ reasoning(delta, itemId, channel) {
142
179
  if (!delta) return;
143
180
  ensureRole();
144
181
  // Many OpenAI-compatible clients accept reasoning_content; harmless if ignored.
145
182
  const frame = chunkBase(id, model, created);
146
183
  frame.choices = [{ index: 0, delta: { reasoning_content: delta }, finish_reason: null }];
147
184
  emit(frame, { semanticBytes: Buffer.byteLength(delta) });
185
+ // Only summary-channel thinking can carry a signature; raw reasoning never does.
186
+ if (channel === "summary") retainReasoningText(itemId, delta);
187
+ },
188
+ reasoningDone({ itemId, signature, redacted, hidden }) {
189
+ // The bridge converter's output_item.done for a reasoning item (#7429), entry for entry.
190
+ const entries = reasoningDetailEntries({
191
+ text: reasoningItemId === itemId ? reasoningText : "",
192
+ ...(signature ? { signature } : {}),
193
+ ...(redacted ? { redacted } : {}),
194
+ ...(hidden ? { hidden } : {}),
195
+ }, nextReasoningDetailIndex);
196
+ releaseReasoningText();
197
+ emitReasoningDetails(entries);
198
+ },
199
+ progressThinking(text, signature) {
200
+ emitReasoningDetails(reasoningDetailEntries({ text, signature }, nextReasoningDetailIndex));
148
201
  },
149
- reasoningDone() { /* reasoning items carry nothing a Chat client reads */ },
150
202
  toolStart(call) {
151
203
  // Custom and tool-search calls have no Chat representation; the converter skips them.
152
204
  if (call.kind !== "function") return;
@@ -198,7 +250,7 @@ function createChatCompletionWriter(sink: ClientFrameSink, model: string): Clien
198
250
  type: "upstream_error",
199
251
  });
200
252
  },
201
- dispose() { /* nothing retained beyond the driver's frames */ },
253
+ dispose() { releaseReasoningText(); },
202
254
  };
203
255
  }
204
256
 
@@ -8,6 +8,14 @@ import { RequestPacingQueueOverloadError } from "./request-pacing";
8
8
  import type { OcxProviderTransport } from "./xai-transport";
9
9
 
10
10
  const MAX_IDENTITIES = 128;
11
+ /** Queued waiters per credential+model identity; one hot identity never refuses another. */
12
+ const MAX_WAITERS_PER_IDENTITY = 128;
13
+ /**
14
+ * Process-wide waiter bound. Model strings come from clients, so identities are not bounded by
15
+ * configuration; this keeps memory and pending requests finite. It is a multiple of the
16
+ * per-identity cap, so no single identity can fill it alone.
17
+ */
18
+ const MAX_WAITERS_TOTAL = 4 * MAX_WAITERS_PER_IDENTITY;
11
19
  const DEFAULT_MAX_WAIT_MS = 60_000; // Existing retry wait budget, not an inferred rate window.
12
20
  const MINUTE_MS = 60 * 1000; // Unit named by the observed headers, never an account quota.
13
21
  type RecoveryState = { generation: number; probe?: { done: Promise<void>; finish(): void } };
@@ -61,7 +69,14 @@ export class MistralBudgetPacer {
61
69
  private readonly recoveries = new Map<string, RecoveryState>();
62
70
  /** Latest live deadline evicted at capacity; unknown identities wait it out instead of sending early. */
63
71
  private evictedUntil = 0;
64
- private waiting = 0;
72
+ /**
73
+ * Parked waiters keyed by identity. A request counts only once it is about to wait, so a held
74
+ * credential or model fills only its own queue and a request that can send now is never answered
75
+ * queue_full (HTTP 429). The map holds identities with live waiters only; the total is capped by
76
+ * MAX_WAITERS_TOTAL.
77
+ */
78
+ private readonly waiting = new Map<string, number>();
79
+ private waitingTotal = 0;
65
80
 
66
81
  constructor(
67
82
  private readonly now: () => number = Date.now,
@@ -103,9 +118,18 @@ export class MistralBudgetPacer {
103
118
  /** Wait within the admission budget and return a release callback for one recovery probe. */
104
119
  async acquire(identity: string, signal?: AbortSignal, maxWaitMs = DEFAULT_MAX_WAIT_MS): Promise<(success?: boolean) => void> {
105
120
  signal?.throwIfAborted();
106
- if (this.waiting >= MAX_IDENTITIES) throw new RequestPacingQueueOverloadError("mistral", "queue_full", 60);
107
- this.waiting++;
108
121
  const deadline = this.now() + maxWaitMs;
122
+ let parked = false;
123
+ const park = (): void => {
124
+ if (parked) return;
125
+ const queued = this.waiting.get(identity) ?? 0;
126
+ if (queued >= MAX_WAITERS_PER_IDENTITY || this.waitingTotal >= MAX_WAITERS_TOTAL) {
127
+ throw new RequestPacingQueueOverloadError("mistral", "queue_full", 60);
128
+ }
129
+ this.waiting.set(identity, queued + 1);
130
+ this.waitingTotal++;
131
+ parked = true;
132
+ };
109
133
  try {
110
134
  for (;;) {
111
135
  signal?.throwIfAborted();
@@ -118,6 +142,7 @@ export class MistralBudgetPacer {
118
142
  throw new RequestPacingQueueOverloadError("mistral", "queue_expired", Math.ceil(delay / 1000));
119
143
  }
120
144
  if (readyAt !== undefined && readyAt > this.now()) {
145
+ park();
121
146
  await this.sleep(readyAt - this.now(), signal);
122
147
  continue;
123
148
  }
@@ -126,6 +151,7 @@ export class MistralBudgetPacer {
126
151
  if (state.probe) {
127
152
  const remaining = deadline - this.now();
128
153
  if (remaining <= 0) throw new RequestPacingQueueOverloadError("mistral", "queue_expired", 1);
154
+ park();
129
155
  const local = new AbortController();
130
156
  try {
131
157
  await Promise.race([state.probe.done, this.sleep(remaining, AbortSignal.any([local.signal, ...(signal ? [signal] : [])]))]);
@@ -150,7 +176,14 @@ export class MistralBudgetPacer {
150
176
  probe.finish();
151
177
  };
152
178
  }
153
- } finally { this.waiting--; }
179
+ } finally {
180
+ if (parked) {
181
+ this.waitingTotal--;
182
+ const left = (this.waiting.get(identity) ?? 1) - 1;
183
+ if (left > 0) this.waiting.set(identity, left);
184
+ else this.waiting.delete(identity);
185
+ }
186
+ }
154
187
  }
155
188
  }
156
189
 
@@ -8,6 +8,7 @@ import { asRecord, normalizePercent, normalizeResetAt, readQuotaJson, REQUEST_TI
8
8
  import { report, type ProviderQuotaReport } from "./report-cache";
9
9
  import { quotaCredentialIdentity } from "./account-cache";
10
10
  import type { ProviderQuota, ProviderQuotaWindow, QuotaFailureCode } from "../quota-types";
11
+ import { httpQuotaFailure } from "./failure-codes";
11
12
 
12
13
  export function antigravityQuotaDiagnosticIdentity(accountId: string, credential = getAccountCredential("google-antigravity", accountId)): string | undefined {
13
14
  return credential ? quotaCredentialIdentity("google-antigravity", accountId, credential, {
@@ -275,13 +276,6 @@ function quotaTransportFailure(error: unknown): QuotaFailureCode {
275
276
  return "transport_error";
276
277
  }
277
278
 
278
- function quotaHttpFailure(status: number): QuotaFailureCode {
279
- if (status >= 300 && status < 400) return "redirect_blocked";
280
- if (status === 401 || status === 403) return "access_denied";
281
- if (status === 429) return "rate_limited";
282
- return "upstream_error";
283
- }
284
-
285
279
  function unavailableAntigravityQuota(failure: QuotaFailureCode): AntigravityQuotaProbeResult {
286
280
  return { kind: "unavailable", failure, legacy: { kind: "null" } };
287
281
  }
@@ -342,7 +336,7 @@ export async function probeAntigravityUsageQuota(accessToken: string, projectId:
342
336
  return unavailableAntigravityQuota(antigravityUnavailableFailure(summaryFailure, "redirect_blocked"));
343
337
  }
344
338
  if (!response.ok) {
345
- return unavailableAntigravityQuota(antigravityUnavailableFailure(summaryFailure, quotaHttpFailure(response.status)));
339
+ return unavailableAntigravityQuota(antigravityUnavailableFailure(summaryFailure, httpQuotaFailure(response.status)));
346
340
  }
347
341
  const customWindows = antigravityWindowsFromModels(asRecord(await readQuotaJson(response)));
348
342
  if (!customWindows.length) {