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
3 changes: 2 additions & 1 deletion cmd/crabbox-ssh-gateway/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
9 changes: 9 additions & 0 deletions cmd/crabbox-ssh-gateway/main_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
10 changes: 8 additions & 2 deletions src/app/utils.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

Expand Down
20 changes: 14 additions & 6 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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);
Expand Down
2 changes: 1 addition & 1 deletion src/runtime-profiles.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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))
Expand Down
2 changes: 1 addition & 1 deletion tests/app-utils.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -129,7 +129,7 @@ test("runtime profile options expose target and enabled capabilities", () => {
target: "linux",
capabilities: {},
}),
"Linux",
"Linux — terminal, desktop, VNC",
);
});

Expand Down
7 changes: 5 additions & 2 deletions tests/runtime-adapter.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -110,15 +110,18 @@ 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",
resultStart,
);
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 \?\?/);
});

Expand Down
10 changes: 7 additions & 3 deletions tests/runtime-profiles.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"}]',
];
Expand All @@ -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(
Expand Down