Skip to content
Merged
Show file tree
Hide file tree
Changes from 14 commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
c3def46
fix(orchestrator): fail closed outside explicit development mode
seonghobae Aug 9, 2026
6525970
test(orchestrator): prove production never returns deterministic fake…
seonghobae Aug 9, 2026
02d1bf6
test(coverage): include orchestrator production boundary
seonghobae Aug 9, 2026
2b02f23
docs(orchestrator): define fail-closed learned-coordination boundary
seonghobae Aug 9, 2026
eb9435e
ci(orchestrator): verify fail-closed production behavior once
seonghobae Aug 9, 2026
cc60a85
docs(changelog): record orchestrator production boundary
seonghobae Aug 9, 2026
bdc81fa
ci: remove completed orchestrator one-shot writer
seonghobae Aug 9, 2026
af69b5f
test(orchestrator): reject oversized provider responses
seonghobae Aug 14, 2026
6281964
fix(orchestrator): bound provider response bodies
seonghobae Aug 14, 2026
75f73e4
merge develop: preserve security hardening in orchestrator PR
seonghobae Aug 14, 2026
cc76f59
test(orchestrator): enforce streaming response byte budget
seonghobae Aug 14, 2026
7ea77f0
fix(orchestrator): bound provider response while streaming
seonghobae Aug 14, 2026
c5c890e
docs(orchestrator): record streaming response bound
seonghobae Aug 14, 2026
1e6916a
test(orchestrator): expect explicit development adapter
seonghobae Aug 14, 2026
4a806b5
test(orchestrator): cover residual production boundary branches
seonghobae Aug 14, 2026
d648c45
test(orchestrator): register residual branch coverage suite
seonghobae Aug 14, 2026
766d577
test(orchestrator): cover IPv6 loopback transport
seonghobae Aug 14, 2026
2a81fb4
fix(orchestrator): recognize IPv6 loopback hostname
seonghobae Aug 14, 2026
bd9bfe3
test(orchestrator): reject ambiguous provider endpoint configuration
seonghobae Aug 15, 2026
268536f
fix(orchestrator): require a canonical provider origin
seonghobae Aug 15, 2026
8c150a7
docs(orchestrator): document canonical provider origin
seonghobae Aug 15, 2026
46723a0
test(orchestrator): classify provider rejection before response body
seonghobae Aug 15, 2026
1578bf3
fix(orchestrator): classify provider rejection before body parsing
seonghobae Aug 15, 2026
b2f9639
test(api): isolate orchestrator mock from inherited provider config
seonghobae Aug 15, 2026
e482891
test(orchestrator): cover non-JSON response branch
seonghobae Aug 15, 2026
d57572a
test(orchestrator): require cancellation of rejected bodies
seonghobae Aug 15, 2026
dd85ee0
fix(orchestrator): cancel rejected provider response bodies
seonghobae Aug 15, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
61 changes: 61 additions & 0 deletions docs/orchestrator-production.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
# 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=<secret>
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.

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.

## 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
6 changes: 3 additions & 3 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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/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 --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/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/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: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:e2e": "playwright test",
"test:e2e:headed": "playwright test --headed",
"test:e2e:cloud": "playwright install chromium && playwright test tests/e2e/cloud.spec.js",
Expand Down
279 changes: 259 additions & 20 deletions server/orchestrator.mjs
Original file line number Diff line number Diff line change
@@ -1,35 +1,274 @@
// 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;
const MAX_PROVIDER_RESPONSE_BYTES = 1024 * 1024;

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 };
});
}

/**
* 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<Buffer>} Non-empty bounded response bytes.
*/
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 body is not stream-readable.',
);
}

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_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<Record<string, unknown>>}
*/
async function responseJson(response) {
const bytes = await boundedResponseBytes(response);
let data;
try {
data = JSON.parse(bytes.toString('utf8'));
} 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<string>}
*/
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) {
Comment thread
seonghobae marked this conversation as resolved.
Resolved
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;
}
4 changes: 2 additions & 2 deletions tests/api/smoke.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
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');
Expand Down Expand Up @@ -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');
Loading
Loading