Merge remote-tracking branch 'gitea/master' into sidecar-app-store

This commit is contained in:
2026-08-11 19:10:52 +00:00
30 changed files with 2313 additions and 729 deletions
+9 -5
View File
@@ -23,7 +23,7 @@ import {
renameOpenCodeSession,
isOpenCodeSessionId,
} from './opencode-sessions';
import { getOpenCodeSession } from './opencode/state';
import { getOpenCodePrompt, getOpenCodeSession } from './opencode/state';
import { listChatModels } from './list-models';
import { logger } from './logger';
import { readSttConfig } from '../server-settings/stt';
@@ -55,7 +55,8 @@ chatRouter.use(async (ctx, next) => {
// The working directory a request operates on: an explicit ?cwd= (a chosen pwd), else the default
// general_chat_sessions dir. Claude groups sessions by cwd, so this selects which project group we read.
// (OpenCode sessions all live in the one fixed server and ignore cwd.)
// OpenCode runs on one fixed serve, but each session records the directory its turn ran in, so cwd
// selects there too.
const cwdOf = (ctx: Context, email: string): string => ctx.req.query('cwd')?.trim() || getGeneralChatSessionsCwd(email);
// GET /chat/pwds — the default /chat dir plus every directory that already has Claude sessions.
@@ -173,9 +174,12 @@ chatRouter.get('/live', async (ctx) => {
isGenerating: true,
pendingTasks: 0,
harness: 'opencode' as const,
// Still null for the first seconds of a turn: the id is only known once the subprocess prints it.
// That window is real and short, and showing nothing beats showing a key the user has never seen.
title: meta?.title ?? null,
// OpenCode's own title wins as soon as it exists — it is derived from the conversation and is
// better than anything we would compose. Until then (and it titles asynchronously, so "until
// then" covers the whole time a turn is RUNNING, which is exactly what this panel shows) fall
// back to the prompt that started the session. Same shape as the Claude side, which has never
// shown a live row without a name.
title: meta?.title && meta.title !== '(untitled)' ? meta.title : (getOpenCodePrompt(sessionKey) ?? null),
cwd: meta?.cwd || null,
};
});
+13 -8
View File
@@ -39,8 +39,12 @@ export function invalidateModelCache(): void {
openCodeCache = null;
}
type OpenCodeModel = {
capabilities?: { input?: { image?: boolean }; reasoning?: boolean };
};
type ProvidersResponse = {
providers?: Array<{ id?: string; models?: Record<string, unknown> }>;
providers?: Array<{ id?: string; models?: Record<string, OpenCodeModel> }>;
};
// Enumerate OpenCode models from the fixed server's GET /config/providers (reliable — no subprocess).
@@ -58,19 +62,20 @@ async function listOpenCodeModels(): Promise<ModelInfo[]> {
const models: ModelInfo[] = [];
for (const provider of data.providers ?? []) {
const providerId = provider.id ?? '';
for (const modelId of Object.keys(provider.models ?? {})) {
for (const [modelId, model] of Object.entries(provider.models ?? {})) {
models.push({
id: `${providerId}/${modelId}`,
name: modelId,
provider: providerId,
contextWindow: 200000,
maxTokens: 8192,
reasoning: false,
// False because nothing carries them: `handleOpenCodeChat`'s message type has no `images`
// field, so an attached image is rendered in the bubble, never sent, and silently dropped.
// The composer gates on this flag, so advertising `true` offered a capability that did not
// exist. Flip it back when images are plumbed through OpenCodeRunParams (parity doc, Phase 4).
images: false,
reasoning: model?.capabilities?.reasoning ?? false,
// Was hardcoded `false`, correctly, while nothing carried images — the composer gates on this
// flag, so advertising `true` offered a capability that did not exist. Images are now plumbed
// through `OpenCodeRunParams` to `opencode run --file`, so the honest answer is the model's
// own: OpenCode publishes it per model and we had never read it. Defaults to false, so a model
// that does not declare the capability keeps the affordance hidden rather than offering it.
images: model?.capabilities?.input?.image ?? false,
});
}
}
@@ -0,0 +1,27 @@
import { describe, expect, it } from 'bun:test';
import { isPlaceholderOpenCodeTitle } from './opencode-sessions';
// OpenCode names a session `New session - <ISO>` until it has titled it from the conversation, which it
// does asynchronously. The strings below are real ones observed on 1.18.16 — both the placeholder and
// the good title it was replaced by.
describe('isPlaceholderOpenCodeTitle', () => {
it('recognises the placeholder OpenCode uses before it has titled a session', () => {
expect(isPlaceholderOpenCodeTitle('New session - 2026-08-10T15:44:17.178Z')).toBe(true);
expect(isPlaceholderOpenCodeTitle('New session - 2026-08-10T12:41:02.757Z')).toBe(true);
});
it('treats an absent or empty title as unnamed too', () => {
expect(isPlaceholderOpenCodeTitle(undefined)).toBe(true);
expect(isPlaceholderOpenCodeTitle('')).toBe(true);
expect(isPlaceholderOpenCodeTitle(' ')).toBe(true);
});
it('keeps a real title, including one that merely mentions a session', () => {
// The title OpenCode actually gave a turn of ours, and the reason this is a pattern match rather
// than a `startsWith('New session')`: a genuine title is allowed to begin with those words.
expect(isPlaceholderOpenCodeTitle('Single color in oc-red2.png')).toBe(false);
expect(isPlaceholderOpenCodeTitle('New session handling in the runner')).toBe(false);
expect(isPlaceholderOpenCodeTitle('New session - what should it be called?')).toBe(false);
});
});
+24 -8
View File
@@ -7,23 +7,39 @@ import { logger } from './logger';
// fixed pm2-managed server's HTTP API (never the DB directly). Returns the same shapes as the Claude
// reader, tagged harness:'opencode', so chat.ts can merge both harnesses transparently.
//
// Sessions do NOT all live in one project. That claim was true when turns went through the serve and
// inherited its directory; turns are now `opencode run --dir <cwd>` subprocesses, so each session
// records the directory it ran in and one serve happily lists sessions across many. Verified: a single
// serve returned 7 sessions spread over several directories, which is also why the cwd filter has to
// read `directory` rather than assume.
// Sessions do NOT all live in one project. Each records the directory its turn ran in — the serve takes
// it per request as `x-opencode-directory` — and one serve holds sessions for many. That is why the cwd
// filter has to read `directory` rather than assume, and why the list has to come from a read that spans
// projects (`client.listSessions`, which is where the project-scoping trap is written up).
/**
* List OpenCode sessions for a working directory, so each context (/chat pwd, email account, project)
* sees only its own. With no cwd, returns all. Never throws — returns [] if the server is unavailable.
*
* Filters on the session's own `directory`, which is what OpenCode records when the runner starts it
* with `--dir`. It used to filter on `metadata.officer.cwd`, a tag whose only writer
* Filters on the session's own `directory`, which is what OpenCode records from the directory the turn
* ran in. It used to filter on `metadata.officer.cwd`, a tag whose only writer
* (`client.createSession`) has no callers — so the comparison was against `undefined` for every session
* and the list was ALWAYS empty. `cwdOf` in chat.ts substitutes a default when no `?cwd=` is given, so
* the `!cwd` escape never fired either and there was no configuration in which an OpenCode session
* appeared in /chat. Verified against the live server: 7 sessions present, 0 returned.
*/
/**
* OpenCode's own placeholder name for a session it has not titled yet.
*
* It titles a session from the conversation, but ASYNCHRONOUSLY and after there is something to read —
* a finished turn ends up called "Single color in oc-red2.png", which is better than anything we would
* generate. Until then the session is literally named `New session - 2026-08-10T15:44:17.178Z`.
*
* That window is exactly when a session is most visible: `/chat/live` shows turns that are RUNNING, so
* the placeholder is what the panel catches. Treating it as "not yet titled" lets the good name arrive
* on its own. Forcing `--title` on the run instead would fix the transient case by permanently
* replacing opencode's title with a truncated prompt — worse where it lasts longest.
*/
export const isPlaceholderOpenCodeTitle = (title: string | undefined): boolean => {
const trimmed = title?.trim();
return !trimmed || /^New session - \d{4}-\d{2}-\d{2}T[\d:.]+Z?$/.test(trimmed);
};
export async function listOpenCodeSessions(cwd?: string): Promise<ClaudeSessionSummary[]> {
try {
const { baseUrl } = await ensureServer();
@@ -35,7 +51,7 @@ export async function listOpenCodeSessions(cwd?: string): Promise<ClaudeSessionS
const updated = s.time?.updated ?? created;
return {
id: s.id,
title: s.title || '(untitled)',
title: isPlaceholderOpenCodeTitle(s.title) ? '(untitled)' : (s.title ?? '(untitled)'),
cwd: s.directory ?? '',
createdAt: new Date(created).toISOString(),
updatedAt: new Date(updated).toISOString(),
+29 -4
View File
@@ -1,5 +1,7 @@
// One connection per `opencode serve` base URL, for the REST reads the chat list and transcript need.
// The SDK-style `/session/*` route family is used (feature-complete, incl. DELETE).
// The SDK-style `/session/*` route family is used for the per-id reads and writes (feature-complete,
// incl. DELETE) — those answer for any session regardless of project, verified 200 with and without a
// directory header. The LIST is the exception and goes through `/api/session`; see `listSessions`.
//
// This used to also hold a shared SSE subscription (`GET /event`) demultiplexed per session, plus
// `createSession`, `postMessage`, `abort` and `isServerHealthy` — the client half of a serve-based turn
@@ -12,10 +14,30 @@ class ServerConnection {
// ── Session history (REST; OpenCode's SQLite store is the source of truth) ──
/**
* Every session the serve knows, across every project.
*
* `GET /session` — the endpoint this used — answers for ONE project: the one the request's directory
* resolves to, which with no `x-opencode-directory` header is the serve's own cwd
* (`DATA_PATH/opencode_server`). That directory is not a git checkout, so it resolves to the catch-all
* project `global`, and so does every other non-git directory — which is why the general chat dir
* listed fine and nothing looked wrong. A cwd that IS a git checkout gets its own project and its
* sessions were simply never returned. Measured on the live serve: `/session` 8, `/api/session` 13,
* and a session created in a git directory came back 0 times from `/session` and 1 from `/api/session`.
*
* Chat pwds are project directories, so that is the ordinary case, not the edge one.
*
* The two surfaces disagree about the shape, and the difference is silent: `/session` carries the
* working directory as top-level `directory`, `/api/session` as `location.directory` with no top-level
* field at all, wrapped in `{data: …}`. Normalised here so callers keep reading `directory` — reading
* the wrong one yields `undefined` for every session, which filters the list down to nothing.
*/
async listSessions(): Promise<OpenCodeSessionInfo[]> {
const res = await fetch(`${this.baseUrl}/session`);
if (!res.ok) throw new Error(`opencode GET /session → ${res.status}`);
return (await res.json()) as OpenCodeSessionInfo[];
const res = await fetch(`${this.baseUrl}/api/session`);
if (!res.ok) throw new Error(`opencode GET /api/session → ${res.status}`);
const body = (await res.json()) as { data?: ApiSessionInfo[] } | ApiSessionInfo[];
const sessions = Array.isArray(body) ? body : (body.data ?? []);
return sessions.map((s) => ({ ...s, directory: s.directory ?? s.location?.directory ?? null }));
}
/** One session's own record. The only place its working directory can be read on resume. */
@@ -72,6 +94,9 @@ export type OpenCodeSessionInfo = {
directory?: string | null;
};
/** `/api/session`'s shape: the same record, with the directory one level down. Normalised by `listSessions`. */
type ApiSessionInfo = OpenCodeSessionInfo & { location?: { directory?: string | null } };
export type OpenCodeStoredPart = {
type?: string;
text?: string;
+21
View File
@@ -12,4 +12,25 @@ export const setOpenCodeSession = (sessionKey: string, opencodeSessionId: string
export const clearOpenCodeSession = (sessionKey: string): void => {
sessionKeyToOpenCode.delete(sessionKey);
sessionKeyToPrompt.delete(sessionKey);
};
// What the user asked, kept only to name a session OpenCode has not named yet.
//
// OpenCode titles a session from the conversation, and does it well — but asynchronously, so a RUNNING
// turn is called `New session - <ISO>`, which is precisely when `/chat/live` displays it. This is the
// stand-in for that window: shown while the real title is still the placeholder, and dropped the moment
// OpenCode publishes its own, which is always the better one.
//
// Set on the FIRST turn of a session only, so it stays the name of the conversation rather than
// following whatever was asked most recently.
const sessionKeyToPrompt = new Map<string, string>();
export const rememberOpenCodePrompt = (sessionKey: string, prompt: string): void => {
if (sessionKeyToPrompt.has(sessionKey)) return;
const oneLine = prompt.replace(/\s+/g, ' ').trim();
if (!oneLine) return;
sessionKeyToPrompt.set(sessionKey, oneLine.length > 60 ? `${oneLine.slice(0, 59)}` : oneLine);
};
export const getOpenCodePrompt = (sessionKey: string): string | undefined => sessionKeyToPrompt.get(sessionKey);
+14 -4
View File
@@ -24,7 +24,7 @@ class SessionManager {
cwd,
model,
piProcess: null,
ws: null,
sockets: new Set(),
lastActivity: Date.now(),
idleTimer: null,
streamBuffer: '',
@@ -145,7 +145,7 @@ class SessionManager {
attachWs(sessionId: string, ws: any): void {
const session = this.sessions.get(sessionId);
if (session) {
session.ws = ws;
session.sockets.add(ws);
session.lastActivity = Date.now();
if (session.idleTimer) {
@@ -155,14 +155,24 @@ class SessionManager {
}
}
detachWs(sessionId: string): void {
/**
* Removes one socket. The caller must say WHICH — a bare `detachWs(sessionId)` used to null the
* session's only socket field, so a stale client's close event silenced whichever client had attached
* after it. A close is only the end of the conversation when nothing else is still watching.
*/
detachWs(sessionId: string, ws: any): void {
const session = this.sessions.get(sessionId);
if (session) {
session.ws = null;
session.sockets.delete(ws);
session.lastActivity = Date.now();
}
}
/** Whether anything is still watching — the idle GC must not start while another client is attached. */
hasSockets(sessionId: string): boolean {
return (this.sessions.get(sessionId)?.sockets.size ?? 0) > 0;
}
setIdleTimeout(sessionId: string, timeoutMs: number): void {
const session = this.sessions.get(sessionId);
if (!session) return;
+7 -1
View File
@@ -299,7 +299,13 @@ export type UserSession = {
cwd: string;
model: string;
piProcess: any | null;
ws: any | null;
/**
* Every socket watching this conversation, not the most recent one. Two panes in one window, or a
* laptop and an iPad on the same chat, are both ordinary now that a tab holds several panes — and a
* single `ws` field meant the newest attach silently stole the turn from everyone else, while any one
* of them closing set it to null and killed delivery for the rest.
*/
sockets: Set<any>;
lastActivity: number;
idleTimer: Timer | null;
streamBuffer: string;
+17 -10
View File
@@ -28,17 +28,24 @@ describe('decideResume', () => {
expect(decideResume(undefined, 'claude-code')).toEqual({ kind: 'adopt', model: 'claude-code' });
});
it('stays unknown rather than defaulting to claude, which is the whole bug', () => {
// No record and no claim. Previously this became `claude-code` and was indistinguishable from a real
// Claude session; now it declines to adopt and declines to run the cut-off check.
expect(decideResume(undefined, undefined)).toEqual({ kind: 'replay-only' });
expect(decideResume(undefined, '')).toEqual({ kind: 'replay-only' });
it('marks a total unknown as an assumption instead of passing it off as knowledge', () => {
// No record and no claim. This still adopts — see below — but the caller can tell it apart, which is
// what keeps the durable cut-off row off a session nobody has identified.
expect(decideResume(undefined, undefined)).toEqual({ kind: 'assume', model: 'claude-code' });
expect(decideResume(undefined, '')).toEqual({ kind: 'assume', model: 'claude-code' });
});
it('never reports replay-only once anything is known, so a live turn is always re-bound', () => {
// The failure mode in the other direction: declining to adopt a session we could have identified
// would silently drop the reconnect that makes turn output resume.
expect(decideResume('claude-code', undefined).kind).toBe('known');
expect(decideResume('', 'claude-code').kind).toBe('adopt');
it('always yields a model to adopt with, because an unbound socket misses the whole turn', () => {
// Regression guard. Refusing to adopt when the harness was unknown looked principled and broke the
// app inside an hour: `useChat.ts` only sends `model` `if (modelRef.current)`, so a reconnect
// without one is routine. The socket never re-bound, the live turn's output went nowhere, and the
// transcript collapsed to "turn completed without output" until a refresh rebuilt it from the log.
for (const d of [
decideResume('claude-code', undefined),
decideResume(undefined, 'opencode/x'),
decideResume(undefined, undefined),
]) {
expect(d.model).toBeTruthy();
}
});
});
+49 -24
View File
@@ -10,6 +10,7 @@ import type {
UserSession,
} from './types';
import { sessionManager } from './session-manager';
import { rememberOpenCodePrompt } from './opencode/state';
import { sendClaudeCodeStreaming } from '@@/channels/send-claude-code';
import { sendOpenCodeStreaming } from '@@/channels/send-opencode';
import { ensureGeneralChatSessionsCwd } from './claude-sessions';
@@ -157,8 +158,10 @@ export function close(ws: ServerWebSocket<WSData>): void {
const sessionId = wsToSessionMap.get(ws);
if (sessionId) {
sessionManager.detachWs(sessionId);
sessionManager.setIdleTimeout(sessionId, IDLE_TIMEOUT_MS);
sessionManager.detachWs(sessionId, ws);
// Only once nothing is watching. Another pane or another device still attached means the
// conversation is live, and arming the idle GC here would collect it out from under them.
if (!sessionManager.hasSockets(sessionId)) sessionManager.setIdleTimeout(sessionId, IDLE_TIMEOUT_MS);
}
}
@@ -243,7 +246,7 @@ function createMessageHandler(sessionId: string, model: string) {
const session = sessionManager.getSession(sessionId);
if (!session) return;
foldIntoSession(session, msg, model);
sendToClient(session.ws as ServerWebSocket<WSData> | null, msg, seq);
for (const socket of session.sockets) sendToClient(socket as ServerWebSocket<WSData>, msg, seq);
};
}
@@ -396,6 +399,10 @@ async function handleOpenCodeChat(
cwd?: string;
cwdRoot?: string;
resumeSessionId?: string;
// The whole of B4 lived in this omission. The browser sent images, the bubble rendered them, and
// they stopped at this signature — so they were never passed on and never reached the model, with
// nothing anywhere reporting a loss.
images?: PromptImage[];
},
effectivePrompt: string,
): Promise<void> {
@@ -403,6 +410,9 @@ async function handleOpenCodeChat(
const cwd = await resolveChatCwd(msg, email, userId);
// Names this session in the Live panel until OpenCode gets round to titling it. First turn only.
rememberOpenCodePrompt(sessionId, msg.displayText || msg.prompt);
const groupSlug = msg.groupSlug || null;
const session = sessionManager.getOrCreate(sessionId, email, cwd, model, groupSlug, msg.context, msg.contextId);
@@ -457,6 +467,7 @@ async function handleOpenCodeChat(
cwd,
model,
resumeSessionId: msg.resumeSessionId,
images: msg.images,
onMessage,
});
@@ -617,19 +628,17 @@ async function handleResumeCursor(
const known = sessionManager.getSession(sessionId);
const decision = decideResume(known?.model, msg.model);
const model = decision.kind === 'replay-only' ? null : decision.model;
if (decision.kind === 'adopt') {
if (decision.kind !== 'known') {
// Adopt on an assumption too: an unbound socket misses the turn entirely, which is worse than a
// harness guess that only ever costs us the cut-off check below.
adoptOrphanedSession(ws, sessionId, decision.model, msg.cwd ?? '');
} else if (decision.kind === 'replay-only') {
// Replay the durable log and stop there. Not adopting costs a live re-subscription; adopting on a
// guess cost correctness — see decideResume.
logger.warn('resume-cursor names a session this process does not know, and no model; replaying only', {
sessionId,
});
if (decision.kind === 'assume') {
logger.warn('resume-cursor for an unknown session with no model; adopting on the default', { sessionId });
}
}
sessionManager.attachWs(sessionId, ws); // a no-op when adoption was skipped
sessionManager.attachWs(sessionId, ws);
wsToSessionMap.set(ws as any, sessionId);
try {
const events = await getChatEventsSince(sessionId, cursor ?? 0);
@@ -639,15 +648,19 @@ async function handleResumeCursor(
} catch (err) {
logger.error('Failed to replay chat events on resume-cursor', { sessionId, error: String(err) });
}
// Only ask when the harness is actually known. The check writes a DURABLE row, so a wrong answer here
// is permanent — silence is the safe failure.
if (msg.generating && model) await endTurnIfAgentIsGone(ws, sessionId, model);
// Only ask when the harness is actually known — never on an assumption. This check writes a DURABLE
// row, so a wrong answer is permanent: that was B7, where a defaulted `claude-code` made officer ask
// the Claude sidecar about an OpenCode session, hear "not generating", and write "the agent went
// away" into a turn that was running perfectly well.
if (msg.generating && decision.kind !== 'assume') {
await endTurnIfAgentIsGone([ws], sessionId, decision.model);
}
}
export type ResumeDecision =
| { kind: 'known'; model: string }
| { kind: 'adopt'; model: string }
| { kind: 'replay-only' };
| { kind: 'assume'; model: string };
/**
* Which harness a resuming session belongs to, and what that permits.
@@ -668,13 +681,21 @@ export type ResumeDecision =
*
* Two rules. **The server's own record beats the client's claim** — a session in memory already knows its
* harness, and letting a socket re-declare it is how the wrong sidecar gets a session in the first place.
* **An unknown harness stays unknown**: no adoption, no cut-off check, just the replay. Defaulting is
* what made a guess indistinguishable from knowledge.
* **A guess is still a guess**: when neither knows, the session is adopted on the default so delivery
* keeps working, but it is marked `assume` and nothing durable may be written from it.
*
* The middle position — refusing to adopt at all when the harness is unknown — was tried and was WRONG,
* visibly so within the hour. `useChat.ts` sends `model` only `if (modelRef.current)`, so a reconnect
* without one is ordinary, not exotic; declining to adopt left the socket unbound to a live turn, and
* the running turn's output went nowhere. On screen: the transcript collapsed to "turn completed
* without output" and only a refresh — which rebuilds from the durable log — brought it back.
*
* So adoption is about DELIVERY and must be generous. Only the durable write needs certainty.
*/
export function decideResume(knownModel: string | undefined, claimedModel: string | undefined): ResumeDecision {
if (knownModel) return { kind: 'known', model: knownModel };
if (claimedModel) return { kind: 'adopt', model: claimedModel };
return { kind: 'replay-only' };
return { kind: 'assume', model: DEFAULT_MODEL };
}
/**
@@ -802,7 +823,7 @@ async function handleAttach(ws: ServerWebSocket<WSData>, msg: { claudeSessionId:
* so its sessions are left alone rather than guessed at.
*/
async function endTurnIfAgentIsGone(
ws: ServerWebSocket<WSData> | null,
targets: Iterable<ServerWebSocket<WSData> | null>,
sessionId: string,
model: string,
): Promise<void> {
@@ -815,11 +836,11 @@ async function endTurnIfAgentIsGone(
const event: ServerMessage = { type: 'cut-off' };
try {
const seq = await appendChatEvent(sessionId, event);
sendToClient(ws, event, seq);
for (const target of targets) sendToClient(target, event, seq);
} catch (err) {
// Still tell this client — an un-replayable explanation beats a spinner that never stops.
// Still tell every client — an un-replayable explanation beats a spinner that never stops.
logger.error('Failed to persist cut-off notice', { sessionId, error: String(err) });
sendToClient(ws, event);
for (const target of targets) sendToClient(target, event);
}
logger.info('Ended a turn whose agent had gone', { sessionId });
}
@@ -831,7 +852,11 @@ async function endTurnIfAgentIsGone(
sidecar.onClaudeSidecarStarted(() => {
for (const session of sessionManager.getAllSessions()) {
if (!session.isGenerating) continue;
void endTurnIfAgentIsGone(session.ws as ServerWebSocket<WSData> | null, session.sessionId, session.model);
void endTurnIfAgentIsGone(
session.sockets as Set<ServerWebSocket<WSData>>,
session.sessionId,
session.model,
);
}
});
+110
View File
@@ -0,0 +1,110 @@
import { createRouter } from '../../create-router';
import { getVaultServerUrl } from './sidecar-server';
import { stripHopByHop, redactPath } from './proxy-util';
// Vaultwarden, reachable at Officer's own hostname with NO Officer authentication.
//
// ── Why this exists ──
//
// The Bitwarden browser extension speaks Vaultwarden's protocol and nothing else: it obtains its own
// bearer token from `/identity/connect/token` and has nowhere to put a platform JWT. `/api/vault` cannot
// serve it — that router requires an Officer session and REPLACES the caller's Authorization header with
// a server-held Vaultwarden token, which is the right design for our own clients (the device then holds
// no vault credential at all) and an impossible one for a third-party client.
//
// So this mount exists to retire a second public hostname. Andre pointed the extension straight at
// Vaultwarden; that URL can now go away, and the extension can use the platform's.
//
// ── Why it is acceptable to leave unauthenticated ──
//
// It is not a new exposure, it is the same exposure through one door instead of two: everything here was
// already reachable at the Vaultwarden URL this replaces, guarded by the same thing that guards it now —
// the vault's own master password and Vaultwarden's own auth. Officer adds no security to a Bitwarden
// client today because it cannot: it has no credential to check.
//
// It is also going behind Tailscale, so the surface is a private network rather than the internet.
//
// ── TEMPORARY ──
//
// The intended end state is a browser extension of our own, reusing `@officer/vault` (already proven to
// run as a plain JS bundle outside React Native — the iOS autofill extension hosts it in JavaScriptCore)
// against the `/api/vault/session/login` broker. Then the client authenticates to OFFICER, Vaultwarden is
// never addressed directly by anything, and this mount is deleted rather than adjusted.
//
// ── The rule that keeps it safe ──
//
// DUMB PASS-THROUGH, and more so than `/api/vault`: that one rewrites the Authorization header, this one
// touches nothing. Method, path, query, headers, status and both body streams go through verbatim. The
// vault is end-to-end encrypted and no part of this may parse, decrypt, buffer, cache or log a body —
// paths are redacted in logs for the same reason. Every line of "cleverness" added here is reachable
// without an Officer session, which is the whole reason it must stay boring.
export const publicVaultRouter = createRouter();
const PREFIX = '/vaultwarden';
/**
* Paths that belong to Vaultwarden and to nothing else in this platform.
*
* These are served at the ROOT so the extension can be configured with the bare Officer URL — no
* `/vaultwarden` suffix — which is the point: one hostname, one URL, nothing to explain. None of them
* collide with an Officer route, so no sniffing is involved; the path alone is the answer.
*/
export const VAULT_ONLY_PREFIXES = ['/identity', '/notifications', '/icons', '/events'];
/**
* Is this a Bitwarden client talking to us?
*
* Needed only for `/api/*`, which is the one namespace both servers claim — Vaultwarden has
* `/api/sync`, `/api/ciphers`, `/api/settings/domains`; Officer has `/api/chat`, `/api/settings` and
* everything else. The path cannot decide it, so the client says who it is: every Bitwarden client
* stamps `Bitwarden-Client-Name` (`browser`, `desktop`, `cli`, `web`), and older ones `Device-Type`.
*
* This is ROUTING, not authentication, which is why trusting a client-supplied header is fine here: the
* worst a forged header achieves is reaching Vaultwarden, which then demands its own credential exactly
* as it would have. Nothing is authorised by this function.
*/
export const isBitwardenClient = (headers: Headers): boolean =>
headers.has('bitwarden-client-name') || headers.has('bitwarden-client-version') || headers.has('device-type');
/** Everything Vaultwarden serves, whichever door it arrived through. */
export const isVaultwardenPath = (path: string, headers: Headers): boolean => {
if (VAULT_ONLY_PREFIXES.some((p) => path === p || path.startsWith(`${p}/`))) return true;
return path.startsWith('/api/') && isBitwardenClient(headers);
};
publicVaultRouter.all('/*', async (ctx) => {
const base = getVaultServerUrl();
if (!base) return ctx.text('Vault sidecar not available', 503);
const url = new URL(ctx.req.url);
// Mounted at `/vaultwarden` AND at the root prefixes above. Strip the prefix only when it is there,
// so `/vaultwarden/identity/...` and a bare `/identity/...` both reach Vaultwarden's own path.
const subpath = (url.pathname.startsWith(PREFIX) ? url.pathname.slice(PREFIX.length) : url.pathname) || '/';
const target = `${base}${subpath}${url.search}`;
const method = ctx.req.method;
const hasBody = method !== 'GET' && method !== 'HEAD';
// Authorization is FORWARDED, not replaced — the opposite of `/api/vault`. The client's Vaultwarden
// token is the only credential in play, and Vaultwarden is the only thing entitled to judge it.
const headers = stripHopByHop(ctx.req.raw.headers);
const init: RequestInit & { duplex?: 'half' } = { method, headers, redirect: 'manual' };
if (hasBody) {
init.body = ctx.req.raw.body;
init.duplex = 'half';
}
let upstream: Response;
try {
upstream = await fetch(target, init);
} catch {
console.error(`[vault:public] ${method} ${redactPath(subpath + url.search)} -> sidecar unreachable`);
return ctx.text('Vault sidecar unreachable', 502);
}
// No 401-refresh-and-retry here, deliberately. That belongs to `/api/vault`, which owns the token it
// injected; this router holds no token and a 401 is Vaultwarden's answer to the CLIENT, which the
// client knows how to handle — it re-authenticates with the master password.
return new Response(upstream.body, { status: upstream.status, headers: stripHopByHop(upstream.headers) });
});