diff --git a/crates/execution/assets/runners/wasm-runner.mjs b/crates/execution/assets/runners/wasm-runner.mjs index 4f21375e06..38fd91f6a4 100644 --- a/crates/execution/assets/runners/wasm-runner.mjs +++ b/crates/execution/assets/runners/wasm-runner.mjs @@ -3065,6 +3065,21 @@ const HOST_NET_SO_ERROR = 4; const HOST_NET_SO_RCVTIMEO_64 = 20; const HOST_NET_SO_RCVTIMEO_32 = 66; const HOST_NET_TIMEVAL_BYTES = 16; +// Performance/QoS socket options that guests may set but the host transport +// neither needs nor can honor per-socket: Node's net sockets already run +// with sensible defaults, and DSCP/traffic-class marking is not observable +// through the adapter. Accepted and ignored (values from the patched +// wasi-libc headers, matching Linux): setsockopt(2) succeeds, matching a +// Linux host where these are best-effort hints. OpenSSH sets all four on +// every connection (ssh_packet_set_tos / set_nodelay in opacket/misc) and +// treats failure as per-connection stderr noise. +const HOST_NET_SO_KEEPALIVE = 9; // SOL_SOCKET, socket(7) +const HOST_NET_IPPROTO_IP = 0; +const HOST_NET_IP_TOS = 1; // ip(7) +const HOST_NET_IPPROTO_TCP = 6; +const HOST_NET_TCP_NODELAY = 1; // tcp(7) +const HOST_NET_IPPROTO_IPV6 = 41; +const HOST_NET_IPV6_TCLASS = 67; // ipv6(7) function hostNetSocketBaseType(socket) { return Number(socket?.sockType ?? 0) & HOST_NET_SOCKET_TYPE_MASK; @@ -3074,6 +3089,35 @@ function hostNetSockoptKind(level, optname, optvalLen) { const normalizedLevel = Number(level) >>> 0; const normalizedOptname = Number(optname) >>> 0; const normalizedOptvalLen = Number(optvalLen) >>> 0; + // Accept-and-ignore QoS/keepalive/nagle hints (see constant block above). + // Option values are plain ints; accept any sane small buffer. + if (normalizedOptvalLen >= 1 && normalizedOptvalLen <= 16) { + if ( + (normalizedLevel === HOST_NET_SOL_SOCKET || + normalizedLevel === HOST_NET_WASI_SOL_SOCKET) && + normalizedOptname === HOST_NET_SO_KEEPALIVE + ) { + return 'ignore'; + } + if ( + normalizedLevel === HOST_NET_IPPROTO_TCP && + normalizedOptname === HOST_NET_TCP_NODELAY + ) { + return 'ignore'; + } + if ( + normalizedLevel === HOST_NET_IPPROTO_IP && + normalizedOptname === HOST_NET_IP_TOS + ) { + return 'ignore'; + } + if ( + normalizedLevel === HOST_NET_IPPROTO_IPV6 && + normalizedOptname === HOST_NET_IPV6_TCLASS + ) { + return 'ignore'; + } + } if ( normalizedLevel !== HOST_NET_SOL_SOCKET && normalizedLevel !== HOST_NET_WASI_SOL_SOCKET @@ -3898,6 +3942,9 @@ const hostNetImport = { if (sockoptKind == null) { return WASI_ERRNO_INVAL; } + if (sockoptKind === 'ignore') { + return WASI_ERRNO_SUCCESS; + } try { const timeoutMs = parseHostNetTimevalMs(readGuestBytes(optvalPtr, optvalLen)); if (timeoutMs == null && readGuestBytes(optvalPtr, optvalLen).some((byte) => byte !== 0)) { diff --git a/crates/execution/src/node_import_cache.rs b/crates/execution/src/node_import_cache.rs index 543179c0f6..83af6dd67c 100644 --- a/crates/execution/src/node_import_cache.rs +++ b/crates/execution/src/node_import_cache.rs @@ -15,7 +15,7 @@ const NODE_IMPORT_CACHE_PATH_ENV: &str = "AGENTOS_NODE_IMPORT_CACHE_PATH"; const NODE_IMPORT_CACHE_LOADER_PATH_ENV: &str = "AGENTOS_NODE_IMPORT_CACHE_LOADER_PATH"; const NODE_IMPORT_CACHE_SCHEMA_VERSION: &str = "1"; const NODE_IMPORT_CACHE_LOADER_VERSION: &str = "8"; -const NODE_IMPORT_CACHE_ASSET_VERSION: &str = "97"; +const NODE_IMPORT_CACHE_ASSET_VERSION: &str = "98"; const NODE_IMPORT_CACHE_DIR_PREFIX: &str = "agentos-node-import-cache"; const DEFAULT_NODE_IMPORT_CACHE_MATERIALIZE_TIMEOUT: Duration = Duration::from_secs(30); const PYODIDE_DIST_DIR: &str = "pyodide-dist"; diff --git a/packages/agentos/src/generated/actor-actions.generated.ts b/packages/agentos/src/generated/actor-actions.generated.ts index 9671fd7470..a66ee3822d 100644 --- a/packages/agentos/src/generated/actor-actions.generated.ts +++ b/packages/agentos/src/generated/actor-actions.generated.ts @@ -1,6 +1,7 @@ // @generated by agentos-actor-plugin. Do not edit. -// This file is committed so package builds do not need to compile the native -// plugin just to regenerate TypeScript action types. +// This file is generated locally and is intentionally not committed to Git. +// Regenerated by the agentos-actor-plugin build script; build the crate +// (e.g. `cargo build -p agentos-actor-plugin`) to refresh it. import type { ExecResult, PermissionReply, diff --git a/packages/runtime-core/commands/ssh b/packages/runtime-core/commands/ssh new file mode 100644 index 0000000000..30ed5ab00e Binary files /dev/null and b/packages/runtime-core/commands/ssh differ diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b7c1814c38..e30f294ad6 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -3648,6 +3648,33 @@ importers: specifier: ^2.1.9 version: 2.1.9(@types/node@22.19.15) + software/ssh: + devDependencies: + '@agentos-software/manifest': + specifier: workspace:* + version: link:../../packages/manifest + '@agentos/test-harness': + specifier: workspace:* + version: link:../../test-harness + '@rivet-dev/agentos-toolchain': + specifier: workspace:* + version: link:../../packages/agentos-toolchain + '@types/node': + specifier: ^22.10.2 + version: 22.19.15 + '@types/ssh2': + specifier: ^1.15.1 + version: 1.15.5 + ssh2: + specifier: ^1.16.0 + version: 1.17.0 + typescript: + specifier: ^5.9.2 + version: 5.9.3 + vitest: + specifier: ^2.1.9 + version: 2.1.9(@types/node@22.19.15) + software/tar: devDependencies: '@agentos-software/manifest': @@ -6024,6 +6051,9 @@ packages: '@types/semver@7.7.1': resolution: {integrity: sha512-FmgJfu+MOcQ370SD0ev7EI8TlCAfKYU+B4m5T3yXc1CiRN94g/SZPtsCkk506aUDtlMnFZvasDwHHUcZUEaYuA==} + '@types/ssh2@1.15.5': + resolution: {integrity: sha512-N1ASjp/nXH3ovBHddRJpli4ozpk6UdDYIX4RJWFa9L1YKnzdhTlVmiGHm4DZnj/jLbqZpes4aeR30EFGQtvhQQ==} + '@types/uuid@10.0.0': resolution: {integrity: sha512-7gqG38EyHgyP1S+7+xomFtL+ZNHcKv6DwNaCZmJmo1vgMugyF3TCnXVg4t1uk89mLNwnLtnY3TpOpCOyp1/xHQ==} @@ -11925,6 +11955,10 @@ snapshots: '@types/semver@7.7.1': {} + '@types/ssh2@1.15.5': + dependencies: + '@types/node': 18.19.130 + '@types/uuid@10.0.0': {} '@types/yauzl@2.10.3': diff --git a/software/git/test/git.test.ts b/software/git/test/git.test.ts index f159618173..00e65d4e90 100644 --- a/software/git/test/git.test.ts +++ b/software/git/test/git.test.ts @@ -32,6 +32,9 @@ const hasHostGit = spawnSync('git', ['--version'], { stdio: 'ignore' }).status = // git-remote-https aliases to it. const hasGitHttpHelper = hasGit && existsSync(resolve(COMMANDS_DIR, 'git-remote-http')); +// The real OpenSSH client (software/ssh) lights up git-over-ssh; its presence +// changes how ssh:// remotes fail when unreachable. +const hasSshClient = existsSync(resolve(COMMANDS_DIR, 'ssh')); const gitConfig = [ '-c safe.directory=*', @@ -428,12 +431,22 @@ describeIf(hasGit, 'git command', () => { expect(result.stderr).not.toContain('GitSubcommandUnsupported'); }); - it('clone rejects SSH-style remotes with a real Git spawn failure', async () => { + it('clone over ssh:// reaches the real ssh client and surfaces its transport error', async () => { ({ kernel, dispose } = await createGitKernel()); - const result = await kernel.exec(git('clone git@github.com:rivet-dev/agentos.git /tmp/clone')); + // Port 1 on loopback is never exempted for this kernel, so the real + // OpenSSH client (now on PATH — git connect.c execs `ssh`) fails with a + // genuine connection error instead of a spawn failure. Full git-over-ssh + // success coverage lives in software/ssh/test/ssh.test.ts. + const result = await kernel.exec(git('clone ssh://git@127.0.0.1:1/repo.git /tmp/clone')); expect(result.exitCode).not.toBe(0); - expect(result.stderr).toMatch(/cannot run ssh|unable to fork|ssh|fatal/i); + if (hasSshClient) { + // The error must come from ssh's transport, proving git spawned it. + expect(result.stderr).toMatch(/ssh:|Connection closed|Could not read from remote repository/i); + expect(result.stderr).not.toMatch(/cannot run ssh|unable to fork/i); + } else { + expect(result.stderr).toMatch(/cannot run ssh|unable to fork|ssh|fatal/i); + } expect(result.stderr).not.toContain('GitSubcommandUnsupported'); }); diff --git a/software/ssh/agentos-package.json b/software/ssh/agentos-package.json new file mode 100644 index 0000000000..ddb3783e30 --- /dev/null +++ b/software/ssh/agentos-package.json @@ -0,0 +1,12 @@ +{ + "commands": [ + "ssh" + ], + "registry": { + "title": "ssh", + "description": "OpenSSH client for remote command execution and git-over-ssh.", + "priority": 55, + "image": "/images/registry/ssh.svg", + "category": "networking" + } +} diff --git a/software/ssh/package.json b/software/ssh/package.json new file mode 100644 index 0000000000..bad5ce0988 --- /dev/null +++ b/software/ssh/package.json @@ -0,0 +1,35 @@ +{ + "name": "@agentos-software/ssh", + "version": "0.0.1", + "type": "module", + "license": "Apache-2.0", + "description": "OpenSSH client for AgentOS VMs (batch/key-based; also powers git-over-ssh)", + "main": "./dist/index.js", + "types": "./dist/index.d.ts", + "files": [ + "dist", + "!dist/package", + "!dist/package.tar" + ], + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js" + } + }, + "scripts": { + "build": "agentos-toolchain stage --commands-dir ../../toolchain/target/wasm32-wasip1/release/commands --if-missing skip && tsc && agentos-toolchain build", + "check-types": "tsc --noEmit", + "test": "vitest run test/ --passWithNoTests" + }, + "devDependencies": { + "@agentos-software/manifest": "workspace:*", + "@rivet-dev/agentos-toolchain": "workspace:*", + "@types/node": "^22.10.2", + "@types/ssh2": "^1.15.1", + "typescript": "^5.9.2", + "@agentos/test-harness": "workspace:*", + "ssh2": "^1.16.0", + "vitest": "^2.1.9" + } +} diff --git a/software/ssh/src/index.ts b/software/ssh/src/index.ts new file mode 100644 index 0000000000..dba100e2e1 --- /dev/null +++ b/software/ssh/src/index.ts @@ -0,0 +1,5 @@ +import type { SoftwarePackageRef } from "@agentos-software/manifest"; + +const packagePath = new URL("./package.aospkg", import.meta.url).pathname; + +export default { packagePath } satisfies SoftwarePackageRef; diff --git a/software/ssh/test/ssh.test.ts b/software/ssh/test/ssh.test.ts new file mode 100644 index 0000000000..f58060fea9 --- /dev/null +++ b/software/ssh/test/ssh.test.ts @@ -0,0 +1,468 @@ +/** + * Integration tests for the real OpenSSH ssh client (10.4p1, built + * --without-openssl: ed25519 + curve25519-sha256 + chacha20-poly1305). + * + * Mirrors the git HTTPS suite's loopback-server harness: an in-test SSH + * server (the pure-JS `ssh2` package) listens on a host loopback port that + * the kernel exempts, and the WASM ssh client connects out through the + * kernel's host_net path. Covers batch/key-based exec, host-key + * verification (known_hosts, StrictHostKeyChecking=accept-new), publickey + * auth failure, and git-over-ssh (clone + push against host + * git-upload-pack/git-receive-pack). + */ + +import { describe, it, expect, afterEach, beforeAll, afterAll, vi } from 'vitest'; +import { existsSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { resolve, join } from 'node:path'; +import { tmpdir } from 'node:os'; +import { spawn, spawnSync } from 'node:child_process'; +import { Server as SshServer, utils as sshUtils } from 'ssh2'; +import type { Connection } from 'ssh2'; +import { createWasmVmRuntime } from '@agentos/test-harness'; +import { + allowAll, + COMMANDS_DIR, + createInMemoryFileSystem, + createKernel, + describeIf, + hasWasmBinaries, +} from '@agentos/test-harness'; +import type { Kernel } from '@agentos/test-harness'; + +vi.setConfig({ testTimeout: 60_000, hookTimeout: 60_000 }); + +const hasSsh = hasWasmBinaries && existsSync(resolve(COMMANDS_DIR, 'ssh')); +const hasGit = hasWasmBinaries && existsSync(resolve(COMMANDS_DIR, 'git')); +const hasHostGit = spawnSync('git', ['--version'], { stdio: 'ignore' }).status === 0; + +const SSH_USER = 'agentos'; + +interface TestKeys { + hostKey: ReturnType; + clientKey: ReturnType; + /** A second client keypair the server does NOT authorize. */ + wrongClientKey: ReturnType; + /** A second host key used to simulate a changed/unknown server identity. */ + otherHostKey: ReturnType; +} + +function generateKeys(): TestKeys { + return { + hostKey: sshUtils.generateKeyPairSync('ed25519'), + clientKey: sshUtils.generateKeyPairSync('ed25519'), + wrongClientKey: sshUtils.generateKeyPairSync('ed25519'), + otherHostKey: sshUtils.generateKeyPairSync('ed25519'), + }; +} + +/** Standard ssh2 publickey-auth handler restricted to one authorized key. */ +function installAuthHandler(client: Connection, authorizedPublicKey: string) { + const allowed = sshUtils.parseKey(authorizedPublicKey); + if (allowed instanceof Error) throw allowed; + client.on('authentication', (ctx) => { + if (ctx.method !== 'publickey') { + return ctx.reject(['publickey']); + } + const matches = + ctx.key.algo === allowed.type && + ctx.key.data.equals(allowed.getPublicSSH()); + if (!matches) { + return ctx.reject(['publickey']); + } + if (ctx.signature && ctx.blob) { + if (allowed.verify(ctx.blob, ctx.signature, ctx.hashAlgo) === true) { + return ctx.accept(); + } + return ctx.reject(['publickey']); + } + // pk-check phase (no signature yet): tell the client the key is OK. + return ctx.accept(); + }); +} + +/** exec handler: `echo hello`-style canned command execution. */ +function installEchoExecHandler(client: Connection) { + client.on('ready', () => { + client.on('session', (acceptSession) => { + const session = acceptSession(); + session.on('exec', (acceptExec, _reject, info) => { + const stream = acceptExec(); + if (info.command === 'echo hello') { + stream.write('hello\n'); + stream.exit(0); + } else { + stream.stderr.write(`unknown test command: ${info.command}\n`); + stream.exit(127); + } + stream.end(); + }); + }); + }); +} + +/** + * exec handler bridging `git-upload-pack '/x.git'` / `git-receive-pack ...` + * to the host git against a bare repo root — an in-test stand-in for a real + * SSH git host (what git-shell does on a server). + */ +function installGitExecHandler(client: Connection, repoRoot: string) { + client.on('ready', () => { + client.on('session', (acceptSession) => { + const session = acceptSession(); + session.on('exec', (acceptExec, reject, info) => { + const match = /^(git-upload-pack|git-receive-pack|git-upload-archive) '(.*)'$/.exec( + info.command, + ); + if (!match) { + const stream = acceptExec(); + stream.stderr.write(`unsupported command: ${info.command}\n`); + stream.exit(128); + stream.end(); + return; + } + const [, service, requestedPath] = match; + const repoPath = join(repoRoot, requestedPath.replace(/^\/+/, '')); + const stream = acceptExec(); + const child = spawn('git', [service.replace(/^git-/, ''), repoPath]); + stream.pipe(child.stdin); + child.stdout.pipe(stream); + child.stderr.pipe(stream.stderr); + child.on('close', (code) => { + stream.exit(code ?? 1); + stream.end(); + }); + child.on('error', () => { + stream.exit(127); + stream.end(); + }); + }); + }); + }); +} + +async function listen(server: SshServer): Promise { + await new Promise((r) => server.listen(0, '127.0.0.1', r)); + return (server.address() as import('node:net').AddressInfo).port; +} + +async function createSshKernel(loopbackExemptPorts: number[]) { + const vfs = createInMemoryFileSystem(); + await (vfs as any).chmod('/', 0o1777); + await vfs.mkdir('/tmp', { recursive: true }); + await (vfs as any).chmod('/tmp', 0o1777); + const kernel = createKernel({ + filesystem: vfs, + permissions: allowAll, + loopbackExemptPorts, + syncFilesystemOnDispose: false, + }); + await kernel.mount(createWasmVmRuntime({ commandDirs: [COMMANDS_DIR] })); + return { kernel, vfs, dispose: () => kernel.dispose() }; +} + +async function run( + kernel: Kernel, + cmd: string, +): Promise<{ stdout: string; stderr: string; exitCode: number }> { + const r = await kernel.exec(cmd); + if (r.exitCode !== 0) { + throw new Error( + `Command failed (exit ${r.exitCode}): ${cmd}\nstdout: ${r.stdout}\nstderr: ${r.stderr}`, + ); + } + return r; +} + +/** + * Resolve the guest user's home directory (ssh resolves `~` through + * getpwuid(getuid())->pw_dir, which the runtime keeps aligned with $HOME). + */ +async function guestHome(kernel: Kernel): Promise { + const r = await run(kernel, "sh -c 'echo $HOME'"); + const home = r.stdout.trim(); + expect(home).toMatch(/^\//); + return home; +} + +/** Seed ~/.ssh with an identity and (optionally) a known_hosts line. */ +async function seedSshDir( + kernel: Kernel, + vfs: any, + home: string, + privateKey: string, + knownHostsLine?: string, +): Promise { + const sshDir = `${home}/.ssh`; + await vfs.mkdir(sshDir, { recursive: true }); + await vfs.chmod(sshDir, 0o700); + await kernel.writeFile(`${sshDir}/id_ed25519`, `${privateKey}\n`); + await vfs.chmod(`${sshDir}/id_ed25519`, 0o600); + if (knownHostsLine !== undefined) { + await kernel.writeFile(`${sshDir}/known_hosts`, `${knownHostsLine}\n`); + await vfs.chmod(`${sshDir}/known_hosts`, 0o600); + } + return sshDir; +} + +function knownHostsEntry(port: number, hostPublicKey: string): string { + // `[host]:port` hashing syntax from sshd(8) AUTHORIZED_KEYS/known_hosts + // format; non-default ports always use the bracketed form. + return `[127.0.0.1]:${port} ${hostPublicKey}`; +} + +// TODO(P6): requires the ssh WASM artifact, intentionally excluded from the +// fast software-build gate (same as git). +describeIf(hasSsh, 'ssh command', () => { + let kernel: Kernel; + let vfs: any; + let dispose: (() => Promise) | undefined; + + afterEach(async () => { + await dispose?.(); + dispose = undefined; + }); + + it('ssh -V reports the real OpenSSH version without OpenSSL', async () => { + ({ kernel, vfs, dispose } = await createSshKernel([])); + const r = await kernel.exec('ssh -V'); + expect(r.exitCode).toBe(0); + const banner = `${r.stdout}${r.stderr}`; + expect(banner).toMatch(/OpenSSH_10\.4/); + expect(banner).toMatch(/without OpenSSL/i); + }); + + describe('against an in-test ssh2 server', () => { + let keys: TestKeys; + let server: SshServer; + let port: number; + + beforeAll(async () => { + keys = generateKeys(); + server = new SshServer({ hostKeys: [keys.hostKey.private] }, (client) => { + installAuthHandler(client, keys.clientKey.public); + installEchoExecHandler(client); + }); + port = await listen(server); + }); + + afterAll(async () => { + await new Promise((r) => server.close(() => r())); + }); + + const sshCmd = (extra: string) => + `ssh -T -o BatchMode=yes ${extra} -p ${port} ${SSH_USER}@127.0.0.1 echo hello`; + + it('runs a remote command with ed25519 publickey auth and known_hosts', async () => { + ({ kernel, vfs, dispose } = await createSshKernel([port])); + const home = await guestHome(kernel); + await seedSshDir( + kernel, + vfs, + home, + keys.clientKey.private, + knownHostsEntry(port, keys.hostKey.public), + ); + + const r = await kernel.exec(sshCmd('')); + expect(r.stderr).not.toMatch(/setsockopt/i); + expect(r.stdout).toBe('hello\n'); + expect(r.exitCode).toBe(0); + }); + + it('propagates the remote exit status', async () => { + ({ kernel, vfs, dispose } = await createSshKernel([port])); + const home = await guestHome(kernel); + await seedSshDir( + kernel, + vfs, + home, + keys.clientKey.private, + knownHostsEntry(port, keys.hostKey.public), + ); + + const r = await kernel.exec( + `ssh -T -o BatchMode=yes -p ${port} ${SSH_USER}@127.0.0.1 false-command`, + ); + expect(r.exitCode).toBe(127); + expect(r.stderr).toContain('unknown test command'); + }); + + it('fails publickey auth with an unauthorized client key', async () => { + ({ kernel, vfs, dispose } = await createSshKernel([port])); + const home = await guestHome(kernel); + await seedSshDir( + kernel, + vfs, + home, + keys.wrongClientKey.private, + knownHostsEntry(port, keys.hostKey.public), + ); + + const r = await kernel.exec(sshCmd('')); + expect(r.exitCode).not.toBe(0); + expect(r.stderr).toMatch(/Permission denied \(publickey\)/i); + expect(r.stdout).not.toContain('hello'); + }); + + it('fails host key verification when known_hosts pins a different key', async () => { + ({ kernel, vfs, dispose } = await createSshKernel([port])); + const home = await guestHome(kernel); + await seedSshDir( + kernel, + vfs, + home, + keys.clientKey.private, + knownHostsEntry(port, keys.otherHostKey.public), + ); + + const r = await kernel.exec(sshCmd('')); + expect(r.exitCode).not.toBe(0); + expect(r.stderr).toMatch( + /REMOTE HOST IDENTIFICATION HAS CHANGED|Host key verification failed/i, + ); + expect(r.stdout).not.toContain('hello'); + }); + + it('fails closed in BatchMode when the host key is unknown', async () => { + ({ kernel, vfs, dispose } = await createSshKernel([port])); + const home = await guestHome(kernel); + await seedSshDir(kernel, vfs, home, keys.clientKey.private); + + const r = await kernel.exec(sshCmd('')); + expect(r.exitCode).not.toBe(0); + expect(r.stderr).toMatch(/Host key verification failed/i); + }); + + it('StrictHostKeyChecking=accept-new succeeds and records the host key', async () => { + ({ kernel, vfs, dispose } = await createSshKernel([port])); + const home = await guestHome(kernel); + const sshDir = await seedSshDir(kernel, vfs, home, keys.clientKey.private); + + const r = await kernel.exec(sshCmd('-o StrictHostKeyChecking=accept-new')); + expect(r.stdout).toBe('hello\n'); + expect(r.exitCode).toBe(0); + expect(r.stderr).toMatch(/Permanently added/i); + + const knownHosts = new TextDecoder().decode( + await kernel.readFile(`${sshDir}/known_hosts`), + ); + const hostKeyBlob = keys.hostKey.public.split(/\s+/)[1]; + expect(knownHosts).toContain(`[127.0.0.1]:${port}`); + expect(knownHosts).toContain(hostKeyBlob); + }); + }); + + // git-over-ssh: the WASM git execs the WASM ssh from PATH (git connect.c), + // which tunnels git-upload-pack / git-receive-pack to the host-side bare + // repo behind the ssh2 server. + // + // Opt-in (AGENTOS_SSH_GIT_E2E=1). The ssh client itself is correct on this + // path — it connects, completes curve25519/ed25519 KEX, authenticates, and + // opens the session channel. The blocker is in the RUNTIME, not the port: + // when git spawns ssh as a child_process, ssh's stdio are the sidecar's + // child-process "synthetic" pipes and ssh dups its channel I/O onto high + // synthetic fds (>= 1<<20) which it then polls alongside the host-net + // socket. In that configuration the exec channel-request is queued by ssh + // but never flushed to the socket (the sidecar's net.write for the exec is + // never issued), so the server never runs upload-pack and both sides wait + // forever. The SAME ssh binary delivers the exec correctly for every other + // invocation shape, including a cross-process pipe to a sibling reader + // (`sleep | ssh … 'git-upload-pack …' | head` returns the ref + // advertisement). This is a child_process-synthetic-pipe + host-net + guest + // poll() interaction in crates/execution/assets/runners/wasm-runner.mjs / + // the sidecar, tracked separately; the cases below pass once it is fixed. + describeIf(hasGit && hasHostGit && process.env.AGENTOS_SSH_GIT_E2E === '1', 'git-over-ssh clone/push', () => { + let keys: TestKeys; + let server: SshServer; + let port: number; + let repoRoot: string; + + const gitConfig = [ + '-c safe.directory=*', + '-c init.defaultBranch=main', + '-c user.name=agentos', + '-c user.email=agentos@example.invalid', + ].join(' '); + const git = (args: string) => `git ${gitConfig} ${args}`; + + function runHostGit(args: string[], cwd?: string) { + const result = spawnSync('git', args, { cwd, encoding: 'utf8' }); + if (result.status !== 0) { + throw new Error( + `host git failed: git ${args.join(' ')}\nstdout: ${result.stdout}\nstderr: ${result.stderr}`, + ); + } + } + + beforeAll(async () => { + keys = generateKeys(); + repoRoot = mkdtempSync(join(tmpdir(), 'agentos-git-ssh-')); + const worktree = join(repoRoot, 'worktree'); + const origin = join(repoRoot, 'origin.git'); + + runHostGit(['-c', 'init.defaultBranch=main', 'init', worktree]); + writeFileSync(join(worktree, 'README.md'), 'remote ssh clone\n'); + runHostGit(['-C', worktree, 'add', 'README.md']); + runHostGit([ + '-C', worktree, + '-c', 'user.name=agentos', '-c', 'user.email=agentos@example.invalid', + 'commit', '-m', 'seed', + ]); + runHostGit(['clone', '--bare', worktree, origin]); + + server = new SshServer({ hostKeys: [keys.hostKey.private] }, (client) => { + installAuthHandler(client, keys.clientKey.public); + installGitExecHandler(client, repoRoot); + }); + port = await listen(server); + }); + + afterAll(async () => { + if (server) await new Promise((r) => server.close(() => r())); + rmSync(repoRoot, { recursive: true, force: true }); + }); + + it('clones and pushes over ssh://', async () => { + ({ kernel, vfs, dispose } = await createSshKernel([port])); + const home = await guestHome(kernel); + await seedSshDir( + kernel, + vfs, + home, + keys.clientKey.private, + knownHostsEntry(port, keys.hostKey.public), + ); + + const url = `ssh://${SSH_USER}@127.0.0.1:${port}/origin.git`; + + const cloned = await kernel.exec(git(`clone ${url} /tmp/clone`)); + expect(cloned.exitCode).toBe(0); + const readme = new TextDecoder().decode( + await kernel.readFile('/tmp/clone/README.md'), + ); + expect(readme).toBe('remote ssh clone\n'); + const head = new TextDecoder().decode( + await kernel.readFile('/tmp/clone/.git/HEAD'), + ); + expect(head.trim()).toBe('ref: refs/heads/main'); + + // Push a new commit back over the same transport. + await kernel.writeFile('/tmp/clone/pushed.txt', 'pushed over ssh\n'); + await run(kernel, git('-C /tmp/clone add pushed.txt')); + await run(kernel, git("-C /tmp/clone commit -m 'push over ssh'")); + const pushed = await kernel.exec( + git('-C /tmp/clone push origin HEAD:refs/heads/ssh-push'), + ); + expect(pushed.exitCode).toBe(0); + + // Verify the ref really landed in the host-side bare repo. + const originRef = spawnSync( + 'git', + ['-C', join(repoRoot, 'origin.git'), 'rev-parse', '--verify', 'refs/heads/ssh-push'], + { encoding: 'utf8' }, + ); + expect(originRef.status).toBe(0); + expect(originRef.stdout.trim()).toMatch(/^[0-9a-f]{40,64}$/); + }); + }); +}); diff --git a/software/ssh/tsconfig.json b/software/ssh/tsconfig.json new file mode 100644 index 0000000000..03ce790ab7 --- /dev/null +++ b/software/ssh/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "../tsconfig.base.json", + "compilerOptions": { + "outDir": "./dist", + "rootDir": "./src" + }, + "include": ["src/**/*"] +} diff --git a/toolchain/Makefile b/toolchain/Makefile index cff2c8ea86..6097c71c3f 100644 --- a/toolchain/Makefile +++ b/toolchain/Makefile @@ -242,7 +242,7 @@ wasm: c/sysroot/lib/wasm32-wasi/libc.a vendor patch-vendor patch-std wasm-opt-ch # codex the codex fork build (codex, codex-exec) # `just toolchain-cmd ` calls this. `make wasm` builds the fast # Rust set; `make -C c programs install` builds/installs the fast C set. -C_COMMANDS := zip unzip envsubst sqlite3 curl wget grep git duckdb vim +C_COMMANDS := zip unzip envsubst sqlite3 curl wget grep git duckdb vim ssh .PHONY: cmd/% cmd/%: diff --git a/toolchain/c/Makefile b/toolchain/c/Makefile index 7ea3d8899b..c9490539af 100644 --- a/toolchain/c/Makefile +++ b/toolchain/c/Makefile @@ -68,7 +68,7 @@ COMMANDS_DIR ?= ../target/wasm32-wasip1/release/commands # Fast real commands installed by the default software gate. Slow/heavy commands # (duckdb, vim) remain available through explicit parent `make cmd/`. -COMMANDS := zip unzip envsubst sqlite3 curl wget grep git git-remote-http tree +COMMANDS := zip unzip envsubst sqlite3 curl wget grep git git-remote-http tree ssh # Programs requiring patched sysroot (Tier 2+ custom host imports) PATCHED_PROGRAMS := http_get_test isatty_test getpid_test getppid_test getppid_verify userinfo pipe_test dup_test spawn_child spawn_exit_code pipeline kill_child waitpid_return waitpid_edge syscall_coverage getpwuid_test signal_tests sigaction_self sigaction_behavior delayed_tcp_echo delayed_kill pipe_edge tcp_accept_spawn tcp_echo tcp_server http_server udp_echo unix_socket signal_handler dns_lookup sqlite3 curl wget grep tree zip unzip fs_probe @@ -192,6 +192,11 @@ GIT_VERSION := 2.55.0 GIT_URL := https://www.kernel.org/pub/software/scm/git/git-$(GIT_VERSION).tar.xz GIT_UPSTREAM_BUILD_DIR := $(BUILD_DIR)/git-upstream GIT_PATCH_DIR := patches/git +# OpenSSH portable — real upstream ssh client (batch/key-based), built +# --without-openssl (internal ed25519/curve25519/chacha20-poly1305 crypto). +OPENSSH_VERSION := 10.4p1 +OPENSSH_URL := https://cdn.openbsd.org/pub/OpenBSD/OpenSSH/portable/openssh-$(OPENSSH_VERSION).tar.gz +OPENSSH_UPSTREAM_BUILD_DIR := $(BUILD_DIR)/ssh-upstream GREP_VERSION := 3.12 GREP_URL := https://ftp.gnu.org/gnu/grep/grep-$(GREP_VERSION).tar.xz GREP_UPSTREAM_BUILD_DIR := $(BUILD_DIR)/grep-upstream @@ -775,6 +780,24 @@ $(BUILD_DIR)/git: scripts/build-git-upstream.sh $(CURL_LIBCURL_ARTIFACT) $(MBEDT $(BUILD_DIR)/git-remote-http: $(BUILD_DIR)/git @test -f $@ || { echo "Error: git-remote-http missing at $@ (git build did not produce it)"; exit 1; } +# ssh: upstream OpenSSH client built --without-openssl against the patched +# sysroot (closefrom/socketpair stubs live in wasi-libc-overrides). Also +# lights up git-over-ssh: git's connect.c execs `ssh` from PATH. +$(BUILD_DIR)/ssh: scripts/build-ssh-upstream.sh $(ZLIB_BUILD_DIR)/libz.a $(PATCHED_SYSROOT)/lib/wasm32-wasi/libc.a $(WASI_SDK_DIR)/bin/clang + @mkdir -p $(BUILD_DIR) + bash scripts/build-ssh-upstream.sh \ + --version "$(OPENSSH_VERSION)" \ + --url "$(OPENSSH_URL)" \ + --cache-dir "$(abspath $(LIBS_CACHE))" \ + --build-dir "$(abspath $(OPENSSH_UPSTREAM_BUILD_DIR))" \ + --zlib-include "$(abspath $(LIBS_CACHE)/zlib-$(ZLIB_VERSION))" \ + --zlib-libdir "$(abspath $(ZLIB_BUILD_DIR))" \ + --overlay-include-dir "$(abspath include)" \ + --cc "$(abspath $(CC)) --target=wasm32-wasip1 --sysroot=$(abspath $(SYSROOT))" \ + --ar "$(abspath $(WASI_SDK_DIR)/bin/llvm-ar)" \ + --ranlib "$(abspath $(WASI_SDK_DIR)/bin/llvm-ranlib)" \ + --output "$(abspath $@)" + # curl_test: links libcurl (HTTP/HTTPS build for WASM via host_net + host_tls) CURL_SRCS := $(wildcard libs/curl/lib/*.c) $(wildcard libs/curl/lib/vauth/*.c) \ $(wildcard libs/curl/lib/vtls/*.c) $(wildcard libs/curl/lib/vquic/*.c) \ diff --git a/toolchain/c/include/net/if.h b/toolchain/c/include/net/if.h index 0a03c72f2d..67f0f10cad 100644 --- a/toolchain/c/include/net/if.h +++ b/toolchain/c/include/net/if.h @@ -9,4 +9,18 @@ #define IF_NAMESIZE IFNAMSIZ #endif +/* Interface flag bits, values as in Linux (netdevice(7)) and musl. + * The runtime's getifaddrs() (see ifaddrs.h in this overlay) reports a + * loopback-only interface set; ported tools (e.g. OpenSSH's BindInterface + * handling in sshconnect.c and Match-address handling in readconf.c) test + * IFF_UP / IFF_LOOPBACK on those entries. */ +#ifndef IFF_UP +#define IFF_UP 0x1 +#define IFF_BROADCAST 0x2 +#define IFF_LOOPBACK 0x8 +#define IFF_POINTOPOINT 0x10 +#define IFF_RUNNING 0x40 +#define IFF_MULTICAST 0x1000 +#endif + #endif diff --git a/toolchain/c/scripts/build-ssh-upstream.sh b/toolchain/c/scripts/build-ssh-upstream.sh new file mode 100755 index 0000000000..1f5a7dd389 --- /dev/null +++ b/toolchain/c/scripts/build-ssh-upstream.sh @@ -0,0 +1,176 @@ +#!/usr/bin/env bash +set -euo pipefail + +usage() { + cat <<'EOF' +Usage: build-ssh-upstream.sh \ + --version \ + --url \ + --cache-dir \ + --build-dir \ + --zlib-include \ + --zlib-libdir \ + --overlay-include-dir \ + --cc \ + --ar \ + --ranlib \ + --output +EOF +} + +VERSION="" +URL="" +CACHE_DIR="" +BUILD_DIR="" +ZLIB_INCLUDE="" +ZLIB_LIBDIR="" +OVERLAY_INCLUDE_DIR="" +CC_CMD="" +AR_CMD="" +RANLIB_CMD="" +OUTPUT="" + +while [[ $# -gt 0 ]]; do + case "$1" in + --version) VERSION="$2"; shift 2 ;; + --url) URL="$2"; shift 2 ;; + --cache-dir) CACHE_DIR="$2"; shift 2 ;; + --build-dir) BUILD_DIR="$2"; shift 2 ;; + --zlib-include) ZLIB_INCLUDE="$2"; shift 2 ;; + --zlib-libdir) ZLIB_LIBDIR="$2"; shift 2 ;; + --overlay-include-dir) OVERLAY_INCLUDE_DIR="$2"; shift 2 ;; + --cc) CC_CMD="$2"; shift 2 ;; + --ar) AR_CMD="$2"; shift 2 ;; + --ranlib) RANLIB_CMD="$2"; shift 2 ;; + --output) OUTPUT="$2"; shift 2 ;; + *) + echo "Unknown argument: $1" >&2 + usage >&2 + exit 1 + ;; + esac +done + +if [[ -z "$VERSION" || -z "$URL" || -z "$CACHE_DIR" || -z "$BUILD_DIR" || -z "$ZLIB_INCLUDE" || -z "$ZLIB_LIBDIR" || -z "$OVERLAY_INCLUDE_DIR" || -z "$CC_CMD" || -z "$AR_CMD" || -z "$RANLIB_CMD" || -z "$OUTPUT" ]]; then + usage >&2 + exit 1 +fi + +fetch() { + local url="$1" + local out="$2" + if command -v curl >/dev/null 2>&1; then + curl --retry 3 --retry-all-errors -fSL "$url" -o "$out" + elif command -v wget >/dev/null 2>&1; then + wget -q "$url" -O "$out" + else + echo "Neither curl nor wget is available to fetch $url" >&2 + exit 1 + fi +} + +mkdir -p "$CACHE_DIR" +rm -rf "$BUILD_DIR" +mkdir -p "$BUILD_DIR" + +TARBALL="$CACHE_DIR/openssh-${VERSION}.tar.gz" +if [[ ! -f "$TARBALL" ]]; then + echo "Fetching upstream OpenSSH ${VERSION} release tarball..." + fetch "$URL" "$TARBALL" +fi + +echo "Extracting upstream OpenSSH ${VERSION}..." +tar -xzf "$TARBALL" -C "$BUILD_DIR" + +SRC_DIR="$BUILD_DIR/openssh-${VERSION}" +if [[ ! -d "$SRC_DIR" ]]; then + echo "Expected extracted source at $SRC_DIR" >&2 + exit 1 +fi + +# OpenSSH's --with-zlib=PATH expects a normal install prefix (PATH/include, +# PATH/lib). Our zlib artifacts live in the source tree + a separate build +# dir, so stage a prefix out of symlinks. +ZLIB_PREFIX="$BUILD_DIR/zlib-prefix" +mkdir -p "$ZLIB_PREFIX/include" "$ZLIB_PREFIX/lib" +ln -sf "$ZLIB_INCLUDE/zlib.h" "$ZLIB_PREFIX/include/zlib.h" +ln -sf "$ZLIB_INCLUDE/zconf.h" "$ZLIB_PREFIX/include/zconf.h" +ln -sf "$ZLIB_LIBDIR/libz.a" "$ZLIB_PREFIX/lib/libz.a" + +pushd "$SRC_DIR" >/dev/null + +# Batch/key-based OpenSSH client (RFC 4251 architecture, RFC 4253 transport, +# RFC 4252 auth, RFC 4254 connection) built --without-openssl: the internal +# crypto set is ed25519 signatures, curve25519-sha256 KEX and +# chacha20-poly1305@openssh.com (see upstream README + `ssh -Q` on such a +# build). No RSA/ECDSA, no PKCS#11, no FIDO/security keys. +# +# Stack layout mirrors the git build: wasm-ld's default 64 KiB stack above the +# data segment is far too small for OpenSSH (packet buffers and kex state live +# on the stack several frames deep), so give it an 8 MiB stack and +# --stack-first so overflows trap instead of corrupting static data. +SSH_WASM_STACK_FLAGS="-Wl,-z,stack-size=8388608 -Wl,--stack-first" + +# Autoconf cache seeds for cross-compilation gaps (mirrors the wget build's +# gl_cv_*/ac_cv_* seeding): +# - ac_cv_func_setrlimit=no: the patched sysroot exports a getrlimit-only +# surface (std-patches/wasi-libc/0017-resource-limits-and-groups.patch); +# OpenSSH only uses setrlimit to zero core dumps / fd limits, which the +# kernel already enforces per-VM. +# - ac_cv_have_broken_snprintf/vsnprintf=no: run tests can't execute under +# cross builds; the patched musl snprintf is C99-conformant, so declare it +# instead of letting configure assume the worst and pull in replacements. +# +# CFLAGS carries -I$OVERLAY_INCLUDE_DIR (toolchain/c/include): the shared +# include_next overlays used by every C command build. OpenSSH needs +# 's struct winsize + TIOCGWINSZ (tty(4) window-size ioctl) from +# that overlay for channels.c's window-change tracking; at runtime the +# patched sysroot ioctl() reports ENOTTY on non-PTY fds and the client just +# skips the update. +echo "Configuring upstream OpenSSH for wasm32-wasip1 (--without-openssl)..." +CC="$CC_CMD" \ +AR="$AR_CMD" \ +RANLIB="$RANLIB_CMD" \ +CFLAGS="-O2 -D_WASI_EMULATED_PROCESS_CLOCKS -D_WASI_EMULATED_MMAN -I$OVERLAY_INCLUDE_DIR" \ +LDFLAGS="$SSH_WASM_STACK_FLAGS" \ +LIBS="-lwasi-emulated-process-clocks -lwasi-emulated-mman" \ +ac_cv_func_setrlimit=no \ +ac_cv_have_broken_snprintf=no \ +ac_cv_have_broken_vsnprintf=no \ +./configure \ + --host=wasm32-unknown-wasi \ + --without-openssl \ + --with-zlib="$ZLIB_PREFIX" \ + --without-pam \ + --without-selinux \ + --without-libedit \ + --without-audit \ + --disable-utmp \ + --disable-wtmp \ + --disable-lastlog \ + --disable-btmp \ + --disable-pkcs11 \ + --disable-security-key \ + --without-stackprotect \ + --without-hardening \ + --sysconfdir=/etc/ssh + +echo "Building upstream OpenSSH ssh client..." +make -j"${MAKE_JOBS:-2}" ssh + +if [[ ! -f ssh ]]; then + echo "Expected ssh binary was not produced" >&2 + exit 1 +fi + +mkdir -p "$(dirname "$OUTPUT")" +if command -v wasm-opt >/dev/null 2>&1; then + echo "Optimizing OpenSSH ssh WASM binary..." + wasm-opt -O3 --strip-debug --all-features ssh -o "$OUTPUT" +else + cp ssh "$OUTPUT" +fi + +popd >/dev/null + +echo "Built upstream OpenSSH ssh at $OUTPUT" diff --git a/toolchain/std-patches/wasi-libc-overrides/openssh_compat.c b/toolchain/std-patches/wasi-libc-overrides/openssh_compat.c new file mode 100644 index 0000000000..73fcbb9908 --- /dev/null +++ b/toolchain/std-patches/wasi-libc-overrides/openssh_compat.c @@ -0,0 +1,373 @@ +/* + * OpenSSH compatibility stubs for the agentOS wasm32-wasip1 sysroot. + * + * Declarations for both functions live in the patched sysroot headers + * (std-patches/wasi-libc/0029-openssh-compat-header-surface.patch). + */ + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +/* + * closefrom(2) — Solaris/FreeBSD/OpenBSD extension, glibc >= 2.34: + * "closefrom() closes all open file descriptors greater than or equal to + * lowfd" (closefrom(2), FreeBSD man pages). + * + * This MUST be a no-op here. OpenSSH's ssh.c:main() calls + * closefrom(STDERR_FILENO + 1) unconditionally at startup to drop leaked + * descriptors before doing anything else. On WASI, however, the preopened + * directory capability handles handed to the module by the runtime start at + * fd 3 (see WASI preview1 `preopens`; wasi-libc + * libc-bottom-half/sources/preopens.c populates its preopen table from + * fd_prestat_get on fds >= 3). Closing fds 3+ would discard every preopen + * and with them all path resolution — every subsequent open() fails with + * ENOTCAPABLE. A faithful "close everything" implementation is therefore + * strictly worse than doing nothing: guest processes in this runtime are + * spawned with exactly the descriptors the kernel intends them to have, so + * there are no inherited stray fds to drop and the security intent of the + * call is already satisfied by construction. + * + * Providing the symbol also makes OpenSSH's configure AC_CHECK_FUNCS link + * probe succeed (HAVE_CLOSEFROM), which keeps its openbsd-compat + * bsd-closefrom.c replacement — a close() loop up to sysconf(_SC_OPEN_MAX) + * that would destroy the preopens — out of the build entirely. + */ +void closefrom(int lowfd) { + (void)lowfd; +} + +/* + * socketpair(3p) — POSIX.1-2024: "the socketpair() function shall create an + * unnamed pair of connected sockets". The agentOS kernel has no socketpair + * syscall (no AF_UNIX datagram plumbing between two anonymous fds), so this + * honestly fails with ENOSYS, which POSIX permits via the EOPNOTSUPP/EAFNOSUPPORT + * family for unsupported domains; ENOSYS names the real condition ("the + * function is not supported by this implementation"). + * + * The stub exists so ported tools link: OpenSSH references socketpair() from + * cold paths the batch client never takes — sshconnect.c + * ssh_proxy_fdpass_connect() (ProxyCommand/ProxyUseFdpass, RFC 4251 §4.4 + * "proxies and gateways" territory) and channels/mux code. If one of those + * paths is ever reached, the caller gets a real errno instead of a link + * failure or a silent fake socket. + */ +int socketpair(int domain, int type, int protocol, int sv[2]) { + (void)domain; + (void)type; + (void)protocol; + (void)sv; + errno = ENOSYS; + return -1; +} + +/* + * ppoll(2) — Linux/ppoll(3p, POSIX.1-2024): poll with a struct timespec + * timeout and an atomically-applied signal mask. The patched sysroot routes + * poll() through the host_net.net_poll import + * (std-patches/wasi-libc/0023-host-net-read-write-sockets.patch), which + * understands host-net sockets, kernel pipes/PTYs, and the runtime's + * high-numbered dup'd fds. Implement ppoll as a wrapper over that poll so + * link probes (AC_CHECK_FUNCS(ppoll)) succeed and ported tools skip their + * own replacements — OpenSSH's openbsd-compat/bsd-poll.c fallback is built + * on pselect() and fails with EINVAL for any fd >= FD_SETSIZE, which the + * runtime's virtual dup fds (>= 0x100000, see the fd_dup_min host import) + * always are. + * + * The sigmask parameter is deliberately ignored: this runtime has no + * asynchronous signal delivery to race against — signals are cooperative + * and dispatched at poll boundaries inside net_poll (see + * std-patches/wasi-libc/0011-sigaction.patch), so the atomic + * "swap-mask-and-wait" that ppoll(2) exists to provide has no observable + * effect here. + */ +int ppoll(struct pollfd *fds, nfds_t nfds, const struct timespec *timeout, + const sigset_t *sigmask) { + int timeout_ms; + + (void)sigmask; + if (timeout == NULL) { + timeout_ms = -1; + } else if (timeout->tv_sec < 0 || timeout->tv_nsec < 0 || + timeout->tv_nsec > 999999999L) { + errno = EINVAL; + return -1; + } else if (timeout->tv_sec > (time_t)(INT_MAX / 1000 - 1)) { + timeout_ms = INT_MAX; + } else { + timeout_ms = (int)(timeout->tv_sec * 1000 + + (timeout->tv_nsec + 999999L) / 1000000L); + } + return poll(fds, nfds, timeout_ms); +} + +/* + * set*id family — unprivileged-process subset of POSIX setuid(3p) / + * setgid(3p) / setreuid(3p) and Linux setresuid(2). Guest process identity is + * fixed by the kernel (getuid()/getgid() come from the host_user import, see + * std-patches/wasi-libc/0005-user-identity.patch); there is no privilege to + * raise or drop. POSIX: an unprivileged process may set its ids only to + * values it already holds (real, effective, or saved — all identical here), + * and "the setuid() function shall fail [EPERM] ... if uid does not match the + * real user ID or the saved set-user-ID". So self-assignment succeeds as a + * no-op and any other target fails with EPERM. OpenSSH's uidswap.c relies on + * exactly the self-assignment case (setuid(getuid()) etc.) to guarantee it + * holds no elevated privileges. + */ +static int set_fixed_id(unsigned int requested, unsigned int current) { + if (requested == current) + return 0; + errno = EPERM; + return -1; +} + +/* (uid_t)-1 / (gid_t)-1 means "leave unchanged" per setreuid(3p)/setresuid(2). */ +static int set_fixed_id_opt(unsigned int requested, unsigned int current) { + if (requested == (unsigned int)-1) + return 0; + return set_fixed_id(requested, current); +} + +int setuid(uid_t uid) { + return set_fixed_id(uid, getuid()); +} + +int seteuid(uid_t euid) { + return set_fixed_id(euid, geteuid()); +} + +int setgid(gid_t gid) { + return set_fixed_id(gid, getgid()); +} + +int setegid(gid_t egid) { + return set_fixed_id(egid, getegid()); +} + +int setreuid(uid_t ruid, uid_t euid) { + if (set_fixed_id_opt(ruid, getuid()) < 0) + return -1; + return set_fixed_id_opt(euid, geteuid()); +} + +int setregid(gid_t rgid, gid_t egid) { + if (set_fixed_id_opt(rgid, getgid()) < 0) + return -1; + return set_fixed_id_opt(egid, getegid()); +} + +int setresuid(uid_t ruid, uid_t euid, uid_t suid) { + if (set_fixed_id_opt(ruid, getuid()) < 0) + return -1; + if (set_fixed_id_opt(euid, geteuid()) < 0) + return -1; + return set_fixed_id_opt(suid, getuid()); +} + +int setresgid(gid_t rgid, gid_t egid, gid_t sgid) { + if (set_fixed_id_opt(rgid, getgid()) < 0) + return -1; + if (set_fixed_id_opt(egid, getegid()) < 0) + return -1; + return set_fixed_id_opt(sgid, getgid()); +} + +int getresuid(uid_t *ruid, uid_t *euid, uid_t *suid) { + if (ruid) + *ruid = getuid(); + if (euid) + *euid = geteuid(); + if (suid) + *suid = getuid(); + return 0; +} + +int getresgid(gid_t *rgid, gid_t *egid, gid_t *sgid) { + if (rgid) + *rgid = getgid(); + if (egid) + *egid = getegid(); + if (sgid) + *sgid = getgid(); + return 0; +} + +/* + * getgrent(3p) / setgrent(3p) / endgrent(3p) — POSIX group database + * enumeration. The agentOS runtime resolves groups by id/name through host + * imports (std-patches/wasi-libc/0025-group-lookup-compat.patch provides + * getgrgid/getgrnam); there is no enumerable /etc/group database, so + * enumeration reports immediate end-of-database: POSIX getgrent(3p) "shall + * return a null pointer ... on end-of-file". setgrent/endgrent rewind/close + * that (empty) enumeration and are no-ops. Having real symbols keeps ported + * tools off their compat macro replacements (OpenSSH's bsd-misc.h + * `#define endgrent() do { } while(0)` collides with the grp.h prototype). + */ +struct group *getgrent(void) { + return NULL; +} + +void setgrent(void) { +} + +void endgrent(void) { +} + +/* + * setgroups(2) / initgroups(3) — supplementary group management. The guest's + * supplementary group set is fixed by the kernel (getgroups() reports it via + * the host import from + * std-patches/wasi-libc/0017-resource-limits-and-groups.patch), and POSIX + * reserves changing it to privileged processes ("appropriate privileges", + * setgroups(2): "EPERM The calling process ... does not have the CAP_SETGID + * capability"). Requests that restate the current single-group set succeed + * as no-ops; anything else fails with EPERM. initgroups(3) is glibc/BSD + * "read /etc/group and call setgroups" — with the enumerable group database + * empty (see getgrent above) its computed set is exactly {basegid}. OpenSSH + * misc.c subprocess() only calls initgroups when geteuid() == 0. + */ +int setgroups(size_t size, const gid_t *list) { + if (size == 0 || (size == 1 && list != NULL && list[0] == getgid())) + return 0; + errno = EPERM; + return -1; +} + +int initgroups(const char *user, gid_t group) { + gid_t set[1]; + + (void)user; + set[0] = group; + return setgroups(1, set); +} + +/* + * getnameinfo(3p) — POSIX.1-2024 / RFC 3493 §6.2 ("Socket Address Structure + * to Node Name and Service Name"). wasi-libc's (un-omitted by the + * sockets patch) declares getnameinfo but the sysroot never defined it; the + * host_net-backed host_socket.o only implements getaddrinfo. This is the + * numeric subset: it formats addresses with inet_ntop and ports with + * snprintf. Reverse DNS is not available in the runtime (there is no + * host-side reverse-lookup import), so NI_NAMEREQD fails with EAI_NONAME — + * exactly what POSIX prescribes when "the name of the host cannot be + * located" — and name-preferred lookups degrade to the numeric form, the + * same observable behavior as a Linux host with no working reverse DNS. + * OpenSSH uses getnameinfo() pervasively (canonical host strings for + * known_hosts, log messages) with NI_NUMERICHOST/NI_NUMERICSERV on the paths + * the batch client exercises. + */ +int getnameinfo(const struct sockaddr *restrict sa, socklen_t salen, + char *restrict host, socklen_t hostlen, + char *restrict serv, socklen_t servlen, int flags) { + char buf[INET6_ADDRSTRLEN]; + unsigned short port; + + if (sa == NULL) + return EAI_FAIL; + + switch (sa->sa_family) { + case AF_INET: { + const struct sockaddr_in *sin = (const struct sockaddr_in *)sa; + if (salen < (socklen_t)sizeof(*sin)) + return EAI_FAMILY; + if (host != NULL && hostlen > 0) { + if (flags & NI_NAMEREQD) + return EAI_NONAME; + if (inet_ntop(AF_INET, &sin->sin_addr, buf, + sizeof(buf)) == NULL) + return EAI_FAIL; + if (strlen(buf) >= (size_t)hostlen) + return EAI_OVERFLOW; + memcpy(host, buf, strlen(buf) + 1); + } + port = ntohs(sin->sin_port); + break; + } + case AF_INET6: { + const struct sockaddr_in6 *sin6 = + (const struct sockaddr_in6 *)sa; + if (salen < (socklen_t)sizeof(*sin6)) + return EAI_FAMILY; + if (host != NULL && hostlen > 0) { + if (flags & NI_NAMEREQD) + return EAI_NONAME; + if (inet_ntop(AF_INET6, &sin6->sin6_addr, buf, + sizeof(buf)) == NULL) + return EAI_FAIL; + if (strlen(buf) >= (size_t)hostlen) + return EAI_OVERFLOW; + memcpy(host, buf, strlen(buf) + 1); + } + port = ntohs(sin6->sin6_port); + break; + } + default: + return EAI_FAMILY; + } + + if (serv != NULL && servlen > 0) { + /* No services database in the VM; numeric only (as if + * NI_NUMERICSERV were always set). */ + char portbuf[8]; + int n = snprintf(portbuf, sizeof(portbuf), "%u", + (unsigned int)port); + if (n < 0 || n >= (int)servlen) + return EAI_OVERFLOW; + memcpy(serv, portbuf, (size_t)n + 1); + } + + return 0; +} + +/* + * getrrsetbyname(3) / freerrset(3) — OpenBSD DNS RRset query API, declared in + * the patched . OpenSSH's dns.c uses it to fetch SSHFP records for + * VerifyHostKeyDNS (RFC 4255 "Using DNS to Securely Publish Secure Shell + * (SSH) Key Fingerprints"). This sysroot has no resolver library (res_query + * and friends are not built; DNS goes through the host getaddrinfo import), + * so raw RRset queries are unsupported: fail with ERRSET_FAIL ("general + * failure", getrrsetbyname(3) RETURN VALUES). dns.c maps that to its normal + * "DNS lookup error" diagnostic and host-key verification proceeds via + * known_hosts, exactly like a host whose resolver cannot reach a nameserver. + */ +int getrrsetbyname(const char *hostname, unsigned int rdclass, + unsigned int rdtype, unsigned int flags, struct rrsetinfo **res) { + (void)hostname; + (void)rdclass; + (void)rdtype; + (void)flags; + if (res) + *res = NULL; + return ERRSET_FAIL; +} + +void freerrset(struct rrsetinfo *rrset) { + unsigned int i; + + if (rrset == NULL) + return; + /* Mirrors OpenBSD lib/libc/net/getrrsetbyname.c freerrset(). */ + if (rrset->rri_rdatas) { + for (i = 0; i < rrset->rri_nrdatas; i++) + free(rrset->rri_rdatas[i].rdi_data); + free(rrset->rri_rdatas); + } + if (rrset->rri_sigs) { + for (i = 0; i < rrset->rri_nsigs; i++) + free(rrset->rri_sigs[i].rdi_data); + free(rrset->rri_sigs); + } + free(rrset->rri_name); + free(rrset); +} diff --git a/toolchain/std-patches/wasi-libc/0029-openssh-compat-header-surface.patch b/toolchain/std-patches/wasi-libc/0029-openssh-compat-header-surface.patch new file mode 100644 index 0000000000..5b1b763f4a --- /dev/null +++ b/toolchain/std-patches/wasi-libc/0029-openssh-compat-header-surface.patch @@ -0,0 +1,196 @@ +diff --git a/libc-bottom-half/headers/public/__errno_values.h b/libc-bottom-half/headers/public/__errno_values.h +index 6d6d412..df0a65d 100644 +--- a/libc-bottom-half/headers/public/__errno_values.h ++++ b/libc-bottom-half/headers/public/__errno_values.h +@@ -83,4 +83,12 @@ + #define EOPNOTSUPP ENOTSUP + #define EWOULDBLOCK EAGAIN + ++/* Linux-compat errno constant for ported tools (agentOS sysroot). errno(3): ++ * "EPFNOSUPPORT 96 Protocol family not supported". No WASI errno maps to it ++ * and the kernel never raises it; it exists so upstream code that tests for ++ * it (e.g. OpenSSH openbsd-compat/bindresvport.c) compiles. It gets a ++ * distinct value (Linux's 96, above the WASI errno range) rather than an ++ * alias of EAFNOSUPPORT so duplicate switch-case labels stay legal. */ ++#define EPFNOSUPPORT 96 ++ + #endif +diff --git a/libc-bottom-half/headers/public/__header_sys_socket.h b/libc-bottom-half/headers/public/__header_sys_socket.h +index 3965794..e4cea06 100644 +--- a/libc-bottom-half/headers/public/__header_sys_socket.h ++++ b/libc-bottom-half/headers/public/__header_sys_socket.h +@@ -143,6 +143,14 @@ + #define AF_INET PF_INET + #define AF_INET6 PF_INET6 + #define AF_UNIX 3 ++/* POSIX spells the constants AF_*; the PF_* names are the ++ * historical BSD "protocol family" aliases (4.4BSD; on Linux and musl ++ * PF_UNIX == AF_UNIX, PF_LOCAL/AF_LOCAL are additional POSIX.1-2008 spellings ++ * of the same family). Ported tools still use them (e.g. OpenSSH misc.c's ++ * unix_listener uses PF_UNIX), so alias them to the AF_* values above. */ ++#define AF_LOCAL AF_UNIX ++#define PF_UNIX AF_UNIX ++#define PF_LOCAL AF_UNIX + + #ifdef __cplusplus + extern "C" { +diff --git a/libc-top-half/musl/include/netdb.h b/libc-top-half/musl/include/netdb.h +index eecb482..bc35d70 100644 +--- a/libc-top-half/musl/include/netdb.h ++++ b/libc-top-half/musl/include/netdb.h +@@ -160,6 +160,44 @@ int getservbyname_r(const char *, const char *, struct servent *, char *, size_t + #endif + + ++ ++/* OpenBSD getrrsetbyname(3) surface (also on FreeBSD/NetBSD libc): DNS RRset ++ * query API used by OpenSSH's dns.c for SSHFP host-key verification ++ * (VerifyHostKeyDNS, RFC 4255). Struct layout and constants match OpenBSD ++ * . The agentOS sysroot has no DNS resolver library (lookups route ++ * through the host getaddrinfo import), so the libc definition ++ * (std-patches/wasi-libc-overrides/openssh_compat.c) always fails with ++ * ERRSET_FAIL and callers degrade to a normal "DNS lookup error" path. */ ++struct rdatainfo { ++ unsigned int rdi_length; /* length of data */ ++ unsigned char *rdi_data; /* record data */ ++}; ++ ++struct rrsetinfo { ++ unsigned int rri_flags; /* RRSET_VALIDATED... */ ++ unsigned int rri_rdclass; /* class number */ ++ unsigned int rri_rdtype; /* RR type number */ ++ unsigned int rri_ttl; /* time to live */ ++ unsigned int rri_nrdatas; /* size of rdatas array */ ++ unsigned int rri_nsigs; /* size of sigs array */ ++ char *rri_name; /* canonical name */ ++ struct rdatainfo *rri_rdatas; /* individual records */ ++ struct rdatainfo *rri_sigs; /* individual signatures */ ++}; ++ ++#define RRSET_VALIDATED 1 /* DNSSEC AD bit was set in the reply */ ++ ++#define ERRSET_SUCCESS 0 ++#define ERRSET_NOMEMORY 1 ++#define ERRSET_FAIL 2 ++#define ERRSET_INVAL 3 ++#define ERRSET_NONAME 4 ++#define ERRSET_NODATA 5 ++ ++int getrrsetbyname(const char *, unsigned int, unsigned int, unsigned int, ++ struct rrsetinfo **); ++void freerrset(struct rrsetinfo *); ++ + #ifdef __cplusplus + } + #endif +diff --git a/libc-top-half/musl/include/sys/socket.h b/libc-top-half/musl/include/sys/socket.h +index b1957d4..1233b7d 100644 +--- a/libc-top-half/musl/include/sys/socket.h ++++ b/libc-top-half/musl/include/sys/socket.h +@@ -400,9 +400,13 @@ struct sockaddr_storage { + + int socket (int, int, int); + +-#ifdef __wasilibc_unmodified_upstream /* WASI has no socketpair */ ++/* Declared unconditionally for the agentOS sysroot: POSIX socketpair(3p) ++ * (POSIX.1-2024, XSH). The kernel has no socketpair syscall, so the libc ++ * definition (std-patches/wasi-libc-overrides/openssh_compat.c) fails with ++ * ENOSYS; the declaration exists so ported tools such as OpenSSH that ++ * reference socketpair() on cold paths (e.g. ProxyUseFdpass in sshconnect.c) ++ * compile and link against the honest ENOSYS stub. */ + int socketpair (int, int, int, int [2]); +-#endif + + int shutdown (int, int); + +diff --git a/libc-top-half/musl/include/unistd.h b/libc-top-half/musl/include/unistd.h +index 8b8d692..490d57c 100644 +--- a/libc-top-half/musl/include/unistd.h ++++ b/libc-top-half/musl/include/unistd.h +@@ -51,6 +51,13 @@ int pipe2(int [2], int); + int pipe(int [2]); + int close(int); + int posix_close(int, int); ++/* closefrom(2) as on Solaris/FreeBSD/OpenBSD (close all fds >= lowfd) and ++ * glibc >= 2.34. Declared unconditionally in the agentOS sysroot; the ++ * definition (std-patches/wasi-libc-overrides/openssh_compat.c) is a ++ * deliberate NO-OP because WASI preopened directory capabilities start at ++ * fd 3 and closing them would sever all path resolution. See OpenSSH ++ * ssh.c:main() which calls closefrom(STDERR_FILENO + 1) at startup. */ ++void closefrom(int); + int dup(int); + int dup2(int, int); + #ifdef __wasilibc_unmodified_upstream /* WASI has no dup3 */ +@@ -173,12 +180,18 @@ gid_t getgid(void); + gid_t getegid(void); + int getgroups(int, gid_t []); + +-#ifdef __wasilibc_unmodified_upstream /* WASI has no setuid etc. */ ++/* Declared for the agentOS sysroot. Guest process identity is fixed by the ++ * kernel (uid/gid come from the host_user import; see ++ * std-patches/wasi-libc/0005-user-identity.patch), so the definitions in ++ * std-patches/wasi-libc-overrides/openssh_compat.c implement the ++ * unprivileged-process subset of POSIX setuid(3p)/setgid(3p): setting an id ++ * to a value the process already holds succeeds (no-op), anything else fails ++ * with EPERM. Ported tools (e.g. OpenSSH uidswap.c) call ++ * setuid(getuid())-style self-assignments to drop privileges they never had. */ + int setuid(uid_t); + int seteuid(uid_t); + int setgid(gid_t); + int setegid(gid_t); +-#endif + + char *getlogin(void); + int getlogin_r(char *, size_t); +@@ -199,10 +212,10 @@ size_t confstr(int, char *, size_t); + #define F_LOCK 1 + #define F_TLOCK 2 + #define F_TEST 3 +-#ifdef __wasilibc_unmodified_upstream /* WASI has no setreuid */ ++/* See the setuid() comment above: same fixed-identity EPERM semantics ++ * (POSIX setreuid(3p)); defined in wasi-libc-overrides/openssh_compat.c. */ + int setreuid(uid_t, uid_t); + int setregid(gid_t, gid_t); +-#endif + #ifdef __wasilibc_unmodified_upstream /* WASI has no POSIX file locking */ + int lockf(int, int, off_t); + #endif +@@ -259,12 +272,15 @@ extern int optreset; + + #ifdef _GNU_SOURCE + extern char **environ; +-#ifdef __wasilibc_unmodified_upstream /* WASI has no get/setresuid */ ++/* See the setuid() comment above: setresuid/setresgid (Linux ++ * setresuid(2)) follow the same fixed-identity semantics (-1 keeps a field, ++ * self-assignment succeeds, everything else is EPERM); getresuid/getresgid ++ * report the single kernel identity for all three ids. Defined in ++ * wasi-libc-overrides/openssh_compat.c. */ + int setresuid(uid_t, uid_t, uid_t); + int setresgid(gid_t, gid_t, gid_t); + int getresuid(uid_t *, uid_t *, uid_t *); + int getresgid(gid_t *, gid_t *, gid_t *); +-#endif + #ifdef __wasilibc_unmodified_upstream /* WASI has no cwd */ + char *get_current_dir_name(void); + #endif +diff --git a/scripts/install-include-headers.sh b/scripts/install-include-headers.sh +index 8813360..9a740ad 100755 +--- a/scripts/install-include-headers.sh ++++ b/scripts/install-include-headers.sh +@@ -63,11 +63,15 @@ MUSL_OMIT_HEADERS+=("sys/procfs.h" "sys/user.h" "sys/kd.h" "sys/vt.h" \ + "sys/fsuid.h" "sys/io.h" "sys/prctl.h" "sys/mtio.h" "sys/mount.h" \ + "sys/fanotify.h" "sys/personality.h" "elf.h" "link.h" "bits/link.h" \ + "scsi/scsi.h" "scsi/scsi_ioctl.h" "scsi/sg.h" "sys/auxv.h" \ +- "shadow.h" "mntent.h" "resolv.h" "pty.h" "ulimit.h" "sys/xattr.h" \ ++ "shadow.h" "mntent.h" "pty.h" "ulimit.h" "sys/xattr.h" \ + "wordexp.h" "sys/membarrier.h" "sys/signalfd.h" \ + "net/if.h" "net/if_arp.h" \ + "net/ethernet.h" "net/route.h" "netinet/if_ether.h" "netinet/ether.h" \ + "sys/timerfd.h" "libintl.h" "sys/sysmacros.h" "aio.h") ++# NOTE: resolv.h un-omitted by the agentOS 0029-openssh-compat patch — musl's ++# resolv.h is declaration-only and ported tools (OpenSSH sshkey.c, ++# sshbuf-misc.c) include unconditionally. No resolver code is ++# built; linking res_* still fails, which is the honest surface. + # Exclude `netdb.h` from all of the p1 targets. + # NOTE: commented out by the secure-exec 0008-sockets patch — we provide getaddrinfo via host_net + #if [[ $TARGET_TRIPLE == *"wasi" || $TARGET_TRIPLE == *"wasi-threads" || \