From d24ff57bc4dd53afbbb1d2c972266e6d89ea5c24 Mon Sep 17 00:00:00 2001 From: lidge-jun Date: Tue, 8 Sep 2026 17:44:26 +0900 Subject: [PATCH 1/2] release: set main channel version 2.48.0 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 7d94d23cab..6547da6552 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@bitkyc08/opencodex", - "version": "2.47.0", + "version": "2.48.0", "description": "Universal provider proxy for OpenAI Codex & Claude Code — use any LLM with Codex CLI/App/SDK and Claude Code", "type": "module", "main": "./bin/package-main.mjs", From d4b1f87a11f9b5f2bf8b76c01d01bf527f451b33 Mon Sep 17 00:00:00 2001 From: luvs01 Date: Mon, 14 Sep 2026 10:34:35 +0900 Subject: [PATCH 2/2] fix(remote): require paired sessions for mutations --- .../content/docs/guides/remote-workspace.md | 11 +++++--- .../content/docs/reference/management-api.md | 10 +++---- src/server/management-api.ts | 7 +++-- src/server/management-auth.ts | 28 +++++++++++++------ .../management/remote-workspace-routes.ts | 4 +-- structure/remote-workspace.md | 2 +- .../remote-workspace-management.test.ts | 23 +++++++++++++-- tests/server/server-management-auth.test.ts | 7 +++++ 8 files changed, 68 insertions(+), 24 deletions(-) diff --git a/docs-site/src/content/docs/guides/remote-workspace.md b/docs-site/src/content/docs/guides/remote-workspace.md index 4d31a6dee0..e477f46473 100644 --- a/docs-site/src/content/docs/guides/remote-workspace.md +++ b/docs-site/src/content/docs/guides/remote-workspace.md @@ -51,10 +51,13 @@ using the feature; do not configure both the legacy sandbox and a permission pro ## Pair an Executor -1. Open **Remote Workspace** in the Hub dashboard. -2. Select **Create pairing code**. -3. On Computer 2, change into the project directory you want to expose. -4. Copy the generated **Linux / macOS terminal** or **Windows PowerShell** command for that computer. +1. Pair the browser with the Hub through the dashboard pairing panel. Run the displayed + `ocx gui pair --origin` command on the Hub and enter its one-time code; an automatically + bootstrapped local or Tailscale session may view status but cannot control Remote Workspace. +2. Open **Remote Workspace** in that paired Hub dashboard. +3. Select **Create pairing code**. +4. On Computer 2, change into the project directory you want to expose. +5. Copy the generated **Linux / macOS terminal** or **Windows PowerShell** command for that computer. It pairs the current directory and keeps `ocx remote-workspace agent` connected in that terminal. diff --git a/docs-site/src/content/docs/reference/management-api.md b/docs-site/src/content/docs/reference/management-api.md index b973add5d9..3a7612aed2 100644 --- a/docs-site/src/content/docs/reference/management-api.md +++ b/docs-site/src/content/docs/reference/management-api.md @@ -153,12 +153,12 @@ readable; mutations refuse without initializing workspace services. | Method and path | Purpose | Notable errors | | --- | --- | --- | | `GET /api/remote-workspace` | Read paired computers, current capabilities, Hub runtimes, and session snapshots | Disabled status when Hub role or explicit opt-in is absent | -| `POST /api/remote-workspace/pairing` | Create a ten-minute one-use Executor enrollment code | GUI session only; 429 pairing capacity | +| `POST /api/remote-workspace/pairing` | Create a ten-minute one-use Executor enrollment code | Operator-paired GUI session only; 429 pairing capacity | | `GET /api/remote-workspace/runtimes` | Read Codex, Claude Code, and Pi availability on the Hub | — | -| `GET, POST /api/remote-workspace/sessions` | List sessions or start one bound to a device, root, runtime, and access mode | POST is GUI session only; 409 offline/unavailable/invalid target | -| `POST /api/remote-workspace/sessions/{id}/prompt` | Continue the bound model session | GUI session only; 409 active turn, offline Executor, or resume failure | -| `DELETE /api/remote-workspace/sessions/{id}` | Stop the model runtime and encrypted Executor session | GUI session only; 404 unknown session | -| `DELETE /api/remote-workspace/devices/{id}` | Revoke one computer and stop its sessions | GUI session only; 404 unknown device | +| `GET, POST /api/remote-workspace/sessions` | List sessions or start one bound to a device, root, runtime, and access mode | POST requires an operator-paired GUI session; 409 offline/unavailable/invalid target | +| `POST /api/remote-workspace/sessions/{id}/prompt` | Continue the bound model session | Operator-paired GUI session only; 409 active turn, offline Executor, or resume failure | +| `DELETE /api/remote-workspace/sessions/{id}` | Stop the model runtime and encrypted Executor session | Operator-paired GUI session only; 404 unknown session | +| `DELETE /api/remote-workspace/devices/{id}` | Revoke one computer and stop its sessions | Operator-paired GUI session only; 404 unknown device | Executor enrollment exchanges a one-use code at `POST /remote-workspace/pair` and then opens `/remote-workspace/agent` as a bearer-authenticated outbound WebSocket. Those two machine endpoints diff --git a/src/server/management-api.ts b/src/server/management-api.ts index 4dc2a16c77..12d9b9e224 100644 --- a/src/server/management-api.ts +++ b/src/server/management-api.ts @@ -157,8 +157,11 @@ async function handleRemoteWorkspaceRoutesOnDemand(ctx: ManagementContext): Prom status: ctx.req.method === "GET" ? 200 : 404, headers: { "cache-control": "no-store" }, }); } - if (ctx.req.method !== "GET" && ctx.principal !== "gui-session") { - return Response.json({ error: "A dashboard session is required for Remote Workspace changes." }, { status: 403 }); + if (ctx.req.method !== "GET" && ( + ctx.principal !== "gui-session" + || ctx.sessionControl?.isPaired(ctx.req, ctx.config) !== true + )) { + return Response.json({ error: "A paired dashboard session is required for Remote Workspace changes." }, { status: 403 }); } const { handleRemoteWorkspaceRoutes } = await import("./management/remote-workspace-routes"); return handleRemoteWorkspaceRoutes(ctx); diff --git a/src/server/management-auth.ts b/src/server/management-auth.ts index 58ae3f7d60..0176c94e06 100644 --- a/src/server/management-auth.ts +++ b/src/server/management-auth.ts @@ -253,23 +253,35 @@ export interface ManagementSessionControl { revokeCurrent(req: Request): boolean; /** Revalidate a long-lived request against current authority, without cached admission or renewal. */ isCurrent(req: Request, config: OcxConfig): boolean; + /** Prove that the current browser session came from the operator-mediated pairing flow. */ + isPaired(req: Request, config: OcxConfig): boolean; } export function createManagementSessionControl(state: ManagementAuthState): ManagementSessionControl { + function currentSession(req: Request, config: OcxConfig): GuiSessionRecord | null { + if (!state.available) return null; + const adminToken = state.token; + const credential = requestManagementCredential(req); + if (!credential || equalSecret(credential, adminToken)) return null; + const session = state.sessions.get(credential); + if (!session) return null; + // Reuse the full origin/expiry/CSRF predicate against the current record, but + // isolate its sliding-expiry mutation: authority checks are not browser activity. + return authorizeGuiSessionRequest(req, config, { + sessions: new Map([[credential, { ...session }]]), + pairingGrants: state.pairingGrants, + }).ok ? session : null; + } return { isCurrent(req: Request, config: OcxConfig): boolean { if (!state.available) return false; const credential = requestManagementCredential(req); if (!credential) return false; if (equalSecret(credential, state.token)) return true; - const session = state.sessions.get(credential); - if (!session) return false; - // Reuse the full origin/expiry/CSRF predicate against the current record, but - // isolate its sliding-expiry mutation: SSE heartbeats are not browser activity. - return authorizeGuiSessionRequest(req, config, { - sessions: new Map([[credential, { ...session }]]), - pairingGrants: state.pairingGrants, - }).ok; + return currentSession(req, config) !== null; + }, + isPaired(req: Request, config: OcxConfig): boolean { + return currentSession(req, config)?.issuance === "pairing"; }, revokeCurrent(req: Request): boolean { if (!state.available) return false; diff --git a/src/server/management/remote-workspace-routes.ts b/src/server/management/remote-workspace-routes.ts index f94ab3dda1..8abedea8d9 100644 --- a/src/server/management/remote-workspace-routes.ts +++ b/src/server/management/remote-workspace-routes.ts @@ -10,9 +10,9 @@ function response(body: unknown, status = 200): Response { } function sessionOnly(ctx: ManagementContext): Response | null { - return ctx.principal === "gui-session" + return ctx.principal === "gui-session" && ctx.sessionControl?.isPaired(ctx.req, ctx.config) === true ? null - : response({ error: "A dashboard session is required for Remote Workspace changes." }, 403); + : response({ error: "A paired dashboard session is required for Remote Workspace changes." }, 403); } async function jsonObject(req: Request): Promise> { diff --git a/structure/remote-workspace.md b/structure/remote-workspace.md index 5f5d05a507..40e92ff39e 100644 --- a/structure/remote-workspace.md +++ b/structure/remote-workspace.md @@ -16,7 +16,7 @@ The optional terminal prototype in `src/remote-control/host.ts` invokes only a c Regression coverage lives in `tests/clients/remote-workspace-session-binding.test.ts`, `tests/clients/remote-workspace-secret-store.test.ts` and the adjacent protocol, agent-wire, device, hub, sessions and command-runner tests. Real CLI and native confinement tests require their explicit environments; generic suite success does not certify those paths. Windows command support remains unavailable pending a verified lifecycle owner. -`src/server/index.ts` admits the opt-in pair exchange and bearer-authenticated agent upgrade after Origin and role checks. The unauthenticated loopback companion does not expose either endpoint. `src/server/management-api.ts` answers disabled workspace status before importing services; mutations require a dashboard session. `src/server/management/remote-workspace-routes.ts` reads bounded management JSON and uses the initialized Hub/session services. +`src/server/index.ts` admits the opt-in pair exchange and bearer-authenticated agent upgrade after Origin and role checks. The unauthenticated loopback companion does not expose either endpoint. `src/server/management-api.ts` answers disabled workspace status before importing services; mutations require a dashboard session issued through the operator-mediated GUI pairing flow. Sessions bootstrapped from an unauthenticated loopback page or inferred Tailscale identity may read status but cannot create grants, control devices, start sessions, or submit prompts. `src/server/management/remote-workspace-routes.ts` reads bounded management JSON and uses the initialized Hub/session services. The listener retains an awaited shutdown callback only after optional activation. It refuses initialization once stop begins, starts listener admission closure and workspace cleanup concurrently, and awaits session shutdown before closing Hub connections in a finally path. Listener drain completes after these owned sockets close; cleanup failures still propagate. `src/server/ws-bridge.ts` carries structural receive/open/close callbacks without importing concrete workspace services. diff --git a/tests/clients/remote-workspace-management.test.ts b/tests/clients/remote-workspace-management.test.ts index 5b911551ac..70c1c5d423 100644 --- a/tests/clients/remote-workspace-management.test.ts +++ b/tests/clients/remote-workspace-management.test.ts @@ -35,6 +35,7 @@ async function call( principal: "admin-token" | "gui-session" = "gui-session", body?: unknown, sessions: RemoteWorkspaceSessionService = emptySessions, + paired = principal === "gui-session", ): Promise<{ status: number; body: Record }> { const url = new URL(`http://127.0.0.1:10100${path}`); const req = new Request(url, { @@ -45,7 +46,11 @@ async function call( const response = await handleManagementAPI(req, url, config, { remoteWorkspaceHub: hub, remoteWorkspaceSessions: sessions, - }, principal); + }, principal, { + isCurrent: () => principal === "gui-session", + isPaired: () => paired, + revokeCurrent: () => false, + }); if (!response) throw new Error("Remote Workspace management route was not mounted"); return { status: response.status, body: await response.json() as Record }; } @@ -62,7 +67,7 @@ const emptySessions = { } as unknown as RemoteWorkspaceSessionService; describe("Remote Workspace management routes", () => { - test("lists devices and requires a GUI consent session to create enrollment grants", async () => { + test("lists devices and requires an operator-paired GUI session to create enrollment grants", async () => { const hub = new RemoteWorkspaceHub(new MemoryStore()); const initial = await call(hubConfig, hub, "GET", "/api/remote-workspace", "admin-token"); expect(initial).toEqual({ @@ -80,6 +85,20 @@ describe("Remote Workspace management routes", () => { }); const denied = await call(hubConfig, hub, "POST", "/api/remote-workspace/pairing", "admin-token"); expect(denied.status).toBe(403); + const bootstrapSession = await call( + hubConfig, + hub, + "POST", + "/api/remote-workspace/pairing", + "gui-session", + undefined, + emptySessions, + false, + ); + expect(bootstrapSession).toEqual({ + status: 403, + body: { error: "A paired dashboard session is required for Remote Workspace changes." }, + }); const created = await call(hubConfig, hub, "POST", "/api/remote-workspace/pairing"); expect(created.status).toBe(201); expect(created.body.code).toMatch(/^[A-Z2-9]{4}-[A-Z2-9]{4}-[A-Z2-9]{4}$/); diff --git a/tests/server/server-management-auth.test.ts b/tests/server/server-management-auth.test.ts index 2e5f3db061..cdd9ac9613 100644 --- a/tests/server/server-management-auth.test.ts +++ b/tests/server/server-management-auth.test.ts @@ -15,6 +15,7 @@ import type { OcxConfig } from "../../src/types"; import { serveGuiFile, serveSessionBootstrap } from "../../src/server/gui-static"; import { isProxyAdmissionSecret } from "../../src/server/auth-cors"; import { + createManagementSessionControl, initializeManagementAuthState, issueGuiSession, managementPrincipal, @@ -1517,6 +1518,12 @@ describe("management and data-plane credential separation", () => { expect(session.expiresAt).toBe(before); expect(authorizeGuiSessionRequest(request({}, "POST"), config, state, issuedAt + 5)).toMatchObject({ ok: true, principal: "gui-session" }); expect(session.expiresAt).toBe(issuedAt + 5 + REMOTE_GUI_SESSION_TTL_MS); + const sessionControl = createManagementSessionControl(state); + expect(sessionControl.isPaired(request({}, "POST"), config)).toBe(true); + const storedSession = state.sessions.get(session.token)!; + storedSession.issuance = "tailscale-identity"; + expect(sessionControl.isPaired(request({}, "POST"), config)).toBe(false); + storedSession.issuance = "pairing"; session.expiresAt = issuedAt + 6; expect(authorizeGuiSessionRequest(request(), config, state, issuedAt + 7)).toMatchObject({ ok: false, reason: "expired" }); expect(state.sessions.has(session.token)).toBe(false);