Merge branch 'openchamber:main' into fix/walkthrough-remote-default-branch
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user