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,83 @@
/**
* ctx.audit.record: a plugin writes an audit line under its own action name,
* and the user it names is always the runtime's actor, never one the plugin
* picked.
*/
import { beforeEach, describe, expect, it, vi } from "vitest";
const auditEntries: Array<Record<string, unknown>> = [];
vi.mock("../../database/repositories/factory.js", () => ({
createCurrentPluginPermissionGrantRepository: () => ({
listByPlugin: async () => [],
}),
}));
vi.mock("../../utils/audit-logger.js", () => ({
logAudit: async (entry: Record<string, unknown>) => {
auditEntries.push(entry);
},
}));
import { createPluginContext, createPluginHandle } from "../../plugins/ctx.js";
import { runAsActor } from "../../plugins/actor.js";
import type { PluginManifest } from "@termix/plugin-sdk/manifest";
function contextFor(pluginId: string) {
const manifest = {
id: pluginId,
name: "Audit Fixture",
version: "1.0.0",
description: "",
author: { name: "test" },
license: "MIT",
category: "Productivity",
engine: { termix: ">=2.9.0", api: "1" },
capabilities: [],
} as PluginManifest;
const handle = createPluginHandle(pluginId, { activate: () => {} });
return createPluginContext(manifest, handle);
}
beforeEach(() => {
auditEntries.length = 0;
});
describe("ctx.audit.record", () => {
it("writes the plugin's action for the acting user", async () => {
const ctx = contextFor("audit-fixture");
await runAsActor("user-3", "request", () =>
ctx.audit.record({
action: "ssh_connect",
resourceType: "host",
resourceId: "12",
resourceName: "root@10.0.0.1:22",
success: true,
}),
);
expect(auditEntries).toEqual([
expect.objectContaining({
userId: "user-3",
action: "ssh_connect",
resourceType: "host",
resourceId: "12",
resourceName: "root@10.0.0.1:22",
details: "via plugin audit-fixture",
success: true,
}),
]);
});
it("falls back to the plugin as the resource and system as the user", async () => {
const ctx = contextFor("audit-fixture");
await ctx.audit.record({ action: "cleanup", success: false });
expect(auditEntries[0]).toMatchObject({
userId: "system",
resourceType: "plugin",
resourceId: "audit-fixture",
resourceName: "Audit Fixture",
success: false,
});
});
});
@@ -18,6 +18,9 @@ const h = vi.hoisted(() => ({
updates: [] as Array<Record<string, unknown>>,
updateResult: null as Record<string, unknown> | null,
users: [] as Array<{ id: string; username: string }>,
activities: [] as Array<Record<string, unknown>>,
sessions: [] as number[],
released: [] as number[],
roles: [] as Array<{
id: number;
name: string;
@@ -90,6 +93,23 @@ vi.mock("../../database/repositories/factory.js", () => ({
listDecryptedByUserId: async () => h.ownedHosts,
}),
}));
vi.mock("../../services/recent-activity.js", () => ({
recordRecentActivity: async (
userId: string,
entry: Record<string, unknown>,
) => {
h.activities.push({ userId, ...entry });
return { status: "logged", id: 1 };
},
}));
vi.mock("../../hosts/host-session-status.js", () => ({
hostSessionStatus: {
register: (hostId: number) => {
h.sessions.push(hostId);
return () => h.released.push(hostId);
},
},
}));
vi.mock("../../utils/shared-host-secrets-manager.js", () => ({
SharedHostSecretsManager: {
getInstance: () => ({
@@ -347,3 +367,40 @@ describe("ctx.hosts", () => {
expect(await hosts.listOwned()).toMatchObject([{ id: 1, name: "own" }]);
});
});
describe("ctx.hosts session tracking and activity", () => {
it("counts a live session through core and releases it", () => {
h.sessions = [];
h.released = [];
const hosts = createPluginHosts({
manifest: manifest(["hosts:read"]),
audit: vi.fn(async () => {}),
});
const release = hosts.trackSession(4);
expect(h.sessions).toEqual([4]);
release();
expect(h.released).toEqual([4]);
});
it("refuses to track a session without hosts:read declared", () => {
const hosts = createPluginHosts({
manifest: manifest([]),
audit: vi.fn(async () => {}),
});
expect(() => hosts.trackSession(4)).toThrow(PluginCapabilityError);
});
it("records activity for the acting user only", async () => {
h.activities = [];
h.granted = new Set(["hosts:read"]);
h.actor = "user-7";
const hosts = createPluginHosts({
manifest: manifest(["hosts:read"]),
audit: vi.fn(async () => {}),
});
await hosts.recordActivity(4, "terminal", "web-01");
expect(h.activities).toEqual([
{ userId: "user-7", type: "terminal", hostId: 4, hostName: "web-01" },
]);
});
});
@@ -0,0 +1,263 @@
/**
* The ctx.ssh members an interactive transport (the terminal) needs: host
* resolution with secrets, prepare with its provider hooks bound, and the
* browser sign-in start and cancel that used to be reached by import.
*/
import { beforeEach, describe, expect, it, vi } from "vitest";
const h = vi.hoisted(() => ({
granted: new Set<string>(),
actor: "user-1" as string | undefined,
resolved: [] as Array<{ by: string; ref: unknown; userId: string }>,
built: [] as Array<Record<string, unknown>>,
}));
vi.mock("../../utils/logger.js", () => {
const log = { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() };
return { pluginLogger: log, sshLogger: log, logger: log, authLogger: log };
});
vi.mock("../../plugins/permissions.js", async () => {
const { PluginCapabilityError } = await import("@termix/plugin-sdk/backend");
return {
assertCapability: async (
pluginId: string,
capability: string,
declared: readonly string[],
) => {
if (!declared.includes(capability) || !h.granted.has(capability)) {
throw new PluginCapabilityError(pluginId, capability);
}
},
};
});
vi.mock("../../plugins/actor.js", () => ({ getActor: () => h.actor }));
vi.mock("../../hosts/connect/core-providers.js", () => ({
ensureCoreSshAuthProviders: () => {},
}));
vi.mock("../../hosts/host-resolver.js", () => ({
resolveHostById: async (id: number, userId: string) => {
h.resolved.push({ by: "id", ref: id, userId });
return id === 404 ? null : { id, ip: "10.0.0.1", password: "pw" };
},
resolveHostBySyncId: async (syncId: string, userId: string) => {
h.resolved.push({ by: "syncId", ref: syncId, userId });
return { id: 9, ip: "10.0.0.9" };
},
}));
vi.mock("../../hosts/connect/build-connect-config.js", () => ({
buildConnectConfig: async (
_host: unknown,
options: Record<string, unknown>,
) => {
h.built.push(options);
return {
config: { host: "10.0.0.1" },
outcome: { status: "ready" },
env: { interactive: options.interactive },
provider: {
type: "fixture-auth",
onBanner: (banner: string, _host: unknown, env: unknown) => ({
action: "hold",
timeoutMs: 1,
message: banner,
details: { env },
}),
onAuthFailed: () => ({ status: "retry", patch: {}, message: "again" }),
},
};
},
}));
vi.mock("../../database/repositories/factory.js", () => ({
createCurrentHostResolutionRepository: () => ({
findHostById: async (hostId: number) =>
hostId === 1
? {
id: 1,
name: "box",
ip: "10.0.0.1",
username: "root",
authType: "fixture-auth",
}
: null,
}),
}));
const { createPluginSsh } = await import("../../plugins/ctx-ssh-auth.js");
const { DisposableBag } = await import("../../plugins/disposables.js");
const { registerSshAuthProvider, resetSshAuthRegistryForTests } =
await import("../../hosts/connect/auth-provider-registry.js");
const { PluginCapabilityError, PluginSshInteractionError } =
await import("@termix/plugin-sdk/backend");
const ALL = ["ssh:connect", "credentials:use"];
function sshWith(capabilities = ALL) {
const audit = vi.fn(async () => {});
const ssh = createPluginSsh({
manifest: {
id: "fixture",
name: "Fixture",
version: "1.0.0",
capabilities,
} as never,
bag: new DisposableBag("fixture"),
audit,
});
return { ssh, audit };
}
beforeEach(() => {
h.granted = new Set(ALL);
h.actor = "user-1";
h.resolved = [];
h.built = [];
resetSshAuthRegistryForTests();
});
describe("ctx.ssh.resolveHost", () => {
it("resolves by sync id first, as the acting user, and audits", async () => {
const { ssh, audit } = sshWith();
expect(await ssh.resolveHost(3, { syncId: "abc" })).toMatchObject({
id: 9,
});
expect(await ssh.resolveHost(3)).toMatchObject({ id: 3, password: "pw" });
expect(h.resolved).toEqual([
{ by: "syncId", ref: "abc", userId: "user-1" },
{ by: "id", ref: 3, userId: "user-1" },
]);
expect(audit).toHaveBeenCalledWith("ssh_resolve_host", "host 3", {
success: true,
});
});
it("answers null for a host this user cannot reach", async () => {
const { ssh } = sshWith();
expect(await ssh.resolveHost(404)).toBeNull();
});
it("refuses without credentials:use", async () => {
h.granted = new Set(["ssh:connect"]);
const { ssh } = sshWith(["ssh:connect"]);
await expect(ssh.resolveHost(3)).rejects.toBeInstanceOf(
PluginCapabilityError,
);
expect(h.resolved).toEqual([]);
});
});
describe("ctx.ssh.prepare", () => {
it("passes the terminal purpose, interactivity and host key socket through", async () => {
const { ssh } = sshWith();
const socket = { send: vi.fn() };
await ssh.prepare(
{ id: 1, ip: "10.0.0.1", port: 22, username: "root" },
{
purpose: "terminal",
client: {},
interactive: true,
hostKeySocket: socket,
},
);
expect(h.built[0]).toMatchObject({
purpose: "terminal",
interactive: true,
hostKeySocket: socket,
userId: "user-1",
});
});
it("hands back the provider's hooks bound to this host", async () => {
const { ssh } = sshWith();
const prepared = await ssh.prepare(
{ id: 1, ip: "10.0.0.1", port: 22, username: "root" },
{ client: {}, interactive: true },
);
expect(prepared.authType).toBe("fixture-auth");
expect(prepared.onBanner?.("waiting")).toMatchObject({
action: "hold",
message: "waiting",
details: { env: { interactive: true } },
});
expect(
prepared.onAuthFailed?.({
error: new Error("x"),
retries: 0,
canRetry: true,
methodNotAvailable: false,
}),
).toMatchObject({ status: "retry" });
});
});
describe("ctx.ssh browser sign-in", () => {
it("starts the interaction on the host's own provider", async () => {
const started = vi.fn(async () => {});
registerSshAuthProvider({
type: "fixture-auth",
pluginId: "core",
labelKey: "k",
interaction: "fixture",
startInteraction: started,
prepare: async () => ({ status: "ready" }),
});
const { ssh } = sshWith();
await ssh.startInteraction("fixture", {
hostId: 1,
socket: "ws",
requestOrigin: "https://termix.example",
});
expect(started).toHaveBeenCalledWith(
expect.objectContaining({
userId: "user-1",
hostId: 1,
socket: "ws",
requestOrigin: "https://termix.example",
host: { name: "box", ip: "10.0.0.1", username: "root" },
}),
);
});
it("says why when nothing can start it", async () => {
const { ssh } = sshWith();
await expect(
ssh.startInteraction("fixture", {
hostId: 1,
socket: null,
requestOrigin: "",
}),
).rejects.toBeInstanceOf(PluginSshInteractionError);
await expect(
ssh.startInteraction("fixture", {
hostId: 2,
socket: null,
requestOrigin: "",
}),
).rejects.toThrow("Host not found");
});
it("cancels on every provider that owns the interaction", async () => {
const first = vi.fn();
const second = vi.fn();
const other = vi.fn();
for (const [type, interaction, cancel] of [
["a", "fixture", first],
["b", "fixture", second],
["c", "elsewhere", other],
] as const) {
registerSshAuthProvider({
type,
pluginId: "core",
labelKey: "k",
interaction,
cancelInteraction: cancel,
prepare: async () => ({ status: "ready" }),
});
}
const { ssh } = sshWith();
await ssh.cancelInteraction("fixture", { requestId: "r1" });
expect(first).toHaveBeenCalledWith({ userId: "user-1", requestId: "r1" });
expect(second).toHaveBeenCalledOnce();
expect(other).not.toHaveBeenCalled();
});
});
@@ -21,6 +21,7 @@ const {
clearServiceRegistry,
createServiceHandle,
getRegistration,
getServiceImplementation,
listServices,
provideService,
resolveRequirements,
@@ -85,6 +86,19 @@ describe("service registry", () => {
expect(revokeService("nothing.here")).toBe(false);
});
it("hands core the implementation of a compatible version, or nothing", () => {
expect(
getServiceImplementation("testplugin.greet", "^1.0.0"),
).toBeUndefined();
const registration = register({ version: "1.4.0" });
expect(getServiceImplementation("testplugin.greet", "^1.0.0")).toBe(
registration.implementation,
);
expect(
getServiceImplementation("testplugin.greet", "^2.0.0"),
).toBeUndefined();
});
describe("resolution", () => {
it("is satisfied when the version is in range", () => {
register({ version: "1.2.0" });
+55
View File
@@ -37,6 +37,13 @@ vi.mock("../../utils/auth-manager.js", () => ({
},
}));
vi.mock("../../utils/data-crypto.js", () => ({
DataCrypto: {
getUserDataKey: (userId: string) =>
userId === state.userId ? Buffer.from("key") : null,
},
}));
vi.mock("../../plugins/permissions.js", () => ({
hasCapability: async (
_pluginId: string,
@@ -190,6 +197,54 @@ describe("ctx.ws authentication", () => {
});
});
describe("ctx.ws optional auth", () => {
function register(seen: Array<Record<string, unknown>>) {
ws.registerPluginWsRoute(
"sample-plugin",
"/mixed",
(connection) => {
seen.push({
userId: connection.userId,
unlocked: connection.isDataUnlocked(),
clientIp: connection.clientIp,
requestOrigin: connection.requestOrigin,
});
(connection.socket as WebSocket).send("hi");
},
DECLARED,
{ public: true, optionalAuth: true },
);
}
it("names the user when a valid token came with the upgrade", async () => {
const seen: Array<Record<string, unknown>> = [];
register(seen);
const base = await startServer();
const result = await connect(
`${base}/plugin-ws/sample-plugin/mixed`,
authProtocols(state.validToken),
);
expect(result.outcome).toBe("open");
expect(seen[0]).toMatchObject({ userId: state.userId, unlocked: true });
expect(typeof seen[0].clientIp).toBe("string");
expect(String(seen[0].requestOrigin)).toMatch(/^http/);
if (result.outcome === "open") result.socket.close();
});
it("still serves a guest, with no user and nothing unlocked", async () => {
const seen: Array<Record<string, unknown>> = [];
register(seen);
const base = await startServer();
const result = await connect(
`${base}/plugin-ws/sample-plugin/mixed`,
authProtocols("nope"),
);
expect(result.outcome).toBe("open");
expect(seen[0]).toMatchObject({ userId: "", unlocked: false });
if (result.outcome === "open") result.socket.close();
});
});
describe("ctx.ws capability", () => {
it("refuses the upgrade when network:serve is not granted", async () => {
ws.registerPluginWsRoute("sample-plugin", "/socket", () => {}, DECLARED);