Skip to content

Commit ece2a68

Browse files
WirasmArchon Maintainer Bot
andauthored
fix: user-scope console chat and resolve chat credentials from the sender (#1982)
* fix: user-scope console chat and resolve chat credentials from the sender On multi-user installs every user shared one per-project console chat, and the orchestrator resolved per-user AI prefs + provider credentials from conversation.user_id (the first creator) — so another user's turns executed on the creator's API keys and model prefs. - orchestrator: introduce executionUserId = sender ?? conversation.user_id and use it at both resolution seams (prefs + provider env) and in the profile-invalid log. Mirrors the workflow executor's run-starter resolution. Creator stays as fallback, so solo installs are unchanged. - console: listConversations now passes the existing non-enforcing mine=true filter, giving each signed-in user their own lazily-created per-project conversation (and thus their own AI session). - tests: divergence coverage for sender-vs-creator in both the prefs and credential-injection suites, plus an explicit creator-fallback case. Closes #1976 * docs: sync CLAUDE.md prefs-seam description with sender-first resolution The user_ai_prefs section still described the chat seam as keyed on conversation.user_id; it now resolves sender-first with the creator as fallback. Also records the accepted shared-thread consequences (per-turn provider variance on Slack/Telegram threads; shared history rides the sender's billed call). * docs: sender-first prefs resolution in database reference (matches CLAUDE.md sync) * review: thread gate/resume actor identity, trace identity fallbacks, harden tests Addresses the multi-agent review on #1982 (I/S ids from that comment): - I1: the two system-initiated dispatch sites no longer execute on the conversation creator. tryAutoResumeAfterGate takes the gate actor's identity (resolved via resolveWebUserId at the approve/reject routes — both callers are HTTP routes with the request in scope, so threading beats the suggested comment-as-intentional), and the run-resume route dispatches as the resuming user. Solo installs pass undefined → creator fallback, unchanged. - I2: orchestrator warns (orchestrator.execution_identity_creator_fallback) when a turn arrives without sender identity while per-user credentials are active — makes a degraded auth resolution distinguishable from the silent solo-install path. - S1: three test hardenings — env-seam fallback argument pinned ('u-test'), combined both-seams-same-identity test (guards seam divergence), profile-invalid log attribution asserted ('sender-2'). - S2: GET /api/conversations warns (api.mine_filter_identity_unresolved) when mine=true narrowing was requested but no identity resolved on a web-auth install; silent without web auth since the console always sends mine=true on solo installs. - S3: CLAUDE.md sweep — conversations entry (provenance vs execution), user_provider_keys (acting user's env), user_ai_prefs table entry (sender-first seam description). Test adjustment: 'skips injection when feature is disabled' now uses a persistent mockReturnValue — the install-level flag is read twice per turn (I2 guard + env seam), so Once was incidental, not semantic. --------- Co-authored-by: Archon Maintainer Bot <maintainer-implementer@archon.local>
1 parent e250e7a commit ece2a68

7 files changed

Lines changed: 150 additions & 21 deletions

File tree

CLAUDE.md

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -454,7 +454,7 @@ import type { DagNode, WorkflowDefinition } from '@/lib/api';
454454

455455
**18 Tables (all prefixed with `remote_agent_`):**
456456
1. **`codebases`** - Repository metadata and commands (JSONB)
457-
2. **`conversations`** - Track platform conversations with titles and soft-delete support; nullable `user_id` records first creator
457+
2. **`conversations`** - Track platform conversations with titles and soft-delete support; nullable `user_id` records first creator (provenance + execution-identity **fallback** only — chat turns execute as the message sender, #1982)
458458
3. **`sessions`** - Track AI SDK sessions with resume capability
459459
4. **`isolation_environments`** - Git worktree isolation tracking; nullable `created_by_user_id` preserves first creator
460460
5. **`workflow_runs`** - Workflow execution tracking and state; nullable `user_id` for per-run attribution
@@ -465,8 +465,8 @@ import type { DagNode, WorkflowDefinition } from '@/lib/api';
465465
10. **`user_identities`** - Per-platform mapping (Slack U-id, Telegram chat id, Discord snowflake, GitHub login, Better Auth web user id) → `users.id`; `UNIQUE(platform, platform_user_id)`
466466
11. **`workflow_node_sessions`** - Per-node provider session IDs persisted across workflow re-runs (opt-in via `persist_session`); keyed by `(workflow_name, node_id, scope_key, provider)`; `scope_key` is typically the conversation UUID
467467
12. **`user_github_tokens`** - Per-user GitHub device-flow tokens encrypted at rest (AES-256-GCM); one row per Archon user (`UNIQUE(user_id)`), cascades on user deletion; numeric `github_user_id` anchors the commit no-reply email
468-
13. **`user_provider_keys`** - Per-user AI-provider credentials encrypted at rest (AES-256-GCM, same `TOKEN_ENCRYPTION_KEY`); one row per `(user_id, provider)` (`UNIQUE(user_id, provider)`), cascades on user deletion; `kind` is `api_key` or `oauth`; resolved + injected into the user's runs/chat env at execution time. Gated on `TOKEN_ENCRYPTION_KEY`. Since #1955 the `provider` column holds **vendor-canonical credential ids** (`anthropic`, `openai`, `github-copilot`, plus the Pi backend vendors) — NOT agent ids; legacy `claude`/`codex`/`copilot` rows are renamed by an idempotent startup data fix (vendor row wins on conflict), and the connectable catalog is derived from provider registrations (`acceptedCredentials` via `credentials:` on `ProviderRegistration`), never hand-listed
469-
14. **`user_ai_prefs`** - Per-user AI preferences (Phase 3): personal model `tiers`/`aliases` (JSON-as-TEXT) + `default_provider`. NON-encrypted (model names aren't secrets — mirrors `codebase_env_vars`, not the provider-key store); one row per user (`UNIQUE(user_id)`), cascades on user deletion. Folded into `buildAiProfile` as the highest-precedence layer at the userId-aware seams (workflow executor + chat orchestrator); needs a web/CLI identity but NO `TOKEN_ENCRYPTION_KEY`
468+
13. **`user_provider_keys`** - Per-user AI-provider credentials encrypted at rest (AES-256-GCM, same `TOKEN_ENCRYPTION_KEY`); one row per `(user_id, provider)` (`UNIQUE(user_id, provider)`), cascades on user deletion; `kind` is `api_key` or `oauth`; resolved + injected into the **acting user's** (run starter / message sender) runs/chat env at execution time. Gated on `TOKEN_ENCRYPTION_KEY`. Since #1955 the `provider` column holds **vendor-canonical credential ids** (`anthropic`, `openai`, `github-copilot`, plus the Pi backend vendors) — NOT agent ids; legacy `claude`/`codex`/`copilot` rows are renamed by an idempotent startup data fix (vendor row wins on conflict), and the connectable catalog is derived from provider registrations (`acceptedCredentials` via `credentials:` on `ProviderRegistration`), never hand-listed
469+
14. **`user_ai_prefs`** - Per-user AI preferences (Phase 3): personal model `tiers`/`aliases` (JSON-as-TEXT) + `default_provider`. NON-encrypted (model names aren't secrets — mirrors `codebase_env_vars`, not the provider-key store); one row per user (`UNIQUE(user_id)`), cascades on user deletion. Folded into `buildAiProfile` as the highest-precedence layer at the userId-aware seams (workflow executor: run starter; chat orchestrator: message **sender**-first, conversation creator only as fallback — #1982); needs a web/CLI identity but NO `TOKEN_ENCRYPTION_KEY`
470470
15–18. **`remote_agent_auth_user` / `remote_agent_auth_session` / `remote_agent_auth_account` / `remote_agent_auth_verification`** - Better Auth tables for opt-in web login (**PostgreSQL only**; always created on Postgres via the idempotent schema apply, but populated only when web auth is enabled — `DATABASE_URL` + `BETTER_AUTH_SECRET`). Owned and shaped by Better Auth (text ids, camelCase columns); Archon never queries them directly — a session maps to the canonical `users` row via `user_identities('web', <betterAuthUserId>)`
471471

472472
**Key Patterns:**
@@ -930,7 +930,7 @@ Pattern: Use `classifyIsolationError()` (from `@archon/isolation`) to map git er
930930
**Per-User AI Prefs (Phase 3; `requireWebUser` — identity only, NO `TOKEN_ENCRYPTION_KEY`):**
931931
- `GET /api/auth/me/ai-prefs` - The current user's stored prefs (raw per-user layer, not merged with config); returns `{ tiers?, aliases?, defaultProvider? }`. 401 without identity — the console hides "Just me" on failure.
932932
- `PATCH /api/auth/me/ai-prefs/tiers` / `…/aliases` / `…/default` - Per-key merge writes (`null` unsets); validate provider via `isRegisteredProvider`, effort via `isEffortValidForProvider`, alias names (`@` prefix, not a reserved tier keyword). All return the updated prefs.
933-
- Stored in `remote_agent_user_ai_prefs` (non-encrypted); folded into `buildAiProfile` as the **highest-precedence** layer (global < repo < user) at the userId-aware seams — workflow executor (`deps.getUserAiPrefs`) and chat orchestrator (`conversation.user_id`). The per-user `defaultProvider` rebases tier defaults and the chat assistant. No identity → byte-for-byte config-only behavior (solo unchanged). A chat request for tier `large` that resolves via the fallback chain emits a one-line non-blocking nudge (`orchestrator.tier_fallback_nudge`).
933+
- Stored in `remote_agent_user_ai_prefs` (non-encrypted); folded into `buildAiProfile` as the **highest-precedence** layer (global < repo < user) at the userId-aware seams — workflow executor (`deps.getUserAiPrefs`, resolved from the run starter) and chat orchestrator (sender-first: `executionUserId = context.userId ?? conversation.user_id` — the SENDER's prefs and credentials win; the conversation creator is only the fallback when no sender identity resolves, see #1982). The per-user `defaultProvider` rebases tier defaults and the chat assistant. No identity → byte-for-byte config-only behavior (solo unchanged). A chat request for tier `large` that resolves via the fallback chain emits a one-line non-blocking nudge (`orchestrator.tier_fallback_nudge`). Note: on genuinely shared threads (Slack/Telegram), per-sender prefs mean the provider can differ per turn within one thread (session transitions churn accordingly), and a sender's turn carries the shared thread history into a call billed to their credential — accepted semantics.
934934

935935
**Config (System; ungated — works on solo installs, NOT `requireWebUser`):**
936936
- `GET /api/config` - Read-only safe config; returns `{ config, database }`. `config` includes `tiers` (configured small/medium/large presets), `tierDefaults` (built-in presets for the default provider, computed via `buildAiProfile` — lets the UI show what an unset tier resolves to), and `aliases` (configured `@custom` aliases, merged repo > global).

packages/core/src/orchestrator/orchestrator-agent.test.ts

Lines changed: 82 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2523,14 +2523,48 @@ describe('resolveUserProviderEnvForChat — chat env injection', () => {
25232523
});
25242524

25252525
test('skips injection when feature is disabled', async () => {
2526-
mockIsPerUserProviderKeysEnabled.mockReturnValueOnce(false);
2526+
// Persistent (not Once): the flag is install-level and read more than once
2527+
// per turn (creator-fallback warn guard + the env seam itself).
2528+
mockIsPerUserProviderKeysEnabled.mockReturnValue(false);
25272529
mockListDecryptedUserProviderCredentials.mockResolvedValueOnce([
25282530
{ provider: 'openrouter', cred: { kind: 'api_key', apiKey: 'or-key' } },
25292531
]);
25302532
const platform = makePlatform();
25312533
await handleMessage(platform, 'conv-1', 'hello');
25322534
expect(mockListDecryptedUserProviderCredentials).not.toHaveBeenCalled();
25332535
});
2536+
2537+
test('resolves credentials from the SENDER, not the conversation creator (#1976)', async () => {
2538+
// beforeEach sets the conversation row's user_id to 'u-test' (the creator).
2539+
// A different sender on this turn must use their OWN credentials — never
2540+
// the creator's.
2541+
mockListDecryptedUserProviderCredentials.mockResolvedValueOnce([
2542+
{ provider: 'openrouter', cred: { kind: 'api_key', apiKey: 'sender-key' } },
2543+
]);
2544+
const platform = makePlatform();
2545+
await handleMessage(platform, 'conv-1', 'hello', { userId: 'sender-2' });
2546+
expect(mockListDecryptedUserProviderCredentials).toHaveBeenCalledWith('sender-2');
2547+
expect(mockListDecryptedUserProviderCredentials).not.toHaveBeenCalledWith('u-test');
2548+
});
2549+
2550+
test('falls back to the conversation creator for credentials when no sender (S1)', async () => {
2551+
// Pins the env seam's fallback ARGUMENT — the prefs seam already asserts
2552+
// its fallback attribution; without this, a regression of the env seam to
2553+
// `undefined` would slip through the mock unnoticed.
2554+
const platform = makePlatform();
2555+
await handleMessage(platform, 'conv-1', 'hello');
2556+
expect(mockListDecryptedUserProviderCredentials).toHaveBeenCalledWith('u-test');
2557+
});
2558+
2559+
test('prefs and credentials resolve to the SAME identity in a single turn (S1)', async () => {
2560+
// Guards seam divergence: each seam's revert is caught individually, but
2561+
// one seam silently using a different identity than the other is not.
2562+
mockGetUserAiPrefsDb.mockClear();
2563+
const platform = makePlatform();
2564+
await handleMessage(platform, 'conv-1', 'hello', { userId: 'sender-2' });
2565+
expect(mockGetUserAiPrefsDb).toHaveBeenCalledWith('sender-2');
2566+
expect(mockListDecryptedUserProviderCredentials).toHaveBeenCalledWith('sender-2');
2567+
});
25342568
});
25352569

25362570
// ─── handleMessage — /setproject dispatch ─────────────────────────────────────
@@ -2837,6 +2871,32 @@ describe('per-user AI prefs in chat + tier-fallback nudge', () => {
28372871
expect(mockGetUserAiPrefsDb).not.toHaveBeenCalled();
28382872
});
28392873

2874+
test("the sender's prefs win over the conversation creator's (#1976)", async () => {
2875+
// Multi-user thread: the conversation row carries the FIRST creator, but
2876+
// the turn must execute with the SENDER's prefs (mirrors the workflow
2877+
// executor's run-starter resolution).
2878+
mockGetOrCreateConversation.mockReturnValueOnce(
2879+
Promise.resolve(makeConversation({ user_id: 'creator-1' } as Partial<Conversation>))
2880+
);
2881+
2882+
const platform = makePlatform();
2883+
await handleMessage(platform, 'conv-1', 'Hello', { userId: 'sender-2' });
2884+
2885+
expect(mockGetUserAiPrefsDb).toHaveBeenCalledWith('sender-2');
2886+
expect(mockGetUserAiPrefsDb).not.toHaveBeenCalledWith('creator-1');
2887+
});
2888+
2889+
test('falls back to conversation.user_id when the context has no sender', async () => {
2890+
mockGetOrCreateConversation.mockReturnValueOnce(
2891+
Promise.resolve(makeConversation({ user_id: 'creator-1' } as Partial<Conversation>))
2892+
);
2893+
2894+
const platform = makePlatform();
2895+
await handleMessage(platform, 'conv-1', 'Hello', {});
2896+
2897+
expect(mockGetUserAiPrefsDb).toHaveBeenCalledWith('creator-1');
2898+
});
2899+
28402900
test('structurally invalid stored prefs degrade to config-only (chat still answers)', async () => {
28412901
mockGetOrCreateConversation.mockReturnValueOnce(
28422902
Promise.resolve(makeConversation({ user_id: 'user-9' } as Partial<Conversation>))
@@ -2853,6 +2913,27 @@ describe('per-user AI prefs in chat + tier-fallback nudge', () => {
28532913
expect(mockSendQuery).toHaveBeenCalled();
28542914
});
28552915

2916+
test('profile-invalid log attributes the EXECUTION identity, not the creator (S1)', async () => {
2917+
mockGetOrCreateConversation.mockReturnValueOnce(
2918+
Promise.resolve(makeConversation({ user_id: 'creator-1' } as Partial<Conversation>))
2919+
);
2920+
// Sender's stored prefs are corrupt → buildAiProfile throws → degrade path
2921+
// logs the identity whose prefs were at fault: the sender.
2922+
mockGetUserAiPrefsDb.mockImplementation(async () => ({
2923+
aliases: { fast: { provider: 'claude', model: 'haiku' } },
2924+
}));
2925+
mockLogger.error.mockClear();
2926+
2927+
const platform = makePlatform();
2928+
await handleMessage(platform, 'conv-1', 'Hello', { userId: 'sender-2' });
2929+
2930+
const invalidLog = mockLogger.error.mock.calls.find(
2931+
c => c[1] === 'orchestrator.user_ai_prefs_profile_invalid'
2932+
) as [Record<string, unknown>, string] | undefined;
2933+
expect(invalidLog).toBeDefined();
2934+
expect(invalidLog?.[0].userId).toBe('sender-2');
2935+
});
2936+
28562937
test('a prefs DB failure falls back to config-only (chat still answers)', async () => {
28572938
mockGetOrCreateConversation.mockReturnValueOnce(
28582939
Promise.resolve(makeConversation({ user_id: 'user-9' } as Partial<Conversation>))

packages/core/src/orchestrator/orchestrator-agent.ts

Lines changed: 25 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -878,7 +878,10 @@ export async function handleMessage(
878878

879879
// 1. Get/create conversation and inherit thread context.
880880
// userId is recorded on the conversation row only on first creation —
881-
// first-user-wins. Per-message attribution happens on workflow_runs.
881+
// first-user-wins. The row's user_id is provenance plus a fallback for
882+
// execution identity; each turn's prefs/credentials resolve from the
883+
// SENDER when the adapter supplied one (see executionUserId below).
884+
// Per-message attribution happens on workflow_runs.
882885
let conversation = await db.getOrCreateConversation(
883886
platform.getPlatformType(),
884887
conversationId,
@@ -1195,12 +1198,26 @@ export async function handleMessage(
11951198
// Reuse the config already loaded during workflow discovery (avoids a second disk read).
11961199
// Fall back to loadConfig only when no codebase is scoped (discoveredConfig is undefined).
11971200
const config = discoveredConfig ?? (await loadConfig());
1201+
// Execution identity: the message sender when the adapter resolved one,
1202+
// else the conversation creator (solo installs / legacy rows / surfaces
1203+
// without auth). Sender-first mirrors the workflow executor, which
1204+
// resolves prefs from the run starter — without it, a multi-user thread
1205+
// would execute every turn on the creator's credentials (#1976).
1206+
const executionUserId = userId ?? conversation.user_id ?? undefined;
1207+
if (!userId && conversation.user_id && isPerUserProviderKeysEnabled()) {
1208+
// No sender identity arrived with this turn while per-user credentials
1209+
// are active — the turn executes (and bills) as the conversation
1210+
// CREATOR. Distinguishes a degraded auth resolution from the normal
1211+
// solo-install path (where per-user keys are off and this stays silent).
1212+
getLog().warn(
1213+
{ conversationId, fallbackUserId: conversation.user_id },
1214+
'orchestrator.execution_identity_creator_fallback'
1215+
);
1216+
}
11981217
// Per-user AI prefs (Phase 3): the user's tiers/aliases/default-assistant
11991218
// override install config (highest precedence). `{}` (no identity, no row,
12001219
// or DB failure) keeps config-only behavior byte-for-byte.
1201-
const userAiPrefs = conversation.user_id
1202-
? await resolveUserAiPrefsForChat(conversation.user_id)
1203-
: {};
1220+
const userAiPrefs = executionUserId ? await resolveUserAiPrefsForChat(executionUserId) : {};
12041221
let configuredProviderKey = userAiPrefs.defaultProvider ?? conversation.ai_assistant_type;
12051222
let aiProfile: ReturnType<typeof buildAiProfile>;
12061223
try {
@@ -1215,7 +1232,7 @@ export async function handleMessage(
12151232
// user's chat — degrade to config-only. A broken config layer still
12161233
// fails fast: the rebuild rethrows the same error.
12171234
getLog().error(
1218-
{ err: profileErr as Error, userId: conversation.user_id },
1235+
{ err: profileErr as Error, userId: executionUserId },
12191236
'orchestrator.user_ai_prefs_profile_invalid'
12201237
);
12211238
configuredProviderKey = conversation.ai_assistant_type;
@@ -1279,10 +1296,10 @@ export async function handleMessage(
12791296
// file writes (Codex `CODEX_HOME/auth.json` for the ChatGPT subscription
12801297
// path) are dropped here and only apply to workflow runs. Merged LAST so
12811298
// a connected user's keys win over file/db env. No-op when the feature is
1282-
// disabled or the conversation has no originating user.
1299+
// disabled or no execution identity resolved (sender, else creator).
12831300
const userProviderEnv =
1284-
isPerUserProviderKeysEnabled() && conversation.user_id
1285-
? await resolveUserProviderEnvForChat(conversation.user_id)
1301+
isPerUserProviderKeysEnabled() && executionUserId
1302+
? await resolveUserProviderEnvForChat(executionUserId)
12861303
: {};
12871304
const effectiveEnv = { ...(config.envVars ?? {}), ...dbEnvVars, ...userProviderEnv };
12881305

packages/docs-web/src/content/docs/reference/database.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -140,7 +140,7 @@ The database has 18 tables, all prefixed with `remote_agent_`:
140140

141141
14. **`remote_agent_user_ai_prefs`** - Per-user AI preferences (personal model tiers, `@custom` aliases, default assistant)
142142
- NON-encrypted (model names aren't secrets); one row per user (`UNIQUE(user_id)`), cascades on user deletion
143-
- `tiers` / `aliases` are JSON-as-TEXT; folded into model resolution as the highest-precedence layer for runs/chats that user starts
143+
- `tiers` / `aliases` are JSON-as-TEXT; folded into model resolution as the highest-precedence layer. Resolution follows the **acting user**: workflow runs use the run starter; chat turns use the message **sender** (the conversation creator's row is only a fallback when no sender identity resolves)
144144
- Editable via the console "Just me" scope, `archon ai … --scope user`, or `/api/auth/me/ai-prefs*`
145145

146146
15–18. **`remote_agent_auth_user` / `remote_agent_auth_session` / `remote_agent_auth_account` / `remote_agent_auth_verification`** - Better Auth tables for opt-in web login

0 commit comments

Comments
 (0)