Files
omarchy/plugins/io.github.elevate08.qs-bitwarden-cli/tests/ssh-agent-control.test.js
T
asepharyana 1cdb82a76f Sync config from arch
- hypr/apps.lua
- hypr/autostart.lua
- hypr/envs.lua
- hypr/hyprland.lua
- hypr/hyprsunset.conf
- hypr/input.lua
- hypr/looknfeel.lua
- hypr/omasettings.lua
- hypr/xdph.conf
- omarchy/branding/about.txt
- omarchy/branding/screensaver.txt
- omarchy/extensions/omarchy-menu.jsonc
- omarchy/hooks/battery-low.d/play-warning-sound.sample
- omarchy/hooks/font-set.d/show-font-notification.sample
- omarchy/hooks/post-boot.d/weather.sample
- omarchy/hooks/post-update.d/install-voxtype.hook
- omarchy/hooks/post-update.d/setup-agent.hook
- omarchy/hooks/post-update.d/setup-fingerprint.hook
- omarchy/hooks/post-update.d/show-update-notification.sample
- omarchy/hooks/pre-refresh-pacman.d/add-custom-repo.sample
- omarchy/hooks/theme-set.d/show-theme-notification.sample
- omarchy/shell.json
- omarchy/shell.toml
- omarchy/theme.name
- omarchy/themes/azure-glow/README.md
- omarchy/themes/azure-glow/alacritty.toml
- omarchy/themes/azure-glow/btop.theme
- omarchy/themes/azure-glow/hyprland.conf
- omarchy/themes/azure-glow/hyprlock.conf
- omarchy/themes/azure-glow/icons.theme
- … 269 more
2026-09-23 15:19:12 +07:00

540 lines
27 KiB
JavaScript

#!/usr/bin/env node
// The panel supervises the SSH companion; it never waits on it. These tests
// cover the pure supervision logic (path resolution, the minimal environment,
// the bounded NDJSON reader, and the restart state machine) and then drive
// that logic with real child processes -- a fake helper and, when it has been
// built, the real one -- so the handshake is proven across the process
// boundary rather than against a mock.
//
// node tests/ssh-agent-control.test.js
const fs = require("fs")
const os = require("os")
const path = require("path")
const { spawn } = require("child_process")
const repoRoot = path.join(__dirname, "..")
const Model = {}
new Function("exports", fs.readFileSync(path.join(repoRoot, "BitwardenModel.js"), "utf8")
.replace(/^\.pragma library\s*$/m, "") + `
exports.pluginDirFromUrl = pluginDirFromUrl
exports.sshAgentHelperPath = sshAgentHelperPath
exports.sshAgentHelperCommand = sshAgentHelperCommand
exports.sshAgentHelperEnv = sshAgentHelperEnv
exports.sshAgentHelloLine = sshAgentHelloLine
exports.sshAgentShutdownLine = sshAgentShutdownLine
exports.parseAgentEvent = parseAgentEvent
exports.sshAgentRestartDelayMs = sshAgentRestartDelayMs
exports.sshAgentInitialState = sshAgentInitialState
exports.sshAgentReduce = sshAgentReduce
exports.sshAgentMaxRestarts = sshAgentMaxRestarts
exports.sshAgentHandshakeTimeoutMs = sshAgentHandshakeTimeoutMs
exports.sshAgentMaxLineBytes = sshAgentMaxLineBytes
`)(Model)
let pass = 0
const failures = []
const check = (label, ok, detail) => ok ? pass++ : failures.push(`${label}\n ${detail}`)
const eq = (label, actual, expected) =>
check(label, actual === expected, `expected ${JSON.stringify(expected)}, got ${JSON.stringify(actual)}`)
// -------------------------------------------------------------------------
// Absolute, plugin-relative helper path
// -------------------------------------------------------------------------
eq("plugin dir from a file URL",
Model.pluginDirFromUrl("file:///home/u/.config/omarchy/plugins/bw/"),
"/home/u/.config/omarchy/plugins/bw")
eq("plugin dir keeps a percent-encoded segment",
Model.pluginDirFromUrl("file:///home/u/my%20plugins/bw/"), "/home/u/my plugins/bw")
eq("plugin dir accepts a bare absolute path", Model.pluginDirFromUrl("/opt/bw/"), "/opt/bw")
eq("plugin dir refuses a relative URL", Model.pluginDirFromUrl("plugins/bw"), "")
eq("plugin dir refuses a non-file scheme", Model.pluginDirFromUrl("qrc:/bw/"), "")
eq("plugin dir refuses traversal", Model.pluginDirFromUrl("file:///opt/bw/../../etc/"), "")
eq("plugin dir refuses an encoded traversal", Model.pluginDirFromUrl("file:///opt/bw/%2e%2e/etc/"), "")
eq("plugin dir refuses an empty url", Model.pluginDirFromUrl(""), "")
eq("plugin dir refuses a non-string", Model.pluginDirFromUrl(null), "")
eq("helper path is plugin-relative and absolute",
Model.sshAgentHelperPath("/opt/bw"), "/opt/bw/agent/target/debug/qs-bitwarden-ssh-agent")
eq("helper path refuses a relative plugin dir", Model.sshAgentHelperPath("opt/bw"), "")
eq("helper path refuses an empty plugin dir", Model.sshAgentHelperPath(""), "")
// The source is chosen by the bundle inspection (see ssh-agent-bundle.test.js),
// and the command follows it rather than guessing. Launching an unvetted
// binary would defeat the point of inspecting one.
const helperCmd = Model.sshAgentHelperCommand("/opt/bw", "development")
eq("helper runs directly with no shell and no arguments", helperCmd.length, 1)
eq("helper command is the absolute helper path", helperCmd[0],
"/opt/bw/agent/target/debug/qs-bitwarden-ssh-agent")
eq("the shipped helper is launched when that is what was accepted",
Model.sshAgentHelperCommand("/opt/bw", "bundled")[0],
"/opt/bw/bin/x86_64-linux/qs-bitwarden-ssh-agent")
eq("no accepted source launches nothing", Model.sshAgentHelperCommand("/opt/bw", "").length, 0)
eq("an unknown source launches nothing", Model.sshAgentHelperCommand("/opt/bw", "elsewhere").length, 0)
check("helper command never goes through a shell",
!helperCmd.some(a => /^(?:ba)?sh$/.test(path.basename(String(a)))), JSON.stringify(helperCmd))
eq("helper command is empty without a plugin dir", Model.sshAgentHelperCommand("", "bundled").length, 0)
// -------------------------------------------------------------------------
// Minimal environment
// -------------------------------------------------------------------------
const env = Model.sshAgentHelperEnv("/run/user/1000")
eq("helper environment carries only XDG_RUNTIME_DIR", Object.keys(env).sort().join(","), "XDG_RUNTIME_DIR")
eq("helper environment points at the runtime dir", env.XDG_RUNTIME_DIR, "/run/user/1000")
for (const banned of ["PATH", "HOME", "BW_SESSION", "BW_PASSWORD", "QSBW_SECRET", "SSH_AUTH_SOCK"]) {
check("helper environment omits " + banned, !(banned in env), JSON.stringify(env))
}
eq("helper environment refuses a relative runtime dir", Model.sshAgentHelperEnv("run/user/1000"), null)
eq("helper environment refuses an empty runtime dir", Model.sshAgentHelperEnv(""), null)
// -------------------------------------------------------------------------
// Bounded NDJSON reader
// -------------------------------------------------------------------------
eq("hello is the versioned v1 handshake", Model.sshAgentHelloLine(), '{"v":1,"type":"hello"}\n')
eq("shutdown is a versioned v1 line", Model.sshAgentShutdownLine(), '{"v":1,"type":"shutdown"}\n')
const ready = Model.parseAgentEvent(JSON.stringify({
v: 1, type: "ready", socketPath: "/run/user/1000/qs-bitwarden-cli/ssh-agent.sock",
fifoPath: "/run/user/1000/qs-bitwarden-cli/ssh-keys.fifo", agentVersion: "0.1.0"
}))
eq("ready parses", ready.ok, true)
eq("ready keeps its socket path", ready.message.socketPath, "/run/user/1000/qs-bitwarden-cli/ssh-agent.sock")
eq("ready keeps its agent version", ready.message.agentVersion, "0.1.0")
eq("an empty line is ignored rather than fatal", Model.parseAgentEvent("").code, "EMPTY")
check("an empty line does not fail closed", Model.parseAgentEvent("").fatal === false,
JSON.stringify(Model.parseAgentEvent("")))
eq("malformed JSON fails closed", Model.parseAgentEvent("{not json").code, "MALFORMED")
check("malformed JSON is fatal", Model.parseAgentEvent("{not json").fatal === true, "not fatal")
eq("a wrong version fails closed", Model.parseAgentEvent('{"v":2,"type":"ready"}').code, "VERSION_MISMATCH")
eq("a missing version fails closed", Model.parseAgentEvent('{"type":"ready"}').code, "VERSION_MISMATCH")
eq("an unknown type fails closed", Model.parseAgentEvent('{"v":1,"type":"exec"}').code, "UNKNOWN_TYPE")
eq("a non-object line fails closed", Model.parseAgentEvent('"ready"').code, "MALFORMED")
eq("a ready missing its socket path fails closed",
Model.parseAgentEvent('{"v":1,"type":"ready","fifoPath":"/f","agentVersion":"1"}').code, "MALFORMED")
const overlong = '{"v":1,"type":"error","message":"' + "x".repeat(Model.sshAgentMaxLineBytes()) + '"}'
eq("an overlong line fails closed before parsing", Model.parseAgentEvent(overlong).code, "LINE_TOO_LONG")
const multibyte = '{"v":1,"type":"error","message":"' + "é".repeat(Model.sshAgentMaxLineBytes() - 100) + '"}'
eq("the line cap counts bytes, not characters", Model.parseAgentEvent(multibyte).code, "LINE_TOO_LONG")
const justUnder = JSON.stringify({ v: 1, type: "error", code: "X", message: "y".repeat(1024), recoverable: true })
eq("a line under the cap still parses", Model.parseAgentEvent(justUnder).ok, true)
// -------------------------------------------------------------------------
// Supervision state machine
// -------------------------------------------------------------------------
const readyLine = JSON.stringify({
v: 1, type: "ready", socketPath: "/run/user/1000/qs-bitwarden-cli/ssh-agent.sock",
fifoPath: "/run/user/1000/qs-bitwarden-cli/ssh-keys.fifo", agentVersion: "0.1.0"
})
function drive(state, events) {
const actions = []
for (const ev of events) {
const step = Model.sshAgentReduce(state, ev)
state = step.state
actions.push(step.action)
}
return { state, actions, last: actions[actions.length - 1] }
}
function reachReady(t) {
return drive(Model.sshAgentInitialState(), [
{ kind: "enabled", value: true, nowMs: t },
{ kind: "started", nowMs: t + 1 },
{ kind: "line", line: readyLine, nowMs: t + 2 }
])
}
const initial = Model.sshAgentInitialState()
eq("the supervisor starts disabled", initial.phase, "disabled")
check("the signing gate starts closed", initial.gateOpen === false, JSON.stringify(initial))
const inert = drive(Model.sshAgentInitialState(), [
{ kind: "started", nowMs: 0 },
{ kind: "line", line: readyLine, nowMs: 1 },
{ kind: "exited", exitCode: 1, nowMs: 2 },
{ kind: "restartTimer", nowMs: 3 }
])
eq("disabled mode stays disabled", inert.state.phase, "disabled")
check("disabled mode starts nothing", inert.actions.every(a => !a.start && !a.writeHello),
JSON.stringify(inert.actions))
check("disabled mode never opens the gate", inert.state.gateOpen === false, JSON.stringify(inert.state))
const enabled = drive(Model.sshAgentInitialState(), [{ kind: "enabled", value: true, nowMs: 0 }])
eq("enabling starts the helper", enabled.last.start, true)
eq("enabling moves to starting", enabled.state.phase, "starting")
check("enabling does not open the gate before the handshake", enabled.state.gateOpen === false,
JSON.stringify(enabled.state))
const handshaking = drive(enabled.state, [{ kind: "started", nowMs: 1 }])
eq("a started helper is sent hello", handshaking.last.writeHello, true)
eq("a started helper is handshaking", handshaking.state.phase, "handshaking")
check("the gate stays closed while handshaking", handshaking.state.gateOpen === false,
JSON.stringify(handshaking.state))
const live = reachReady(0)
eq("a v1 ready completes the handshake", live.state.phase, "ready")
check("ready opens the signing gate", live.state.gateOpen === true, JSON.stringify(live.state))
eq("ready records the socket path", live.state.socketPath, "/run/user/1000/qs-bitwarden-cli/ssh-agent.sock")
eq("ready records the fifo path", live.state.fifoPath, "/run/user/1000/qs-bitwarden-cli/ssh-keys.fifo")
eq("ready records the agent version", live.state.agentVersion, "0.1.0")
check("no reduction ever asks QML to wait",
live.actions.every(a => !("wait" in a) && !("waitMs" in a)), JSON.stringify(live.actions))
const badVersion = drive(Model.sshAgentInitialState(), [
{ kind: "enabled", value: true, nowMs: 0 },
{ kind: "started", nowMs: 1 },
{ kind: "line", line: '{"v":2,"type":"ready","socketPath":"/s","fifoPath":"/f","agentVersion":"9"}', nowMs: 2 }
])
eq("a version mismatch stops the helper", badVersion.last.stop, true)
eq("a version mismatch is reported", badVersion.state.errorCode, "VERSION_MISMATCH")
check("a version mismatch keeps the gate closed", badVersion.state.gateOpen === false,
JSON.stringify(badVersion.state))
const garbage = drive(reachReady(0).state, [{ kind: "line", line: "not json at all", nowMs: 100 }])
eq("a malformed line closes the gate", garbage.state.gateOpen, false)
eq("a malformed line stops the helper", garbage.last.stop, true)
eq("a malformed line is reported", garbage.state.errorCode, "MALFORMED")
const tooLong = drive(reachReady(0).state, [{ kind: "line", line: overlong, nowMs: 100 }])
eq("an overlong line closes the gate", tooLong.state.gateOpen, false)
eq("an overlong line is reported", tooLong.state.errorCode, "LINE_TOO_LONG")
const blank = drive(reachReady(0).state, [{ kind: "line", line: "", nowMs: 100 }])
eq("a blank line is ignored", blank.state.phase, "ready")
check("a blank line leaves the gate open", blank.state.gateOpen === true, JSON.stringify(blank.state))
const passthrough = drive(reachReady(0).state, [{
kind: "line", nowMs: 100,
line: JSON.stringify({ v: 1, type: "keys_loaded", epoch: 7, keyCount: 2, skipped: [] })
}])
eq("a live event stays ready", passthrough.state.phase, "ready")
eq("a live event reaches the panel", passthrough.last.message.type, "keys_loaded")
const secondReady = drive(reachReady(0).state, [{ kind: "line", line: readyLine, nowMs: 100 }])
eq("a duplicate ready is a protocol violation", secondReady.state.errorCode, "PROTOCOL")
check("a duplicate ready closes the gate", secondReady.state.gateOpen === false,
JSON.stringify(secondReady.state))
const earlyEvent = drive(handshaking.state, [{
kind: "line", nowMs: 5, line: JSON.stringify({ v: 1, type: "locked", epoch: 1 })
}])
eq("an event before ready is a protocol violation", earlyEvent.state.errorCode, "PROTOCOL")
const stalled = drive(handshaking.state, [{ kind: "handshakeTimeout", nowMs: 9999 }])
eq("a stalled handshake is bounded", stalled.state.errorCode, "HANDSHAKE_TIMEOUT")
eq("a stalled handshake stops the helper", stalled.last.stop, true)
check("a stalled handshake keeps the gate closed", stalled.state.gateOpen === false,
JSON.stringify(stalled.state))
const eof = drive(reachReady(0).state, [{ kind: "exited", exitCode: 0, nowMs: 100 }])
eq("stdout EOF closes the gate", eof.state.gateOpen, false)
eq("stdout EOF schedules a restart", eof.last.restartInMs, Model.sshAgentRestartDelayMs(1))
eq("stdout EOF backs off", eof.state.phase, "backoff")
const restarted = drive(eof.state, [{ kind: "restartTimer", nowMs: 200 }])
eq("the backoff timer restarts the helper", restarted.last.start, true)
eq("the backoff timer returns to starting", restarted.state.phase, "starting")
eq("backoff step 1", Model.sshAgentRestartDelayMs(1), 500)
eq("backoff step 2", Model.sshAgentRestartDelayMs(2), 1000)
eq("backoff step 3", Model.sshAgentRestartDelayMs(3), 2000)
check("backoff is capped", Model.sshAgentRestartDelayMs(50) === 30000,
String(Model.sshAgentRestartDelayMs(50)))
check("backoff never goes negative", Model.sshAgentRestartDelayMs(0) >= 0,
String(Model.sshAgentRestartDelayMs(0)))
// A crash loop: every run dies immediately, so nothing ever counts as healthy.
let loop = Model.sshAgentInitialState()
let loopActions = []
let clock = 0
loop = Model.sshAgentReduce(loop, { kind: "enabled", value: true, nowMs: clock }).state
for (let i = 0; i < Model.sshAgentMaxRestarts() + 2; i++) {
clock += 10
loop = Model.sshAgentReduce(loop, { kind: "started", nowMs: clock }).state
clock += 10
const step = Model.sshAgentReduce(loop, { kind: "exited", exitCode: 101, nowMs: clock })
loop = step.state
loopActions.push(step.action)
if (loop.phase !== "backoff") break
clock += 10
loop = Model.sshAgentReduce(loop, { kind: "restartTimer", nowMs: clock }).state
}
eq("a crash loop stops restarting", loop.phase, "failed")
eq("a crash loop is reported", loop.errorCode, "CRASH_LOOP")
check("a crash loop leaves the gate closed", loop.gateOpen === false, JSON.stringify(loop))
eq("a crash loop schedules no further restart", loopActions[loopActions.length - 1].restartInMs, -1)
check("a crash loop is bounded by the restart cap",
loopActions.filter(a => a.restartInMs > 0).length === Model.sshAgentMaxRestarts(),
String(loopActions.filter(a => a.restartInMs > 0).length))
const failedIgnores = drive(loop, [
{ kind: "restartTimer", nowMs: clock + 1000 },
{ kind: "started", nowMs: clock + 1001 }
])
eq("a failed supervisor stays failed", failedIgnores.state.phase, "failed")
check("a failed supervisor starts nothing", failedIgnores.actions.every(a => !a.start),
JSON.stringify(failedIgnores.actions))
// The loop that actually happens in practice: the helper starts fine, answers
// the handshake, serves briefly, and dies -- over and over. Completing a
// handshake must not wipe the failure history, or a helper that crashes a
// second after every start is restarted forever.
{
let crashy = Model.sshAgentInitialState()
let scheduled = 0
let t = 0
crashy = Model.sshAgentReduce(crashy, { kind: "enabled", value: true, nowMs: t }).state
for (let i = 0; i < Model.sshAgentMaxRestarts() + 3; i++) {
t += 10
crashy = Model.sshAgentReduce(crashy, { kind: "started", nowMs: t }).state
t += 10
crashy = Model.sshAgentReduce(crashy, { kind: "line", line: readyLine, nowMs: t }).state
// Serves for well under the healthy threshold, then dies.
t += 1500
const step = Model.sshAgentReduce(crashy, { kind: "exited", exitCode: 137, nowMs: t })
crashy = step.state
if (step.action.restartInMs >= 0) scheduled++
if (crashy.phase !== "backoff") break
t += 10
crashy = Model.sshAgentReduce(crashy, { kind: "restartTimer", nowMs: t }).state
}
eq("a helper that handshakes then dies still trips the bound", crashy.phase, "failed")
eq("that loop is reported as a crash loop", crashy.errorCode, "CRASH_LOOP")
eq("that loop is bounded by the same restart cap", scheduled, Model.sshAgentMaxRestarts())
check("that loop leaves the gate closed", crashy.gateOpen === false, JSON.stringify(crashy))
}
// A helper that ran healthily for a long time is not a crash loop.
const healthy = drive(reachReady(0).state, [{ kind: "exited", exitCode: 0, nowMs: 10 * 60 * 1000 }])
eq("a long healthy run resets the backoff", healthy.last.restartInMs, Model.sshAgentRestartDelayMs(1))
eq("a long healthy run keeps supervising", healthy.state.phase, "backoff")
const disabledMidflight = drive(reachReady(0).state, [{ kind: "enabled", value: false, nowMs: 100 }])
eq("disabling stops the helper", disabledMidflight.last.stop, true)
eq("disabling cancels a pending restart", disabledMidflight.last.cancelRestart, true)
eq("disabling returns to disabled", disabledMidflight.state.phase, "disabled")
check("disabling closes the gate", disabledMidflight.state.gateOpen === false,
JSON.stringify(disabledMidflight.state))
const disabledDuringBackoff = drive(eof.state, [{ kind: "enabled", value: false, nowMs: 150 }])
eq("disabling during backoff cancels the restart", disabledDuringBackoff.last.cancelRestart, true)
eq("disabling during backoff is disabled", disabledDuringBackoff.state.phase, "disabled")
const reEnabled = drive(loop, [{ kind: "enabled", value: false, nowMs: 1 }, { kind: "enabled", value: true, nowMs: 2 }])
eq("re-enabling clears the crash-loop failure", reEnabled.state.errorCode, "")
eq("re-enabling starts the helper again", reEnabled.last.start, true)
const stoppingExit = drive(garbage.state, [{ kind: "exited", exitCode: 143, nowMs: 200 }])
eq("an exit after a protocol stop still backs off", stoppingExit.state.phase, "backoff")
eq("an exit after a protocol stop keeps its error", stoppingExit.state.errorCode, "MALFORMED")
// -------------------------------------------------------------------------
// Real child processes
// -------------------------------------------------------------------------
const tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), "qsbw-agent-"))
const runtimeDir = path.join(tmpRoot, "run")
fs.mkdirSync(runtimeDir, { mode: 0o700 })
function runHelper(command, environment, opts) {
return new Promise(resolve => {
const child = spawn(command[0], command.slice(1), {
env: environment, stdio: ["pipe", "pipe", "pipe"]
})
let state = Model.sshAgentInitialState()
state = Model.sshAgentReduce(state, { kind: "enabled", value: true, nowMs: 0 }).state
let buffered = ""
let settled = false
// The state at the moment the handshake landed. The exit that follows
// clears the helper's advertised paths by design, so what `ready` carried
// has to be captured while it is still true.
let readyState = null
const messages = []
const finish = () => {
if (settled) return
settled = true
clearTimeout(guard)
try { child.kill("SIGKILL") } catch (e) {}
resolve({ state, readyState, messages })
}
const guard = setTimeout(finish, (opts && opts.timeoutMs) || 5000)
const started = Model.sshAgentReduce(state, { kind: "started", nowMs: 1 })
state = started.state
if (started.action.writeHello) child.stdin.write(Model.sshAgentHelloLine())
child.stdout.on("data", chunk => {
buffered += chunk.toString("utf8")
let nl
while ((nl = buffered.indexOf("\n")) >= 0) {
const line = buffered.slice(0, nl)
buffered = buffered.slice(nl + 1)
const step = Model.sshAgentReduce(state, { kind: "line", line: line, nowMs: Date.now() })
state = step.state
if (step.action.message) messages.push(step.action.message)
if (step.action.stop) { try { child.kill("SIGTERM") } catch (e) {} }
if (state.phase === "ready" && !readyState) {
readyState = state
if (opts && opts.stopOnReady) child.stdin.end()
}
}
})
child.on("exit", code => {
state = Model.sshAgentReduce(state, { kind: "exited", exitCode: code, nowMs: Date.now() }).state
finish()
})
child.on("error", () => finish())
})
}
function writeFakeHelper(name, body) {
const file = path.join(tmpRoot, name)
fs.writeFileSync(file, "#!/usr/bin/env node\n" + body, { mode: 0o700 })
return file
}
const fakeReady = writeFakeHelper("fake-ready.js", `
process.stdin.resume()
let seen = ""
process.stdin.on("data", d => {
seen += d.toString()
if (seen.indexOf('"hello"') >= 0) {
process.stdout.write(JSON.stringify({ v: 1, type: "ready",
socketPath: "/run/fake/ssh-agent.sock", fifoPath: "/run/fake/ssh-keys.fifo",
agentVersion: "0.0.0-fake" }) + "\\n")
seen = ""
}
})
process.stdin.on("end", () => process.exit(0))
`)
const fakeGarbage = writeFakeHelper("fake-garbage.js", `
process.stdin.resume()
process.stdout.write("this is not ndjson\\n")
setTimeout(() => process.exit(0), 2000)
`)
const fakeSilent = writeFakeHelper("fake-silent.js", `
process.stdin.resume()
setTimeout(() => process.exit(0), 60000)
`)
const fakeCrash = writeFakeHelper("fake-crash.js", `process.exit(9)`)
const fakeFlood = writeFakeHelper("fake-flood.js", `
process.stdin.resume()
let seen = ""
process.stdin.on("data", d => {
seen += d.toString()
if (seen.indexOf('"hello"') >= 0) {
process.stdout.write('{"v":1,"type":"error","code":"X","message":"' + "z".repeat(200000) + '"}\\n')
seen = ""
}
})
`)
const realHelper = path.join(repoRoot, "agent", "target", "debug", "qs-bitwarden-ssh-agent")
async function processTests() {
const nodeBin = process.execPath
const okRun = await runHelper([nodeBin, fakeReady], Model.sshAgentHelperEnv(runtimeDir), { stopOnReady: true })
check("a fake helper completes the handshake", okRun.readyState !== null, JSON.stringify(okRun.state))
eq("a fake helper opens the gate on ready", okRun.readyState && okRun.readyState.gateOpen, true)
eq("a fake helper reports its version", okRun.readyState && okRun.readyState.agentVersion, "0.0.0-fake")
eq("closing stdin ends the fake helper and closes the gate", okRun.state.gateOpen, false)
const garbageRun = await runHelper([nodeBin, fakeGarbage], Model.sshAgentHelperEnv(runtimeDir))
eq("a garbage-emitting helper fails closed", garbageRun.state.errorCode, "MALFORMED")
check("a garbage-emitting helper never opens the gate", garbageRun.state.gateOpen === false,
JSON.stringify(garbageRun.state))
const floodRun = await runHelper([nodeBin, fakeFlood], Model.sshAgentHelperEnv(runtimeDir))
eq("an overlong helper line fails closed", floodRun.state.errorCode, "LINE_TOO_LONG")
const crashRun = await runHelper([nodeBin, fakeCrash], Model.sshAgentHelperEnv(runtimeDir))
eq("a helper that dies at once backs off", crashRun.state.phase, "backoff")
check("a helper that dies at once leaves the gate closed", crashRun.state.gateOpen === false,
JSON.stringify(crashRun.state))
const silentRun = await runHelper([nodeBin, fakeSilent], Model.sshAgentHelperEnv(runtimeDir), { timeoutMs: 1200 })
eq("a silent helper never opens the gate", silentRun.state.gateOpen, false)
eq("a silent helper stays in the handshake", silentRun.state.phase, "handshaking")
if (fs.existsSync(realHelper)) {
const realRun = await runHelper([realHelper], Model.sshAgentHelperEnv(runtimeDir), { stopOnReady: true })
check("the real helper completes the v1 handshake with only XDG_RUNTIME_DIR",
realRun.readyState !== null, JSON.stringify(realRun.state))
const readyReal = realRun.readyState || { socketPath: "", fifoPath: "", agentVersion: "" }
check("the real helper reported a socket under the runtime dir",
readyReal.socketPath.indexOf(runtimeDir) === 0,
readyReal.socketPath + " (expected under " + runtimeDir + ")")
check("the real helper reported a fifo under the runtime dir",
readyReal.fifoPath.indexOf(runtimeDir) === 0,
readyReal.fifoPath + " (expected under " + runtimeDir + ")")
check("the real helper reported a version", /^\d+\.\d+\.\d+$/.test(readyReal.agentVersion),
readyReal.agentVersion)
check("closing stdin exits the real helper", realRun.state.phase === "backoff",
"phase " + realRun.state.phase)
eq("the real helper leaves the gate closed once it is gone", realRun.state.gateOpen, false)
check("the real helper removed its socket on the way out",
!fs.existsSync(readyReal.socketPath), "socket still present at " + readyReal.socketPath)
} else {
failures.push("the real helper binary is missing\n build it with: cargo build --manifest-path agent/Cargo.toml --locked")
}
}
// -------------------------------------------------------------------------
// The ordinary vault must not depend on the helper
// -------------------------------------------------------------------------
// The panel is three QML files now -- the SSH settings sections and the
// approval screen have their own. A check that reads only the largest one
// silently narrows as markup moves out of it.
const panelSrc = ["Panel.qml", "SshAgentSettings.qml", "SshApprovalScreen.qml"]
.map(file => fs.readFileSync(path.join(repoRoot, file), "utf8"))
.join("\n")
check("the supervisor Process is tracked, not detached",
!/execDetached\([^)]*sshAgent/i.test(panelSrc), "found execDetached for the ssh agent")
check("the supervisor keeps stdin open", /id:\s*sshAgentProc[\s\S]{0,400}?stdinEnabled:\s*true/.test(panelSrc),
"sshAgentProc has no stdinEnabled: true")
check("the supervisor parses stdout by line from startup",
/id:\s*sshAgentProc[\s\S]{0,600}?stdout:\s*SplitParser/.test(panelSrc),
"sshAgentProc has no SplitParser attached")
check("the supervisor uses a minimal environment",
/id:\s*sshAgentProc[\s\S]{0,600}?clearEnvironment:\s*true/.test(panelSrc),
"sshAgentProc does not clear its environment")
// The helper cleans up its socket and FIFO when its control channel closes,
// and not when it is signalled. A stop that goes straight to SIGTERM leaves
// both behind for the next start to reclaim, so the supervisor has to ask
// before it terminates.
check("stopping the helper closes its control channel first",
/function stopSshAgentHelper\(\)[\s\S]{0,600}?stdinEnabled = false/.test(panelSrc),
"the stop path never closes stdin")
check("stopping the helper sends the shutdown line",
/function stopSshAgentHelper\(\)[\s\S]{0,600}?sshAgentShutdownLine\(\)/.test(panelSrc),
"the stop path never sends shutdown")
check("termination is a backstop behind a grace period",
/sshAgentTerminateTimer[\s\S]{0,300}?onTriggered:\s*if \(sshAgentProc\.running\) sshAgentProc\.running = false/.test(panelSrc),
"nothing terminates a helper that ignores the shutdown request")
check("starting the helper reopens its control channel",
/function startSshAgentHelper\(\)[\s\S]{0,300}?stdinEnabled = true/.test(panelSrc),
"a restarted helper would have no control channel")
processTests().then(() => {
if (failures.length) {
console.error(`\n${failures.length} failed, ${pass} passed\n`)
failures.forEach(f => console.error(` FAIL ${f}`))
process.exit(1)
}
console.log(`ssh-agent-control: ${pass} passed`)
try { fs.rmSync(tmpRoot, { recursive: true, force: true }) } catch (e) {}
})