Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
98 changes: 98 additions & 0 deletions apps/cli/test/tar-copyfile-disable.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
import { readFileSync, readdirSync, statSync } from 'node:fs';
import { join } from 'node:path';
import { describe, expect, it } from 'vitest';

/**
* Source-level guard: a `tar` that CREATES an archive from a host path must
* pass `COPYFILE_DISABLE=1`.
*
* macOS BSD tar otherwise emits `._*` AppleDouble resource-fork stubs, and they
* land wherever the archive is unpacked — a box's `/workspace`, a bake context,
* an exported clone. This is not a theoretical rule: it has been fixed FIVE
* separate times at five call sites, each time as a bug report from a real box
* (`._agentbox.yaml`, `._AGENTS.md`, `._skills`), because the rule lived only in
* the comments of the sites that already had it.
*
* Extractions (`-x`) are exempt — the variable means nothing when unpacking.
*
* A source scan rather than a lint rule for the same reason
* `no-inline-agent-union.test.ts` is one: it is one rule about one repo fact,
* and it must fail in the same `pnpm test` run that would otherwise pass.
*/
const REPO = join(__dirname, '..', '..', '..');

const ROOTS = [
'apps/cli/src',
'packages/core/src',
'packages/ctl/src',
'packages/relay/src',
'packages/sandbox-core/src',
'packages/sandbox-cloud/src',
'packages/sandbox-docker/src',
'packages/sandbox-remote-docker/src',
'packages/sandbox-hetzner/src',
'packages/sandbox-digitalocean/src',
];

/** A create flag: `-c`, `-cf`, `-czf`, `-cvf`… but never `-x…`. */
const CREATE_FLAG = /'-[a-zA-Z]*c[a-zA-Z]*'/;

function walk(dir: string, out: string[] = []): string[] {
let entries: string[];
try {
entries = readdirSync(dir);
} catch {
return out;
}
for (const name of entries) {
const full = join(dir, name);
if (statSync(full).isDirectory()) {
if (name !== 'node_modules' && name !== 'dist') walk(full, out);
} else if (name.endsWith('.ts')) {
out.push(full);
}
}
return out;
}

/** Every `execa('tar', …)` call site, as (file, line, call text). */
function tarCalls(): { file: string; line: number; call: string }[] {
const out: { file: string; line: number; call: string }[] = [];
for (const root of ROOTS) {
for (const file of walk(join(REPO, root))) {
const src = readFileSync(file, 'utf8');
const re = /execa\(\s*'tar'/g;
let m: RegExpExecArray | null;
while ((m = re.exec(src)) !== null) {
// Enough of the call to cover its options object; these are short.
const seg = src.slice(m.index, m.index + 800);
const end = seg.indexOf('});');
out.push({
file: file.slice(REPO.length + 1),
line: src.slice(0, m.index).split('\n').length,
call: end === -1 ? seg.slice(0, 400) : seg.slice(0, end + 3),
});
}
}
}
return out;
}

describe('tar invocations', () => {
const calls = tarCalls();

it('finds the tar call sites at all (guards the scanner itself)', () => {
expect(calls.length).toBeGreaterThan(10);
});

it('every archive-CREATING tar disables macOS AppleDouble stubs', () => {
const offenders = calls
.filter((c) => CREATE_FLAG.test(c.call))
.filter((c) => !c.call.includes('COPYFILE_DISABLE') && !c.call.includes('HOST_TAR_ENV'))
.map((c) => `${c.file}:${String(c.line)}`);
expect(
offenders,
`add \`env: { ...process.env, COPYFILE_DISABLE: '1' }\` to:\n ${offenders.join('\n ')}`,
).toEqual([]);
});
});
22 changes: 22 additions & 0 deletions docs/plans/service-boxes-backlog.md
Original file line number Diff line number Diff line change
Expand Up @@ -499,3 +499,25 @@ spec-declared base overlay would provide. Not built.
then reports the same "Browser origin not allowed". Only daytona is still
untested.

## A fresh box needs `openclaw doctor --fix` before exec approvals work (2026-09-07)

On a brand-new hetzner box, straight after `openclaw onboard`:

```
$ openclaw skills list
ExecApprovalsMigrationRequiredError: Legacy exec approvals exist at
/home/vscode/.openclaw/exec-approvals.json. Run `openclaw doctor --fix`
before using exec approvals.
```

The gateway itself is fine — the service reaches `ready`, `/healthz` answers,
the Control UI serves, and `agents.defaults.workspace` is correct. But an
openclaw CLI command that touches exec approvals refuses until `doctor --fix`
has run, on a box where nothing legacy could plausibly exist.

Unclear whether this only affects the CLI or also the gateway's own exec
approvals at runtime — the latter would matter for anything a channel actually
asks the agent to do, so check it alongside the channel-pairing work. If it is
just onboard leaving a stale-shaped file, the fix is probably a
`openclaw doctor --fix` step in the agent's `tasks`, after onboard.

25 changes: 8 additions & 17 deletions docs/plans/service-boxes-plan.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,36 +30,27 @@ closes a design question, so re-opening one needs new evidence, not an opinion.

## What is left

Re-checked against the code and a live Hetzner box on 2026-09-07. The URL
blocker and the hub-create blocker are **done**; what follows is what actually
remains.
Re-checked against the code and live Hetzner boxes on 2026-09-07. The URL, the
hub-create, the cloud workspace-env and the AppleDouble blockers are **done**;
what follows is what actually remains.

### Blocking — OpenClaw is not usable without these

1. **Cloud boxes ignore the workspace.** `spec.boxRunEnv` reaches the sandbox's
provision env but not `/etc/agentbox/box.env`, which is what ctl's tasks
read — so `OPENCLAW_WORKSPACE_DIR` is absent and onboard runs against
`~/.openclaw/workspace`. Verified live: a Hetzner box has the user's
`AGENTS.md` / `skills` / `memory` in `/workspace` and
`agents.defaults.workspace` pointing somewhere else entirely. "Your project
dir is the agent's workspace" — the headline behaviour — is broken off
docker. Hetzner/DO only; vercel and e2b carry the env through the SDK exec.
2. **Channel pairing has never been verified.** Every smoke stops at a healthy
1. **Channel pairing has never been verified.** Every smoke stops at a healthy
gateway with **zero channels**. Until a real token goes through
`openclaw channels add --use-env`, we do not know openclaw does anything
useful in a box. This is the last open question about whether the feature
works, not a polish item — do it first.
3. **The Control UI cannot connect on a public-preview provider.** e2b and
2. **The Control UI cannot connect on a public-preview provider.** e2b and
vercel serve `/` fine (their edges add no forwarded headers), but the WS
connect is refused with "Browser origin not allowed" until the box's own
origin is in `gateway.controlUi.allowedOrigins`. AgentBox knows that origin
at create; it needs somewhere to assert a config key it owns. Setting it by
hand reaches the normal "paste the gateway token" state, so nothing else is
wrong. See the backlog for the measurement.
4. **The non-git cloud seed still writes AppleDouble sidecars.** `seedFromTar`
(`sandbox-cloud/src/sync/workspace-seed.ts`, the `tar -C … -czf` with no
`env`) is the fourth call site of this bug; the other three are fixed. One
line.
3. **A fresh box needs `openclaw doctor --fix`** before any CLI command that
touches exec approvals works. The gateway is unaffected; whether the runtime
approvals path is too is unknown — check it alongside (1). See the backlog.

### Rough edges

Expand Down
25 changes: 25 additions & 0 deletions packages/sandbox-cloud/src/bootstrap-launch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,18 @@ export interface KickCloudBootstrapArgs {
* Omitted = `gh`, matching the config default.
*/
hubGitAuth?: HubGitAuthMode;
/**
* The selected agents' declared `spec.boxRunEnv`, merged
* (`buildCloudBoxRunEnv`). Docker delivers the same field through `docker run
* -e`, where one container env serves the daemon and every login shell; a VPS
* has no such store, so on cloud it has to ride BOTH surfaces below.
*
* Safe in the 0644 box.env, unlike the rest of `CloudProvisionRequest.env`:
* this is only what a registry row declares — committed to the repo,
* non-secret by construction — never the forwarded provider API keys that
* `buildForwardedEnv` adds on top.
*/
agentRunEnv?: Record<string, string>;
onLog?: (line: string) => void;
}

Expand Down Expand Up @@ -160,6 +172,19 @@ export function buildBootstrapEnv(args: KickCloudBootstrapArgs): {
boxEnvFile.push(`AGENTBOX_GIT_DIRECT=1`);
}

// The agents' declared run-env, on BOTH surfaces. `env` is the one that fixes
// the units: the daemon is spawned with `env: process.env` and the supervisor
// hands each task `{ ...process.env }`, so without this openclaw's onboard
// never sees `OPENCLAW_WORKSPACE_DIR` and writes `~/.openclaw/workspace`
// instead of `/workspace`. `boxEnvFile` covers the other half a cloud box has
// and docker does not: the interactive tmux login shell does NOT inherit the
// daemon's env, so a hand-run `openclaw` would otherwise disagree with the
// service unit — which is exactly what the field's own doc asks for.
for (const [k, v] of Object.entries(args.agentRunEnv ?? {})) {
env.push(`${k}=${quoteShellArgv([v])}`);
boxEnvFile.push(`${k}=${quoteShellArgv([v])}`);
}

return { env, boxEnvFile };
}

Expand Down
8 changes: 8 additions & 0 deletions packages/sandbox-cloud/src/cloud-provider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,7 @@ import {
TERM_FALLBACK_SNIPPET,
} from '@agentbox/sandbox-docker';
import {
buildCloudBoxRunEnv,
cloudVolumesUsable,
ensureAgentsInstalledForCloud,
ensureAgentVolumesForCloud,
Expand Down Expand Up @@ -607,6 +608,9 @@ export function createCloudProvider(
// refresh, so reading the port off it here is what would let ctl bind one
// port while the host forwards another.
webProxyPort: webPort,
// Re-sent on every kick: the kick REWRITES box.env with `tee`, so a value
// it omits is gone for the rest of the box's life, not merely stale.
agentRunEnv: buildCloudBoxRunEnv(box.agents ?? []),

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Resume drops cloud agent run-env

Medium Severity

Resume builds agentRunEnv from box.agents ?? [], so a missing agents field becomes no run-env. Create uses agentVolumes.agents, which means every agent when the caller omitted a selection, and the kick then rewrites box.env with tee. A later start therefore strips OPENCLAW_WORKSPACE_DIR (and other declared run-env) for generic and pre-selection boxes, undoing the workspace fix this change just applied.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 32c257c. Configure here.

launchDockerd: opts.launchDockerd !== false,
vncPassword: box.vncEnabled ? box.vncPassword : undefined,
controlPlaneUrl: box.cloud?.controlPlaneUrl,
Expand Down Expand Up @@ -1115,6 +1119,10 @@ export function createCloudProvider(
gitPushMode: req.gitPushMode,
hubGitAuth: req.hubGitAuth,
boxHost: deriveCloudBoxHost(name, webPreview?.url),
// `agentVolumes.env` also carries the forwarded provider API keys —
// take the declared run-env alone, since this half is written to the
// world-readable box.env.
agentRunEnv: buildCloudBoxRunEnv(agentVolumes.agents),
onLog: log,
});

Expand Down
8 changes: 7 additions & 1 deletion packages/sandbox-cloud/src/sync/workspace-seed.ts
Original file line number Diff line number Diff line change
Expand Up @@ -955,7 +955,13 @@ async function seedFromTar(args: SeedFromTarArgs): Promise<void> {
const stage = await mkdtemp(join(tmpdir(), 'agentbox-tar-'));
const tarPath = join(stage, 'workspace.tar.gz');
try {
await execa('tar', ['-C', args.hostDir, '-czf', tarPath, '.']);
// COPYFILE_DISABLE silences macOS BSD tar's `._*` resource-fork stubs,
// which would otherwise land in the box's /workspace. This is the NO-GIT
// seed — an OpenClaw workspace is usually exactly that — and a real hetzner
// box came up with `._agentbox.yaml`, `._AGENTS.md` and `._skills` in it.
await execa('tar', ['-C', args.hostDir, '-czf', tarPath, '.'], {
env: { ...process.env, COPYFILE_DISABLE: '1' },
});
const remoteTar = '/tmp/agentbox-workspace.tar.gz';
await args.backend.uploadFile(args.handle, tarPath, remoteTar);
const SUDO = `if command -v sudo >/dev/null 2>&1; then SUDO='sudo -n'; else SUDO=''; fi`;
Expand Down
58 changes: 58 additions & 0 deletions packages/sandbox-cloud/test/bootstrap-env.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -76,3 +76,61 @@ describe('buildBootstrapEnv control-plane threading', () => {
});
});
});

/**
* The agents' declared run-env has to reach BOTH surfaces on a cloud box.
*
* Docker delivers `spec.boxRunEnv` through `docker run -e`, where one container
* env serves the ctl daemon and every login shell alike. A VPS has no such
* store, and the two halves are reached differently:
*
* - `env` is exported before `agentbox-ctl bootstrap`; the daemon is spawned
* with `env: process.env` and hands each task `{ ...process.env }`, so this
* is what the units see. Without it openclaw's onboard never saw
* `OPENCLAW_WORKSPACE_DIR` and wrote `~/.openclaw/workspace` — measured on a
* real hetzner box whose /workspace held the user's files all along.
* - `boxEnvFile` becomes /etc/agentbox/box.env, which the interactive tmux
* login shell sources. It does NOT inherit the daemon's env, so without this
* a hand-run `openclaw` would disagree with the service unit.
*
* The kick REWRITES box.env with `tee` on every create and resume, so a value
* omitted here is gone for the life of the box, not merely stale.
*/
describe('buildBootstrapEnv agent run-env', () => {
it('puts a declared run-env var on both surfaces', () => {
const { env, boxEnvFile } = buildBootstrapEnv({
...base,
agentRunEnv: { OPENCLAW_WORKSPACE_DIR: '/workspace' },
});
expect(env).toContain('OPENCLAW_WORKSPACE_DIR=/workspace');
expect(boxEnvFile).toContain('OPENCLAW_WORKSPACE_DIR=/workspace');
});

it('adds nothing when no agent declares one', () => {
const withNone = buildBootstrapEnv(base);
const withEmpty = buildBootstrapEnv({ ...base, agentRunEnv: {} });
expect(withEmpty.env).toEqual(withNone.env);
expect(withEmpty.boxEnvFile).toEqual(withNone.boxEnvFile);
});

it('shell-quotes a value so box.env survives `set -a; . box.env`', () => {
// box.env is sourced, not parsed: an unquoted space would split the value.
const { boxEnvFile } = buildBootstrapEnv({
...base,
agentRunEnv: { SOME_DIR: '/a b/c' },
});
const line = boxEnvFile.find((l) => l.startsWith('SOME_DIR='));
expect(line).toBeDefined();
expect(line).not.toBe('SOME_DIR=/a b/c');
expect(line).toContain("'");
});

it('merges several agents without dropping either', () => {
const { env } = buildBootstrapEnv({
...base,
agentRunEnv: { A_ONE: '1', B_TWO: '2' },
});
expect(env).toContain('A_ONE=1');
expect(env).toContain('B_TWO=2');
});
});
7 changes: 6 additions & 1 deletion packages/sandbox-core/src/sync/concerns/env.ts
Original file line number Diff line number Diff line change
Expand Up @@ -143,7 +143,12 @@ export async function pushEnvFiles(
const packed = await execa(
'tar',
['-C', ctx.hostWorkspace, '--null', '-T', '-', '-cf', localTar],
{ input: list.join('\0'), reject: false },
{
input: list.join('\0'),
reject: false,
// COPYFILE_DISABLE silences macOS BSD tar's `._*` resource-fork stubs.
env: { ...process.env, COPYFILE_DISABLE: '1' },
},
);
if (packed.exitCode !== 0) {
ctx.onLog(`warning: env-file tar pack failed: ${String(packed.stderr).slice(0, 300)}`);
Expand Down
2 changes: 2 additions & 0 deletions packages/sandbox-docker/src/sync/host-export.ts
Original file line number Diff line number Diff line change
Expand Up @@ -401,6 +401,8 @@ export async function copyHostFilesToBox(opts: CopyHostFilesOptions): Promise<{
input: list.join('\0'),
encoding: 'buffer',
reject: false,
// COPYFILE_DISABLE silences macOS BSD tar's `._*` resource-fork stubs.
env: { ...process.env, COPYFILE_DISABLE: '1' },
});
if (packed.exitCode !== 0) {
log(`warning: env-file tar pack failed: ${String(packed.stderr).slice(0, 300)}`);
Expand Down
Loading