@bitkyc08/opencodex 2.67.0 → 2.68.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/ocx.mjs +3 -0
- package/gui/dist/assets/App-DCBismRi.css +1 -0
- package/gui/dist/assets/App-mpsibsFj.js +51 -0
- package/gui/dist/assets/Tray-Bn8WhKBH.js +1 -0
- package/gui/dist/assets/{index-DtNmX7hW.css → index-BDUBS8PW.css} +1 -1
- package/gui/dist/assets/index-kUE8tuAK.js +86 -0
- package/gui/dist/assets/quota-summary-DL-bNuNL.js +1 -0
- package/gui/dist/index.html +21 -2
- package/package.json +1 -1
- package/src/adapters/anthropic-image-guard.ts +13 -1
- package/src/adapters/anthropic-image-normalize.ts +3 -2
- package/src/adapters/anthropic-output-schema.ts +36 -0
- package/src/adapters/anthropic.ts +10 -2
- package/src/adapters/base.ts +7 -0
- package/src/adapters/codebuddy/live-models.ts +180 -0
- package/src/adapters/coding-agent/protocol.ts +91 -12
- package/src/adapters/coding-agent/turn.ts +32 -27
- package/src/adapters/devin/cloud-direct/index.ts +1 -0
- package/src/adapters/devin/cloud-direct/stated-reset-retry.ts +18 -4
- package/src/adapters/devin.ts +9 -6
- package/src/adapters/google-http.ts +4 -4
- package/src/adapters/google.ts +93 -3
- package/src/adapters/kiro/adapter.ts +6 -2
- package/src/adapters/kiro/stream.ts +12 -2
- package/src/adapters/kiro/usage.ts +3 -1
- package/src/adapters/kiro-errors.ts +11 -1
- package/src/adapters/kiro-events.ts +27 -1
- package/src/adapters/kiro-refusal.ts +30 -0
- package/src/adapters/kiro-retry.ts +71 -31
- package/src/adapters/openai-chat/deepseek-artifact-schema.ts +45 -0
- package/src/adapters/openai-chat/messages.ts +31 -4
- package/src/adapters/openai-chat/serialized-tool-call-content.ts +52 -7
- package/src/adapters/openai-chat/tool-call-id-remint.ts +65 -0
- package/src/adapters/openai-chat/tool-schema.ts +6 -2
- package/src/adapters/openai-chat.ts +2 -2
- package/src/adapters/openai-responses/muse-tool-choice.ts +31 -0
- package/src/adapters/openai-responses/passthrough.ts +9 -3
- package/src/adapters/opencode-go-additional-tools.ts +19 -10
- package/src/adapters/physical-send.ts +10 -3
- package/src/adapters/registry.ts +2 -1
- package/src/adapters/responses-tool-schema.ts +31 -3
- package/src/adapters/run-turn-queue.ts +63 -23
- package/src/adapters/unique-tool-call-ids.ts +63 -0
- package/src/adapters/xai-web-search.ts +32 -2
- package/src/bridge/sse.ts +14 -2
- package/src/chat/inbound.ts +20 -6
- package/src/claude/agents-inject.ts +4 -0
- package/src/claude/desktop-3p.ts +53 -13
- package/src/claude/desktop-profile.ts +41 -4
- package/src/claude/inbound-model-options.ts +14 -2
- package/src/claude/inbound.ts +1 -1
- package/src/claude/intercept/connect-proxy.ts +17 -1
- package/src/claude/intercept/local-ca.ts +7 -1
- package/src/claude/message-threads.ts +28 -0
- package/src/cli/account-api.ts +13 -0
- package/src/cli/account-auth.ts +49 -2
- package/src/cli/account-extended.ts +4 -2
- package/src/cli/account.ts +4 -2
- package/src/cli/capabilities.ts +17 -0
- package/src/cli/claude.ts +3 -1
- package/src/cli/dispatch.ts +62 -5
- package/src/cli/index.ts +84 -79
- package/src/cli/minimax.ts +4 -2
- package/src/cli/opencode.ts +6 -1
- package/src/cli/restart-handoff.ts +110 -0
- package/src/cli/status.ts +51 -0
- package/src/client/connect.ts +34 -15
- package/src/client/link-ingress.ts +102 -0
- package/src/client/link-join.ts +37 -16
- package/src/client/link-relay.ts +228 -45
- package/src/client/link-state.ts +54 -0
- package/src/client/link-status.ts +39 -0
- package/src/client/link-teardown.ts +2 -2
- package/src/client/link-tunnel.ts +615 -86
- package/src/client/machine-api.ts +10 -4
- package/src/client/machine-listener.ts +57 -10
- package/src/client/runtime.ts +210 -35
- package/src/clients/config-export/constants.ts +1 -12
- package/src/clients/config-export/contracts.ts +2 -0
- package/src/clients/config-export/model-metadata.ts +9 -3
- package/src/clients/config-export/omp.ts +1 -1
- package/src/clients/config-export/zcode-store.ts +2 -2
- package/src/clients/config-export.ts +36 -3
- package/src/codex/app-server-processes.ts +32 -0
- package/src/codex/app-server-restart-service.ts +20 -3
- package/src/codex/catalog/access-programs.ts +50 -0
- package/src/codex/catalog/build-entries.ts +13 -0
- package/src/codex/catalog/derive-entry.ts +1 -0
- package/src/codex/catalog/model-hints.ts +11 -0
- package/src/codex/catalog/parsing.ts +6 -2
- package/src/codex/catalog/provider-models.ts +81 -0
- package/src/codex/catalog/retained-sync.ts +3 -0
- package/src/codex/codex-write-lock.ts +2 -2
- package/src/codex/convergence.ts +2 -0
- package/src/codex/desired-state.ts +14 -4
- package/src/codex/home.ts +21 -3
- package/src/codex/inject/restore.ts +17 -0
- package/src/codex/inject/routing-classify.ts +3 -2
- package/src/codex/inject-coordination.ts +4 -1
- package/src/codex/inject.ts +8 -1
- package/src/codex/journal.ts +6 -2
- package/src/codex/management-convergence.ts +9 -0
- package/src/codex/model-entitlements.ts +28 -5
- package/src/codex/routing/idle-window.ts +58 -0
- package/src/codex/routing-drift.ts +134 -0
- package/src/codex/routing-healer.ts +419 -0
- package/src/codex/routing.ts +10 -0
- package/src/codex/runtime.ts +188 -57
- package/src/codex/sibling-handoff.ts +71 -0
- package/src/codex/sibling-start.ts +139 -0
- package/src/codex/sync.ts +5 -3
- package/src/combos/failover.ts +32 -5
- package/src/combos/request.ts +11 -3
- package/src/combos/reset-window.ts +10 -5
- package/src/combos/resolve.ts +36 -6
- package/src/config/diagnostics.ts +2 -1
- package/src/config/load-degrade.ts +2 -0
- package/src/config/paths.ts +7 -1
- package/src/config/process-state.ts +10 -1
- package/src/config/schema/compaction-recovery.ts +15 -0
- package/src/config/schema/config-schema.ts +2 -0
- package/src/config/schema/leaf-validators.ts +9 -3
- package/src/generated/compatibility-version.json +360 -204
- package/src/images/loop.ts +47 -32
- package/src/integrations/catalog-refresh.ts +4 -0
- package/src/integrations/omp-yaml-source.ts +1 -0
- package/src/lib/claude-request-projection.ts +101 -0
- package/src/lib/local-account-switch-capability.ts +48 -0
- package/src/lib/local-upstream.ts +146 -0
- package/src/lib/package-tree-integrity.ts +25 -3
- package/src/lib/package-tree-retarget.ts +130 -0
- package/src/lib/process-control.ts +4 -1
- package/src/lib/system-restart-contract.ts +106 -0
- package/src/lib/upstream-retry.ts +57 -1
- package/src/link/ports.ts +9 -0
- package/src/link/ssh-argv.ts +16 -0
- package/src/link/ssh-runner.ts +70 -2
- package/src/link/tunnel-state.ts +58 -9
- package/src/oauth/account-quota-rank.ts +29 -18
- package/src/oauth/generic-account-failover.ts +179 -22
- package/src/oauth/index.ts +17 -4
- package/src/oauth/kiro-account-load.ts +107 -0
- package/src/oauth/kiro-device-login.ts +312 -0
- package/src/oauth/kiro-terminal-failover.ts +28 -0
- package/src/oauth/login-flow-state.ts +7 -1
- package/src/oauth/pool-settings-capability.ts +23 -14
- package/src/oauth/store.ts +56 -3
- package/src/oauth/types.ts +4 -0
- package/src/plugins/loader.ts +350 -0
- package/src/plugins/upstream-hooks.ts +119 -0
- package/src/protocols/encoders/adapter-events.ts +9 -2
- package/src/providers/account-quota-disk.ts +42 -4
- package/src/providers/codebuddy-models.ts +6 -2
- package/src/providers/command-code-efforts.ts +33 -10
- package/src/providers/kiro-account-state-disk.ts +44 -0
- package/src/providers/kiro-model-catalog.ts +162 -0
- package/src/providers/kiro-models.ts +5 -4
- package/src/providers/kiro-quota-metrics.ts +35 -0
- package/src/providers/kiro-usage.ts +118 -14
- package/src/providers/quota/account-cache.ts +43 -7
- package/src/providers/quota/antigravity.ts +8 -3
- package/src/providers/quota/kiro-account-probe.ts +12 -0
- package/src/providers/quota/vendor-probes-key.ts +31 -26
- package/src/providers/quota/vendor-probes-oauth.ts +16 -7
- package/src/providers/quota-types.ts +14 -0
- package/src/providers/quota.ts +21 -21
- package/src/providers/registry/entries-extended.ts +5 -3
- package/src/providers/request-pacing.ts +410 -12
- package/src/remote-control/workspace-codex-runtime.ts +4 -3
- package/src/responses/citation-markers.ts +132 -65
- package/src/responses/hosted-tool-policy.ts +14 -3
- package/src/responses/parser-content.ts +3 -2
- package/src/responses/parser.ts +4 -1
- package/src/responses/schema.ts +4 -0
- package/src/responses/visualization-directives.ts +182 -0
- package/src/server/chat-native-sse.ts +24 -12
- package/src/server/chat-native.ts +137 -68
- package/src/server/claude-messages.ts +147 -12
- package/src/server/index/link-listener.ts +7 -0
- package/src/server/index/optional-listeners.ts +4 -1
- package/src/server/index/package-tree-guard.ts +35 -16
- package/src/server/index/serve-options.ts +4 -2
- package/src/server/index/startup-warnings.ts +6 -0
- package/src/server/index.ts +7 -7
- package/src/server/inference/client-encoder-delivery.ts +3 -0
- package/src/server/inference/context.ts +30 -2
- package/src/server/local-account-switch-auth.ts +79 -0
- package/src/server/management/agent-settings-routes.ts +11 -3
- package/src/server/management/config-routes.ts +28 -11
- package/src/server/management/context.ts +2 -0
- package/src/server/management/link-routes.ts +123 -39
- package/src/server/management/logs-usage-routes.ts +9 -42
- package/src/server/management/model-rows.ts +1 -0
- package/src/server/management/oauth-account-routes.ts +67 -12
- package/src/server/management/route-registry.ts +5 -5
- package/src/server/management/sibling-guard.ts +60 -0
- package/src/server/management/storage-log-guard-routes.ts +72 -6
- package/src/server/management/system-restart.ts +92 -135
- package/src/server/management/system-routes.ts +1 -1
- package/src/server/management-api.ts +31 -3
- package/src/server/management-auth.ts +3 -0
- package/src/server/port-reclaim.ts +111 -1
- package/src/server/proxy-liveness.ts +16 -0
- package/src/server/relay.ts +16 -15
- package/src/server/request-log-filter.ts +28 -27
- package/src/server/request-log.ts +7 -3
- package/src/server/request-metrics.ts +28 -0
- package/src/server/responses/adapter-continuation.ts +151 -21
- package/src/server/responses/adapter-delivery.ts +21 -7
- package/src/server/responses/adapter-dispatch.ts +239 -51
- package/src/server/responses/codex-ws-pool.ts +5 -4
- package/src/server/responses/codex-ws-request.ts +4 -1
- package/src/server/responses/compact.ts +8 -4
- package/src/server/responses/compaction-recovery-policy.ts +116 -0
- package/src/server/responses/compaction-recovery.ts +330 -0
- package/src/server/responses/core-combo-failure.ts +1 -0
- package/src/server/responses/core-combo.ts +5 -1
- package/src/server/responses/core-lifetime.ts +22 -0
- package/src/server/responses/core-options.ts +16 -1
- package/src/server/responses/core.ts +14 -4
- package/src/server/responses/empty-completion-guard.ts +2 -0
- package/src/server/responses/fetch-helpers.ts +82 -31
- package/src/server/responses/passthrough-delivery.ts +3 -2
- package/src/server/responses/passthrough-dispatch.ts +12 -2
- package/src/server/responses/passthrough-execution.ts +2 -2
- package/src/server/responses/request-send-budget.ts +21 -5
- package/src/server/responses/request-transport.ts +116 -18
- package/src/server/responses/reset-replay.ts +32 -0
- package/src/server/responses/run-turn-execution.ts +136 -19
- package/src/server/responses/sidecar-execution.ts +41 -8
- package/src/server/responses/terminal-guard.ts +2 -0
- package/src/server/responses/ws-upstream.ts +44 -4
- package/src/server/restart-replacement.ts +382 -0
- package/src/server/startup-health-cache.ts +23 -4
- package/src/server/stop-teardown.ts +35 -1
- package/src/server/system-env.ts +3 -0
- package/src/service/windows-taskxml.ts +11 -7
- package/src/service/windows-wrapper-exit.ts +8 -0
- package/src/stall-timeout.ts +35 -10
- package/src/storage/policy-job.ts +4 -0
- package/src/storage/scanner.ts +177 -29
- package/src/storage/storage-mutation-coordinator.ts +17 -0
- package/src/tray/windows-tray.ps1 +50 -23
- package/src/types/config.ts +4 -0
- package/src/types/provider.ts +5 -1
- package/src/types/request.ts +13 -2
- package/src/update/index.ts +3 -1
- package/src/update/job.ts +6 -2
- package/src/update/mise-launcher-target.ts +100 -0
- package/src/update/worker-launch.ts +46 -0
- package/src/usage/log.ts +2 -0
- package/src/web-search/loop.ts +5 -2
- package/gui/dist/assets/App-BJsT8Icc.css +0 -1
- package/gui/dist/assets/App-D3pNGiN4.js +0 -51
- package/gui/dist/assets/Tray-yb05wCjb.js +0 -1
- package/gui/dist/assets/index-CouvvtMV.js +0 -86
- package/gui/dist/assets/tray-data-CYGdjCJ7.js +0 -1
|
@@ -0,0 +1,350 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Local plugin loader.
|
|
3
|
+
*
|
|
4
|
+
* `ocx start` imports every `*.ts`, `*.js` and `*.mjs` file in `$OPENCODEX_HOME/plugins/`
|
|
5
|
+
* before the server binds, so a plugin's hooks are in place for the first request. A missing
|
|
6
|
+
* directory means no plugins and no work. `OCX_PLUGINS=0` disables loading for one start.
|
|
7
|
+
*
|
|
8
|
+
* A plugin is a module whose default export is `{ name?, setup(ctx) }`. It runs in the proxy
|
|
9
|
+
* process with the operator's credentials, so the loader accepts only files owned by the
|
|
10
|
+
* current user that no other user can write — the same trust boundary as `config.json`.
|
|
11
|
+
* Windows auto-loading stays disabled until this trust check is backed by an ACL check.
|
|
12
|
+
* Plugins cannot import ocx internals (a compiled binary keeps them inside `$bunfs`); they
|
|
13
|
+
* receive everything they may use through `OcxPluginContext`.
|
|
14
|
+
*
|
|
15
|
+
* Failures are contained: a plugin that throws, times out or has the wrong shape is reported
|
|
16
|
+
* and skipped, its context stops accepting registrations, and the remaining plugins and the
|
|
17
|
+
* proxy start normally. The setup deadline bounds setup that yields to the event loop; plugins
|
|
18
|
+
* run in the proxy's own thread, so synchronous work that never yields cannot be interrupted.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
import { lstatSync, readdirSync, realpathSync } from "node:fs";
|
|
22
|
+
import { basename, dirname, join } from "node:path";
|
|
23
|
+
import { pathToFileURL } from "node:url";
|
|
24
|
+
import { spawnSync } from "node:child_process";
|
|
25
|
+
import { userInfo } from "node:os";
|
|
26
|
+
import { getConfigDir } from "../config/paths";
|
|
27
|
+
import { registerOptionalShutdownHook } from "../lib/optional-shutdown-hooks";
|
|
28
|
+
import { registerUpstreamRewriter, type UpstreamRewriter } from "./upstream-hooks";
|
|
29
|
+
|
|
30
|
+
export type { UpstreamRewriter, UpstreamTarget, UpstreamTransport } from "./upstream-hooks";
|
|
31
|
+
|
|
32
|
+
export interface OcxPluginContext {
|
|
33
|
+
/** The plugin's own name, as reported in logs. */
|
|
34
|
+
readonly name: string;
|
|
35
|
+
/** `$OPENCODEX_HOME`, for plugins that keep state next to the proxy's. */
|
|
36
|
+
readonly configDir: string;
|
|
37
|
+
/** `$OPENCODEX_HOME/plugins`. */
|
|
38
|
+
readonly pluginDir: string;
|
|
39
|
+
log(message: string): void;
|
|
40
|
+
/** See `src/plugins/upstream-hooks.ts`. Called synchronously on every provider send. */
|
|
41
|
+
registerUpstreamRewriter(rewrite: UpstreamRewriter): void;
|
|
42
|
+
/** Runs once when the proxy shuts down. Must not throw or block. */
|
|
43
|
+
onShutdown(teardown: () => void): void;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export interface OcxPlugin {
|
|
47
|
+
name?: string;
|
|
48
|
+
setup(context: OcxPluginContext): void | Promise<void>;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export interface PluginLoadResult {
|
|
52
|
+
file: string;
|
|
53
|
+
name: string;
|
|
54
|
+
loaded: boolean;
|
|
55
|
+
error?: "windows_auto_load_disabled" | "directory_read_failed" | "directory_resolution_failed"
|
|
56
|
+
| "directory_untrusted" | "ancestor_untrusted" | "file_untrusted"
|
|
57
|
+
| "import_failed" | "invalid_export" | "setup_failed" | "setup_timeout";
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const PLUGIN_EXTENSIONS = [".ts", ".js", ".mjs"];
|
|
61
|
+
const SETUP_TIMEOUT_MS = 5_000;
|
|
62
|
+
const ACL_PROBE_TIMEOUT_MS = 2_000;
|
|
63
|
+
|
|
64
|
+
const MAC_ACL_BENIGN_TOKENS = new Set([
|
|
65
|
+
"read", "list", "search", "execute", "readattr", "readextattr", "readsecurity",
|
|
66
|
+
"file_inherit", "directory_inherit", "limit_inherit", "only_inherit",
|
|
67
|
+
]);
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* A deny, trusted-user grant, or read-only ACL does not let another user replace a checked path.
|
|
71
|
+
* `only_inherit` is not effective on this object; inherited grants on descendants are checked
|
|
72
|
+
* when those descendants are visited. Unknown allow rights and malformed entries fail closed.
|
|
73
|
+
*/
|
|
74
|
+
export function macAclListingTrustError(listing: string, currentUser = userInfo().username): string | null {
|
|
75
|
+
const [header, ...lines] = listing.split("\n");
|
|
76
|
+
const owner = /^\S+\s+\d+\s+(\S+)\s+/.exec(header ?? "")?.[1];
|
|
77
|
+
let unparseable = !owner;
|
|
78
|
+
for (const line of lines) {
|
|
79
|
+
if (!/^\s*\d+:/.test(line)) continue;
|
|
80
|
+
const entry = /^\s*\d+:\s+(.+?)\s+(?:inherited\s+)?(allow|deny)\s+([a-z_,]+)\s*$/.exec(line);
|
|
81
|
+
if (!entry) { unparseable = true; continue; }
|
|
82
|
+
if (entry[2] === "deny") continue;
|
|
83
|
+
const principal = entry[1]!;
|
|
84
|
+
// The file owner, this process's user, and root already control the path without an ACE.
|
|
85
|
+
// Require the `user:` prefix: a bare or group principal might include other users.
|
|
86
|
+
if (principal.startsWith("user:")
|
|
87
|
+
&& [owner, currentUser, "root", "0"].includes(principal.slice(5))) continue;
|
|
88
|
+
const rights = entry[3]!.split(",");
|
|
89
|
+
if (rights.includes("only_inherit")) continue;
|
|
90
|
+
if (rights.some(right => !MAC_ACL_BENIGN_TOKENS.has(right))) return "has an access control list";
|
|
91
|
+
}
|
|
92
|
+
return unparseable ? "access control list inspection failed" : null;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
interface MacAclProbeResult {
|
|
96
|
+
status: number | null;
|
|
97
|
+
stdout: string;
|
|
98
|
+
error?: Error;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** A failed inspection never grants trust, even if a subsequent probe is attempted. */
|
|
102
|
+
export function macAclProbeTrustError(probe: () => MacAclProbeResult): string | null {
|
|
103
|
+
try {
|
|
104
|
+
const first = probe();
|
|
105
|
+
// A timeout may carry partial stdout. Its unsafe ACEs are authoritative even if a retry
|
|
106
|
+
// would be clean; any other partial listing is incomplete and therefore also refused.
|
|
107
|
+
// Retry only when the first probe produced no ACL evidence at all.
|
|
108
|
+
let result = first;
|
|
109
|
+
if ((first.error as NodeJS.ErrnoException | undefined)?.code === "ETIMEDOUT") {
|
|
110
|
+
if (first.stdout.trim()) {
|
|
111
|
+
return macAclListingTrustError(first.stdout) ?? "access control list inspection failed";
|
|
112
|
+
}
|
|
113
|
+
result = probe();
|
|
114
|
+
}
|
|
115
|
+
if (result.error || result.status !== 0) return "access control list inspection failed";
|
|
116
|
+
return macAclListingTrustError(result.stdout);
|
|
117
|
+
} catch {
|
|
118
|
+
return "access control list inspection failed";
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/** Refuse extended ACLs: mode bits alone cannot prove who can rewrite a plugin path. */
|
|
123
|
+
function aclTrustError(path: string): string | null {
|
|
124
|
+
if (process.platform !== "darwin" && process.platform !== "linux") return null;
|
|
125
|
+
const mac = process.platform === "darwin";
|
|
126
|
+
// CI and operator PATHs may put GNU coreutils ahead of the macOS tool.
|
|
127
|
+
// GNU ls does not support -e, so pin the OS ACL inspector.
|
|
128
|
+
const probe = () => spawnSync(mac ? "/bin/ls" : "getfacl", mac
|
|
129
|
+
? ["-lebd", "--", path] : ["-cp", "--", path], {
|
|
130
|
+
encoding: "utf8",
|
|
131
|
+
timeout: ACL_PROBE_TIMEOUT_MS,
|
|
132
|
+
maxBuffer: 64 * 1024,
|
|
133
|
+
env: { ...process.env, LC_ALL: "C" },
|
|
134
|
+
});
|
|
135
|
+
if (mac) return macAclProbeTrustError(probe);
|
|
136
|
+
const result = probe();
|
|
137
|
+
// getfacl is optional on Linux. Without it, the POSIX owner/mode checks below remain;
|
|
138
|
+
// the documented Linux ACL limitation must be visible to operators.
|
|
139
|
+
if ((result.error as NodeJS.ErrnoException | undefined)?.code === "ENOENT") return null;
|
|
140
|
+
if (result.error || result.status !== 0) return "access control list inspection failed";
|
|
141
|
+
return result.stdout.split("\n").some(line => /^(?:user:[^:]+:|group:[^:]+:|mask::|default:)/.test(line))
|
|
142
|
+
? "has an access control list" : null;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
export function pluginDirectory(): string {
|
|
146
|
+
return join(getConfigDir(), "plugins");
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/** A missing directory is "no plugins"; any other read failure propagates to be reported. */
|
|
150
|
+
function listPluginFiles(dir: string): string[] {
|
|
151
|
+
let entries: string[];
|
|
152
|
+
try {
|
|
153
|
+
entries = readdirSync(dir);
|
|
154
|
+
} catch (error) {
|
|
155
|
+
if ((error as NodeJS.ErrnoException).code === "ENOENT") return [];
|
|
156
|
+
throw error;
|
|
157
|
+
}
|
|
158
|
+
return entries
|
|
159
|
+
.filter(entry => !entry.startsWith(".") && !entry.startsWith("_") && !entry.endsWith(".d.ts"))
|
|
160
|
+
.filter(entry => PLUGIN_EXTENSIONS.some(extension => entry.endsWith(extension)))
|
|
161
|
+
.sort()
|
|
162
|
+
.map(entry => join(dir, entry));
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* Null when `path` is safe to trust, otherwise the reason it is refused. `lstat` is used so a
|
|
167
|
+
* symbolic link is judged as a link — and refused — rather than as the file it points to: a
|
|
168
|
+
* link to a file you own would otherwise pass the owner and mode checks. Windows has no
|
|
169
|
+
* POSIX owner or mode bits; the loader refuses execution on that platform.
|
|
170
|
+
*/
|
|
171
|
+
function trustError(path: string, kind: "file" | "directory"): string | null {
|
|
172
|
+
let stats: ReturnType<typeof lstatSync>;
|
|
173
|
+
try {
|
|
174
|
+
stats = lstatSync(path);
|
|
175
|
+
} catch {
|
|
176
|
+
return "filesystem inspection failed";
|
|
177
|
+
}
|
|
178
|
+
if (stats.isSymbolicLink()) return "is a symbolic link";
|
|
179
|
+
if (kind === "file" ? !stats.isFile() : !stats.isDirectory()) return `not a regular ${kind}`;
|
|
180
|
+
if (process.platform === "win32") return null;
|
|
181
|
+
const uid = process.getuid?.();
|
|
182
|
+
if (uid !== undefined && stats.uid !== uid) return "owned by another user";
|
|
183
|
+
// A POSIX ACL write mask may also set group-write mode bits. Inspect the ACL first so
|
|
184
|
+
// the extended grant is explicitly refused even when the mode check would also refuse it.
|
|
185
|
+
const aclError = aclTrustError(path);
|
|
186
|
+
if (aclError) return aclError;
|
|
187
|
+
if ((stats.mode & 0o022) !== 0) return "writable by group or others (chmod go-w)";
|
|
188
|
+
return null;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/** Null when the file is safe to execute, otherwise the reason it is refused. */
|
|
192
|
+
export function pluginFileTrustError(file: string): string | null {
|
|
193
|
+
return trustError(file, "file");
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/**
|
|
197
|
+
* A directory another user can write lets them add or swap plugin files, so its owner and
|
|
198
|
+
* mode are checked like each file's.
|
|
199
|
+
*/
|
|
200
|
+
export function pluginDirectoryTrustError(dir: string): string | null {
|
|
201
|
+
return trustError(dir, "directory");
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/**
|
|
205
|
+
* Every directory above the (resolved) plugin directory, up to `/`, must be owned by the user
|
|
206
|
+
* or root and not writable by group or others unless it is sticky (like `/tmp`), where others
|
|
207
|
+
* cannot rename or replace entries they do not own. With no writable component on the path, no
|
|
208
|
+
* other user can swap what the loader checked for something else before it is imported
|
|
209
|
+
* (OpenSSH StrictModes applies the same rule). POSIX only.
|
|
210
|
+
*/
|
|
211
|
+
export function pluginAncestorsTrustError(realDir: string): string | null {
|
|
212
|
+
if (process.platform === "win32") return null;
|
|
213
|
+
const uid = process.getuid?.();
|
|
214
|
+
let current = dirname(realDir);
|
|
215
|
+
for (;;) {
|
|
216
|
+
let stats: ReturnType<typeof lstatSync>;
|
|
217
|
+
try {
|
|
218
|
+
stats = lstatSync(current);
|
|
219
|
+
} catch {
|
|
220
|
+
return "ancestor inspection failed";
|
|
221
|
+
}
|
|
222
|
+
if (uid !== undefined && stats.uid !== uid && stats.uid !== 0) return `${current} is owned by another user`;
|
|
223
|
+
const aclError = aclTrustError(current);
|
|
224
|
+
if (aclError) return aclError;
|
|
225
|
+
if ((stats.mode & 0o022) !== 0 && (stats.mode & 0o1000) === 0) {
|
|
226
|
+
return `${current} is writable by group or others`;
|
|
227
|
+
}
|
|
228
|
+
const parent = dirname(current);
|
|
229
|
+
if (parent === current) return null;
|
|
230
|
+
current = parent;
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
function isPlugin(value: unknown): value is OcxPlugin {
|
|
235
|
+
return typeof value === "object" && value !== null && typeof (value as OcxPlugin).setup === "function";
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
async function withTimeout<T>(work: Promise<T>, ms: number): Promise<T> {
|
|
239
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
240
|
+
const timeout = new Promise<never>((_, reject) => {
|
|
241
|
+
timer = setTimeout(() => reject(new PluginSetupTimeoutError()), ms);
|
|
242
|
+
});
|
|
243
|
+
try {
|
|
244
|
+
return await Promise.race([work, timeout]);
|
|
245
|
+
} finally {
|
|
246
|
+
clearTimeout(timer);
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
class PluginSetupTimeoutError extends Error {}
|
|
251
|
+
|
|
252
|
+
export interface LoadOcxPluginsOptions {
|
|
253
|
+
/** Deadline for a setup that yields; see the module comment. */
|
|
254
|
+
setupTimeoutMs?: number;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
export async function loadOcxPlugins(
|
|
258
|
+
dir = pluginDirectory(),
|
|
259
|
+
options: LoadOcxPluginsOptions = {},
|
|
260
|
+
): Promise<PluginLoadResult[]> {
|
|
261
|
+
if (process.env["OCX_PLUGINS"] === "0") return [];
|
|
262
|
+
if (process.platform === "win32") {
|
|
263
|
+
return [{ file: dir, name: "plugins directory", loaded: false, error: "windows_auto_load_disabled" }];
|
|
264
|
+
}
|
|
265
|
+
let files: string[];
|
|
266
|
+
try {
|
|
267
|
+
files = listPluginFiles(dir);
|
|
268
|
+
} catch {
|
|
269
|
+
return [{ file: dir, name: "plugins directory", loaded: false, error: "directory_read_failed" }];
|
|
270
|
+
}
|
|
271
|
+
if (files.length === 0) return [];
|
|
272
|
+
const dirRefused = pluginDirectoryTrustError(dir);
|
|
273
|
+
if (dirRefused) return [{ file: dir, name: "plugins directory", loaded: false, error: "directory_untrusted" }];
|
|
274
|
+
// Check and import through the resolved path, so both refer to the same components.
|
|
275
|
+
let realDir: string;
|
|
276
|
+
try {
|
|
277
|
+
realDir = realpathSync(dir);
|
|
278
|
+
} catch {
|
|
279
|
+
return [{ file: dir, name: "plugins directory", loaded: false, error: "directory_resolution_failed" }];
|
|
280
|
+
}
|
|
281
|
+
const ancestorRefused = pluginAncestorsTrustError(realDir);
|
|
282
|
+
if (ancestorRefused) return [{ file: dir, name: "plugins directory", loaded: false, error: "ancestor_untrusted" }];
|
|
283
|
+
const results: PluginLoadResult[] = [];
|
|
284
|
+
for (const file of files.map(listed => join(realDir, basename(listed)))) {
|
|
285
|
+
const fallbackName = basename(file).replace(/\.(ts|js|mjs)$/, "");
|
|
286
|
+
const refused = pluginFileTrustError(file);
|
|
287
|
+
if (refused) {
|
|
288
|
+
results.push({ file, name: fallbackName, loaded: false, error: "file_untrusted" });
|
|
289
|
+
continue;
|
|
290
|
+
}
|
|
291
|
+
const unregister: Array<() => void> = [];
|
|
292
|
+
// Closed when setup fails or times out: a setup that resumes later must not register.
|
|
293
|
+
let active = true;
|
|
294
|
+
let shutdownCount = 0;
|
|
295
|
+
const whileActive = (name: string, register: () => () => void): void => {
|
|
296
|
+
if (!active) {
|
|
297
|
+
console.error("[opencodex] plugin registration ignored: setup_failed");
|
|
298
|
+
return;
|
|
299
|
+
}
|
|
300
|
+
unregister.push(register());
|
|
301
|
+
};
|
|
302
|
+
let phase: "import" | "export" | "setup" = "import";
|
|
303
|
+
try {
|
|
304
|
+
const module = await import(pathToFileURL(file).href) as { default?: unknown; plugin?: unknown };
|
|
305
|
+
const plugin = module.default ?? module.plugin;
|
|
306
|
+
phase = "export";
|
|
307
|
+
if (!isPlugin(plugin)) throw new Error("default export must be { name?, setup(context) }");
|
|
308
|
+
const name = typeof plugin.name === "string" && plugin.name.trim() ? plugin.name.trim() : fallbackName;
|
|
309
|
+
const context: OcxPluginContext = {
|
|
310
|
+
name,
|
|
311
|
+
configDir: getConfigDir(),
|
|
312
|
+
pluginDir: dir,
|
|
313
|
+
log: message => console.log(`[plugin:${name}] ${message}`),
|
|
314
|
+
registerUpstreamRewriter: rewrite => whileActive(name, () => registerUpstreamRewriter(name, rewrite)),
|
|
315
|
+
// Keyed by file and registration, not name: two plugins may share a display name, and
|
|
316
|
+
// one plugin may register several teardowns.
|
|
317
|
+
onShutdown: teardown => whileActive(name, () => registerOptionalShutdownHook(`plugin:${file}#${++shutdownCount}`, teardown)),
|
|
318
|
+
};
|
|
319
|
+
const timeoutMs = options.setupTimeoutMs ?? SETUP_TIMEOUT_MS;
|
|
320
|
+
phase = "setup";
|
|
321
|
+
await withTimeout(Promise.resolve(plugin.setup(context)), timeoutMs);
|
|
322
|
+
results.push({ file, name, loaded: true });
|
|
323
|
+
} catch (error) {
|
|
324
|
+
// A half-initialised plugin must not leave hooks behind, now or later.
|
|
325
|
+
active = false;
|
|
326
|
+
for (const undo of unregister) undo();
|
|
327
|
+
results.push({
|
|
328
|
+
file,
|
|
329
|
+
name: fallbackName,
|
|
330
|
+
loaded: false,
|
|
331
|
+
error: error instanceof PluginSetupTimeoutError ? "setup_timeout"
|
|
332
|
+
: phase === "import" ? "import_failed"
|
|
333
|
+
: phase === "export" ? "invalid_export" : "setup_failed",
|
|
334
|
+
});
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
return results;
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
/** `ocx start` entry: load and print one line per plugin. Never throws. */
|
|
341
|
+
export async function loadAndReportOcxPlugins(): Promise<void> {
|
|
342
|
+
try {
|
|
343
|
+
for (const result of await loadOcxPlugins()) {
|
|
344
|
+
if (result.loaded) console.log(`🔌 Plugin loaded: ${result.name}`);
|
|
345
|
+
else console.error(`⚠️ Plugin skipped: ${result.error}`);
|
|
346
|
+
}
|
|
347
|
+
} catch {
|
|
348
|
+
console.error("⚠️ Plugin loading failed: internal_error");
|
|
349
|
+
}
|
|
350
|
+
}
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Upstream rewrite slot for local plugins.
|
|
3
|
+
*
|
|
4
|
+
* A plugin loaded by `src/plugins/loader.ts` may register a rewriter that sees every
|
|
5
|
+
* provider send at the physical boundary, after the transport was chosen: HTTP in
|
|
6
|
+
* `sendWithConnectionPolicy` and the Codex WebSocket dial in `CodexWsSession`. The rewriter
|
|
7
|
+
* may replace the URL and add or change headers — enough to put a local sidecar (a
|
|
8
|
+
* compression proxy, a recorder) in front of the provider without the core knowing it exists.
|
|
9
|
+
*
|
|
10
|
+
* This module imports nothing, so the request path pays one array-length check when no
|
|
11
|
+
* plugin is installed. A rewriter that throws is disabled for the rest of the process and
|
|
12
|
+
* the send continues unmodified: a broken plugin must never take the proxy down with it.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
export type UpstreamTransport = "http" | "websocket";
|
|
16
|
+
|
|
17
|
+
export interface UpstreamTarget {
|
|
18
|
+
/** Absolute upstream URL. A rewriter may assign a new one. */
|
|
19
|
+
url: string;
|
|
20
|
+
/** Mutable outbound headers. Credentials are present; a rewriter must not log them. */
|
|
21
|
+
headers: Headers;
|
|
22
|
+
readonly transport: UpstreamTransport;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export type UpstreamRewriter = (target: UpstreamTarget) => void;
|
|
26
|
+
|
|
27
|
+
interface Registration {
|
|
28
|
+
readonly name: string;
|
|
29
|
+
readonly rewrite: UpstreamRewriter;
|
|
30
|
+
disabled: boolean;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const registrations: Registration[] = [];
|
|
34
|
+
|
|
35
|
+
export function registerUpstreamRewriter(name: string, rewrite: UpstreamRewriter): () => void {
|
|
36
|
+
const registration: Registration = { name, rewrite, disabled: false };
|
|
37
|
+
registrations.push(registration);
|
|
38
|
+
return () => {
|
|
39
|
+
const index = registrations.indexOf(registration);
|
|
40
|
+
if (index >= 0) registrations.splice(index, 1);
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function hasUpstreamRewriters(): boolean {
|
|
45
|
+
return registrations.length > 0;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Run every active rewriter over one send. Returns the input untouched (same objects) when
|
|
50
|
+
* no rewriter is registered, so the common path allocates nothing.
|
|
51
|
+
*/
|
|
52
|
+
export function rewriteUpstream<H extends HeadersInit | undefined>(
|
|
53
|
+
url: string,
|
|
54
|
+
headers: H,
|
|
55
|
+
transport: UpstreamTransport,
|
|
56
|
+
): { url: string; headers: H | Headers } {
|
|
57
|
+
if (registrations.length === 0) return { url, headers };
|
|
58
|
+
const target: UpstreamTarget = { url, headers: new Headers(headers), transport };
|
|
59
|
+
for (const registration of registrations) {
|
|
60
|
+
if (registration.disabled) continue;
|
|
61
|
+
// A rewriter that edits the target and then throws must not leave a half-rewritten send
|
|
62
|
+
// for the next rewriter or the network.
|
|
63
|
+
const urlBefore = target.url;
|
|
64
|
+
const headersBefore = new Headers(target.headers);
|
|
65
|
+
try {
|
|
66
|
+
registration.rewrite(target);
|
|
67
|
+
} catch {
|
|
68
|
+
target.url = urlBefore;
|
|
69
|
+
target.headers = headersBefore;
|
|
70
|
+
registration.disabled = true;
|
|
71
|
+
console.error("[opencodex] plugin upstream rewriter disabled: plugin_exception");
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
return { url: target.url, headers: target.headers };
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** Plain-record variant for callers that hold headers as `Record<string, string>` (WebSocket dial). */
|
|
78
|
+
export function rewriteUpstreamRecord(
|
|
79
|
+
url: string,
|
|
80
|
+
headers: Record<string, string>,
|
|
81
|
+
transport: UpstreamTransport,
|
|
82
|
+
): { url: string; headers: Record<string, string> } {
|
|
83
|
+
if (registrations.length === 0) return { url, headers };
|
|
84
|
+
const result = rewriteUpstream(url, headers, transport);
|
|
85
|
+
const record: Record<string, string> = {};
|
|
86
|
+
new Headers(result.headers).forEach((value, key) => { record[key] = value; });
|
|
87
|
+
return { url: result.url, headers: record };
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export function isLoopbackUrl(raw: string): boolean {
|
|
91
|
+
let host: string;
|
|
92
|
+
try {
|
|
93
|
+
host = new URL(raw).hostname.toLowerCase().replace(/^\[|\]$/g, "");
|
|
94
|
+
} catch {
|
|
95
|
+
return false;
|
|
96
|
+
}
|
|
97
|
+
return host === "localhost" || host.endsWith(".localhost") || host === "::1" || /^127\.\d{1,3}\.\d{1,3}\.\d{1,3}$/.test(host);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* WebSocket dial variant, run for every exchange before a pooled socket is chosen so the pool
|
|
102
|
+
* identity can include the rewritten destination. The caller chose `proxy` for the original
|
|
103
|
+
* destination; a proxy elsewhere on the network cannot reach this machine's loopback, so a
|
|
104
|
+
* rewrite onto a loopback sidecar dials directly. Any other rewrite keeps the caller's proxy.
|
|
105
|
+
*/
|
|
106
|
+
export function rewriteWebSocketDial(
|
|
107
|
+
url: string,
|
|
108
|
+
headers: Record<string, string>,
|
|
109
|
+
proxy: string | undefined,
|
|
110
|
+
): { url: string; headers: Record<string, string>; proxy: string | undefined } {
|
|
111
|
+
if (registrations.length === 0) return { url, headers, proxy };
|
|
112
|
+
const target = rewriteUpstreamRecord(url, headers, "websocket");
|
|
113
|
+
const redirectedToLoopback = target.url !== url && isLoopbackUrl(target.url);
|
|
114
|
+
return { ...target, proxy: redirectedToLoopback ? undefined : proxy };
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
export function resetUpstreamRewritersForTests(): void {
|
|
118
|
+
registrations.length = 0;
|
|
119
|
+
}
|
|
@@ -142,6 +142,11 @@ export interface AdapterEventEncodeOptions {
|
|
|
142
142
|
freeformToolNames?: ReadonlySet<string>;
|
|
143
143
|
toolSearchToolNames?: ReadonlySet<string>;
|
|
144
144
|
stallTimeoutSec?: number;
|
|
145
|
+
/**
|
|
146
|
+
* Operator-trusted local upstream (loopback / private / `.local` / `.lan`): an unset budget
|
|
147
|
+
* resolves to disabled there instead of the 300 s default, matching the bridge (#5876).
|
|
148
|
+
*/
|
|
149
|
+
localUpstream?: boolean;
|
|
145
150
|
/** Wire-silence heartbeat and stall tick; the bridge's 2 s default. */
|
|
146
151
|
heartbeatMs?: number;
|
|
147
152
|
hooks?: ClientEncodeHooks;
|
|
@@ -165,7 +170,9 @@ export function encodeAdapterEventStream(
|
|
|
165
170
|
const heartbeatMs = options.heartbeatMs ?? 2_000;
|
|
166
171
|
const setTimer = options.timers?.setInterval ?? ((handler: () => void, ms: number) => setInterval(handler, ms));
|
|
167
172
|
const clearTimer = options.timers?.clearInterval ?? ((id: unknown) => clearInterval(id as ReturnType<typeof setInterval>));
|
|
168
|
-
const maxStallTicks = Math.ceil(
|
|
173
|
+
const maxStallTicks = Math.ceil(
|
|
174
|
+
(resolveStallTimeoutSec(options.stallTimeoutSec, { localUpstream: options.localUpstream }) * 1000) / heartbeatMs,
|
|
175
|
+
);
|
|
169
176
|
const textEncoder = new TextEncoder();
|
|
170
177
|
|
|
171
178
|
let controller!: ReadableStreamDefaultController<Uint8Array>;
|
|
@@ -836,7 +843,7 @@ export function encodeAdapterEventStream(
|
|
|
836
843
|
if (upstreamActivity) {
|
|
837
844
|
upstreamActivity = false;
|
|
838
845
|
stallTicks = 0;
|
|
839
|
-
} else if (++stallTicks >= maxStallTicks) {
|
|
846
|
+
} else if (maxStallTicks > 0 && ++stallTicks >= maxStallTicks) {
|
|
840
847
|
stall();
|
|
841
848
|
return;
|
|
842
849
|
}
|
|
@@ -14,6 +14,8 @@ import { existsSync, readFileSync } from "node:fs";
|
|
|
14
14
|
import { join } from "node:path";
|
|
15
15
|
import { atomicWriteFile, getConfigDir } from "../config";
|
|
16
16
|
import type { ProviderQuota } from "./quota-types";
|
|
17
|
+
import type { KiroPersistedQuota, KiroPersistedVerdict } from "./kiro-account-state-disk";
|
|
18
|
+
import { ACCOUNT_QUOTA_TTL_MS } from "./quota-wire";
|
|
17
19
|
|
|
18
20
|
const FILENAME = "provider-account-quota-cache.json";
|
|
19
21
|
|
|
@@ -31,7 +33,8 @@ const PERSIST_DEBOUNCE_MS = 250;
|
|
|
31
33
|
type DiskFile = {
|
|
32
34
|
version: 1;
|
|
33
35
|
/** provider\u0000accountId -> quota, the same key the in-memory cache uses. */
|
|
34
|
-
rows: Record<string, ProviderQuota>;
|
|
36
|
+
rows: Record<string, ProviderQuota | KiroPersistedQuota>;
|
|
37
|
+
kiroVerdicts?: Record<string, KiroPersistedVerdict>;
|
|
35
38
|
};
|
|
36
39
|
|
|
37
40
|
let persistTimer: ReturnType<typeof setTimeout> | null = null;
|
|
@@ -45,8 +48,12 @@ export function readPersistedAccountQuotas(now = Date.now()): Map<string, Provid
|
|
|
45
48
|
const parsed = JSON.parse(readFileSync(path, "utf8")) as DiskFile;
|
|
46
49
|
if (!parsed || parsed.version !== 1 || !parsed.rows || typeof parsed.rows !== "object") return rows;
|
|
47
50
|
for (const [key, quota] of Object.entries(parsed.rows)) {
|
|
48
|
-
if (!quota || typeof quota !== "object" || typeof quota.updatedAt !== "number"
|
|
51
|
+
if (!quota || typeof quota !== "object" || typeof quota.updatedAt !== "number"
|
|
52
|
+
|| !Number.isFinite(quota.updatedAt)) continue;
|
|
49
53
|
if (now - quota.updatedAt > DISK_MAX_AGE_MS) continue;
|
|
54
|
+
if (key.startsWith("kiro\0") && (quota.updatedAt > now
|
|
55
|
+
|| typeof (quota as KiroPersistedQuota).identity !== "string"
|
|
56
|
+
|| !/^[a-f0-9]{64}$/.test((quota as KiroPersistedQuota).identity))) continue;
|
|
50
57
|
rows.set(key, quota);
|
|
51
58
|
}
|
|
52
59
|
} catch {
|
|
@@ -55,15 +62,46 @@ export function readPersistedAccountQuotas(now = Date.now()): Map<string, Provid
|
|
|
55
62
|
return rows;
|
|
56
63
|
}
|
|
57
64
|
|
|
65
|
+
export function readPersistedKiroVerdicts(now = Date.now()): Map<string, KiroPersistedVerdict> {
|
|
66
|
+
const result = new Map<string, KiroPersistedVerdict>();
|
|
67
|
+
try {
|
|
68
|
+
const file: unknown = JSON.parse(readFileSync(join(getConfigDir(), FILENAME), "utf8"));
|
|
69
|
+
if (!file || typeof file !== "object" || Array.isArray(file)) return result;
|
|
70
|
+
const parsed = file as Partial<DiskFile>;
|
|
71
|
+
if (parsed.version !== 1 || !parsed.kiroVerdicts || typeof parsed.kiroVerdicts !== "object"
|
|
72
|
+
|| Array.isArray(parsed.kiroVerdicts)) return result;
|
|
73
|
+
for (const [key, value] of Object.entries(parsed.kiroVerdicts)) {
|
|
74
|
+
if (!key.startsWith("kiro\0") || !value || typeof value !== "object") continue;
|
|
75
|
+
const row = value as Partial<KiroPersistedVerdict>;
|
|
76
|
+
if (typeof row.exhausted !== "boolean" || typeof row.identity !== "string"
|
|
77
|
+
|| !/^[a-f0-9]{64}$/.test(row.identity) || typeof row.observedAt !== "number"
|
|
78
|
+
|| !Number.isFinite(row.observedAt) || row.observedAt > now
|
|
79
|
+
|| now - row.observedAt >= ACCOUNT_QUOTA_TTL_MS
|
|
80
|
+
|| (row.resetAt !== undefined && (typeof row.resetAt !== "number"
|
|
81
|
+
|| !Number.isFinite(row.resetAt) || row.resetAt <= now
|
|
82
|
+
|| !Number.isFinite(new Date(row.resetAt).getTime())))) continue;
|
|
83
|
+
result.set(key, row as KiroPersistedVerdict);
|
|
84
|
+
}
|
|
85
|
+
} catch { /* Missing or malformed evidence is unknown. */ }
|
|
86
|
+
return result;
|
|
87
|
+
}
|
|
88
|
+
|
|
58
89
|
/** Write the snapshot, debounced. Best-effort: a failed write is not an error. */
|
|
59
|
-
export function schedulePersistAccountQuotas(
|
|
90
|
+
export function schedulePersistAccountQuotas(
|
|
91
|
+
rows: () => Iterable<[string, ProviderQuota | KiroPersistedQuota]>,
|
|
92
|
+
verdicts: () => Iterable<[string, KiroPersistedVerdict]> = () => [],
|
|
93
|
+
): void {
|
|
60
94
|
if (persistTimer) clearTimeout(persistTimer);
|
|
61
95
|
persistTimer = setTimeout(() => {
|
|
62
96
|
persistTimer = null;
|
|
63
97
|
try {
|
|
64
98
|
const out: Record<string, ProviderQuota> = {};
|
|
65
99
|
for (const [key, quota] of rows()) out[key] = quota;
|
|
66
|
-
const
|
|
100
|
+
const kiroVerdicts: Record<string, KiroPersistedVerdict> = {};
|
|
101
|
+
for (const [key, verdict] of verdicts()) {
|
|
102
|
+
if (key.startsWith("kiro\0") && /^[a-f0-9]{64}$/.test(verdict.identity)) kiroVerdicts[key] = verdict;
|
|
103
|
+
}
|
|
104
|
+
const body: DiskFile = { version: 1, rows: out, kiroVerdicts };
|
|
67
105
|
atomicWriteFile(join(getConfigDir(), FILENAME), `${JSON.stringify(body)}\n`);
|
|
68
106
|
} catch {
|
|
69
107
|
// Best-effort persistence only.
|
|
@@ -7,8 +7,12 @@
|
|
|
7
7
|
* Global and CN are deliberately NOT the same roster (§八). Context windows, output caps, vision
|
|
8
8
|
* and reasoning ladders are filled ONLY where the official manifest states them; a model with no
|
|
9
9
|
* published figure is omitted rather than guessed (§二十八/§二十九). CodeBuddy exposes no documented
|
|
10
|
-
* third-party live `/v1/models` endpoint, so
|
|
11
|
-
*
|
|
10
|
+
* third-party live `/v1/models` endpoint, so live discovery instead reads the key-authenticated
|
|
11
|
+
* product configuration roster (src/adapters/codebuddy/live-models.ts) and this static catalog is
|
|
12
|
+
* only the degraded seed for keys that fail to authenticate or requests that fail. The
|
|
13
|
+
* server-side roster can list models the manifest does not know — an account's entitlement can
|
|
14
|
+
* be newer than the bundled manifest — so a mismatch between this file and a live roster is
|
|
15
|
+
* expected, not a catalog bug.
|
|
12
16
|
*/
|
|
13
17
|
|
|
14
18
|
/** Global (`public`) session models accepted by `codebuddy --model`. */
|