Skip to content

Cowork N2: guest side of shares (SH6) - #139

Merged
Winterrks merged 7 commits into
cowork/output-integrationfrom
cowork/n2-sh6-guest-shares
Oct 24, 2026
Merged

Winterrks merged 7 commits into
cowork/output-integrationfrom
cowork/n2-sh6-guest-shares

Conversation

@Winterrks

@Winterrks Winterrks commented Oct 24, 2026 •

Copy link
Copy Markdown
Member

SH6 of the N2 extensions design (docs/research/2026-10-18-n2-extensions-design.md sections 2.1, 2.5, 2.6, R6, R8, R15, 4b.3, O14), against contract revision 3 (docs/cowork-native-worker.md section 12, native/protocol.ts). Guest only: no host, libkrun or SDK change. No VM was run.

What the guest now does

After mounted and before conversations the helper accepts one bootstrap shares message:

  1. Binding and order (helper). It must echo the plan's operationId and nonce (as conversations does), arrive before any conversation set, at most once, with nothing more from the host until shares_mounted (checked before PID 1 is asked, and again for anything that arrives while PID 1 mounts). Any departure, and a broken or unbound share set, is share_mount_failed with nothing mounted. A set that follows a share step that did not end in shares_mounted is bad_conversations and never reaches PID 1; PID 1 fail-stops on one as a helper fault (defence in depth).
  2. Inventory (PID 1). <sys>/fs/virtiofs/<n>/tag must equal the declared tags exactly (no extra, missing or repeated tag), and the virtio bus must hold exactly as many type-26 devices, so a virtio-fs device that published no tag is an extra device too. Otherwise share_inventory.
  3. Mount points. The three Winter mount points are the existing skeleton's (/run/winter/shared-instructions, /workspace/.winter/memory, /workspace/outputs) and are never made here. A folder's /workspace/folders/<grantId> is created only directly below the skeleton's /workspace/folders, root's, mode 0700 (PID 1's umask). Every component is opened O_NOFOLLOW and must be root's; the mount point must sit on its parent's filesystem.
  4. Mounts. mount(tag, /proc/self/fd/<n>, "virtiofs", MS_NOSUID|MS_NODEV [|MS_RDONLY] [|MS_NOEXEC on guidance, memory, outputs], "dax=never"); folder sources keep exec. The target is the descriptor of the mount point the walk checked, held open from the check to the mount, so the path is never resolved a second time.
  5. mountinfo check. For each share: exactly one entry at the path (nothing under or over it), root /, fstype virtiofs, source the tag, ro exactly when read-only, nosuid and nodev, noexec exactly on a Winter share, dax=never as the only DAX option, and the tag mounted nowhere else. Any mismatch is share_mount_failed.
  6. Alias. With the guidance share, /workspace/.winter/WINTER.md is a root-owned symlink to /run/winter/shared-instructions/WINTER.md; one left by an earlier boot is kept only if it is exactly that link.
  7. Only then shares_mounted. Any failure after a mount unmounts what the step mounted (last first); what could not be unmounted is handed to the drain.

Never chown or chmod. The share step contains no chown, chmod, xattr, truncate, unlink or write open (a test reads the source); a behavioural test compares every owner and mode in the tree before and after the step.

Drain. The shares are unmounted first, last mounted first; the ext4 volumes are unmounted only once no share is mounted. halting still means every filesystem this boot mounted is unmounted: a share that stays mounted withholds it (fault after drain_ack, as for a failed ext4 unmount).

Image (R15). Integration's #140 ships the system gitconfig: the pinned scripts/cowork-guest-artifact/environment/gitconfig at the base's /etc/gitconfig, with init.templateDir = /usr/share/winter/git-empty-template, an empty root-owned directory, checked by verify.check_gitconfig_text and check_environment_gitconfig. SDK SH6b mounts that file read-only into the process worker. This branch's own gitconfig was dropped in the merge in favour of integration's; the two would have clashed at the same path.

What the browse uid can and cannot see on a share (O14)

  • Through the file channel, nothing on any share. The file worker opens a root only if it is ext4 with the data volume's st_dev (4b.3 step 2) and walks with RESOLVE_NO_XDEV. Every share root is a virtio-fs mount with its own device, so a share root, or a share mounted below a data-volume root, answers file_unavailable. PID 1 makes no share a data root (/workspace/folders/*, /workspace/outputs, /workspace/.winter/memory are skipped, Cowork N2: guest side of the file channel (FC3), gated on file-channel-v1 #122) and binds no share into any compartment view.
  • On the data volume, as before. Group-readable files below the root:workspaceGid 02770 data roots (FC3), with no capability (SDK FC4). A file a tool made 0600 stays unreadable (accepted, O14).
  • Why permissions are not the boundary on a share. The confined server runs LinuxSimplified with no xattrs (R7): guest uid, gid and mode bits on a share are a simplified view, not an access boundary. A group-only rule, a setgid root or a chmod there would promise nothing. So PID 1 never chowns or chmods a share mount point or anything on a share. The boundaries for shares are the mount view, the root-device check, and the host side (the confined server and its Seatbelt profile). Mac readers read shares directly on the host, so the channel loses nothing.

Decisions

  • Shares are revision 3, which only an image that declares file-channel-v1 speaks. All PID 1 code is under WG_FILE_CHANNEL_V1 (new init/shares.{c,h}, built only into init-file-channel). The production programs' preprocessed sources are unchanged, so their binary pins are kept; their source pins and the helper's script pin are re-pinned (PinTests passes).
  • Every host order violation around shares is share_mount_failed with nothing mounted; an inventory mismatch is share_inventory.
  • The mountinfo check requires the kernel to print dax=never. This kernel has CONFIG_FUSE_DAX=y, so it does. The optional CONFIG_FUSE_DAX=n fragment would stop it printing, and must change this one rule in the same change.

The nine shares transcripts

bootstrap-order.json's nine share transcripts that #122 left waiting are now completed by the guest like the other fifteen (24 of 25; plan-before-hello cannot involve it), with exact outputs pinned for each, and a test that the share set reaches PID 1 only in the three transcripts where the order allows it.

Review fold (REJECT at 0c61494)

  1. The red Linux test. op_mount_shares passed owner 0, so on the unprivileged runner the walk refused the test's own directories before mkdirat or mount. init_linux.c now has SHARES_OWNER. It defaults to 0, only tests/linux_init_tool.c builds pass another value, and build.py passes none. A guard test holds that, and two mutants of it are killed. The test now reaches the real mount(2) and asserts its refusal: errno=EPERM, nothing left mounted, and only the folder's mount point made (0700).
  2. Real mounts in CI. A new root step in cowork-guest-boot.yml runs the realmount tests. Each runs in its own private mount namespace, with WG_REQUIRE_REALMOUNT=1 so a test that cannot run fails rather than skips:
    • The whole share step for real on tmpfs: inventory, mount points, five mounts through the checked descriptors, the kernel's own mountinfo checked, the alias, then unmount. This is the same code as production, with tmpfs and nr_inodes=4096 in place of virtiofs and dax=never.
    • A mount point that already holds a mount is refused before anything is mounted.
    • The production virtiofs call, reached as root and refused by the kernel (EINVAL "tag not found", or ENODEV) because this runner has no virtio-fs device, with the step undone.
    • The mountinfo reader and every flag and data-option check on real kernel output. This includes ext4 mounted with dax=never on a loop device: a real dax=never line, judged by the same DAX rule.
    • The live virtio-fs mount is not proven here: G7 checks it.
  3. The virtio-fs dax=never line. tests/virtiofs-mountinfo-v6.18.json is derived, not captured: no VM may run in this lane. Each line is assembled from the Linux v6.18 source of the functions that print it (show_mountinfo/show_vfsmnt_opts, fuse_show_options, virtio_fs_init_fs_context/virtio_fs_get_tree, fuse_dax_conn_alloc), each recorded with its URL and SHA-256. That source shows a virtio-fs mount prints the tag as its source and ro|rw,dax=never as its super options: the context is zeroed, so no legacy options. Every share passes against it and fails with any other DAX option. G7 replaces it with a captured live table.
  4. The shares.c comment on the exactly-one-entry rule now says what it does: none means not mounted; two or more means stacked on, or under, another mount.

Second fold (ACCEPT at 5654174, lows)

  • The root step cannot pass by skipping. Under WG_REQUIRE_REALMOUNT=1, test_pid1_linux raises in setUpModule when it lacks Linux, a compiler, root or unshare(1). The class decorators would otherwise skip every test first. A test that runs everywhere pins this.
  • "Refused before anything is mounted" is now observed, not inferred. The real step counts its mount(2) calls, and the test asserts mounts=0 (the mountinfo rollback masked this before).
  • Payload truncations. Every cut of the share payload is parsed from a heap block of exactly its own size, plus a path length that claims more than remains. The address sanitizer sees any read past the end.
  • mountinfo parser. New cases: a trailing space, a field after the super options, and a separator with no super options.
  • Alias owner. A root test makes the right link owned by another uid (lchown) and expects a refusal that leaves the link untouched.
  • UMOUNT_NOFOLLOW on the share rollback and drain unmounts.
  • Mutations. 8 named mutants: 6 killed locally; 2 are root-only (the not-yet-a-mount-point st_dev check and the alias owner check). CI's root step runs those two through RootMutantTests, which also refuses to skip.

Tests

  • Local (macOS): bootstrap 198, guest tests (native C modules, relay, state machine, hygiene, shares, and the whole mutation suite) 300 (42 skipped: Linux-only and root-only), artifact scripts 126 (6 skipped), kernel 46.
  • CI only (Linux; the workflow's path filter covers every file here): SharesPid1Tests as the runner user (4: the inventory and real-sysfs refusals, the real mount(2) EPERM undoing the step, the runner's own mountinfo), and as root in the new step (6 realmount tests: the whole step on tmpfs, a mount point that is already a mount, the refused virtio-fs call, every check on real output, ext4 with dax=never, a tmpfs probe with a share's flags), plus the existing Linux-only suites.
  • Mutation, guards only (inventory equality, mount flags per kind, read-only, dax=never, the mountinfo check, the operation/nonce binding, no chown/chmod, the order before conversations, the mount through the checked descriptor, the test-build-only owner): 48 of 48 killed, plus the second fold's 8 (6 killed locally, 2 root-only in CI).
  • The no-NIC/no-vsock scans (test_hygiene.py, guest-boot-anti-wiring.test.ts) cover shares.c; a test pins that.

NEEDS

  • Already in the pinned SDK (ec91fe06): SH6b admits exactly /workspace/.winter/memory (LINUX_MEMORY_ROOT, and the supervisor's MEMORY_ROOT), and the process worker mounts the base's /etc/gitconfig.
  • Controller: the image digests (expectedArtifact) are stale, because the helper changed in the cpio. A new owner-armed one-run image build re-pins them.
  • G7 (live gate): the live virtio-fs mount. Capture its mountinfo (source = tag, dax=never) and replace the derived fixture with it. Also confirm the virtio device count with 12 shares: discovery refuses more than 16 virtio devices; 12 shares plus the console and two disks is 15.

…step, PID 1 inventory, mounts, mountinfo check, guidance alias, drain order); the nine shares transcripts completed
…uard mutants, the image's system gitconfig with an empty root-owned init.templateDir (R15), guest pins re-pinned
…ed mount point's descriptor; a test-build-only owner lets the unprivileged test reach the refused mount(2); a root CI step proves the step, the mountinfo reader and every check on real mounts (tmpfs, ext4 with dax=never, the refused virtiofs call); a source-derived virtio-fs mountinfo fixture; 48 guard mutants; init re-pinned
…tshare subcommands (dropped in the review fold), and a test that runs everywhere and fails when a subcommand the Linux tests use is missing from the driver
…the refused-before-mounting test counts mount(2) calls, exact-size payload truncations, two mountinfo parser cases, a root test of the alias owner, UMOUNT_NOFOLLOW on share unmounts, the named mutants (root-only ones run in CI's root step); init re-pinned
…cowork/n2-sh6-guest-shares

# Conflicts:
#	.github/workflows/cowork-guest-boot.yml
#	apple/Cowork/WinterCoworkGuest/tests/linux_init_tool.c
#	scripts/cowork-guest-artifact/image.py
#	scripts/cowork-guest-artifact/pins.json
@Winterrks
Winterrks marked this pull request as ready for review October 24, 2026 18:08
@Winterrks
Winterrks merged commit db130d5 into cowork/output-integration Oct 24, 2026
15 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant