From 0040e3b90ba03553e6ee7163d8afeea22bc65ce3 Mon Sep 17 00:00:00 2001
From: Dkm0315 <127371606+Dkm0315@users.noreply.github.com>
Date: Mon, 26 Oct 2026 21:30:56 +0530
Subject: [PATCH] Run coding-client chats in Muster's own home (#720)
---
.../03-providers-models-and-accounts.md | 38 ++++
packages/agent-app/CHANGELOG.md | 4 +
packages/agent-app/src/runtime/codex-home.ts | 82 +++++++++
.../src/runtime/provider-instances.ts | 10 +-
.../agent-app/src/runtime/provider-usage.ts | 22 ++-
packages/agent-app/src/runtime/provider.ts | 3 +
.../tests/own-codex-home-720.test.ts | 165 ++++++++++++++++++
7 files changed, 319 insertions(+), 5 deletions(-)
create mode 100644 packages/agent-app/src/runtime/codex-home.ts
create mode 100644 packages/agent-app/tests/own-codex-home-720.test.ts
diff --git a/docs/spec/clauses/03-providers-models-and-accounts.md b/docs/spec/clauses/03-providers-models-and-accounts.md
index ffa680b1..7ddb6982 100644
--- a/docs/spec/clauses/03-providers-models-and-accounts.md
+++ b/docs/spec/clauses/03-providers-models-and-accounts.md
@@ -51,6 +51,9 @@ Boundaries:
| [MUS-SPEC-03-716A](03-providers-models-and-accounts.md#mus-spec-03-716a) | A resume refused with "replay state expired or is unavailable" before the turn starts is retried once on a fresh thread: the message reaches the provider twice at most, the retry resumes nothing and starts with a summary of the earlier messages, one notice says a fresh conversation started, and the chat ends completed on the new thread. | [`a resumed router thread whose replay state expired starts one fresh conversation with a summary`](../../../packages/agent-app/tests/replay-expired-716.test.ts) |
| [MUS-SPEC-03-716B](03-providers-models-and-accounts.md#mus-spec-03-716b) | If the fresh conversation is refused with the same error there is no third attempt and the error is shown through the usual recovery notice. | [`when the fresh conversation is refused with the same 409 there is no third attempt and the error is shown`](../../../packages/agent-app/tests/replay-expired-716.test.ts) |
| [MUS-SPEC-03-716C](03-providers-models-and-accounts.md#mus-spec-03-716c) | A 409 with other text, or the same text after the turn produced output, does not start a fresh conversation. | [`a 409 with other text, or after the turn produced output, does not start a fresh conversation`](../../../packages/agent-app/tests/replay-expired-716.test.ts) |
+| [MUS-SPEC-03-720A](03-providers-models-and-accounts.md#mus-spec-03-720a) | The chat runs in Muster's own coding-client home under its data folder: no thread or thread index is written to the person's home, the person's profile is untouched, and follow-ups continue the same thread. | [`a new chat runs in Muster’s own coding-client home: nothing is written to the person’s home, which keeps its configuration`](../../../packages/agent-app/tests/own-codex-home-720.test.ts) |
+| [MUS-SPEC-03-720B](03-providers-models-and-accounts.md#mus-spec-03-720b) | A chat started before the change continues on its own thread with its earlier messages, and the person's copy of that thread is not written to again. | [`a chat started before the change continues its own thread with its history, without writing to the person’s home again`](../../../packages/agent-app/tests/own-codex-home-720.test.ts) |
+| [MUS-SPEC-03-720C](03-providers-models-and-accounts.md#mus-spec-03-720c) | Usage limits recorded in the person's home by their other coding apps still show for that account, the newest reading winning. | [`usage limits recorded by the person’s other coding apps still show for a chat that runs in Muster’s own home`](../../../packages/agent-app/tests/own-codex-home-720.test.ts) |
### Configured routers
@@ -448,3 +451,38 @@ Boundaries:
- Environment: Node test runner; the real service and provider adapter with a fake Codex client; no network.
- Known gaps: None known.
- Evidence: UNVERIFIED
+
+### Muster's own coding-client home
+
+
+#### MUS-SPEC-03-720A: A new chat runs in Muster's own home
+
+- Precondition: The person has a coding-client home with a sign-in or profile (here a router profile with an auth helper).
+- Action: Start a chat on that profile, send a message, then a follow-up.
+- Expected result: The run reaches the router through the person's profile; the thread is stored under `/codex-home//sessions`, the person's home gets no rollout and no thread index, the profile file is unchanged, and the follow-up continues the same thread.
+- Test: `packages/agent-app/tests/own-codex-home-720.test.ts::a new chat runs in Muster’s own coding-client home: nothing is written to the person’s home, which keeps its configuration`
+- Environment: Node test runner; the real service, provider adapter and installed coding client against a local mock router; temporary home and data folders.
+- Known gaps: A ChatGPT sign-in kept only in the Keychain is tied to the home's path, so that route keeps the person's home; Windows without symlink rights copies shared files instead of linking them.
+- Evidence: UNVERIFIED
+
+
+#### MUS-SPEC-03-720B: A chat from before the change keeps its thread
+
+- Precondition: A chat ran on an earlier build, so its thread is in the person's home.
+- Action: Open it on this build and send a message that depends on the earlier one.
+- Expected result: The chat keeps its provider thread, the model sees the earlier message, the person's copy of the thread does not grow, and no new thread appears there.
+- Test: `packages/agent-app/tests/own-codex-home-720.test.ts::a chat started before the change continues its own thread with its history, without writing to the person’s home again`
+- Environment: Node test runner; the real service, provider adapter and installed coding client against a local mock router; temporary home and data folders.
+- Known gaps: None known.
+- Evidence: UNVERIFIED
+
+
+#### MUS-SPEC-03-720C: Usage limits still show
+
+- Precondition: A chat has run in Muster's own home, and another coding app has since recorded a newer rate-limit reading for the same account in the person's home.
+- Action: Read the usage for that account.
+- Expected result: The newer reading from the person's home is shown.
+- Test: `packages/agent-app/tests/own-codex-home-720.test.ts::usage limits recorded by the person’s other coding apps still show for a chat that runs in Muster’s own home`
+- Environment: Node test runner; the real service, provider adapter and installed coding client against a local mock router; temporary home and data folders.
+- Known gaps: None known.
+- Evidence: UNVERIFIED
diff --git a/packages/agent-app/CHANGELOG.md b/packages/agent-app/CHANGELOG.md
index e9d98bda..d4f37cfa 100644
--- a/packages/agent-app/CHANGELOG.md
+++ b/packages/agent-app/CHANGELOG.md
@@ -3,6 +3,10 @@
All notable changes to Muster Agent. Each `## ` section becomes the notes of the
`agent-v` GitHub Release (`.github/workflows/agent-app-release.yml`).
+## Unreleased
+
+- Chats on your coding-client sign-ins and profiles no longer show up in your other coding apps. Muster now keeps its own threads in its data folder while sharing your sign-in, profiles, skills and plugins; existing chats continue where they left off.
+
## 0.3.15
Claude chats on your own router recover on their own when an older conversation can no longer be continued, and notification toasts move to the bottom-right corner, clear of your messages.
diff --git a/packages/agent-app/src/runtime/codex-home.ts b/packages/agent-app/src/runtime/codex-home.ts
new file mode 100644
index 00000000..a2ddf67b
--- /dev/null
+++ b/packages/agent-app/src/runtime/codex-home.ts
@@ -0,0 +1,82 @@
+/**
+ * #720: Muster runs the coding client with its own home, so the threads it creates never show up in the person's other
+ * coding apps that read the same home. The person's home stays the source of truth for everything the client should
+ * share: sign-in, configuration, profiles, model catalogs, skills, plugins, rules and helpers are linked in. Threads,
+ * the thread index, logs, history and memories stay private to Muster's home.
+ */
+import {createHash} from 'node:crypto';
+import {copyFileSync, existsSync, lstatSync, mkdirSync, readdirSync, readlinkSync, statSync, symlinkSync, unlinkSync, type Dirent} from 'node:fs';
+import {join, resolve} from 'node:path';
+
+const SHARED = new Set(['auth.json', 'config.toml', 'models_cache.json', 'model-catalogs', 'skills', 'plugins', 'rules', 'prompts',
+ 'AGENTS.md', 'AGENTS.override.md', 'helpers', 'computer-use', 'node_repl', 'packages', 'vendor_imports']);
+/** Whether an entry of the person's home is shared with Muster's home (profiles are `.config.toml`). */
+export const sharedCodexEntry = (name: string): boolean => SHARED.has(name) || /^[A-Za-z0-9_.-]{1,120}\.config\.toml$/.test(name);
+
+/** Muster's own home for one of the person's homes (the default one or an extra account). */
+export function musterCodexHome(dataDir: string, sourceHome: string): string {
+ return join(dataDir, 'codex-home', createHash('sha256').update(resolve(sourceHome)).digest('hex').slice(0, 12));
+}
+
+/** Links the shared entries of the person's home into Muster's home. Run before each launch, so profiles added since
+ * appear and a link the client replaced with a file (a save that renamed over it) is folded back: the newer copy
+ * goes to the person's home and the link is restored. Where links can't be made (Windows without the privilege),
+ * directories use junctions and files are copied both ways by age. */
+export function prepareMusterCodexHome(sourceHome: string, home: string): void {
+ mkdirSync(home, {recursive: true, mode: 0o700});
+ let entries: Dirent[] = [];
+ try { entries = readdirSync(sourceHome, {withFileTypes: true}); } catch { return; }
+ for (const entry of entries) {
+ if (!sharedCodexEntry(entry.name)) continue;
+ const target = join(sourceHome, entry.name), link = join(home, entry.name);
+ let directory = entry.isDirectory();
+ try { if (entry.isSymbolicLink()) directory = statSync(target).isDirectory(); } catch { continue; }
+ let current: ReturnType | undefined;
+ try { current = lstatSync(link); } catch { current = undefined; }
+ try {
+ if (current?.isSymbolicLink()) { if (readlinkSync(link) === target) continue; unlinkSync(link); }
+ else if (current?.isFile()) {
+ if (current.mtimeMs > statSync(target).mtimeMs) copyFileSync(link, target);
+ unlinkSync(link);
+ } else if (current) continue;
+ try { symlinkSync(target, link, directory ? 'junction' : 'file'); }
+ catch { if (!directory) copyFileSync(target, link); }
+ } catch { /* this entry stays as it is; the client reports what it misses */ }
+ }
+}
+
+const THREAD_ID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
+const adopted = new Set();
+const subdirs = (dir: string) => { try { return readdirSync(dir, {withFileTypes: true}).filter(entry => entry.isDirectory()).map(entry => entry.name).sort().reverse(); } catch { return []; } };
+const rolloutIn = (dir: string, suffix: string) => { try { return readdirSync(dir).find(name => name.startsWith('rollout-') && name.endsWith(suffix)); } catch { return undefined; } };
+
+/** Where a thread's rollout lives under a home: `sessions/YYYY/MM/DD/`, or flat in `archived_sessions/`. */
+function findRollout(home: string, threadId: string): {path: string; dated: string[]} | undefined {
+ const suffix = `-${threadId}.jsonl`, sessions = join(home, 'sessions');
+ for (const year of subdirs(sessions)) for (const month of subdirs(join(sessions, year))) for (const day of subdirs(join(sessions, year, month))) {
+ const name = rolloutIn(join(sessions, year, month, day), suffix);
+ if (name) return {path: join(sessions, year, month, day, name), dated: [year, month, day, name]};
+ }
+ const archived = rolloutIn(join(home, 'archived_sessions'), suffix);
+ const date = archived?.match(/^rollout-(\d{4})-(\d{2})-(\d{2})T/);
+ return archived && date ? {path: join(home, 'archived_sessions', archived), dated: [date[1]!, date[2]!, date[3]!, archived]} : undefined;
+}
+
+/** A chat started before #720 has its thread in the person's home: copy it into Muster's home once, so the chat
+ * continues there with its whole history and the person's copy is never written to again. */
+export function adoptCodexThread(sourceHome: string, home: string, threadId: string | undefined): void {
+ if (!threadId || !THREAD_ID.test(threadId)) return;
+ const key = `${home}\0${threadId}`;
+ if (adopted.has(key)) return;
+ if (!findRollout(home, threadId)) {
+ const found = findRollout(sourceHome, threadId);
+ if (found) try {
+ const [year, month, day, name] = found.dated as [string, string, string, string];
+ const folder = join(home, 'sessions', year, month, day);
+ mkdirSync(folder, {recursive: true, mode: 0o700});
+ if (!existsSync(join(folder, name))) copyFileSync(found.path, join(folder, name));
+ } catch { return; }
+ }
+ if (adopted.size >= 4096) adopted.clear();
+ adopted.add(key);
+}
diff --git a/packages/agent-app/src/runtime/provider-instances.ts b/packages/agent-app/src/runtime/provider-instances.ts
index 3a90665f..1cfc5cb7 100644
--- a/packages/agent-app/src/runtime/provider-instances.ts
+++ b/packages/agent-app/src/runtime/provider-instances.ts
@@ -1,3 +1,4 @@
+import {musterCodexHome} from './codex-home.ts';
import {createRequire} from 'node:module';
import {createHash} from 'node:crypto';
import * as nodeFs from 'node:fs';
@@ -511,12 +512,15 @@ function homeInstances({fs,directory,codexHome,cli,node,env,track,validator,fetc
const label=duplicate?`${names[index]} (${route.profile})`:names[index]!;
const name=account?`${label} · ${account.label??email??`account ${suffix.slice(1,7)}`}`:label;
const catalogPath=route.catalogPath&&isAbsolute(route.catalogPath)?route.catalogPath:undefined;
- const childEnv:Record={MUSTER_PROVIDER_NODE:node.node,...node.env,CODEX_HOME:codexHome,...(cli?{MUSTER_CODEX_COMMAND:cli}:{}),
+ // #720: chats run in Muster's own home (shared sign-in and configuration, private threads). A sign-in kept outside
+ // auth.json (the Keychain) is tied to the home's path, so that route keeps the person's home.
+ const ownHome=registeredDataDir&&env.MUSTER_SHARED_CODEX_HOME!=='1'&&(route.kind!=='chatgpt'||Boolean(auth))?musterCodexHome(registeredDataDir,codexHome):undefined;
+ const childEnv:Record={MUSTER_PROVIDER_NODE:node.node,...node.env,CODEX_HOME:ownHome??codexHome,...(ownHome?{MUSTER_CODEX_SOURCE_HOME:codexHome}:{}),...(cli?{MUSTER_CODEX_COMMAND:cli}:{}),
...(route.profile?{MUSTER_CODEX_PROFILE:route.profile}:{MUSTER_CODEX_PROVIDER:route.modelProvider,...(catalogPath?{MUSTER_CODEX_CATALOG:catalogPath}:{})})};
const codex={modelProvider:route.modelProvider,kind:route.kind,...(route.profile?{profile:route.profile}:{}),...(account?{account:suffix.slice(1)}:{})} satisfies NonNullable;
const source=route.profile?`Codex profile ${route.profile}.config.toml${account?` in ${account.codexHome}`:''}`:route.kind==='chatgpt'?`Codex sign-in${account?` at ${account.codexHome}`:''}`:`[model_providers.${route.modelProvider}] in Codex config.toml${account?` at ${account.codexHome}`:''}`;
const base={id,name,driver:'codex-app-server',codex,identityMasked:route.kind==='gateway'?'Gateway · account hidden':email??'ChatGPT account · hidden',models:[],available:false,source,...(route.baseUrl?{endpoint:route.baseUrl}:{})} satisfies ProviderInfo;
- const sessionsRoot=join(codexHome,'sessions');
+ const sessionsRoot=join(ownHome??codexHome,'sessions');
const fail=(error:string,status:'configured'|'error'='configured'):ProviderInstance=>({info:{...base,status,error,detail:`${error} No provider fallback will be used. ${mcpDetail(mcp)}`},command,env:childEnv,sessionsRoot});
if(route.invalid)return fail(`${route.profile}.config.toml did not validate: ${route.invalid}`,'error');
try {fs.accessSync(command,constants.X_OK);} catch {return fail('Muster’s bundled Codex launcher is missing. Reinstall Muster.','error');}
@@ -598,5 +602,5 @@ function homeInstances({fs,directory,codexHome,cli,node,env,track,validator,fetc
/** The Codex route behind a provider id, from the current instance list; undefined for non-Codex routes. */
export function codexRouteFor(id:string,options:ProviderInstanceOptions={}):{codex:NonNullable;codexHome:string;sessionsRoot:string;instance:ProviderInstance}|undefined {
const instance=configuredProviderInstances(options).find(row=>row.info.id===id&&row.info.codex);
- return instance?{codex:instance.info.codex!,codexHome:instance.env.CODEX_HOME!,sessionsRoot:instance.sessionsRoot,instance}:undefined;
+ return instance?{codex:instance.info.codex!,codexHome:instance.env.MUSTER_CODEX_SOURCE_HOME??instance.env.CODEX_HOME!,sessionsRoot:instance.sessionsRoot,instance}:undefined;
}
diff --git a/packages/agent-app/src/runtime/provider-usage.ts b/packages/agent-app/src/runtime/provider-usage.ts
index 725205d0..5c700d79 100644
--- a/packages/agent-app/src/runtime/provider-usage.ts
+++ b/packages/agent-app/src/runtime/provider-usage.ts
@@ -5,8 +5,8 @@
* - session-log: the newest `rate_limits` line in a Codex rollout file under sessionsRoot.
* Percentages are clamped to 0-100; anything malformed is ignored, never shown as 0%.
*/
-import { closeSync, openSync, readSync, readdirSync, statSync } from 'node:fs';
-import { join } from 'node:path';
+import { closeSync, openSync, readSync, readdirSync, readlinkSync, statSync } from 'node:fs';
+import { dirname, isAbsolute, join, resolve } from 'node:path';
import type { ProviderUsage, ProviderUsageWindow } from '../shared/domains/providers-protocol.ts';
const record = (value: unknown): Record | undefined => value && typeof value === 'object' && !Array.isArray(value) ? value as Record : undefined;
@@ -73,6 +73,24 @@ export function recentRollouts(sessionsRoot: string, limit = 12): string[] {
}
/** The newest rate-limit snapshot per model provider id (`session_meta.model_provider`) across recent rollouts. */
export function sessionRateLimits(sessionsRoot: string): Map {
+ const found = rootRateLimits(sessionsRoot);
+ // #720: Muster's own home links its sign-in to the person's home; other apps' recent rollouts there carry the same
+ // account's limits, so the newest reading from either side wins.
+ const source = linkedSourceHome(dirname(sessionsRoot));
+ if (source) for (const [provider, usage] of rootRateLimits(join(source, 'sessions'))) {
+ const mine = found.get(provider);
+ if (!mine || usage.updatedAt > mine.updatedAt) found.set(provider, usage);
+ }
+ return found;
+}
+/** The person's home behind Muster's own home, read from the link Muster keeps for its sign-in or configuration. */
+function linkedSourceHome(home: string): string | undefined {
+ for (const name of ['auth.json', 'config.toml']) {
+ try { const target = readlinkSync(join(home, name)); const path = isAbsolute(target) ? target : resolve(home, target); return dirname(path); } catch { /* not a link */ }
+ }
+ return undefined;
+}
+function rootRateLimits(sessionsRoot: string): Map {
const found = new Map();
for (const file of recentRollouts(sessionsRoot)) {
let head = '', tail = '';
diff --git a/packages/agent-app/src/runtime/provider.ts b/packages/agent-app/src/runtime/provider.ts
index 9475fc88..31c816ba 100644
--- a/packages/agent-app/src/runtime/provider.ts
+++ b/packages/agent-app/src/runtime/provider.ts
@@ -23,6 +23,7 @@ import {ASK_USER_METHOD, ASK_USER_RULE} from './ask-user.ts';
import type {McpServerSpec} from './adapters/types.ts';
import type {RunnableAdapter} from './adapters/types.ts';
import {CLAUDE_LANE_DRIVER} from '../shared/provider-lanes.ts';
+import {adoptCodexThread, prepareMusterCodexHome} from './codex-home.ts';
import {claudeLaneAllowed, LANE_DROPPED_SERVERS, withoutUnavailableToolNotes} from './claude-lane.ts';
import {SANDBOX_MCP_SERVER} from '../shared/sandbox-routes.ts';
import {routeSandboxNote} from './sandbox-note.ts';
@@ -415,6 +416,8 @@ export function createProviderAdapter(options: { core?: CoreClient; available?:
const developerInstructions = runInstructions(input.chat.mode, input.developerInstructions, true, providerAccessPolicy(input.chat).permissionMode);
const split = splitSecretOverrides(input.configOverrides);
const extraOverrides = Object.entries(split.args).filter(([name]) => /^[A-Za-z0-9_.-]{1,128}$/.test(name) && !/^(sandbox|approval_policy)/.test(name)).map(([name, value]) => `${name}=${JSON.stringify(value)}`);
+ // #720: link the person's shared configuration into Muster's own home, and bring over a thread from before.
+ if (route.env.MUSTER_CODEX_SOURCE_HOME && route.env.CODEX_HOME) { prepareMusterCodexHome(route.env.MUSTER_CODEX_SOURCE_HOME, route.env.CODEX_HOME); adoptCodexThread(route.env.MUSTER_CODEX_SOURCE_HOME, route.env.CODEX_HOME, resumeThread); }
const launch = {
cwd: input.cwd, command: route.command, model, reasoning,
env: { MUSTER_PROVIDER_NODE: node.node, ...node.env, ...route.env }, transportOwner: session.owner, cacheKey: session.key,
diff --git a/packages/agent-app/tests/own-codex-home-720.test.ts b/packages/agent-app/tests/own-codex-home-720.test.ts
new file mode 100644
index 00000000..c2e8ec3a
--- /dev/null
+++ b/packages/agent-app/tests/own-codex-home-720.test.ts
@@ -0,0 +1,165 @@
+// Muster's coding-client chats must not appear in the person's other coding apps (#720): Muster runs the client
+// with its own home under its data folder, sharing sign-in, configuration, profiles and skills with the person's
+// home but keeping its own threads. Chats made before this change still continue on their own thread.
+import assert from 'node:assert/strict';
+import {test, type TestContext} from 'node:test';
+import {createRequire} from 'node:module';
+import {randomUUID} from 'node:crypto';
+import {existsSync, readdirSync, readFileSync, statSync} from 'node:fs';
+import {mkdir, mkdtemp, writeFile, chmod} from 'node:fs/promises';
+import {join, resolve} from 'node:path';
+import {tmpdir} from 'node:os';
+import {execFileSync} from 'node:child_process';
+import {createServer, type IncomingMessage, type ServerResponse} from 'node:http';
+import type {AddressInfo} from 'node:net';
+import {createAgentService} from '../src/runtime/service.ts';
+import {createProviderAdapter, type CoreClient} from '../src/runtime/provider.ts';
+import {configuredProviderInstances, invalidateProviderInstances} from '../src/runtime/provider-instances.ts';
+import {sessionRateLimits} from '../src/runtime/provider-usage.ts';
+import {removeTmp} from './fixtures/tmp-dir.ts';
+import {codexCatalog} from './fixtures/mock-openai-router.ts';
+
+const appDir = resolve(import.meta.dirname, '..');
+const coreBundle = join(appDir, 'dist/runtime/core-client.cjs');
+const cli = process.env.MUSTER_CODEX_COMMAND || (() => {try {return execFileSync('/bin/sh', ['-c', 'command -v codex'], {encoding: 'utf8'}).trim();} catch {return undefined;}})();
+const skip = !cli ? 'coding client unavailable' : !existsSync(coreBundle) ? 'build core-client.cjs first' : false;
+if (skip && process.env.MUSTER_REQUIRE_CODEX_E2E === '1') throw new Error(String(skip));
+const token = 'sk-fixture-720-not-a-real-key';
+const MODEL = 'fixture-model-720';
+
+/** Every rollout file under a coding-client home's sessions folder. */
+function rollouts(home: string): string[] {
+ const out: string[] = [];
+ const walk = (dir: string) => { for (const entry of existsSync(dir) ? readdirSync(dir, {withFileTypes: true}) : []) { const path = join(dir, entry.name); if (entry.isDirectory()) walk(path); else if (entry.name.endsWith('.jsonl')) out.push(path); } };
+ walk(join(home, 'sessions'));
+ return out;
+}
+
+async function router(t: TestContext) {
+ const inputs: string[][] = [];
+ const server = createServer(async (req: IncomingMessage, res: ServerResponse) => {
+ const chunks: Buffer[] = [];
+ for await (const chunk of req) chunks.push(chunk as Buffer);
+ const path = (req.url ?? '').split('?')[0]!;
+ if (req.headers.authorization !== `Bearer ${token}`) { res.writeHead(401); res.end(); return; }
+ if (req.method === 'GET' && path === '/v1/models') { res.writeHead(200, {'content-type': 'application/json'}); res.end(JSON.stringify({object: 'list', data: [{id: MODEL, object: 'model'}]})); return; }
+ if (req.method !== 'POST' || path !== '/v1/responses') { res.writeHead(404); res.end(); return; }
+ const body = JSON.parse(Buffer.concat(chunks).toString('utf8')) as {input: Array<{type?: string; role?: string; content?: Array<{text?: string}>}>};
+ inputs.push(body.input.filter(item => item.type === 'message' && item.role === 'user').flatMap(item => (item.content ?? []).map(part => part.text ?? '')));
+ const item = {type: 'message', id: `msg_${inputs.length}`, role: 'assistant', status: 'completed', content: [{type: 'output_text', text: `answer ${inputs.length}`, annotations: []}]};
+ res.writeHead(200, {'content-type': 'text/event-stream'});
+ for (const [event, data] of [
+ ['response.created', {type: 'response.created', response: {id: `resp_${inputs.length}`, object: 'response', status: 'in_progress', model: MODEL}}],
+ ['response.output_item.done', {type: 'response.output_item.done', output_index: 0, item}],
+ ['response.completed', {type: 'response.completed', response: {id: `resp_${inputs.length}`, object: 'response', status: 'completed', model: MODEL, output: [item],
+ usage: {input_tokens: 3, input_tokens_details: {cached_tokens: 0}, output_tokens: 4, output_tokens_details: {reasoning_tokens: 0}, total_tokens: 7}}}],
+ ] as const) res.write(`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`);
+ res.end();
+ });
+ await new Promise(done => server.listen(0, '127.0.0.1', done));
+ t.after(() => new Promise(done => { server.closeAllConnections(); server.close(() => done()); }));
+ return {base: `http://127.0.0.1:${(server.address() as AddressInfo).port}/v1`, inputs};
+}
+
+async function fixture(t: TestContext) {
+ const root = await mkdtemp(join(tmpdir(), 'muster-home-720-'));
+ t.after(() => removeTmp(root));
+ const mock = await router(t);
+ const home = join(root, 'home'), codex = join(home, '.codex'), cwd = join(root, 'work'), dataDir = join(root, 'data');
+ await mkdir(codex, {recursive: true}); await mkdir(cwd);
+ const helper = join(root, 'token.sh');
+ await writeFile(helper, `#!/bin/sh\nprintf '%s' '${token}'\n`); await chmod(helper, 0o700);
+ await writeFile(join(codex, 'config.toml'), '');
+ const catalog = join(root, 'catalog.json');
+ await writeFile(catalog, codexCatalog([MODEL]));
+ const profile = `model_provider="fixture720"\nmodel_catalog_json=${JSON.stringify(catalog)}\n[model_providers.fixture720]\nname="Fixture router"\nbase_url=${JSON.stringify(mock.base)}\nwire_api="responses"\nrequest_max_retries=0\nstream_max_retries=0\n[model_providers.fixture720.auth]\ncommand=${JSON.stringify(helper)}\nargs=[]\n`;
+ await writeFile(join(codex, 'fixture.config.toml'), profile);
+ const core = createRequire(join(appDir, 'fixture.cjs'))(coreBundle) as CoreClient;
+ /** One app session: the service and coding client as Muster runs them. `shared` simulates a build from before #720. */
+ async function open(shared = false) {
+ const env = {...process.env, HOME: home, CODEX_HOME: codex, MUSTER_CODEX_COMMAND: cli!, MUSTER_PROVIDER_NODE: process.execPath, ...(shared ? {MUSTER_SHARED_CODEX_HOME: '1'} : {})};
+ delete (env as Record).OPENAI_API_KEY;
+ if (!shared) delete (env as Record).MUSTER_SHARED_CODEX_HOME;
+ invalidateProviderInstances();
+ const provider = createProviderAdapter({core, instances: () => configuredProviderInstances({directory: join(appDir, 'dist/runtime'), home, env})});
+ const service = createAgentService({dataDir, provider, onEvent() {}});
+ t.after(() => service.dispose());
+ return service;
+ }
+ async function send(service: Awaited>, id: string, text: string) {
+ await service.invoke('chat.send', {id, text, requestId: randomUUID()});
+ const deadline = Date.now() + 45_000;
+ while (Date.now() < deadline) {
+ const row = (await service.invoke('app.snapshot', undefined)).chats.find(c => c.id === id)!;
+ if (['completed', 'failed', 'interrupted'].includes(row.status)) return row;
+ await new Promise(done => setTimeout(done, 20));
+ }
+ assert.fail('chat did not settle within 45 seconds');
+ }
+ async function newChat(service: Awaited>) {
+ const folder = await service.invoke('folder.add', {path: cwd});
+ const created = await service.invoke('chat.create', {folderId: folder.id});
+ await service.invoke('chat.selectProvider', {id: created.id, providerId: 'fixture720', model: MODEL});
+ return created.id;
+ }
+ return {mock, codex, dataDir, profile, open, send, newChat};
+}
+
+// Spec: MUS-SPEC-03-720A (docs/spec/clauses/03-providers-models-and-accounts.md#mus-spec-03-720a)
+test('a new chat runs in Muster’s own coding-client home: nothing is written to the person’s home, which keeps its configuration', {skip, timeout: 120_000}, async t => {
+ const {mock, codex, dataDir, profile, open, send, newChat} = await fixture(t);
+ const service = await open();
+ const id = await newChat(service);
+ const row = await send(service, id, 'hello from a new chat');
+ assert.equal(row.status, 'completed', row.error ?? '');
+ assert.ok(mock.inputs.length >= 1, 'the run reached the router through the person’s profile');
+ assert.deepEqual(rollouts(codex), [], 'no thread is written to the person’s home, so other coding apps never list it');
+ assert.ok(!readdirSync(codex).some(name => /^state_\d+\.sqlite$/.test(name)), 'no thread index is created in the person’s home');
+ const own = join(dataDir, 'codex-home');
+ assert.ok(existsSync(own), 'Muster keeps its own coding-client home in its data folder');
+ const ownRollouts = readdirSync(own).flatMap(entry => rollouts(join(own, entry)));
+ assert.equal(ownRollouts.length, 1, 'the thread lives in Muster’s own home');
+ assert.ok(readFileSync(ownRollouts[0]!, 'utf8').includes(row.providerThreadId!), 'it is this chat’s thread');
+ assert.equal(readFileSync(join(codex, 'fixture.config.toml'), 'utf8'), profile, 'the person’s profile is untouched');
+ const second = await send(service, id, 'and a follow-up');
+ assert.equal(second.status, 'completed', second.error ?? '');
+ assert.equal(second.providerThreadId, row.providerThreadId, 'follow-ups continue the same thread');
+});
+
+// Spec: MUS-SPEC-03-720B (docs/spec/clauses/03-providers-models-and-accounts.md#mus-spec-03-720b)
+test('a chat started before the change continues its own thread with its history, without writing to the person’s home again', {skip, timeout: 120_000}, async t => {
+ const {mock, codex, open, send, newChat} = await fixture(t);
+ const before = await open(true);
+ const id = await newChat(before);
+ const first = await send(before, id, 'remember the word MARIGOLD');
+ assert.equal(first.status, 'completed', first.error ?? '');
+ const [old] = rollouts(codex);
+ assert.ok(old, 'the earlier build wrote this chat’s thread into the person’s home');
+ await before.dispose();
+ const size = statSync(old).size;
+
+ const after = await open();
+ const next = await send(after, id, 'which word did I ask you to remember?');
+ assert.equal(next.status, 'completed', next.error ?? '');
+ assert.equal(next.providerThreadId, first.providerThreadId, 'the chat keeps its thread');
+ assert.ok(mock.inputs.at(-1)!.some(text => text.includes('remember the word MARIGOLD')), 'the earlier message is still in the history the model sees');
+ assert.equal(statSync(old).size, size, 'the person’s copy of the thread is not written to again');
+ assert.equal(rollouts(codex).length, 1, 'no new thread appears in the person’s home');
+});
+
+// Spec: MUS-SPEC-03-720C (docs/spec/clauses/03-providers-models-and-accounts.md#mus-spec-03-720c)
+test('usage limits recorded by the person’s other coding apps still show for a chat that runs in Muster’s own home', {skip, timeout: 120_000}, async t => {
+ const {codex, dataDir, open, send, newChat} = await fixture(t);
+ const service = await open();
+ const id = await newChat(service);
+ assert.equal((await send(service, id, 'hello')).status, 'completed');
+ const [own] = readdirSync(join(dataDir, 'codex-home'));
+ const ownSessions = join(dataDir, 'codex-home', own!, 'sessions');
+ const day = join(codex, 'sessions', '2099', '01', '01');
+ await mkdir(day, {recursive: true});
+ await writeFile(join(day, `rollout-2099-01-01T00-00-00-${randomUUID()}.jsonl`), [
+ JSON.stringify({type: 'session_meta', payload: {model_provider: 'fixture720'}}),
+ JSON.stringify({timestamp: '2099-01-01T00:00:00Z', type: 'event_msg', payload: {type: 'token_count', rate_limits: {primary: {used_percent: 64, window_minutes: 300, resets_in_seconds: 60}}}}),
+ ].join('\n'));
+ assert.equal(sessionRateLimits(ownSessions).get('fixture720')?.primary?.usedPercent, 64, 'the newest reading from the person’s home is used');
+});