feat: move the terminal fully into the ssh-terminal plugin (B9)

This commit is contained in:
LukeGus committed 2026-09-23 21:41:22 -05:00
1 parent 535126fd1b
commit 79d77678c9
309 files changed
+41789 -14958

No files matched your search

@@ -0,0 +1,104 @@
import { afterEach, describe, expect, it } from "vitest";
import express from "express";
import {
createMockCtx,
createTestDb,
type TestDb,
} from "@termix/plugin-sdk/testing";
import { PluginCapabilityError } from "@termix/plugin-sdk/backend";
import { activate } from "../../src/backend/index.js";
import { hostImportNormalizer } from "../../src/backend/host-import.js";
import type {
LiveSessionsV1,
TerminalHistoryV1,
} from "../../src/backend/services.js";
import { manifest, pluginDir, startServer, type TestServer } from "./helpers";
let db: TestDb | null = null;
let server: TestServer | null = null;
afterEach(async () => {
db?.close();
db = null;
await server?.close();
server = null;
});
describe("ssh-terminal activate", () => {
it("serves the terminal socket", async () => {
server = await startServer();
expect(server.mock.wsRoutes).toEqual([{ path: "/terminal", raw: false }]);
});
it("provides sessions.live and terminal.history", async () => {
server = await startServer();
const live = server.mock.services.get("sessions.live") as LiveSessionsV1;
expect(live.getSession("missing")).toBeNull();
expect(live.listForUser("user-1")).toEqual([]);
expect(live.write("missing", "ls\r")).toBe(false);
expect(live.idleTimeoutMinutes()).toBe(30);
await server.request("POST", "/command-history", {
body: { hostId: 1, command: "df -h" },
});
const history = server.mock.services.get(
"terminal.history",
) as TerminalHistoryV1;
const listed = await server.mock.ctx.asUser("user-1", () =>
history.list(1, 10),
);
expect(listed.map((entry) => entry.command)).toEqual(["df -h"]);
});
it("keeps a plugin caller to its own actor's sessions", async () => {
server = await startServer();
const live = server.mock.services.get("sessions.live") as LiveSessionsV1;
const other = await server.mock.ctx.asUser("user-2", async () =>
live.listForUser("user-1"),
);
expect(other).toEqual([]);
});
it("uses the admin session timeout for detached sessions", async () => {
server = await startServer({ settings: { sessionTimeoutMinutes: 90 } });
const live = server.mock.services.get("sessions.live") as LiveSessionsV1;
expect(live.idleTimeoutMinutes()).toBe(90);
});
it("offers a host import normalizer", async () => {
server = await startServer();
expect(
server.mock.ctx.registry.consume("ssh-terminal.hostImportNormalizer"),
).toBe(hostImportNormalizer);
});
it.each(["db:own", "network:serve"])(
"fails closed without %s",
async (capability) => {
db = await createTestDb(pluginDir);
const mock = createMockCtx({
pluginId: manifest.id,
manifest,
capabilities: manifest.capabilities.filter((c) => c !== capability),
db: db.database,
router: () => express.Router(),
});
await expect(activate(mock.ctx)).rejects.toBeInstanceOf(
PluginCapabilityError,
);
for (const dispose of [...mock.disposals].reverse()) await dispose();
},
);
});
describe("host import", () => {
it("carries the terminal switches of an imported host", () => {
expect(hostImportNormalizer({ name: "web" })).toBeNull();
expect(
hostImportNormalizer({
enableTerminal: false,
enableCommandHistory: true,
}),
).toEqual({ enableTerminal: false, enableCommandHistory: true });
});
});
@@ -0,0 +1,92 @@
import http from "node:http";
import type { AddressInfo } from "node:net";
import { fileURLToPath } from "node:url";
import express, { type Router } from "express";
import {
createMockCtx,
createTestDb,
type MockPluginContext,
type TestDb,
} from "@termix/plugin-sdk/testing";
import type { PluginManifest } from "@termix/plugin-sdk/manifest";
import manifestJson from "../../manifest.json";
export const pluginDir = fileURLToPath(new URL("../..", import.meta.url));
export const manifest = manifestJson as unknown as PluginManifest;
export interface TestServer {
db: TestDb;
mock: MockPluginContext;
request: (
method: string,
path: string,
options?: { user?: string; body?: unknown },
// Route bodies vary per test; asserting on them is the point.
// eslint-disable-next-line @typescript-eslint/no-explicit-any
) => Promise<{ status: number; body: any }>;
close: () => Promise<void>;
}
/**
* The plugin activated against a real in-memory database, its router mounted
* the way core mounts it, with the acting user taken from a test header.
*/
export async function startServer(
options: { permissions?: string[]; settings?: Record<string, unknown> } = {},
): Promise<TestServer> {
const db = await createTestDb(pluginDir);
for (const user of ["user-1", "user-2"]) {
db.sqlite
.prepare("INSERT INTO users (id, username) VALUES (?, ?)")
.run(user, user);
}
db.sqlite.exec("INSERT INTO ssh_data (id) VALUES (1), (2)");
let router: Router | null = null;
const mock = createMockCtx({
pluginId: manifest.id,
manifest,
capabilities: manifest.capabilities,
db: db.database,
router: () => (router = express.Router()),
permissions: options.permissions,
settings: options.settings,
});
const { activate } = await import("../../src/backend/index.js");
await activate(mock.ctx);
const app = express();
app.use((req, _res, next) => {
mock.setActor(req.header("x-test-user") ?? undefined);
next();
});
app.use((req, res, next) => router!(req, res, next));
const server = http.createServer(app);
await new Promise<void>((resolve) => server.listen(0, resolve));
const { port } = server.address() as AddressInfo;
return {
db,
mock,
async request(method, path, { user = "user-1", body: given } = {}) {
const body = method === "GET" ? undefined : given;
const response = await fetch(`http://127.0.0.1:${port}${path}`, {
method,
headers: {
"x-test-user": user,
...(body !== undefined ? { "content-type": "application/json" } : {}),
},
body: body !== undefined ? JSON.stringify(body) : undefined,
});
const text = await response.text();
return { status: response.status, body: text ? JSON.parse(text) : null };
},
async close() {
await new Promise<void>((resolve) => server.close(() => resolve()));
for (const dispose of [...mock.disposals].reverse()) await dispose();
db.close();
},
};
}
@@ -0,0 +1,191 @@
import path from "node:path";
import { describe, expect, it } from "vitest";
import {
DEFAULT_IMAGE_HOST_PATH,
DEFAULT_IMAGE_MAX_BYTES,
DEFAULT_IMAGE_MAX_COUNT,
DEFAULT_IMAGE_TTL_MS,
defaultImageLocalDir,
parseImageHostPath,
parseImageLocalDir,
parseTerminalImageStorageMode,
resolveTerminalImageStorageSettings,
TERMINAL_IMAGE_STORAGE_KEYS,
} from "../../../src/backend/images/image-storage-settings.js";
import { createFakeContext } from "@termix/plugin-sdk/testing";
import { readImageStorageSettings } from "../../../src/backend/settings.js";
// parseImageLocalDir resolves against the host platform, so a POSIX literal
// becomes a drive-rooted path on Windows. Compare against the same resolution.
function localDir(posixPath: string): string {
return path.resolve(posixPath);
}
function stubSettings(values: Record<string, string> = {}) {
return {
get: async (key: string) => values[key] ?? null,
};
}
const EMPTY_ENV: NodeJS.ProcessEnv = {};
describe("terminal image storage settings", () => {
describe("mode parsing", () => {
it("accepts the three documented modes case-insensitively", () => {
expect(parseTerminalImageStorageMode("auto")).toBe("auto");
expect(parseTerminalImageStorageMode("LOCAL")).toBe("local");
expect(parseTerminalImageStorageMode(" remote-sftp ")).toBe(
"remote-sftp",
);
});
it("rejects unknown modes and non-strings", () => {
expect(parseTerminalImageStorageMode("s3")).toBeNull();
expect(parseTerminalImageStorageMode("")).toBeNull();
expect(parseTerminalImageStorageMode(undefined)).toBeNull();
});
});
describe("path validation", () => {
it("accepts absolute local directories and normalizes them", () => {
expect(parseImageLocalDir("/var/lib/termix/images")).toBe(
localDir("/var/lib/termix/images"),
);
expect(parseImageLocalDir("/var/lib/termix/../termix/images")).toBeNull();
});
it("rejects relative, empty and NUL-containing local directories", () => {
expect(parseImageLocalDir("images")).toBeNull();
expect(parseImageLocalDir("./db/data/images")).toBeNull();
expect(parseImageLocalDir("")).toBeNull();
expect(parseImageLocalDir("/tmp/a\0b")).toBeNull();
});
it("requires the agent-visible host path to be POSIX-absolute", () => {
expect(parseImageHostPath("/tmp/termix-image-v0")).toBe(
"/tmp/termix-image-v0",
);
expect(parseImageHostPath("tmp/termix-image-v0")).toBeNull();
expect(parseImageHostPath("C:\\images")).toBeNull();
expect(parseImageHostPath("/tmp/a\0b")).toBeNull();
});
});
describe("resolution precedence", () => {
it("uses built-in defaults when neither the database nor env has values", async () => {
const resolved = await resolveTerminalImageStorageSettings(
stubSettings(),
EMPTY_ENV,
);
expect(resolved.mode).toBe("auto");
expect(resolved.localDir).toBe(defaultImageLocalDir(EMPTY_ENV));
expect(resolved.hostPath).toBe(DEFAULT_IMAGE_HOST_PATH);
expect(resolved.ttlMs).toBe(DEFAULT_IMAGE_TTL_MS);
expect(resolved.maxCount).toBe(DEFAULT_IMAGE_MAX_COUNT);
expect(resolved.maxBytes).toBe(DEFAULT_IMAGE_MAX_BYTES);
});
it("seeds defaults from legacy TERMIX_IMAGE_* env when no DB value exists", async () => {
const resolved = await resolveTerminalImageStorageSettings(
stubSettings(),
{
TERMIX_IMAGE_DIR: "/host-tmp/images",
TERMIX_IMAGE_HOST_PATH: "/tmp/images",
TERMIX_IMAGE_TTL_MS: "60000",
TERMIX_MAX_IMAGE_COUNT: "5",
TERMIX_MAX_IMAGE_STORAGE_BYTES: "10485760",
},
);
expect(resolved.localDir).toBe(localDir("/host-tmp/images"));
expect(resolved.hostPath).toBe("/tmp/images");
expect(resolved.ttlMs).toBe(60_000);
expect(resolved.maxCount).toBe(5);
expect(resolved.maxBytes).toBe(10_485_760);
});
it("lets persisted DB values win over legacy env values", async () => {
const resolved = await resolveTerminalImageStorageSettings(
stubSettings({
[TERMINAL_IMAGE_STORAGE_KEYS.localDir]: "/db/images",
[TERMINAL_IMAGE_STORAGE_KEYS.ttlMs]: "1000",
[TERMINAL_IMAGE_STORAGE_KEYS.maxCount]: "7",
}),
{
TERMIX_IMAGE_DIR: "/host-tmp/images",
TERMIX_IMAGE_TTL_MS: "60000",
TERMIX_MAX_IMAGE_COUNT: "5",
},
);
expect(resolved.localDir).toBe(localDir("/db/images"));
expect(resolved.ttlMs).toBe(1_000);
expect(resolved.maxCount).toBe(7);
});
it("falls through to env and defaults when the DB value is invalid", async () => {
const resolved = await resolveTerminalImageStorageSettings(
stubSettings({
[TERMINAL_IMAGE_STORAGE_KEYS.localDir]: "relative/path",
[TERMINAL_IMAGE_STORAGE_KEYS.ttlMs]: "not-a-number",
}),
{ TERMIX_IMAGE_DIR: "/host-tmp/images" },
);
expect(resolved.localDir).toBe(localDir("/host-tmp/images"));
expect(resolved.ttlMs).toBe(DEFAULT_IMAGE_TTL_MS);
});
it("keeps legacy explicit local mappings on local mode", async () => {
const resolved = await resolveTerminalImageStorageSettings(
stubSettings(),
{ TERMIX_IMAGE_DIR: "/host-tmp/images" },
);
expect(resolved.mode).toBe("local");
// hostPath falls back to the legacy local dir, which resolves natively.
expect(resolved.hostPath).toBe(localDir("/host-tmp/images"));
expect(resolved.localMappingConfigured).toBe(true);
});
it("lets a persisted DB mode override the legacy local mapping", async () => {
const resolved = await resolveTerminalImageStorageSettings(
stubSettings({
[TERMINAL_IMAGE_STORAGE_KEYS.mode]: "remote-sftp",
}),
{ TERMIX_IMAGE_DIR: "/host-tmp/images" },
);
expect(resolved.mode).toBe("remote-sftp");
});
it("clamps out-of-range numeric values like the legacy env parsing did", async () => {
const resolved = await resolveTerminalImageStorageSettings(
stubSettings(),
{
TERMIX_IMAGE_TTL_MS: "-5",
TERMIX_MAX_IMAGE_COUNT: "0",
TERMIX_MAX_IMAGE_STORAGE_BYTES: "10",
},
);
expect(resolved.ttlMs).toBe(0);
expect(resolved.maxCount).toBe(1);
expect(resolved.maxBytes).toBe(1_048_576);
});
it("reads the plugin's admin settings, and treats unset fields as unset", async () => {
const { ctx } = createFakeContext({
settings: {
imageStorageMode: "local",
imageLocalDir: "/persisted/images",
},
});
const resolved = await readImageStorageSettings(ctx);
expect(resolved.mode).toBe("local");
expect(resolved.localDir).toBe(localDir("/persisted/images"));
// Nothing stored and no environment: the built-in default.
expect(resolved.maxCount).toBe(DEFAULT_IMAGE_MAX_COUNT);
});
it("keeps TERMINAL_IMAGE_STORAGE_KEYS as the lookup names", () => {
expect(TERMINAL_IMAGE_STORAGE_KEYS.mode).toBe(
"terminal_image_storage_mode",
);
});
});
});
@@ -0,0 +1,571 @@
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { EventEmitter } from "events";
import fs from "fs/promises";
import os from "os";
import path from "path";
import { randomUUID } from "crypto";
import {
REMOTE_IMAGE_DIR,
selectImageStorageMode,
storeImageLocally,
storeImageViaSftp,
TerminalImageStorageError,
type ImageSftpClient,
} from "../../../src/backend/images/image-storage.js";
import type { TerminalImageStorageSettings } from "../../../src/backend/images/image-storage-settings.js";
const PNG_BYTES = Buffer.from([0x89, 0x50, 0x4e, 0x47]);
function settings(
overrides: Partial<TerminalImageStorageSettings>,
): TerminalImageStorageSettings {
return {
mode: "local",
localDir: "/nonexistent",
hostPath: "/tmp/termix-image-v0",
ttlMs: 3_600_000,
maxCount: 100,
maxBytes: 5_368_709_120,
localMappingConfigured: false,
...overrides,
};
}
describe("selectImageStorageMode", () => {
it("keeps explicit modes deterministic regardless of capability", () => {
expect(
selectImageStorageMode(settings({ mode: "local" }), {
remoteSftpAvailable: true,
}),
).toBe("local");
expect(
selectImageStorageMode(settings({ mode: "remote-sftp" }), {
remoteSftpAvailable: false,
}),
).toBe("remote-sftp");
});
it("falls back on capability only in auto mode", () => {
expect(
selectImageStorageMode(settings({ mode: "auto" }), {
remoteSftpAvailable: true,
}),
).toBe("remote-sftp");
expect(
selectImageStorageMode(settings({ mode: "auto" }), {
remoteSftpAvailable: false,
}),
).toBe("unavailable");
expect(
selectImageStorageMode(
settings({ mode: "auto", localMappingConfigured: true }),
{ remoteSftpAvailable: false, localHostVisible: true },
),
).toBe("local");
});
});
describe("storeImageLocally", () => {
let dir: string;
beforeEach(async () => {
dir = await fs.mkdtemp(path.join(os.tmpdir(), "termix-images-test-"));
});
afterEach(async () => {
await fs.rm(dir, { recursive: true, force: true });
});
async function seedFile(bytes: number, mtimeMs?: number): Promise<string> {
const name = `${randomUUID()}.png`;
const filePath = path.join(dir, name);
await fs.writeFile(filePath, Buffer.alloc(bytes));
if (mtimeMs !== undefined) {
const date = new Date(mtimeMs);
await fs.utimes(filePath, date, date);
}
return name;
}
it("writes a UUID-named PNG and returns the agent-visible host path", async () => {
const stored = await storeImageLocally(
PNG_BYTES,
settings({ localDir: dir, hostPath: "/host-view/images" }),
);
expect(stored.storage).toBe("local");
expect(stored.filename).toBe(`${stored.id}.png`);
expect(stored.shellPath).toBe(
path.posix.join("/host-view/images", stored.filename),
);
expect(stored.shellPath).not.toContain(dir);
await expect(fs.readFile(path.join(dir, stored.filename))).resolves.toEqual(
PNG_BYTES,
);
});
it("rejects with IMAGE_STORAGE_LIMIT_REACHED when the count cap is full", async () => {
await seedFile(10);
const error = await storeImageLocally(
PNG_BYTES,
settings({ localDir: dir, maxCount: 1 }),
).catch((caught: unknown) => caught);
expect(error).toBeInstanceOf(TerminalImageStorageError);
expect((error as TerminalImageStorageError).code).toBe(
"IMAGE_STORAGE_LIMIT_REACHED",
);
});
it("rejects with IMAGE_STORAGE_LIMIT_REACHED when the byte cap is full", async () => {
await seedFile(900);
const error = await storeImageLocally(
Buffer.alloc(200),
settings({ localDir: dir, maxBytes: 1_000 }),
).catch((caught: unknown) => caught);
expect((error as TerminalImageStorageError).code).toBe(
"IMAGE_STORAGE_LIMIT_REACHED",
);
});
it("cleans expired files during upload so they no longer count", async () => {
await seedFile(10, Date.now() - 2 * 3_600_000);
const stored = await storeImageLocally(
PNG_BYTES,
settings({ localDir: dir, maxCount: 1, ttlMs: 3_600_000 }),
);
expect(stored.storage).toBe("local");
const remaining = await fs.readdir(dir);
expect(remaining).toEqual([stored.filename]);
});
it("fails closed when local storage inspection is unavailable", async () => {
const blocked = path.join(dir, "blocked-file");
await fs.writeFile(blocked, "not a directory");
const error = await storeImageLocally(
PNG_BYTES,
settings({ localDir: blocked }),
).catch((caught: unknown) => caught);
expect(error).toBeInstanceOf(TerminalImageStorageError);
expect((error as TerminalImageStorageError).code).toBe(
"IMAGE_LOCAL_INSPECTION_FAILED",
);
});
it("reports inspection failures before attempting a write", async () => {
const blocked = path.join(dir, "blocked");
await fs.writeFile(blocked, "not a directory");
const error = await storeImageLocally(
PNG_BYTES,
settings({ localDir: path.join(blocked, "images") }),
).catch((caught: unknown) => caught);
expect(error).toBeInstanceOf(TerminalImageStorageError);
expect((error as TerminalImageStorageError).code).toBe(
"IMAGE_LOCAL_INSPECTION_FAILED",
);
});
});
describe("storeImageViaSftp", () => {
function fakeSftp(behavior: {
mkdirError?: Error;
writeError?: Error;
stallWrite?: boolean;
stallLock?: boolean;
stallReaddir?: boolean;
stallRmdir?: boolean;
stallUnlink?: boolean;
readdirEntries?: Array<{ filename: string; mtime?: number; size?: number }>;
readdirError?: Error;
statMode?: number;
}): {
sftp: ImageSftpClient;
written: Map<string, Buffer>;
calls: {
mkdir: Array<{ dir: string; mode?: number }>;
createWriteStream: Array<{ path: string; mode?: number }>;
};
streams: Array<
NodeJS.WritableStream & { destroy: () => void; destroyed: boolean }
>;
} {
const written = new Map<string, Buffer>();
const streams: Array<
NodeJS.WritableStream & { destroy: () => void; destroyed: boolean }
> = [];
const calls = { mkdir: [], createWriteStream: [] } as {
mkdir: Array<{ dir: string; mode?: number }>;
createWriteStream: Array<{ path: string; mode?: number }>;
};
const sftp = {
mkdir: (
dir: string,
attrsOrCallback: { mode?: number } | ((err?: Error) => void),
maybeCallback?: (err?: Error) => void,
) => {
const callback =
typeof attrsOrCallback === "function"
? attrsOrCallback
: maybeCallback!;
calls.mkdir.push({
dir,
mode:
typeof attrsOrCallback === "function"
? undefined
: attrsOrCallback.mode,
});
if (
behavior.stallLock &&
dir === `${REMOTE_IMAGE_DIR}/.termix-write-lock`
) {
return;
}
callback(dir === REMOTE_IMAGE_DIR ? behavior.mkdirError : undefined);
},
stat: (
_dir: string,
callback: (error: Error | undefined, attrs?: { mode?: number }) => void,
) => callback(undefined, { mode: behavior.statMode ?? 0o40700 }),
chmod: (_dir: string, _mode: number, callback: (error?: Error) => void) =>
callback(),
readdir: (
_dir: string,
callback: (
error: Error | undefined,
entries: Array<{
filename: string;
attrs?: { mtime?: number; size?: number };
}>,
) => void,
) => {
if (behavior.stallReaddir) return;
callback(
behavior.readdirError,
behavior.readdirEntries?.map((entry) => ({
filename: entry.filename,
attrs: entry,
})) ?? [],
);
},
createWriteStream: (remotePath: string, options?: { mode?: number }) => {
calls.createWriteStream.push({ path: remotePath, mode: options?.mode });
const stream = new EventEmitter() as NodeJS.WritableStream & {
end: (data: Buffer) => void;
destroy: () => void;
destroyed: boolean;
};
stream.destroyed = false;
stream.destroy = () => {
stream.destroyed = true;
};
streams.push(stream);
stream.end = (data: Buffer) => {
if (behavior.stallWrite) return;
queueMicrotask(() => {
if (behavior.writeError) {
stream.emit("error", behavior.writeError);
return;
}
written.set(remotePath, data);
stream.emit("close");
});
};
return stream;
},
unlink: (_remotePath: string, callback: (error?: Error) => void) => {
if (behavior.stallUnlink) return;
callback();
},
rmdir: (_dir: string, callback: (error?: Error) => void) => {
if (behavior.stallRmdir) return;
callback();
},
} as unknown as ImageSftpClient;
return { sftp, written, calls, streams };
}
it("writes into the remote image directory and returns its POSIX path", async () => {
const { sftp, written } = fakeSftp({});
const stored = await storeImageViaSftp(sftp, PNG_BYTES);
expect(stored.storage).toBe("remote-sftp");
expect(stored.shellPath).toBe(`${REMOTE_IMAGE_DIR}/${stored.id}.png`);
expect(written.get(stored.shellPath)).toEqual(PNG_BYTES);
});
it("requests restrictive modes for the remote directory and file", async () => {
const { sftp, calls } = fakeSftp({});
const stored = await storeImageViaSftp(sftp, PNG_BYTES);
expect(stored.storage).toBe("remote-sftp");
expect(calls.mkdir).toEqual([
{ dir: REMOTE_IMAGE_DIR, mode: 0o700 },
{ dir: `${REMOTE_IMAGE_DIR}/.termix-write-lock`, mode: 0o700 },
]);
expect(calls.createWriteStream).toEqual([
{ path: stored.shellPath, mode: 0o600 },
]);
});
it("removes expired UUID PNGs with best-effort remote retention", async () => {
const nowMs = 10_000_000;
const expiredName = `${randomUUID()}.png`;
const freshName = `${randomUUID()}.png`;
const unlinked: string[] = [];
const base = fakeSftp({});
const sftp = base.sftp as ImageSftpClient & {
readdir: (
dir: string,
callback: (
err: Error | undefined,
entries: Array<{ filename: string; attrs?: { mtime?: number } }>,
) => void,
) => void;
unlink: (remotePath: string, callback: (err?: Error) => void) => void;
};
sftp.readdir = (_dir, callback) =>
callback(undefined, [
{ filename: expiredName, attrs: { mtime: (nowMs - 7_200_000) / 1000 } },
{ filename: freshName, attrs: { mtime: nowMs / 1000 } },
{ filename: "other.txt", attrs: { mtime: 0 } },
]);
sftp.unlink = (remotePath, callback) => {
unlinked.push(remotePath);
callback();
};
await storeImageViaSftp(sftp, PNG_BYTES, {
ttlMs: 3_600_000,
nowMs,
});
expect(unlinked).toEqual([`${REMOTE_IMAGE_DIR}/${expiredName}`]);
});
it("fails closed when remote quota inspection fails", async () => {
const { sftp } = fakeSftp({
readdirError: new Error("remote listing failed"),
});
const error = await storeImageViaSftp(sftp, PNG_BYTES, {
maxCount: 10,
maxBytes: 1024,
}).catch((caught: unknown) => caught);
expect(error).toBeInstanceOf(TerminalImageStorageError);
expect((error as TerminalImageStorageError).code).toBe(
"IMAGE_REMOTE_QUOTA_UNAVAILABLE",
);
});
it("rejects remote writes at the configured count limit", async () => {
const { sftp } = fakeSftp({
readdirEntries: [{ filename: `${randomUUID()}.png`, size: 12 }],
});
const error = await storeImageViaSftp(sftp, PNG_BYTES, {
maxCount: 1,
maxBytes: 1024,
}).catch((caught: unknown) => caught);
expect(error).toBeInstanceOf(TerminalImageStorageError);
expect((error as TerminalImageStorageError).code).toBe(
"IMAGE_STORAGE_LIMIT_REACHED",
);
});
it("destroys a stalled SFTP write after its timeout", async () => {
const { sftp, streams } = fakeSftp({ stallWrite: true });
const error = await storeImageViaSftp(sftp, PNG_BYTES, {
writeTimeoutMs: 25,
}).catch((caught: unknown) => caught);
expect(error).toBeInstanceOf(TerminalImageStorageError);
expect((error as TerminalImageStorageError).code).toBe(
"IMAGE_REMOTE_WRITE_FAILED",
);
expect(streams[0]!.destroyed).toBe(true);
});
it("rejects an existing remote path that is not a directory", async () => {
const { sftp } = fakeSftp({
mkdirError: new Error("Failure: file already exists"),
statMode: 0o100644,
});
const error = await storeImageViaSftp(sftp, PNG_BYTES).catch(
(caught: unknown) => caught,
);
expect(error).toBeInstanceOf(TerminalImageStorageError);
expect((error as TerminalImageStorageError).code).toBe(
"IMAGE_REMOTE_WRITE_FAILED",
);
});
it("preserves SFTP client context for directory inspection", async () => {
const base = fakeSftp({ mkdirError: new Error("already exists") });
const sftp = base.sftp;
sftp.lstat = function (
this: ImageSftpClient,
_dir: string,
callback: (error: Error | undefined, attrs?: { mode?: number }) => void,
) {
if (this !== sftp) {
callback(new Error("SFTP context lost"));
return;
}
callback(undefined, { mode: 0o40700 });
};
const result = await storeImageViaSftp(sftp, PNG_BYTES);
expect(result.storage).toBe("remote-sftp");
});
it("tolerates mkdir failures for an already-existing directory", async () => {
const { sftp } = fakeSftp({
mkdirError: new Error("Failure: file already exists"),
});
await expect(storeImageViaSftp(sftp, PNG_BYTES)).resolves.toMatchObject({
storage: "remote-sftp",
});
});
it("cleans up a partially created remote file after a write failure", async () => {
const { sftp } = fakeSftp({ writeError: new Error("write failed") });
const unlinked: string[] = [];
(sftp as ImageSftpClient).unlink = (remotePath, callback) => {
unlinked.push(remotePath);
callback();
};
const error = await storeImageViaSftp(sftp, PNG_BYTES).catch(
(caught: unknown) => caught,
);
expect(error).toBeInstanceOf(TerminalImageStorageError);
expect(unlinked).toHaveLength(1);
expect(unlinked[0]).toMatch(
new RegExp(`${REMOTE_IMAGE_DIR}/[0-9a-f-]+\\.png`),
);
});
it("preserves the write error when partial-file cleanup stalls", async () => {
const { sftp } = fakeSftp({
writeError: new Error("write failed"),
stallUnlink: true,
});
const error = await storeImageViaSftp(sftp, PNG_BYTES).catch(
(caught: unknown) => caught,
);
expect(error).toBeInstanceOf(TerminalImageStorageError);
expect((error as TerminalImageStorageError).code).toBe(
"IMAGE_REMOTE_WRITE_FAILED",
);
}, 5_000);
it("recovers a stale remote lock before writing", async () => {
const base = fakeSftp({});
const sftp = base.sftp as ImageSftpClient;
const lockPath = `${REMOTE_IMAGE_DIR}/.termix-write-lock`;
let lockAttempt = 0;
const removed: string[] = [];
const originalMkdir = sftp.mkdir.bind(sftp);
sftp.mkdir = (dir, attrs, callback) => {
if (dir === lockPath && lockAttempt++ === 0) {
(typeof attrs === "function" ? attrs : callback!)(new Error("exists"));
return;
}
originalMkdir(dir, attrs, callback);
};
sftp.lstat = (_dir, callback) =>
callback(undefined, {
mode: 0o40700,
mtime: (Date.now() - 60_000) / 1000,
});
sftp.rmdir = (dir, callback) => {
removed.push(dir);
callback();
};
await expect(storeImageViaSftp(sftp, PNG_BYTES)).resolves.toMatchObject({
storage: "remote-sftp",
});
expect(removed).toContain(lockPath);
});
it("does not replace a successful write with an unlock failure", async () => {
const base = fakeSftp({});
const sftp = base.sftp as ImageSftpClient;
const lockPath = `${REMOTE_IMAGE_DIR}/.termix-write-lock`;
const originalMkdir = sftp.mkdir.bind(sftp);
sftp.mkdir = (dir, attrs, callback) => originalMkdir(dir, attrs, callback);
sftp.rmdir = (dir, callback) => {
if (dir === lockPath) callback(new Error("unlock failed"));
else callback();
};
const error = await storeImageViaSftp(sftp, PNG_BYTES).catch(
(caught: unknown) => caught,
);
expect(error).toBeInstanceOf(TerminalImageStorageError);
expect((error as TerminalImageStorageError).code).toBe(
"IMAGE_REMOTE_WRITE_FAILED",
);
});
it("fails closed when remote lock acquisition stalls", async () => {
const { sftp } = fakeSftp({ stallLock: true });
const error = await storeImageViaSftp(sftp, PNG_BYTES).catch(
(caught: unknown) => caught,
);
expect(error).toBeInstanceOf(TerminalImageStorageError);
expect((error as TerminalImageStorageError).code).toBe(
"IMAGE_REMOTE_WRITE_FAILED",
);
}, 12_000);
it("fails closed when remote quota inspection stalls", async () => {
const { sftp } = fakeSftp({ stallReaddir: true });
const error = await storeImageViaSftp(sftp, PNG_BYTES, {
maxCount: 10,
maxBytes: 1024,
}).catch((caught: unknown) => caught);
expect(error).toBeInstanceOf(TerminalImageStorageError);
expect((error as TerminalImageStorageError).code).toBe(
"IMAGE_REMOTE_WRITE_FAILED",
);
}, 7_000);
it("fails closed when remote expiry cleanup stalls", async () => {
const { sftp } = fakeSftp({ stallReaddir: true });
const error = await storeImageViaSftp(sftp, PNG_BYTES, {
ttlMs: 1_000,
}).catch((caught: unknown) => caught);
expect(error).toBeInstanceOf(TerminalImageStorageError);
expect((error as TerminalImageStorageError).code).toBe(
"IMAGE_REMOTE_WRITE_FAILED",
);
}, 7_000);
it("fails closed when remote lock release stalls", async () => {
const { sftp } = fakeSftp({ stallRmdir: true });
const error = await storeImageViaSftp(sftp, PNG_BYTES).catch(
(caught: unknown) => caught,
);
expect(error).toBeInstanceOf(TerminalImageStorageError);
expect((error as TerminalImageStorageError).code).toBe(
"IMAGE_REMOTE_WRITE_FAILED",
);
}, 5_000);
it("maps SFTP failures to IMAGE_REMOTE_WRITE_FAILED", async () => {
const { sftp } = fakeSftp({ writeError: new Error("Permission denied") });
const error = await storeImageViaSftp(sftp, PNG_BYTES).catch(
(caught: unknown) => caught,
);
expect(error).toBeInstanceOf(TerminalImageStorageError);
expect((error as TerminalImageStorageError).code).toBe(
"IMAGE_REMOTE_WRITE_FAILED",
);
expect((error as TerminalImageStorageError).message).toBe(
"Failed to write image to the remote host",
);
});
});
@@ -0,0 +1,76 @@
import { describe, expect, it } from "vitest";
import {
exceedsImageStorageLimit,
exceedsNormalizedImageSize,
imageExtensionForFormat,
createConcurrencyLimiter,
isExpiredImage,
isImageFilename,
} from "../../../src/backend/images/image-utils.js";
describe("terminal image utilities", () => {
it("accepts UUID-based image filenames", () => {
expect(isImageFilename("b797234d-eb5d-4b1b-b7f3-17c26f257506.jpeg")).toBe(
true,
);
});
it("rejects unsafe or unrelated filenames", () => {
expect(isImageFilename("../secrets.txt")).toBe(false);
expect(isImageFilename("not-an-image.jpeg")).toBe(false);
expect(isImageFilename("b797234d-eb5d-4b1b-b7f3-17c26f257506")).toBe(false);
});
it("maps decoded raster formats while excluding SVG", () => {
expect(imageExtensionForFormat("jpeg")).toBe("jpg");
expect(imageExtensionForFormat("heif")).toBe("heif");
expect(imageExtensionForFormat("tiff")).toBe("tiff");
expect(imageExtensionForFormat("svg")).toBeUndefined();
expect(imageExtensionForFormat(undefined)).toBeUndefined();
});
it("rejects normalized output beyond the byte ceiling", () => {
expect(exceedsNormalizedImageSize(10_000_001, 10_000_000)).toBe(true);
expect(exceedsNormalizedImageSize(10_000_000, 10_000_000)).toBe(false);
});
it("expires files older than the configured TTL", () => {
expect(isExpiredImage(1_000, 3_000, 1_000)).toBe(true);
expect(isExpiredImage(2_500, 3_000, 1_000)).toBe(false);
});
it("treats a zero TTL as retention disabled", () => {
expect(isExpiredImage(1_000, 999_999, 0)).toBe(false);
});
it("bounds the admission queue", async () => {
const limiter = createConcurrencyLimiter(1, 0);
const release = await limiter.acquire();
await expect(limiter.acquire()).rejects.toThrow("queue is full");
release();
});
it("rejects uploads that exceed the count or byte limit", () => {
expect(exceedsImageStorageLimit(100, 10, 1, 100, 1000)).toBe(true);
expect(exceedsImageStorageLimit(1, 900, 101, 100, 1000)).toBe(true);
expect(exceedsImageStorageLimit(1, 900, 100, 100, 1000)).toBe(false);
});
it("bounds concurrent work", async () => {
const limiter = createConcurrencyLimiter(1);
let active = 0;
let peak = 0;
const task = async () => {
const release = await limiter.acquire();
active += 1;
peak = Math.max(peak, active);
await new Promise((resolve) => setTimeout(resolve, 5));
active -= 1;
release();
};
await Promise.all([task(), task(), task()]);
expect(peak).toBe(1);
expect(limiter.active).toBe(0);
});
});
@@ -0,0 +1,292 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import type {
PluginKeyboardInteractiveDecision,
PluginKeyboardInteractivePrompt,
PluginSshHost,
} from "@termix/plugin-sdk/backend";
import { SSHAuthManager } from "../../src/backend/keyboard-prompt.js";
/**
* Stands in for core's classifier (ctx.ssh.classifyKeyboardInteractive),
* which core's own tests cover. These tests are about what the terminal does
* with each decision: the socket messages, the answers and the timeouts.
*/
const PASSWORD = /password/i;
const FORTI = /type\s+['"]?push['"]?/i;
const PUSH =
/choose.*push.*totp|press enter.*(push|send)|push notification|authentication by phone/i;
const TOTP =
/verification code|verification_code|token|otp|2fa|authenticator|google.*auth/i;
function classify(
round: {
name: string;
instructions: string;
prompts: PluginKeyboardInteractivePrompt[];
},
host: PluginSshHost,
): PluginKeyboardInteractiveDecision {
if (/warpgate/i.test(round.name)) {
return {
kind: "warpgate",
url: round.instructions.match(/https?:\/\/\S+/)?.[0] ?? null,
securityKey: round.instructions.match(/Security key: (\S+)/)?.[1] ?? "",
instructions: round.instructions,
};
}
const texts = round.prompts.map((p) => p.prompt);
const isPush =
!texts.some((t) => FORTI.test(t)) && texts.some((t) => PUSH.test(t));
if (!isPush) {
const totp = round.prompts.findIndex((p) => TOTP.test(p.prompt));
if (totp !== -1) return { kind: "totp", promptIndex: totp };
}
const stored = !!host.password && host.authType !== "none";
const first = round.prompts.findIndex(
(p) => !(PASSWORD.test(p.prompt) && stored),
);
if (first === -1) {
return {
kind: "auto",
responses: round.prompts.map((p) =>
PASSWORD.test(p.prompt) ? String(host.password) : "",
),
};
}
return { kind: "input", promptIndex: first, isPush: PUSH.test(texts[first]) };
}
const log = { info: vi.fn(), success: vi.fn(), warn: vi.fn(), error: vi.fn() };
function createManager() {
const sent: Record<string, unknown>[] = [];
const ws = { send: (data: string) => sent.push(JSON.parse(data)) } as any;
const manager = new SSHAuthManager({
ssh: { classifyKeyboardInteractive: classify },
log,
userId: "user-1",
ws,
hostId: 1,
isKeyboardInteractive: false,
keyboardInteractiveResponded: false,
keyboardInteractiveFinish: null,
totpPromptSent: false,
warpgateAuthPromptSent: false,
totpTimeout: null,
warpgateAuthTimeout: null,
totpAttempts: 0,
});
return { manager, sent };
}
describe("SSHAuthManager.handleKeyboardInteractive", () => {
beforeEach(() => {
vi.useRealTimers();
});
it("routes a TOTP verification prompt to the totp flow", () => {
const { manager, sent } = createManager();
const finish = vi.fn();
manager.handleKeyboardInteractive(
"",
"",
"",
[{ prompt: "Verification code: ", echo: true }],
finish,
{ username: "root", authType: "none" },
);
expect(sent).toEqual([
{
type: "connection_log",
data: {
stage: "auth",
level: "info",
message: "TOTP verification required",
},
},
{ type: "totp_required", prompt: "Verification code: " },
]);
expect(finish).not.toHaveBeenCalled();
});
it("forwards echo:true for a JumpCloud-style push/TOTP menu prompt", () => {
const { manager, sent } = createManager();
const finish = vi.fn();
manager.handleKeyboardInteractive(
"",
"",
"",
[{ prompt: "Choose [1] Push, or [2] TOTP: ", echo: true }],
finish,
{ username: "root", authType: "none" },
);
expect(sent).toEqual([
{
type: "connection_log",
data: {
stage: "auth",
level: "info",
message: "Password authentication required",
},
},
{
type: "password_required",
prompt: "Choose [1] Push, or [2] TOTP: ",
echo: true,
},
]);
});
it("silently auto-answers a plain password prompt when a stored password exists", () => {
const { manager, sent } = createManager();
const finish = vi.fn();
manager.handleKeyboardInteractive(
"",
"",
"",
[{ prompt: "Password: ", echo: false }],
finish,
{ username: "root", password: "hunter2", authType: "password" },
);
expect(finish).toHaveBeenCalledWith(["hunter2"]);
expect(sent).toEqual([]);
});
it("prompts the user for a push-confirm prompt and accepts an empty response", () => {
const { manager, sent } = createManager();
const finish = vi.fn();
manager.handleKeyboardInteractive(
"",
"",
"",
[{ prompt: "Press enter to send Push request: ", echo: true }],
finish,
{ username: "root", authType: "none" },
);
expect(sent).toEqual([
{
type: "connection_log",
data: {
stage: "auth",
level: "info",
message: "Password authentication required",
},
},
{
type: "password_required",
prompt: "Press enter to send Push request: ",
echo: true,
},
]);
manager.context.keyboardInteractiveFinish?.([""]);
expect(finish).toHaveBeenCalledWith([""]);
});
it("uses a longer timeout for push-style prompts than generic prompts", () => {
vi.useFakeTimers();
try {
const { manager, sent } = createManager();
const finish = vi.fn();
manager.handleKeyboardInteractive(
"",
"",
"",
[{ prompt: "Press enter to send Push request: ", echo: true }],
finish,
{ username: "root", authType: "none" },
);
vi.advanceTimersByTime(180001);
expect(sent.some((m) => m.type === "error")).toBe(false);
vi.advanceTimersByTime(120000);
expect(sent.some((m) => m.type === "error")).toBe(true);
} finally {
vi.useRealTimers();
}
});
it("routes a FortiToken prompt to the totp flow instead of the push-confirm flow", () => {
const { manager, sent } = createManager();
const finish = vi.fn();
manager.handleKeyboardInteractive(
"",
"",
"",
[
{
prompt:
"Enter your Token or type 'push' to receive a push notification: ",
echo: true,
},
],
finish,
{ username: "root", authType: "none" },
);
expect(sent).toEqual([
{
type: "connection_log",
data: {
stage: "auth",
level: "info",
message: "TOTP verification required",
},
},
{
type: "totp_required",
prompt:
"Enter your Token or type 'push' to receive a push notification: ",
},
]);
manager.context.keyboardInteractiveFinish?.(["push"]);
expect(finish).toHaveBeenCalledWith(["push"]);
});
it("routes Warpgate prompts to the warpgate flow, not the generic path", () => {
const { manager, sent } = createManager();
const finish = vi.fn();
manager.handleKeyboardInteractive(
"Warpgate Authentication",
"Visit https://warpgate.example.com/auth to continue. Security key: AB12",
"",
[{ prompt: "Press enter once done: ", echo: true }],
finish,
{ username: "root", authType: "none" },
);
expect(sent).toEqual([
{
type: "connection_log",
data: {
stage: "auth",
level: "info",
message: "Warpgate authentication required",
},
},
{
type: "warpgate_auth_required",
url: "https://warpgate.example.com/auth",
securityKey: "AB12",
instructions:
"Visit https://warpgate.example.com/auth to continue. Security key: AB12",
},
]);
});
});
@@ -0,0 +1,105 @@
import { describe, it, expect, vi } from "vitest";
import { generateKeyPairSync } from "crypto";
import ssh2Pkg, { type ParsedKey } from "ssh2";
import { MemoryAgent } from "../../src/backend/helpers.js";
describe("MemoryAgent", () => {
it("serves identities and signatures over the agent protocol", async () => {
const { privateKey } = generateKeyPairSync("rsa", { modulusLength: 2048 });
const parsed = ssh2Pkg.utils.parseKey(
privateKey.export({ type: "pkcs1", format: "pem" }),
);
expect(parsed).not.toBeInstanceOf(Error);
const agent = new MemoryAgent(parsed as ParsedKey);
const stream = await new Promise<NodeJS.ReadWriteStream>(
(resolve, reject) => {
agent.getStream((error, result) => {
if (error || !result)
reject(error ?? new Error("Missing agent stream"));
else resolve(result);
});
},
);
const client = new ssh2Pkg.AgentProtocol(true);
client.pipe(stream).pipe(client);
const identities = await new Promise<ParsedKey[]>((resolve, reject) => {
client.getIdentities((error, keys) => {
if (error || !keys) reject(error ?? new Error("Missing identities"));
else resolve(keys);
});
});
expect(identities).toHaveLength(1);
expect(identities[0].getPublicSSH()).toEqual(
(parsed as ParsedKey).getPublicSSH(),
);
const data = Buffer.from("forwarded-agent-test");
const signature = await new Promise<Buffer>((resolve, reject) => {
client.sign(identities[0], data, (error, result) => {
if (error || !result) reject(error ?? new Error("Missing signature"));
else resolve(result);
});
});
expect((parsed as ParsedKey).verify(data, signature)).toBe(true);
client.destroy();
stream.destroy();
});
});
describe("MemoryAgent unsupported extension framing", () => {
it.each([false, true])(
"can enumerate keys after rejecting session-bind (fragmented=%s)",
async (fragmented) => {
const { privateKey } = generateKeyPairSync("rsa", {
modulusLength: 2048,
});
const key = ssh2Pkg.utils.parseKey(
privateKey.export({ type: "pkcs1", format: "pem" }),
) as ParsedKey;
const agent = new MemoryAgent(key);
const stream = await new Promise<NodeJS.ReadWriteStream>(
(resolve, reject) => {
agent.getStream((error, result) =>
error || !result ? reject(error) : resolve(result),
);
},
);
const replies: Buffer[] = [];
let buffered = Buffer.alloc(0);
stream.on("data", (data: Buffer) => {
buffered = Buffer.concat([buffered, data]);
while (
buffered.length >= 4 &&
buffered.length >= buffered.readUInt32BE(0) + 4
) {
const end = buffered.readUInt32BE(0) + 4;
replies.push(buffered.subarray(4, end));
buffered = buffered.subarray(end);
}
});
const extension = Buffer.from("session-bind@openssh.com");
const request = Buffer.alloc(4 + 1 + 4 + extension.length);
request.writeUInt32BE(request.length - 4, 0);
request[4] = 27;
request.writeUInt32BE(extension.length, 5);
extension.copy(request, 9);
const identities = Buffer.from([0, 0, 0, 1, 11]);
try {
const requests = Buffer.concat([request, identities]);
if (fragmented) {
stream.write(requests.subarray(0, 7));
stream.write(requests.subarray(7));
} else stream.write(requests);
await vi.waitFor(() => expect(replies).toHaveLength(2));
expect(replies[0][0]).toBe(5); // Unsupported extension remains refused.
expect(replies[1][0]).toBe(12); // The following identities request succeeds.
expect(replies[1].readUInt32BE(1)).toBe(1);
} finally {
stream.destroy();
}
},
);
});
@@ -0,0 +1,110 @@
import { afterEach, describe, expect, it } from "vitest";
import { createTestDb, type TestDb } from "@termix/plugin-sdk/testing";
import { pluginDir } from "./helpers";
// command_history as core's SQLite bootstrap created it before 2.9.0, plus the
// index performance-indexes.ts added.
const LEGACY_DDL = `
CREATE TABLE command_history (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id TEXT NOT NULL,
host_id INTEGER NOT NULL,
command TEXT NOT NULL,
executed_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE,
FOREIGN KEY (host_id) REFERENCES ssh_data (id) ON DELETE CASCADE
);
CREATE INDEX idx_command_history_user_host ON command_history (user_id, host_id);
`;
let db: TestDb | null = null;
afterEach(() => {
db?.close();
db = null;
});
const tableExists = (name: string) =>
!!db!.sqlite
.prepare("SELECT name FROM sqlite_master WHERE type = 'table' AND name = ?")
.get(name);
const indexesOn = (table: string) =>
(
db!.sqlite
.prepare(
"SELECT name FROM sqlite_master WHERE type = 'index' AND tbl_name = ? AND name NOT LIKE 'sqlite_%'",
)
.all(table) as { name: string }[]
).map((row) => row.name);
describe("adopting command_history", () => {
it("keeps every existing row and its index when the legacy table is there", async () => {
db = await createTestDb(pluginDir, {
before: (sqlite) => {
sqlite.exec("INSERT INTO users (id) VALUES ('user-1'), ('user-2')");
sqlite.exec("INSERT INTO ssh_data (id) VALUES (1), (2)");
sqlite.exec(LEGACY_DDL);
sqlite.exec(`
INSERT INTO command_history (user_id, host_id, command, executed_at)
VALUES ('user-1', 1, 'ls -la', '2026-01-01T00:00:00.000Z'),
('user-2', 2, 'uptime', '2026-01-02T00:00:00.000Z');
`);
},
});
expect(db.applied).toEqual(["0001_adopt_command_history"]);
expect(tableExists("command_history")).toBe(false);
expect(
db.sqlite
.prepare(
"SELECT user_id, host_id, command, executed_at FROM p_ssh_terminal_command_history ORDER BY id",
)
.all(),
).toEqual([
{
user_id: "user-1",
host_id: 1,
command: "ls -la",
executed_at: "2026-01-01T00:00:00.000Z",
},
{
user_id: "user-2",
host_id: 2,
command: "uptime",
executed_at: "2026-01-02T00:00:00.000Z",
},
]);
expect(indexesOn("p_ssh_terminal_command_history")).toEqual([
"idx_command_history_user_host",
]);
});
it("creates a fresh table when there is no legacy one", async () => {
db = await createTestDb(pluginDir);
expect(tableExists("p_ssh_terminal_command_history")).toBe(true);
expect(indexesOn("p_ssh_terminal_command_history")).toEqual([
"idx_command_history_user_host",
]);
});
it("cascades when the user or the host is deleted", async () => {
db = await createTestDb(pluginDir, {
before: (sqlite) => {
sqlite.exec("INSERT INTO users (id) VALUES ('user-1'), ('user-2')");
sqlite.exec("INSERT INTO ssh_data (id) VALUES (1), (2)");
},
});
db.sqlite.exec(`
INSERT INTO p_ssh_terminal_command_history (user_id, host_id, command)
VALUES ('user-1', 1, 'a'), ('user-2', 1, 'b'), ('user-2', 2, 'c');
`);
db.sqlite.exec("DELETE FROM users WHERE id = 'user-1'");
db.sqlite.exec("DELETE FROM ssh_data WHERE id = 2");
expect(
db.sqlite
.prepare("SELECT command FROM p_ssh_terminal_command_history")
.all(),
).toEqual([{ command: "b" }]);
});
});
@@ -0,0 +1,134 @@
import { afterEach, describe, expect, it } from "vitest";
import { startServer, type TestServer } from "./helpers";
let server: TestServer | null = null;
afterEach(async () => {
await server?.close();
server = null;
});
const history = (s: TestServer) =>
s.db.sqlite
.prepare(
"SELECT user_id, host_id, command FROM p_ssh_terminal_command_history ORDER BY id",
)
.all();
describe("command history routes", () => {
it("saves, lists newest first without duplicates, deletes and clears", async () => {
server = await startServer();
for (const command of ["ls", "pwd", "ls"]) {
const saved = await server.request("POST", "/command-history", {
body: { hostId: 1, command },
});
expect(saved.status).toBe(201);
}
await server.request("POST", "/command-history", {
user: "user-2",
body: { hostId: 1, command: "whoami" },
});
const listed = await server.request("GET", "/command-history/1");
expect(listed.body).toEqual(["ls", "pwd"]);
await server.request("POST", "/command-history/delete", {
body: { hostId: 1, command: "pwd" },
});
expect((await server.request("GET", "/command-history/1")).body).toEqual([
"ls",
]);
await server.request("DELETE", "/command-history/1");
expect(history(server)).toEqual([
{ user_id: "user-2", host_id: 1, command: "whoami" },
]);
expect(server.db.persisted).toBeGreaterThan(0);
});
it("acknowledges but never stores a command that looks like a secret", async () => {
server = await startServer();
const saved = await server.request("POST", "/command-history", {
body: { hostId: 1, command: "export API_TOKEN=abc" },
});
expect(saved.status).toBe(201);
expect(history(server)).toEqual([]);
});
it("stores nothing while command history is off globally", async () => {
server = await startServer({ settings: { commandHistoryEnabled: false } });
await server.request("POST", "/command-history", {
body: { hostId: 1, command: "ls" },
});
expect(history(server)).toEqual([]);
});
it("stores nothing for a host that turned command history off", async () => {
server = await startServer();
await server.mock.ctx.settings.setHost(2, "enableCommandHistory", false);
await server.request("POST", "/command-history", {
body: { hostId: 2, command: "ls" },
});
await server.request("POST", "/command-history", {
body: { hostId: 1, command: "ls" },
});
expect(history(server)).toEqual([
{ user_id: "user-1", host_id: 1, command: "ls" },
]);
});
it("rejects a request missing its fields", async () => {
server = await startServer();
const saved = await server.request("POST", "/command-history", {
body: { hostId: 1 },
});
expect(saved.status).toBe(400);
});
it("needs hosts.view for the recent list", async () => {
server = await startServer({ permissions: [] });
const denied = await server.request("GET", "/command-history/1/recent");
expect(denied.status).toBe(403);
});
it("lists recent commands with duplicates for a hosts.view holder", async () => {
server = await startServer({ permissions: ["hosts.view"] });
for (const command of ["uptime", "uptime"]) {
await server.request("POST", "/command-history", {
body: { hostId: 1, command },
});
}
const allowed = await server.request("GET", "/command-history/1/recent");
expect(allowed.body).toEqual(["uptime", "uptime"]);
});
});
describe("client settings and image storage", () => {
it("serves the terminal's settings to any signed-in user", async () => {
server = await startServer({
settings: { sessionTimeoutMinutes: 45, sessionPersistence: false },
});
const { body } = await server.request("GET", "/client-settings");
expect(body.sessionTimeoutMinutes).toBe(45);
expect(body.sessionPersistence).toBe(false);
expect(body.commandHistoryEnabled).toBe(true);
expect(body.touchInput.enabled).toBe(true);
});
it("refuses the image storage test to a non-admin", async () => {
server = await startServer({ permissions: [] });
const denied = await server.request("POST", "/image-storage/test", {
body: { instanceId: "tab-1" },
});
expect(denied.status).toBe(403);
});
it("reports an unconnected session to an admin", async () => {
server = await startServer({ permissions: ["admin.plugins.manage"] });
const result = await server.request("POST", "/image-storage/test", {
body: { instanceId: "tab-1" },
});
expect(result.status).toBe(200);
expect(result.body.connected).toBe(false);
});
});
@@ -0,0 +1,503 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import {
TerminalSessionManager,
isMessageAllowedForParticipant,
} from "../../src/backend/session-manager.js";
import type { RecordingSink } from "../../src/backend/services.js";
// A recordings.writer provider that keeps what it was given.
const appends = vi.fn<(chunk: string) => Promise<void>>();
const persists = vi.fn<RecordingSink["persist"]>();
const discards = vi.fn();
const opens = vi.fn();
const log = { info: vi.fn(), success: vi.fn(), warn: vi.fn(), error: vi.fn() };
const sessionManager = new TerminalSessionManager({
log,
getTimeoutMinutes: () => 30,
getRecordings: () => ({
open: async (meta) => {
opens(meta);
return { append: appends, persist: persists, discard: discards };
},
}),
});
// Minimal fake WebSocket - only the surface session-manager touches.
function makeFakeWs(readyState = 1 /* OPEN */) {
return {
readyState,
send: vi.fn(),
close: vi.fn(),
terminate: vi.fn(),
} as unknown as import("ws").WebSocket;
}
const WS_OPEN = 1;
const WS_CLOSED = 3;
describe("TerminalSessionManager - session logging", () => {
beforeEach(() => {
vi.clearAllMocks();
appends.mockResolvedValue(undefined);
persists.mockResolvedValue(undefined);
});
it("createSession stores sessionLoggingEnabled=true by default", () => {
const id = sessionManager.createSession("u1", 1, "host", 80, 24);
const session = sessionManager.getSession(id);
expect(session?.sessionLoggingEnabled).toBe(true);
sessionManager.destroySession(id);
});
it("createSession stores sessionLoggingEnabled=false when passed", () => {
const id = sessionManager.createSession(
"u1",
1,
"host",
80,
24,
undefined,
false,
);
const session = sessionManager.getSession(id);
expect(session?.sessionLoggingEnabled).toBe(false);
sessionManager.destroySession(id);
});
it("does not record when sessionLoggingEnabled=false", async () => {
const id = sessionManager.createSession(
"u1",
1,
"host",
80,
24,
undefined,
false,
);
sessionManager.bufferOutput(id, "some output");
sessionManager.destroySession(id);
await new Promise((r) => setTimeout(r, 20));
expect(opens).not.toHaveBeenCalled();
expect(appends).not.toHaveBeenCalled();
});
it("hands the recording to the recordings service in one batch, then persists it", async () => {
const id = sessionManager.createSession(
"u1",
1,
"host",
80,
24,
undefined,
true,
);
sessionManager.bufferOutput(id, "terminal output data");
sessionManager.bufferOutput(id, "more output");
sessionManager.bufferInput(id, "ls\r");
sessionManager.destroySession(id);
await new Promise((r) => setTimeout(r, 20));
// One write for the whole burst, never one per chunk (issue #1049).
expect(appends).toHaveBeenCalledOnce();
const chunk = appends.mock.calls[0][0];
expect(chunk.split("\n")[0]).toContain('"version":2');
expect(chunk).toContain("terminal output data");
expect(chunk).toContain("more output");
expect(persists).toHaveBeenCalledOnce();
expect(opens).toHaveBeenCalledWith(
expect.objectContaining({ hostId: 1, userId: "u1", protocol: "ssh" }),
);
});
it("does not write log file when buffer is empty", async () => {
const id = sessionManager.createSession(
"u1",
1,
"host",
80,
24,
undefined,
true,
);
sessionManager.destroySession(id);
await new Promise((r) => setTimeout(r, 20));
expect(appends).not.toHaveBeenCalled();
expect(discards).toHaveBeenCalledOnce();
});
it("records nothing when no recordings service is running", async () => {
const withoutRecordings = new TerminalSessionManager({
log,
getTimeoutMinutes: () => 30,
getRecordings: () => null,
});
const id = withoutRecordings.createSession("u1", 1, "host", 80, 24);
expect(withoutRecordings.getSession(id)?.sessionLoggingEnabled).toBe(false);
withoutRecordings.bufferOutput(id, "output");
withoutRecordings.destroySession(id);
await new Promise((r) => setTimeout(r, 20));
expect(appends).not.toHaveBeenCalled();
withoutRecordings.destroyAll();
});
it("bufferOutput trims old data when exceeding 512KB", () => {
const id = sessionManager.createSession(
"u1",
1,
"host",
80,
24,
undefined,
false,
);
const chunk = "x".repeat(300 * 1024);
sessionManager.bufferOutput(id, chunk);
sessionManager.bufferOutput(id, chunk);
const session = sessionManager.getSession(id);
expect(session!.outputBufferBytes).toBeLessThanOrEqual(512 * 1024);
sessionManager.destroySession(id);
});
});
describe("TerminalSessionManager - multiplayer participants", () => {
beforeEach(() => {
vi.clearAllMocks();
appends.mockResolvedValue(undefined);
persists.mockResolvedValue(undefined);
});
function createConnectedSession(): string {
const id = sessionManager.createSession(
"owner-1",
1,
"host",
80,
24,
undefined,
false,
);
// Mark connected without a real ssh2 stream - only isConnected is read
// by attachWs/joinAsParticipant.
const session = sessionManager.getSession(id)!;
session.isConnected = true;
return id;
}
it("joinAsParticipant adds a participant without evicting the owner", () => {
const id = createConnectedSession();
const ownerWs = makeFakeWs();
sessionManager.attachWs(id, "owner-1", ownerWs);
const guestWs = makeFakeWs();
const session = sessionManager.joinAsParticipant(id, guestWs, {
userId: null,
permissionLevel: "read-only",
guestLabel: "Guest",
});
expect(session).not.toBeNull();
expect(session!.participants.size).toBe(2);
const ownerParticipant = sessionManager.getParticipantForWs(
session!,
ownerWs,
);
expect(ownerParticipant?.isOwner).toBe(true);
// The join is announced to everyone already in the session - and that is
// the only unsolicited message the owner receives.
expect(ownerWs.send).toHaveBeenCalledTimes(1);
const announced = JSON.parse(
(ownerWs.send as ReturnType<typeof vi.fn>).mock.calls[0][0] as string,
);
expect(announced.type).toBe("participants");
expect(announced.participants).toHaveLength(2);
sessionManager.destroySession(id);
});
it("setRoomShareControl makes only the controller read-write and never touches the owner", () => {
const id = createConnectedSession();
const ownerWs = makeFakeWs();
sessionManager.attachWs(id, "owner-1", ownerWs);
const aliceWs = makeFakeWs();
const bobWs = makeFakeWs();
const session = sessionManager.joinAsParticipant(id, aliceWs, {
userId: "alice",
permissionLevel: "read-only",
shareId: "stage-share",
})!;
sessionManager.joinAsParticipant(id, bobWs, {
userId: "bob",
permissionLevel: "read-only",
shareId: "stage-share",
});
sessionManager.setRoomShareControl(id, "stage-share", "alice");
expect(
sessionManager.getParticipantForWs(session, aliceWs)?.permissionLevel,
).toBe("read-write");
expect(
sessionManager.getParticipantForWs(session, bobWs)?.permissionLevel,
).toBe("read-only");
expect(
sessionManager.getParticipantForWs(session, ownerWs)?.permissionLevel,
).toBe("read-write");
sessionManager.setRoomShareControl(id, "stage-share", null);
expect(
sessionManager.getParticipantForWs(session, aliceWs)?.permissionLevel,
).toBe("read-only");
sessionManager.destroySession(id);
});
it("disconnectShareParticipants revokes only the selected share participants", () => {
const id = createConnectedSession();
const ownerWs = makeFakeWs();
const aliceWs = makeFakeWs();
const guestWs = makeFakeWs();
sessionManager.attachWs(id, "owner-1", ownerWs);
const session = sessionManager.joinAsParticipant(id, aliceWs, {
userId: "alice",
permissionLevel: "read-only",
shareId: "stage-share",
})!;
sessionManager.joinAsParticipant(id, guestWs, {
userId: null,
permissionLevel: "read-only",
shareId: "stage-share",
});
expect(
sessionManager.disconnectShareParticipants(id, "stage-share", {
userId: "alice",
reason: "Removed",
}),
).toBe(1);
expect(sessionManager.getParticipantForWs(session, aliceWs)).toBeNull();
expect(sessionManager.getParticipantForWs(session, guestWs)).not.toBeNull();
expect(sessionManager.getParticipantForWs(session, ownerWs)?.isOwner).toBe(
true,
);
expect(aliceWs.close).toHaveBeenCalledWith(1008, "Removed");
sessionManager.destroySession(id);
});
it("joinAsParticipant returns null for a nonexistent or unconnected session", () => {
expect(
sessionManager.joinAsParticipant("does-not-exist", makeFakeWs(), {
userId: null,
permissionLevel: "read-only",
}),
).toBeNull();
});
it("broadcast sends to all OPEN participant sockets and skips CLOSED ones", () => {
const id = createConnectedSession();
const ownerWs = makeFakeWs(WS_OPEN);
sessionManager.attachWs(id, "owner-1", ownerWs);
const openGuestWs = makeFakeWs(WS_OPEN);
const closedGuestWs = makeFakeWs(WS_CLOSED);
sessionManager.joinAsParticipant(id, openGuestWs, {
userId: null,
permissionLevel: "read-only",
});
sessionManager.joinAsParticipant(id, closedGuestWs, {
userId: null,
permissionLevel: "read-only",
});
sessionManager.broadcast(id, { type: "data", data: "hello" });
expect(ownerWs.send).toHaveBeenCalledWith(
JSON.stringify({ type: "data", data: "hello" }),
);
expect(openGuestWs.send).toHaveBeenCalledWith(
JSON.stringify({ type: "data", data: "hello" }),
);
expect(closedGuestWs.send).not.toHaveBeenCalled();
sessionManager.destroySession(id);
});
it("broadcast does not throw if a socket's send throws", () => {
const id = createConnectedSession();
const throwingWs = makeFakeWs(WS_OPEN);
(throwingWs.send as ReturnType<typeof vi.fn>).mockImplementation(() => {
throw new Error("send failed");
});
sessionManager.attachWs(id, "owner-1", throwingWs);
expect(() =>
sessionManager.broadcast(id, { type: "data", data: "x" }),
).not.toThrow();
sessionManager.destroySession(id);
});
it("broadcast is a no-op for a nonexistent session", () => {
expect(() =>
sessionManager.broadcast("does-not-exist", { type: "data" }),
).not.toThrow();
});
it("owner detach arms the idle timeout (existing behavior)", () => {
vi.useFakeTimers();
try {
const id = createConnectedSession();
const ownerWs = makeFakeWs();
sessionManager.attachWs(id, "owner-1", ownerWs);
sessionManager.detachWs(id);
const session = sessionManager.getSession(id);
expect(session?.detachTimeout).not.toBeNull();
expect(session?.lastDetachedAt).not.toBeNull();
sessionManager.destroySession(id);
} finally {
vi.useRealTimers();
}
});
it("removeParticipant on a non-owner does not arm a timeout or destroy the session", () => {
const id = createConnectedSession();
const ownerWs = makeFakeWs();
sessionManager.attachWs(id, "owner-1", ownerWs);
const guestWs = makeFakeWs();
sessionManager.joinAsParticipant(id, guestWs, {
userId: null,
permissionLevel: "read-write",
});
sessionManager.removeParticipant(id, guestWs);
const session = sessionManager.getSession(id);
expect(session).not.toBeNull();
expect(session?.detachTimeout).toBeNull();
expect(session?.participants.size).toBe(1);
expect(sessionManager.getParticipantForWs(session!, guestWs)).toBeNull();
sessionManager.destroySession(id);
});
it("removeParticipant is a no-op when the ws belongs to the owner", () => {
const id = createConnectedSession();
const ownerWs = makeFakeWs();
sessionManager.attachWs(id, "owner-1", ownerWs);
sessionManager.removeParticipant(id, ownerWs);
const session = sessionManager.getSession(id);
expect(session?.participants.size).toBe(1);
expect(sessionManager.getParticipantForWs(session!, ownerWs)?.isOwner).toBe(
true,
);
sessionManager.destroySession(id);
});
it("destroySession cleans up all participants, not just the owner", () => {
const id = createConnectedSession();
const ownerWs = makeFakeWs();
sessionManager.attachWs(id, "owner-1", ownerWs);
const guestWs = makeFakeWs();
sessionManager.joinAsParticipant(id, guestWs, {
userId: null,
permissionLevel: "read-only",
});
sessionManager.destroySession(id);
expect(guestWs.send).toHaveBeenCalled();
expect(sessionManager.getSession(id)).toBeNull();
});
it("ownerEndSession notifies non-owner participants and destroys the session", () => {
const id = createConnectedSession();
const ownerWs = makeFakeWs();
sessionManager.attachWs(id, "owner-1", ownerWs);
const guestWs = makeFakeWs();
sessionManager.joinAsParticipant(id, guestWs, {
userId: null,
permissionLevel: "read-write",
});
sessionManager.ownerEndSession(id, "owner ended the session");
expect(guestWs.send).toHaveBeenCalledWith(
JSON.stringify({
type: "sessionTerminatedByOwner",
reason: "owner ended the session",
}),
);
expect(sessionManager.getSession(id)).toBeNull();
});
});
describe("isMessageAllowedForParticipant", () => {
it("allows any message type for the owner or when there is no participant", () => {
expect(isMessageAllowedForParticipant(null, "connectToHost")).toBe(true);
expect(
isMessageAllowedForParticipant(
{ isOwner: true, permissionLevel: "read-write" },
"resize",
),
).toBe(true);
});
it("drops input from a read-only participant", () => {
expect(
isMessageAllowedForParticipant(
{ isOwner: false, permissionLevel: "read-only" },
"input",
),
).toBe(false);
});
it("allows input from a read-write non-owner participant", () => {
expect(
isMessageAllowedForParticipant(
{ isOwner: false, permissionLevel: "read-write" },
"input",
),
).toBe(true);
});
it("allows ping and disconnect for any non-owner participant", () => {
expect(
isMessageAllowedForParticipant(
{ isOwner: false, permissionLevel: "read-only" },
"ping",
),
).toBe(true);
expect(
isMessageAllowedForParticipant(
{ isOwner: false, permissionLevel: "read-only" },
"disconnect",
),
).toBe(true);
});
it("blocks resize and auth/tmux message types for non-owner participants regardless of permission level", () => {
for (const type of [
"resize",
"totp_response",
"password_response",
"tmux_attach",
"tmux_detach",
"get_cwd",
"vault_start_auth",
"opkssh_start_auth",
]) {
expect(
isMessageAllowedForParticipant(
{ isOwner: false, permissionLevel: "read-write" },
type,
),
).toBe(false);
}
});
});