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
7 changes: 7 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -36,6 +37,7 @@ Session types are discovered dynamically from `terminal-types/*` at startup.
- `terminal-types/<type-id>/type.json`
- `terminal-types/<type-id>/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.
Expand Down Expand Up @@ -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=<uuidv7>&cols=<n>&rows=<n>`

Expand Down
10 changes: 7 additions & 3 deletions public/app/sessionController.js
Original file line number Diff line number Diff line change
Expand Up @@ -125,14 +125,18 @@ 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`, {
mode: nextMode,
})
.then((data) => {
session.authMode = normalizeAuthMode(data.authMode);
updateTabAuth(session);
updateTabAuth(session, sessionTypesModel);
})
.catch((error) => {
console.warn(`Failed to switch auth mode (${sessionId}):`, error.message);
Expand Down Expand Up @@ -172,7 +176,7 @@ export function createSessionController({

if (msg.type === "auth_mode") {
session.authMode = normalizeAuthMode(msg.mode);
updateTabAuth(session);
updateTabAuth(session, sessionTypesModel);
return;
}

Expand Down Expand Up @@ -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");
Expand Down
24 changes: 24 additions & 0 deletions public/app/sessionTypesModel.js
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand All @@ -22,6 +26,7 @@ function fallbackType(typeId) {
description: "",
badge: normalized,
icon: undefined,
authPolicy: "both",
default: false,
builtIn: false,
};
Expand All @@ -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),
})),
Expand All @@ -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";
Expand Down
1 change: 1 addition & 0 deletions public/app/state.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ const DEFAULT_SESSION_TYPES = [
description: "Plain shell session",
badge: "terminal",
icon: "⌂",
authPolicy: "both",
default: true,
builtIn: true,
},
Expand Down
17 changes: 16 additions & 1 deletion public/app/tabUi.js
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
9 changes: 9 additions & 0 deletions public/styles.css
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
73 changes: 71 additions & 2 deletions src/http/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -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);

Expand Down Expand Up @@ -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,
});
Expand Down Expand Up @@ -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);
}
Expand Down
12 changes: 11 additions & 1 deletion src/terminalTypes/registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,26 +2,35 @@ 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",
name: "Terminal",
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(),
});

Expand Down Expand Up @@ -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,
Expand Down
3 changes: 3 additions & 0 deletions src/terminalTypes/types.ts
Original file line number Diff line number Diff line change
@@ -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;
};
Expand Down
5 changes: 5 additions & 0 deletions terminal-types/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ terminal-types/<type-id>/
"description": "Launch Claude Code in the terminal",
"badge": "claude",
"icon": "✶",
"authPolicy": "both",
"entrypoint": "launch.sh"
}
```
Expand All @@ -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`
Expand Down
Loading