diff --git a/README.md b/README.md index dd39843..70a2454 100644 --- a/README.md +++ b/README.md @@ -25,6 +25,7 @@ When running inside a Databricks App, terminal commands execute under the app's - `Cmd+T` (macOS) / `Ctrl+T` (Windows/Linux) opens the same launcher. - Launcher controls: `↑/↓` or `j/k`, `Enter`, `Esc`, `1..9`, plus `?` (help) and `a` (about). - Each tab shows a tiny auth badge (`m2m` / `user`); click it to toggle auth mode for that session. + - Some session types can pin auth mode via `authPolicy` (`user`-only or `m2m`-only); pinned tabs show a locked auth badge and cannot be toggled. - Tab titles follow terminal title escape sequences from the running shell/app. ## Terminal types @@ -36,6 +37,7 @@ Session types are discovered dynamically from `terminal-types/*` at startup. - `terminal-types//type.json` - `terminal-types//launch.sh` - `type.json` can include optional `icon` (unicode/custom glyph string) for CLI-style picker display. +- `type.json` can include optional `authPolicy` (`both` default, or pinned `user` / `m2m`). - Included profiles in this repo: `claude`, `codex`, `pi` (plus built-in `terminal`). - Bundled logo font assets live under `public/assets/terminal-icons` (source SVGs in `assets/terminal-icons/src`). - Type launch scripts run on top of the base terminal runtime/auth model. @@ -92,6 +94,11 @@ databricks apps deploy --profile SHARED - `POST /api/sessions/:sessionId/auth-mode` (body: `{ mode: "m2m" | "user" }`) - `DELETE /api/sessions/:sessionId` +Notes: +- auth mode is constrained by terminal type `authPolicy` + - `both`: mode can switch between `m2m` and `user` + - `user`/`m2m`: mode is pinned and disallowed switches return `AUTH_MODE_NOT_ALLOWED_FOR_SESSION_TYPE` + ### WebSocket - `GET /ws/terminal?sessionId=&cols=&rows=` diff --git a/public/app/sessionController.js b/public/app/sessionController.js index 62a5439..7fd83ab 100644 --- a/public/app/sessionController.js +++ b/public/app/sessionController.js @@ -125,6 +125,10 @@ export function createSessionController({ return; } + if (!sessionTypesModel.isAuthToggleEnabled(session.typeId)) { + return; + } + const nextMode = session.authMode === "user" ? "m2m" : "user"; api("POST", `/api/sessions/${encodeURIComponent(sessionId)}/auth-mode`, { @@ -132,7 +136,7 @@ export function createSessionController({ }) .then((data) => { session.authMode = normalizeAuthMode(data.authMode); - updateTabAuth(session); + updateTabAuth(session, sessionTypesModel); }) .catch((error) => { console.warn(`Failed to switch auth mode (${sessionId}):`, error.message); @@ -172,7 +176,7 @@ export function createSessionController({ if (msg.type === "auth_mode") { session.authMode = normalizeAuthMode(msg.mode); - updateTabAuth(session); + updateTabAuth(session, sessionTypesModel); return; } @@ -331,7 +335,7 @@ export function createSessionController({ state.sessions.set(sessionId, stateEntry); updateTabTitle(stateEntry); updateTabType(stateEntry, sessionTypesModel); - updateTabAuth(stateEntry); + updateTabAuth(stateEntry, sessionTypesModel); if (isLauncher) { updateTabStatus(state, sessionId, "connected"); diff --git a/public/app/sessionTypesModel.js b/public/app/sessionTypesModel.js index 663a4eb..8865982 100644 --- a/public/app/sessionTypesModel.js +++ b/public/app/sessionTypesModel.js @@ -2,6 +2,10 @@ function normalizeTypeId(typeId) { return typeof typeId === "string" && typeId.length > 0 ? typeId : "terminal"; } +function normalizeAuthPolicy(policy) { + return policy === "user" || policy === "m2m" ? policy : "both"; +} + function sortedSessionTypes(list) { return [...list].sort((a, b) => { if (a.default) { @@ -22,6 +26,7 @@ function fallbackType(typeId) { description: "", badge: normalized, icon: undefined, + authPolicy: "both", default: false, builtIn: false, }; @@ -47,6 +52,7 @@ export function createSessionTypesModel(state) { description: type.description || "", badge: type.badge || type.id || "terminal", icon: typeof type.icon === "string" && type.icon.length > 0 ? type.icon : undefined, + authPolicy: normalizeAuthPolicy(type.authPolicy), default: Boolean(type.default), builtIn: Boolean(type.builtIn), })), @@ -59,6 +65,24 @@ export function createSessionTypesModel(state) { return found || fallbackType(normalized); }, + authPolicyForType(typeId) { + const type = this.findType(typeId); + return normalizeAuthPolicy(type.authPolicy); + }, + + allowsAuthMode(typeId, mode) { + const normalizedMode = mode === "user" ? "user" : "m2m"; + const policy = this.authPolicyForType(typeId); + if (policy === "both") { + return true; + } + return policy === normalizedMode; + }, + + isAuthToggleEnabled(typeId) { + return this.authPolicyForType(typeId) === "both"; + }, + defaultTypeId() { const found = state.sessionTypes.find((type) => type.default); return found ? found.id : "terminal"; diff --git a/public/app/state.js b/public/app/state.js index dc23643..1dfb2f1 100644 --- a/public/app/state.js +++ b/public/app/state.js @@ -7,6 +7,7 @@ const DEFAULT_SESSION_TYPES = [ description: "Plain shell session", badge: "terminal", icon: "⌂", + authPolicy: "both", default: true, builtIn: true, }, diff --git a/public/app/tabUi.js b/public/app/tabUi.js index 12b99bf..9c7ee0c 100644 --- a/public/app/tabUi.js +++ b/public/app/tabUi.js @@ -16,11 +16,26 @@ function displayTitle(session) { : shortSessionLabel(session.sessionId); } -export function updateTabAuth(session) { +export function updateTabAuth(session, sessionTypesModel) { const mode = normalizeAuthMode(session.authMode); session.authMode = mode; + + const policy = sessionTypesModel.authPolicyForType(session.typeId); + const toggleEnabled = policy === "both"; + session.authEl.textContent = authBadgeText(mode); session.authEl.classList.toggle("user", mode === "user"); + session.authEl.classList.toggle("locked", !toggleEnabled); + session.authEl.disabled = !toggleEnabled; + + if (toggleEnabled) { + session.authEl.setAttribute("aria-label", `Toggle auth mode for ${session.sessionId}`); + session.authEl.title = "Toggle auth mode"; + } else { + const pinnedText = policy === "user" ? "Pinned to user auth" : "Pinned to m2m auth"; + session.authEl.setAttribute("aria-label", pinnedText); + session.authEl.title = pinnedText; + } } export function updateTabType(session, sessionTypesModel) { diff --git a/public/styles.css b/public/styles.css index 6176e5a..9b32a84 100644 --- a/public/styles.css +++ b/public/styles.css @@ -128,6 +128,15 @@ body { color: var(--fg); } +.tab-auth.locked { + cursor: default; + opacity: 0.8; +} + +.tab-auth.locked:hover { + color: var(--muted); +} + .tab-type { border: 1px solid var(--border); background: #1a2433; diff --git a/src/http/app.ts b/src/http/app.ts index c6d2e95..f04c0e7 100644 --- a/src/http/app.ts +++ b/src/http/app.ts @@ -11,7 +11,11 @@ import { SESSION_ID_PATTERN } from "../sessions/ptySessionManager.js"; import type { SessionAuthMode, SessionManager } from "../sessions/types.js"; import type { RuntimeDiagnosticsManager } from "../runtime/diagnostics.js"; import type { ServiceRegistry } from "../services/registry.js"; -import type { TerminalTypeRegistry } from "../terminalTypes/types.js"; +import type { + ResolvedTerminalType, + TerminalTypeAuthPolicy, + TerminalTypeRegistry, +} from "../terminalTypes/types.js"; import { TerminalGateway } from "../ws/terminalGateway.js"; import { v7 as uuidv7 } from "uuid"; @@ -113,6 +117,65 @@ function assertUserTokenAuthEnabled(config: AppConfig): void { } } +function allowedAuthModes(policy: TerminalTypeAuthPolicy): SessionAuthMode[] { + if (policy === "user") { + return ["user"]; + } + + if (policy === "m2m") { + return ["m2m"]; + } + + return ["m2m", "user"]; +} + +function authPolicyForType(type: ResolvedTerminalType | undefined): TerminalTypeAuthPolicy { + return type?.authPolicy || "both"; +} + +function assertAuthModeAllowed( + mode: SessionAuthMode, + typeId: string, + policy: TerminalTypeAuthPolicy, +): void { + const allowedModes = allowedAuthModes(policy); + if (allowedModes.includes(mode)) { + return; + } + + throw new AppError( + 400, + "AUTH_MODE_NOT_ALLOWED_FOR_SESSION_TYPE", + `authMode=${mode} is not allowed for session type '${typeId}'`, + false, + { + typeId, + policy, + allowedModes, + requestedMode: mode, + }, + ); +} + +function resolveCreateAuthMode( + requestedMode: RequestedAuthMode | undefined, + type: ResolvedTerminalType, +): SessionAuthMode { + const policy = authPolicyForType(type); + + if (policy === "both") { + return normalizeAuthMode(requestedMode); + } + + const pinnedMode: SessionAuthMode = policy; + if (requestedMode !== undefined) { + const normalized = normalizeAuthMode(requestedMode); + assertAuthModeAllowed(normalized, type.id, policy); + } + + return pinnedMode; +} + function resolveSessionAuth(input: SessionAuthResolutionInput): SessionAuthResolution { const mode = normalizeAuthMode(input.requestedMode); @@ -287,8 +350,9 @@ export function createApp(services: AppServices): express.Express { } const userAccessToken = readHeaderValue(req, services.config.userAccessTokenHeader); + const requestedAuthMode = resolveCreateAuthMode(payload.authMode, sessionType); const auth = resolveSessionAuth({ - requestedMode: payload.authMode, + requestedMode: requestedAuthMode, userAccessToken, config: services.config, }); @@ -413,6 +477,11 @@ export function createApp(services: AppServices): express.Express { const payload = parseBody(req, setAuthModeBodySchema); const mode = normalizeAuthMode(payload.mode); + const sessionInfo = await services.sessions.getSessionInfo(params.sessionId); + const sessionType = services.terminalTypes.resolveType(sessionInfo.typeId); + const sessionTypePolicy = authPolicyForType(sessionType); + assertAuthModeAllowed(mode, sessionInfo.typeId, sessionTypePolicy); + if (mode === "user") { assertUserTokenAuthEnabled(services.config); } diff --git a/src/terminalTypes/registry.ts b/src/terminalTypes/registry.ts index 0c0263a..e2d8b48 100644 --- a/src/terminalTypes/registry.ts +++ b/src/terminalTypes/registry.ts @@ -2,7 +2,12 @@ import fs from "node:fs/promises"; import path from "node:path"; import { z } from "zod"; import type { Logger } from "../logging/logger.js"; -import type { ResolvedTerminalType, TerminalType, TerminalTypeRegistry } from "./types.js"; +import type { + ResolvedTerminalType, + TerminalType, + TerminalTypeAuthPolicy, + TerminalTypeRegistry, +} from "./types.js"; const BASE_TERMINAL_TYPE: ResolvedTerminalType = { id: "terminal", @@ -10,18 +15,22 @@ const BASE_TERMINAL_TYPE: ResolvedTerminalType = { description: "Plain shell session", badge: "terminal", icon: "⌂", + authPolicy: "both", default: true, builtIn: true, }; const typeIdPattern = /^[a-z0-9][a-z0-9-_]{0,63}$/; +const authPolicyValues = ["both", "user", "m2m"] as const satisfies readonly TerminalTypeAuthPolicy[]; + const terminalTypeManifestSchema = z.object({ id: z.string().regex(typeIdPattern).optional(), name: z.string().min(1).max(80), description: z.string().min(1).max(160).optional(), badge: z.string().min(1).max(24).optional(), icon: z.string().min(1).max(8).optional(), + authPolicy: z.enum(authPolicyValues).optional(), entrypoint: z.string().min(1).max(200).optional(), }); @@ -144,6 +153,7 @@ export async function loadTerminalTypeRegistry( description: manifest.description, badge: manifest.badge || id, icon: manifest.icon, + authPolicy: manifest.authPolicy || "both", builtIn: false, default: false, entrypointPath, diff --git a/src/terminalTypes/types.ts b/src/terminalTypes/types.ts index d761b66..a951b03 100644 --- a/src/terminalTypes/types.ts +++ b/src/terminalTypes/types.ts @@ -1,9 +1,12 @@ +export type TerminalTypeAuthPolicy = "both" | "user" | "m2m"; + export type TerminalType = { id: string; name: string; description?: string; badge?: string; icon?: string; + authPolicy: TerminalTypeAuthPolicy; default: boolean; builtIn: boolean; }; diff --git a/terminal-types/README.md b/terminal-types/README.md index db6afa1..158b11e 100644 --- a/terminal-types/README.md +++ b/terminal-types/README.md @@ -24,6 +24,7 @@ terminal-types// "description": "Launch Claude Code in the terminal", "badge": "claude", "icon": "✶", + "authPolicy": "both", "entrypoint": "launch.sh" } ``` @@ -35,6 +36,10 @@ Fields: - `badge` (optional): short tab badge label - `icon` (optional): short icon/logo string (e.g. unicode glyph) used in TUI picker and tab badge - can be a private-use glyph when backed by a bundled icon font +- `authPolicy` (optional): auth-mode policy for sessions of this type + - `both` (default): users can toggle between `m2m` and `user` + - `user`: pinned to `user` mode (toggle disabled) + - `m2m`: pinned to `m2m` mode (toggle disabled) - `entrypoint` (optional): launch script path relative to type folder, default `launch.sh` ## `launch.sh` diff --git a/test/lifecycle.test.ts b/test/lifecycle.test.ts index 543a107..4277d59 100644 --- a/test/lifecycle.test.ts +++ b/test/lifecycle.test.ts @@ -44,13 +44,21 @@ function makeConfig(overrides?: Partial): AppConfig { } function makeTerminalTypes( - custom: Array<{ id: string; name: string; badge?: string; description?: string; entrypointPath?: string }> = [], + custom: Array<{ + id: string; + name: string; + badge?: string; + description?: string; + entrypointPath?: string; + authPolicy?: "both" | "user" | "m2m"; + }> = [], ): TerminalTypeRegistry { const base = { id: "terminal", name: "Terminal", badge: "terminal", description: "Plain shell session", + authPolicy: "both", default: true, builtIn: true, }; @@ -62,6 +70,7 @@ function makeTerminalTypes( name: type.name, badge: type.badge || type.id, description: type.description, + authPolicy: type.authPolicy || "both", default: false, builtIn: false, })), @@ -75,6 +84,7 @@ function makeTerminalTypes( name: type.name, badge: type.badge || type.id, description: type.description, + authPolicy: type.authPolicy || "both", default: false, builtIn: false, entrypointPath: type.entrypointPath, @@ -199,6 +209,7 @@ test("session types endpoint lists built-in terminal type", async () => { const terminal = response.body.data.types.find((type: { id: string }) => type.id === "terminal"); assert.equal(Boolean(terminal), true); + assert.equal(terminal.authPolicy, "both"); }); test("session create rejects unknown session type", async () => { @@ -241,6 +252,87 @@ test("session create supports custom session type", async () => { assert.equal(sessionManager.creates[0].typeEntrypointPath, "/tmp/terminal-types/claude/launch.sh"); }); +test("session create enforces user auth policy for pinned user-only types", async () => { + const customTypes = makeTerminalTypes([ + { + id: "caspersai", + name: "CaspersAI", + authPolicy: "user", + }, + ]); + + const { app } = makeApp([], undefined, customTypes); + + const created = await request(app) + .post("/api/sessions") + .set("x-forwarded-access-token", "user.token.value") + .send({ + typeId: "caspersai", + }) + .expect(201); + + assert.equal(created.body.ok, true); + assert.equal(created.body.data.typeId, "caspersai"); + assert.equal(created.body.data.authMode, "user"); +}); + +test("session create rejects disallowed auth mode for pinned type", async () => { + const customTypes = makeTerminalTypes([ + { + id: "caspersai", + name: "CaspersAI", + authPolicy: "user", + }, + ]); + + const { app } = makeApp([], undefined, customTypes); + + const response = await request(app) + .post("/api/sessions") + .set("x-forwarded-access-token", "user.token.value") + .send({ + typeId: "caspersai", + authMode: "m2m", + }) + .expect(400); + + assert.equal(response.body.ok, false); + assert.equal(response.body.error.code, "AUTH_MODE_NOT_ALLOWED_FOR_SESSION_TYPE"); +}); + +test("session auth mode toggle rejects disallowed mode for pinned type", async () => { + const customTypes = makeTerminalTypes([ + { + id: "locked-m2m", + name: "Locked M2M", + authPolicy: "m2m", + }, + ]); + + const { app } = makeApp([], undefined, customTypes); + + const created = await request(app) + .post("/api/sessions") + .set("x-forwarded-access-token", "user.token.value") + .send({ + typeId: "locked-m2m", + }) + .expect(201); + + const sessionId = created.body.data.session.sessionId; + + const response = await request(app) + .post(`/api/sessions/${encodeURIComponent(sessionId)}/auth-mode`) + .set("x-forwarded-access-token", "user.token.value") + .send({ + mode: "user", + }) + .expect(400); + + assert.equal(response.body.ok, false); + assert.equal(response.body.error.code, "AUTH_MODE_NOT_ALLOWED_FOR_SESSION_TYPE"); +}); + test("session create supports user auth mode for Databricks CLI env", async () => { const { app, sessionManager } = makeApp(); diff --git a/test/websocket.attach.test.ts b/test/websocket.attach.test.ts index cc9f836..899ac35 100644 --- a/test/websocket.attach.test.ts +++ b/test/websocket.attach.test.ts @@ -125,6 +125,7 @@ test("websocket attach path streams output", async (t) => { name: "Terminal", badge: "terminal", description: "Plain shell session", + authPolicy: "both", default: true, builtIn: true, }, @@ -138,6 +139,7 @@ test("websocket attach path streams output", async (t) => { name: "Terminal", badge: "terminal", description: "Plain shell session", + authPolicy: "both", default: true, builtIn: true, }; @@ -147,6 +149,7 @@ test("websocket attach path streams output", async (t) => { name: "Terminal", badge: "terminal", description: "Plain shell session", + authPolicy: "both", default: true, builtIn: true, }),