diff --git a/cmd/crabbox-ssh-gateway/main.go b/cmd/crabbox-ssh-gateway/main.go index 28dd02a..7b2f152 100644 --- a/cmd/crabbox-ssh-gateway/main.go +++ b/cmd/crabbox-ssh-gateway/main.go @@ -562,8 +562,9 @@ func printHelp(out io.Writer, user user) { fmt.Fprintln(out, "commands:") fmt.Fprintln(out, " whoami") fmt.Fprintln(out, " list") - fmt.Fprintln(out, " new [--repo owner/repo] [--branch main] [--runtime crabbox|container] [--parent id] [--purpose text] [--command codex] [--vnc] [prompt]") + fmt.Fprintln(out, " new [--repo owner/repo] [--branch main] [--runtime crabbox|container] [--profile name] [--parent id] [--purpose text] [--command codex] [--vnc] [prompt]") fmt.Fprintln(out, " --runtime overrides the deployment default") + fmt.Fprintln(out, " --profile overrides the deployment default") fmt.Fprintln(out, " attach SESSION_ID") fmt.Fprintln(out, " vnc SESSION_ID") fmt.Fprintln(out, " delete SESSION_ID") diff --git a/cmd/crabbox-ssh-gateway/main_test.go b/cmd/crabbox-ssh-gateway/main_test.go index b2c704d..31809f5 100644 --- a/cmd/crabbox-ssh-gateway/main_test.go +++ b/cmd/crabbox-ssh-gateway/main_test.go @@ -215,6 +215,15 @@ func TestHelpNamesDeleteAsCanonicalCommand(t *testing.T) { } } +func TestHelpDocumentsProfileOverride(t *testing.T) { + var output bytes.Buffer + printHelp(&output, user{Login: "operator", Role: "owner"}) + if got := output.String(); !strings.Contains(got, "[--profile name]") || + !strings.Contains(got, "--profile overrides the deployment default") { + t.Fatalf("help = %q", got) + } +} + func TestLegacyProviderCleanupWarningRequiresConfirmedLegacyStop(t *testing.T) { if !legacyProviderCleanupMayBeRequired(interactiveSession{Status: "stopped"}) { t.Fatal("confirmed legacy stop should retain the cleanup warning") diff --git a/src/app/utils.js b/src/app/utils.js index b0ceca6..a440476 100644 --- a/src/app/utils.js +++ b/src/app/utils.js @@ -394,14 +394,20 @@ export function runtimeProfileOptionLabel(profile) { if (profile?.target && String(profile.target).toLowerCase() !== label.toLowerCase()) { details.push(String(profile.target)); } + const effectiveCapabilities = { + terminal: true, + desktop: true, + vnc: true, + ...profile?.capabilities, + }; const capabilities = [ ["terminal", "terminal"], ["desktop", "desktop"], ["vnc", "VNC"], ] - .filter(([name]) => profile?.capabilities?.[name] === true) + .filter(([name]) => effectiveCapabilities[name] === true) .map(([, name]) => name); - if (capabilities.length > 0) details.push(capabilities.join(", ")); + details.push(capabilities.length > 0 ? capabilities.join(", ") : "no terminal or desktop"); return details.length > 0 ? `${label} — ${details.join(" · ")}` : label; } diff --git a/src/index.ts b/src/index.ts index 287530d..7e13d09 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1093,6 +1093,18 @@ function deploymentConfig(env: RuntimeEnv): DeploymentConfig { }; } +function selectedRuntimeProfile( + deployment: DeploymentConfig, + value: unknown, +): { profile: string; descriptor: RuntimeProfileDescriptor | undefined } { + const profile = clean(value, 120) || deployment.defaultProfile; + const descriptor = runtimeProfileByID(deployment.runtimeProfiles, profile); + if (deployment.runtimeProfiles.length > 0 && !descriptor) { + throw badRequest("profile is not configured"); + } + return { profile, descriptor }; +} + function publicDeploymentConfig(env: RuntimeEnv): PublicDeploymentConfig { const { label, canonicalUrl, productUrl, sshHost } = deploymentConfig(env); return { @@ -4113,11 +4125,7 @@ async function createInteractiveSessionFromInput( | "crabbox" | "container"; requireRuntimeAdapterCreatePreflight(env, runtime); - const profile = clean(body.profile, 120) || deployment.defaultProfile; - const runtimeProfile = runtimeProfileByID(deployment.runtimeProfiles, profile); - if (deployment.runtimeProfiles.length > 0 && !runtimeProfile) { - throw badRequest("profile is not configured"); - } + const { profile, descriptor: runtimeProfile } = selectedRuntimeProfile(deployment, body.profile); const requestedCapabilities = runtimeProfileCapabilities( runtime === "crabbox" ? runtimeProfile : undefined, runtime === "crabbox" ? crabboxCapabilities : containerCapabilities, @@ -8767,7 +8775,7 @@ async function provisionInteractiveEndpoint( | "crabbox" | "container"; const command = interactiveCommand(session.command); - const profile = clean(session.profile, 120) || deploymentConfig(env).defaultProfile; + const { profile } = selectedRuntimeProfile(deploymentConfig(env), session.profile); const prompt = clean(session.prompt, 4000); const purpose = interactiveSessionPurpose(session.purpose, prompt, repo, branch, command); const summary = interactiveSessionSummary(session.summary, purpose, prompt); diff --git a/src/runtime-profiles.ts b/src/runtime-profiles.ts index f6a6ce1..56dd7c9 100644 --- a/src/runtime-profiles.ts +++ b/src/runtime-profiles.ts @@ -60,7 +60,7 @@ export function parseRuntimeProfiles(value: string | undefined): RuntimeProfileD seen.add(id); seenLabels.add(normalizedLabel); - const rawCapabilities = entry.capabilities ?? {}; + const rawCapabilities = entry.capabilities === undefined ? {} : entry.capabilities; if ( !isRecord(rawCapabilities) || Object.keys(rawCapabilities).some((key) => !capabilityNameSet.has(key)) diff --git a/tests/app-utils.test.ts b/tests/app-utils.test.ts index 200f83a..bbca4b1 100644 --- a/tests/app-utils.test.ts +++ b/tests/app-utils.test.ts @@ -129,7 +129,7 @@ test("runtime profile options expose target and enabled capabilities", () => { target: "linux", capabilities: {}, }), - "Linux", + "Linux — terminal, desktop, VNC", ); }); diff --git a/tests/runtime-adapter.test.ts b/tests/runtime-adapter.test.ts index 562c1bd..dc86345 100644 --- a/tests/runtime-adapter.test.ts +++ b/tests/runtime-adapter.test.ts @@ -110,6 +110,9 @@ test("configured profiles fence every adapter runtime and preserve requested cap const createStart = source.indexOf("async function createInteractiveSessionFromInput"); const createEnd = source.indexOf("function initialRuntimeAdapterWorkspaceId", createStart); const createSource = source.slice(createStart, createEnd); + const profileStart = source.indexOf("function selectedRuntimeProfile"); + const profileEnd = source.indexOf("function publicDeploymentConfig", profileStart); + const profileSource = source.slice(profileStart, profileEnd); const resultStart = source.indexOf("function runtimeAdapterProvisionResult"); const resultEnd = source.indexOf( "async function reconcileStoppingRuntimeAdapterWorkspace", @@ -117,8 +120,8 @@ test("configured profiles fence every adapter runtime and preserve requested cap ); const resultSource = source.slice(resultStart, resultEnd); - assert.match(createSource, /deployment\.runtimeProfiles\.length > 0 && !runtimeProfile/); - assert.doesNotMatch(createSource, /runtime === "crabbox" && deployment\.runtimeProfiles/); + assert.match(createSource, /selectedRuntimeProfile\(deployment, body\.profile\)/); + assert.match(profileSource, /deployment\.runtimeProfiles\.length > 0 && !descriptor/); assert.match(resultSource, /session\.adapterRequestedCapabilities \?\?/); }); diff --git a/tests/runtime-profiles.test.ts b/tests/runtime-profiles.test.ts index e3d3d79..52f3836 100644 --- a/tests/runtime-profiles.test.ts +++ b/tests/runtime-profiles.test.ts @@ -52,6 +52,7 @@ test("runtime profile catalog fails closed on malformed or ambiguous input", () '[{"id":"a","label":" A"}]', '[{"id":"a","label":"A\\nB"}]', '[{"id":"a","label":"A","capabilities":{"desktop":"yes"}}]', + '[{"id":"a","label":"A","capabilities":null}]', '[{"id":"a","label":"A","capabilities":{"unknown":true}}]', '[{"id":"a","label":"A","privateProvider":"hidden"}]', ]; @@ -65,9 +66,12 @@ test("runtime profile catalog fails closed on malformed or ambiguous input", () test("profile allowlisting and capability withdrawals stay enforced at provisioning", async () => { const source = await readFile(new URL("../src/index.ts", import.meta.url), "utf8"); const selectionStart = source.indexOf("const profile = clean(body.profile"); - const selectionEnd = source.indexOf("const command = interactiveCommand", selectionStart); - const selection = source.slice(selectionStart, selectionEnd); - assert.match(selection, /deployment\.runtimeProfiles\.length > 0 && !runtimeProfile/); + assert.equal(selectionStart, -1); + const helperStart = source.indexOf("function selectedRuntimeProfile"); + const helperEnd = source.indexOf("function publicDeploymentConfig", helperStart); + const helper = source.slice(helperStart, helperEnd); + assert.match(helper, /deployment\.runtimeProfiles\.length > 0 && !descriptor/); + assert.ok(source.indexOf("selectedRuntimeProfile(deploymentConfig(env), session.profile)") > 0); const resultStart = source.indexOf("function runtimeAdapterProvisionResult"); const resultEnd = source.indexOf(