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:
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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:` +
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user