@hegemonart/get-design-done 1.33.0 → 1.33.6
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/.claude-plugin/marketplace.json +2 -2
- package/.claude-plugin/plugin.json +1 -1
- package/CHANGELOG.md +49 -0
- package/README.md +4 -0
- package/SKILL.md +1 -0
- package/agents/design-authority-watcher.md +4 -0
- package/connections/connections.md +2 -0
- package/connections/openrouter.md +86 -0
- package/hooks/budget-enforcer.ts +103 -0
- package/package.json +5 -2
- package/reference/gdd-runtime-audit.md +111 -0
- package/reference/gdd-threat-model.md +399 -0
- package/reference/openrouter-tier-mapping.md +98 -0
- package/reference/prices.openrouter.md +26 -0
- package/reference/registry.json +28 -0
- package/scripts/lib/authority-watcher/index.cjs +147 -0
- package/scripts/lib/budget-enforcer.cjs +16 -0
- package/scripts/lib/openrouter/catalog-fetcher.cjs +326 -0
- package/scripts/lib/peer-cli/acp-client.cjs +9 -1
- package/scripts/lib/peer-cli/asp-client.cjs +10 -1
- package/scripts/lib/peer-cli/sanitize-env.cjs +198 -0
- package/scripts/lib/redact.cjs +20 -1
- package/scripts/lib/tier-resolver-openrouter.cjs +343 -0
- package/scripts/lib/transports/ws.cjs +67 -3
- package/sdk/event-stream/types.ts +24 -2
- package/sdk/mcp/gdd-state/schemas/add_blocker.schema.json +2 -0
- package/sdk/mcp/gdd-state/schemas/add_decision.schema.json +1 -0
- package/sdk/mcp/gdd-state/schemas/add_must_have.schema.json +1 -0
- package/sdk/mcp/gdd-state/schemas/checkpoint.schema.json +1 -0
- package/sdk/mcp/gdd-state/schemas/frontmatter_update.schema.json +1 -1
- package/sdk/mcp/gdd-state/schemas/get.schema.json +2 -1
- package/sdk/mcp/gdd-state/schemas/probe_connections.schema.json +2 -0
- package/sdk/mcp/gdd-state/schemas/resolve_blocker.schema.json +1 -0
- package/sdk/mcp/gdd-state/server.js +137 -48
- package/sdk/mcp/gdd-state/tools/add_blocker.ts +2 -0
- package/sdk/mcp/gdd-state/tools/add_decision.ts +2 -0
- package/sdk/mcp/gdd-state/tools/add_must_have.ts +2 -0
- package/sdk/mcp/gdd-state/tools/checkpoint.ts +2 -0
- package/sdk/mcp/gdd-state/tools/frontmatter_update.ts +2 -0
- package/sdk/mcp/gdd-state/tools/get.ts +2 -0
- package/sdk/mcp/gdd-state/tools/probe_connections.ts +2 -0
- package/sdk/mcp/gdd-state/tools/resolve_blocker.ts +2 -0
- package/sdk/mcp/gdd-state/tools/set_status.ts +2 -0
- package/sdk/mcp/gdd-state/tools/shared.ts +117 -7
- package/sdk/mcp/gdd-state/tools/transition_stage.ts +2 -0
- package/sdk/mcp/gdd-state/tools/update_progress.ts +2 -0
- package/skills/openrouter-status/SKILL.md +86 -0
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
// scripts/lib/peer-cli/sanitize-env.cjs
|
|
2
|
+
//
|
|
3
|
+
// Plan 33.5-04 — peer-CLI environment sandbox (SC#4; CONTEXT D-03).
|
|
4
|
+
//
|
|
5
|
+
// ============================================================================
|
|
6
|
+
// WHY THIS EXISTS
|
|
7
|
+
// ============================================================================
|
|
8
|
+
//
|
|
9
|
+
// The two peer-CLI clients (acp-client.cjs / asp-client.cjs) spawn external
|
|
10
|
+
// peer binaries (Gemini / Cursor / Copilot / Qwen / Codex) over stdio. Before
|
|
11
|
+
// this module, both clients defaulted the child's environment to the FULL
|
|
12
|
+
// `process.env` whenever the caller did not supply `opts.env` (acp line ~102,
|
|
13
|
+
// asp line ~122). That leaks GDD's own secrets — ANTHROPIC_API_KEY, GH_TOKEN,
|
|
14
|
+
// any GDD_* var — into every spawned peer, even though peers authenticate with
|
|
15
|
+
// their OWN logged-in credentials and have no need for GDD's keys.
|
|
16
|
+
//
|
|
17
|
+
// D-03 (locked) makes the sandbox ALLOWLIST-FORWARD / DEFAULT-DENY: the child
|
|
18
|
+
// env is built from (a) an OS-essential baseline (just enough for a binary to
|
|
19
|
+
// launch on Windows + macOS + Linux) PLUS (b) an explicit caller allowlist read
|
|
20
|
+
// from `.design/config.json#peer_cli.env_allowlist`. Everything else is dropped.
|
|
21
|
+
// GDD secrets and any secret-shaped var are NEVER forwarded unless the operator
|
|
22
|
+
// explicitly allowlists them — a one-line escape hatch for the rare peer that
|
|
23
|
+
// genuinely needs an inherited provider key.
|
|
24
|
+
//
|
|
25
|
+
// No new runtime dependency (D-12): plain JS + a defensive config read that
|
|
26
|
+
// mirrors registry.cjs's `readEnabledPeers` idiom.
|
|
27
|
+
|
|
28
|
+
'use strict';
|
|
29
|
+
|
|
30
|
+
const fs = require('node:fs');
|
|
31
|
+
const path = require('node:path');
|
|
32
|
+
|
|
33
|
+
// ── OS-essential baseline ────────────────────────────────────────────────────
|
|
34
|
+
//
|
|
35
|
+
// Exact variable names a child process generally needs to *launch* and behave
|
|
36
|
+
// correctly across Windows + POSIX. Kept deliberately pragmatic: anything not
|
|
37
|
+
// here (and not explicitly allowlisted) is dropped. The test only pins that
|
|
38
|
+
// PATH + HOME survive, so this set can evolve without breaking the contract.
|
|
39
|
+
|
|
40
|
+
const BASELINE = Object.freeze([
|
|
41
|
+
// PATH resolution (Windows uses `Path`; PATHEXT picks executable suffixes).
|
|
42
|
+
'PATH',
|
|
43
|
+
'Path',
|
|
44
|
+
'PATHEXT',
|
|
45
|
+
// Home / profile (POSIX HOME; Windows USERPROFILE + HOMEDRIVE/HOMEPATH).
|
|
46
|
+
'HOME',
|
|
47
|
+
'USERPROFILE',
|
|
48
|
+
'HOMEDRIVE',
|
|
49
|
+
'HOMEPATH',
|
|
50
|
+
// System roots (Windows).
|
|
51
|
+
'SystemRoot',
|
|
52
|
+
'windir',
|
|
53
|
+
'SystemDrive',
|
|
54
|
+
// Temp dirs (cross-platform variants).
|
|
55
|
+
'TEMP',
|
|
56
|
+
'TMP',
|
|
57
|
+
'TMPDIR',
|
|
58
|
+
// Locale / shell.
|
|
59
|
+
'LANG',
|
|
60
|
+
'SHELL',
|
|
61
|
+
// Windows command interpreter + platform descriptors.
|
|
62
|
+
'COMSPEC',
|
|
63
|
+
'OS',
|
|
64
|
+
'NUMBER_OF_PROCESSORS',
|
|
65
|
+
'PROCESSOR_ARCHITECTURE',
|
|
66
|
+
]);
|
|
67
|
+
|
|
68
|
+
// Documented baseline PREFIXES — any var whose name starts with one of these is
|
|
69
|
+
// treated as baseline (locale family + Node runtime knobs like NODE_OPTIONS).
|
|
70
|
+
const BASELINE_PREFIXES = Object.freeze(['LC_', 'NODE_']);
|
|
71
|
+
|
|
72
|
+
// ── Secret matchers (extra guard on the baseline) ─────────────────────────────
|
|
73
|
+
//
|
|
74
|
+
// SECRET_NAME — exact GDD-held secret variable names that must never leak.
|
|
75
|
+
// SECRET_PREFIX — any GDD_* var is GDD-internal and never forwarded.
|
|
76
|
+
// SECRET_SHAPE — generic secret-shaped suffixes; catches third-party keys a
|
|
77
|
+
// future baseline addition might otherwise let through.
|
|
78
|
+
//
|
|
79
|
+
// All three are overridden ONLY by an explicit entry in opts.allowlist
|
|
80
|
+
// (explicit allowlist WINS — see sanitizeEnv below).
|
|
81
|
+
|
|
82
|
+
const SECRET_NAME = Object.freeze([
|
|
83
|
+
'ANTHROPIC_API_KEY',
|
|
84
|
+
'GH_TOKEN',
|
|
85
|
+
'GITHUB_TOKEN',
|
|
86
|
+
]);
|
|
87
|
+
|
|
88
|
+
const SECRET_PREFIX = Object.freeze(['GDD_']);
|
|
89
|
+
|
|
90
|
+
const SECRET_SHAPE = /(_KEY|_TOKEN|_SECRET|_PASSWORD|_AUTH)$/i;
|
|
91
|
+
|
|
92
|
+
// ── Helpers ───────────────────────────────────────────────────────────────────
|
|
93
|
+
|
|
94
|
+
function isBaseline(key) {
|
|
95
|
+
if (BASELINE.includes(key)) return true;
|
|
96
|
+
for (const pfx of BASELINE_PREFIXES) {
|
|
97
|
+
if (key.startsWith(pfx)) return true;
|
|
98
|
+
}
|
|
99
|
+
return false;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function isSecret(key) {
|
|
103
|
+
if (SECRET_NAME.includes(key)) return true;
|
|
104
|
+
for (const pfx of SECRET_PREFIX) {
|
|
105
|
+
if (key.startsWith(pfx)) return true;
|
|
106
|
+
}
|
|
107
|
+
return SECRET_SHAPE.test(key);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Defensively read `<cwd>/.design/config.json` and extract
|
|
112
|
+
* `peer_cli.env_allowlist` (a string[]). Returns [] on ANY failure path
|
|
113
|
+
* (file missing, unparsable, wrong shape) — never throws. Mirrors
|
|
114
|
+
* registry.cjs's `readEnabledPeers` idiom so both share a defensive reader.
|
|
115
|
+
*
|
|
116
|
+
* @param {string} [cwd] defaults to process.cwd()
|
|
117
|
+
* @returns {string[]} allowlisted env var names (deduped); empty by default
|
|
118
|
+
*/
|
|
119
|
+
function readPeerCliAllowlist(cwd) {
|
|
120
|
+
const root = typeof cwd === 'string' && cwd.length > 0 ? cwd : process.cwd();
|
|
121
|
+
const cfgPath = path.join(root, '.design', 'config.json');
|
|
122
|
+
let raw;
|
|
123
|
+
try {
|
|
124
|
+
raw = fs.readFileSync(cfgPath, 'utf8');
|
|
125
|
+
} catch {
|
|
126
|
+
return [];
|
|
127
|
+
}
|
|
128
|
+
let parsed;
|
|
129
|
+
try {
|
|
130
|
+
parsed = JSON.parse(raw);
|
|
131
|
+
} catch {
|
|
132
|
+
return [];
|
|
133
|
+
}
|
|
134
|
+
const peerCli = parsed && typeof parsed === 'object' ? parsed.peer_cli : null;
|
|
135
|
+
const list = peerCli && Array.isArray(peerCli.env_allowlist) ? peerCli.env_allowlist : [];
|
|
136
|
+
const out = [];
|
|
137
|
+
const seen = new Set();
|
|
138
|
+
for (const item of list) {
|
|
139
|
+
if (typeof item !== 'string' || item.length === 0) continue;
|
|
140
|
+
if (seen.has(item)) continue;
|
|
141
|
+
seen.add(item);
|
|
142
|
+
out.push(item);
|
|
143
|
+
}
|
|
144
|
+
return out;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// ── sanitizeEnv ─────────────────────────────────────────────────────────────--
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* Build a sanitized child environment (allowlist-forward / default-deny).
|
|
151
|
+
*
|
|
152
|
+
* For each KEY in sourceEnv, forward it iff:
|
|
153
|
+
* - KEY is explicitly in opts.allowlist (explicit allowlist WINS — even over
|
|
154
|
+
* the secret filters), OR
|
|
155
|
+
* - KEY is in the OS-essential BASELINE (exact name or a documented prefix)
|
|
156
|
+
* AND KEY is NOT a GDD secret / secret-shaped var.
|
|
157
|
+
*
|
|
158
|
+
* Everything else is dropped. Pure: never mutates the input.
|
|
159
|
+
*
|
|
160
|
+
* @param {Record<string,string>} [sourceEnv=process.env]
|
|
161
|
+
* @param {{ allowlist?: string[] }} [opts]
|
|
162
|
+
* @returns {Record<string,string>}
|
|
163
|
+
*/
|
|
164
|
+
function sanitizeEnv(sourceEnv, opts) {
|
|
165
|
+
const src = sourceEnv && typeof sourceEnv === 'object' ? sourceEnv : process.env;
|
|
166
|
+
const o = opts && typeof opts === 'object' ? opts : {};
|
|
167
|
+
const allowlist = Array.isArray(o.allowlist) ? new Set(o.allowlist) : new Set();
|
|
168
|
+
|
|
169
|
+
const result = {};
|
|
170
|
+
for (const key of Object.keys(src)) {
|
|
171
|
+
const value = src[key];
|
|
172
|
+
// A value that is not a string (e.g. inherited prototype noise) is skipped;
|
|
173
|
+
// child env entries must be strings.
|
|
174
|
+
if (typeof value !== 'string') continue;
|
|
175
|
+
|
|
176
|
+
// Explicit allowlist wins over everything, including the secret filters.
|
|
177
|
+
if (allowlist.has(key)) {
|
|
178
|
+
result[key] = value;
|
|
179
|
+
continue;
|
|
180
|
+
}
|
|
181
|
+
// Otherwise the key must be baseline AND not secret-shaped.
|
|
182
|
+
if (isBaseline(key) && !isSecret(key)) {
|
|
183
|
+
result[key] = value;
|
|
184
|
+
}
|
|
185
|
+
// Default-deny: anything else is dropped.
|
|
186
|
+
}
|
|
187
|
+
return result;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
module.exports = {
|
|
191
|
+
sanitizeEnv,
|
|
192
|
+
readPeerCliAllowlist,
|
|
193
|
+
BASELINE,
|
|
194
|
+
BASELINE_PREFIXES,
|
|
195
|
+
SECRET_NAME,
|
|
196
|
+
SECRET_PREFIX,
|
|
197
|
+
SECRET_SHAPE,
|
|
198
|
+
};
|
package/scripts/lib/redact.cjs
CHANGED
|
@@ -45,11 +45,30 @@ const PATTERNS = [
|
|
|
45
45
|
type: 'slack',
|
|
46
46
|
re: /\bxox[baprs]-[A-Za-z0-9-]{10,}\b/g,
|
|
47
47
|
},
|
|
48
|
-
// GitHub personal access token.
|
|
48
|
+
// GitHub personal access token (classic).
|
|
49
49
|
{
|
|
50
50
|
type: 'github_pat',
|
|
51
51
|
re: /\bghp_[A-Za-z0-9]{36,}\b/g,
|
|
52
52
|
},
|
|
53
|
+
// Google / Gemini / GCP API key (AIza…). Distinct shape — no collision
|
|
54
|
+
// with any existing pattern; placed with the specific patterns (D-07, 33.5-05).
|
|
55
|
+
{
|
|
56
|
+
type: 'gemini',
|
|
57
|
+
re: /\bAIza[0-9A-Za-z_\-]{35}\b/g,
|
|
58
|
+
},
|
|
59
|
+
// GitHub fine-grained PAT (github_pat_…). Distinct prefix from classic
|
|
60
|
+
// `ghp_` — both coexist (D-07, 33.5-05).
|
|
61
|
+
{
|
|
62
|
+
type: 'github_pat_fine_grained',
|
|
63
|
+
re: /\bgithub_pat_[0-9A-Za-z_]{22,}\b/g,
|
|
64
|
+
},
|
|
65
|
+
// GitHub server/oauth/user/refresh tokens (ghs_/gho_/ghu_/ghr_). The
|
|
66
|
+
// `[sour]` class excludes `p`, so this never collides with `ghp_` above
|
|
67
|
+
// (D-07, 33.5-05).
|
|
68
|
+
{
|
|
69
|
+
type: 'github_token',
|
|
70
|
+
re: /\bgh[sour]_[A-Za-z0-9]{36,}\b/g,
|
|
71
|
+
},
|
|
53
72
|
// AWS access key ID.
|
|
54
73
|
{
|
|
55
74
|
type: 'aws',
|
|
@@ -0,0 +1,343 @@
|
|
|
1
|
+
// scripts/lib/tier-resolver-openrouter.cjs
|
|
2
|
+
//
|
|
3
|
+
// Plan 33.6-02 — OpenRouter tier-resolver adapter.
|
|
4
|
+
//
|
|
5
|
+
// `resolve(tier, opts?) → openrouter-model-id | null`
|
|
6
|
+
//
|
|
7
|
+
// Maps GDD's tier vocabulary (`opus` / `sonnet` / `haiku` — the same
|
|
8
|
+
// VALID_TIERS the Phase-26 tier-resolver.cjs enforces, D-04) onto a concrete
|
|
9
|
+
// model id from OpenRouter's DYNAMIC aggregator catalog. Two inputs decide the
|
|
10
|
+
// answer, in this precedence (FIRST hit wins):
|
|
11
|
+
//
|
|
12
|
+
// 1. An explicit user override (`.design/config.json#openrouter_tier_overrides`,
|
|
13
|
+
// or `opts.overrides` injected for tests). A non-empty override string for
|
|
14
|
+
// the tier is returned VERBATIM and wins over the heuristic — even when that
|
|
15
|
+
// id is not present in the catalog (the user's explicit choice). (D-03)
|
|
16
|
+
// 2. Otherwise the deterministic heuristic over the catalog `models[]`:
|
|
17
|
+
// opus = top-tier CLOSED model (priciest closed-vendor id),
|
|
18
|
+
// sonnet = mid / top-OPEN (the closed model below opus, else the strongest
|
|
19
|
+
// open model — always distinct from the opus pick),
|
|
20
|
+
// haiku = cheap OPEN model (the cheapest open-vendor id).
|
|
21
|
+
// Deterministic for a fixed catalog (stable sort; no Date, no randomness) so
|
|
22
|
+
// the 33.6-04 golden baseline is stable.
|
|
23
|
+
// 3. Otherwise (no catalog / empty models / no candidate, and no override) →
|
|
24
|
+
// null, so the CALLER falls back to the native provider via the existing
|
|
25
|
+
// scripts/lib/tier-resolver.cjs fallback chain. (D-08)
|
|
26
|
+
//
|
|
27
|
+
// The catalog comes from `opts.catalog` (alias `opts.models`) when injected
|
|
28
|
+
// (tests — hermetic, D-07), otherwise from the 33.6-01 cache via
|
|
29
|
+
// `scripts/lib/openrouter/catalog-fetcher.cjs#readCatalog`, required defensively
|
|
30
|
+
// so a missing sibling module degrades to null rather than crashing import.
|
|
31
|
+
//
|
|
32
|
+
// NEVER throws (D-08): an unknown tier, a missing/corrupt config, a corrupt
|
|
33
|
+
// cache, or garbage opts all degrade to null (or to an override when one
|
|
34
|
+
// applies). Zero npm dependencies — node builtins only (D-10). `.cjs` to match
|
|
35
|
+
// the Phase-26 sibling and stay require-able from .ts hooks under
|
|
36
|
+
// --experimental-strip-types.
|
|
37
|
+
//
|
|
38
|
+
// PATTERN: mirrors scripts/lib/tier-resolver.cjs discipline (VALID_TIERS,
|
|
39
|
+
// opts.models injection, never-throws, null-is-valid). This adapter has one
|
|
40
|
+
// upstream (OpenRouter) and no runtime argument, so its signature is
|
|
41
|
+
// `resolve(tier, opts)` rather than `resolve(runtime, tier, opts)`.
|
|
42
|
+
//
|
|
43
|
+
// SCOPE (D-12): OpenRouter is represented ONLY in this tier-resolution layer —
|
|
44
|
+
// NOT in the install registry (scripts/lib/install/runtimes.cjs) and NOT as a
|
|
45
|
+
// reference/runtime-models.md row. This adapter is the catalog's canonical
|
|
46
|
+
// consumer; the router/budget-enforcer consultation + cost-tag wiring lives in
|
|
47
|
+
// plan 33.6-03.
|
|
48
|
+
|
|
49
|
+
'use strict';
|
|
50
|
+
|
|
51
|
+
const fs = require('node:fs');
|
|
52
|
+
const path = require('node:path');
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* GDD's public tier vocabulary — the same set tier-resolver.cjs enforces
|
|
56
|
+
* (D-04). `resolve` returns null for anything outside this set (no throw).
|
|
57
|
+
*/
|
|
58
|
+
const VALID_TIERS = Object.freeze(['opus', 'sonnet', 'haiku']);
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Vendor-namespace classification. The id prefix before the first `/` names
|
|
62
|
+
* the vendor; CLOSED = frontier/premium, OPEN = commodity/cheap. The
|
|
63
|
+
* closed-vs-open split is the heuristic's primary axis (D-03).
|
|
64
|
+
*/
|
|
65
|
+
const CLOSED_VENDORS = Object.freeze(['anthropic', 'openai', 'google']);
|
|
66
|
+
const OPEN_VENDORS = Object.freeze(['meta-llama', 'qwen', 'mistralai', 'deepseek']);
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* The internal capability buckets the heuristic computes, and their one-to-one
|
|
70
|
+
* map onto the public tiers (D-04). Exported for tests + documentation parity
|
|
71
|
+
* with reference/openrouter-tier-mapping.md.
|
|
72
|
+
*/
|
|
73
|
+
const TIER_BUCKETS = Object.freeze({
|
|
74
|
+
opus: 'high', // top-tier closed
|
|
75
|
+
sonnet: 'medium', // mid / top-open
|
|
76
|
+
haiku: 'low', // cheap open
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
const DEFAULT_CONFIG_PATH = path.join('.design', 'config.json');
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Best-effort read of `.design/config.json#openrouter_tier_overrides`. Returns
|
|
83
|
+
* a plain object (possibly empty); a missing file, missing key, or corrupt JSON
|
|
84
|
+
* degrades to `{}`. NEVER throws. `opts.configPath` overrides the location for
|
|
85
|
+
* tests; otherwise the path is resolved relative to `cwd` (default
|
|
86
|
+
* `process.cwd()`).
|
|
87
|
+
*
|
|
88
|
+
* @param {object} [opts]
|
|
89
|
+
* @param {string} [opts.configPath]
|
|
90
|
+
* @param {string} [opts.cwd]
|
|
91
|
+
* @returns {{ opus?: string, sonnet?: string, haiku?: string }}
|
|
92
|
+
*/
|
|
93
|
+
function readOpenrouterOverrides(opts) {
|
|
94
|
+
try {
|
|
95
|
+
const o = opts || {};
|
|
96
|
+
const configPath =
|
|
97
|
+
typeof o.configPath === 'string' && o.configPath.length > 0
|
|
98
|
+
? o.configPath
|
|
99
|
+
: path.join(o.cwd || process.cwd(), DEFAULT_CONFIG_PATH);
|
|
100
|
+
if (!fs.existsSync(configPath)) return {};
|
|
101
|
+
const parsed = JSON.parse(fs.readFileSync(configPath, 'utf8'));
|
|
102
|
+
if (!parsed || typeof parsed !== 'object') return {};
|
|
103
|
+
const ov = parsed.openrouter_tier_overrides;
|
|
104
|
+
if (!ov || typeof ov !== 'object') return {};
|
|
105
|
+
return ov;
|
|
106
|
+
} catch {
|
|
107
|
+
// Missing/corrupt config must never break resolution — degrade to no
|
|
108
|
+
// overrides so the heuristic (or null) takes over.
|
|
109
|
+
return {};
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Defensive lazy read of the 33.6-01 catalog cache. Requires the sibling
|
|
115
|
+
* fetcher in a try/catch so a missing module (Wave-A ordering) degrades to
|
|
116
|
+
* null rather than crashing this module's import. Returns `Array<model>|null`.
|
|
117
|
+
*
|
|
118
|
+
* @param {string} [cachePath]
|
|
119
|
+
* @returns {Array<object>|null}
|
|
120
|
+
*/
|
|
121
|
+
function readCatalogDefensive(cachePath) {
|
|
122
|
+
try {
|
|
123
|
+
const fetcher = require('./openrouter/catalog-fetcher.cjs');
|
|
124
|
+
if (!fetcher || typeof fetcher.readCatalog !== 'function') return null;
|
|
125
|
+
const models = fetcher.readCatalog(
|
|
126
|
+
typeof cachePath === 'string' && cachePath.length > 0 ? { cachePath } : undefined,
|
|
127
|
+
);
|
|
128
|
+
return Array.isArray(models) ? models : null;
|
|
129
|
+
} catch {
|
|
130
|
+
return null;
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* The vendor namespace of a model id (the segment before the first `/`),
|
|
136
|
+
* lower-cased. Returns '' for a malformed id.
|
|
137
|
+
*/
|
|
138
|
+
function vendorOf(id) {
|
|
139
|
+
if (typeof id !== 'string') return '';
|
|
140
|
+
const slash = id.indexOf('/');
|
|
141
|
+
if (slash <= 0) return '';
|
|
142
|
+
return id.slice(0, slash).toLowerCase();
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function isClosed(id) {
|
|
146
|
+
return CLOSED_VENDORS.indexOf(vendorOf(id)) >= 0;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function isOpen(id) {
|
|
150
|
+
return OPEN_VENDORS.indexOf(vendorOf(id)) >= 0;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* Parse a pricing string ("0.000075") to a finite Number, or null when absent
|
|
155
|
+
* / unparseable. The completion price is the heuristic's ranking key.
|
|
156
|
+
*/
|
|
157
|
+
function completionPrice(model) {
|
|
158
|
+
if (!model || typeof model !== 'object' || !model.pricing || typeof model.pricing !== 'object') {
|
|
159
|
+
return null;
|
|
160
|
+
}
|
|
161
|
+
const raw = model.pricing.completion;
|
|
162
|
+
const n = typeof raw === 'number' ? raw : Number.parseFloat(raw);
|
|
163
|
+
return Number.isFinite(n) ? n : null;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
function contextLengthOf(model) {
|
|
167
|
+
const n = model && typeof model.context_length === 'number' ? model.context_length : 0;
|
|
168
|
+
return Number.isFinite(n) ? n : 0;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* Keep only well-formed catalog rows: objects with a non-empty string `id`.
|
|
173
|
+
* Drops `null`, numbers, and shapeless entries so the ranking never touches a
|
|
174
|
+
* bad row.
|
|
175
|
+
*/
|
|
176
|
+
function sanitize(models) {
|
|
177
|
+
if (!Array.isArray(models)) return [];
|
|
178
|
+
return models.filter(
|
|
179
|
+
m => m && typeof m === 'object' && typeof m.id === 'string' && m.id.length > 0,
|
|
180
|
+
);
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* Stable comparator factory. `dir` = -1 sorts completion price DESCENDING
|
|
185
|
+
* (priciest first, for opus); `dir` = +1 sorts ASCENDING (cheapest first, for
|
|
186
|
+
* haiku). Models with no parseable price sort LAST regardless of direction.
|
|
187
|
+
* Ties break by context_length (more capable first for desc, less for asc),
|
|
188
|
+
* then by id ascending so the order is fully deterministic for a fixed catalog.
|
|
189
|
+
*/
|
|
190
|
+
function byCompletionPrice(dir) {
|
|
191
|
+
return (a, b) => {
|
|
192
|
+
const pa = completionPrice(a);
|
|
193
|
+
const pb = completionPrice(b);
|
|
194
|
+
const aMissing = pa === null;
|
|
195
|
+
const bMissing = pb === null;
|
|
196
|
+
if (aMissing && bMissing) return a.id < b.id ? -1 : a.id > b.id ? 1 : 0;
|
|
197
|
+
if (aMissing) return 1; // a sorts last
|
|
198
|
+
if (bMissing) return -1; // b sorts last
|
|
199
|
+
if (pa !== pb) return dir < 0 ? pb - pa : pa - pb;
|
|
200
|
+
const ca = contextLengthOf(a);
|
|
201
|
+
const cb = contextLengthOf(b);
|
|
202
|
+
if (ca !== cb) return dir < 0 ? cb - ca : ca - cb;
|
|
203
|
+
return a.id < b.id ? -1 : a.id > b.id ? 1 : 0;
|
|
204
|
+
};
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/**
|
|
208
|
+
* Compute the heuristic pick for `tier` over a sanitized catalog. Returns a
|
|
209
|
+
* model id or null when no suitable candidate exists.
|
|
210
|
+
*
|
|
211
|
+
* opus → priciest CLOSED model (fallback: priciest model overall).
|
|
212
|
+
* haiku → cheapest OPEN model (fallback: cheapest model overall).
|
|
213
|
+
* sonnet → the next CLOSED model below the opus pick, else the strongest
|
|
214
|
+
* (priciest) OPEN model; always distinct from the opus pick when the
|
|
215
|
+
* catalog has >1 usable model.
|
|
216
|
+
*/
|
|
217
|
+
function heuristicPick(tier, clean) {
|
|
218
|
+
if (clean.length === 0) return null;
|
|
219
|
+
|
|
220
|
+
const closed = clean.filter(m => isClosed(m.id));
|
|
221
|
+
const open = clean.filter(m => isOpen(m.id));
|
|
222
|
+
|
|
223
|
+
if (tier === 'opus') {
|
|
224
|
+
const pool = closed.length > 0 ? closed : clean;
|
|
225
|
+
const ranked = pool.slice().sort(byCompletionPrice(-1));
|
|
226
|
+
return ranked.length > 0 ? ranked[0].id : null;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
if (tier === 'haiku') {
|
|
230
|
+
const pool = open.length > 0 ? open : clean;
|
|
231
|
+
const ranked = pool.slice().sort(byCompletionPrice(1));
|
|
232
|
+
return ranked.length > 0 ? ranked[0].id : null;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
// sonnet = MEDIUM (mid / top-open). Prefer the closed model directly below
|
|
236
|
+
// the opus pick; otherwise the strongest open model. Never collapse onto the
|
|
237
|
+
// opus pick when an alternative exists.
|
|
238
|
+
const opusPick = heuristicPick('opus', clean);
|
|
239
|
+
|
|
240
|
+
const closedDesc = closed.slice().sort(byCompletionPrice(-1));
|
|
241
|
+
// The first closed model that is NOT the opus pick (i.e. the second-priciest
|
|
242
|
+
// closed, the natural "mid closed" slot).
|
|
243
|
+
const midClosed = closedDesc.find(m => m.id !== opusPick);
|
|
244
|
+
if (midClosed) return midClosed.id;
|
|
245
|
+
|
|
246
|
+
// No second closed model — take the strongest (priciest) OPEN model.
|
|
247
|
+
const openDesc = open.slice().sort(byCompletionPrice(-1));
|
|
248
|
+
const topOpen = openDesc.find(m => m.id !== opusPick);
|
|
249
|
+
if (topOpen) return topOpen.id;
|
|
250
|
+
|
|
251
|
+
// Degenerate single-model catalog: fall back to any non-opus candidate, else
|
|
252
|
+
// the opus pick itself (better a valid id than null for a tier the caller
|
|
253
|
+
// asked for).
|
|
254
|
+
const anyOther = clean.find(m => m.id !== opusPick);
|
|
255
|
+
if (anyOther) return anyOther.id;
|
|
256
|
+
return opusPick;
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
/**
|
|
260
|
+
* Resolve a GDD tier to an OpenRouter catalog model id, or null.
|
|
261
|
+
*
|
|
262
|
+
* @param {string | null | undefined} tier
|
|
263
|
+
* One of `opus` / `sonnet` / `haiku` (D-04). Anything else → null (no throw).
|
|
264
|
+
* @param {object} [opts]
|
|
265
|
+
* @param {Array<object>} [opts.catalog]
|
|
266
|
+
* Injected catalog `models[]` (tests, hermetic — D-07). Takes precedence over
|
|
267
|
+
* the on-disk cache. `opts.models` is accepted as an interop alias.
|
|
268
|
+
* @param {Array<object>} [opts.models]
|
|
269
|
+
* Interop alias for `opts.catalog` (mirrors tier-resolver.cjs naming).
|
|
270
|
+
* @param {{opus?:string,sonnet?:string,haiku?:string}} [opts.overrides]
|
|
271
|
+
* Injected override map (tests). When absent, read from
|
|
272
|
+
* `.design/config.json#openrouter_tier_overrides` (best-effort; missing/
|
|
273
|
+
* corrupt → {}).
|
|
274
|
+
* @param {string} [opts.cachePath]
|
|
275
|
+
* Passed through to readCatalog when no catalog is injected (tests).
|
|
276
|
+
* @param {string} [opts.configPath]
|
|
277
|
+
* Override the .design/config.json location (tests).
|
|
278
|
+
* @param {string} [opts.cwd]
|
|
279
|
+
* Base dir for the default config path (tests).
|
|
280
|
+
* @returns {string | null} an OpenRouter model id, or null (caller falls back
|
|
281
|
+
* to the native provider — D-08).
|
|
282
|
+
*/
|
|
283
|
+
function resolve(tier, opts) {
|
|
284
|
+
try {
|
|
285
|
+
// Validate the tier FIRST — an unknown tier is null regardless of overrides
|
|
286
|
+
// or catalog (the override map is keyed by the valid tiers only).
|
|
287
|
+
if (typeof tier !== 'string' || VALID_TIERS.indexOf(tier) < 0) return null;
|
|
288
|
+
|
|
289
|
+
const o = opts && typeof opts === 'object' ? opts : {};
|
|
290
|
+
|
|
291
|
+
// 1. Override wins (D-03). Read injected map, else best-effort config.
|
|
292
|
+
const overrides =
|
|
293
|
+
o.overrides && typeof o.overrides === 'object'
|
|
294
|
+
? o.overrides
|
|
295
|
+
: readOpenrouterOverrides({ configPath: o.configPath, cwd: o.cwd });
|
|
296
|
+
const override = overrides ? overrides[tier] : undefined;
|
|
297
|
+
if (typeof override === 'string' && override.length > 0) {
|
|
298
|
+
return override; // verbatim — wins over the heuristic, catalog-membership irrelevant
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
// 2. Heuristic over the catalog. Injected catalog/models take precedence;
|
|
302
|
+
// otherwise read the cache defensively. An explicit `catalog: null`
|
|
303
|
+
// (or `models: null`) is honored as "no catalog" and does NOT fall
|
|
304
|
+
// through to the on-disk read — keeps injected tests hermetic.
|
|
305
|
+
let models;
|
|
306
|
+
if ('catalog' in o) {
|
|
307
|
+
models = o.catalog;
|
|
308
|
+
} else if ('models' in o) {
|
|
309
|
+
models = o.models;
|
|
310
|
+
} else {
|
|
311
|
+
models = readCatalogDefensive(o.cachePath);
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
const clean = sanitize(models);
|
|
315
|
+
if (clean.length === 0) return null; // 3. no catalog + no override → null (D-08)
|
|
316
|
+
|
|
317
|
+
const pick = heuristicPick(tier, clean);
|
|
318
|
+
return typeof pick === 'string' && pick.length > 0 ? pick : null;
|
|
319
|
+
} catch {
|
|
320
|
+
// Absolute backstop — resolve NEVER throws (D-08).
|
|
321
|
+
return null;
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
module.exports = {
|
|
326
|
+
resolve,
|
|
327
|
+
readOpenrouterOverrides,
|
|
328
|
+
VALID_TIERS,
|
|
329
|
+
TIER_BUCKETS,
|
|
330
|
+
CLOSED_VENDORS,
|
|
331
|
+
OPEN_VENDORS,
|
|
332
|
+
// internals surfaced for tests only — stable API = `resolve` +
|
|
333
|
+
// `readOpenrouterOverrides`.
|
|
334
|
+
_internal: {
|
|
335
|
+
vendorOf,
|
|
336
|
+
isClosed,
|
|
337
|
+
isOpen,
|
|
338
|
+
completionPrice,
|
|
339
|
+
heuristicPick,
|
|
340
|
+
sanitize,
|
|
341
|
+
readCatalogDefensive,
|
|
342
|
+
},
|
|
343
|
+
};
|
|
@@ -24,7 +24,9 @@
|
|
|
24
24
|
'use strict';
|
|
25
25
|
|
|
26
26
|
const http = require('node:http');
|
|
27
|
+
const crypto = require('node:crypto');
|
|
27
28
|
const { readFileSync, existsSync } = require('node:fs');
|
|
29
|
+
const path = require('node:path');
|
|
28
30
|
const { probeOptional } = require('../probe-optional.cjs');
|
|
29
31
|
|
|
30
32
|
const ws = probeOptional('ws');
|
|
@@ -56,16 +58,62 @@ function* readEventsSync(path) {
|
|
|
56
58
|
}
|
|
57
59
|
}
|
|
58
60
|
|
|
61
|
+
/**
|
|
62
|
+
* Defensively read `.design/config.json`. Returns the parsed object or `{}`
|
|
63
|
+
* on ANY failure (missing file, bad JSON, read error) — NEVER throws. The
|
|
64
|
+
* transport must still start when no config is present, so this mirrors the
|
|
65
|
+
* house defensive-fs idiom.
|
|
66
|
+
*
|
|
67
|
+
* @returns {Record<string, any>}
|
|
68
|
+
*/
|
|
69
|
+
function readDesignConfig() {
|
|
70
|
+
try {
|
|
71
|
+
const cfgPath = path.join(process.cwd(), '.design', 'config.json');
|
|
72
|
+
if (!existsSync(cfgPath)) return {};
|
|
73
|
+
const parsed = JSON.parse(readFileSync(cfgPath, 'utf8'));
|
|
74
|
+
return parsed && typeof parsed === 'object' ? parsed : {};
|
|
75
|
+
} catch {
|
|
76
|
+
return {};
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Resolve the bind host once, before listen (D-04). Order:
|
|
82
|
+
* opts.host → env GDD_WS_BIND_HOST → .design/config.json#event_stream.bind_host → '127.0.0.1'
|
|
83
|
+
* The DEFAULT (no opt, no env, no config) is loopback only — remote bind is
|
|
84
|
+
* an explicit operator opt-in.
|
|
85
|
+
*
|
|
86
|
+
* @param {{ host?: unknown }} opts
|
|
87
|
+
* @returns {string}
|
|
88
|
+
*/
|
|
89
|
+
function resolveBindHost(opts) {
|
|
90
|
+
if (typeof opts.host === 'string' && opts.host.trim()) {
|
|
91
|
+
return opts.host.trim();
|
|
92
|
+
}
|
|
93
|
+
const envHost = process.env['GDD_WS_BIND_HOST'];
|
|
94
|
+
if (typeof envHost === 'string' && envHost.trim()) {
|
|
95
|
+
return envHost.trim();
|
|
96
|
+
}
|
|
97
|
+
const cfg = readDesignConfig();
|
|
98
|
+
const cfgHost =
|
|
99
|
+
cfg && cfg.event_stream && typeof cfg.event_stream.bind_host === 'string'
|
|
100
|
+
? cfg.event_stream.bind_host.trim()
|
|
101
|
+
: '';
|
|
102
|
+
if (cfgHost) return cfgHost;
|
|
103
|
+
return '127.0.0.1';
|
|
104
|
+
}
|
|
105
|
+
|
|
59
106
|
/**
|
|
60
107
|
* Start the WebSocket server. Returns a handle with `close()`.
|
|
61
108
|
*
|
|
62
109
|
* @param {{
|
|
63
110
|
* port: number,
|
|
64
111
|
* token: string,
|
|
112
|
+
* host?: string,
|
|
65
113
|
* tailFrom?: string,
|
|
66
114
|
* subscribe?: (handler: (ev: unknown) => void) => () => void,
|
|
67
115
|
* }} opts
|
|
68
|
-
* @returns {Promise<{close: () => void, port: number}>}
|
|
116
|
+
* @returns {Promise<{close: () => void, port: number, host: string}>}
|
|
69
117
|
*/
|
|
70
118
|
async function startServer(opts) {
|
|
71
119
|
if (typeof opts.port !== 'number' || !Number.isFinite(opts.port)) {
|
|
@@ -75,6 +123,9 @@ async function startServer(opts) {
|
|
|
75
123
|
throw new TypeError('startServer: token (string, ≥8 chars) required');
|
|
76
124
|
}
|
|
77
125
|
|
|
126
|
+
// Resolve the bind host once (D-04): default 127.0.0.1 (loopback only).
|
|
127
|
+
const host = resolveBindHost(opts);
|
|
128
|
+
|
|
78
129
|
const httpServer = http.createServer((_req, res) => {
|
|
79
130
|
res.statusCode = 426; // Upgrade Required
|
|
80
131
|
res.setHeader('Content-Type', 'text/plain');
|
|
@@ -109,7 +160,16 @@ async function startServer(opts) {
|
|
|
109
160
|
|
|
110
161
|
httpServer.on('upgrade', (req, socket, head) => {
|
|
111
162
|
const auth = req.headers['authorization'];
|
|
112
|
-
|
|
163
|
+
const expected = `Bearer ${opts.token}`;
|
|
164
|
+
// Constant-time compare (D-04, D-12 node:crypto built-in). The length
|
|
165
|
+
// pre-check is REQUIRED — timingSafeEqual throws on a length mismatch —
|
|
166
|
+
// and is acceptable here because the secret is the TOKEN bytes, not its
|
|
167
|
+
// length. A missing/short/mismatched token still yields the 401 close.
|
|
168
|
+
const ok =
|
|
169
|
+
typeof auth === 'string' &&
|
|
170
|
+
Buffer.byteLength(auth) === Buffer.byteLength(expected) &&
|
|
171
|
+
crypto.timingSafeEqual(Buffer.from(auth), Buffer.from(expected));
|
|
172
|
+
if (!ok) {
|
|
113
173
|
socket.write('HTTP/1.1 401 Unauthorized\r\nConnection: close\r\n\r\n');
|
|
114
174
|
socket.destroy();
|
|
115
175
|
return;
|
|
@@ -142,12 +202,16 @@ async function startServer(opts) {
|
|
|
142
202
|
|
|
143
203
|
await new Promise((resolve, reject) => {
|
|
144
204
|
httpServer.once('error', reject);
|
|
145
|
-
httpServer.listen(opts.port, () => resolve(undefined));
|
|
205
|
+
httpServer.listen(opts.port, host, () => resolve(undefined));
|
|
146
206
|
});
|
|
147
207
|
|
|
148
208
|
const addr = httpServer.address();
|
|
149
209
|
return {
|
|
150
210
|
port: typeof addr === 'object' && addr ? addr.port : opts.port,
|
|
211
|
+
host:
|
|
212
|
+
typeof addr === 'object' && addr && typeof addr.address === 'string'
|
|
213
|
+
? addr.address
|
|
214
|
+
: host,
|
|
151
215
|
close() {
|
|
152
216
|
try {
|
|
153
217
|
unsub();
|