Skip to content

Commit 7a1cca7

Browse files
authored
Fix Mac workspace sync for guest symlinks (#45)
Signed-off-by: Joseph Yaksich <gitcommit90@users.noreply.github.com> Co-authored-by: Joseph Yaksich <gitcommit90@users.noreply.github.com>
1 parent d2ce524 commit 7a1cca7

8 files changed

Lines changed: 37 additions & 11 deletions

File tree

‎CHANGELOG.md‎

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
77

88
## [Unreleased]
99

10+
## [0.0.32] - 2026-08-01
11+
12+
### Fixed
13+
14+
- Fixed macOS channel commands, stop, and archive after ordinary guest tools
15+
create workspace symlinks such as Python virtual environments. Symlinks now
16+
remain durable inside the isolated Linux computer and are omitted from the
17+
symlink-free host mirror instead of failing the entire mirror transaction.
18+
1019
## [0.0.31] - 2026-08-01
1120

1221
### Fixed
@@ -911,7 +920,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
911920
notarization, stapled tickets, Gatekeeper verification, persistent
912921
Application Support, and isolated Apple container machines.
913922

914-
[Unreleased]: https://github.com/gitcommit90/1Helm/compare/v0.0.31...HEAD
923+
[Unreleased]: https://github.com/gitcommit90/1Helm/compare/v0.0.32...HEAD
924+
[0.0.32]: https://github.com/gitcommit90/1Helm/compare/v0.0.31...v0.0.32
915925
[0.0.31]: https://github.com/gitcommit90/1Helm/compare/v0.0.30...v0.0.31
916926
[0.0.30]: https://github.com/gitcommit90/1Helm/compare/v0.0.29...v0.0.30
917927
[0.0.29]: https://github.com/gitcommit90/1Helm/compare/v0.0.28...v0.0.29

‎README.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -313,7 +313,7 @@ A fresh data directory opens first-run setup. The source runtime defaults to
313313
| `PORT` | `8123` | HTTP/WebSocket control-plane port. |
314314
| `CTRL_DATA_DIR` | `./data` | Databases, routing state, uploads, and non-OCI development/Apple workspace mirrors. |
315315
| `HELM_CHANNEL_COMPUTER_BACKEND` | `apple` on macOS, `oci` on Linux and Windows | Host isolation backend; `native` and `mock` are explicit development/test overrides. |
316-
| `HELM_CHANNEL_MACHINE_IMAGE` | `local/1helm-channel-machine:0.0.31` | Versioned channel-machine image contract. |
316+
| `HELM_CHANNEL_MACHINE_IMAGE` | `local/1helm-channel-machine:0.0.32` | Versioned channel-machine image contract. |
317317

318318
### Agent-first JSON CLI
319319

‎package-lock.json‎

Lines changed: 2 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

‎package.json‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
{
22
"name": "1helm",
33
"productName": "1Helm",
4-
"version": "0.0.31",
4+
"version": "0.0.32",
55
"private": true,
66
"type": "module",
77
"license": "AGPL-3.0-only",

‎src/server/channel-computers.ts‎

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -68,7 +68,7 @@ const APPLE_RUNTIME_VERSION = "1.1.0";
6868
export const APPLE_RUNTIME_PACKAGE = `container-${APPLE_RUNTIME_VERSION}-installer-signed.pkg`;
6969
export const APPLE_RUNTIME_URL = `https://github.com/apple/container/releases/download/${APPLE_RUNTIME_VERSION}/${APPLE_RUNTIME_PACKAGE}`;
7070
export const APPLE_RUNTIME_SHA256 = "0ca1c42a2269c2557efb1d82b1b38ac553e6a3a3da1b1179c439bcee1e7d6714";
71-
export const DEFAULT_CHANNEL_IMAGE = process.env.HELM_CHANNEL_MACHINE_IMAGE || "local/1helm-channel-machine:0.0.31";
71+
export const DEFAULT_CHANNEL_IMAGE = process.env.HELM_CHANNEL_MACHINE_IMAGE || "local/1helm-channel-machine:0.0.32";
7272
const CONTAINER_CANDIDATES = [process.env.HELM_CONTAINER_CLI, "/usr/local/bin/container", "/opt/homebrew/bin/container", "container"].filter(Boolean) as string[];
7373
const OCI_RUNTIME_VERSION = "1helm-oci-runtime-v1";
7474
const OCI_HELPER_CANDIDATES = [
@@ -1033,7 +1033,14 @@ async function syncGuestToHostUnlocked(computer: ChannelComputer): Promise<void>
10331033
const staging = `${hostWorldRoot(computer.channel_id)}.guest-sync-${randomBytes(6).toString("hex")}`;
10341034
mkdirSync(staging, { recursive: true });
10351035
try {
1036-
const invocation = isolatedInvocation(["/bin/tar", "-C", "/", "-cf", "-", "workspace"], computer, "agent", "/workspace");
1036+
// Guest workspaces legitimately contain symlinks (Python venvs are the
1037+
// common case). The host mirror deliberately has no symlink semantics,
1038+
// so export only ordinary directories/files. Symlinks remain durable in
1039+
// the guest VM and can never be followed across the host boundary.
1040+
const invocation = isolatedInvocation([
1041+
"/bin/bash", "-lc",
1042+
"set -o pipefail; cd /; find -P workspace -xdev \\( -type d -o -type f \\) -print0 | tar --null --no-recursion -T - -cf -",
1043+
], computer, "agent", "/workspace");
10371044
const child = spawn(invocation.command, invocation.args, { stdio: ["ignore", "pipe", "pipe"] });
10381045
const extract = spawn("tar", ["-C", staging, "-xf", "-", "--no-same-owner", "--no-same-permissions"], { stdio: ["pipe", "ignore", "pipe"] });
10391046
let archiveBytes = 0, oversized = false;

‎src/server/db.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -939,7 +939,7 @@ export function migrate(): void {
939939
const platformBackend = process.platform === "darwin" ? "apple" : "oci";
940940
const configuredBackend = String(process.env.HELM_CHANNEL_COMPUTER_BACKEND || platformBackend);
941941
const backend = ["apple", "oci", "native", "mock"].includes(configuredBackend) ? configuredBackend : platformBackend;
942-
const image = String(process.env.HELM_CHANNEL_MACHINE_IMAGE || "local/1helm-channel-machine:0.0.31");
942+
const image = String(process.env.HELM_CHANNEL_MACHINE_IMAGE || "local/1helm-channel-machine:0.0.32");
943943
for (const channel of q(`SELECT c.id FROM channels c JOIN agent_channels ac ON ac.channel_id=c.id
944944
WHERE c.kind='channel' AND c.status<>'deleted'`)) {
945945
const channelId = Number(channel.id);

‎test/channel-computers.mjs‎

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -102,6 +102,13 @@ test("Apple channel-computer contract preserves isolation, files, wakes, archive
102102
assert.equal(readFileSync(join(dataDir, "channels", String(alpha.channelId), "workspace", "name with spaces.txt"), "utf8"), "semi; quote ' and space", "guest command quoting survives Apple's login-shell handoff");
103103
assert.equal(existsSync(join(dataDir, "channels", String(alpha.channelId), "files", "guest.txt")), false, "guest deletions do not resurrect stale Files entries");
104104

105+
result = await computers.runChannelCommand(alpha.channelId, "mkdir -p .venv/bin; ln -s /usr/bin/python3 .venv/bin/python3; ln -s python3 .venv/bin/python; printf venv-ok > .venv/marker");
106+
assert.equal(result.exit_code, 0, "a normal Python-style venv does not make the command fail during mirror sync");
107+
assert.equal(readFileSync(join(dataDir, "channels", String(alpha.channelId), "workspace", ".venv", "marker"), "utf8"), "venv-ok");
108+
assert.equal(existsSync(join(dataDir, "channels", String(alpha.channelId), "workspace", ".venv", "bin", "python3")), false, "guest symlinks stay in the VM instead of crossing the host mirror boundary");
109+
result = await computers.runChannelCommand(alpha.channelId, "test -L .venv/bin/python3 && readlink .venv/bin/python3");
110+
assert.match(result.output, /\/usr\/bin\/python3/, "the venv symlink remains durable and usable inside the guest");
111+
105112
computers.upsertObligation(alpha.channelId, "command", "held", "resident", "active work");
106113
await computers.stopChannelComputer(alpha.channelId, "idle");
107114
assert.equal(db.q1("SELECT observed_state FROM channel_computers WHERE channel_id=?", alpha.channelId).observed_state, "running", "resident work prevents idle sleep");
@@ -167,7 +174,7 @@ test("Apple channel-computer contract preserves isolation, files, wakes, archive
167174
test("runtime digest and packaged image recipe stay pinned", async () => {
168175
assert.equal(computers.APPLE_RUNTIME_SHA256, "0ca1c42a2269c2557efb1d82b1b38ac553e6a3a3da1b1179c439bcee1e7d6714");
169176
assert.match(computers.APPLE_RUNTIME_URL, /\/1\.1\.0\/container-1\.1\.0-installer-signed\.pkg$/);
170-
assert.equal(computers.DEFAULT_CHANNEL_IMAGE, "local/1helm-channel-machine:0.0.31");
177+
assert.equal(computers.DEFAULT_CHANNEL_IMAGE, "local/1helm-channel-machine:0.0.32");
171178
const packaging = await readFile(join(root, "scripts", "package-mac-dmg.cjs"), "utf8");
172179
assert.match(packaging, /container\(\?:\$\|\\\/\)/, "release packaging includes container/ image assets");
173180
const image = await readFile(join(root, "container", "Containerfile"), "utf8");

‎test/fake-container.mjs‎

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -119,10 +119,12 @@ if (script.includes("image-contract") && script.includes("/var/lib/1helm/owner")
119119
writeFileSync(join(root, "var", "lib", "1helm", "owner"), `${ownerValue}\n`);
120120
process.exit(0);
121121
}
122-
if (command[0] === "/bin/tar" && command.includes("-cf")) {
122+
if ((command[0] === "/bin/tar" && command.includes("-cf")) || (script.includes("find -P workspace") && script.includes("--no-recursion"))) {
123123
const delay = Math.max(0, Number(process.env.FAKE_CONTAINER_EXPORT_DELAY_MS || 0));
124124
if (delay) Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, delay);
125-
const tar = spawnSync("tar", ["-C", root, "-cf", "-", "workspace"], { maxBuffer: 256 * 1024 ** 2 });
125+
const listed = spawnSync("find", ["-P", "workspace", "-xdev", "(", "-type", "d", "-o", "-type", "f", ")", "-print0"], { cwd: root });
126+
if (listed.status !== 0) fail(String(listed.stderr || "fake workspace listing failed"));
127+
const tar = spawnSync("tar", ["-C", root, "--null", "--no-recursion", "-T", "-", "-cf", "-"], { input: listed.stdout, maxBuffer: 256 * 1024 ** 2 });
126128
if (tar.status !== 0) fail(String(tar.stderr || "fake export failed"));
127129
process.stdout.write(tar.stdout);
128130
process.exit(0);

0 commit comments

Comments
 (0)