Merge branch 'openchamber:main' into fix/walkthrough-remote-default-branch

This commit is contained in:
Rajat Asthana
2026-08-04 22:27:56 +05:30
committed by GitHub
47 changed files with 1579 additions and 333 deletions
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "@openchamber/web",
"version": "1.18.0",
"version": "1.18.1",
"private": false,
"type": "module",
"main": "./server/index.js",
@@ -25,7 +25,7 @@
"dependencies": {
"@clack/prompts": "^1.1.0",
"@octokit/rest": "^22.0.1",
"@opencode-ai/sdk": "1.18.11",
"@opencode-ai/sdk": "1.18.12",
"@simplewebauthn/server": "13.3.1",
"adm-zip": "^0.5.16",
"bun-pty": "^0.4.5",
@@ -375,6 +375,8 @@ an authoritative loopback callback URL even when OpenChamber binds port `0`.
- SSE forwarders: `GET /api/global/event`, `GET /api/event`
- Downstream heartbeats keep clients and intermediaries alive, while a separate upstream-only stall watchdog closes the downstream response when OpenCode stops producing bytes so clients reconnect instead of trusting synthetic heartbeats indefinitely. Each watchdog reset uses the current load-aware timeout, matching the shared event transport.
- Session message forwarder: `POST /api/session/:sessionId/message`
- Interactive OAuth forwarder: `POST /api/provider/:providerID/oauth/callback`
- Upstream blocks inside this call for the whole browser sign-in (device-code polling or a loopback redirect), so it is exempt from the ordinary request deadline and uses a 15-minute proxy timeout instead of `LONG_REQUEST_TIMEOUT_MS`. All other `/api/provider/*` routes, including `oauth/authorize`, keep the ordinary deadline.
- Generic `/api/*` forwarding with hop-by-hop header filtering
- Windows `/session` merge fallback path behavior
- OpenCode readiness gate for proxied `/api` requests
+21 -3
View File
@@ -309,6 +309,16 @@ export const registerOpenCodeProxy = (app, deps) => {
const PROXY_REQUEST_TIMEOUT_MS = normalizeProxyTimeout(LONG_REQUEST_TIMEOUT_MS);
const PROXY_TIMEOUT_MARKER = Symbol('openchamberProxyTimedOut');
// A provider OAuth callback blocks upstream for as long as the user takes to
// sign in in their browser (device-code polling, or a loopback redirect), so
// it cannot share the ordinary request deadline. Bounded by the shortest
// upstream expiry we know of — GitHub device codes last ~15 minutes.
const INTERACTIVE_OAUTH_TIMEOUT_MS = 15 * 60 * 1000;
const INTERACTIVE_OAUTH_PATH = /^\/provider\/[^/]+\/oauth\/callback\/?$/;
const isInteractiveOAuthCallback = (req) =>
req.method === 'POST' && INTERACTIVE_OAUTH_PATH.test(req.path);
const isProxyTimeoutError = (error) => {
const code = typeof error?.code === 'string' ? error.code : '';
const message = typeof error?.message === 'string' ? error.message.toLowerCase() : '';
@@ -327,6 +337,10 @@ export const registerOpenCodeProxy = (app, deps) => {
};
const applyProxyResponseDeadline = (req, res, next) => {
if (isInteractiveOAuthCallback(req)) {
return next();
}
const timeout = setTimeout(() => {
req[PROXY_TIMEOUT_MARKER] = true;
if (sendProxyErrorResponse(res, 504)) {
@@ -753,12 +767,12 @@ export const registerOpenCodeProxy = (app, deps) => {
});
// Generic proxy for non-SSE OpenCode API routes.
const apiProxy = createProxyMiddleware({
const createApiProxy = (timeoutMs) => createProxyMiddleware({
target: resolveProxyTarget(),
changeOrigin: true,
pathRewrite: { '^/api': '' },
timeout: PROXY_REQUEST_TIMEOUT_MS,
proxyTimeout: PROXY_REQUEST_TIMEOUT_MS,
timeout: timeoutMs,
proxyTimeout: timeoutMs,
// Dynamic target — port can change after restart
router: () => resolveProxyTarget(),
on: {
@@ -805,6 +819,9 @@ export const registerOpenCodeProxy = (app, deps) => {
},
});
const apiProxy = createApiProxy(PROXY_REQUEST_TIMEOUT_MS);
const interactiveOAuthProxy = createApiProxy(INTERACTIVE_OAUTH_TIMEOUT_MS);
// Best-effort fallback for stale clients still sending symlink paths.
// Settings and project selection normalize at source; this cached async path
// avoids blocking the proxy hot path on every directory-scoped request.
@@ -821,5 +838,6 @@ export const registerOpenCodeProxy = (app, deps) => {
});
app.use('/api', applyProxyResponseDeadline);
app.post('/api/provider/:providerID/oauth/callback', interactiveOAuthProxy);
app.use('/api', apiProxy);
};
@@ -382,6 +382,21 @@ endpoint nothing calls is a maintenance surface that rots untested.
Registered lazily from `feature-routes-runtime.js`. `/api/walkthrough` is in the
JSON body-parser allowlist in `core-routes.js`.
## A server that does not have these routes
An `/api/*` path no OpenChamber route claims reaches the OpenCode proxy, and
OpenCode answers any path it does not know with its embedded web UI — HTML, with
status **200**. So a client newer than the server it is connected to is not told
"no such route"; it is handed a web page. Parsing that as JSON is where
`Unexpected token '<', "<!doctype "...` came from, a message that names neither
the cause nor the remedy.
The client therefore checks the content type before parsing. A non-JSON answer
on 2xx or 404 becomes `server-unsupported`, which the panel renders as "this
server is older than the app, update it". A non-JSON **5xx** keeps its own
failure: a server that answered badly is not a server missing the feature, and
telling someone to upgrade would send them after the wrong thing.
## Runtime availability
Web, desktop, and hosted mobile reach these routes normally. VS Code serves Git
@@ -623,4 +623,87 @@ describe('OpenCode proxy SSE forwarding', () => {
expect(response.status).toBe(504);
await expect(response.json()).resolves.toMatchObject({ error: 'OpenCode upstream timed out' });
});
it('exempts interactive provider OAuth callbacks from the request deadline', async () => {
const upstream = express();
// Stands in for upstream blocking until the user finishes signing in.
upstream.post('/provider/:providerID/oauth/callback', async (_req, res) => {
await new Promise((resolve) => setTimeout(resolve, 250));
res.json(true);
});
upstreamServer = await listen(upstream);
const upstreamPort = upstreamServer.address().port;
const externalBaseUrl = `http://127.0.0.1:${upstreamPort}`;
const app = express();
registerOpenCodeProxy(app, {
fs: {},
os: {},
path,
OPEN_CODE_READY_GRACE_MS: 0,
LONG_REQUEST_TIMEOUT_MS: 50,
getRuntime: () => ({
openCodePort: upstreamPort,
openCodeBaseUrl: externalBaseUrl,
isOpenCodeReady: true,
openCodeNotReadySince: 0,
isRestartingOpenCode: false,
}),
getOpenCodeAuthHeaders: () => ({}),
buildOpenCodeUrl: (requestPath) => `${externalBaseUrl}${requestPath}`,
ensureOpenCodeApiPrefix: () => {},
});
proxyServer = await listen(app);
const proxyPort = proxyServer.address().port;
const response = await fetch(`http://127.0.0.1:${proxyPort}/api/provider/github-copilot/oauth/callback`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ method: 0 }),
signal: AbortSignal.timeout(5000),
});
expect(response.status).toBe(200);
await expect(response.json()).resolves.toBe(true);
});
it('still applies the request deadline to the OAuth authorize call', async () => {
const upstream = express();
upstream.post('/provider/:providerID/oauth/authorize', (_req, _res) => {
// Leave the response open so the proxy timeout path is exercised.
});
upstreamServer = await listen(upstream);
const upstreamPort = upstreamServer.address().port;
const externalBaseUrl = `http://127.0.0.1:${upstreamPort}`;
const app = express();
registerOpenCodeProxy(app, {
fs: {},
os: {},
path,
OPEN_CODE_READY_GRACE_MS: 0,
LONG_REQUEST_TIMEOUT_MS: 50,
getRuntime: () => ({
openCodePort: upstreamPort,
openCodeBaseUrl: externalBaseUrl,
isOpenCodeReady: true,
openCodeNotReadySince: 0,
isRestartingOpenCode: false,
}),
getOpenCodeAuthHeaders: () => ({}),
buildOpenCodeUrl: (requestPath) => `${externalBaseUrl}${requestPath}`,
ensureOpenCodeApiPrefix: () => {},
});
proxyServer = await listen(app);
const proxyPort = proxyServer.address().port;
const response = await fetch(`http://127.0.0.1:${proxyPort}/api/provider/github-copilot/oauth/authorize`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ method: 0 }),
signal: AbortSignal.timeout(2000),
});
expect(response.status).toBe(504);
});
});