|
| 1 | +/** |
| 2 | + * Proves the Tenki-facing contract OpenHermit's `tenki` exec backend relies on |
| 3 | + * (apps/agent/src/core/backends/tenki.ts), with no gateway or PostgreSQL needed: |
| 4 | + * a sticky session boots, commands run via `sh -c` with env pass-through, files |
| 5 | + * round-trip (the skill-sync path), and — what OpenHermit leans on across |
| 6 | + * gateway restarts — the same session reattaches by id via `client.get()`. |
| 7 | + * Token/workspace from env (CI) or ~/.config/tenki/config.yaml (local `tenki login`). |
| 8 | + * Exits non-zero on any failure. |
| 9 | + */ |
| 10 | +import { TenkiSandbox } from "@tenkicloud/sandbox"; |
| 11 | +import { readFileSync } from "node:fs"; |
| 12 | +import { homedir } from "node:os"; |
| 13 | + |
| 14 | +const cfg = (key) => { |
| 15 | + try { |
| 16 | + const c = readFileSync(`${homedir()}/.config/tenki/config.yaml`, "utf8"); |
| 17 | + return (c.match(new RegExp(`^${key}:\\s*(.+)$`, "m"))?.[1] ?? "").trim(); |
| 18 | + } catch { |
| 19 | + return ""; |
| 20 | + } |
| 21 | +}; |
| 22 | + |
| 23 | +const authToken = process.env.TENKI_AUTH_TOKEN || process.env.TENKI_API_KEY || cfg("auth_token"); |
| 24 | +const workspaceId = process.env.TENKI_WORKSPACE_ID || cfg("current_workspace_id") || undefined; |
| 25 | +if (!authToken) { |
| 26 | + console.error("No token. Set TENKI_AUTH_TOKEN, or run `tenki login`."); |
| 27 | + process.exit(1); |
| 28 | +} |
| 29 | + |
| 30 | +const client = new TenkiSandbox({ authToken }); |
| 31 | +const text = (bytes) => new TextDecoder().decode(bytes); |
| 32 | +let session; |
| 33 | +try { |
| 34 | + // 1) boot a sticky session, as the backend's ensure() does |
| 35 | + session = await client.createAndWait({ |
| 36 | + cpuCores: 1, |
| 37 | + memoryMb: 1024, |
| 38 | + sticky: true, |
| 39 | + metadata: { agentId: "cookbook-openhermit-verify" }, |
| 40 | + workspaceId, |
| 41 | + }); |
| 42 | + |
| 43 | + // 2) exec the way TenkiExecBackend.exec() does: sh -c, cwd agent_home, env passed through |
| 44 | + const r1 = await session.run(["sh", "-c", 'echo "$GREETING $(python3 -c "print(6*7)")"'], { |
| 45 | + cwd: "/home/tenki", |
| 46 | + env: { GREETING: "hermit" }, |
| 47 | + }); |
| 48 | + if (!(r1.exitCode === 0 && text(r1.stdout).trim() === "hermit 42")) { |
| 49 | + throw new Error(`exec: exit ${r1.exitCode}, stdout ${JSON.stringify(text(r1.stdout))}, stderr ${JSON.stringify(text(r1.stderr))}`); |
| 50 | + } |
| 51 | + |
| 52 | + // 3) the skill-sync path: mkdir + writeFile, then read back |
| 53 | + await session.mkdir("/home/tenki/skills"); |
| 54 | + await session.writeFile("/home/tenki/skills/demo.md", "# demo skill\n"); |
| 55 | + if (text(await session.readFile("/home/tenki/skills/demo.md")) !== "# demo skill\n") { |
| 56 | + throw new Error("skill file round-trip mismatch"); |
| 57 | + } |
| 58 | + |
| 59 | + // 4) reattach by id — what the gateway does after a restart (resume if paused) |
| 60 | + const again = await client.get(session.id); |
| 61 | + if (again.state === "PAUSED") await again.resume(); |
| 62 | + const r2 = await again.run(["sh", "-c", "cat skills/demo.md"], { cwd: "/home/tenki" }); |
| 63 | + if (!text(r2.stdout).includes("demo skill")) throw new Error("reattached session lost workspace state"); |
| 64 | + |
| 65 | + console.log("✓ openhermit-tenki: sticky session → sh -c exec with env (hermit 42) → skill file sync → reattach by id → dispose"); |
| 66 | +} catch (e) { |
| 67 | + console.error("✗ " + (e?.message ?? e)); |
| 68 | + process.exitCode = 1; |
| 69 | +} finally { |
| 70 | + if (session) { |
| 71 | + try { |
| 72 | + await session.closeIfOpen(); |
| 73 | + } catch { |
| 74 | + /* self-reaps via idle/lifetime caps */ |
| 75 | + } |
| 76 | + } |
| 77 | +} |
0 commit comments