Skip to content
Merged
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
21 changes: 0 additions & 21 deletions .devcontainer/Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -28,29 +28,8 @@
&& apt-get update && apt-get install -y --no-install-recommends docker-ce-cli docker-compose-plugin \
&& rm -rf /var/lib/apt/lists/*

# Cloudflare publishes no trixie suite (pkg.cloudflare.com/cloudflared/dists/trixie returns HTTP 404);
# the bookworm package is compatible with trixie, so this suite stays pinned to bookworm.
RUN curl -fsSL https://pkg.cloudflare.com/cloudflare-main.gpg \
-o /usr/share/keyrings/cloudflare-main.gpg \
&& echo "deb [arch=$(dpkg --print-architecture) signed-by=/usr/share/keyrings/cloudflare-main.gpg] https://pkg.cloudflare.com/cloudflared bookworm main" \
> /etc/apt/sources.list.d/cloudflared.list \
&& apt-get update && apt-get install -y --no-install-recommends cloudflared \
&& rm -rf /var/lib/apt/lists/*

RUN BUN_INSTALL=/usr/local curl -fsSL https://bun.sh/install | bash

ARG HERDR_VERSION=0.7.4
RUN case "$(dpkg --print-architecture)" in \
amd64) herdr_arch=x86_64; herdr_sha=bc0fc02d4ba500f9cac2353a43e67fe036785ecca6eb55378e050fac3c103059 ;; \
arm64) herdr_arch=aarch64; herdr_sha=544e0002de42806d1ab64ccdef3a7e7414f24717b0b6b022bc9e57d2eefd26a2 ;; \
*) echo "Unsupported Herdr architecture: $(dpkg --print-architecture)" >&2; exit 1 ;; \
esac \
&& curl -fsSL "https://github.com/ogulcancelik/herdr/releases/download/v${HERDR_VERSION}/herdr-linux-${herdr_arch}" \
-o /usr/local/bin/herdr \
&& echo "${herdr_sha} /usr/local/bin/herdr" | sha256sum -c - \
&& chmod 0755 /usr/local/bin/herdr \
&& test "$(herdr --version)" = "herdr ${HERDR_VERSION}"

RUN curl -LsSf https://astral.sh/uv/install.sh | env INSTALLER_NO_MODIFY_PATH=1 sh \
&& cp /root/.local/bin/uv /usr/local/bin/uv \
&& cp /root/.local/bin/uvx /usr/local/bin/uvx
Expand Down Expand Up @@ -78,7 +57,7 @@
ENV NPM_USER_PREFIX="/home/sandbox/.local"
ENV PATH="$NPM_USER_PREFIX/bin:$PNPM_HOME:$PATH"
ENV COREPACK_ENABLE_DOWNLOAD_PROMPT=0
RUN corepack enable && corepack prepare pnpm@10.33.0 --activate \

Check failure on line 60 in .devcontainer/Dockerfile

View workflow job for this annotation

GitHub Actions / Boot Path Lint (shellcheck + hadolint)

SC2015 info: Note that A && B || C is not if-then-else. C may run when A is true.
&& pnpm setup --force 2>/dev/null || true

SHELL ["/bin/bash", "-c"]
Expand Down Expand Up @@ -136,7 +115,7 @@

COPY --chown=sandbox:sandbox .oh/install/.zshrc /home/sandbox/.zshrc

RUN printf '%s\n' \

Check failure on line 118 in .devcontainer/Dockerfile

View workflow job for this annotation

GitHub Actions / Boot Path Lint (shellcheck + hadolint)

SC2016 info: Expressions don't expand in single quotes, use double quotes for that.
'export NPM_USER_PREFIX="/home/sandbox/.local"' \
'export PNPM_HOME="/usr/local/share/pnpm"' \
'export PATH="$NPM_USER_PREFIX/bin:$PNPM_HOME:$PATH"' \
Expand All @@ -150,7 +129,7 @@

RUN su - sandbox -c "git config --global --add safe.directory ${OH_PROJECT_ROOT}"

RUN echo "cd ${OH_PROJECT_ROOT} 2>/dev/null" >> /home/sandbox/.bashrc

Check failure on line 132 in .devcontainer/Dockerfile

View workflow job for this annotation

GitHub Actions / Boot Path Lint (shellcheck + hadolint)

DL3059 info: Multiple consecutive `RUN` instructions. Consider consolidation.

COPY --chown=sandbox:sandbox .oh/install/ /home/sandbox/install/
RUN chmod +x /home/sandbox/install/*.sh
Expand Down
10 changes: 5 additions & 5 deletions .devcontainer/docker-compose.image-only.yml
Original file line number Diff line number Diff line change
Expand Up @@ -57,11 +57,11 @@ services:
interval: 30s
timeout: 10s
retries: 3
# Boot now installs the default harnesses into the home mount instead of
# unpacking them from the image (entrypoint.sh, bounded by
# OH_PROVISION_HARNESSES_TIMEOUT). A cold first boot on a fresh home mount
# therefore spends up to 180s on npm before the control plane is reachable.
# 300s left ~120s for the rest of boot; 600s keeps the same headroom.
# Boot installs the default harnesses AND the default tools (herdr,
# cloudflared) into the home mount instead of unpacking them from the
# image (entrypoint.sh, bounded by OH_PROVISION_DEFAULTS_TIMEOUT). A cold
# first boot on a fresh home mount therefore spends up to 240s downloading
# before the control plane is reachable; 600s leaves room for the rest.
start_period: 600s
restart: unless-stopped

Expand Down
10 changes: 5 additions & 5 deletions .devcontainer/docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -83,11 +83,11 @@ services:
interval: 30s
timeout: 10s
retries: 3
# Boot now installs the default harnesses into the home mount instead of
# unpacking them from the image (entrypoint.sh, bounded by
# OH_PROVISION_HARNESSES_TIMEOUT). A cold first boot on a fresh home mount
# therefore spends up to 180s on npm before the control plane is reachable.
# 300s left ~120s for the rest of boot; 600s keeps the same headroom.
# Boot installs the default harnesses AND the default tools (herdr,
# cloudflared) into the home mount instead of unpacking them from the
# image (entrypoint.sh, bounded by OH_PROVISION_DEFAULTS_TIMEOUT). A cold
# first boot on a fresh home mount therefore spends up to 240s downloading
# before the control plane is reachable; 600s leaves room for the rest.
start_period: 600s
restart: unless-stopped

Expand Down
9 changes: 5 additions & 4 deletions .devcontainer/entrypoint.sh
Original file line number Diff line number Diff line change
Expand Up @@ -150,10 +150,11 @@ if [ -x "$HARNESS/.oh/scripts/link-providers.sh" ]; then
fi
fi

if [ "${OH_PROVISION_HARNESSES:-true}" = "true" ] \
&& [ -x "$HARNESS/.oh/scripts/provision-harnesses.sh" ]; then
if ! OH_EXECUTION_TARGET=local timeout "${OH_PROVISION_HARNESSES_TIMEOUT:-180}" bash "$HARNESS/.oh/scripts/provision-harnesses.sh"; then
echo "[entrypoint] WARNING: harness provisioning did not complete; run: bash .oh/scripts/provision-harnesses.sh" >&2
if [ "${OH_PROVISION_DEFAULTS:-true}" = "true" ] \
&& [ -x "$HARNESS/.oh/scripts/provision-defaults.sh" ]; then
if ! OH_EXECUTION_TARGET=local timeout "${OH_PROVISION_DEFAULTS_TIMEOUT:-240}" bash "$HARNESS/.oh/scripts/provision-defaults.sh"; then
echo "[entrypoint] WARNING: default provisioning did not complete; run: bash .oh/scripts/provision-defaults.sh" >&2
echo "[entrypoint] WARNING: herdr may be unavailable — 'tmux' still works as a fallback multiplexer" >&2
fi
fi

Expand Down
4 changes: 2 additions & 2 deletions .github/workflows/sandbox-boot-guard.yml
Original file line number Diff line number Diff line change
Expand Up @@ -134,8 +134,8 @@ jobs:

# This step is the ONLY place the boot-time harness install runs end to end.
# It boots on a fresh home volume (the smoke tears down with `down -v`), so
# provision-harnesses.sh does real npm work every run and the smoke asserts
# the result. Do not set OH_PROVISION_HARNESSES=false here to speed it up —
# provision-defaults.sh does real npm work every run and the smoke asserts
# the result. Do not set OH_PROVISION_DEFAULTS=false here to speed it up —
# that would return the install path to being untested dead code.
- name: Boot sandbox and verify healthcheck (exercises boot-time harness provisioning)
env:
Expand Down
10 changes: 5 additions & 5 deletions .oh/cli/src/__tests__/harness-catalog.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -158,8 +158,8 @@ describe("harness catalog", () => {
});

it("provisions the default harnesses at boot rather than baking them", () => {
expect(ENTRYPOINT).toContain("OH_PROVISION_HARNESSES");
expect(ENTRYPOINT).toContain(".oh/scripts/provision-harnesses.sh");
expect(ENTRYPOINT).toContain("OH_PROVISION_DEFAULTS");
expect(ENTRYPOINT).toContain(".oh/scripts/provision-defaults.sh");
});

it.each(defaults.map((h) => [h.id, h] as const))(
Expand All @@ -169,7 +169,7 @@ describe("harness catalog", () => {
expect(pkg, `${id} declares no install package`).toMatch(/^(@[^/]+\/)?[^-].*/);
expect(
DOCKERFILE_CODE,
`${id} is baked into the image; it belongs to provision-harnesses.sh`,
`${id} is baked into the image; it belongs to provision-defaults.sh`,
).not.toContain(pkg);
},
);
Expand All @@ -180,9 +180,9 @@ describe("harness catalog", () => {

it("bounds the boot-path provisioner so an unreachable registry cannot stall the entrypoint", () => {
expect(ENTRYPOINT).toMatch(
/timeout "\$\{OH_PROVISION_HARNESSES_TIMEOUT:-\d+\}" bash "\$HARNESS\/\.oh\/scripts\/provision-harnesses\.sh"/,
/timeout "\$\{OH_PROVISION_DEFAULTS_TIMEOUT:-\d+\}" bash "\$HARNESS\/\.oh\/scripts\/provision-defaults\.sh"/,
);
expect(ENTRYPOINT).toContain("WARNING: harness provisioning did not complete");
expect(ENTRYPOINT).toContain("WARNING: default provisioning did not complete");
});
});

Expand Down
53 changes: 48 additions & 5 deletions .oh/cli/src/__tests__/tool-catalog.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,8 +25,27 @@ describe("tool catalog shape", () => {
]);
});

it("has exactly one installable tool", () => {
expect(installableToolIds()).toEqual(["agent-browser"]);
it("makes exactly the default and opt-in tools installable", () => {
expect(installableToolIds()).toEqual(["agent-browser", "herdr", "cloudflared"]);
for (const t of TOOL_CATALOG) {
// A kind:"default" tool is provisioned at boot through `oh tool install`,
// so it MUST be installable; a baked-in one must not be.
if (t.kind === "default") expect(t.installArgv, t.id).toBeDefined();
if (t.kind === "baked-in") expect(t.installArgv, t.id).toBeUndefined();
}
});

// #906: commands/tool.ts installs with stdio:"inherit", so local-target.ts
// picks plain `sudo --` for a root install — and /etc/sudoers.d/sandbox has
// no NOPASSWD. A root-installed default would hang an agent on a password
// prompt, and could not be upgraded by the running sandbox afterwards.
it("installs every default tool as the sandbox user into the home mount", () => {
for (const t of TOOL_CATALOG) {
if (t.kind !== "default") continue;
expect(t.installUser, t.id).toBe("sandbox");
expect(t.installArgv!.join("\n"), t.id).toContain("NPM_USER_PREFIX");
expect(t.installArgv!.join("\n"), t.id).toContain("sha256sum -c -");
}
});

it("makes every non-installable tool say why", () => {
Expand All @@ -49,17 +68,29 @@ describe("tool catalog shape", () => {

it("declares a version probe only where the flag is a safe standard", () => {
const withVersion = TOOL_CATALOG.filter((t) => t.versionArgv !== undefined).map((t) => t.id);
expect(withVersion).toEqual(["cloudflared", "docker-cli", "gh"]);
expect(withVersion).toEqual(["herdr", "cloudflared", "docker-cli", "gh"]);
for (const t of TOOL_CATALOG) {
if (t.versionArgv) expect(t.versionArgv, t.id).toEqual([t.binary, "--version"]);
}
});

it("passes argv arrays with no interpolation this process performs", () => {
// The hazard is a JS template literal that Node expands before the argv
// ever reaches a shell. A `bash -lc` script body legitimately contains
// ${...} for the shell IN the container to expand, so exempt that one
// token and forbid backticks in the catalog source instead.
expect(
read(".oh/cli/src/lib/tools/catalog.ts"),
"a template literal with ${...} would be expanded by Node before any shell sees it",
).not.toMatch(/`[^`]*\$\{/s);
for (const t of TOOL_CATALOG) {
for (const argv of [t.installArgv, t.verifyArgv, t.versionArgv]) {
if (!argv) continue;
for (const token of argv) expect(token, `${t.id}: ${token}`).not.toContain("${");
const shellBody = argv[0] === "bash" && argv[1] === "-lc" ? 2 : -1;
argv.forEach((token, i) => {
if (i === shellBody) return;
expect(token, `${t.id}: ${token}`).not.toContain("${");
});
}
}
});
Expand Down Expand Up @@ -153,8 +184,20 @@ describe("baked-in tools", () => {

it("are each actually in the Dockerfile", () => {
const dockerfile = read(".devcontainer/Dockerfile");
const baked = TOOL_CATALOG.filter((t) => t.kind === "baked-in");
expect(baked.length, "no baked-in tool left to check").toBeGreaterThan(0);
for (const t of baked) {
expect(dockerfile, t.id).toContain(t.binary);
}
});

// #906: herdr and cloudflared moved to kind:"default". The inverse of the
// check above — a default tool must NOT be in the Dockerfile — lives in
// .oh/evals/probes/default-provisioning.sh, which matches on the pinned
// project URL rather than the bare binary name.
it("no longer claims herdr or cloudflared", () => {
for (const id of ["herdr", "cloudflared"]) {
expect(dockerfile, id).toContain(id);
expect(findTool(id)!.kind, id).toBe("default");
}
});
});
14 changes: 12 additions & 2 deletions .oh/cli/src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -370,7 +370,7 @@ runtime (see \`oh runtime\`) — a headless browser, a tunnel client, the
GitHub CLI.

Usage:
oh tool list List known tools and their state
oh tool list [--defaults] List known tools and their state
oh tool status [name] Show installed state and version
oh tool install <name> Install a tool into the sandbox

Expand All @@ -387,6 +387,7 @@ Flags:
--no-persist Live-install only; leave oh.json unchanged
--yes Accept a large download without prompting
--json Machine-readable output (list/status)
--defaults List only kind:"default" tools (list)

Tools:
${toolIds().map((t) => ` ${t}`).join("\n")}
Expand Down Expand Up @@ -943,13 +944,15 @@ interface ToolArgs {
noPersist: boolean;
yes: boolean;
json: boolean;
defaultsOnly: boolean;
subcommand?: "list" | "install" | "status";
name?: string;
}

export function parseToolArgs(rest: string[]): ParseResult<ToolArgs> {
const args: ToolArgs = {
help: false, persistOnly: false, noPersist: false, yes: false, json: false,
defaultsOnly: false,
};
if (rest.length === 0 || isHelpFlag(rest[0])) {
return { ok: true, args: { ...args, help: true } };
Expand All @@ -961,6 +964,7 @@ export function parseToolArgs(rest: string[]): ParseResult<ToolArgs> {
else if (token === "--no-persist") args.noPersist = true;
else if (token === "--yes" || token === "-y") args.yes = true;
else if (token === "--json") args.json = true;
else if (token === "--defaults") args.defaultsOnly = true;
else if (token.startsWith("-")) {
return { ok: false, error: `oh tool: unknown flag "${token}"` };
} else positionals.push(token);
Expand Down Expand Up @@ -989,6 +993,12 @@ export function parseToolArgs(rest: string[]): ParseResult<ToolArgs> {
error: "oh tool: --persist-only conflicts with --no-persist — pass at most one",
};
}
if (args.defaultsOnly && sub !== "list") {
return {
ok: false,
error: `oh tool ${sub}: --defaults applies to \`oh tool list\` only`,
};
}

args.subcommand = sub;
if (name !== undefined) args.name = name;
Expand Down Expand Up @@ -1405,7 +1415,7 @@ async function main(argv: string[]): Promise<number> {
stderr: (s) => process.stderr.write(s),
};
if (a.subcommand === "list") {
return await runToolList({ json: a.json }, io);
return await runToolList({ json: a.json, defaultsOnly: a.defaultsOnly }, io);
}
if (a.subcommand === "status") {
return await runToolStatus(a.name, { json: a.json }, io);
Expand Down
17 changes: 13 additions & 4 deletions .oh/cli/src/commands/tool.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import {
setInstallFlag,
} from "../lib/env-file.js";
import {
defaultTools,
findTool,
installableToolIds,
toolIds,
Expand All @@ -32,6 +33,7 @@ export interface ToolOptions {
cwd?: string;
run?: LifecycleRunner;
json?: boolean;
defaultsOnly?: boolean;
env?: NodeJS.ProcessEnv;
}

Expand All @@ -44,6 +46,7 @@ export interface ToolInstallOptions extends ToolOptions {
interface ToolRow {
id: string;
title: string;
binary: string;
kind: string;
enabled: boolean | null;
installed: boolean | null;
Expand Down Expand Up @@ -107,9 +110,9 @@ async function collectRows(
root: string,
run: LifecycleRunner,
env?: NodeJS.ProcessEnv,
only?: ToolEntry,
only?: readonly ToolEntry[],
): Promise<ToolRow[]> {
const entries = only ? [only] : [...TOOL_CATALOG];
const entries = only ? [...only] : [...TOOL_CATALOG];
const target = targetFor(root, run, env);

let reachable = false;
Expand All @@ -125,6 +128,7 @@ async function collectRows(
rows.push({
id: entry.id,
title: entry.title,
binary: entry.binary,
kind: entry.kind,
enabled:
entry.toolKey === undefined ? null : isInstallFlagEnabled(root, entry.toolKey),
Expand Down Expand Up @@ -175,7 +179,12 @@ function renderDetail(rows: ToolRow[], io: ToolIO): void {
export async function runToolList(opts: ToolOptions, io: ToolIO): Promise<number> {
const run = opts.run ?? spawnRunner;
const root = resolveProjectRoot(opts.cwd);
const rows = await collectRows(root, run, opts.env);
const rows = await collectRows(
root,
run,
opts.env,
opts.defaultsOnly === true ? defaultTools() : undefined,
);
if (opts.json) {
io.stdout(`${JSON.stringify(rows, null, 2)}\n`);
} else {
Expand Down Expand Up @@ -204,7 +213,7 @@ export async function runToolStatus(
if (!only) return unknownTool(name, io);
}

const rows = await collectRows(root, run, opts.env, only);
const rows = await collectRows(root, run, opts.env, only ? [only] : undefined);
if (opts.json) {
io.stdout(`${JSON.stringify(only ? rows[0] : rows, null, 2)}\n`);
} else {
Expand Down
Loading
Loading