From c3def462354fc83396e4a2d0e9d62d38719026fb Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 9 Aug 2026 20:01:02 +0900 Subject: [PATCH 01/26] fix(orchestrator): fail closed outside explicit development mode --- server/orchestrator.mjs | 191 +++++++++++++++++++++++++++++++++++----- 1 file changed, 171 insertions(+), 20 deletions(-) diff --git a/server/orchestrator.mjs b/server/orchestrator.mjs index 1205ebe7..2a015976 100644 --- a/server/orchestrator.mjs +++ b/server/orchestrator.mjs @@ -1,35 +1,186 @@ -// contextual-orchestrator(LLM 오케스트레이션) 클라이언트. -// 실서버: ORCHESTRATOR_URL + ORCHESTRATOR_TOKEN 설정 시 OpenAI 호환 -// /v1/chat/completions 호출. 미설정 시 결정적 MOCK으로 전 플로우 테스트 가능. +// contextual-orchestrator client. Production requires an authenticated endpoint; +// deterministic responses exist only under the explicit SCOPEWEAVE_DEV=1 boundary. const OC_URL = (process.env.ORCHESTRATOR_URL || '').replace(/\/$/, ''); const OC_TOKEN = process.env.ORCHESTRATOR_TOKEN || ''; +const OC_MODEL = process.env.ORCHESTRATOR_MODEL || 'contextual-orchestrator'; +const ORCHESTRATOR_TIMEOUT_MS = 120_000; +const MAX_MESSAGE_COUNT = 256; +const MAX_CONTENT_LENGTH = 100_000; -export const orchestratorMock = !OC_URL; +export const orchestratorMock = process.env.SCOPEWEAVE_DEV === '1' && !OC_URL; +/** Stable provider-boundary failure for AI briefing requests. */ +export class OrchestratorConfigurationError extends Error { + /** + * Create one operator-safe orchestrator error. + * @param {string} code machine-readable failure code + * @param {string} message operator-safe detail + */ + constructor(code, message) { + super(message); + this.name = 'OrchestratorConfigurationError'; + this.code = code; + } +} + +/** + * Resolve explicit development mode or a complete authenticated production endpoint. + * @returns {{mock: true} | {mock: false, baseUrl: string, token: string}} + */ +function orchestratorConfiguration() { + if (orchestratorMock) return { mock: true }; + if (!OC_URL) { + throw new OrchestratorConfigurationError( + 'orchestrator_not_configured', + 'contextual-orchestrator is unavailable because ORCHESTRATOR_URL is not configured.', + ); + } + let url; + try { + url = new URL(OC_URL); + } catch { + throw new OrchestratorConfigurationError( + 'orchestrator_url_invalid', + 'ORCHESTRATOR_URL must be a valid absolute URL.', + ); + } + if (!['https:', 'http:'].includes(url.protocol)) { + throw new OrchestratorConfigurationError( + 'orchestrator_url_invalid', + 'ORCHESTRATOR_URL must use HTTP or HTTPS.', + ); + } + if (url.protocol !== 'https:' && !['localhost', '127.0.0.1', '::1'].includes(url.hostname)) { + throw new OrchestratorConfigurationError( + 'orchestrator_transport_insecure', + 'contextual-orchestrator production traffic requires HTTPS.', + ); + } + if (!OC_TOKEN.trim()) { + throw new OrchestratorConfigurationError( + 'orchestrator_token_missing', + 'ORCHESTRATOR_TOKEN is required for production requests.', + ); + } + return { mock: false, baseUrl: url.toString().replace(/\/$/, ''), token: OC_TOKEN }; +} + +/** + * Validate and copy OpenAI-compatible messages without accepting unbounded content. + * @param {unknown} messages candidate conversation + * @returns {{role: string, content: string}[]} + */ +function validatedMessages(messages) { + if (!Array.isArray(messages) || messages.length === 0 || messages.length > MAX_MESSAGE_COUNT) { + throw new OrchestratorConfigurationError( + 'orchestrator_messages_invalid', + 'Orchestrator messages must be a non-empty bounded array.', + ); + } + return messages.map((message) => { + if (!message || typeof message !== 'object' || Array.isArray(message)) { + throw new OrchestratorConfigurationError( + 'orchestrator_message_invalid', + 'Each orchestrator message must be an object.', + ); + } + if (!['system', 'developer', 'user', 'assistant'].includes(message.role)) { + throw new OrchestratorConfigurationError( + 'orchestrator_message_role_invalid', + 'Orchestrator message role is unsupported.', + ); + } + if ( + typeof message.content !== 'string' + || message.content.length === 0 + || message.content.length > MAX_CONTENT_LENGTH + ) { + throw new OrchestratorConfigurationError( + 'orchestrator_message_content_invalid', + 'Orchestrator message content is outside the accepted boundary.', + ); + } + return { role: message.role, content: message.content }; + }); +} + +/** + * Parse one provider response without returning raw provider payloads in failures. + * @param {Response} response provider response + * @returns {Promise>} + */ +async function responseJson(response) { + let data; + try { + data = await response.json(); + } catch { + throw new OrchestratorConfigurationError( + 'orchestrator_response_invalid', + 'contextual-orchestrator returned a non-JSON response.', + ); + } + if (!data || typeof data !== 'object' || Array.isArray(data)) { + throw new OrchestratorConfigurationError( + 'orchestrator_response_invalid', + 'contextual-orchestrator returned an invalid response object.', + ); + } + return data; +} + +/** + * Generate one AI briefing through contextual-orchestrator. + * @param {unknown} messages OpenAI-compatible messages + * @returns {Promise} + */ export async function chat(messages) { - if (orchestratorMock) { - const user = messages.filter((m) => m.role === 'user').map((m) => m.content).join('\n'); - return `[mock-orchestrator] 분석 요약: ${user.slice(0, 120)}…에 대한 모의 응답입니다. ` + const configuration = orchestratorConfiguration(); + const safeMessages = validatedMessages(messages); + if (configuration.mock) { + const user = safeMessages + .filter((message) => message.role === 'user') + .map((message) => message.content) + .join('\n'); + return `[dev-orchestrator] 분석 요약: ${user.slice(0, 120)}…에 대한 개발 응답입니다. ` + '리스크: 지연 작업을 우선 점검하세요. 권고: 임계경로 작업의 담당자 부하를 재배분하세요.'; } - const ctrl = new AbortController(); - const to = setTimeout(() => ctrl.abort(), 60000); + if (typeof globalThis.fetch !== 'function') { + throw new OrchestratorConfigurationError( + 'orchestrator_transport_unavailable', + 'Orchestrator HTTP transport is unavailable.', + ); + } + + let response; try { - const res = await fetch(`${OC_URL}/v1/chat/completions`, { + response = await globalThis.fetch(`${configuration.baseUrl}/v1/chat/completions`, { method: 'POST', headers: { 'content-type': 'application/json', - ...(OC_TOKEN ? { authorization: `Bearer ${OC_TOKEN}` } : {}), + authorization: `Bearer ${configuration.token}`, }, - // orchestrator는 알 수 없는 필드를 거부(strict validation) — model+messages만 전송. - body: JSON.stringify({ model: 'contextual-orchestrator', messages }), - signal: ctrl.signal, + body: JSON.stringify({ model: OC_MODEL, messages: safeMessages }), + signal: AbortSignal.timeout(ORCHESTRATOR_TIMEOUT_MS), }); - const data = await res.json().catch(() => ({})); - const content = data?.choices?.[0]?.message?.content; - if (!res.ok || !content) throw new Error(data?.error?.message || `orchestrator failed (${res.status})`); - return content; - } finally { - clearTimeout(to); + } catch { + throw new OrchestratorConfigurationError( + 'orchestrator_provider_unavailable', + 'contextual-orchestrator could not be reached.', + ); + } + const data = await responseJson(response); + const content = data?.choices?.[0]?.message?.content; + if (!response.ok) { + throw new OrchestratorConfigurationError( + 'orchestrator_provider_rejected', + `contextual-orchestrator rejected the request with HTTP ${response.status}.`, + ); + } + if (typeof content !== 'string' || !content.trim()) { + throw new OrchestratorConfigurationError( + 'orchestrator_response_invalid', + 'contextual-orchestrator returned no assistant content.', + ); } + return content; } From 6525970117a9ee9cf2d7ba4ad3e18e70e0f75fc7 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 9 Aug 2026 20:01:43 +0900 Subject: [PATCH 02/26] test(orchestrator): prove production never returns deterministic fake output --- tests/unit/orchestrator.test.mjs | 135 +++++++++++++++++++++++++++++++ 1 file changed, 135 insertions(+) create mode 100644 tests/unit/orchestrator.test.mjs diff --git a/tests/unit/orchestrator.test.mjs b/tests/unit/orchestrator.test.mjs new file mode 100644 index 00000000..575239d7 --- /dev/null +++ b/tests/unit/orchestrator.test.mjs @@ -0,0 +1,135 @@ +import assert from 'node:assert/strict'; + +const ORIGINAL_ENV = { ...process.env }; +const ORIGINAL_FETCH = globalThis.fetch; + +function restoreEnvironment() { + for (const key of Object.keys(process.env)) { + if (!(key in ORIGINAL_ENV)) delete process.env[key]; + } + Object.assign(process.env, ORIGINAL_ENV); + globalThis.fetch = ORIGINAL_FETCH; +} + +async function freshModule(label) { + return import(`../../server/orchestrator.mjs?test=${label}-${Date.now()}-${Math.random()}`); +} + +try { + delete process.env.ORCHESTRATOR_URL; + delete process.env.ORCHESTRATOR_TOKEN; + delete process.env.ORCHESTRATOR_MODEL; + delete process.env.SCOPEWEAVE_DEV; + const unconfigured = await freshModule('unconfigured'); + assert.equal(unconfigured.orchestratorMock, false); + await assert.rejects( + unconfigured.chat([{ role: 'user', content: 'status' }]), + (error) => error.code === 'orchestrator_not_configured', + ); + + process.env.SCOPEWEAVE_DEV = '1'; + const development = await freshModule('development'); + assert.equal(development.orchestratorMock, true); + const developmentResult = await development.chat([ + { role: 'system', content: 'Summarize the plan.' }, + { role: 'user', content: 'Find the critical path.' }, + ]); + assert.match(developmentResult, /^\[dev-orchestrator\]/); + assert.match(developmentResult, /Find the critical path/); + + delete process.env.SCOPEWEAVE_DEV; + process.env.ORCHESTRATOR_URL = 'https://orchestrator.example'; + delete process.env.ORCHESTRATOR_TOKEN; + const missingToken = await freshModule('missing-token'); + await assert.rejects( + missingToken.chat([{ role: 'user', content: 'status' }]), + (error) => error.code === 'orchestrator_token_missing', + ); + + process.env.ORCHESTRATOR_URL = 'http://orchestrator.example'; + process.env.ORCHESTRATOR_TOKEN = 'secret-token'; + const insecure = await freshModule('insecure'); + await assert.rejects( + insecure.chat([{ role: 'user', content: 'status' }]), + (error) => error.code === 'orchestrator_transport_insecure', + ); + + process.env.ORCHESTRATOR_URL = 'https://orchestrator.example'; + process.env.ORCHESTRATOR_MODEL = 'nvidia/nemotron-3-super-120b-a12b'; + const configured = await freshModule('configured'); + const calls = []; + globalThis.fetch = async (url, init) => { + calls.push({ url, init }); + return new Response(JSON.stringify({ + choices: [{ message: { content: 'Grounded production response' } }], + }), { + status: 200, + headers: { 'content-type': 'application/json' }, + }); + }; + assert.equal( + await configured.chat([{ role: 'user', content: 'status' }]), + 'Grounded production response', + ); + assert.equal(calls.length, 1); + assert.equal(calls[0].url, 'https://orchestrator.example/v1/chat/completions'); + assert.equal(calls[0].init.headers.authorization, 'Bearer secret-token'); + assert.ok(calls[0].init.signal instanceof AbortSignal); + assert.deepEqual(JSON.parse(calls[0].init.body), { + model: 'nvidia/nemotron-3-super-120b-a12b', + messages: [{ role: 'user', content: 'status' }], + }); + + for (const invalidMessages of [ + [], + [null], + [{ role: 'tool', content: 'status' }], + [{ role: 'user', content: '' }], + [{ role: 'user', content: 'x'.repeat(100_001) }], + ]) { + await assert.rejects( + configured.chat(invalidMessages), + (error) => error.code.startsWith('orchestrator_message'), + ); + } + + globalThis.fetch = async () => { throw new Error('offline'); }; + await assert.rejects( + configured.chat([{ role: 'user', content: 'status' }]), + (error) => error.code === 'orchestrator_provider_unavailable', + ); + + globalThis.fetch = async () => new Response('not-json', { status: 502 }); + await assert.rejects( + configured.chat([{ role: 'user', content: 'status' }]), + (error) => error.code === 'orchestrator_response_invalid', + ); + + globalThis.fetch = async () => new Response(JSON.stringify({ error: {} }), { + status: 503, + headers: { 'content-type': 'application/json' }, + }); + await assert.rejects( + configured.chat([{ role: 'user', content: 'status' }]), + (error) => error.code === 'orchestrator_provider_rejected', + ); + + globalThis.fetch = async () => new Response(JSON.stringify({ choices: [] }), { + status: 200, + headers: { 'content-type': 'application/json' }, + }); + await assert.rejects( + configured.chat([{ role: 'user', content: 'status' }]), + (error) => error.code === 'orchestrator_response_invalid', + ); + + globalThis.fetch = undefined; + await assert.rejects( + configured.chat([{ role: 'user', content: 'status' }]), + (error) => error.code === 'orchestrator_transport_unavailable', + ); +} finally { + restoreEnvironment(); +} + +console.log('✓ orchestrator production boundary tests passed'); From 02d1bf6acad6a8e03452cd80194d149b123d87be Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 9 Aug 2026 20:02:15 +0900 Subject: [PATCH 03/26] test(coverage): include orchestrator production boundary --- package.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/package.json b/package.json index 7790e678..d30f8507 100644 --- a/package.json +++ b/package.json @@ -10,11 +10,11 @@ }, "scripts": { "check:python-docstrings": "node scripts/ci/static_coverage_evidence.mjs docstrings", - "coverage": "c8 --all --include=app.js --include=cloud-sync.js --include=scripts/ci/static_coverage_evidence.mjs --include=server/app.mjs --include=server/auth.mjs --reporter=json --reporter=json-summary npm run test:coverage", + "coverage": "c8 --all --include=app.js --include=cloud-sync.js --include=scripts/ci/static_coverage_evidence.mjs --include=server/app.mjs --include=server/auth.mjs --include=server/orchestrator.mjs --reporter=json --reporter=json-summary npm run test:coverage", "server": "node server/server.mjs", "test:api": "node tests/api/auth-secret.test.mjs && node tests/api/smoke.mjs && node tests/api/ratelimit.test.mjs", - "test:unit": "node tests/unit/analytics.test.mjs && node tests/unit/cpm.test.mjs && node tests/unit/baseline-compare.test.mjs && node tests/unit/workload.test.mjs && node tests/unit/cost-evm.test.mjs && node tests/unit/msproject.test.mjs && node tests/unit/auth-password.test.mjs && node tests/unit/editor-unsaved.test.mjs && node tests/unit/static-coverage-evidence.test.mjs && node tests/unit/dep-types.test.mjs && node tests/unit/weekly-report.test.mjs && node tests/unit/clearfolio.test.mjs && node tests/unit/sprint-stats.test.mjs && node tests/unit/burndown.test.mjs && node tests/unit/pm-analysis.test.mjs && node tests/unit/cloud-sync-security.test.mjs", - "test:coverage": "node tests/unit/msproject.test.mjs && node tests/unit/auth-password.test.mjs && node tests/unit/editor-unsaved.test.mjs && node tests/unit/static-coverage-evidence.test.mjs && npm run test:api", + "test:unit": "node tests/unit/analytics.test.mjs && node tests/unit/cpm.test.mjs && node tests/unit/baseline-compare.test.mjs && node tests/unit/workload.test.mjs && node tests/unit/cost-evm.test.mjs && node tests/unit/msproject.test.mjs && node tests/unit/auth-password.test.mjs && node tests/unit/editor-unsaved.test.mjs && node tests/unit/static-coverage-evidence.test.mjs && node tests/unit/dep-types.test.mjs && node tests/unit/weekly-report.test.mjs && node tests/unit/clearfolio.test.mjs && node tests/unit/orchestrator.test.mjs && node tests/unit/sprint-stats.test.mjs && node tests/unit/burndown.test.mjs && node tests/unit/pm-analysis.test.mjs && node tests/unit/cloud-sync-security.test.mjs", + "test:coverage": "node tests/unit/orchestrator.test.mjs && node tests/unit/msproject.test.mjs && node tests/unit/auth-password.test.mjs && node tests/unit/editor-unsaved.test.mjs && node tests/unit/static-coverage-evidence.test.mjs && npm run test:api", "test:e2e": "playwright test", "test:e2e:headed": "playwright test --headed", "test:e2e:cloud": "playwright test tests/e2e/cloud.spec.js", From 2b02f236fa8f965d22b5837ba57bc81794601ab8 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 9 Aug 2026 20:02:59 +0900 Subject: [PATCH 04/26] docs(orchestrator): define fail-closed learned-coordination boundary --- docs/orchestrator-production.md | 52 +++++++++++++++++++++++++++++++++ 1 file changed, 52 insertions(+) create mode 100644 docs/orchestrator-production.md diff --git a/docs/orchestrator-production.md b/docs/orchestrator-production.md new file mode 100644 index 00000000..6261434c --- /dev/null +++ b/docs/orchestrator-production.md @@ -0,0 +1,52 @@ +# contextual-orchestrator Production Contract + +ScopeWeave delegates AI briefing work to `contextual-orchestrator`; it does not +silently replace unavailable production inference with deterministic text. + +## Required environment + +```text +ORCHESTRATOR_URL=https://orchestrator.example +ORCHESTRATOR_TOKEN= +ORCHESTRATOR_MODEL=contextual-orchestrator +``` + +Production requests fail closed when the endpoint or bearer token is absent. +Non-loopback HTTP endpoints are rejected, requests are bounded to 120 seconds, +message count and content size are validated, provider payloads are not exposed +in errors, and an empty or malformed assistant response is never reported as a +successful briefing. + +The deterministic adapter is available only when `SCOPEWEAVE_DEV=1` and the +endpoint is absent. That variable must never be set in staging or production. + +## Orchestration responsibility + +ScopeWeave intentionally sends only a versioned OpenAI-compatible request to +the orchestration service. Model selection, single-model versus multi-agent +allocation, task decomposition, role-specific reasoning effort, recursion +limits, access lists, synthesis, and verification belong to +`contextual-orchestrator`, where they can be evaluated and evolved centrally. +This separation is consistent with learned coordination research: Conductor +learns task decompositions, worker assignments, communication topologies, and +recursive test-time scaling; TRINITY adaptively assigns Thinker, Worker, and +Verifier roles over multiple turns; Fugu operationalizes learned orchestration +behind one model-compatible API. + +ScopeWeave therefore does not hard-code a local fake solver, fixed topology, or +provider-specific bypass. Changes to orchestration policy require benchmarked +ablation evidence in `contextual-orchestrator`, including single-model, +parallel, sequential, hierarchical, recursive, and verifier-assisted paths. + +## APA 7th references + +Nielsen, S., Cetin, E., Schwendeman, P., Sun, Q., Xu, J., & Tang, Y. (2025). +*Learning to orchestrate agents in natural language with the Conductor*. +arXiv. https://doi.org/10.48550/arXiv.2512.04388 + +Sakana AI. (2026). *Sakana Fugu: Multi-agent system as a model*. +https://sakana.ai/fugu/ + +Xu, J., Sun, Q., Schwendeman, P., Nielsen, S., Cetin, E., & Tang, Y. (2025). +*TRINITY: An evolved LLM coordinator*. arXiv. +https://doi.org/10.48550/arXiv.2512.04695 From eb9435e6e07d454e3d7b20b701b2dd57b69753f9 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 9 Aug 2026 20:03:18 +0900 Subject: [PATCH 05/26] ci(orchestrator): verify fail-closed production behavior once --- ...-shot-orchestrator-production-boundary.yml | 70 +++++++++++++++++++ 1 file changed, 70 insertions(+) create mode 100644 .github/workflows/one-shot-orchestrator-production-boundary.yml diff --git a/.github/workflows/one-shot-orchestrator-production-boundary.yml b/.github/workflows/one-shot-orchestrator-production-boundary.yml new file mode 100644 index 00000000..e7eba335 --- /dev/null +++ b/.github/workflows/one-shot-orchestrator-production-boundary.yml @@ -0,0 +1,70 @@ +name: One-shot orchestrator production boundary verification + +on: + push: + branches: [fix/orchestrator-production-fail-closed-20260809] + +permissions: + contents: write + +concurrency: + group: one-shot-orchestrator-production-boundary + cancel-in-progress: false + +jobs: + verify-document-commit: + if: github.actor != 'github-actions[bot]' + runs-on: ubuntu-latest + timeout-minutes: 25 + steps: + - name: Harden runner + uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0 + with: + egress-policy: audit + + - name: Checkout exact branch head + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + ref: fix/orchestrator-production-fail-closed-20260809 + fetch-depth: 0 + + - name: Record the product contract in CHANGELOG + shell: python3 {0} + run: | + from pathlib import Path + + path = Path('CHANGELOG.md') + source = path.read_text(encoding='utf-8') + anchor = '### Security\n\n' + entry = '- Made contextual-orchestrator briefing requests fail closed unless an authenticated endpoint is configured. Deterministic generated text is now restricted to explicit `SCOPEWEAVE_DEV=1`, message/provider responses are bounded and validated, and non-loopback HTTP transport is rejected.\n' + if source.count(anchor) != 1: + raise SystemExit('expected exactly one Unreleased Security anchor') + if entry not in source: + source = source.replace(anchor, anchor + entry, 1) + path.write_text(source, encoding='utf-8') + + - name: Setup Node 22.13 + uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6.0.0 + with: + node-version: 22.13.0 + cache: npm + + - name: Install locked dependencies + run: npm ci + + - name: Run orchestrator and full coverage verification + run: | + node tests/unit/orchestrator.test.mjs + npm run test:unit + npm run test:api + npm run coverage + + - name: Commit verified documentation and remove one-shot workflow + run: | + set -euo pipefail + git rm .github/workflows/one-shot-orchestrator-production-boundary.yml + git add CHANGELOG.md + git config user.name 'github-actions[bot]' + git config user.email '41898282+github-actions[bot]@users.noreply.github.com' + git commit -m 'docs(changelog): record orchestrator production boundary' + git push origin HEAD:fix/orchestrator-production-fail-closed-20260809 From cc60a856d14215813bd692b08cce32307ec9944a Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 9 Aug 2026 20:58:30 +0900 Subject: [PATCH 06/26] docs(changelog): record orchestrator production boundary --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index e84f41f8..ff209d81 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -22,6 +22,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Security +- Made contextual-orchestrator briefing requests fail closed unless an authenticated endpoint is configured. Deterministic generated text is restricted to explicit `SCOPEWEAVE_DEV=1`, message/provider responses are bounded and validated, and non-loopback HTTP transport is rejected. - Made `SCOPEWEAVE_JWT_SECRET` mandatory at startup and rejected weak or unexpanded placeholder values so production deployments fail closed. - Neutralized audit-log CSV formulas even when executable prefixes are hidden From bdc81fa86b12ac005daa60ef846ed3283d425e4c Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 9 Aug 2026 20:58:43 +0900 Subject: [PATCH 07/26] ci: remove completed orchestrator one-shot writer --- ...-shot-orchestrator-production-boundary.yml | 70 ------------------- 1 file changed, 70 deletions(-) delete mode 100644 .github/workflows/one-shot-orchestrator-production-boundary.yml diff --git a/.github/workflows/one-shot-orchestrator-production-boundary.yml b/.github/workflows/one-shot-orchestrator-production-boundary.yml deleted file mode 100644 index e7eba335..00000000 --- a/.github/workflows/one-shot-orchestrator-production-boundary.yml +++ /dev/null @@ -1,70 +0,0 @@ -name: One-shot orchestrator production boundary verification - -on: - push: - branches: [fix/orchestrator-production-fail-closed-20260809] - -permissions: - contents: write - -concurrency: - group: one-shot-orchestrator-production-boundary - cancel-in-progress: false - -jobs: - verify-document-commit: - if: github.actor != 'github-actions[bot]' - runs-on: ubuntu-latest - timeout-minutes: 25 - steps: - - name: Harden runner - uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0 - with: - egress-policy: audit - - - name: Checkout exact branch head - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - with: - ref: fix/orchestrator-production-fail-closed-20260809 - fetch-depth: 0 - - - name: Record the product contract in CHANGELOG - shell: python3 {0} - run: | - from pathlib import Path - - path = Path('CHANGELOG.md') - source = path.read_text(encoding='utf-8') - anchor = '### Security\n\n' - entry = '- Made contextual-orchestrator briefing requests fail closed unless an authenticated endpoint is configured. Deterministic generated text is now restricted to explicit `SCOPEWEAVE_DEV=1`, message/provider responses are bounded and validated, and non-loopback HTTP transport is rejected.\n' - if source.count(anchor) != 1: - raise SystemExit('expected exactly one Unreleased Security anchor') - if entry not in source: - source = source.replace(anchor, anchor + entry, 1) - path.write_text(source, encoding='utf-8') - - - name: Setup Node 22.13 - uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6.0.0 - with: - node-version: 22.13.0 - cache: npm - - - name: Install locked dependencies - run: npm ci - - - name: Run orchestrator and full coverage verification - run: | - node tests/unit/orchestrator.test.mjs - npm run test:unit - npm run test:api - npm run coverage - - - name: Commit verified documentation and remove one-shot workflow - run: | - set -euo pipefail - git rm .github/workflows/one-shot-orchestrator-production-boundary.yml - git add CHANGELOG.md - git config user.name 'github-actions[bot]' - git config user.email '41898282+github-actions[bot]@users.noreply.github.com' - git commit -m 'docs(changelog): record orchestrator production boundary' - git push origin HEAD:fix/orchestrator-production-fail-closed-20260809 From af69b5f9c0788831270136aea811e5f3a2e8b557 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 14 Aug 2026 21:48:42 +0900 Subject: [PATCH 08/26] test(orchestrator): reject oversized provider responses --- tests/unit/orchestrator.test.mjs | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/tests/unit/orchestrator.test.mjs b/tests/unit/orchestrator.test.mjs index 575239d7..49399dbf 100644 --- a/tests/unit/orchestrator.test.mjs +++ b/tests/unit/orchestrator.test.mjs @@ -105,6 +105,17 @@ try { (error) => error.code === 'orchestrator_response_invalid', ); + globalThis.fetch = async () => new Response(JSON.stringify({ + choices: [{ message: { content: 'x'.repeat(1024 * 1024) } }], + }), { + status: 200, + headers: { 'content-type': 'application/json' }, + }); + await assert.rejects( + configured.chat([{ role: 'user', content: 'status' }]), + (error) => error.code === 'orchestrator_response_size_invalid', + ); + globalThis.fetch = async () => new Response(JSON.stringify({ error: {} }), { status: 503, headers: { 'content-type': 'application/json' }, From 62819641456e575ab602c1f0f694968bc35b78e2 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 14 Aug 2026 21:49:09 +0900 Subject: [PATCH 09/26] fix(orchestrator): bound provider response bodies --- server/orchestrator.mjs | 20 ++++++++++++++++++-- 1 file changed, 18 insertions(+), 2 deletions(-) diff --git a/server/orchestrator.mjs b/server/orchestrator.mjs index 2a015976..709857a0 100644 --- a/server/orchestrator.mjs +++ b/server/orchestrator.mjs @@ -6,6 +6,7 @@ const OC_MODEL = process.env.ORCHESTRATOR_MODEL || 'contextual-orchestrator'; const ORCHESTRATOR_TIMEOUT_MS = 120_000; const MAX_MESSAGE_COUNT = 256; const MAX_CONTENT_LENGTH = 100_000; +const MAX_PROVIDER_RESPONSE_BYTES = 1024 * 1024; export const orchestratorMock = process.env.SCOPEWEAVE_DEV === '1' && !OC_URL; @@ -105,14 +106,29 @@ function validatedMessages(messages) { } /** - * Parse one provider response without returning raw provider payloads in failures. + * Parse one bounded provider response without returning raw provider payloads in failures. * @param {Response} response provider response * @returns {Promise>} */ async function responseJson(response) { + let bytes; + try { + bytes = Buffer.from(await response.arrayBuffer()); + } catch { + throw new OrchestratorConfigurationError( + 'orchestrator_response_invalid', + 'contextual-orchestrator response could not be read.', + ); + } + if (bytes.length === 0 || bytes.length > MAX_PROVIDER_RESPONSE_BYTES) { + throw new OrchestratorConfigurationError( + 'orchestrator_response_size_invalid', + 'contextual-orchestrator response size is outside the accepted boundary.', + ); + } let data; try { - data = await response.json(); + data = JSON.parse(bytes.toString('utf8')); } catch { throw new OrchestratorConfigurationError( 'orchestrator_response_invalid', From cc76f5922a937a779bb6ce858f1fd75ae8add115 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 14 Aug 2026 23:52:37 +0900 Subject: [PATCH 10/26] test(orchestrator): enforce streaming response byte budget --- tests/unit/orchestrator.test.mjs | 48 ++++++++++++++++++++++++++++++++ 1 file changed, 48 insertions(+) diff --git a/tests/unit/orchestrator.test.mjs b/tests/unit/orchestrator.test.mjs index 49399dbf..29d1512d 100644 --- a/tests/unit/orchestrator.test.mjs +++ b/tests/unit/orchestrator.test.mjs @@ -116,6 +116,54 @@ try { (error) => error.code === 'orchestrator_response_size_invalid', ); + let knownLengthBodyRead = false; + globalThis.fetch = async () => ({ + ok: true, + status: 200, + headers: new Headers({ 'content-length': String(1024 * 1024 + 1) }), + body: { + getReader() { + knownLengthBodyRead = true; + throw new Error('oversized declared body must not be read'); + }, + }, + }); + await assert.rejects( + configured.chat([{ role: 'user', content: 'status' }]), + (error) => error.code === 'orchestrator_response_size_invalid', + ); + assert.equal(knownLengthBodyRead, false, 'oversized declared response is rejected before body allocation'); + + let streamedReads = 0; + let streamedCancelled = false; + globalThis.fetch = async () => ({ + ok: true, + status: 200, + headers: new Headers(), + body: { + getReader() { + return { + async read() { + streamedReads += 1; + if (streamedReads === 1) { + return { done: false, value: new Uint8Array(1024 * 1024 + 1) }; + } + throw new Error('reader must stop after the first oversized chunk'); + }, + async cancel() { + streamedCancelled = true; + }, + }; + }, + }, + }); + await assert.rejects( + configured.chat([{ role: 'user', content: 'status' }]), + (error) => error.code === 'orchestrator_response_size_invalid', + ); + assert.equal(streamedReads, 1, 'stream reader stops as soon as the response exceeds the byte budget'); + assert.equal(streamedCancelled, true, 'oversized response stream is cancelled'); + globalThis.fetch = async () => new Response(JSON.stringify({ error: {} }), { status: 503, headers: { 'content-type': 'application/json' }, From 7ea77f029ab2effc7f4f89a727a7a2fd2594af6b Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 14 Aug 2026 23:54:39 +0900 Subject: [PATCH 11/26] fix(orchestrator): bound provider response while streaming --- server/orchestrator.mjs | 94 ++++++++++++++++++++++++++++++++++++----- 1 file changed, 83 insertions(+), 11 deletions(-) diff --git a/server/orchestrator.mjs b/server/orchestrator.mjs index 709857a0..fa00bea3 100644 --- a/server/orchestrator.mjs +++ b/server/orchestrator.mjs @@ -106,26 +106,98 @@ function validatedMessages(messages) { } /** - * Parse one bounded provider response without returning raw provider payloads in failures. + * Build the stable response-size failure used by declared and streamed limits. + * @returns {OrchestratorConfigurationError} Operator-safe size error. + */ +function responseSizeError() { + return new OrchestratorConfigurationError( + 'orchestrator_response_size_invalid', + 'contextual-orchestrator response size is outside the accepted boundary.', + ); +} + +/** + * Read one provider body without ever buffering more than the configured limit. + * + * A trustworthy numeric Content-Length can reject an oversized response before + * body allocation. The stream reader remains authoritative because providers + * may omit or misstate that header. The reader is cancelled as soon as the + * accumulated byte count exceeds the limit. + * * @param {Response} response provider response - * @returns {Promise>} + * @returns {Promise} Non-empty bounded response bytes. */ -async function responseJson(response) { - let bytes; - try { - bytes = Buffer.from(await response.arrayBuffer()); - } catch { +async function boundedResponseBytes(response) { + const declaredLength = response.headers?.get?.('content-length'); + if (declaredLength !== null && declaredLength !== undefined && declaredLength !== '') { + const normalizedLength = String(declaredLength).trim(); + if (!/^\d+$/.test(normalizedLength)) { + throw new OrchestratorConfigurationError( + 'orchestrator_response_invalid', + 'contextual-orchestrator returned an invalid response length.', + ); + } + const length = Number(normalizedLength); + if (!Number.isSafeInteger(length)) throw responseSizeError(); + if (length === 0 || length > MAX_PROVIDER_RESPONSE_BYTES) throw responseSizeError(); + } + + const reader = response.body?.getReader?.(); + if (!reader || typeof reader.read !== 'function') { throw new OrchestratorConfigurationError( 'orchestrator_response_invalid', - 'contextual-orchestrator response could not be read.', + 'contextual-orchestrator response body is not stream-readable.', ); } - if (bytes.length === 0 || bytes.length > MAX_PROVIDER_RESPONSE_BYTES) { + + const chunks = []; + let totalBytes = 0; + try { + for (;;) { + const { done, value } = await reader.read(); + if (done) break; + if (!(value instanceof Uint8Array)) { + throw new OrchestratorConfigurationError( + 'orchestrator_response_invalid', + 'contextual-orchestrator returned an invalid response chunk.', + ); + } + totalBytes += value.byteLength; + if (totalBytes > MAX_PROVIDER_RESPONSE_BYTES) { + try { + await reader.cancel(); + } catch { + // Cancellation is best effort after the byte budget has already failed closed. + } + throw responseSizeError(); + } + chunks.push(Buffer.from(value)); + } + } catch (error) { + if (error instanceof OrchestratorConfigurationError) throw error; throw new OrchestratorConfigurationError( - 'orchestrator_response_size_invalid', - 'contextual-orchestrator response size is outside the accepted boundary.', + 'orchestrator_response_invalid', + 'contextual-orchestrator response could not be read.', ); + } finally { + try { + reader.releaseLock?.(); + } catch { + // Releasing a consumed/cancelled reader is cleanup only and cannot alter the result. + } } + + if (totalBytes === 0) throw responseSizeError(); + return Buffer.concat(chunks, totalBytes); +} + +/** + * Parse one bounded provider response without returning raw provider payloads in failures. + * @param {Response} response provider response + * @returns {Promise>} + */ +async function responseJson(response) { + const bytes = await boundedResponseBytes(response); let data; try { data = JSON.parse(bytes.toString('utf8')); From c5c890e3538eebe9a62f30bb73532edeae571978 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 14 Aug 2026 23:55:19 +0900 Subject: [PATCH 12/26] docs(orchestrator): record streaming response bound --- docs/orchestrator-production.md | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/docs/orchestrator-production.md b/docs/orchestrator-production.md index 6261434c..f1655397 100644 --- a/docs/orchestrator-production.md +++ b/docs/orchestrator-production.md @@ -17,6 +17,15 @@ message count and content size are validated, provider payloads are not exposed in errors, and an empty or malformed assistant response is never reported as a successful briefing. +Provider response bodies have a hard 1 MiB caller-side byte budget **while they +are being read**. An oversized numeric `Content-Length` is rejected before body +allocation; when the header is absent or inaccurate, the stream reader counts +bytes incrementally, cancels the body as soon as the budget is exceeded, and +never buffers an unbounded provider payload before applying the limit. Empty, +non-stream-readable, malformed-length, non-JSON, oversized, or structurally +invalid responses fail with stable operator-safe errors rather than exposing +provider payload details. + The deterministic adapter is available only when `SCOPEWEAVE_DEV=1` and the endpoint is absent. That variable must never be set in staging or production. From 1e6916a5eb5cd078c393b96f790d0ae19f680bb1 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 15 Aug 2026 00:54:59 +0900 Subject: [PATCH 13/26] test(orchestrator): expect explicit development adapter --- tests/api/smoke.mjs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/api/smoke.mjs b/tests/api/smoke.mjs index 8cb0f4a2..ff6afd8a 100644 --- a/tests/api/smoke.mjs +++ b/tests/api/smoke.mjs @@ -619,7 +619,7 @@ assert.equal(r.status, 200, 'sprint delete'); r = await req(`/api/projects/${proj.id}/ai/brief`, { method: 'POST', headers: auth }); assert.equal(r.status, 200, 'ai brief 200'); const brief = await r.json(); -assert.ok(brief.analysis.includes('mock-orchestrator'), 'mock analysis returned'); +assert.ok(brief.analysis.includes('dev-orchestrator'), 'explicit development analysis returned'); assert.ok(brief.analysis.length > 40, 'non-trivial analysis'); r = await req(`/api/projects/${proj.id}/ai/brief`, { method: 'POST', headers: oauth }); assert.equal(r.status, 404, 'non-member ai brief → 404'); @@ -747,4 +747,4 @@ assert.equal((await r.json()).orgs.find((o) => o.id === orgAId)?.role, 'admin', r = await req(`/api/orgs/${orgAId}/leave`, { method: 'POST', headers: auth }); assert.equal(r.status, 200, 'former owner can now leave'); -console.log('✓ API smoke tests passed'); +console.log('✓ API smoke tests passed'); \ No newline at end of file From 4a806b59fbd44f6076c4c5de3c748d871ebfb40b Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 15 Aug 2026 06:41:36 +0900 Subject: [PATCH 14/26] test(orchestrator): cover residual production boundary branches --- tests/unit/orchestrator-coverage.test.mjs | 235 ++++++++++++++++++++++ 1 file changed, 235 insertions(+) create mode 100644 tests/unit/orchestrator-coverage.test.mjs diff --git a/tests/unit/orchestrator-coverage.test.mjs b/tests/unit/orchestrator-coverage.test.mjs new file mode 100644 index 00000000..43dab8b2 --- /dev/null +++ b/tests/unit/orchestrator-coverage.test.mjs @@ -0,0 +1,235 @@ +import assert from 'node:assert/strict'; + +const ORIGINAL_ENV = { ...process.env }; +const ORIGINAL_FETCH = globalThis.fetch; + +function restoreEnvironment() { + for (const key of Object.keys(process.env)) { + if (!(key in ORIGINAL_ENV)) delete process.env[key]; + } + Object.assign(process.env, ORIGINAL_ENV); + globalThis.fetch = ORIGINAL_FETCH; +} + +function configure({ url = 'https://orchestrator.example', token = 'secret-token', dev = false } = {}) { + process.env.ORCHESTRATOR_URL = url; + process.env.ORCHESTRATOR_TOKEN = token; + process.env.ORCHESTRATOR_MODEL = 'contextual-orchestrator'; + if (dev) process.env.SCOPEWEAVE_DEV = '1'; + else delete process.env.SCOPEWEAVE_DEV; +} + +async function freshModule(label) { + return import(`../../server/orchestrator.mjs?coverage=${label}-${Date.now()}-${Math.random()}`); +} + +async function expectCode(module, messages, code) { + await assert.rejects( + module.chat(messages), + (error) => error?.code === code, + `expected ${code}`, + ); +} + +function streamResponse({ chunks = [], headers, ok = true, status = 200, cancel, releaseLock, readError } = {}) { + let index = 0; + return { + ok, + status, + ...(headers === undefined ? {} : { headers }), + body: { + getReader() { + return { + async read() { + if (readError) throw readError; + if (index >= chunks.length) return { done: true, value: undefined }; + const value = chunks[index]; + index += 1; + return { done: false, value }; + }, + ...(cancel ? { cancel } : {}), + ...(releaseLock ? { releaseLock } : {}), + }; + }, + }, + }; +} + +try { + configure({ url: 'not an absolute url' }); + await expectCode( + await freshModule('invalid-url'), + [{ role: 'user', content: 'status' }], + 'orchestrator_url_invalid', + ); + + configure({ url: 'ftp://orchestrator.example' }); + await expectCode( + await freshModule('invalid-protocol'), + [{ role: 'user', content: 'status' }], + 'orchestrator_url_invalid', + ); + + configure({ url: 'http://localhost:8080/' }); + globalThis.fetch = async (url) => { + assert.equal(url, 'http://localhost:8080/v1/chat/completions'); + return new Response(JSON.stringify({ choices: [{ message: { content: 'loopback ok' } }] }), { + status: 200, + headers: { 'content-type': 'application/json' }, + }); + }; + assert.equal( + await (await freshModule('loopback-http')).chat([{ role: 'developer', content: 'status' }]), + 'loopback ok', + ); + + configure(); + const configured = await freshModule('message-boundaries'); + globalThis.fetch = async () => new Response(JSON.stringify({ + choices: [{ message: { content: 'ok' } }], + }), { status: 200, headers: { 'content-type': 'application/json' } }); + + for (const invalidMessages of [ + null, + Array.from({ length: 257 }, () => ({ role: 'user', content: 'x' })), + [[]], + [{ role: 'assistant', content: 42 }], + ]) { + await assert.rejects( + configured.chat(invalidMessages), + (error) => error?.code?.startsWith('orchestrator_message'), + ); + } + assert.equal( + await configured.chat([ + { role: 'assistant', content: 'prior' }, + { role: 'developer', content: 'policy' }, + { role: 'user', content: 'status' }, + ]), + 'ok', + ); + + const responseCases = [ + { + label: 'invalid-content-length', + response: streamResponse({ + headers: new Headers({ 'content-length': '12x' }), + chunks: [new TextEncoder().encode('{}')], + }), + code: 'orchestrator_response_invalid', + }, + { + label: 'unsafe-content-length', + response: streamResponse({ + headers: new Headers({ 'content-length': '9007199254740992' }), + chunks: [new TextEncoder().encode('{}')], + }), + code: 'orchestrator_response_size_invalid', + }, + { + label: 'zero-content-length', + response: streamResponse({ + headers: new Headers({ 'content-length': '0' }), + chunks: [], + }), + code: 'orchestrator_response_size_invalid', + }, + { + label: 'missing-body', + response: { ok: true, status: 200, headers: new Headers(), body: null }, + code: 'orchestrator_response_invalid', + }, + { + label: 'missing-reader', + response: { ok: true, status: 200, headers: new Headers(), body: {} }, + code: 'orchestrator_response_invalid', + }, + { + label: 'invalid-chunk', + response: streamResponse({ headers: new Headers(), chunks: ['not-bytes'] }), + code: 'orchestrator_response_invalid', + }, + { + label: 'read-error', + response: streamResponse({ headers: new Headers(), readError: new Error('private stream failure') }), + code: 'orchestrator_response_invalid', + }, + { + label: 'empty-stream', + response: streamResponse({ headers: new Headers(), chunks: [] }), + code: 'orchestrator_response_size_invalid', + }, + ]; + + for (const { label, response, code } of responseCases) { + globalThis.fetch = async () => response; + await expectCode(configured, [{ role: 'user', content: label }], code); + } + + let cancelAttempted = false; + globalThis.fetch = async () => streamResponse({ + headers: new Headers(), + chunks: [new Uint8Array(1024 * 1024 + 1)], + cancel: async () => { + cancelAttempted = true; + throw new Error('cancel cleanup failure'); + }, + }); + await expectCode( + configured, + [{ role: 'user', content: 'oversized cancel failure' }], + 'orchestrator_response_size_invalid', + ); + assert.equal(cancelAttempted, true); + + let released = false; + globalThis.fetch = async () => streamResponse({ + chunks: [new TextEncoder().encode(JSON.stringify({ choices: [{ message: { content: 'release ok' } }] }))], + releaseLock() { + released = true; + throw new Error('release cleanup failure'); + }, + }); + assert.equal( + await configured.chat([{ role: 'user', content: 'release cleanup' }]), + 'release ok', + ); + assert.equal(released, true); + + for (const [label, body] of [ + ['null-json', 'null'], + ['primitive-json', '"string"'], + ['array-json', '[]'], + ]) { + globalThis.fetch = async () => new Response(body, { status: 200 }); + await expectCode(configured, [{ role: 'user', content: label }], 'orchestrator_response_invalid'); + } + + globalThis.fetch = async () => streamResponse({ + chunks: [new TextEncoder().encode(JSON.stringify({ choices: [{ message: { content: 'no headers ok' } }] }))], + }); + assert.equal( + await configured.chat([{ role: 'user', content: 'missing headers object' }]), + 'no headers ok', + ); + + globalThis.fetch = async () => new Response(JSON.stringify({}), { status: 200 }); + await expectCode( + configured, + [{ role: 'user', content: 'missing choices' }], + 'orchestrator_response_invalid', + ); + + globalThis.fetch = async () => new Response(JSON.stringify({ + choices: [{ message: { content: ' ' } }], + }), { status: 200 }); + await expectCode( + configured, + [{ role: 'user', content: 'blank assistant content' }], + 'orchestrator_response_invalid', + ); +} finally { + restoreEnvironment(); +} + +console.log('✓ orchestrator residual branch coverage tests passed'); From d648c45871a134c45033508ed15ca6917de0e4bb Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 15 Aug 2026 06:41:48 +0900 Subject: [PATCH 15/26] test(orchestrator): register residual branch coverage suite --- package.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/package.json b/package.json index 76a836c6..93430c55 100644 --- a/package.json +++ b/package.json @@ -13,9 +13,9 @@ "coverage": "npm run test:coverage", "server": "node server/server.mjs", "test:api": "node tests/api/auth-secret.test.mjs && node tests/api/smoke.mjs && node tests/api/ratelimit.test.mjs && node tests/api/attachment-status.test.mjs && node tests/api/session-revocation.test.mjs", - "test:unit": "node tests/unit/analytics.test.mjs && node tests/unit/cpm.test.mjs && node tests/unit/baseline-compare.test.mjs && node tests/unit/workload.test.mjs && node tests/unit/cost-evm.test.mjs && node tests/unit/msproject.test.mjs && node tests/unit/auth-password.test.mjs && node tests/unit/editor-unsaved.test.mjs && node tests/unit/static-coverage-evidence.test.mjs && node tests/unit/dep-types.test.mjs && node tests/unit/weekly-report.test.mjs && node tests/unit/clearfolio.test.mjs && node tests/unit/clearfolio-adapter-mock-hmac.test.mjs && node tests/unit/orchestrator.test.mjs && node tests/unit/sprint-stats.test.mjs && node tests/unit/burndown.test.mjs && node tests/unit/pm-analysis.test.mjs && node tests/unit/cloud-sync-security.test.mjs && node tests/unit/attachment-status.test.mjs && node tests/unit/clearfolio-status-signal.test.mjs && node tests/unit/coverage-script-contract.test.mjs", + "test:unit": "node tests/unit/analytics.test.mjs && node tests/unit/cpm.test.mjs && node tests/unit/baseline-compare.test.mjs && node tests/unit/workload.test.mjs && node tests/unit/cost-evm.test.mjs && node tests/unit/msproject.test.mjs && node tests/unit/auth-password.test.mjs && node tests/unit/editor-unsaved.test.mjs && node tests/unit/static-coverage-evidence.test.mjs && node tests/unit/dep-types.test.mjs && node tests/unit/weekly-report.test.mjs && node tests/unit/clearfolio.test.mjs && node tests/unit/clearfolio-adapter-mock-hmac.test.mjs && node tests/unit/orchestrator.test.mjs && node tests/unit/orchestrator-coverage.test.mjs && node tests/unit/sprint-stats.test.mjs && node tests/unit/burndown.test.mjs && node tests/unit/pm-analysis.test.mjs && node tests/unit/cloud-sync-security.test.mjs && node tests/unit/attachment-status.test.mjs && node tests/unit/clearfolio-status-signal.test.mjs && node tests/unit/coverage-script-contract.test.mjs", "test:coverage": "c8 --all --include=app.js --include=cloud-sync.js --include=scripts/ci/static_coverage_evidence.mjs --include=server/attachment_status.mjs --include=server/app.mjs --include=server/auth.mjs --include=server/clearfolio.mjs --include=server/orchestrator.mjs --reporter=json --reporter=json-summary npm run test:coverage:cases", - "test:coverage:cases": "node tests/unit/coverage-script-contract.test.mjs && node tests/unit/attachment-status.test.mjs && node tests/unit/clearfolio-status-signal.test.mjs && node tests/unit/clearfolio-adapter-mock-hmac.test.mjs && node tests/unit/orchestrator.test.mjs && node tests/unit/msproject.test.mjs && node tests/unit/auth-password.test.mjs && node tests/unit/editor-unsaved.test.mjs && node tests/unit/static-coverage-evidence.test.mjs && npm run test:api", + "test:coverage:cases": "node tests/unit/coverage-script-contract.test.mjs && node tests/unit/attachment-status.test.mjs && node tests/unit/clearfolio-status-signal.test.mjs && node tests/unit/clearfolio-adapter-mock-hmac.test.mjs && node tests/unit/orchestrator.test.mjs && node tests/unit/orchestrator-coverage.test.mjs && node tests/unit/msproject.test.mjs && node tests/unit/auth-password.test.mjs && node tests/unit/editor-unsaved.test.mjs && node tests/unit/static-coverage-evidence.test.mjs && npm run test:api", "test:e2e": "playwright test", "test:e2e:headed": "playwright test --headed", "test:e2e:cloud": "playwright install chromium && playwright test tests/e2e/cloud.spec.js", From 766d577580ea9813ee0ab11bba298a930cb01f00 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 15 Aug 2026 07:36:49 +0900 Subject: [PATCH 16/26] test(orchestrator): cover IPv6 loopback transport --- tests/unit/orchestrator-coverage.test.mjs | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/tests/unit/orchestrator-coverage.test.mjs b/tests/unit/orchestrator-coverage.test.mjs index 43dab8b2..debd58c7 100644 --- a/tests/unit/orchestrator-coverage.test.mjs +++ b/tests/unit/orchestrator-coverage.test.mjs @@ -83,6 +83,20 @@ try { 'loopback ok', ); + configure({ url: 'http://[::1]:8080/' }); + globalThis.fetch = async (url) => { + assert.equal(url, 'http://[::1]:8080/v1/chat/completions'); + return new Response(JSON.stringify({ choices: [{ message: { content: 'ipv6 loopback ok' } }] }), { + status: 200, + headers: { 'content-type': 'application/json' }, + }); + }; + assert.equal( + await (await freshModule('ipv6-loopback-http')).chat([{ role: 'developer', content: 'status' }]), + 'ipv6 loopback ok', + 'WHATWG IPv6 loopback hostname serialization must remain accepted by the documented local transport boundary', + ); + configure(); const configured = await freshModule('message-boundaries'); globalThis.fetch = async () => new Response(JSON.stringify({ From 2a81fb4d02cd436dd118cd1fc2fdace4c65e9934 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 15 Aug 2026 07:37:20 +0900 Subject: [PATCH 17/26] fix(orchestrator): recognize IPv6 loopback hostname --- server/orchestrator.mjs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/server/orchestrator.mjs b/server/orchestrator.mjs index fa00bea3..ad2548e0 100644 --- a/server/orchestrator.mjs +++ b/server/orchestrator.mjs @@ -7,6 +7,8 @@ const ORCHESTRATOR_TIMEOUT_MS = 120_000; const MAX_MESSAGE_COUNT = 256; const MAX_CONTENT_LENGTH = 100_000; const MAX_PROVIDER_RESPONSE_BYTES = 1024 * 1024; +// WHATWG URL serializes an IPv6 hostname with brackets (`[::1]`). +const LOOPBACK_HOSTNAMES = new Set(['localhost', '127.0.0.1', '[::1]']); export const orchestratorMock = process.env.SCOPEWEAVE_DEV === '1' && !OC_URL; @@ -51,7 +53,7 @@ function orchestratorConfiguration() { 'ORCHESTRATOR_URL must use HTTP or HTTPS.', ); } - if (url.protocol !== 'https:' && !['localhost', '127.0.0.1', '::1'].includes(url.hostname)) { + if (url.protocol !== 'https:' && !LOOPBACK_HOSTNAMES.has(url.hostname)) { throw new OrchestratorConfigurationError( 'orchestrator_transport_insecure', 'contextual-orchestrator production traffic requires HTTPS.', From bd9bfe3e315f26705324bc71035641934282a42f Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 15 Aug 2026 10:06:20 +0900 Subject: [PATCH 18/26] test(orchestrator): reject ambiguous provider endpoint configuration --- tests/unit/orchestrator.test.mjs | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/tests/unit/orchestrator.test.mjs b/tests/unit/orchestrator.test.mjs index 29d1512d..0ebc797a 100644 --- a/tests/unit/orchestrator.test.mjs +++ b/tests/unit/orchestrator.test.mjs @@ -54,6 +54,27 @@ try { (error) => error.code === 'orchestrator_transport_insecure', ); + const invalidEndpointConfigurations = [ + ['credentials', 'https://user:pass@orchestrator.example', 'orchestrator_url_credentials_forbidden'], + ['path', 'https://orchestrator.example/api', 'orchestrator_url_path_forbidden'], + ['query', 'https://orchestrator.example?tenant=scopeweave', 'orchestrator_url_query_forbidden'], + ['fragment', 'https://orchestrator.example#tenant', 'orchestrator_url_fragment_forbidden'], + ]; + const transportBeforeEndpointChecks = globalThis.fetch; + globalThis.fetch = async () => { + throw new Error('invalid endpoint configuration must fail before transport'); + }; + for (const [label, url, expectedCode] of invalidEndpointConfigurations) { + process.env.ORCHESTRATOR_URL = url; + const invalidEndpoint = await freshModule(`invalid-endpoint-${label}`); + await assert.rejects( + invalidEndpoint.chat([{ role: 'user', content: 'status' }]), + (error) => error.code === expectedCode, + `${label} endpoint configuration fails before provider transport`, + ); + } + globalThis.fetch = transportBeforeEndpointChecks; + process.env.ORCHESTRATOR_URL = 'https://orchestrator.example'; process.env.ORCHESTRATOR_MODEL = 'nvidia/nemotron-3-super-120b-a12b'; const configured = await freshModule('configured'); From 268536f9389149c7d380af91ec65cfcde49f235b Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 15 Aug 2026 10:07:38 +0900 Subject: [PATCH 19/26] fix(orchestrator): require a canonical provider origin --- server/orchestrator.mjs | 32 +++++++++++++++++++++++++++++++- 1 file changed, 31 insertions(+), 1 deletion(-) diff --git a/server/orchestrator.mjs b/server/orchestrator.mjs index ad2548e0..9f45db9b 100644 --- a/server/orchestrator.mjs +++ b/server/orchestrator.mjs @@ -28,6 +28,12 @@ export class OrchestratorConfigurationError extends Error { /** * Resolve explicit development mode or a complete authenticated production endpoint. + * + * The provider setting is an origin, not an arbitrary request URL. Rejecting + * credentials and additional URL components keeps endpoint authority separate + * from the bearer token and prevents operator-supplied path/query/fragment data + * from changing the fixed OpenAI-compatible request path. + * * @returns {{mock: true} | {mock: false, baseUrl: string, token: string}} */ function orchestratorConfiguration() { @@ -53,6 +59,30 @@ function orchestratorConfiguration() { 'ORCHESTRATOR_URL must use HTTP or HTTPS.', ); } + if (url.username || url.password) { + throw new OrchestratorConfigurationError( + 'orchestrator_url_credentials_forbidden', + 'ORCHESTRATOR_URL must not contain credentials.', + ); + } + if (url.pathname !== '/') { + throw new OrchestratorConfigurationError( + 'orchestrator_url_path_forbidden', + 'ORCHESTRATOR_URL must identify the provider origin without a path.', + ); + } + if (url.search) { + throw new OrchestratorConfigurationError( + 'orchestrator_url_query_forbidden', + 'ORCHESTRATOR_URL must not contain a query string.', + ); + } + if (url.hash) { + throw new OrchestratorConfigurationError( + 'orchestrator_url_fragment_forbidden', + 'ORCHESTRATOR_URL must not contain a fragment.', + ); + } if (url.protocol !== 'https:' && !LOOPBACK_HOSTNAMES.has(url.hostname)) { throw new OrchestratorConfigurationError( 'orchestrator_transport_insecure', @@ -65,7 +95,7 @@ function orchestratorConfiguration() { 'ORCHESTRATOR_TOKEN is required for production requests.', ); } - return { mock: false, baseUrl: url.toString().replace(/\/$/, ''), token: OC_TOKEN }; + return { mock: false, baseUrl: url.origin, token: OC_TOKEN }; } /** From 8c150a75c68f467216363ced193b59ecd8929e55 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 15 Aug 2026 10:09:24 +0900 Subject: [PATCH 20/26] docs(orchestrator): document canonical provider origin --- docs/orchestrator-production.md | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/docs/orchestrator-production.md b/docs/orchestrator-production.md index f1655397..c2c4c5c7 100644 --- a/docs/orchestrator-production.md +++ b/docs/orchestrator-production.md @@ -11,6 +11,13 @@ ORCHESTRATOR_TOKEN= ORCHESTRATOR_MODEL=contextual-orchestrator ``` +`ORCHESTRATOR_URL` is a provider **origin**, not an arbitrary request URL. It +must not contain user-info credentials, a non-root path, a query string, or a +fragment. ScopeWeave owns the fixed `/v1/chat/completions` request path and keeps +bearer credentials in `ORCHESTRATOR_TOKEN`; operator URL text therefore cannot +silently alter request routing or mix endpoint authority with credentials. +Custom ports remain valid because they are part of the origin. + Production requests fail closed when the endpoint or bearer token is absent. Non-loopback HTTP endpoints are rejected, requests are bounded to 120 seconds, message count and content size are validated, provider payloads are not exposed From 46723a07c5032c146b798944fa8b246cc104ac3a Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 15 Aug 2026 10:11:59 +0900 Subject: [PATCH 21/26] test(orchestrator): classify provider rejection before response body --- tests/unit/orchestrator.test.mjs | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/tests/unit/orchestrator.test.mjs b/tests/unit/orchestrator.test.mjs index 0ebc797a..c900ff58 100644 --- a/tests/unit/orchestrator.test.mjs +++ b/tests/unit/orchestrator.test.mjs @@ -120,11 +120,23 @@ try { (error) => error.code === 'orchestrator_provider_unavailable', ); - globalThis.fetch = async () => new Response('not-json', { status: 502 }); + let rejectedBodyRead = false; + globalThis.fetch = async () => ({ + ok: false, + status: 502, + headers: new Headers({ 'content-type': 'text/plain' }), + body: { + getReader() { + rejectedBodyRead = true; + throw new Error('rejected provider body must not be parsed'); + }, + }, + }); await assert.rejects( configured.chat([{ role: 'user', content: 'status' }]), - (error) => error.code === 'orchestrator_response_invalid', + (error) => error.code === 'orchestrator_provider_rejected', ); + assert.equal(rejectedBodyRead, false, 'non-success provider responses are classified before body parsing'); globalThis.fetch = async () => new Response(JSON.stringify({ choices: [{ message: { content: 'x'.repeat(1024 * 1024) } }], From 1578bf34a10d8acc0053fadb002a9a5f202193d4 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 15 Aug 2026 10:13:32 +0900 Subject: [PATCH 22/26] fix(orchestrator): classify provider rejection before body parsing --- server/orchestrator.mjs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/server/orchestrator.mjs b/server/orchestrator.mjs index 9f45db9b..ed614f4d 100644 --- a/server/orchestrator.mjs +++ b/server/orchestrator.mjs @@ -288,14 +288,14 @@ export async function chat(messages) { 'contextual-orchestrator could not be reached.', ); } - const data = await responseJson(response); - const content = data?.choices?.[0]?.message?.content; if (!response.ok) { throw new OrchestratorConfigurationError( 'orchestrator_provider_rejected', `contextual-orchestrator rejected the request with HTTP ${response.status}.`, ); } + const data = await responseJson(response); + const content = data?.choices?.[0]?.message?.content; if (typeof content !== 'string' || !content.trim()) { throw new OrchestratorConfigurationError( 'orchestrator_response_invalid', From b2f9639db4e049eecb5c2238111bb34d74bd6976 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 15 Aug 2026 10:20:08 +0900 Subject: [PATCH 23/26] test(api): isolate orchestrator mock from inherited provider config --- tests/api/smoke.mjs | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/api/smoke.mjs b/tests/api/smoke.mjs index ff6afd8a..5ecf351a 100644 --- a/tests/api/smoke.mjs +++ b/tests/api/smoke.mjs @@ -5,6 +5,7 @@ import assert from 'node:assert'; process.env.SCOPEWEAVE_DB = ':memory:'; process.env.SCOPEWEAVE_DEV = '1'; // enables the dev-activate-pro endpoint for this test +delete process.env.ORCHESTRATOR_URL; // keep the AI briefing on the explicit local dev adapter process.env.SCOPEWEAVE_JWT_SECRET = '0123456789abcdef0123456789abcdef'; const { app } = await import('../../server/app.mjs'); From e482891a462656558576d83d264deeb13656414b Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 15 Aug 2026 12:54:22 +0900 Subject: [PATCH 24/26] test(orchestrator): cover non-JSON response branch --- tests/unit/orchestrator-coverage.test.mjs | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/tests/unit/orchestrator-coverage.test.mjs b/tests/unit/orchestrator-coverage.test.mjs index debd58c7..d1dbd60e 100644 --- a/tests/unit/orchestrator-coverage.test.mjs +++ b/tests/unit/orchestrator-coverage.test.mjs @@ -210,6 +210,13 @@ try { ); assert.equal(released, true); + globalThis.fetch = async () => new Response('{not-json', { status: 200 }); + await expectCode( + configured, + [{ role: 'user', content: 'non-json response' }], + 'orchestrator_response_invalid', + ); + for (const [label, body] of [ ['null-json', 'null'], ['primitive-json', '"string"'], From d57572adce97fe21ab437b4542d29ac3bc9306c8 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 15 Aug 2026 16:48:16 +0900 Subject: [PATCH 25/26] test(orchestrator): require cancellation of rejected bodies --- tests/unit/orchestrator.test.mjs | 35 ++++++++++++++++++++++++++++++++ 1 file changed, 35 insertions(+) diff --git a/tests/unit/orchestrator.test.mjs b/tests/unit/orchestrator.test.mjs index c900ff58..14de7136 100644 --- a/tests/unit/orchestrator.test.mjs +++ b/tests/unit/orchestrator.test.mjs @@ -121,6 +121,7 @@ try { ); let rejectedBodyRead = false; + let rejectedBodyCancelled = false; globalThis.fetch = async () => ({ ok: false, status: 502, @@ -130,6 +131,9 @@ try { rejectedBodyRead = true; throw new Error('rejected provider body must not be parsed'); }, + async cancel() { + rejectedBodyCancelled = true; + }, }, }); await assert.rejects( @@ -137,6 +141,37 @@ try { (error) => error.code === 'orchestrator_provider_rejected', ); assert.equal(rejectedBodyRead, false, 'non-success provider responses are classified before body parsing'); + assert.equal(rejectedBodyCancelled, true, 'non-success provider response bodies are explicitly cancelled'); + + globalThis.fetch = async () => ({ + ok: false, + status: 429, + headers: new Headers(), + body: { + async cancel() { + throw new Error('private cancel failure'); + }, + }, + }); + await assert.rejects( + configured.chat([{ role: 'user', content: 'status' }]), + (error) => { + assert.equal(error.code, 'orchestrator_provider_rejected'); + assert.doesNotMatch(error.message, /private cancel failure/); + return true; + }, + ); + + globalThis.fetch = async () => ({ + ok: false, + status: 503, + headers: new Headers(), + body: null, + }); + await assert.rejects( + configured.chat([{ role: 'user', content: 'status' }]), + (error) => error.code === 'orchestrator_provider_rejected', + ); globalThis.fetch = async () => new Response(JSON.stringify({ choices: [{ message: { content: 'x'.repeat(1024 * 1024) } }], From dd85ee0670c39333a1b09f298ce8226a54eea13a Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 15 Aug 2026 16:49:02 +0900 Subject: [PATCH 26/26] fix(orchestrator): cancel rejected provider response bodies --- server/orchestrator.mjs | 31 +++++++++++++++++++++++++------ 1 file changed, 25 insertions(+), 6 deletions(-) diff --git a/server/orchestrator.mjs b/server/orchestrator.mjs index ed614f4d..b3e8e400 100644 --- a/server/orchestrator.mjs +++ b/server/orchestrator.mjs @@ -248,6 +248,30 @@ async function responseJson(response) { return data; } +/** + * Cancel an unread non-success provider response before returning a fixed rejection. + * + * Undici-backed fetch bodies must be consumed or cancelled for predictable + * connection reuse. Cancellation failures remain private cleanup details and + * never replace the stable provider-rejection classification. + * + * @param {Response} response rejected provider response + * @returns {Promise} + */ +async function rejectProviderResponse(response) { + try { + if (response?.body && typeof response.body.cancel === 'function') { + await response.body.cancel(); + } + } catch { + // Provider rejection remains authoritative even if cleanup fails. + } + throw new OrchestratorConfigurationError( + 'orchestrator_provider_rejected', + `contextual-orchestrator rejected the request with HTTP ${response.status}.`, + ); +} + /** * Generate one AI briefing through contextual-orchestrator. * @param {unknown} messages OpenAI-compatible messages @@ -288,12 +312,7 @@ export async function chat(messages) { 'contextual-orchestrator could not be reached.', ); } - if (!response.ok) { - throw new OrchestratorConfigurationError( - 'orchestrator_provider_rejected', - `contextual-orchestrator rejected the request with HTTP ${response.status}.`, - ); - } + if (!response.ok) return rejectProviderResponse(response); const data = await responseJson(response); const content = data?.choices?.[0]?.message?.content; if (typeof content !== 'string' || !content.trim()) {