From e530150e43fe40a755e1242494e136645b2b9986 Mon Sep 17 00:00:00 2001 From: Hare Date: Sun, 2 Aug 2026 00:26:28 +0900 Subject: [PATCH] auth: bootstrap legacy workspace ownership --- crates/workspace-server/src/server.rs | 13 +- crates/workspace-server/src/store.rs | 221 ++++++++++++++++++ ...tored-worker-workspace-credential-stale.md | 28 +++ .../console/worker-console.ui.test.ts | 8 + .../src/lib/workspace/multiplexer.ts | 57 +++-- .../workers/[workerId]/console/+page.svelte | 24 +- 6 files changed, 330 insertions(+), 21 deletions(-) create mode 100644 docs/report/2026-07-30-restored-worker-workspace-credential-stale.md diff --git a/crates/workspace-server/src/server.rs b/crates/workspace-server/src/server.rs index 3d74536d..e0b77d05 100644 --- a/crates/workspace-server/src/server.rs +++ b/crates/workspace-server/src/server.rs @@ -5150,8 +5150,9 @@ async fn post_passkey_registration_complete( ) })?; let credential_id = passkey_credential_id(&passkey)?; - api.store - .upsert_passkey_credential(&PasskeyCredentialRecord { + let registered_at = crate::auth::now_rfc3339(); + api.store.upsert_passkey_and_claim_legacy_workspace_owner( + &PasskeyCredentialRecord { credential_id, user_id: user.user_id.clone(), public_key_cose: serde_json::to_string(&passkey).map_err(|error| { @@ -5159,9 +5160,13 @@ async fn post_passkey_registration_complete( })?, transports_json: None, sign_count: 0, - created_at: crate::auth::now_rfc3339(), + created_at: registered_at.clone(), last_used_at: None, - })?; + }, + api.workspace_id(), + &user.account_id, + ®istered_at, + )?; issue_browser_session_response(&api, user) } diff --git a/crates/workspace-server/src/store.rs b/crates/workspace-server/src/store.rs index aaa9bc9b..0623e28b 100644 --- a/crates/workspace-server/src/store.rs +++ b/crates/workspace-server/src/store.rs @@ -504,6 +504,16 @@ pub trait ControlPlaneStore: Send + Sync { fn get_user_by_handle(&self, handle: &str) -> Result>; fn any_user(&self) -> Result>; fn upsert_passkey_credential(&self, record: &PasskeyCredentialRecord) -> Result<()>; + /// Temporary migration bridge for legacy local databases that predate + /// Workspace ownership. The first verified passkey atomically claims an + /// ownerless Workspace; remove after supported databases have owners. + fn upsert_passkey_and_claim_legacy_workspace_owner( + &self, + record: &PasskeyCredentialRecord, + workspace_id: &str, + account_id: &str, + updated_at: &str, + ) -> Result; fn get_passkey_credential( &self, credential_id: &str, @@ -1453,6 +1463,52 @@ impl ControlPlaneStore for SqliteWorkspaceStore { }) } + fn upsert_passkey_and_claim_legacy_workspace_owner( + &self, + record: &PasskeyCredentialRecord, + workspace_id: &str, + account_id: &str, + updated_at: &str, + ) -> Result { + self.with_conn(|conn| { + let tx = conn.unchecked_transaction()?; + let verified_passkeys = tx.query_row( + "SELECT COUNT(*) FROM passkey_credentials", + [], + |row| row.get::<_, i64>(0), + )?; + tx.execute( + r#"INSERT INTO passkey_credentials (credential_id, user_id, public_key_cose, transports_json, sign_count, created_at, last_used_at) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7) + ON CONFLICT(credential_id) DO UPDATE SET + public_key_cose = excluded.public_key_cose, + transports_json = excluded.transports_json, + sign_count = excluded.sign_count, + last_used_at = excluded.last_used_at"#, + params![record.credential_id, record.user_id, record.public_key_cose, record.transports_json, record.sign_count, record.created_at, record.last_used_at], + )?; + let claimed = if verified_passkeys == 0 { + tx.execute( + r#"UPDATE workspaces + SET owner_account_id = ?2, updated_at = ?3 + WHERE workspace_id = ?1 + AND owner_account_id IS NULL + AND EXISTS ( + SELECT 1 FROM users + WHERE user_id = ?4 + AND account_id = ?2 + AND state = 'active' + )"#, + params![workspace_id, account_id, updated_at, record.user_id], + )? == 1 + } else { + false + }; + tx.commit()?; + Ok(claimed) + }) + } + fn get_passkey_credential( &self, credential_id: &str, @@ -5249,6 +5305,171 @@ CREATE TABLE ticket_assignment_operations ( ); } + #[tokio::test] + async fn first_verified_passkey_claims_legacy_ownerless_workspace_once() { + let store = SqliteWorkspaceStore::in_memory().unwrap(); + let now = "2026-07-30T00:00:00Z".to_string(); + store + .upsert_workspace(&WorkspaceRecord { + workspace_id: "legacy-local".to_string(), + owner_account_id: None, + display_name: "Legacy local".to_string(), + state: "active".to_string(), + created_at: now.clone(), + updated_at: now.clone(), + }) + .await + .unwrap(); + + for suffix in ["first", "second"] { + store + .upsert_account(&AccountRecord { + account_id: format!("account-{suffix}"), + kind: "user".to_string(), + handle: suffix.to_string(), + display_name: suffix.to_string(), + created_at: now.clone(), + updated_at: now.clone(), + }) + .unwrap(); + store + .upsert_user(&UserRecord { + user_id: format!("user-{suffix}"), + account_id: format!("account-{suffix}"), + handle: suffix.to_string(), + display_name: suffix.to_string(), + created_at: now.clone(), + updated_at: now.clone(), + }) + .unwrap(); + } + + let first = PasskeyCredentialRecord { + credential_id: "credential-first".to_string(), + user_id: "user-first".to_string(), + public_key_cose: "public-key-first".to_string(), + transports_json: None, + sign_count: 0, + created_at: now.clone(), + last_used_at: None, + }; + assert!( + store + .upsert_passkey_and_claim_legacy_workspace_owner( + &first, + "legacy-local", + "account-first", + &now, + ) + .unwrap() + ); + + let second = PasskeyCredentialRecord { + credential_id: "credential-second".to_string(), + user_id: "user-second".to_string(), + public_key_cose: "public-key-second".to_string(), + transports_json: None, + sign_count: 0, + created_at: now.clone(), + last_used_at: None, + }; + assert!( + !store + .upsert_passkey_and_claim_legacy_workspace_owner( + &second, + "legacy-local", + "account-second", + &now, + ) + .unwrap() + ); + assert_eq!( + store + .get_workspace("legacy-local") + .await + .unwrap() + .unwrap() + .owner_account_id + .as_deref(), + Some("account-first") + ); + } + + #[tokio::test] + async fn first_verified_passkey_preserves_existing_workspace_owner() { + let store = SqliteWorkspaceStore::in_memory().unwrap(); + let now = "2026-07-30T00:00:00Z".to_string(); + store + .upsert_account(&AccountRecord { + account_id: "account-owner".to_string(), + kind: "user".to_string(), + handle: "owner".to_string(), + display_name: "Owner".to_string(), + created_at: now.clone(), + updated_at: now.clone(), + }) + .unwrap(); + store + .upsert_workspace(&WorkspaceRecord { + workspace_id: "owned".to_string(), + owner_account_id: Some("account-owner".to_string()), + display_name: "Owned".to_string(), + state: "active".to_string(), + created_at: now.clone(), + updated_at: now.clone(), + }) + .await + .unwrap(); + store + .upsert_account(&AccountRecord { + account_id: "account-first".to_string(), + kind: "user".to_string(), + handle: "first".to_string(), + display_name: "First".to_string(), + created_at: now.clone(), + updated_at: now.clone(), + }) + .unwrap(); + store + .upsert_user(&UserRecord { + user_id: "user-first".to_string(), + account_id: "account-first".to_string(), + handle: "first".to_string(), + display_name: "First".to_string(), + created_at: now.clone(), + updated_at: now.clone(), + }) + .unwrap(); + assert!( + !store + .upsert_passkey_and_claim_legacy_workspace_owner( + &PasskeyCredentialRecord { + credential_id: "credential-first".to_string(), + user_id: "user-first".to_string(), + public_key_cose: "public-key-first".to_string(), + transports_json: None, + sign_count: 0, + created_at: now.clone(), + last_used_at: None, + }, + "owned", + "account-first", + &now, + ) + .unwrap() + ); + assert_eq!( + store + .get_workspace("owned") + .await + .unwrap() + .unwrap() + .owner_account_id + .as_deref(), + Some("account-owner") + ); + } + #[tokio::test] async fn account_and_login_records_round_trip() { let store = SqliteWorkspaceStore::in_memory().unwrap(); diff --git a/docs/report/2026-07-30-restored-worker-workspace-credential-stale.md b/docs/report/2026-07-30-restored-worker-workspace-credential-stale.md new file mode 100644 index 00000000..13ef5845 --- /dev/null +++ b/docs/report/2026-07-30-restored-worker-workspace-credential-stale.md @@ -0,0 +1,28 @@ +# Restored Worker retained unusable Workspace credential after Backend restart + +## Observed + +After restarting the Runtime and Server, Worker 30 restored and continued executing normal turns, but every typed Ticket operation failed with: + +```text +Worker Workspace authentication failed: missing Runtime Workspace credential +``` + +The Server control-plane DB contained a current active `worker_workspace_credentials` row for the same Workspace, Runtime, and Worker identity, while the restored Worker/tool request did not authenticate with it. The failure prevented the required ticket-first workflow for an auth/storage regression even though the Worker itself remained live. + +## Impact + +- Restore can appear healthy because model turns still execute while Workspace-authority tools are unusable. +- A Worker cannot report or ticket the restore regression through the intended typed authority. +- The failure is easy to misattribute to the Browser multiplexer; in this incident Browser authentication/bootstrap was a separate issue. + +## Suggested investigation + +Trace the credential lifecycle across Backend restart and Runtime Worker restore: + +1. whether Backend rotates or recreates the credential record; +2. whether restored Worker execution receives the current plaintext credential rather than retaining an old environment/config bundle; +3. whether credential binding should remain stable across Backend restart or be explicitly refreshed before marking the Worker restored; +4. whether restore health should include a bounded Workspace API authentication probe. + +Do not treat a current DB credential row alone as proof that the restored Worker possesses it. diff --git a/web/workspace/src/lib/workspace/console/worker-console.ui.test.ts b/web/workspace/src/lib/workspace/console/worker-console.ui.test.ts index 9ad47a8c..07053be0 100644 --- a/web/workspace/src/lib/workspace/console/worker-console.ui.test.ts +++ b/web/workspace/src/lib/workspace/console/worker-console.ui.test.ts @@ -731,4 +731,12 @@ Deno.test("Workspace Worker list and Console share the multiplexed connection", multiplexer.includes("frame: 'worker_protocol'"), "Sidebar and Console should share one Workspace multiplexer and route Worker methods through a subscription lane", ); + assert( + multiplexer.includes("loadWhoami") && + multiplexer.includes("actor === null") && + multiplexer.includes("AUTH_RECHECK_DELAY_MS") && + multiplexer.includes("Register or sign in on the Account page") && + consolePage.includes("workspace_subscription_auth_required"), + "Workspace multiplexer should replace a tight unauthenticated WebSocket loop with an actionable auth status and bounded recheck", + ); }); diff --git a/web/workspace/src/lib/workspace/multiplexer.ts b/web/workspace/src/lib/workspace/multiplexer.ts index a748476f..2d011863 100644 --- a/web/workspace/src/lib/workspace/multiplexer.ts +++ b/web/workspace/src/lib/workspace/multiplexer.ts @@ -6,6 +6,7 @@ import type { SubscriptionId, } from '$lib/generated/protocol'; import { workspaceApiPath } from '$lib/workspace/api/http'; +import { loadWhoami } from '$lib/workspace/auth/api'; type Listener = { onFrame(frame: SubscriptionFrame): void; @@ -26,6 +27,9 @@ export type WorkspaceMultiplexerSubscription = { }; const multiplexers = new Map(); +const RECONNECT_DELAY_MS = 500; +const AUTH_RECHECK_DELAY_MS = 5_000; +const AUTH_REQUIRED_MESSAGE = 'Authentication required. Register or sign in on the Account page.'; export function workspaceMultiplexer(workspaceId: string): WorkspaceMultiplexer { let multiplexer = multiplexers.get(workspaceId); @@ -89,20 +93,45 @@ export class WorkspaceMultiplexer { }); socket.addEventListener('message', (event) => this.#receive(String(event.data))); socket.addEventListener('error', () => socket.close()); - socket.addEventListener('close', () => { - if (this.#socket !== socket) return; - this.#socket = null; - this.#requests.clear(); - this.#runtimeSubscriptions.clear(); - for (const subscription of this.#subscriptions.values()) { - subscription.requestId = null; - subscription.subscriptionId = null; - subscription.listener.onStatus?.('closed', 'Workspace subscription disconnected'); - } - if (!this.#closed && this.#subscriptions.size > 0) { - this.#reconnectTimer = setTimeout(() => this.#ensureConnected(), 500); - } - }); + socket.addEventListener('close', () => void this.#handleSocketClose(socket)); + } + + async #handleSocketClose(socket: WebSocket): Promise { + if (this.#socket !== socket) return; + this.#socket = null; + this.#requests.clear(); + this.#runtimeSubscriptions.clear(); + for (const subscription of this.#subscriptions.values()) { + subscription.requestId = null; + subscription.subscriptionId = null; + } + if (this.#closed || this.#subscriptions.size === 0) return; + + let authenticationRequired = false; + try { + authenticationRequired = (await loadWhoami()).actor === null; + } catch { + // A failed auth probe is treated as a transient network/backend failure. + } + if (this.#closed || this.#subscriptions.size === 0 || this.#socket) return; + + const message = authenticationRequired + ? AUTH_REQUIRED_MESSAGE + : 'Workspace subscription disconnected'; + for (const subscription of this.#subscriptions.values()) { + subscription.listener.onStatus?.('closed', message); + } + this.#scheduleReconnect( + authenticationRequired ? AUTH_RECHECK_DELAY_MS : RECONNECT_DELAY_MS, + ); + } + + #scheduleReconnect(delayMs: number): void { + if (this.#reconnectTimer) clearTimeout(this.#reconnectTimer); + this.#reconnectTimer = setTimeout(() => { + this.#reconnectTimer = null; + this.#ensureConnected(); + }, delayMs); } #sendSubscribe(subscription: ActiveSubscription): void { diff --git a/web/workspace/src/routes/w/[workspaceId]/runtimes/[runtimeId]/workers/[workerId]/console/+page.svelte b/web/workspace/src/routes/w/[workspaceId]/runtimes/[runtimeId]/workers/[workerId]/console/+page.svelte index dcd21196..20249cb7 100644 --- a/web/workspace/src/routes/w/[workspaceId]/runtimes/[runtimeId]/workers/[workerId]/console/+page.svelte +++ b/web/workspace/src/routes/w/[workspaceId]/runtimes/[runtimeId]/workers/[workerId]/console/+page.svelte @@ -564,11 +564,29 @@ ]; } }, - onStatus: (status) => { + onStatus: (status, message) => { if (token !== reloadToken) return; - protocolState = status === "open" ? "connecting" : status; + const authenticationRequired = message?.startsWith("Authentication required") ?? false; + protocolState = authenticationRequired + ? "error" + : status === "open" + ? "connecting" + : status; + if (authenticationRequired && message) { + streamDiagnostics = [ + ...streamDiagnostics.filter( + (diagnostic) => + diagnostic.code !== "workspace_subscription_auth_required", + ), + { + code: "workspace_subscription_auth_required", + severity: "error", + message, + }, + ]; + } if (status === "closed") { - rejectPendingCompletion(new Error("Worker protocol WebSocket closed.")); + rejectPendingCompletion(new Error(message ?? "Worker protocol WebSocket closed.")); } }, },