diff --git a/crates/execution/assets/runners/wasm-runner.mjs b/crates/execution/assets/runners/wasm-runner.mjs index 38fd91f6a4..e0c5c3f707 100644 --- a/crates/execution/assets/runners/wasm-runner.mjs +++ b/crates/execution/assets/runners/wasm-runner.mjs @@ -3299,14 +3299,16 @@ const hostNetImport = { net_poll(fdsPtr, nfds, timeoutMs, retReadyPtr) { const n = Number(nfds) >>> 0; const base0 = Number(fdsPtr) >>> 0; - // The patched wasi sysroot's effective poll bits (bits/poll.h): POLLIN=POLLRDNORM=0x1, - // POLLOUT=POLLWRNORM=0x2 (NOT the 0x004 in legacy poll.h). Guests (X server + libxcb) use - // these, so net_poll must match or POLLOUT readiness is never reported and writers block. + // Match the owned sysroot ABI in __header_poll.h exactly. Its WASI event + // representation uses POLLIN=0x1, POLLOUT=0x2 and the widened exceptional + // bits below, rather than Linux's numeric values. poll(2) defines the + // behavior; the sysroot header defines the guest-visible wire values. + // https://man7.org/linux/man-pages/man2/poll.2.html const POLLIN = 0x001; const POLLOUT = 0x002; - const POLLERR = 0x008; - const POLLHUP = 0x010; - const POLLNVAL = 0x020; + const POLLERR = 0x1000; + const POLLHUP = 0x2000; + const POLLNVAL = 0x4000; const t = Number(timeoutMs) | 0; const deadline = t < 0 ? null : Date.now() + Math.max(0, t); const kernelManagedStdio = @@ -3322,6 +3324,7 @@ const hostNetImport = { // comes from a batched __kernel_poll below, which doubles as the wait slice. const kernelTargets = []; const kernelEntries = []; + let hasHostNetWaitTarget = false; for (let i = 0; i < n; i++) { const base = base0 + i * 8; const fd = view.getInt32(base, true); @@ -3330,6 +3333,7 @@ const hostNetImport = { const socket = getHostNetSocket(fd); const handle = fd >= 0 ? lookupFdHandle(fd >>> 0) : undefined; if (socket && !socket.closed) { + hasHostNetWaitTarget = true; if (socket.serverId) { if (events & POLLIN) { // Report the listener readable only when a connection is actually pending. @@ -3344,6 +3348,16 @@ const hostNetImport = { if (events & POLLIN && socket.readChunks && socket.readChunks.length > 0) { revents |= POLLIN; } + // poll(2) reports peer shutdown as POLLHUP even when it was not + // requested, and a read after the queued data drains must return + // EOF without blocking. OpenSSH waits on this transition before + // exiting after the remote command closes its connection. + // https://man7.org/linux/man-pages/man2/poll.2.html + if (socket.readableEnded) { + revents |= POLLHUP; + if (events & POLLIN) revents |= POLLIN; + } + if (socket.lastError) revents |= POLLERR; if (events & POLLOUT) revents |= POLLOUT; } } else if (handle?.kind === 'pipe-read') { @@ -3360,21 +3374,28 @@ const hostNetImport = { } } else if (handle?.kind === 'pipe-write') { if (events & POLLOUT) revents |= POLLOUT; - } else if ( - fd >= 0 && - fd <= 2 && - kernelManagedStdio && - (!handle || (handle.kind === 'passthrough' && handle.targetFd === fd)) - ) { - // Kernel-managed stdio (PTY slave / stdio pipes): ask the kernel, like a - // native poll(2) on the terminal fd. + } else if (fd >= 0 && kernelManagedStdio && ( + (!handle && fd <= 2) || + (handle?.kind === 'passthrough' && Number(handle.targetFd) >= 0 && + Number(handle.targetFd) <= 2) + )) { + // poll(2): readiness means the requested operation will not block. + // https://man7.org/linux/man-pages/man2/poll.2.html + // Kernel-managed stdio (PTY slave / stdio pipes), including dup'd + // aliases: ask the kernel instead of treating a high alias like a + // regular file that is always ready. A false POLLIN here makes a + // guest block on an empty stdin pipe before it services another + // ready fd (for example OpenSSH flushing an exec request). + const kernelFd = handle?.kind === 'passthrough' + ? Number(handle.targetFd) >>> 0 + : fd; kernelTargets.push({ - fd, + fd: kernelFd, events: ((events & POLLIN) !== 0 ? KERNEL_POLLIN : 0) | ((events & POLLOUT) !== 0 ? KERNEL_POLLOUT : 0), }); - kernelEntries.push({ base, fd, events }); + kernelEntries.push({ base, fd, kernelFd, events }); } else if (handle) { // Regular files / other VFS-backed fds: always ready, as on Linux. revents |= events & (POLLIN | POLLOUT); @@ -3391,10 +3412,13 @@ const hostNetImport = { if (kernelTargets.length > 0) { // If something is already ready (or this is a non-blocking poll), probe the - // kernel without waiting; otherwise let the kernel wait one slice for us. + // kernel without waiting. Mixed host-net + kernel polls must also keep + // this probe nonblocking: __kernel_poll cannot wake for a host socket, + // so sleeping here starves each queued SSH packet for a full 10s slice. + // The socket pump below supplies the bounded wait in that case. const remaining = deadline == null ? Infinity : deadline - Date.now(); const sliceMs = - ready > 0 || t === 0 + ready > 0 || t === 0 || hasHostNetWaitTarget ? 0 : Math.max(0, Math.min(KERNEL_WAIT_SLICE_MS, remaining)); let response = null; @@ -3406,7 +3430,7 @@ const hostNetImport = { const responseEntries = Array.isArray(response?.fds) ? response.fds : []; for (const entry of kernelEntries) { const responseEntry = responseEntries.find( - (item) => (Number(item?.fd) >>> 0) === (entry.fd >>> 0), + (item) => (Number(item?.fd) >>> 0) === (entry.kernelFd >>> 0), ); const kernelRevents = Number(responseEntry?.revents) >>> 0; let revents = 0; @@ -5257,11 +5281,14 @@ wasiImport.fd_read = (fd, iovs, iovsLen, nreadPtr) => { } if ( - numericFd === 0 && handle?.kind === 'passthrough' && - handle.targetFd === 0 && - passthroughHandles.get(0) === handle + handle.targetFd === 0 ) { + // dup(2) aliases share the same open file description as fd 0. In a + // sidecar-managed process they must therefore read the kernel stdin pipe, + // not the runner process's unrelated host stdin. OpenSSH duplicates stdin + // before its poll/read loop, so splitting these paths loses pipe EOF. + // https://man7.org/linux/man-pages/man2/dup.2.html const sidecarManagedProcess = typeof process?.env?.AGENTOS_SANDBOX_ROOT === 'string' && process.env.AGENTOS_SANDBOX_ROOT.length > 0; diff --git a/crates/execution/src/node_import_cache.rs b/crates/execution/src/node_import_cache.rs index 83af6dd67c..8a37976c2c 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 = "98"; +const NODE_IMPORT_CACHE_ASSET_VERSION: &str = "104"; 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/docs-internal/registry-networking-handoff.md b/docs-internal/registry-networking-handoff.md index ee40f3fdb3..7a6878fcd5 100644 --- a/docs-internal/registry-networking-handoff.md +++ b/docs-internal/registry-networking-handoff.md @@ -7,9 +7,9 @@ This picks up an in-flight effort: give curl/wget/git **real HTTPS with native Linux semantics**, make the **codex build reproducible**, and then continue into **ssh**, **/proc**, and the Node stdlib migration. Current proof state: - curl/wget/git HTTPS is **done and green** on in-guest mbedTLS. -- ssh is built and its direct suite is **7/7 green**, but the git-over-SSH - clone/push test is opt-in and blocked by a runtime synthetic-pipe + host-net - poll/flush bug (§4); the ssh rev is not yet forklifted. +- ssh is built and its default suite is **8/8 green**, including real + git-over-SSH clone + push through the runtime's synthetic-pipe/host-net poll + path (§4). - the kernel already has a **partial synthetic procfs**, but it has not met this program's acceptance bar: no authoritative-format citations at the emitters, no real-Linux golden fixture, no real procps-ng/psmisc packages, and no real @@ -128,7 +128,7 @@ files belong in a rev. --- -## 4. ssh — BUILT + VERIFIED, needs forklift + one gap closed +## 4. ssh — BUILT + VERIFIED, including git-over-SSH Rev `opqnkltx` (`feat(ssh)…`). **Real OpenSSH portable 10.4p1, `--without-openssl`**, built for wasm32-wasip1 (845 KB, at `software/ssh/bin/ssh`, `packages/runtime-core/ @@ -138,19 +138,14 @@ upstream.sh`, `toolchain/c/Makefile` target, `toolchain/std-patches/wasi-libc/ overrides/openssh_compat.c` (the no-op `closefrom` + ENOSYS `socketpair`), `software/ssh/*`, `wasm-runner.mjs` setsockopt polish. -**Verified — ssh e2e 7/7** (`~/progress/agent-os/2026-07-09-openssh/…-ssh-suite- -final.log`): ed25519 publickey auth + known_hosts; exit-status propagation; -unauthorized-key failure; host-key-verification failure; BatchMode fail-closed on -unknown host; `StrictHostKeyChecking=accept-new`. **git 25/25 unregressed.** - -**Gap to close (state reconciled):** the git-over-SSH clone/push test now exists -at `software/ssh/test/ssh.test.ts`, but is opt-in behind -`AGENTOS_SSH_GIT_E2E=1`. It reaches the real ssh binary, completes KEX/auth, and -opens the session; it then hangs because child-spawn synthetic pipes and a -host-net socket are polled together but the queued exec channel request is not -flushed. Fix that runtime poll/write interaction in the owning runtime layer, -remove the opt-in gate, and require clone + push green by default. Do not weaken -the test or patch git/OpenSSH around the runtime bug. +**Verified — ssh e2e 8/8** +(`~/progress/agent-os/2026-07-09-registry-networking/2026-07-09T17-00-20-0700-ssh-full-suite-pass.log`): +the original seven direct cases plus real git-over-SSH clone + push. The runtime +fix teaches mixed host-net/kernel polling to probe kernel stdio without starving +the socket, routes duplicated stdio aliases to the kernel fd, and emits the owned +sysroot's actual poll exceptional-bit ABI. The in-test SSH server also preserves +the channel until it sends the child exit status. **git 25/25 unregressed** +(`~/progress/agent-os/2026-07-09-registry-networking/2026-07-09T17-01-10-0700-git-full-suite-pass.log`). **Build/test commands:** ``` @@ -164,9 +159,9 @@ pnpm --dir software/ssh test # with AGENTOS_SIDECAR_BIN pinned (see § ### 5.1 Finish the reg-tests stack -1. **Close git-over-SSH:** fix the runtime synthetic-pipe + host-net poll/flush - bug described in §4, remove `AGENTOS_SSH_GIT_E2E`, and make real clone + push - pass by default with a pinned `AGENTOS_SIDECAR_BIN`. +1. **DONE — close git-over-SSH:** the runtime synthetic-pipe + host-net poll/flush + bug is fixed, `AGENTOS_SSH_GIT_E2E` is removed, and real clone + push pass by + default with a pinned `AGENTOS_SIDECAR_BIN` (§4). 2. **Finish `/proc` from the existing partial implementation:** retain the process-table-backed design in `crates/kernel/src/kernel.rs`, then add the missing consumer surface (`/proc//comm`, `/proc/stat`, and any further @@ -269,14 +264,11 @@ make the shim future `Send` or cfg-gate rmcp oauth off wasi). ## 8. Immediate next steps for the picking-up agent -1. Forklift the pending policy/ssh/handoff revisions, preserving the shared - workspace and resolving only the known workflow conflicts if they recur. -2. Fix the git-over-SSH runtime blocker and make clone + push unskipped and green. -3. Complete and prove `/proc`, then ship real procps-ng/psmisc commands. -4. Forklift the completed reg-tests revisions and update this status section. -5. Switch to the existing `node-stdlib` workspace without moving `reg-tests`'s +1. Complete and prove `/proc`, then ship real procps-ng/psmisc commands. +2. Forklift each completed reg-tests revision and keep this status current. +3. Switch to the existing `node-stdlib` workspace without moving `reg-tests`'s `@`; execute the Node spec M0→M5 with the shared real-OpenSSL decision. -6. Optional after the ordered work: git credential-cache note and the codex +4. Optional after the ordered work: git credential-cache note and the codex `rmcp` OAuth `Send` blocker. Everything real-tool must stay **real upstream, patch the sysroot not the app** diff --git a/docs-internal/registry-parity-worklist.md b/docs-internal/registry-parity-worklist.md index aa803a07f2..dcff5ce09b 100644 --- a/docs-internal/registry-parity-worklist.md +++ b/docs-internal/registry-parity-worklist.md @@ -814,7 +814,7 @@ breadth; (2) `git` is **rare inside agent turns** (harnesses extract the diff out-of-band) but stays essential; (3) a **long tail of project-specific CLIs** (`dvc`, `sqlglot`, `sanic`, …) comes from pip/npm install, not the registry. -**Requested (add):** ssh 🟡, rsync 🟡, tmux/screen 🟡 (PTY — session persistence), +**Requested (add):** ssh ✅, rsync 🟡, tmux/screen 🟡 (PTY — session persistence), gpg 🟡, ffmpeg 🟡 (media transcode — heavy but headless), jj 🟢, dig 🟡, nslookup 🟡, less ⭐🟡 (pager), openssl ⭐🟡 (TLS/certs/keys/hashing). tail/head/cat are already in coreutils — confirm present. @@ -823,7 +823,7 @@ tail/head/cat are already in coreutils — confirm present. big C runtime but real; 563 uses in history), miller `mlr` 🟢 (CSV/JSON), xmlstarlet 🟢, pcre2grep 🟢. (jq/yq/sed/awk/grep/head/tail already covered.) -**Networking (host TCP/DNS bridge only):** openssl ⭐🟡, ssh 🟡, nc/netcat 🟡 +**Networking (host TCP/DNS bridge only):** openssl ⭐🟡, ssh ✅, nc/netcat 🟡 (TCP/UDP), socat 🟡, whois 🟢, dig/nslookup 🟡, redis-cli / psql client 🟡, aria2 🟡 (C++ downloader), sshpass 🟢 (ssh password helper). diff --git a/software/ssh/test/ssh.test.ts b/software/ssh/test/ssh.test.ts index f58060fea9..13731cc825 100644 --- a/software/ssh/test/ssh.test.ts +++ b/software/ssh/test/ssh.test.ts @@ -125,8 +125,8 @@ function installGitExecHandler(client: Connection, repoRoot: string) { 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.stdout.pipe(stream, { end: false }); + child.stderr.pipe(stream.stderr, { end: false }); child.on('close', (code) => { stream.exit(code ?? 1); stream.end(); @@ -356,22 +356,9 @@ describeIf(hasSsh, 'ssh command', () => { // 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', () => { + // This also regresses mixed polling in the runtime: ssh polls a dup'd stdin + // pipe alongside its host-net socket while Git waits for the remote helper. + describeIf(hasGit && hasHostGit, 'git-over-ssh clone/push', () => { let keys: TestKeys; let server: SshServer; let port: number; @@ -436,7 +423,7 @@ describeIf(hasSsh, 'ssh command', () => { 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); + expect(cloned.exitCode, cloned.stderr).toBe(0); const readme = new TextDecoder().decode( await kernel.readFile('/tmp/clone/README.md'), );