Files
kandev/apps/web/e2e/tests/ssh/launch-task.spec.ts
2026-09-16 10:38:31 +01:00

431 lines
15 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { test, expect } from "../../fixtures/ssh-test-base";
import {
execInContainer,
listRemoteDir,
readRemoteFile,
remotePathExists,
} from "../../helpers/ssh";
import { waitForLatestSessionDone } from "../../helpers/session";
/**
* Full end-to-end task launch on the real sshd container. The smoke test
* for the SSH executor: upload agentctl, mkdir the per-task dir, clone the
* repo, launch the per-session agentctl, port-forward, run the agent to
* completion, observe the on-remote filesystem layout.
*
* Covers e2e-plan.md group H (H1–H8).
*/
test.describe("ssh executor — task launch", () => {
test("launches a session and records ssh runtime on the task environment", async ({
apiClient,
seedData,
}) => {
test.setTimeout(180_000);
const task = await apiClient.createTaskWithAgent(
seedData.workspaceId,
"H1 launch",
seedData.agentProfileId,
{
description: "/e2e:simple-message",
workflow_id: seedData.workflowId,
workflow_step_id: seedData.startStepId,
repository_ids: [seedData.repositoryId],
executor_profile_id: seedData.sshExecutorProfileId,
},
);
await waitForLatestSessionDone(apiClient, task.id, 1, "Wait for first SSH session");
const env = await apiClient.getTaskEnvironment(task.id);
expect(env).not.toBeNull();
expect(env!.executor_type).toBe("ssh");
const row = (await apiClient.listSSHSessions(seedData.sshExecutorId)).find(
(session) => session.task_id === task.id,
);
expect(row?.remote_task_dir).toBeTruthy();
const workspace = row!.remote_task_dir!;
expect(
execInContainer(seedData.sshTarget, [
"git",
"-c",
"safe.directory=*",
"-C",
workspace,
"rev-parse",
"--show-toplevel",
]).trim(),
).toBe(workspace);
expect(
execInContainer(seedData.sshTarget, [
"git",
"-c",
"safe.directory=*",
"-C",
workspace,
"branch",
"--show-current",
]).trim(),
).not.toBe("main");
expect(readRemoteFile(seedData.sshTarget, `${workspace}/remote-source.txt`)).toBe(
"e2e-ssh fixture\n",
);
});
test("runs custom prepare and terminal cleanup hooks on the remote workspace", async ({
apiClient,
seedData,
}) => {
test.setTimeout(180_000);
const profile = await apiClient.createExecutorProfile(seedData.sshExecutorId, {
name: "E2E SSH hooks",
config: {},
prepare_script: `#!/bin/bash
set -euo pipefail
workspace={{workspace.path}}
repository_url={{repository.clone_url}}
repository_branch={{repository.branch}}
git init -q "$workspace"
git -C "$workspace" remote add origin "$repository_url"
git -C "$workspace" fetch --no-tags origin "+refs/heads/$repository_branch:refs/remotes/origin/$repository_branch"
git -C "$workspace" checkout -b "$repository_branch" "origin/$repository_branch"
printf 'prepared\\n' > "$workspace/custom-prepare-marker"
`,
cleanup_script: "printf 'cleaned\\n' > {{workspace.path}}/custom-cleanup-marker",
env_vars: [],
});
try {
const task = await apiClient.createTaskWithAgent(
seedData.workspaceId,
"SSH custom hooks",
seedData.agentProfileId,
{
description: 'e2e:delay(30000)\ne2e:message("still running")',
workflow_id: seedData.workflowId,
workflow_step_id: seedData.startStepId,
repository_ids: [seedData.repositoryId],
executor_profile_id: profile.id,
},
);
let workspace = "";
await expect
.poll(
async () => {
const row = (await apiClient.listSSHSessions(seedData.sshExecutorId)).find(
(session) => session.task_id === task.id,
);
workspace = row?.remote_task_dir ?? "";
return (
workspace !== "" &&
(row?.remote_agentctl_port ?? 0) > 0 &&
(await apiClient.getTask(task.id)).state === "IN_PROGRESS" &&
readRemoteFile(seedData.sshTarget, `${workspace}/custom-prepare-marker`) ===
"prepared\n"
);
},
{
timeout: 60_000,
message: "Wait for custom SSH prepare hook",
},
)
.toBe(true);
expect(remotePathExists(seedData.sshTarget, `${workspace}/custom-cleanup-marker`)).toBe(
false,
);
await apiClient.archiveTask(task.id);
await expect
.poll(() => remotePathExists(seedData.sshTarget, `${workspace}/custom-cleanup-marker`), {
timeout: 30_000,
message: "Wait for terminal SSH cleanup hook",
})
.toBe(true);
expect(remotePathExists(seedData.sshTarget, workspace)).toBe(true);
} finally {
await apiClient.deleteExecutorProfile(profile.id).catch(() => undefined);
}
});
test("agentctl is uploaded on first launch and sha256 sidecar lands", async ({
apiClient,
seedData,
}) => {
test.setTimeout(180_000);
const task = await apiClient.createTaskWithAgent(
seedData.workspaceId,
"H2 upload agentctl",
seedData.agentProfileId,
{
description: "/e2e:simple-message",
workflow_id: seedData.workflowId,
workflow_step_id: seedData.startStepId,
repository_ids: [seedData.repositoryId],
executor_profile_id: seedData.sshExecutorProfileId,
},
);
await waitForLatestSessionDone(apiClient, task.id, 1, "Wait for upload");
expect(remotePathExists(seedData.sshTarget, "/home/kandev/.kandev/bin/agentctl")).toBe(true);
expect(remotePathExists(seedData.sshTarget, "/home/kandev/.kandev/bin/agentctl.sha256")).toBe(
true,
);
const sha = readRemoteFile(seedData.sshTarget, "/home/kandev/.kandev/bin/agentctl.sha256");
expect(sha.trim()).toMatch(/^[0-9a-f]{64}$/);
});
test("second launch on the same host skips the upload (sha matches)", async ({
apiClient,
seedData,
}) => {
test.setTimeout(240_000);
// First launch primes the cache.
const first = await apiClient.createTaskWithAgent(
seedData.workspaceId,
"H3 first launch",
seedData.agentProfileId,
{
description: "/e2e:simple-message",
workflow_id: seedData.workflowId,
workflow_step_id: seedData.startStepId,
repository_ids: [seedData.repositoryId],
executor_profile_id: seedData.sshExecutorProfileId,
},
);
await waitForLatestSessionDone(apiClient, first.id, 1, "First launch");
// Inspect mtime BEFORE the second launch.
const beforeMtime = readRemoteFile(
seedData.sshTarget,
"/home/kandev/.kandev/bin/agentctl.sha256",
);
const second = await apiClient.createTaskWithAgent(
seedData.workspaceId,
"H3 second launch",
seedData.agentProfileId,
{
description: "/e2e:simple-message",
workflow_id: seedData.workflowId,
workflow_step_id: seedData.startStepId,
repository_ids: [seedData.repositoryId],
executor_profile_id: seedData.sshExecutorProfileId,
},
);
await waitForLatestSessionDone(apiClient, second.id, 1, "Second launch");
// The sha256 file should be byte-identical (same hash, content reused).
const afterMtime = readRemoteFile(
seedData.sshTarget,
"/home/kandev/.kandev/bin/agentctl.sha256",
);
expect(afterMtime).toBe(beforeMtime);
});
test("per-task workdir lives at <workdir>/tasks/<task-dir>/", async ({ apiClient, seedData }) => {
test.setTimeout(180_000);
const task = await apiClient.createTaskWithAgent(
seedData.workspaceId,
"H4 task workdir",
seedData.agentProfileId,
{
description: "/e2e:simple-message",
workflow_id: seedData.workflowId,
workflow_step_id: seedData.startStepId,
repository_ids: [seedData.repositoryId],
executor_profile_id: seedData.sshExecutorProfileId,
},
);
await waitForLatestSessionDone(apiClient, task.id, 1, "Wait for workdir");
const sessions = await apiClient.listSSHSessions(seedData.sshExecutorId);
const row = sessions.find((s) => s.task_id === task.id);
expect(row?.remote_task_dir).toMatch(/\/tasks\/[^/]+$/);
expect(remotePathExists(seedData.sshTarget, row!.remote_task_dir!)).toBe(true);
});
test("per-session runtime dir holds the agentctl pid and port files", async ({
apiClient,
seedData,
}) => {
test.setTimeout(180_000);
const task = await apiClient.createTaskWithAgent(
seedData.workspaceId,
"H5 session runtime",
seedData.agentProfileId,
{
description: "/e2e:simple-message",
workflow_id: seedData.workflowId,
workflow_step_id: seedData.startStepId,
repository_ids: [seedData.repositoryId],
executor_profile_id: seedData.sshExecutorProfileId,
},
);
await waitForLatestSessionDone(apiClient, task.id, 1, "Wait for session runtime");
const sessions = await apiClient.listSSHSessions(seedData.sshExecutorId);
const row = sessions.find((s) => s.task_id === task.id);
expect(row).toBeDefined();
const sessionDir = `${row!.remote_task_dir}/.kandev/sessions/${row!.session_id}`;
expect(remotePathExists(seedData.sshTarget, sessionDir)).toBe(true);
const entries = listRemoteDir(seedData.sshTarget, sessionDir);
// The wrapper writes the pid file and the log; the port travels via the
// AGENTCTL_PORT env var and the ExecutorRunning metadata, not a file.
expect(entries).toEqual(expect.arrayContaining(["agentctl.pid", "agentctl.log"]));
expect(row!.remote_agentctl_port).toBeGreaterThan(0);
});
test("uploads path-mode attachments into the remote executor workspace", async ({
apiClient,
seedData,
}) => {
test.setTimeout(180_000);
const content = "remote attachment bytes";
const task = await apiClient.createTaskWithAgent(
seedData.workspaceId,
"H6 attachment upload",
seedData.agentProfileId,
{
description: "Inspect the attached file.",
workflow_id: seedData.workflowId,
workflow_step_id: seedData.startStepId,
repository_ids: [seedData.repositoryId],
executor_profile_id: seedData.sshExecutorProfileId,
attachments: [
{
type: "resource",
data: Buffer.from(content, "utf8").toString("base64"),
mime_type: "text/plain",
name: "remote-note.txt",
delivery_mode: "path",
},
],
},
);
await waitForLatestSessionDone(apiClient, task.id, 1, "Wait for attachment upload");
await expect
.poll(
async () => {
const sessions = await apiClient.listSSHSessions(seedData.sshExecutorId);
return sessions.find((s) => s.task_id === task.id)?.remote_task_dir ?? "";
},
{ timeout: 60_000, message: "Wait for SSH session row" },
)
.not.toBe("");
const sessions = await apiClient.listSSHSessions(seedData.sshExecutorId);
const row = sessions.find((s) => s.task_id === task.id);
expect(row?.remote_task_dir).toBeTruthy();
const savedPath = execInContainer(seedData.sshTarget, [
"sh",
"-c",
`find ${JSON.stringify(row!.remote_task_dir)}/.kandev/attachments -name remote-note.txt -type f -print -quit`,
]).trim();
expect(savedPath).toContain("/.kandev/attachments/");
expect(readRemoteFile(seedData.sshTarget, savedPath)).toBe(content);
});
test("uploads path-mode attachments queued while the remote task is running", async ({
apiClient,
seedData,
}) => {
test.setTimeout(180_000);
const content = "remote follow-up attachment bytes";
const task = await apiClient.createTaskWithAgent(
seedData.workspaceId,
"H6 running attachment upload",
seedData.agentProfileId,
{
description: "/slow 20s",
workflow_id: seedData.workflowId,
workflow_step_id: seedData.startStepId,
repository_ids: [seedData.repositoryId],
executor_profile_id: seedData.sshExecutorProfileId,
},
);
if (!task.session_id) throw new Error("createTaskWithAgent did not return a session_id");
const queueIdentity = await apiClient.getQueueSessionIdentity(task.id, task.session_id);
await apiClient.queueMessage(queueIdentity, "Inspect the follow-up file.", [
{
type: "resource",
data: Buffer.from(content, "utf8").toString("base64"),
mime_type: "text/plain",
name: "remote-followup.txt",
delivery_mode: "path",
},
]);
await expect
.poll(
async () => {
const sessions = await apiClient.listSSHSessions(seedData.sshExecutorId);
return sessions.find((s) => s.task_id === task.id)?.remote_task_dir ?? "";
},
{ timeout: 60_000, message: "Wait for SSH session row" },
)
.not.toBe("");
const sessions = await apiClient.listSSHSessions(seedData.sshExecutorId);
const row = sessions.find((s) => s.task_id === task.id);
const taskDir = row?.remote_task_dir;
expect(taskDir).toBeTruthy();
const findAttachment = () =>
execInContainer(seedData.sshTarget, [
"sh",
"-c",
`find ${JSON.stringify(taskDir)}/.kandev/attachments -name remote-followup.txt -type f -print -quit 2>/dev/null || true`,
]).trim();
await expect
.poll(findAttachment, {
timeout: 120_000,
message: "Wait for queued attachment to upload remotely",
})
.not.toBe("");
const attachmentPath = findAttachment();
expect(attachmentPath).toContain("/.kandev/attachments/");
expect(readRemoteFile(seedData.sshTarget, attachmentPath)).toBe(content);
});
test("stopping the session cleans up the session runtime dir but leaves the task dir", async ({
apiClient,
seedData,
backend,
}) => {
test.setTimeout(180_000);
await backend.ensureReady();
const task = await apiClient.createTaskWithAgent(
seedData.workspaceId,
"H7 stop cleanup",
seedData.agentProfileId,
{
description: "/e2e:simple-message",
workflow_id: seedData.workflowId,
workflow_step_id: seedData.startStepId,
repository_ids: [seedData.repositoryId],
executor_profile_id: seedData.sshExecutorProfileId,
},
);
await waitForLatestSessionDone(apiClient, task.id, 1, "Wait before stop");
const sessions = await apiClient.listSSHSessions(seedData.sshExecutorId);
const row = sessions.find((s) => s.task_id === task.id);
expect(row).toBeDefined();
const sessionDir = `${row!.remote_task_dir}/.kandev/sessions/${row!.session_id}`;
// Archive (or otherwise end) the task to trigger StopInstance.
await apiClient.archiveTask(task.id);
await expect
.poll(() => remotePathExists(seedData.sshTarget, sessionDir), { timeout: 30_000 })
.toBe(false);
// Task dir intact — v1 spec, no auto-clean on last session stop.
expect(remotePathExists(seedData.sshTarget, row!.remote_task_dir!)).toBe(true);
});
});