Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
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
11 changes: 7 additions & 4 deletions docs-site/src/content/docs/guides/remote-workspace.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
10 changes: 5 additions & 5 deletions docs-site/src/content/docs/reference/management-api.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
7 changes: 5 additions & 2 deletions src/server/management-api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
)) {
Comment on lines +160 to +163

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Provide a pairing path for automatically bootstrapped dashboards

When the Hub dashboard is opened directly, the page is automatically given a loopback or tailscale-identity session, but gui/src/App.tsx renders ConnectPairingForm only for a connected-client topology and only while no shared session exists. This new check therefore makes every Remote Workspace mutation return 403 without exposing any UI path to obtain the required pairing-issued session; even a manually paired session is replaced by an automatic one after reloading the Hub page. Add an in-dashboard upgrade path for these read-only sessions before enforcing this gate.

Useful? React with 👍 / 👎.

return Response.json({ error: "A paired dashboard session is required for Remote Workspace changes." }, { status: 403 });

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Update the activation test for the new error response

For an enabled workspace mutation authorized as admin-token, this branch now returns the new “paired dashboard session” error, while tests/clients/remote-workspace-activation.test.ts still asserts the previous exact JSON string. That test will deterministically fail in the normal test suite, so its expected response must be updated with this behavior change.

Useful? React with 👍 / 👎.

}
const { handleRemoteWorkspaceRoutes } = await import("./management/remote-workspace-routes");
return handleRemoteWorkspaceRoutes(ctx);
Expand Down
28 changes: 20 additions & 8 deletions src/server/management-auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
4 changes: 2 additions & 2 deletions src/server/management/remote-workspace-routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<Record<string, unknown>> {
Expand Down
2 changes: 1 addition & 1 deletion structure/remote-workspace.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
23 changes: 21 additions & 2 deletions tests/clients/remote-workspace-management.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, unknown> }> {
const url = new URL(`http://127.0.0.1:10100${path}`);
const req = new Request(url, {
Expand All @@ -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<string, unknown> };
}
Expand All @@ -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({
Expand All @@ -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}$/);
Expand Down
7 changes: 7 additions & 0 deletions tests/server/server-management-auth.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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);
Expand Down
Loading