daemon: agent-driven workspace images — repo→image registry (GET/PUT/DELETE /api/repos), spawn resolves custom image, self-healing lvmh-ops control container with docker.sock; 132 tests, e2e 87/87

This commit is contained in:
Raphael Westphal
2026-08-18 23:54:09 +02:00
parent 8fcaeb7c03
commit 7287b831e4
15 changed files with 1216 additions and 38 deletions
+2
View File
@@ -37,6 +37,7 @@ import { run as promptRouting } from "./scenarios/prompt-routing.mjs";
import { run as sessionList } from "./scenarios/session-list.mjs";
import { run as gitlab } from "./scenarios/gitlab.mjs";
import { run as spawnValidation } from "./scenarios/spawn-validation.mjs";
import { run as repoImages } from "./scenarios/repo-images.mjs";
import { run as pluginContract } from "./scenarios/plugin-contract.mjs";
import { run as resilience } from "./scenarios/resilience.mjs";
@@ -49,6 +50,7 @@ const SCENARIOS = [
["session-list", sessionList],
["gitlab", gitlab],
["spawn-validation", spawnValidation],
["repo-images", repoImages],
["plugin-contract", pluginContract],
["resilience", resilience], // last: SIGKILLs and reboots the daemon
];
+125
View File
@@ -0,0 +1,125 @@
// scenarios/repo-images.mjs — repo→custom worker image registry (REST CRUD).
// Spawn itself is NOT exercised here: it needs Docker, which the harness must
// not require (see spawn-validation.mjs).
import { rest } from "../lib.mjs";
export async function run(ctx) {
const { r, base, token } = ctx;
const unauth = await rest(base, null, "/api/repos");
r.check(
"GET /api/repos without token → 401",
unauth.status === 401,
`got ${unauth.status}`,
);
const empty = await rest(base, token, "/api/repos");
r.check(
"GET /api/repos on fresh daemon → 200 []",
empty.status === 200 &&
Array.isArray(empty.json) &&
empty.json.length === 0,
`got ${empty.status} ${empty.text}`,
);
const put = await rest(base, token, "/api/repos/lvmh/e2e/image", {
method: "PUT",
body: { image: "lvmh-worker-lvmh--e2e-ab12cd" },
});
r.check(
"PUT valid repo/image → 200 {ok:true}",
put.status === 200 && put.json?.ok === true,
`got ${put.status} ${put.text}`,
);
const putTagged = await rest(base, token, "/api/repos/lvmh/e2e2/image", {
method: "PUT",
body: { image: "lvmh-worker-lvmh--e2e2-ab12cd:1.0" },
});
r.check(
"PUT tagged image → 200",
putTagged.status === 200,
`got ${putTagged.status}`,
);
const list = await rest(base, token, "/api/repos");
const registrations = Array.isArray(list.json)
? list.json.filter((x) => x.repo === "lvmh/e2e" || x.repo === "lvmh/e2e2")
: [];
r.check(
"GET /api/repos lists both registrations",
list.status === 200 &&
registrations.length === 2 &&
registrations[0].repo === "lvmh/e2e" &&
registrations[0].image === "lvmh-worker-lvmh--e2e-ab12cd" &&
registrations[1].repo === "lvmh/e2e2" &&
registrations[1].image === "lvmh-worker-lvmh--e2e2-ab12cd:1.0",
`got ${list.status} ${list.text}`,
);
for (const image of ["evil-image", "", "lvmh-worker-", "LVMH-worker-x"]) {
const bad = await rest(base, token, "/api/repos/lvmh/e2e/image", {
method: "PUT",
body: { image },
});
r.check(
`PUT invalid image ${JSON.stringify(image)} → 400`,
bad.status === 400 && typeof bad.json?.error === "string",
`got ${bad.status} ${bad.text}`,
);
}
const badRepo = await rest(base, token, "/api/repos/noslash/image", {
method: "PUT",
body: { image: "lvmh-worker-x" },
});
r.check(
"PUT invalid repo → 400",
badRepo.status === 400,
`got ${badRepo.status}`,
);
const malformed = await fetch(`${base}/api/repos/lvmh/e2e/image`, {
method: "PUT",
headers: {
Authorization: `Bearer ${token}`,
"Content-Type": "application/json",
},
body: "{not json",
});
r.check(
"PUT malformed JSON → 400",
malformed.status === 400,
`got ${malformed.status}`,
);
await malformed.text();
const del = await rest(base, token, "/api/repos/lvmh/e2e/image", {
method: "DELETE",
});
r.check(
"DELETE image → 200 {ok:true}",
del.status === 200 && del.json?.ok === true,
`got ${del.status}`,
);
const delAgain = await rest(base, token, "/api/repos/lvmh/e2e/image", {
method: "DELETE",
});
r.check(
"DELETE absent image → 200 (idempotent)",
delAgain.status === 200,
`got ${delAgain.status}`,
);
const after = await rest(base, token, "/api/repos");
const remaining = Array.isArray(after.json)
? after.json.filter((x) => x.repo === "lvmh/e2e")
: null;
r.check(
"GET after delete no longer lists lvmh/e2e",
after.status === 200 && remaining?.length === 0,
`got ${after.status} ${after.text}`,
);
}