Fix container detail terminal isolation and working directory

This commit is contained in:
KodekaTeam committed 2026-06-19 00:27:47 +07:00
1 parent 30e1c5e934
commit 58bc5d2547
6 files changed
+101 -29

No files matched your search

@@ -31,7 +31,7 @@ type StoredTerminalSession = {
const INPUT_CHUNK_SIZE = 8192;
const INPUT_BACKPRESSURE_THRESHOLD = 256 * 1024;
const IMMEDIATE_INPUT_MAX_LENGTH = 4;
const STORAGE_KEY = "app_container_terminal_sessions_v1";
const STORAGE_KEY = "app_container_terminal_sessions_v3";
const summaryToneColor: Record<
TerminalTabData["summaries"][number]["tone"],
@@ -69,6 +69,20 @@ function shellQuote(value: string) {
return `'${value.replaceAll("'", "'\\''")}'`;
}
function createContainerShellCommand({
execTarget,
shell,
workingDirectory,
}: Pick<TerminalTabData, "execTarget" | "shell" | "workingDirectory">) {
const initialPath = workingDirectory || "/";
const enterWorkingDirectory = `cd ${shellQuote(initialPath)} 2>/dev/null || cd /`;
const startShell = `exec ${shellQuote(shell)}`;
return `docker exec -it ${shellQuote(execTarget)} ${shellQuote(
shell,
)} -c ${shellQuote(`${enterWorkingDirectory}; ${startShell}`)}`;
}
function createSessionId(terminal: TerminalTabData) {
const safeSessionTarget = terminal.execTarget
.replace(/[^a-zA-Z0-9_-]/g, "")
@@ -106,15 +120,6 @@ function writeStoredSession(storageId: string, session: StoredTerminalSession) {
function getInitialSession(terminal: TerminalTabData): StoredTerminalSession {
const storageId = getStorageId(terminal);
const stored = readStoredSessions()[storageId];
if (stored?.id) {
return {
id: stored.id,
initialized: Boolean(stored.initialized),
};
}
const nextSession = {
id: createSessionId(terminal),
initialized: false,
@@ -267,6 +272,8 @@ function ContainerTerminalPane({
useEffect(() => {
if (!containerRef.current || !terminal.canExecute) return;
let destroyed = false;
let wsForEffect: WebSocket | null = null;
let termForEffect: import("@xterm/xterm").Terminal | null = null;
isDisposedRef.current = false;
onStatusChange("connecting");
@@ -315,6 +322,7 @@ function ContainerTerminalPane({
}
termRef.current = term;
termForEffect = term;
fitRef.current = fitAddon;
scheduleResize();
@@ -331,6 +339,14 @@ function ContainerTerminalPane({
const ticketResponse = await terminalApi.wsTicket(
terminal.serverId,
session.id,
{
type: "command",
command: createContainerShellCommand({
execTarget: terminal.execTarget,
shell: terminal.shell,
workingDirectory: terminal.workingDirectory,
}),
},
);
if (destroyed) return undefined;
@@ -339,9 +355,12 @@ function ContainerTerminalPane({
ticket: ticketResponse.data.ticket,
});
const ws = new WebSocket(wsUrl);
wsForEffect = ws;
wsRef.current = ws;
const isCurrentSocket = () => !destroyed && wsRef.current === ws;
ws.onopen = () => {
if (!isCurrentSocket()) return;
onStatusChange("connected");
onSenderChange(sendInput);
enqueueTerminalOutput("\x1b[32mSSH connection established\x1b[0m\r\n");
@@ -349,11 +368,6 @@ function ContainerTerminalPane({
`\x1b[36mOpening container shell: ${terminal.execTarget}\x1b[0m\r\n`,
);
if (shouldEnterContainer) {
sendInput(
`docker exec -it ${shellQuote(terminal.execTarget)} ${shellQuote(
terminal.shell,
)}\r`,
);
onSessionInitialized();
} else {
enqueueTerminalOutput(
@@ -364,6 +378,7 @@ function ContainerTerminalPane({
};
ws.onmessage = (ev) => {
if (!isCurrentSocket()) return;
try {
const msg = JSON.parse(ev.data as string);
if (msg.type === "ready" || msg.type === "data") {
@@ -385,11 +400,13 @@ function ContainerTerminalPane({
};
ws.onerror = () => {
if (!isCurrentSocket()) return;
onStatusChange("error");
enqueueTerminalOutput("\x1b[31m\r\nWebSocket error\x1b[0m\r\n");
};
ws.onclose = () => {
if (!isCurrentSocket()) return;
onStatusChange("disconnected");
onSenderChange(null);
enqueueTerminalOutput("\x1b[33m\r\n[Connection closed]\x1b[0m\r\n");
@@ -430,11 +447,21 @@ function ContainerTerminalPane({
encoderRef.current = null;
resizeObserverRef.current?.disconnect();
cleanup.then((fn) => fn?.());
wsRef.current?.close();
termRef.current?.dispose();
termRef.current = null;
if (wsForEffect) {
wsForEffect.onopen = null;
wsForEffect.onmessage = null;
wsForEffect.onerror = null;
wsForEffect.onclose = null;
wsForEffect.close();
if (wsRef.current === wsForEffect) {
wsRef.current = null;
}
}
termForEffect?.dispose();
if (termRef.current === termForEffect) {
termRef.current = null;
fitRef.current = null;
}
};
}, [
enqueueTerminalOutput,
@@ -447,6 +474,7 @@ function ContainerTerminalPane({
session.initialized,
terminal.canExecute,
terminal.execTarget,
terminal.workingDirectory,
terminal.serverId,
terminal.shell,
]);
@@ -32,7 +32,7 @@ export function createTerminalData(
const inspect = isRecord(detail?.inspect) ? detail.inspect : {};
const config = getRecord(inspect, "Config");
const state = getRecord(inspect, "State");
const workingDirectory = getString(config?.WorkingDir, "/app");
const workingDirectory = getString(config?.WorkingDir, "/");
const shell = "/bin/sh";
const isRunning = container.status === "RUNNING";
+3 -1
View File
@@ -41,7 +41,9 @@ function readStoredTabs(): Tab[] {
if (!raw) return [];
const parsed = JSON.parse(raw) as Tab[];
if (!Array.isArray(parsed)) return [];
return parsed.map((tab) => ({
return parsed
.filter((tab) => !tab.sessionId.startsWith("container-"))
.map((tab) => ({
...tab,
label: tab.label || tab.serverName,
}));
+6 -2
View File
@@ -2769,14 +2769,18 @@ export const terminal = {
stderr: string;
exitCode: number;
}>("/terminal/exec", { serverId, command }),
wsTicket: (serverId: string, sessionId?: string) =>
wsTicket: (
serverId: string,
sessionId?: string,
target?: { type: "shell" } | { type: "command"; command: string },
) =>
post<{
success: boolean;
data: {
ticket: string;
expiresAt: string;
};
}>("/terminal/ws-ticket", { serverId, sessionId }),
}>("/terminal/ws-ticket", { serverId, sessionId, target }),
/** Returns WebSocket URL for interactive shell */
wsUrl: (
serverId: string,
+38 -7
View File
@@ -10,6 +10,7 @@ import {
consumeTerminalSocketTicket,
issueTerminalSocketTicket,
} from "../services/terminal-ticket.service";
import type { TerminalSocketTarget } from "../services/terminal-ticket.service";
const SESSION_TTL_MS = 30 * 60 * 1000;
const TERMINAL_EXEC_TIMEOUT_MS = 20_000;
@@ -50,6 +51,7 @@ interface TerminalSession {
serverId: string;
serverName: string;
serverIp: string;
target: TerminalSocketTarget;
shellStream: TerminalShellStream;
createdAt: number;
lastActiveAt: number;
@@ -149,22 +151,28 @@ async function createTerminalSession(
cols: number,
rows: number,
sessionId?: string,
target: TerminalSocketTarget = { type: "shell" },
) {
const ssh = await getConnection(server);
const id = sessionId || randomUUID();
const shellStream = await new Promise<TerminalShellStream>(
(resolve, reject) => {
ssh.connection!.shell(
{ term: "xterm-256color", cols, rows },
(err, stream) => {
const handleStream = (err: Error | undefined, stream: unknown) => {
if (err) {
reject(err);
return;
}
resolve(stream as TerminalShellStream);
},
);
};
const pty = { term: "xterm-256color", cols, rows };
if (target.type === "command") {
ssh.connection!.exec(target.command, { pty }, handleStream);
return;
}
ssh.connection!.shell(pty, handleStream);
},
);
@@ -174,6 +182,7 @@ async function createTerminalSession(
serverId: server.id,
serverName: server.name,
serverIp: server.ip,
target,
shellStream,
createdAt: Date.now(),
lastActiveAt: Date.now(),
@@ -193,7 +202,10 @@ export async function terminalRoutes(app: FastifyInstance) {
app.get("/sessions", { preHandler: terminalAccess }, async (req, reply) => {
pruneExpiredSessions();
const sessions = [...terminalSessions.values()]
.filter((session) => session.userId === req.userId)
.filter(
(session) =>
session.userId === req.userId && session.target.type === "shell",
)
.map(getPublicSession)
.sort((left, right) =>
right.lastActiveAt.localeCompare(left.lastActiveAt),
@@ -224,6 +236,15 @@ export async function terminalRoutes(app: FastifyInstance) {
.object({
serverId: z.string().min(1),
sessionId: z.string().trim().min(1).max(128).optional(),
target: z
.discriminatedUnion("type", [
z.object({ type: z.literal("shell") }),
z.object({
type: z.literal("command"),
command: z.string().trim().min(1).max(4096),
}),
])
.optional(),
})
.safeParse(req.body);
if (!body.success) {
@@ -232,7 +253,7 @@ export async function terminalRoutes(app: FastifyInstance) {
.send({ success: false, error: body.error.flatten() });
}
const { serverId, sessionId } = body.data;
const { serverId, sessionId, target } = body.data;
const server = await getAccessibleServer(
req.userId,
serverId,
@@ -251,6 +272,7 @@ export async function terminalRoutes(app: FastifyInstance) {
organizationId: req.organizationId,
serverId,
sessionId,
target: target ?? { type: "shell" },
});
return reply.send({
@@ -388,6 +410,7 @@ export async function terminalRoutes(app: FastifyInstance) {
parsedCols,
parsedRows,
sessionId,
socketTicket.target,
);
}
@@ -443,15 +466,23 @@ export async function terminalRoutes(app: FastifyInstance) {
socket.on("close", () => {
if (session) {
session.lastActiveAt = Date.now();
if (session.target.type === "command") {
destroySession(session.id);
} else {
detachSocket(session);
}
}
});
socket.on("error", () => {
if (session) {
session.lastActiveAt = Date.now();
if (session.target.type === "command") {
destroySession(session.id);
} else {
detachSocket(session);
}
}
});
await auditLog({
@@ -8,10 +8,15 @@ export interface TerminalSocketTicket {
organizationId?: string;
serverId: string;
sessionId?: string;
target?: TerminalSocketTarget;
createdAt: number;
expiresAt: number;
}
export type TerminalSocketTarget =
| { type: "shell" }
| { type: "command"; command: string };
const terminalSocketTickets = new Map<string, TerminalSocketTicket>();
function pruneExpiredTerminalSocketTickets(now = Date.now()) {
@@ -27,6 +32,7 @@ export function issueTerminalSocketTicket(input: {
organizationId?: string;
serverId: string;
sessionId?: string;
target?: TerminalSocketTarget;
}): TerminalSocketTicket {
const now = Date.now();
pruneExpiredTerminalSocketTickets(now);
@@ -37,6 +43,7 @@ export function issueTerminalSocketTicket(input: {
organizationId: input.organizationId,
serverId: input.serverId,
sessionId: input.sessionId,
target: input.target,
createdAt: now,
expiresAt: now + TERMINAL_SOCKET_TICKET_TTL_MS,
};