Merge remote-tracking branch 'gitea/master' into sidecar-app-store
This commit is contained in:
@@ -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,
|
||||
};
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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(),
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -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) });
|
||||
});
|
||||
Reference in New Issue
Block a user