Skip to content

fix(desktop): embedded UI auto-login against a joined brain + isolate the store in desktop tests - #134

Merged
pyramation merged 1 commit into
mainfrom
fix/desktop-remote-brain-auth
Sep 21, 2026
Merged

pyramation merged 1 commit into
mainfrom
fix/desktop-remote-brain-auth

Conversation

@pyramation

Copy link
Copy Markdown
Contributor

Summary

Upstreams the fixes found bringing up the Grace receiver laptop (receiver-only mode against wss://hipzap.com), plus two small follow-ups the brief suggested.

1. Auto-login never worked against a remote brain. operatorToken() minted the embedded-UI JWT with a sid from a session created in the laptop's store; the remote brain checks sid against its store, so the token was always rejected ("Session expired or revoked") and the operator got the login screen even with matching jwtSecret. Fix: in receiver-only mode (receiver.server set) mint a sid-less token and skip the phantom local session — both /api/me and the WS upgrade guard the session lookup with if (payload.sid), so it authenticates on signature alone.

if (store.getProjectConfig(project)?.receiver?.server) {
  return signJwt(account.username, { role: account.role, ttlSec: TTL_MS / 1000 });
}

Trade-off: that token can't be revoked from Access → Sessions; only the 12h TTL bounds it.

2. Desktop tests wrote to the developer's real ~/.wavegrid. openStore() without APPSTASH_BASE_DIR is the real store; a jest run created desk-auth/no-users/joined-brain projects and left joined-brain active — repointing the show laptop at a 6-cannon layout with a key the brain rejects. Now packages/desktop/jest.setup.js (jest setupFiles) pins APPSTASH_BASE_DIR to a temp dir for every desktop suite, and the two affected suites also pin it locally.

3. deleteProject orphaned secrets. It removed data/projects/<name> but left config/secrets/<name>.json, so a later project with the same name silently inherited the old jwtSecret/receiverKey. Now removed alongside (best-effort), with a test.

Not verified: the embedded BrowserView rendering without a login prompt in the running Electron app — the brief's author probed the live brain's /api/me with a sid-less token (ok) vs. a local-sid token (rejected), and unit tests cover the token shape. Desktop: tsc clean, 99/99 jest; settings 85/85.

Link to Devin session: https://app.devin.ai/sessions/c4872c2982734093bdee80d04a7d5ceb
Open in Devin Desktop: https://app.devin.ai/desktop/session/c4872c2982734093bdee80d04a7d5ceb?variant=devin
Requested by: @pyramation

…olate the store in desktop tests

operatorToken embedded a sid from the local session store, which a
remote brain validates against its own store — always rejected, so the
artist UI showed a login screen even with imported secrets. Mint a
sid-less token in receiver-only mode (TTL-bounded, not revocable).

Desktop tests now run against a temp APPSTASH_BASE_DIR (jest setupFiles)
instead of the developer's real ~/.wavegrid. deleteProject also removes
the project's secrets file.

Co-authored-by: Dan Lynch <pyramation@gmail.com>
@devin-ai-integration

Copy link
Copy Markdown
Contributor

I'll fix CI failures and address comments from users with write access. I'll skip comments containing "(aside)".

  • Disable automatic comment, CI, and merge conflict monitoring

@tenki-reviewer

tenki-reviewer Bot commented Sep 21, 2026 •

Copy link
Copy Markdown

Review complete. 🟡 2 medium

💬 Inline comments (2)


This PR adds a receiver-mode branch in the desktop operator-session so an operator embedded on a joined remote brain can obtain a role: admin JWT with no session id, and extends the settings store's deleteProject to also remove the per-project secrets file. Test isolation is introduced via jest.setup.js pointing the shared settings store at a temp APPSTASH_BASE_DIR, with new tests covering receiver-env token behavior, the sid-less flow, and secrets cleanup on deletion.

Core finding 1: the sid-less receiver-mode admin token (packages/desktop/src/main/operator-session.ts) is accepted by the brain's WebSocket upgrade by signature alone and cannot be revoked or invalidated by session revocation or account demotion, staying valid for the full 12h TTL. Core finding 2: the new secret purge in packages/settings/src/projects.ts runs inside the same catch {} after directory removal, so a failed directory removal short-circuits secret deletion while still reporting success, which can silently leave keys on disk.

Files Change
operator-session.ts + receiver-env.test.ts Adds sid-less receiver-mode admin token minting and tests its behavior against joined brains
projects.ts + store.test.ts Purges per-project secrets file on delete and tests the cleanup
jest.config.js + jest.setup.js + session tests Introduces per-run store isolation via APPSTASH_BASE_DIR in test setup

Reviewed commit: 7dc65c1

@pyramation
pyramation merged commit dd197db into main Sep 21, 2026
5 of 6 checks passed

@tenki-reviewer tenki-reviewer Bot left a comment •

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Adds receiver-only operator session mode with a sid-less admin JWT against joined brains, plus a secrets file purge on project deletion with associated test isolation and coverage.

Key findings

Comment on lines +46 to +51
if (store.getProjectConfig(project)?.receiver?.server) {
return signJwt(account.username, {
role: account.role,
ttlSec: Math.floor(TTL_MS / 1000)
});
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟡 security · medium

Non-revocable 12h admin operator token

In receiver mode operatorToken mints a role: admin token with no sid against a joined brain (packages/desktop/src/main/operator-session.ts:46-50), and the brain's WebSocket upgrade accepts a sid-less JWT by signature alone with no live-session, role, or user-existence check (packages/server/src/server.ts:314-329).

Revoking the operator's sessions or demoting/removing the account does not kill an already-distributed token, which stays valid for the full 12h TTL_MS; a leaked token therefore grants admin show control that cannot be cut off server-side, unlike the session-bound local path.

📋 Prompt for AI Agents

In packages/desktop/src/main/operator-session.ts lines 46-51, the receiver-mode branch mints a sid-less role:admin JWT valid for TTL_MS (12h) that the remote brain's WebSocket upgrade accepts by signature alone (server.ts:314-329) with no session/role/user check. Reduce the blast radius by shortening ttlSec for this branch (e.g. re-mint on each embedded page load) and add a comment recording that this token cannot be revoked from Access→Sessions nor invalidated by demoting/removing the operator, so it is bounded only by expiry.

Comment on lines +109 to 112
fs.rmSync(projectSecretsFile(paths, name), { force: true });
} catch {
/* best effort */
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟡 security · medium

Best-effort secret purge can silently leak keys

The new fs.rmSync(projectSecretsFile(...)) runs inside the same catch {} that already swallowed the project-dir removal, and it executes after the registry entry is dropped — so deleteProject returns true even when the secret purge is skipped or fails (packages/settings/src/projects.ts:107-112).

Impact: a "deleted" project's jwtSecret/receiverKey can remain at config/secrets/<project>.json and be resurrected if the same-named project is re-created, since generateSecrets only back-fills missing values.

📋 Prompt for AI Agents

In packages/settings/src/projects.ts deleteProject() (lines 101-115), the secrets file purge (line 109) is inside the same try/catch as the project-dir removal and runs after the registry write, so a failed or short-circuited removal is silently reported as success. Move the secrets removal out of the best-effort block and before writeRegistry, and on failure log/rethrow so callers know the signing key was not purged.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant