@vantran-se/openchamber-web 1.22.1 → 1.22.2
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/README.md +2 -3
- package/bin/cli.js +4 -1
- package/bin/cli.test.js +92 -0
- package/bin/lib/cli-startup.js +49 -0
- package/bin/lib/commands-startup.js +41 -6
- package/dist/assets/{CodeMirrorCommentWidgets-C53Q7pbX.js → CodeMirrorCommentWidgets-CV1HNV5l.js} +1 -1
- package/dist/assets/{DiffView-Dh1ey2x9.js → DiffView-DkkSa30n.js} +3 -3
- package/dist/assets/DirtyBranchSwitchDialog-DW4WaXPc.js +1 -0
- package/dist/assets/FilesView-DDu8zDy5.js +17 -0
- package/dist/assets/GitView-1Ixo8z-M.js +6 -0
- package/dist/assets/{JsonTreeView-Bl0lp9Rs.js → JsonTreeView-DvCLL7dT.js} +1 -1
- package/dist/assets/LinearIssuesView-Bs1rZrF2.js +1 -0
- package/dist/assets/{MarkdownImageGallery-bA0PS-tn.js → MarkdownImageGallery-DyJUc6h9.js} +1 -1
- package/dist/assets/MarkdownRendererImpl-Bdm8vMkS.js +12 -0
- package/dist/assets/{OnboardingScreen-D3kYJIb6.js → OnboardingScreen-Dg66B6oB.js} +1 -1
- package/dist/assets/{PatchDiff-D6NXAfJk.js → PatchDiff-CAduNjfZ.js} +1 -1
- package/dist/assets/{PierreDiffViewer-Ct2A13NO.js → PierreDiffViewer-CTIb1GYD.js} +2 -2
- package/dist/assets/PlanView-trDaszfq.js +5 -0
- package/dist/assets/SettingsView-DStMfBmy.js +28 -0
- package/dist/assets/{SettingsWindow-BiWDBRvb.js → SettingsWindow-DQQFnrSZ.js} +1 -1
- package/dist/assets/{ToolOutputDialog-BQexMQIm.js → ToolOutputDialog-SjY2XZlA.js} +6 -6
- package/dist/assets/ToolPartDiffPreview-DJdzUQPE.js +8 -0
- package/dist/assets/{VirtualizedFileDiff-Dpz2ra8h.js → VirtualizedFileDiff-Iehm4iMH.js} +1 -1
- package/dist/assets/{WalkthroughView-AX-u2pFg.js → WalkthroughView-BALp2d9O.js} +2 -2
- package/dist/assets/{computeVirtualFileMetrics-DtyozJdt.js → computeVirtualFileMetrics-BKZA43vN.js} +2 -2
- package/dist/assets/{contextPanelEmbeddedChat-C9EcRSDF.js → contextPanelEmbeddedChat-D2PM_pEv.js} +1 -1
- package/dist/assets/{de-DqVn5vme.js → de-DlSNLAcy.js} +6 -5
- package/dist/assets/{es-hj43Z53i.js → es-D1GbowZ3.js} +6 -5
- package/dist/assets/{fr-BmkZt2mF.js → fr-BPOLXIAY.js} +6 -5
- package/dist/assets/{index-DmhujXbT.js → index-Ce0LR32V.js} +1 -1
- package/dist/assets/index-D42fPvt4.css +1 -0
- package/dist/assets/index-DlFbCkEy.js +39 -0
- package/dist/assets/{isDiffPlainText-XyulMHxx.js → isDiffPlainText-DaB2u_Kv.js} +1 -1
- package/dist/assets/{isFilePlainText-B19Ddps1.js → isFilePlainText-yJZa2IAC.js} +1 -1
- package/dist/assets/{ja-DioMIEGF.js → ja-Dm2-m9io.js} +7 -6
- package/dist/assets/{ko-BqPgaQJb.js → ko-CIXlzQ5-.js} +7 -6
- package/dist/assets/main-B7qTsd_w.js +930 -0
- package/dist/assets/{main-B2a_JfKZ.js → main-CyQrD9dG.js} +2 -2
- package/dist/assets/{markdownCore-CWRCqSH2.js → markdownCore-CeEwGgEK.js} +2 -2
- package/dist/assets/{miniChat-B3CSU_Ps.js → miniChat-7cPEx4sV.js} +2 -2
- package/dist/assets/{mobile-QNcFIVCW.js → mobile-QtzkPTM8.js} +2 -2
- package/dist/assets/{patchFileDiff-BpMQZnYu.js → patchFileDiff-No55fDiD.js} +1 -1
- package/dist/assets/pl-B2zmkOpw.js +16 -0
- package/dist/assets/{pt-BR-BqH8fuCH.js → pt-BR-C-JE5H6y.js} +6 -5
- package/dist/assets/{renderElectronMiniChatApp-D77bmLqo.js → renderElectronMiniChatApp-ePTkBJNS.js} +2 -2
- package/dist/assets/renderMobileApp-D9RjB9DX.js +25 -0
- package/dist/assets/runtimeEndpointReset-DfJIWwwo.js +17 -0
- package/dist/assets/{scheduledTasksApi-BSRCtP6T.js → scheduledTasksApi-DuhdyYEa.js} +1 -1
- package/dist/assets/{shikiHighlight-DE-OwqGw.js → shikiHighlight-EHEg58-n.js} +1 -1
- package/dist/assets/tr-MJmRIzEY.js +7 -0
- package/dist/assets/{uk-BkkfxbOs.js → uk-BmD7eM8k.js} +6 -5
- package/dist/assets/{useAppFontEffects-COAeIX-D.js → useAppFontEffects-CuJPKtI1.js} +151 -180
- package/dist/assets/useInlineCommentController-DD7_7OP_.js +2 -0
- package/dist/assets/{useRootScrollLock-C0N9mCwz.js → useRootScrollLock-DRGAe8PN.js} +1 -1
- package/dist/assets/{vendor-codemirror-language-data-BG3v1Ghq.js → vendor-codemirror-language-data-9ZzoH0o0.js} +2 -2
- package/dist/assets/vendor-zod-CK21ihK-.js +39 -0
- package/dist/assets/{web-components-BNMwa0qm.js → web-components-PttKBrhi.js} +1 -1
- package/dist/assets/{zh-CN-C6FMdVgv.js → zh-CN-JJHl4ehT.js} +6 -5
- package/dist/assets/{zh-TW-CdcMjDxv.js → zh-TW-DPc4DEEW.js} +6 -5
- package/dist/index.html +5 -5
- package/dist/mini-chat.html +4 -4
- package/dist/mobile.html +4 -4
- package/package.json +8 -4
- package/server/index.js +38 -5
- package/server/lib/agent-tool/runtime.js +2 -19
- package/server/lib/changelog/update-notes.js +82 -0
- package/server/lib/changelog/update-notes.test.js +59 -0
- package/server/lib/dev-tunnel/DOCUMENTATION.md +13 -4
- package/server/lib/dev-tunnel/runtime.js +4 -4
- package/server/lib/dev-tunnel/tunnel.test.js +20 -0
- package/server/lib/fs/DOCUMENTATION.md +2 -0
- package/server/lib/fs/routes.js +32 -21
- package/server/lib/fs/routes.test.js +77 -0
- package/server/lib/git/DOCUMENTATION.md +3 -1
- package/server/lib/git/routes.js +16 -0
- package/server/lib/git/service.js +107 -17
- package/server/lib/git/service.test.js +171 -0
- package/server/lib/mcp-reconnect/DOCUMENTATION.md +55 -0
- package/server/lib/mcp-reconnect/runtime.js +122 -0
- package/server/lib/mcp-reconnect/runtime.test.js +140 -0
- package/server/lib/message-queue/DOCUMENTATION.md +158 -0
- package/server/lib/message-queue/runtime.js +870 -0
- package/server/lib/message-queue/runtime.test.js +482 -0
- package/server/lib/openchamber-sessions/routes.js +102 -0
- package/server/lib/openchamber-sessions/routes.test.js +88 -0
- package/server/lib/opencode/DOCUMENTATION.md +13 -5
- package/server/lib/opencode/config-paths.test.js +116 -0
- package/server/lib/opencode/core-routes.js +1 -0
- package/server/lib/opencode/feature-routes-runtime.js +5 -0
- package/server/lib/opencode/input-history-scope.js +14 -0
- package/server/lib/opencode/managed-plugin-config.js +35 -0
- package/server/lib/opencode/openchamber-routes.test.js +2 -2
- package/server/lib/opencode/plugins.js +2 -2
- package/server/lib/opencode/providers.js +44 -2
- package/server/lib/opencode/providers.test.js +131 -4
- package/server/lib/opencode/routes.js +4 -4
- package/server/lib/opencode/server-startup-runtime.js +1 -1
- package/server/lib/opencode/settings-helpers.js +28 -0
- package/server/lib/opencode/settings-helpers.test.js +177 -0
- package/server/lib/opencode/settings-runtime.js +2 -2
- package/server/lib/opencode/settings-runtime.test.js +29 -0
- package/server/lib/opencode/shared.js +4 -1
- package/server/lib/opencode/shutdown-runtime.js +2 -0
- package/server/lib/opencode/skill-routes.js +2 -1
- package/server/lib/opencode/snippets.js +1 -2
- package/server/lib/package-manager.js +4 -29
- package/server/lib/package-manager.test.js +2 -2
- package/server/lib/quota/DOCUMENTATION.md +2 -1
- package/server/lib/quota/credentials/providers.js +4 -0
- package/server/lib/quota/credentials/store.js +1 -1
- package/server/lib/quota/credentials/store.test.js +4 -4
- package/server/lib/quota/providers/exe-dev.js +75 -0
- package/server/lib/quota/providers/exe-dev.test.js +47 -0
- package/server/lib/quota/providers/index.js +7 -0
- package/server/lib/quota/providers/opencode-go.js +1 -0
- package/server/lib/quota/providers/opencode-go.test.js +5 -1
- package/server/lib/quota/routes.js +2 -0
- package/server/lib/quota/utils/auth.js +1 -1
- package/server/lib/relay/DOCUMENTATION.md +1 -1
- package/server/lib/relay/host-client.test.js +9 -1
- package/server/lib/relay/tunnel-host.js +3 -1
- package/server/lib/relay/tunnel-host.test.js +9 -1
- package/server/lib/security/request-security.js +21 -6
- package/server/lib/security/request-security.test.js +42 -8
- package/server/lib/session-assist/runtime.js +1 -0
- package/server/lib/session-goal/DOCUMENTATION.md +20 -7
- package/server/lib/session-goal/create.js +3 -1
- package/server/lib/session-goal/runtime.js +77 -11
- package/server/lib/session-goal/runtime.test.js +426 -0
- package/server/lib/skills-catalog/DOCUMENTATION.md +1 -1
- package/server/lib/skills-catalog/install.js +3 -2
- package/server/lib/small-model/DOCUMENTATION.md +4 -0
- package/server/lib/small-model/call.js +8 -2
- package/server/lib/small-model/call.test.js +23 -0
- package/server/lib/small-model/index.js +2 -1
- package/server/lib/small-model/routes.js +2 -1
- package/server/lib/system-prompt/runtime.js +2 -21
- package/server/lib/terminal/DOCUMENTATION.md +11 -7
- package/server/lib/terminal/runtime.js +180 -26
- package/server/lib/terminal/runtime.test.js +553 -26
- package/server/lib/terminal/shells.js +17 -0
- package/server/lib/terminal/shells.test.js +32 -1
- package/server/lib/ui-auth/ui-auth.js +4 -1
- package/server/lib/ui-auth/ui-auth.test.js +16 -0
- package/server/lib/walkthrough/index.js +1 -0
- package/server/lib/walkthrough/routes.test.js +26 -5
- package/dist/assets/CommitSection-X-f2RgYr.js +0 -1
- package/dist/assets/FilesView-3ZYP72ki.js +0 -17
- package/dist/assets/GitView-CMuN7z2Z.js +0 -6
- package/dist/assets/LinearIssuesView-CQiUDU8b.js +0 -1
- package/dist/assets/MarkdownRendererImpl-BGQi2PLq.js +0 -12
- package/dist/assets/PlanView-6AUjzNQn.js +0 -5
- package/dist/assets/SettingsView-CHI8fFaK.js +0 -28
- package/dist/assets/ToolPartDiffPreview-DPAATNn6.js +0 -8
- package/dist/assets/index-L_ncV-6H.css +0 -1
- package/dist/assets/index-qES19dlJ.js +0 -7
- package/dist/assets/main-CYgSu6dx.js +0 -928
- package/dist/assets/pl-slULBF4R.js +0 -15
- package/dist/assets/renderMobileApp-BQ3Kdm-d.js +0 -25
- package/dist/assets/runtimeEndpointReset-CRGn1gVj.js +0 -16
- package/dist/assets/tr-CjEbw1Aw.js +0 -6
- package/dist/assets/useInlineCommentController-JsBboQwt.js +0 -2
- package/dist/assets/vendor-zod-BLfBSgBh.js +0 -39
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { describe, expect, it } from 'bun:test';
|
|
2
|
+
import { fetchExeDevUsage, parseExeDevUsage } from './exe-dev.js';
|
|
3
|
+
|
|
4
|
+
const payload = {
|
|
5
|
+
allowance_spend_usd: 0.11,
|
|
6
|
+
extra_spend_usd: 0,
|
|
7
|
+
group: 'day',
|
|
8
|
+
month: '2026-09',
|
|
9
|
+
monthly_allowance_usd: 20,
|
|
10
|
+
period_end: '2026-10-01T00:00:00Z',
|
|
11
|
+
period_start: '2026-09-01T00:00:00Z',
|
|
12
|
+
total_cost_usd: 0.11,
|
|
13
|
+
total_requests: 17,
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
describe('exe.dev quota provider', () => {
|
|
17
|
+
it('parses monthly credit usage', () => {
|
|
18
|
+
const windows = parseExeDevUsage(payload);
|
|
19
|
+
expect(windows?.monthly.usedPercent).toBeCloseTo(0.55);
|
|
20
|
+
expect(windows?.monthly.valueLabel).toBe('$0.11 / $20.00');
|
|
21
|
+
expect(windows?.monthly.resetAt).toBe(Date.parse('2026-10-01T00:00:00Z'));
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
it('sends the scoped billing command without exposing the token elsewhere', async () => {
|
|
25
|
+
const requests = [];
|
|
26
|
+
const windows = await fetchExeDevUsage({ usageToken: 'test-token' }, async (url, init) => {
|
|
27
|
+
requests.push({ url, init });
|
|
28
|
+
return Response.json(payload);
|
|
29
|
+
});
|
|
30
|
+
expect(windows.monthly.valueLabel).toBe('$0.11 / $20.00');
|
|
31
|
+
expect(requests).toHaveLength(1);
|
|
32
|
+
expect(requests[0].url).toBe('https://exe.dev/exec');
|
|
33
|
+
expect(requests[0].init.method).toBe('POST');
|
|
34
|
+
expect(requests[0].init.body).toBe('billing credits usage --group=day --json');
|
|
35
|
+
expect(requests[0].init.headers.Authorization).toBe('Bearer test-token');
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it('rejects malformed successful responses', async () => {
|
|
39
|
+
await expect(fetchExeDevUsage({ usageToken: 'test-token' }, async () => Response.json({})))
|
|
40
|
+
.rejects.toThrow('could not be parsed');
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it('reports authentication failure without including the token', async () => {
|
|
44
|
+
await expect(fetchExeDevUsage({ usageToken: 'test-token' }, async () => new Response('', { status: 401 })))
|
|
45
|
+
.rejects.toThrow('exe.dev authentication failed');
|
|
46
|
+
});
|
|
47
|
+
});
|
|
@@ -13,6 +13,7 @@ import * as copilot from './copilot.js';
|
|
|
13
13
|
import * as crof from './crof.js';
|
|
14
14
|
import * as cursor from './cursor.js';
|
|
15
15
|
import * as deepseek from './deepseek.js';
|
|
16
|
+
import * as exeDev from './exe-dev.js';
|
|
16
17
|
import * as google from './google/index.js';
|
|
17
18
|
import * as kimi from './kimi.js';
|
|
18
19
|
import * as nanogpt from './nanogpt.js';
|
|
@@ -59,6 +60,12 @@ const registry = {
|
|
|
59
60
|
isConfigured: deepseek.isConfigured,
|
|
60
61
|
fetchQuota: deepseek.fetchQuota
|
|
61
62
|
},
|
|
63
|
+
'exe-dev': {
|
|
64
|
+
providerId: exeDev.providerId,
|
|
65
|
+
providerName: exeDev.providerName,
|
|
66
|
+
isConfigured: exeDev.isConfigured,
|
|
67
|
+
fetchQuota: exeDev.fetchQuota
|
|
68
|
+
},
|
|
62
69
|
google: {
|
|
63
70
|
providerId: google.providerId,
|
|
64
71
|
providerName: google.providerName,
|
|
@@ -37,6 +37,7 @@ export const fetchOpenCodeGoUsage = async (apiKey, fetchImpl = fetch) => {
|
|
|
37
37
|
headers: {
|
|
38
38
|
Accept: 'application/json',
|
|
39
39
|
Authorization: `Bearer ${apiKey}`,
|
|
40
|
+
'x-opencode-session': 'openchamber-usage',
|
|
40
41
|
'User-Agent': 'OpenChamber quota provider',
|
|
41
42
|
},
|
|
42
43
|
signal: AbortSignal.timeout(15_000),
|
|
@@ -43,7 +43,11 @@ describe('OpenCode Go quota provider', () => {
|
|
|
43
43
|
return new Response(JSON.stringify({ usage: { rolling: { percent: 25, resetsAt: '2026-08-12T12:00:00.000Z' } } }));
|
|
44
44
|
});
|
|
45
45
|
expect(request.url).toBe('https://opencode.ai/zen/go/v1/usage');
|
|
46
|
-
expect(request.options.headers).toMatchObject({
|
|
46
|
+
expect(request.options.headers).toMatchObject({
|
|
47
|
+
Accept: 'application/json',
|
|
48
|
+
Authorization: 'Bearer secret',
|
|
49
|
+
'x-opencode-session': 'openchamber-usage',
|
|
50
|
+
});
|
|
47
51
|
expect(request.options.headers.Cookie).toBeUndefined();
|
|
48
52
|
expect(usage['5h'].usedPercent).toBe(25);
|
|
49
53
|
});
|
|
@@ -2,8 +2,10 @@ import express from 'express';
|
|
|
2
2
|
import { deleteManagedCredential, getManagedCredentialStatus, normalizers, readManagedCredential, writeManagedCredential } from './credentials/providers.js';
|
|
3
3
|
import { fetchOllamaCloudUsage } from './providers/ollama-cloud.js';
|
|
4
4
|
import { importCursorCredential, validateCursorCredential } from './providers/cursor.js';
|
|
5
|
+
import { fetchExeDevUsage } from './providers/exe-dev.js';
|
|
5
6
|
|
|
6
7
|
const validators = {
|
|
8
|
+
'exe-dev': fetchExeDevUsage,
|
|
7
9
|
'ollama-cloud': fetchOllamaCloudUsage,
|
|
8
10
|
cursor: validateCursorCredential,
|
|
9
11
|
};
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
import fs from 'fs';
|
|
2
2
|
import path from 'path';
|
|
3
3
|
import os from 'os';
|
|
4
|
+
import { OPENCODE_CONFIG_DIR } from '../../opencode/shared.js';
|
|
4
5
|
|
|
5
|
-
const OPENCODE_CONFIG_DIR = path.join(os.homedir(), '.config', 'opencode');
|
|
6
6
|
const OPENCODE_DATA_DIR = path.join(os.homedir(), '.local', 'share', 'opencode');
|
|
7
7
|
|
|
8
8
|
export const ANTIGRAVITY_ACCOUNTS_PATHS = [
|
|
@@ -41,7 +41,7 @@ Relay is not a separate link format: it is one transport candidate inside the un
|
|
|
41
41
|
Everything a client normally sends to the single OpenChamber origin:
|
|
42
42
|
- **HTTP** — REST endpoints and proxied OpenCode SDK calls under `/api/*`, plus `/auth/*` and `/health`.
|
|
43
43
|
- **SSE** — long-lived streamed responses (the event stream and notifications). These are just HTTP responses whose body streams; the tunnel needs no special SSE handling.
|
|
44
|
-
- **WebSocket** — the endpoints that use a real socket (the global event stream on platforms that support WS, terminal I/O, dictation).
|
|
44
|
+
- **WebSocket** — the endpoints that use a real socket (the global event stream on platforms that support WS, terminal I/O, dictation, and desktop dev-server previews).
|
|
45
45
|
|
|
46
46
|
The host dispatcher restricts tunneled traffic to explicit path allowlists (one for HTTP, one for WS).
|
|
47
47
|
|
|
@@ -98,6 +98,9 @@ const startFakeRelay = () => {
|
|
|
98
98
|
wsUrl: `ws://127.0.0.1:${port}`,
|
|
99
99
|
state,
|
|
100
100
|
stop: () => new Promise((r) => {
|
|
101
|
+
// A socket a failed test left open would hold server.close() until
|
|
102
|
+
// the hook timeout; drop them so a failure is reported once.
|
|
103
|
+
for (const client of wss.clients) client.terminate();
|
|
101
104
|
wss.close();
|
|
102
105
|
server.close(() => r());
|
|
103
106
|
}),
|
|
@@ -162,7 +165,6 @@ const runScriptedClient = async ({ relayUrl, serverId, hostEncPubJwk }) => {
|
|
|
162
165
|
url.searchParams.set('role', 'client');
|
|
163
166
|
url.searchParams.set('serverId', serverId);
|
|
164
167
|
url.searchParams.set('connectionId', connectionId);
|
|
165
|
-
const ws = new WebSocket(url.toString());
|
|
166
168
|
|
|
167
169
|
const hostPub = await globalThis.crypto.subtle.importKey(
|
|
168
170
|
'jwk',
|
|
@@ -182,6 +184,12 @@ const runScriptedClient = async ({ relayUrl, serverId, hostEncPubJwk }) => {
|
|
|
182
184
|
resolveDone = resolve;
|
|
183
185
|
});
|
|
184
186
|
|
|
187
|
+
// Dialed only now, with the key material ready and the listeners attached
|
|
188
|
+
// in the same tick. Dialing before the WebCrypto awaits above let a loopback
|
|
189
|
+
// socket open while the key generation was still queued on the threadpool,
|
|
190
|
+
// and an `open` event with no listener means no hello, no ready, and a
|
|
191
|
+
// client that waits forever. Loaded CI runners hit exactly that.
|
|
192
|
+
const ws = new WebSocket(url.toString());
|
|
185
193
|
ws.on('open', async () => {
|
|
186
194
|
ws.send(JSON.stringify({
|
|
187
195
|
t: 'hello',
|
|
@@ -33,7 +33,9 @@ const ALLOWED_WS_PATHS = new Set([
|
|
|
33
33
|
'/api/event/ws',
|
|
34
34
|
'/api/terminal/ws',
|
|
35
35
|
'/api/dictation/ws',
|
|
36
|
+
'/api/dev-tunnel',
|
|
36
37
|
]);
|
|
38
|
+
export const isAllowedRelayWebSocketPath = (pathname) => ALLOWED_WS_PATHS.has(pathname);
|
|
37
39
|
|
|
38
40
|
// Hop-by-hop headers stripped from tunneled requests; `host` is set by fetch
|
|
39
41
|
// to the loopback origin. content-length is dropped too because the body is
|
|
@@ -425,7 +427,7 @@ export const createTunnelHost = ({ connectionId, getLocalPort, sendFrame, getBuf
|
|
|
425
427
|
void sendAbort(streamId, error?.message ?? 'malformed ws open');
|
|
426
428
|
return;
|
|
427
429
|
}
|
|
428
|
-
if (!
|
|
430
|
+
if (!isAllowedRelayWebSocketPath(open.path)) {
|
|
429
431
|
void sendAbort(streamId, 'Path is not allowed through the relay');
|
|
430
432
|
return;
|
|
431
433
|
}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { describe, test, expect } from 'bun:test';
|
|
2
2
|
import http from 'node:http';
|
|
3
3
|
|
|
4
|
-
import { createTunnelHost } from './tunnel-host.js';
|
|
4
|
+
import { createTunnelHost, isAllowedRelayWebSocketPath } from './tunnel-host.js';
|
|
5
5
|
import { decodeTunnelFrame, encodeTunnelFrame, encodeJsonPayload, TunnelFrameType } from './tunnel-codec.js';
|
|
6
6
|
|
|
7
7
|
const startLoopback = () =>
|
|
@@ -145,3 +145,11 @@ describe('tunnel-host HTTP body forwarding', () => {
|
|
|
145
145
|
await loopback.stop();
|
|
146
146
|
});
|
|
147
147
|
});
|
|
148
|
+
|
|
149
|
+
describe('relay host WebSocket allowlist', () => {
|
|
150
|
+
test('allows only the exact dev-server tunnel path', () => {
|
|
151
|
+
expect(isAllowedRelayWebSocketPath('/api/dev-tunnel')).toBe(true);
|
|
152
|
+
expect(isAllowedRelayWebSocketPath('/api/dev-tunnel/')).toBe(false);
|
|
153
|
+
expect(isAllowedRelayWebSocketPath('/api/database/ws')).toBe(false);
|
|
154
|
+
});
|
|
155
|
+
});
|
|
@@ -67,6 +67,7 @@ export const createRequestSecurityRuntime = (deps) => {
|
|
|
67
67
|
|
|
68
68
|
const getRequestOriginCandidates = async (req) => {
|
|
69
69
|
const origins = new Set();
|
|
70
|
+
const hosts = new Set();
|
|
70
71
|
const forwardedProto = typeof req.headers['x-forwarded-proto'] === 'string'
|
|
71
72
|
? req.headers['x-forwarded-proto'].split(',')[0].trim().toLowerCase()
|
|
72
73
|
: '';
|
|
@@ -78,6 +79,7 @@ export const createRequestSecurityRuntime = (deps) => {
|
|
|
78
79
|
const host = forwardedHost || (typeof req.headers.host === 'string' ? req.headers.host.trim() : '');
|
|
79
80
|
|
|
80
81
|
if (host) {
|
|
82
|
+
hosts.add(host.toLowerCase());
|
|
81
83
|
origins.add(`${protocol}://${host}`);
|
|
82
84
|
const [hostname, port] = host.split(':');
|
|
83
85
|
const normalizedHost = typeof hostname === 'string' ? hostname.toLowerCase() : '';
|
|
@@ -98,10 +100,10 @@ export const createRequestSecurityRuntime = (deps) => {
|
|
|
98
100
|
} catch {
|
|
99
101
|
}
|
|
100
102
|
|
|
101
|
-
return origins;
|
|
103
|
+
return { origins, hosts };
|
|
102
104
|
};
|
|
103
105
|
|
|
104
|
-
const isRequestOriginAllowed =
|
|
106
|
+
const isRequestOriginAllowed = (req) => {
|
|
105
107
|
const originHeader = typeof req.headers.origin === 'string' ? req.headers.origin.trim() : '';
|
|
106
108
|
if (!originHeader) {
|
|
107
109
|
return false;
|
|
@@ -111,15 +113,28 @@ export const createRequestSecurityRuntime = (deps) => {
|
|
|
111
113
|
return true;
|
|
112
114
|
}
|
|
113
115
|
|
|
114
|
-
let
|
|
116
|
+
let origin;
|
|
115
117
|
try {
|
|
116
|
-
|
|
118
|
+
origin = new URL(originHeader);
|
|
117
119
|
} catch {
|
|
118
120
|
return false;
|
|
119
121
|
}
|
|
120
122
|
|
|
121
|
-
const
|
|
122
|
-
|
|
123
|
+
const forwardedHostHeader = req.headers['x-forwarded-host'];
|
|
124
|
+
const forwardedHost = (Array.isArray(forwardedHostHeader) ? forwardedHostHeader[0] : forwardedHostHeader || '')
|
|
125
|
+
.split(',')[0].trim().toLowerCase();
|
|
126
|
+
const hostHeader = req.headers.host;
|
|
127
|
+
const host = forwardedHost || (Array.isArray(hostHeader) ? hostHeader[0] : hostHeader || '').trim().toLowerCase();
|
|
128
|
+
if (host && host === origin.host.toLowerCase()) return true;
|
|
129
|
+
|
|
130
|
+
// TLS commonly ends at a cloud edge before an HTTP hop to OpenChamber.
|
|
131
|
+
// In that setup the browser's Origin is https while a generic reverse
|
|
132
|
+
// proxy reports the upstream request as http. The external host remains
|
|
133
|
+
// authoritative, so compare it directly instead of requiring the proxy to
|
|
134
|
+
// preserve the browser-facing protocol.
|
|
135
|
+
return getRequestOriginCandidates(req).then((candidates) => (
|
|
136
|
+
candidates.origins.has(origin.origin) || candidates.hosts.has(origin.host.toLowerCase())
|
|
137
|
+
));
|
|
123
138
|
};
|
|
124
139
|
|
|
125
140
|
return {
|
|
@@ -9,41 +9,75 @@ describe('request security runtime', () => {
|
|
|
9
9
|
test('allows packaged client origins for remote client transports', async () => {
|
|
10
10
|
const runtime = createRuntime();
|
|
11
11
|
|
|
12
|
-
await
|
|
12
|
+
expect(await runtime.isRequestOriginAllowed({
|
|
13
13
|
headers: {
|
|
14
14
|
origin: 'openchamber-ui://app',
|
|
15
15
|
host: '192.168.1.130:1202',
|
|
16
16
|
},
|
|
17
17
|
socket: {},
|
|
18
|
-
})).
|
|
18
|
+
})).toBe(true);
|
|
19
19
|
|
|
20
|
-
await
|
|
20
|
+
expect(await runtime.isRequestOriginAllowed({
|
|
21
21
|
headers: {
|
|
22
22
|
origin: 'capacitor://localhost',
|
|
23
23
|
host: '192.168.1.130:1202',
|
|
24
24
|
},
|
|
25
25
|
socket: {},
|
|
26
|
-
})).
|
|
26
|
+
})).toBe(true);
|
|
27
27
|
|
|
28
28
|
// Android Capacitor WebView (androidScheme 'https') reports this origin.
|
|
29
|
-
await
|
|
29
|
+
expect(await runtime.isRequestOriginAllowed({
|
|
30
30
|
headers: {
|
|
31
31
|
origin: 'https://localhost',
|
|
32
32
|
host: '192.168.1.130:1202',
|
|
33
33
|
},
|
|
34
34
|
socket: {},
|
|
35
|
-
})).
|
|
35
|
+
})).toBe(true);
|
|
36
36
|
});
|
|
37
37
|
|
|
38
38
|
test('rejects unknown origins', async () => {
|
|
39
39
|
const runtime = createRuntime();
|
|
40
40
|
|
|
41
|
-
await
|
|
41
|
+
expect(await runtime.isRequestOriginAllowed({
|
|
42
42
|
headers: {
|
|
43
43
|
origin: 'https://evil.example.com',
|
|
44
44
|
host: '192.168.1.130:1202',
|
|
45
45
|
},
|
|
46
46
|
socket: {},
|
|
47
|
-
})).
|
|
47
|
+
})).toBe(false);
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
test('allows the external host when TLS terminates before an HTTP proxy hop', async () => {
|
|
51
|
+
const runtime = createRuntime();
|
|
52
|
+
|
|
53
|
+
expect(await runtime.isRequestOriginAllowed({
|
|
54
|
+
headers: {
|
|
55
|
+
origin: 'https://devchamber.example.com',
|
|
56
|
+
host: 'devchamber.example.com',
|
|
57
|
+
'x-forwarded-proto': 'http',
|
|
58
|
+
},
|
|
59
|
+
socket: {},
|
|
60
|
+
})).toBe(true);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
test('uses the forwarded external host without trusting a different origin', async () => {
|
|
64
|
+
const runtime = createRuntime();
|
|
65
|
+
const request = {
|
|
66
|
+
headers: {
|
|
67
|
+
host: '127.0.0.1:3000',
|
|
68
|
+
'x-forwarded-host': 'devchamber.example.com',
|
|
69
|
+
'x-forwarded-proto': 'http',
|
|
70
|
+
},
|
|
71
|
+
socket: {},
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
expect(await runtime.isRequestOriginAllowed({
|
|
75
|
+
...request,
|
|
76
|
+
headers: { ...request.headers, origin: 'https://devchamber.example.com' },
|
|
77
|
+
})).toBe(true);
|
|
78
|
+
expect(await runtime.isRequestOriginAllowed({
|
|
79
|
+
...request,
|
|
80
|
+
headers: { ...request.headers, origin: 'https://evil.example.com' },
|
|
81
|
+
})).toBe(false);
|
|
48
82
|
});
|
|
49
83
|
});
|
|
@@ -257,6 +257,7 @@ export const createSessionAssistRuntime = ({
|
|
|
257
257
|
prompt: `The latest exchange in the conversation:\n\n${transcript}\n\nWrite ${requestedFields} in the SAME language as this sample from the conversation: "${languageSample}"`,
|
|
258
258
|
system: buildAssistSystemPrompt(targets),
|
|
259
259
|
directory,
|
|
260
|
+
sessionID: sessionId,
|
|
260
261
|
preferredProviderID: typeof lastAssistantInfo.providerID === 'string' ? lastAssistantInfo.providerID : undefined,
|
|
261
262
|
preferredModelID: typeof lastAssistantInfo.modelID === 'string' ? lastAssistantInfo.modelID : undefined,
|
|
262
263
|
});
|
|
@@ -108,13 +108,26 @@ before touching the filesystem). Rationale: metadata rides every
|
|
|
108
108
|
stop), with a tick-side safety net. Messages sent while paused leave
|
|
109
109
|
the goal alone; Resume re-arms the loop, and resuming over an aborted
|
|
110
110
|
tail skips the audit and goes straight to a continuation nudge;
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
111
|
+
- terminal checks, cheapest first: assistant turn error → `blocked`;
|
|
112
|
+
`tokensUsed >= tokenBudget` → `budgetLimited`;
|
|
113
|
+
`turnsUsed >= MAX_AUTO_TURNS` (20) → `blocked`;
|
|
114
|
+
- error classification is independent of `finish`: `MessageAbortedError`
|
|
115
|
+
keeps the pause/resume behavior; only a `finish: "length"` with no
|
|
116
|
+
error, or `MessageOutputLengthError`, is an in-progress truncation that
|
|
117
|
+
skips the audit and continues. Any other non-null error wins over a
|
|
118
|
+
length finish and blocks with its non-empty `error.name`, or
|
|
119
|
+
`assistant turn failed` when unnamed;
|
|
120
|
+
- length recovery is bounded separately from the token budget and
|
|
121
|
+
auto-continuation cap: the first truncation permits one continuation, but
|
|
122
|
+
a second consecutive completed, non-summary assistant turn that is also
|
|
123
|
+
truncated settles the goal as `blocked` (`repeated output truncation`).
|
|
124
|
+
The consecutive state is derived from the loaded message history, not
|
|
125
|
+
persisted, using `info.time.created` chronology rather than message IDs.
|
|
126
|
+
Summary messages are not agent turns; an ordinary completed assistant
|
|
127
|
+
turn naturally breaks the consecutive condition. Explicit Resume grants
|
|
128
|
+
one new recovery attempt over the same transcript; the continuation
|
|
129
|
+
consumes that permission, so another truncation blocks again. Resume
|
|
130
|
+
does not bypass assistant errors or the token budget;
|
|
118
131
|
- otherwise, small-model audit of the objective + the last assistant turn
|
|
119
132
|
only — no conversation history and no continuation prompts
|
|
120
133
|
(`restrictToPreferredProvider`, session's own provider/model preferred):
|
|
@@ -14,7 +14,7 @@ export const buildGoalIntroText = (tokenBudget) => {
|
|
|
14
14
|
+ '\n</system-reminder>';
|
|
15
15
|
};
|
|
16
16
|
|
|
17
|
-
const fitObjective = async ({ objective, directory, providerID, modelID, warn }) => {
|
|
17
|
+
const fitObjective = async ({ objective, directory, sessionID, providerID, modelID, warn }) => {
|
|
18
18
|
if (objective.length <= GOAL_OBJECTIVE_CHAR_LIMIT) return objective;
|
|
19
19
|
|
|
20
20
|
let distilled = null;
|
|
@@ -32,6 +32,7 @@ const fitObjective = async ({ objective, directory, providerID, modelID, warn })
|
|
|
32
32
|
'Write in the same language as the task text.',
|
|
33
33
|
].join('\n'),
|
|
34
34
|
directory,
|
|
35
|
+
sessionID,
|
|
35
36
|
preferredProviderID: providerID,
|
|
36
37
|
preferredModelID: modelID,
|
|
37
38
|
});
|
|
@@ -66,6 +67,7 @@ export const createSessionGoal = async ({
|
|
|
66
67
|
const objectiveText = await fitObjective({
|
|
67
68
|
objective: String(objective ?? '').trim(),
|
|
68
69
|
directory,
|
|
70
|
+
sessionID,
|
|
69
71
|
providerID,
|
|
70
72
|
modelID,
|
|
71
73
|
warn,
|
|
@@ -244,6 +244,57 @@ const messageTokenTotal = (info) => {
|
|
|
244
244
|
return input + cachedRead + output;
|
|
245
245
|
};
|
|
246
246
|
|
|
247
|
+
const getErrorName = (error) => error?.name?.trim?.() ?? '';
|
|
248
|
+
|
|
249
|
+
const isLengthTruncated = (info, errorName = getErrorName(info?.error)) => {
|
|
250
|
+
const error = info?.error;
|
|
251
|
+
const hasError = error !== null && error !== undefined;
|
|
252
|
+
return errorName === 'MessageOutputLengthError' || (!hasError && info?.finish === 'length');
|
|
253
|
+
};
|
|
254
|
+
|
|
255
|
+
// Summary messages are assistant-shaped, but they are compaction turns rather
|
|
256
|
+
// than agent turns. They must not break or satisfy the consecutive truncation
|
|
257
|
+
// check; only completed, non-summary assistant turns participate. Chronology
|
|
258
|
+
// comes from `time.created`, never from message IDs; array position is only a
|
|
259
|
+
// tie-breaker for equal timestamps.
|
|
260
|
+
const hasRepeatedLengthTail = (messages, latestAssistant, goalCreatedAt) => {
|
|
261
|
+
const latestInfo = latestAssistant?.info;
|
|
262
|
+
if (latestInfo?.summary === true) return false;
|
|
263
|
+
const latestIndex = messages.indexOf(latestAssistant);
|
|
264
|
+
const latestCreated = latestInfo?.time?.created;
|
|
265
|
+
if (
|
|
266
|
+
latestIndex < 0
|
|
267
|
+
|| !(latestInfo?.time?.completed > 0)
|
|
268
|
+
|| !(Number.isFinite(latestCreated) && latestCreated > 0)
|
|
269
|
+
|| !isLengthTruncated(latestInfo)
|
|
270
|
+
) return false;
|
|
271
|
+
|
|
272
|
+
let previous = null;
|
|
273
|
+
for (let i = 0; i < messages.length; i += 1) {
|
|
274
|
+
const info = messages[i]?.info;
|
|
275
|
+
if (info?.role !== 'assistant' || info.summary === true || !(info.time?.completed > 0)) continue;
|
|
276
|
+
const created = info.time?.created;
|
|
277
|
+
// An unknown timestamp cannot safely participate in chronology. Ignore it
|
|
278
|
+
// rather than letting an unrelated older message hide known chronology.
|
|
279
|
+
if (!(Number.isFinite(created) && created > 0)) continue;
|
|
280
|
+
if (i === latestIndex) continue;
|
|
281
|
+
if (created > latestCreated || (created === latestCreated && i > latestIndex)) continue;
|
|
282
|
+
if (
|
|
283
|
+
!previous
|
|
284
|
+
|| created > previous.created
|
|
285
|
+
|| (created === previous.created && i > previous.index)
|
|
286
|
+
) {
|
|
287
|
+
previous = { info, created, index: i };
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
return Boolean(
|
|
292
|
+
previous
|
|
293
|
+
&& previous.created > goalCreatedAt
|
|
294
|
+
&& isLengthTruncated(previous.info),
|
|
295
|
+
);
|
|
296
|
+
};
|
|
297
|
+
|
|
247
298
|
export const createSessionGoalRuntime = ({
|
|
248
299
|
buildOpenCodeUrl,
|
|
249
300
|
getOpenCodeAuthHeaders,
|
|
@@ -378,6 +429,7 @@ export const createSessionGoalRuntime = ({
|
|
|
378
429
|
prompt: `The goal objective:\n\n<objective>\n${goal.objective}\n</objective>\n\nThe agent's latest turn:\n\n${assistantText}\n\nReturn the verdict JSON. Write the note in the SAME language as this sample from the objective: "${goal.objective.slice(0, 200).replace(/\s+/g, ' ').trim()}"`,
|
|
379
430
|
system: buildAuditSystemPrompt(),
|
|
380
431
|
directory,
|
|
432
|
+
sessionID: typeof lastAssistantInfo?.sessionID === 'string' ? lastAssistantInfo.sessionID : undefined,
|
|
381
433
|
preferredProviderID: typeof lastAssistantInfo?.providerID === 'string' ? lastAssistantInfo.providerID : undefined,
|
|
382
434
|
preferredModelID: typeof lastAssistantInfo?.modelID === 'string' ? lastAssistantInfo.modelID : undefined,
|
|
383
435
|
});
|
|
@@ -608,7 +660,11 @@ export const createSessionGoalRuntime = ({
|
|
|
608
660
|
// resumed over an aborted tail: that is an explicit "keep going", so it
|
|
609
661
|
// falls through to the continuation below (skipping the audit — an
|
|
610
662
|
// aborted reply is not evidence of anything).
|
|
611
|
-
const
|
|
663
|
+
const error = lastAssistantInfo.error;
|
|
664
|
+
const errorName = getErrorName(error);
|
|
665
|
+
const hasError = error !== null && error !== undefined;
|
|
666
|
+
const abortedTail = errorName === 'MessageAbortedError';
|
|
667
|
+
const lengthTail = isLengthTruncated(lastAssistantInfo, errorName);
|
|
612
668
|
if (abortedTail && goal.statusReason !== 'resumed') {
|
|
613
669
|
await writeGoal(sessionId, directory, goal.id, () => ({
|
|
614
670
|
status: 'paused',
|
|
@@ -622,13 +678,12 @@ export const createSessionGoalRuntime = ({
|
|
|
622
678
|
return;
|
|
623
679
|
}
|
|
624
680
|
|
|
625
|
-
//
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
: 'assistant turn failed';
|
|
681
|
+
// Non-length turn error → blocked (prevents runaway auto-continuation into
|
|
682
|
+
// failures). Recognized length cutoffs are in-progress continuations, not
|
|
683
|
+
// hard failures.
|
|
684
|
+
if (!abortedTail && !lengthTail && hasError) {
|
|
630
685
|
await settleGoal({
|
|
631
|
-
sessionId, directory, goal, status: 'blocked', statusReason:
|
|
686
|
+
sessionId, directory, goal, status: 'blocked', statusReason: errorName || 'assistant turn failed', tokensUsed, tokensBaseline, tokensCommitted, lastAccountedMessageID,
|
|
632
687
|
});
|
|
633
688
|
return;
|
|
634
689
|
}
|
|
@@ -649,17 +704,28 @@ export const createSessionGoalRuntime = ({
|
|
|
649
704
|
return;
|
|
650
705
|
}
|
|
651
706
|
|
|
707
|
+
// A second consecutive completed, non-summary length-truncated turn is a
|
|
708
|
+
// bounded recovery failure. Derive this from the loaded transcript rather
|
|
709
|
+
// than persisting another goal counter.
|
|
710
|
+
if (lengthTail && goal.statusReason !== 'resumed' && hasRepeatedLengthTail(messages, lastAssistant, goal.createdAt)) {
|
|
711
|
+
await settleGoal({
|
|
712
|
+
sessionId, directory, goal, status: 'blocked', statusReason: 'repeated output truncation', tokensUsed, tokensBaseline, tokensCommitted, lastAccountedMessageID,
|
|
713
|
+
});
|
|
714
|
+
return;
|
|
715
|
+
}
|
|
716
|
+
|
|
652
717
|
// --- Small-model audit: the sole termination authority besides the hard
|
|
653
718
|
// stops above (turn error, budget, continuation cap). The working agent
|
|
654
719
|
// has no channel to settle its own goal.
|
|
655
720
|
//
|
|
656
|
-
// Exception: when the latest message is a compaction summary
|
|
657
|
-
// by
|
|
658
|
-
// "in progress, not finished".
|
|
721
|
+
// Exception: when the latest message is a compaction summary or was cut off
|
|
722
|
+
// by the output token limit (length stop), the agent by definition ran into
|
|
723
|
+
// the context/output limit mid-work — that IS "in progress, not finished".
|
|
724
|
+
// No audit call; continue unconditionally.
|
|
659
725
|
let audit = null;
|
|
660
726
|
let blockedStreak = 0;
|
|
661
727
|
let auditFailStreak = goal.auditFailStreak;
|
|
662
|
-
if (lastAssistantInfo.summary === true || abortedTail) {
|
|
728
|
+
if (lastAssistantInfo.summary === true || abortedTail || lengthTail) {
|
|
663
729
|
blockedStreak = goal.blockedStreak;
|
|
664
730
|
} else {
|
|
665
731
|
audit = await runAudit({ goal: { ...goal, objective: effectiveObjective }, assistantText, directory, lastAssistantInfo: executionInfo ?? lastAssistantInfo });
|