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
4 changes: 2 additions & 2 deletions cli/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion cli/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@yc-software/qm",
"version": "0.1.2",
"version": "0.1.3",
"license": "MIT",
"description": "Control-plane CLI for portable QM deployments on Docker, Fly, and AWS.",
"type": "module",
Expand Down
23 changes: 23 additions & 0 deletions cli/templates/deployment/deployment.md
Original file line number Diff line number Diff line change
Expand Up @@ -143,6 +143,29 @@ endpoints and the email gate in `env.portal`. For Google Workspace:
}
```

### Playground mode

A playground is a public try-it deployment: unauthenticated visitors get
anonymous browser-pinned identities instead of a sign-in page, while the one
administrator still signs in through whichever route above the deployment
configured. Enable it in `qm.config.jsonc`:

```json
"env": { "portal": { "PORTAL_PLAYGROUND": "1" } }
```

A playground must be its **own deployment**, never a flag on a working org's
instance: every visitor is an ordinary internal principal of the deployment's
org, so anything granted or published at org scope — including org-granted
credentials — is theirs. Grant nothing sensitive at org scope, connect no real
connector credentials, and load no company data. A cleared cookie is a fresh
identity, so set `env.core.ORG_BUDGET_USD_PER_WINDOW` — the one hard spend
ceiling — in the same pass, and from the Admin page after first boot restrict
the model picker to the subset you want to offer (one model or several).
Nothing garbage-collects an abandoned visitor's scope yet.
`plugins/portal/README.md` § "Playground mode" covers the rest: per-address
mint limits, the boot refusals, and what anonymous visitors are denied.

### The base model

Whichever sign-in route the deployment takes, the base model needs a key in the
Expand Down
8 changes: 5 additions & 3 deletions src/api/app-turn.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import { isHalt, routeWake, type Wake } from "../wake/wake.ts";
import type { OrchestratorInput } from "../core/orchestrator.ts";
import { resolveTurnOrigin } from "../core/turn-origin.ts";
import { isTerminal, leaseLapsed } from "../runs/run-store.ts";
import { turnModelOptions, validateWebTurnModelOptions } from "../core/turn-options.ts";
import { turnModelOptions, validateWebTurnModelOptions, webTurnRuntimeModelRefusal } from "../core/turn-options.ts";
import { isProjectGroupRef, projectIdFromGroupRef } from "../projects/project-store.ts";
import {
defaultModelForHarness,
Expand Down Expand Up @@ -172,7 +172,7 @@ export function createTurnMethods(
const configuredWebuiModels = await deps.config.getWebuiModelsDurable(org);
let enabledWebuiModels: string[] | null = null;
if (configuredWebuiModels?.length) {
enabledWebuiModels = configuredWebuiModels;
enabledWebuiModels = [...new Set([...configuredWebuiModels, orgRuntime.modelId])];
} else if (providers?.openrouter) {
enabledWebuiModels = [
...new Set([
Expand All @@ -184,7 +184,9 @@ export function createTurnMethods(
]),
];
}
const invalidModelOption = validateWebTurnModelOptions(req, enabledWebuiModels, providers);
const invalidModelOption =
validateWebTurnModelOptions(req, enabledWebuiModels, providers) ??
webTurnRuntimeModelRefusal(runtime.modelId, orgRuntime.modelId, configuredWebuiModels);
if (invalidModelOption) return { status: "refused", reason: invalidModelOption };
}

Expand Down
14 changes: 13 additions & 1 deletion src/api/routes/surface.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1015,6 +1015,16 @@ async function getRuntimeConfig(ctx: ApiCtx): Promise<void> {
return sendJson(ctx.res, 200, await runtimeConfigBody(ctx, target.scope));
}

async function webuiModelEnabled(ctx: ApiCtx, modelId: string): Promise<boolean> {
const config = ctx.deps.config!;
const picker = await config.getWebuiModelsDurable(orgScope(ctx.deps));
if (!picker?.length || picker.includes(modelId)) return true;
const org = orgScope(ctx.deps);
const stored = await config.getRuntimeSelectionDurable(org);
const orgModel = stored?.modelId ?? (await config.getBaseModelOwnDurable(org)) ?? runtimeFallback(ctx).modelId;
return modelId === orgModel;
}

async function putRuntimeConfig(ctx: ApiCtx): Promise<void> {
if (!ctx.deps.config || !isObj(ctx.body)) return sendJson(ctx.res, 400, { error: "bad_request" });
if (ctx.capability && ctx.capability.liveActor !== true)
Expand All @@ -1033,7 +1043,8 @@ async function putRuntimeConfig(ctx: ApiCtx): Promise<void> {
if (
legacyModel &&
approved.includes(fallback.harnessId) &&
modelSupportedByHarness(legacyModel, fallback.harnessId)
modelSupportedByHarness(legacyModel, fallback.harnessId) &&
(await webuiModelEnabled(ctx, legacyModel))
) {
await config.setRuntimeSelectionLatest(target.scope, { harnessId: fallback.harnessId, modelId: legacyModel });
}
Expand All @@ -1047,6 +1058,7 @@ async function putRuntimeConfig(ctx: ApiCtx): Promise<void> {
return sendJson(ctx.res, 400, { error: "harness_not_approved" });
if (typeof modelId !== "string" || !modelSupportedByHarness(modelId, harnessId))
return sendJson(ctx.res, 400, { error: "model_not_supported" });
if (!(await webuiModelEnabled(ctx, modelId))) return sendJson(ctx.res, 400, { error: "model_not_enabled" });
await config.setRuntimeSelectionLatest(target.scope, { harnessId, modelId });
}
audit(ctx.deps, {
Expand Down
10 changes: 10 additions & 0 deletions src/core/turn-options.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,16 @@ export function turnModelOptions(input: { triggered?: boolean; thinkingLevel?: s
};
}

export function webTurnRuntimeModelRefusal(
runtimeModelId: string,
orgModelId: string,
configuredWebuiModels: readonly string[] | null | undefined,
): string | null {
if (!configuredWebuiModels?.length) return null;
if (runtimeModelId === orgModelId) return null;
return configuredWebuiModels.includes(runtimeModelId) ? null : "that model is not enabled for the web UI";
}

export function validateWebTurnModelOptions(
input: { model?: string; thinkingLevel?: string },
enabledModels: readonly string[] | null,
Expand Down
13 changes: 13 additions & 0 deletions test/admin-resources.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -249,6 +249,19 @@ test("runtime-config lets a person set, keep, and inherit an approved personal r
]);
assert.deepEqual(initial.modelsByHarness.codex, ["gpt-5.6-sol", "gpt-5.6-terra", "gpt-5.6-luna"]);

const outsidePicker = await fetch(`${srv.base}/v1/runtime-config`, {
method: "PUT",
headers: { "content-type": "application/json" },
body: JSON.stringify({
principalId: "alice",
scopeId: "personal:alice",
harnessId: "claude",
modelId: "claude-haiku-4-5",
}),
});
assert.equal(outsidePicker.status, 400);
assert.equal(((await outsidePicker.json()) as { error: string }).error, "model_not_enabled");

const set = await fetch(`${srv.base}/v1/runtime-config`, {
method: "PUT",
headers: { "content-type": "application/json" },
Expand Down
35 changes: 35 additions & 0 deletions test/model-credential-route.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -411,3 +411,38 @@ test("admin model credentials survive a second app instance on the same durable
assert.doesNotMatch(JSON.stringify(await backing.all()), /durable-openrouter-key/);
assert.doesNotMatch(JSON.stringify(await second.statuses()), /durable-openrouter-key/);
});

test("a stored scope override outside the configured picker refuses web turns; the org default stays exempt", async () => {
const srv = start({ anthropicApiKey: "deployment-anthropic-key" });
try {
srv.built.config.setRuntimeSelection("org:default-org", { harnessId: "mock", modelId: "claude-opus-4-8" });
srv.built.config.setWebuiModels("org:default-org", ["claude-sonnet-4-6"]);
await srv.built.config.flushScope("org:default-org");
await srv.built.config.setRuntimeSelectionLatest("personal:alice", {
harnessId: "mock",
modelId: "claude-haiku-4-5",
});
const turn = (threadRef: string, model?: string) =>
srv.built.app.turn({
surface: "web",
actor: { externalId: "alice" },
conversation: { kind: "dm", threadRef },
text: "hello",
...(model ? { model } : {}),
async: true,
});

const stale = await turn("web:alice:stale-override");
assert.equal(stale.status, "refused");
assert.match(stale.reason ?? "", /not enabled for the web UI/);

const explicitOrgDefault = await turn("web:alice:org-default", "claude-opus-4-8");
assert.equal(explicitOrgDefault.status, "queued");

await srv.built.config.setRuntimeSelectionLatest("personal:alice", null);
const inherited = await turn("web:alice:inherited");
assert.equal(inherited.status, "queued");
} finally {
await srv.close();
}
});
13 changes: 13 additions & 0 deletions test/turn-options.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import {
NON_INTERACTIVE_THINKING_LEVEL,
turnModelOptions,
validateWebTurnModelOptions,
webTurnRuntimeModelRefusal,
} from "../src/core/turn-options.ts";

test("triggered turns default to extra-high thinking and non-fast mode", () => {
Expand All @@ -30,6 +31,18 @@ test("web model controls are bounded by admin configuration", () => {
assert.equal(validateWebTurnModelOptions({ model: "claude-opus-4-8", thinkingLevel: "high" }, null), null);
});

test("a resolved scope override outside the configured picker is refused, the org default is not", () => {
const picker = ["claude-sonnet-4-6"];
assert.equal(
webTurnRuntimeModelRefusal("claude-opus-4-8", "claude-sonnet-4-6", picker),
"that model is not enabled for the web UI",
);
assert.equal(webTurnRuntimeModelRefusal("claude-sonnet-4-6", "claude-opus-4-8", picker), null);
assert.equal(webTurnRuntimeModelRefusal("claude-opus-4-8", "claude-opus-4-8", picker), null);
assert.equal(webTurnRuntimeModelRefusal("claude-opus-4-8", "claude-sonnet-4-6", null), null);
assert.equal(webTurnRuntimeModelRefusal("claude-opus-4-8", "claude-sonnet-4-6", []), null);
});

test("interactive turns do not force model options", () => {
assert.deepEqual(turnModelOptions({}), {});
});
Expand Down