Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 25 additions & 10 deletions docs/model-semantics-and-runtime-injection.md
Original file line number Diff line number Diff line change
Expand Up @@ -133,6 +133,20 @@ of an explicitly configured Workspace provider. Vault secrets are resolved
just in time and enter only the child environment. Workspace fingerprints make
replacement visible instead of silently resuming through a different key.

Credential, model, and effort are independent optional launch dimensions. A
native credential binding means OpenAlice injects no managed key or endpoint;
the Agent runtime owns authentication and provider discovery through its normal
login/config chain. That native binding may still carry a process-level model
or effort override. A native binding with neither override is also valid and
must still traverse the adapter projection seam, even when the resulting
projection is empty.

Legacy resume identities that predate this binding contract are upgraded to an
explicit native binding on their next activation. They must not inspect and
adopt a provider that was added to the Workspace after the Session was created.
Fresh Sessions may still resolve an existing Workspace-local provider as their
creation default and persist that choice before the first process starts.

Every Agent adapter must implement `sessionRuntime.project(...)`. Registration
rejects an Agent adapter without that contract; utility adapters such as Shell
explicitly opt out. The adapter maps the same resolved binding to its native
Expand Down Expand Up @@ -218,20 +232,21 @@ replace the curated registry.

The UI must disclose configuration ownership instead of presenting every
resolved launch value as if it were already on disk. A launch surface
distinguishes a Workspace-local binding from a credential that will be written
only when the next session starts. Creation defaults are also explicitly
creation-time policy: changing one never rewrites an existing Workspace.
distinguishes a Workspace-local default from a credential that will be bound
only to the next Session. Selecting the latter must not rewrite the Workspace.
Creation defaults are also explicitly creation-time policy: changing one never
rewrites an existing Workspace.
Stable Workspace ownership stays implicit so provenance does not displace the
effective model, reasoning, and context values. When Send will write or replace
runtime configuration, that pending side effect is disclosed on its own line;
successful explicit saves use transient confirmation instead of a permanent
success state.
effective model, reasoning, and context values. When Send will apply a
Session-only provider/model override, that ownership is disclosed on its own
line; successful explicit Workspace saves use transient confirmation instead
of a permanent success state.
This disclosure applies to all four supported Agent runtimes. Claude Code and
Codex use their native global login and global runtime configuration by default.
Merely storing a compatible credential in Alice never selects or injects it;
only an explicit Workspace binding or explicit new-Workspace creation default
overrides the native fallback. When a Workspace-local override exists its real
model must be shown instead of a generic “runtime managed” label. Their native
only an explicit Session selection, Workspace binding, or new-Workspace
creation default overrides the native fallback. When a Workspace-local override
exists its real model must be shown instead of a generic “runtime managed” label. Their native
project files do not declare a context limit, so the UI omits that field rather
than borrowing the Pi/opencode injection default.

Expand Down
13 changes: 10 additions & 3 deletions plans/session-runtime-bindings.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,8 +33,10 @@ Agent runtime to implement one Session contract.
records, logs, command arguments, or fixtures.
5. Native runtime login is a first-class explicit source. A missing override
does not cause OpenAlice to pick or inject an arbitrary vault credential.
6. Legacy identities without a binding retain compatibility resolution once;
all newly-created Sessions persist an explicit binding before spawn.
6. Legacy identities without a binding upgrade to explicit native-runtime
ownership on their next activation; they never adopt mutable Workspace
provider state that appeared after the Session was created. All newly-created
Sessions persist an explicit binding before spawn.
7. Exact Session resumes replay their stored binding and reject conflicting
runtime/model/effort/credential input instead of silently changing it.

Expand Down Expand Up @@ -64,6 +66,8 @@ Agent runtime to implement one Session contract.
- [x] Preserve Issue declarations as Session-creation preferences and expose
safe effective binding metadata on Session/run projections.
- [x] Update UI/demo contracts where launch selection already exists.
- [x] Keep credential, model, and effort independently optional; make both
Quick Chat and Workspace Manager submit the visible selection atomically.

### 4. Verification and delivery

Expand All @@ -80,7 +84,10 @@ Delivered one mandatory adapter contract across Claude Code, Codex, OpenCode,
and Pi; persisted versioned bindings on `resumeId`; removed the headless-only
override seam; and verified the same binding in browser/dev and an isolated
packaged Electron scheduled-Pi run. Utility Shell Sessions remain explicitly
outside the Agent runtime binding contract.
outside the Agent runtime binding contract. A follow-up hardening pass made
legacy binding absence mean native runtime ownership, fixed login-backed
credential/model selection as one Session launch, and carried the same optional
model/effort values through Workspace Manager.

## Completion Criteria

Expand Down
34 changes: 34 additions & 0 deletions src/webui/routes/workspaces-quickchat.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -189,6 +189,7 @@ function build(opts: {
app,
opencode,
spawn,
resumeRecords,
creator,
rememberRecentChatWorkspace,
rememberAutoQuantDefaultWorkspace,
Expand Down Expand Up @@ -546,6 +547,39 @@ describe('POST /quick-chat — native auth and explicit credential overrides', (
expect(runtime.ai.apiKey).toBe('sk-second');
});

it('upgrades a legacy resumed Session to native ownership without reading current Workspace credentials', async () => {
vi.mocked(readCredentials).mockResolvedValue({});
const { app, opencode, resumeRecords, spawn } = build({
opencodeConfig: {
apiKey: 'workspace-key-added-after-session-creation',
model: 'workspace-model-added-later',
wireShape: 'openai-chat',
},
});
resumeRecords.set('resume-legacy', {
resumeId: 'resume-legacy',
wsId: 'ws-1',
agent: 'opencode',
agentSessionId: 'native-session-1',
});

const result = await spawnSession(app, {
agent: 'opencode',
resumeId: 'resume-legacy',
});

expect(result.status).toBe(201);
expect(opencode.readAiConfig).not.toHaveBeenCalled();
expect((spawn.mock.calls[0] as any[])[1].sessionRuntime).toEqual({
binding: { version: 1, credential: { source: 'native' } },
ai: null,
});
expect(resumeRecords.get('resume-legacy').runtimeBinding).toEqual({
version: 1,
credential: { source: 'native' },
});
});

it('explicit credential pick overrides a globally-ready opencode config', async () => {
vi.mocked(readCredentials).mockResolvedValue({
'openai-2': { ...openaiKey, apiKey: 'sk-second', lastModel: 'gpt-5.5-mini' },
Expand Down
17 changes: 16 additions & 1 deletion src/webui/routes/workspaces.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1311,13 +1311,28 @@ describe('Workspace manager surface routes', () => {
const result = await post(app, '/manager/quick-start', {
prompt: 'Map ownership.',
agent: 'codex',
model: 'gpt-5.6-terra',
reasoningEffort: 'high',
});
expect(result.status).toBe(201);
expect(result.body).toMatchObject({
session: { wsId: 'workspace-manager', agent: 'codex', surface: 'terminal' },
snapshot: null,
});
expect(spawnedContext).toMatchObject({ agentId: 'codex' });
expect(spawnedContext).toMatchObject({
agentId: 'codex',
sessionRuntime: {
binding: {
credential: { source: 'native' },
model: 'gpt-5.6-terra',
reasoningEffort: 'high',
},
ai: {
model: 'gpt-5.6-terra',
reasoningEffort: 'high',
},
},
});
expect(result.body).toMatchObject({ session: { title: 'Map ownership.' } });
expect(spawnedContext.initialPrompt).toContain('OpenAlice Workspace Manager');
expect(spawnedContext.initialPrompt).toContain('User request:\nMap ownership.');
Expand Down
29 changes: 20 additions & 9 deletions src/webui/routes/workspaces.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@ import {
type ModelReasoningMode,
} from '../../ai-providers/model-semantics.js';
import {
createNativeSessionRuntimeBinding,
createSessionRuntimeBinding,
resolveSessionRuntimeBinding,
SessionRuntimeBindingError,
Expand Down Expand Up @@ -322,12 +323,14 @@ export function createWorkspaceRoutes(
let sessionRuntime: ResolvedSessionRuntimeBinding | undefined;
if (isAgentRuntime(adapter)) {
try {
sessionRuntime = requestedIdentity?.runtimeBinding
? await resolveSessionRuntimeBinding({
adapter,
cwd: meta.dir,
binding: requestedIdentity.runtimeBinding,
})
sessionRuntime = requestedIdentity
? requestedIdentity.runtimeBinding
? await resolveSessionRuntimeBinding({
adapter,
cwd: meta.dir,
binding: requestedIdentity.runtimeBinding,
})
: createNativeSessionRuntimeBinding({ adapter })
: await createSessionRuntimeBinding({
adapter,
cwd: meta.dir,
Expand Down Expand Up @@ -589,6 +592,8 @@ export function createWorkspaceRoutes(
let prompt: string;
let agentId: string | undefined;
let credentialSlug: string | undefined;
let model: string | undefined;
let reasoningEffort: ModelReasoningEffort | undefined;
try {
const body = await safeJson(c);
const fields = body && typeof body === 'object' ? body as Record<string, unknown> : {};
Expand All @@ -602,6 +607,10 @@ export function createWorkspaceRoutes(
if (typeof fields['credentialSlug'] === 'string' && fields['credentialSlug'].length > 0) {
credentialSlug = fields['credentialSlug'];
}
const rawModel = fields['model'];
if (typeof rawModel === 'string' && rawModel.trim().length > 0) model = rawModel.trim();
const rawEffort = fields['reasoningEffort'];
if (isModelReasoningEffort(rawEffort)) reasoningEffort = rawEffort;
} catch (error) {
return c.json({ error: 'bad_request', message: (error as Error).message }, 400);
}
Expand All @@ -624,6 +633,8 @@ export function createWorkspaceRoutes(
const spawned = await spawnInteractiveSession(meta, {
agentId: resolvedAgentId,
...(credentialSlug ? { credentialSlug } : {}),
...(model ? { model } : {}),
...(reasoningEffort ? { reasoningEffort } : {}),
...(resolvedAgentId === 'pi' ? {} : { initialPrompt: managerTerminalPrompt(prompt) }),
title: prompt,
});
Expand Down Expand Up @@ -1437,8 +1448,8 @@ export function createWorkspaceRoutes(
prompt = seed.prompt;
const rawAgent = fields['agent'];
if (typeof rawAgent === 'string' && rawAgent.length > 0) agentId = rawAgent;
// Optional: which vault credential to seed a loginless runtime with. Only
// consulted for opencode/pi; claude/codex ignore it (own login).
// Optional Session-only vault override. Every Agent adapter owns how it
// projects the selected credential; omission preserves native auth.
const rawSlug = fields['credentialSlug'];
if (typeof rawSlug === 'string' && rawSlug.length > 0) credentialSlug = rawSlug;
const rawModel = fields['model'];
Expand Down Expand Up @@ -1646,7 +1657,7 @@ export function createWorkspaceRoutes(
cwd: meta.dir,
binding: identity.runtimeBinding,
})
: await createSessionRuntimeBinding({ adapter, cwd: meta.dir });
: createNativeSessionRuntimeBinding({ adapter });
if (!identity?.runtimeBinding) {
await svc.resumeRegistry.ensure({
resumeId: record.resumeId,
Expand Down
5 changes: 3 additions & 2 deletions src/workspaces/service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ import {
import { loadConfig, type ServerConfig } from './config.js';
import { ensureAgentCredentialReady } from './agent-credential-readiness.js';
import {
createNativeSessionRuntimeBinding,
createSessionRuntimeBinding,
resolveSessionRuntimeBinding,
type SessionRuntimeSelection,
Expand Down Expand Up @@ -1495,7 +1496,7 @@ export async function createWorkspaceService(opts: CreateWorkspaceServiceOptions
}
sessionRuntime = identity.runtimeBinding
? await resolveSessionRuntimeBinding({ adapter, cwd: ws.dir, binding: identity.runtimeBinding })
: await createSessionRuntimeBinding({ adapter, cwd: ws.dir });
: createNativeSessionRuntimeBinding({ adapter });
} else {
sessionRuntime = await createSessionRuntimeBinding({
adapter,
Expand Down Expand Up @@ -2194,7 +2195,7 @@ export async function createWorkspaceService(opts: CreateWorkspaceServiceOptions
cwd: meta.dir,
binding: identity.runtimeBinding,
})
: await createSessionRuntimeBinding({ adapter, cwd: meta.dir });
: createNativeSessionRuntimeBinding({ adapter });
if (!identity?.runtimeBinding) {
await resumeRegistry.ensure({
resumeId: record.resumeId,
Expand Down
48 changes: 48 additions & 0 deletions src/workspaces/session-runtime-binding.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import { codexAdapter } from './adapters/codex.js'
import { opencodeAdapter } from './adapters/opencode.js'
import { piAdapter } from './adapters/pi.js'
import {
createNativeSessionRuntimeBinding,
createSessionRuntimeBinding,
resolveSessionRuntimeBinding,
SessionRuntimeBindingError,
Expand Down Expand Up @@ -47,6 +48,32 @@ function fakeAdapter(readAiConfig: () => Promise<WorkspaceAiCred | null>): CliAd
}

describe('durable Session runtime binding', () => {
it('represents native credentials as an explicit optional binding with independent model and effort', () => {
const readAiConfig = vi.fn(async (): Promise<WorkspaceAiCred> => ({
apiKey: 'workspace-secret-that-must-not-be-read',
model: 'workspace-model',
wireShape: 'openai-responses',
}))
const adapter = fakeAdapter(readAiConfig)

expect(createNativeSessionRuntimeBinding({
adapter,
selection: { model: 'native-model-override', reasoningEffort: 'low' },
})).toEqual({
binding: {
version: 1,
credential: { source: 'native' },
model: 'native-model-override',
reasoningEffort: 'low',
},
ai: {
model: 'native-model-override',
reasoningEffort: 'low',
},
})
expect(readAiConfig).not.toHaveBeenCalled()
})

it('persists a vault reference and resolved model without persisting its key', async () => {
const resolved = await createSessionRuntimeBinding({
adapter: codexAdapter,
Expand Down Expand Up @@ -172,6 +199,27 @@ describe('built-in Agent Session runtime projection', () => {
},
)

it.each([claudeAdapter, codexAdapter, opencodeAdapter, piAdapter])(
'$id accepts a credentialless native binding and still projects model/effort',
(adapter) => {
const native = createNativeSessionRuntimeBinding({
adapter,
selection: { model: 'native-model-override', reasoningEffort: 'medium' },
})
const projected = adapter.sessionRuntime!.project(ctx, native)
const serializedEnv = Object.values(projected.env).join(' ')
const serializedArgs = [
...projected.interactiveArgs,
...projected.headlessArgs,
...(projected.webArgs ?? []),
].join(' ')

expect(serializedArgs).toContain('native-model-override')
expect(serializedEnv).not.toContain('sk-')
expect(native.ai).not.toHaveProperty('apiKey')
},
)

it('projects the native model and effort flags on every launch surface', () => {
expect(claudeAdapter.sessionRuntime!.project(ctx, runtime).interactiveArgs)
.toEqual(['--model', 'session-model', '--effort', 'high'])
Expand Down
41 changes: 30 additions & 11 deletions src/workspaces/session-runtime-binding.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,35 @@ export interface SessionRuntimeSelection {
readonly reasoningEffort?: SessionRuntimeBinding['reasoningEffort']
}

/**
* Freeze an explicit native-runtime binding without consulting Workspace files.
*
* This is intentionally separate from `createSessionRuntimeBinding()`: fresh
* Sessions may adopt a Workspace-local creation default, while an existing
* legacy Session whose identity predates runtime bindings must not silently
* adopt whatever provider happens to be in that Workspace today. Native auth,
* model, and provider discovery remain owned by the child runtime; OpenAlice
* may still project an independently persisted model or effort override.
*/
export function createNativeSessionRuntimeBinding(input: {
readonly adapter: CliAdapter
readonly selection?: Omit<SessionRuntimeSelection, 'credentialSlug'>
}): ResolvedSessionRuntimeBinding {
assertedAgentContract(input.adapter)
const selection = input.selection ?? {}
const binding: SessionRuntimeBinding = {
version: 1,
credential: { source: 'native' },
...modelFields(selection.model, selection.reasoningEffort),
}
return {
binding,
ai: binding.model || binding.reasoningEffort
? { model: binding.model ?? null, reasoningEffort: binding.reasoningEffort ?? null }
: null,
}
}

function providerFingerprint(ai: WorkspaceAiCred): string {
return createHash('sha256').update(JSON.stringify({
baseUrl: ai.baseUrl ?? null,
Expand Down Expand Up @@ -134,17 +163,7 @@ export async function createSessionRuntimeBinding(input: {

const workspace = await input.adapter.readAiConfig?.(input.cwd).catch(() => null) ?? null
if (!workspace) {
const binding: SessionRuntimeBinding = {
version: 1,
credential: { source: 'native' },
...modelFields(selection.model, selection.reasoningEffort),
}
return {
binding,
ai: binding.model || binding.reasoningEffort
? { model: binding.model ?? null, reasoningEffort: binding.reasoningEffort ?? null }
: null,
}
return createNativeSessionRuntimeBinding({ adapter: input.adapter, selection })
}

const selectedModel = selection.model ?? workspace.model ?? undefined
Expand Down
Loading
Loading