row 210 (#1707): round 2 for duckworth-0052, B1 fail not skip, N1 raise grant check, N2 addKey limit and abort, N3 ssh -E log (Gizmoduck)

B1: host-run tests fail when sshd or another binary is missing (SSHD at a missing path: exit 1, 13 fail, 0 skipped).
N1: a host.sudo raise checks the raiser's grant and argv prefix. N2: addKey is killed at its limit or on abort.
N3: host-key-mismatch reads only ssh's own -E log on exit 255. Singles 17 files 137/0, 53 mutant anchors match once.
This commit is contained in:
2026-10-20 16:31:55 -05:00
parent 12ffe0b568
commit 455964f23d
7 changed files with 163 additions and 40 deletions
+45 -6
View File
@@ -1,4 +1,4 @@
# Row 210 round 1 (#424, Gitea #1707): agent host access with sudo through the credential store
# Row 210 round 2 (#424, Gitea #1707): agent host access with sudo through the credential store
Gizmoduck, 2026-10-20. Reviewer Duckworth, gate owner Sage.
@@ -11,6 +11,9 @@ Gizmoduck, 2026-10-20. Reviewer Duckworth, gate owner Sage.
- Rebase: round 1 was built on addee664a (8ec923585 on `gizmoduck/row210-on-199`). 199's tip moved to
07141966e, which touches `packages/cli/src/credentials.mjs` and its test, both also changed here. The
rebase applied with no conflict, and every touched test file was rerun singly on the new base.
- Round 2 answers duckworth-0052 (B1, N1 to N3) on the same base. The round-1 candidate after the rebase was
12ffe0b56. Round 2 touches only B1's lines and the notes, plus a fixture line in `hard-waits.test.mjs` that N1
needed. See "Round 2" below.
- Candidate: the commit that adds this file. `candidate-manifest.sha256` lists the code and test files.
- Scope (sage-1468): the `host.run` broker op, the grant check, the executor and the receipts, over
`broker.sock`. No `packages/harness` change. The `host_run` tool is Sage's follow-up row.
@@ -36,6 +39,32 @@ Gizmoduck, 2026-10-20. Reviewer Duckworth, gate owner Sage.
T1, T2 and T26 are in `packages/business/tests/hosts.test.mjs`. T23 is in `packages/cli/tests/credentials.test.mjs`.
The rest are in `packages/bus/tests/host-run.test.mjs`, against a scratch sshd on 127.0.0.1 with a fake sudo.
## Round 2 (duckworth-0052)
- B1. `host-run.test.mjs` no longer skips. A missing `sshd`, `ssh`, `ssh-agent`, `ssh-add`, `ssh-keygen` or
`setpriv` fails a first test that names the binary, and `sshd()` throws the same way, so every executor test
fails too. I picked failing over the `# skipped 0` gate check because it needs no gate change. With `SSHD`
edited in place to `/nonexistent/sshd` (backup, restored, sha checked), the file exits 1: tests 21, pass 8,
fail 13, skipped 0. The binary test and all 12 executor tests fail with "/nonexistent/sshd is missing".
- N1. A `host.sudo` raise now checks the raiser's own grant for the target and the argv prefix before it asks the
human (broker.mjs:1964-1967). It uses the same check `host.run` uses (:1732-1735). T5 to T8 now raise for an
ungranted host (`host-not-granted`) and an argv outside the grant (`command-not-granted`), and count zero
`host.sudo` decisions after. T9 grants web2 through a new `coderWeb2` fixture option. `hard-waits.test.mjs`
raised `host.sudo` from a coder with no grant, so its fixture now grants web1 `systemctl status`.
- N2. `addKey` takes `limitMs` (default `HOST_LIMITS.agentWaitMs`) and `signal` (host-run.mjs:87-104). At the
limit it kills ssh-add's group and refuses `host-failed`. On abort it does the same and refuses
`credential-unavailable`. `runHost` passes both (:178). The new N2 test uses a unix socket that accepts and
never answers. The 300 ms limit refuses in under 2 s, the abort at 100 ms refuses `credential-unavailable`, and
the socket sees both ssh-add connections close. It carries a 10 s test limit so M37a and M37b fail in seconds.
- N3. ssh writes its own messages to `<run dir>/ssh.log` with `-E` (host-run.mjs:33). `host-key-mismatch` now
reads only that log, and only on exit 255 (:194-195). The remote stderr can't trigger it. On another exit 255
the log goes after the remote stderr, so an ssh failure keeps its message. The new N3 test runs a fake
`fail255` that prints the pin refusal text and exits 255: the answer is the command result with exit 255. A run
to port 1 answers exit 255 with "port 1: Connection refused". T14 still refuses `host-key-mismatch`. T15 checks
the `-E` argv.
- N4. The deviations are accepted. No change.
- N5. The business test helper leaving `mosaic-business-*` tmp dirs predates this row. It is a follow-up, below.
## Deviations from the design
1. `resolve.mjs` filters `host.*` out of the session's credentials (D5). The bus needs them, so
@@ -71,7 +100,7 @@ The rest are in `packages/bus/tests/host-run.test.mjs`, against a scratch sshd o
## Mutants
`mutants.py`: 47 mutants, design section 9 split where the design says "one mutant each".
`mutants.py`: 53 mutants, design section 9 split where the design says "one mutant each".
- M04 is a to e (one per argv shape check).
- M22 is a (broker pre-check) and b (executor map).
@@ -80,21 +109,26 @@ The rest are in `packages/bus/tests/host-run.test.mjs`, against a scratch sshd o
frame and line regex (:29, :31).
- M28 is a and b. M34 is a to d (no-op sweep, bus pid ignored, start time ignored, bus start never sweeps).
- M12 dies on T15's argv check as well as T14, because `accept-new` still refuses a changed known key.
- Round 2 adds M36a and M36b (N1 raise grant and prefix checks), M37a and M37b (N2 limit and abort) and M38a and
M38b (N3 mismatch read from the remote stderr, `-E` dropped). M02's anchor now starts at the line break, since
the raise's check at 10 spaces holds the host.run line at 6 as a substring.
All 47 anchors match once (dry count, no `__pycache__`). I ran M34d by hand once as a single test file: it
fails in under a second and the file restored to its sha. **The batch is not run**, as it needs Sage's slot.
All 53 anchors match once (dry count on the round-2 tree, no `__pycache__`). In round 1 I ran M34d by hand once
as a single test file: it fails in under a second and the file restored to its sha. The new round-2 mutants are
not run by hand. **The batch is not run**, as it needs Sage's slot.
Run it as `python3 -I agents/gizmoduck/work/queue-210/mutants.py <checkout> <empty scratch dir>`. It runs
the control first, puts each run in its own process group with a 240 s timeout and a killpg after it, checks
the files restored at the end, and exits 1 on any survivor or missed anchor.
## Checks
Single files on 07141966e, no slot. 17 files, 134 pass, 0 fail (awk sum over the singles log):
Single files on 07141966e with the round-2 edits, no slot. 17 files, 137 pass, 0 fail, 0 skipped (awk sum over
the singles log):
| File | Pass |
|------|------|
| business hosts, hard-waits | 7, 5 |
| bus host-run | 18 |
| bus host-run | 21 |
| bus hard-waits, credentials, credential-store, credential-verbs, credential-status | 13, 5, 11, 2, 4 |
| bus business, web, approval-binding, decisions, single-use | 1, 19, 6, 7, 12 |
| bus publish, transport, instances-verb | 12, 5, 2 |
@@ -106,6 +140,11 @@ Single files on 07141966e, no slot. 17 files, 134 pass, 0 fail (awk sum over the
- `node tools/unslop-hook/unslop-check.js` is clean on this file.
- Not run: the bus, business and cli suites, `scripts/test-*.sh` and the mutant batch. All need Sage's slot.
## Follow-up, N5
`packages/business/tests/` helpers leave `mosaic-business-*` dirs under the tmp dir after a run. This predates
row 210 and isn't changed here. It needs its own row.
## Follow-up for 199, closed
CLI `ISSUED` lacked `rotation-due`, so `credentials rotate` on a key in that state refused. Megavolt fixed it
@@ -6,15 +6,15 @@ ab5f7dc38a2ca5978b02d257068f4f82706db847cf0d6400f7cd548f8f6abe43 packages/busin
830e690a8eea825c210747d9e91d24453d648d0302ab507589aeb978f19c7fcf packages/business/src/vocabulary.mjs
6a2c9b371fc9236bb0730ed108cbd770e9487b8ba733dbebccd11891ccba6f17 packages/business/tests/hard-waits.test.mjs
9ef8b8be3c290f072e7c943b176a1ec688fda03fab4ba114d31213caf2216e42 packages/business/tests/hosts.test.mjs
99961a8fef7165df16e878f5e6a6b88a43d3443ea1209013841053e5f250dbb0 packages/bus/src/broker.mjs
dbd0276dd299ad95d10a5a2d61a213bb92c3794fbd75ea4495cd6a107dd08343 packages/bus/src/broker.mjs
ff927da3cf22fbacc9ace77b2e8b803bffc2953fb58cf7ab4e5f6f6493732996 packages/bus/src/business.mjs
27d2ae155a2919788d7ad02da10a7056c23f5b71228e9af406b1b8aa0de55efc packages/bus/src/credentials.mjs
86b4321dd436b97e389101873197dbab95b3d2202e5ef7da712c4978f4f9159f packages/bus/src/credential-verbs.mjs
990131d2c6359a4f1de273dcaf46c7726237fe96431b6af8ef16965eeaa3b98b packages/bus/src/host-run.mjs
5d227edec1d33cb58607fae4e43784a0decc8aaf55cad6bc4318cec4eda6fbea packages/bus/src/host-run.mjs
3721190045a4f114d67c9c7d32a448cf3d5dbbc6d4554dbc348e9f6fd428b5dc packages/bus/src/runtime.mjs
3ef86e4f0b77cc513bdd58ec7eb984ceb02af6625f39d1be5254f2c0b0e99734 packages/bus/src/server.mjs
2f0f081d12042ee7ebfaa38770903ec8de297f3393f90dfd236fcf0a4a004d07 packages/bus/tests/hard-waits.test.mjs
3ed86692fda522744f601eac1095bb3762bdd88b843e1991f72df94748b28a5e packages/bus/tests/host-run.test.mjs
58e45b7a0d0118be8827bd71d1a427ff84d0ecac46c22adece36dec36e6d521d packages/bus/tests/hard-waits.test.mjs
28ad8ce2a29722768820ad916273f2100eb79281255c6a3689964cd24564237c packages/bus/tests/host-run.test.mjs
be4e1eb88b689e8e4f859de75218af151feae8dce2c1409b726cb9c3dcd5ffe9 packages/bus/tests/spec-fixtures.mjs
19755f7bb002d8455990c5dc7c458e64aeb95252ec7231a2ed2fd9c638fcb511 packages/cli/src/credentials.mjs
152a3e57f7241db45bf8262fbf86ee029cf3b98f0d75503990abd46ac151a4b2 packages/cli/tests/credentials.test.mjs
+16 -2
View File
@@ -1,5 +1,6 @@
#!/usr/bin/env python3
"""Row 210 (#1707) mutants: design section 9, M1 to M35.
"""Row 210 (#1707) mutants: design section 9, M1 to M35, and round 2's M36 to M38 for
duckworth-0052 N1 to N3.
Usage: mutants.py <checkout> <scratch-dir>
@@ -28,7 +29,7 @@ MUTANTS = [
("M01 registry check", BROKER,
[(" if (!Object.hasOwn(b.hosts ?? {}, a.host)) fail('host-unknown');\n", "")], [HOST_TEST]),
("M02 agent grant check", BROKER,
[(" if (!grant) fail('host-not-granted');\n", "")], [HOST_TEST]),
[("\n if (!grant) fail('host-not-granted');\n", "\n")], [HOST_TEST]),
("M03 grant read from any agent", BROKER,
[("const grant = Object.hasOwn(b.roles[s.role]?.hosts ?? {}, a.host) ? b.roles[s.role].hosts[a.host] : null;",
"const any = Object.assign({}, ...Object.values(b.roles).map((r) => r.hosts ?? {})); const grant = any[a.host] ?? null;")],
@@ -142,6 +143,19 @@ MUTANTS = [
[(" if (runRoot) sweepRunDir(runRoot);\n", "")], [HOST_TEST]),
("M35 own-machine check", HOSTS,
[(" if (ownMachine(address)) refuse(", " if (false) refuse(")], [HOSTS_TEST]),
("M36a host.sudo raise skips the grant check", BROKER,
[(" if (!grant) fail('host-not-granted');\n", "")], [HOST_TEST]),
("M36b host.sudo raise skips the prefix check", BROKER,
[(" if (!commandAllowed(a.argv, grant.commands)) fail('command-not-granted');\n", "")], [HOST_TEST]),
("M37a addKey has no time limit", RUN,
[(" const timer = setTimeout(() => stop('host-failed'), limitMs);", " const timer = null;")], [HOST_TEST]),
("M37b addKey ignores abort", RUN,
[(" const abort = () => stop('credential-unavailable');", " const abort = () => {};")], [HOST_TEST]),
("M38a mismatch read from the remote stderr too", RUN,
[(" if (log.includes('Host key verification failed')) deny('host-key-mismatch');",
" if ((log + out.stderr).includes('Host key verification failed')) deny('host-key-mismatch');")], [HOST_TEST]),
("M38b no -E log", RUN,
[("'-T', '-E', join(dir, 'ssh.log'), ", "'-T', ")], [HOST_TEST]),
]
# M21 and M23 leave a 30 s sleep running, so a test file gets room past it.
+4
View File
@@ -1961,6 +1961,10 @@ export class Broker {
if (sudo) {
if (a.target === undefined || !Object.hasOwn(b.hosts ?? {}, a.target)) fail('host-unknown');
if (!validArgv(a.argv) || !validStdin(a.stdin)) fail('invalid-request');
// The human is asked only about a call host.run would take: the raiser's own grant and prefix (N1).
const grant = Object.hasOwn(b.roles[s.role]?.hosts ?? {}, a.target) ? b.roles[s.role].hosts[a.target] : null;
if (!grant) fail('host-not-granted');
if (!commandAllowed(a.argv, grant.commands)) fail('command-not-granted');
const h = b.hosts[a.target];
question +=
`\n\nHost ${a.target}, ${h.user} at ${h.address} port ${h.port}, runs as root:` +
+26 -7
View File
@@ -29,7 +29,8 @@ export const remoteCommand = (argv) => ['sudo', '-n', '--', ...argv.map(quote)].
export function sshArgs({ hostId, address, port, user, dir }) {
const o = (x) => ['-o', x];
return [
'-F', '/dev/null', '-T', '-p', String(port),
// ssh's own messages go to the run dir's log, apart from the remote command's stderr (N3).
'-F', '/dev/null', '-T', '-E', join(dir, 'ssh.log'), '-p', String(port),
...o('BatchMode=yes'), ...o('IdentitiesOnly=yes'), ...o(`IdentityFile=${join(dir, 'id.pub')}`),
...o(`IdentityAgent=${join(dir, 'a.sock')}`),
...o('StrictHostKeyChecking=yes'), ...o(`UserKnownHostsFile=${join(dir, 'known_hosts')}`),
@@ -80,14 +81,28 @@ async function waitForSocket(agent, sock, limitMs) {
}
}
// Runs ssh-add with the key on its stdin; resolves on exit 0.
export function addKey(sock, text, lifetime) {
// Runs ssh-add with the key on its stdin; resolves on exit 0. A hung ssh-add is killed at
// `limitMs` (host-failed) or on abort (credential-unavailable), so a revoke or a stuck agent
// never holds the host busy (N2).
export function addKey(sock, text, lifetime, { limitMs = HOST_LIMITS.agentWaitMs, signal = null } = {}) {
return new Promise((resolve, reject) => {
if (signal?.aborted) return reject(new BusError('credential-unavailable'));
const add = start('ssh-add', ['-q', '-t', String(lifetime), '-'], { PATH, SSH_AUTH_SOCK: sock }, ['pipe', 'ignore', 'ignore']);
const stop = (code) => {
group(add, 'SIGKILL');
reject(new BusError(code));
};
const timer = setTimeout(() => stop('host-failed'), limitMs);
const abort = () => stop('credential-unavailable');
signal?.addEventListener('abort', abort, { once: true });
const settle = () => {
clearTimeout(timer);
signal?.removeEventListener('abort', abort);
};
add.stdin.on('error', () => {});
add.stdin.end(text);
add.on('error', () => reject(new BusError('host-failed')));
add.on('close', (code) => (code === 0 ? resolve() : reject(new BusError('host-failed'))));
add.on('error', () => (settle(), reject(new BusError('host-failed'))));
add.on('close', (code) => (settle(), code === 0 ? resolve() : reject(new BusError('host-failed'))));
});
}
@@ -160,7 +175,7 @@ export async function runHost({ root, hostId, entry, key, argv, stdin, timeout,
if (agent.pid) record(dir, agent.pid);
await waitForSocket(agent, sock, limits.agentWaitMs);
// D7 step 3: the agent drops the key at the call's limit plus 30 s even if nothing else runs.
await addKey(sock, key.text, timeout + 30);
await addKey(sock, key.text, timeout + 30, { limitMs: limits.agentWaitMs, signal });
if (signal?.aborted) deny('credential-unavailable');
const out = await runSsh(
[...sshArgs({ hostId, ...entry, dir }), remoteCommand(argv)],
@@ -174,7 +189,11 @@ export async function runHost({ root, hostId, entry, key, argv, stdin, timeout,
},
);
if (signal?.aborted) deny('credential-unavailable');
if (out.exit === 255 && out.stderr.includes('Host key verification failed')) deny('host-key-mismatch');
// Only ssh writes its log, so a remote command that prints the same text can't fake a pin
// refusal. On ssh's own exit 255 the agent still gets its message, after the remote stderr.
const log = out.exit === 255 && existsSync(join(dir, 'ssh.log')) ? readFileSync(join(dir, 'ssh.log'), 'utf8').slice(0, 4096) : '';
if (log.includes('Host key verification failed')) deny('host-key-mismatch');
if (log && !out.truncated.stderr) out.stderr += log;
return out;
} finally {
group(ssh, 'SIGKILL');
+3 -2
View File
@@ -37,8 +37,9 @@ test('REQ-WAIT-2: a business granting a hard wait at either class is refused; th
test('REQ-DEC-2: every hard wait goes to the human, cited or not, and the arbiter cannot resolve it', async t => {
const f = await fixture(t, { roles: { coder: { definition: 'coder',
authority: { withinRole: ['message.send', 'record.write', 'git.push.working'], crossRole: [] } } },
// Row 210: a host.sudo raise names a registered host and the argv it binds.
authority: { withinRole: ['message.send', 'record.write', 'git.push.working'], crossRole: [] },
hosts: { web1: { commands: [['systemctl', 'status']] } } } },
// Row 210: a host.sudo raise names a registered host the raiser is granted and an argv its grant allows.
hosts: { web1: { address: '10.0.0.5', port: 22, user: 'admin', hostKeys: ['ssh-ed25519 AAAA'] } } });
// Row 199: a git.push.remote raise binds the task's latest push, so the task has one.
f.broker.recordPush(f.coder, { task_ref: 'vikunja:1/2', project: 'stack', branch: 'feat', ref: 'work/vikunja-stack-2',
+65 -19
View File
@@ -4,7 +4,7 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { execFileSync, spawn } from 'node:child_process';
import { connect } from 'node:net';
import { connect, createServer } from 'node:net';
import { chmodSync, existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, rmSync, writeFileSync } from 'node:fs';
import { tmpdir, userInfo } from 'node:os';
import { join } from 'node:path';
@@ -19,7 +19,6 @@ import { businessDoc, rolesCopy, systemFor, writeJson } from '../../business/tes
const SSHD = '/usr/bin/sshd';
const REPO = fileURLToPath(new URL('../../../', import.meta.url));
const skip = existsSync(SSHD) ? false : 'no /usr/bin/sshd: the gate fails on this skip';
const USER = userInfo().username;
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
const keygen = (file, ...extra) => execFileSync('ssh-keygen', ['-q', '-t', 'ed25519', '-N', '', '-C', 'test', ...extra, '-f', file]);
@@ -59,6 +58,7 @@ const listening = (port) =>
// A scratch sshd. ForceCommand puts the fake sudo first on PATH and hands the command to /bin/sh, so
// the remote side parses the command line the way a real host's POSIX shell does.
async function sshd(t, dir) {
if (!existsSync(SSHD)) throw new Error(`${SSHD} is missing: the executor tests need it and fail, never skip`);
keygen(join(dir, 'hostkey'));
keygen(join(dir, 'otherhost'));
keygen(join(dir, 'user'));
@@ -69,6 +69,8 @@ async function sshd(t, dir) {
`#!/bin/sh\n'${process.execPath}' -e 'require("fs").appendFileSync(process.argv[1], JSON.stringify(process.argv.slice(2)) + "\\n")' '${dir}/argv.json' "$@"\nshift 2\nexec "$@"\n`,
);
chmodSync(join(dir, 'fake', 'sudo'), 0o755);
// A remote command that prints ssh's pin refusal text and exits 255 (N3).
writeFileSync(join(dir, 'fake', 'fail255'), '#!/bin/sh\necho "Host key verification failed." >&2\nexit 255\n', { mode: 0o755 });
writeFileSync(join(dir, 'wrap.sh'), `#!/bin/sh\nPATH=${dir}/fake:/usr/bin:/bin; export PATH\nexec /bin/sh -c "$SSH_ORIGINAL_COMMAND"\n`);
chmodSync(join(dir, 'wrap.sh'), 0o755);
for (let attempt = 0; attempt < 5; attempt++) {
@@ -92,7 +94,7 @@ async function sshd(t, dir) {
}
const GRANT = [['true'], ['false'], ['echo'], ['sleep'], ['head'], ['cat'], ['systemctl', 'status']];
async function hostFixture(t, { commands = GRANT, reviewerHosts, wrongHostKey = false } = {}) {
async function hostFixture(t, { commands = GRANT, reviewerHosts, wrongHostKey = false, coderWeb2 = false } = {}) {
const dir = mkdtempSync(join(tmpdir(), 'h210-'));
t.after(() => rmSync(dir, { recursive: true, force: true }));
const port = await sshd(t, dir);
@@ -108,7 +110,7 @@ async function hostFixture(t, { commands = GRANT, reviewerHosts, wrongHostKey =
const authority = { withinRole: ['message.send', 'record.write'], crossRole: [] };
const f = await fixture(t, {
roles: {
coder: { definition: 'coder', authority, hosts: { web1: { commands } } },
coder: { definition: 'coder', authority, hosts: { web1: { commands }, ...(coderWeb2 ? { web2: { commands } } : {}) } },
reviewer: { definition: 'reviewer', authority: { withinRole: ['message.send'], crossRole: [] }, ...(reviewerHosts ? { hosts: reviewerHosts } : {}) },
},
hosts: { web1: entry, web2: { ...entry } },
@@ -129,6 +131,11 @@ async function hostFixture(t, { commands = GRANT, reviewerHosts, wrongHostKey =
}
const refusesWith = async (p, code) => assert.rejects(p, (e) => (assert.equal(e.code, code), true));
test('the executor tests need sshd, ssh, ssh-agent, ssh-add, ssh-keygen and setpriv: a missing one fails, never skips', () => {
for (const bin of [SSHD, '/usr/bin/ssh', '/usr/bin/ssh-agent', '/usr/bin/ssh-add', '/usr/bin/ssh-keygen', '/usr/bin/setpriv'])
assert.ok(existsSync(bin), `${bin} is missing`);
});
test('T3: organization.set, limits.set and instances.set refuse hosts, and the writer never runs', (t) => {
const root = mkdtempSync(join(tmpdir(), 'h210-'));
const store = new Store(root);
@@ -160,7 +167,7 @@ test('T3: organization.set, limits.set and instances.set refuse hosts, and the w
assert.deepEqual(calls.map((c) => c[1]), ['limits.set', 'limits.set', 'instances.set']);
});
test('T4: the value check takes one unencrypted ed25519 key and nothing else', { skip }, () => {
test('T4: the value check takes one unencrypted ed25519 key and nothing else', () => {
const dir = mkdtempSync(join(tmpdir(), 'h210-'));
try {
keygen(join(dir, 'good'));
@@ -205,7 +212,7 @@ test('T4: the value check takes one unencrypted ed25519 key and nothing else', {
}
});
test('T5 to T8: registry, grant, argv shape and allowlist refusals', { skip }, async (t) => {
test('T5 to T8: registry, grant, argv shape and allowlist refusals', async (t) => {
const f = await hostFixture(t, { reviewerHosts: { web2: { commands: [['true']] } } });
await refusesWith(f.run({ host: 'web9', argv: ['true'] }), 'host-unknown');
await refusesWith(f.run({ host: 'web2', argv: ['true'] }), 'host-not-granted');
@@ -216,6 +223,10 @@ test('T5 to T8: registry, grant, argv shape and allowlist refusals', { skip }, a
await refusesWith(f.run({ host: 'web1', argv: ['systemctl'] }), 'command-not-granted');
await refusesWith(f.run({ host: 'web1', argv: ['systemctl', 'restart', 'nginx'] }), 'command-not-granted');
await refusesWith(f.run({ host: 'web1', argv: ['ls'] }), 'command-not-granted');
// N1: the raise refuses the same way, so the human is never asked about a call host.run refuses.
assert.throws(() => f.raise({ action: 'host.sudo', target: 'web2', argv: ['true'] }), /host-not-granted/);
assert.throws(() => f.raise({ action: 'host.sudo', target: 'web1', argv: ['ls'] }), /command-not-granted/);
assert.equal(f.store.get("SELECT count(*) AS n FROM decisions WHERE action='host.sudo'").n, 0);
// Granted prefixes run, a longer argv too.
await f.call(['systemctl', 'status']);
assert.equal((await f.call(['echo', 'hi', 'there'])).stdout, 'hi there\n');
@@ -224,8 +235,8 @@ test('T5 to T8: registry, grant, argv shape and allowlist refusals', { skip }, a
assert.ok(refused.includes('host-unknown') && refused.includes('command-not-granted'));
});
test('T9: under 1.7 every call needs a decision bound to its host, argv and stdin, spent once', { skip }, async (t) => {
const f = await hostFixture(t);
test('T9: under 1.7 every call needs a decision bound to its host, argv and stdin, spent once', async (t) => {
const f = await hostFixture(t, { coderWeb2: true });
await refusesWith(f.run({ host: 'web1', argv: ['true'] }), 'decision-required');
const other = await f.approve(['echo', 'a']);
await refusesWith(f.run({ host: 'web1', argv: ['echo', 'b'], decision: other }), 'decision-mismatch');
@@ -251,7 +262,7 @@ test('T9: under 1.7 every call needs a decision bound to its host, argv and stdi
assert.equal(f.store.get("SELECT class FROM decisions WHERE id=?", once).class, 'gated');
});
test('T10, T11: argv reaches sudo unchanged after sudo -n --', { skip }, async (t) => {
test('T10, T11: argv reaches sudo unchanged after sudo -n --', async (t) => {
const f = await hostFixture(t);
const argv = ['true', 'a b', "it's", '"q"', '$(touch x)', '`id`', 'a;b', 'line\nbreak', '*', '?[a]', '$HOME', '\\'];
assert.equal((await f.call(argv)).exit, 0);
@@ -261,7 +272,7 @@ test('T10, T11: argv reaches sudo unchanged after sudo -n --', { skip }, async (
assert.equal(remoteCommand(['a b']), "sudo -n -- 'a b'");
});
test('T12, T13: exit code passes through, the timeout kills the group, output is capped', { skip }, async (t) => {
test('T12, T13: exit code passes through, the timeout kills the group, output is capped', async (t) => {
const f = await hostFixture(t);
assert.equal((await f.call(['false'])).exit, 1);
const began = Date.now();
@@ -274,17 +285,30 @@ test('T12, T13: exit code passes through, the timeout kills the group, output is
assert.deepEqual(big.truncated, { stdout: true, stderr: false });
});
test('T14: a host key not in the registry refuses and nothing runs', { skip }, async (t) => {
test('T14: a host key not in the registry refuses and nothing runs', async (t) => {
const f = await hostFixture(t, { wrongHostKey: true });
await refusesWith(f.call(['true']), 'host-key-mismatch');
assert.deepEqual(f.argvSeen(), []);
});
test('T15: ssh reads no config file and no user known_hosts', { skip }, async (t) => {
test('N3: a remote exit 255 that prints the pin refusal text is the command result, and an ssh failure keeps its message', async (t) => {
const f = await hostFixture(t, { commands: [['fail255']] });
const out = await f.call(['fail255']);
assert.equal(out.exit, 255);
assert.match(out.stderr, /Host key verification failed/);
assert.deepEqual(f.argvSeen(), [['-n', '--', 'fail255']]);
// A closed port: ssh's message reaches the agent from the run dir's log.
const closed = await runHost({ root: f.root, hostId: 'web1', entry: { ...f.entry, port: 1 }, key: f.key, argv: ['true'], stdin: '', timeout: 10 });
assert.equal(closed.exit, 255);
assert.match(closed.stderr, /port 1: Connection refused/);
});
test('T15: ssh reads no config file and no user known_hosts', async (t) => {
// ssh finds ~/.ssh from the passwd entry, not $HOME, and the test may not write the real home, so
// the options are checked here and the call runs with an alias no user file holds (BUILD.md).
const args = sshArgs({ hostId: 'web1', address: '10.0.0.5', port: 22, user: 'admin', dir: '/r' });
assert.deepEqual(args.slice(0, 2), ['-F', '/dev/null']);
assert.deepEqual(args.slice(3, 5), ['-E', '/r/ssh.log']);
for (const o of ['StrictHostKeyChecking=yes', 'UserKnownHostsFile=/r/known_hosts', 'GlobalKnownHostsFile=/dev/null',
'HostKeyAlias=mosaic-host-web1', 'BatchMode=yes', 'IdentitiesOnly=yes', 'ForwardAgent=no', 'ProxyCommand=none', 'ControlMaster=no'])
assert.ok(args.includes(o), o);
@@ -293,7 +317,7 @@ test('T15: ssh reads no config file and no user known_hosts', { skip }, async (t
assert.equal((await f.call(['true'])).exit, 0);
});
test('T16, T17: key material in output or stdin refuses credential-leak, and no event holds it', { skip }, async (t) => {
test('T16, T17: key material in output or stdin refuses credential-leak, and no event holds it', async (t) => {
const f = await hostFixture(t, { commands: [['cat']] });
const line = f.key.text.split('\n')[2];
writeFileSync(join(f.dir, 'one-line'), line + '\n');
@@ -308,7 +332,7 @@ test('T16, T17: key material in output or stdin refuses credential-leak, and no
assert.ok(failed.every((e) => e.outcome === 'failed' && e.code === 'credential-leak'));
});
test('T18, T19, T20: one call at a time, no key in any argv or env, and nothing left after', { skip }, async (t) => {
test('T18, T19, T20: one call at a time, no key in any argv or env, and nothing left after', async (t) => {
const f = await hostFixture(t);
const first = f.call(['sleep', '3']);
assert.ok(await until(() => f.argvSeen().length === 1), 'fake sudo started');
@@ -332,7 +356,7 @@ test('T18, T19, T20: one call at a time, no key in any argv or env, and nothing
assert.ok(await until(() => procsNaming(f.root).length === 0, 2000));
});
test('T21: revoke kills the call in flight, and the next call refuses', { skip }, async (t) => {
test('T21: revoke kills the call in flight, and the next call refuses', async (t) => {
const f = await hostFixture(t);
const slow = f.call(['sleep', '30']);
assert.ok(await until(() => f.argvSeen().length === 1));
@@ -342,7 +366,7 @@ test('T21: revoke kills the call in flight, and the next call refuses', { skip }
assert.ok(await until(() => procsNaming(f.root).length === 0, 2000));
});
test('T22: events carry host, address, user and argv in clear, and sizes and hashes only', { skip }, async (t) => {
test('T22: events carry host, address, user and argv in clear, and sizes and hashes only', async (t) => {
const f = await hostFixture(t);
await f.call(['cat'], { stdin: 'secret-ish stdin' });
const [requested, completed] = f.events().filter((e) => e.action === 'host.sudo' && e.phase);
@@ -379,7 +403,7 @@ test('T25: no role definition lists host.sudo while it is a hard wait', () => {
}
});
test('T27: the bus child dying takes ssh and ssh-agent with it', { skip }, async (t) => {
test('T27: the bus child dying takes ssh and ssh-agent with it', async (t) => {
const f = await hostFixture(t);
// A separate bus child runs one call; the test SIGKILLs it while fake sudo sleeps.
const script = join(f.dir, 'child.mjs');
@@ -405,7 +429,7 @@ await executor.run({ business: 'demo', role: 'coder' }, 'web1', ${JSON.stringify
assert.deepEqual(readdirSync(join(f.dir, 'run2')), []);
});
test('T27: ssh-add -t drops the key from an agent the test keeps alive', { skip }, async (t) => {
test('T27: ssh-add -t drops the key from an agent the test keeps alive', async (t) => {
const dir = mkdtempSync(join(tmpdir(), 'h210-'));
t.after(() => rmSync(dir, { recursive: true, force: true }));
keygen(join(dir, 'k'));
@@ -426,7 +450,29 @@ test('T27: ssh-add -t drops the key from an agent the test keeps alive', { skip
assert.match(list(), /no identities/);
});
test('T27: the start sweep keeps a live bus dir and kills only a matching live process', { skip }, async (t) => {
// The test's own limit makes a hung addKey (M37a, M37b) fail in seconds, not at the batch timeout.
test('N2: a hung ssh-add is killed at its limit or on abort, and nothing is left running', { timeout: 10000 }, async (t) => {
const dir = mkdtempSync(join(tmpdir(), 'h210-'));
t.after(() => rmSync(dir, { recursive: true, force: true }));
keygen(join(dir, 'k'));
const text = readFileSync(join(dir, 'k'), 'utf8');
// An agent socket that accepts and never answers.
const closed = [];
const server = createServer((c) => c.resume().on('close', () => closed.push(Date.now())));
const sock = join(dir, 'a.sock');
await new Promise((r) => server.listen(sock, r));
t.after(() => server.close());
const began = Date.now();
await refusesWith(addKey(sock, text, 1, { limitMs: 300 }), 'host-failed');
assert.ok(Date.now() - began < 2000);
const controller = new AbortController();
setTimeout(() => controller.abort(), 100);
await refusesWith(addKey(sock, text, 1, { limitMs: 60000, signal: controller.signal }), 'credential-unavailable');
// Both ssh-add processes are gone: the socket saw each connection close.
assert.ok(await until(() => closed.length === 2), `closed ${closed.length}`);
});
test('T27: the start sweep keeps a live bus dir and kills only a matching live process', async (t) => {
const root = mkdtempSync(join(tmpdir(), 'h210-run-'));
t.after(() => rmSync(root, { recursive: true, force: true }));
chmodSync(root, 0o700);