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
34 changes: 0 additions & 34 deletions .devcontainer/Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -34,46 +34,15 @@
&& cp /root/.local/bin/uv /usr/local/bin/uv \
&& cp /root/.local/bin/uvx /usr/local/bin/uvx

ARG INSTALL_HERMES=false
RUN if [ "${INSTALL_HERMES}" = "true" ]; then \
curl -fsSL https://hermes-agent.nousresearch.com/install.sh \
| bash -s -- --skip-setup --skip-browser; \
rm -rf /root/.hermes; \
uv pip install --python /usr/local/lib/hermes-agent/venv/bin/python 'hermes-agent[slack,teams,web,pty]'; \
else \
echo "Skipping Hermes Agent CLI install (INSTALL_HERMES=false)"; \
fi

ARG INSTALL_DEEPAGENTS=false
RUN export UV_TOOL_DIR=/opt/uv/tools UV_TOOL_BIN_DIR=/usr/local/bin \
&& mkdir -p "$UV_TOOL_DIR" \
&& if [ "${INSTALL_DEEPAGENTS}" = "true" ]; then \
uv tool install deepagents-cli; \
else \
echo "Skipping DeepAgents CLI install (INSTALL_DEEPAGENTS=false)"; \
fi

ENV PNPM_HOME="/usr/local/share/pnpm"
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 41 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"]

ARG INSTALL_OPENCODE=false
ARG INSTALL_GROK_BUILD=false

RUN set -e; \
if [ "${INSTALL_OPENCODE}" = "true" ]; then npm install -g opencode-ai; \
else echo "Skipping OpenCode CLI install (INSTALL_OPENCODE=false)"; fi; \
if [ "${INSTALL_GROK_BUILD}" = "true" ]; then \
curl -fsSL https://x.ai/cli/install.sh | HOME=/opt/grok-build GROK_BIN_DIR=/opt/grok-build/bin bash -s 0.2.39; \
ln -sf /opt/grok-build/bin/grok /usr/local/bin/grok; \
rm -f /usr/local/bin/agent; \
else echo "Skipping Grok Build CLI install (INSTALL_GROK_BUILD=false)"; fi

RUN npm install -g cc-safety-net@1.0.6

COPY .oh/cli/ /opt/oh/
Expand Down Expand Up @@ -115,7 +84,7 @@

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

RUN printf '%s\n' \

Check failure on line 87 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 @@ -129,7 +98,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 101 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 All @@ -146,9 +115,6 @@
&& rm -rf /home/sandbox/.cache/uv

FROM base AS final
RUN chown -R sandbox:sandbox /opt/uv 2>/dev/null || true \
&& if [ -d /usr/local/lib/hermes-agent ]; then chown -R sandbox:sandbox /usr/local/lib/hermes-agent; fi

RUN mkdir -p /.devcontainer && echo '{"workspaceFolder":"/home/sandbox/harness","remoteUser":"sandbox"}' > /.devcontainer/devcontainer.json

LABEL devcontainer.metadata='[{"remoteUser":"sandbox","workspaceFolder":"/home/sandbox/harness"}]'
Expand Down
5 changes: 0 additions & 5 deletions .devcontainer/docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -39,11 +39,6 @@ services:
build:
context: ..
dockerfile: .devcontainer/Dockerfile
args:
INSTALL_OPENCODE: ${INSTALL_OPENCODE:-false}
INSTALL_GROK_BUILD: ${INSTALL_GROK_BUILD:-false}
INSTALL_DEEPAGENTS: ${INSTALL_DEEPAGENTS:-false}
INSTALL_HERMES: ${INSTALL_HERMES:-false}
volumes:
- ${OH_HOME_MOUNT:-workspace}:/home/sandbox
- ..:/home/sandbox/harness
Expand Down
80 changes: 65 additions & 15 deletions .github/workflows/sandbox-compatibility.yml
Original file line number Diff line number Diff line change
Expand Up @@ -50,8 +50,8 @@ jobs:
node:22-bookworm-slim \
node:22-trixie-slim

optional-installers-image:
name: Build one amd64 image with every optional installer
optional-harness-install:
name: Install every optional harness through the CLI
runs-on: ${{ vars.CI_RUNNER || 'ubuntu-latest' }}

steps:
Expand All @@ -64,31 +64,81 @@ jobs:
- name: Ensure Mifune submodule
run: bash .oh/scripts/link-providers.sh --init

- name: Build with all INSTALL_* paths enabled
# #908 removed the INSTALL_* build args, so there is no longer an image to
# build with every optional installer enabled. The path that replaced it —
# `oh harness install <id>` into the home mount, as the sandbox user — is
# what this job must exercise instead. Building and then INSTALLING is
# strictly closer to what an operator does than the old build-arg matrix.
- name: Build the sandbox image
run: |
docker build \
--file .devcontainer/Dockerfile \
--build-arg INSTALL_HERMES=true \
--build-arg INSTALL_DEEPAGENTS=true \
--build-arg INSTALL_OPENCODE=true \
--build-arg INSTALL_GROK_BUILD=true \
--tag openharness-sandbox-optional:${{ github.sha }} \
.

- name: Verify the optional image still satisfies the default contract
- name: Verify the image satisfies the default contract
run: bash .oh/scripts/verify-sandbox-image.sh openharness-sandbox-optional:${{ github.sha }}

- name: Verify a version from every optional CLI
- name: Install each optional harness through the CLI and verify its version
run: |
set -euo pipefail
for tool in hermes deepagents opencode grok; do
echo "--- $tool --version"
output="$(docker run --rm --entrypoint /bin/bash \
openharness-sandbox-optional:${{ github.sha }} \
-lc "$tool --version" 2>&1)"
cid=$(docker run -d --rm \
-e SANDBOX_NAME=openharness-optional-${{ github.run_id }} \
-e OH_PROVISION_DEFAULTS=false \
--entrypoint sleep \
openharness-sandbox-optional:${{ github.sha }} infinity)
trap 'docker rm -f "$cid" >/dev/null 2>&1 || true' EXIT

# The image ships an empty /home/sandbox; seed it the way the
# entrypoint would so ~/.local exists and is sandbox-owned.
docker exec "$cid" bash -lc 'cp -a -n /opt/home-seed/. /home/sandbox/ 2>/dev/null || true; chown -R sandbox:sandbox /home/sandbox'

ids=$(docker exec -u sandbox "$cid" bash -lc \
'cd /opt/oh-seed && OH_EXECUTION_TARGET=local oh harness list --json' \
| jq -r '.[] | select(.kind == "optional") | .id')
if [ -z "$ids" ]; then
echo "ERROR: the harness catalog declares no optional harnesses — this job would pass vacuously" >&2
exit 1
fi

# These four installs reach four third-party endpoints. A transient
# upstream failure must not block this repo's merges: Hermes' own
# installer hard-fails the whole install when its `npm install` step
# blips, which took this job down once on a commit that was correct
# (the rerun passed unchanged). One retry absorbs that. It does NOT
# weaken the contract below — a genuine break (wrong user, wrong
# path, a sudo prompt) fails both attempts and still fails the job.
for id in $ids; do
for attempt in 1 2; do
echo "--- oh harness install $id (attempt $attempt)"
if docker exec -u sandbox "$cid" bash -lc \
"cd /opt/oh-seed && OH_EXECUTION_TARGET=local oh harness install '$id' --no-persist"; then
break
fi
if [ "$attempt" = 2 ]; then
echo "ERROR: $id failed to install twice — this is not a transient upstream blip" >&2
exit 1
fi
echo "$id install failed; retrying once after 15s in case the upstream endpoint blipped" >&2
sleep 15
done
done

for id in $ids; do
binary=$(docker exec -u sandbox "$cid" bash -lc \
'cd /opt/oh-seed && OH_EXECUTION_TARGET=local oh harness list --json' \
| jq -r --arg id "$id" '.[] | select(.id == $id) | .binary')
echo "--- $binary --version"
output=$(docker exec -u sandbox "$cid" bash -lc "
path=\$(type -P '$binary')
case \"\$path\" in
/home/sandbox/.local/*) ;;
*) echo \"$id resolved to '\$path', not under /home/sandbox/.local\" >&2; exit 1 ;;
esac
\"\$path\" --version" 2>&1)
printf '%s\n' "$output"
if ! grep -Eq '(^|[^[:alnum:]])v?[0-9]+([.][0-9]+)+([^[:alnum:]]|$)' <<<"$output"; then
echo "ERROR: $tool --version did not output a numeric dotted version" >&2
echo "ERROR: $binary --version did not output a numeric dotted version" >&2
exit 1
fi
done
69 changes: 36 additions & 33 deletions .oh/cli/src/__tests__/harness-catalog.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,12 +42,6 @@ describe("harness catalog", () => {
}
});

it("pairs harnessKey and buildArg — never one without the other", () => {
for (const h of HARNESS_CATALOG) {
expect(Boolean(h.harnessKey)).toBe(Boolean(h.buildArg));
}
});

it("gives every optional harness a flag, and no other kind one", () => {
for (const h of HARNESS_CATALOG) {
if (h.kind === "optional") expect(h.harnessKey).toBeDefined();
Expand All @@ -67,60 +61,69 @@ describe("harness catalog", () => {
}
});

describe("does not drift from the image build", () => {
const flagged = HARNESS_CATALOG.filter((h) => h.buildArg !== undefined);
// #908: the INSTALL_* build args are gone. The catalog no longer mirrors the
// Dockerfile — it replaces it, and `oh harness install` is the only path.
describe("owns the install, and the image no longer does", () => {
const optional = HARNESS_CATALOG.filter((h) => h.kind === "optional");

it("covers all four optional harnesses", () => {
expect(flagged.map((h) => h.id).sort()).toEqual([
expect(optional.map((h) => h.id).sort()).toEqual([
"deepagents",
"grok-build",
"hermes",
"opencode",
]);
});

it.each(flagged.map((h) => [h.id, h] as const))(
"%s: build arg is in the Dockerfile",
it("declares no buildArg anywhere — the field itself is gone", () => {
expect(read(".oh/cli/src/lib/harnesses/catalog.ts")).not.toContain("buildArg");
});

it.each(optional.map((h) => [h.id, h] as const))(
"%s: its INSTALL_* build arg is absent from the Dockerfile",
(_id, h) => {
expect(DOCKERFILE).toContain(h.buildArg as string);
const arg = `INSTALL_${(h.harnessKey as string).toUpperCase()}`;
expect(DOCKERFILE).not.toMatch(new RegExp(`^ARG ${arg}`, "m"));
expect(COMPOSE_YML).not.toContain(`${arg}: \${${arg}:-false}`);
},
);

it.each(flagged.map((h) => [h.id, h] as const))(
"%s: the INSTALL_* key derived from harnessKey IS the build arg, and compose forwards it",
it.each(optional.map((h) => [h.id, h] as const))(
"%s: installs as the sandbox user into the home mount",
(_id, h) => {
expect(`INSTALL_${(h.harnessKey as string).toUpperCase()}`).toBe(h.buildArg);
expect(COMPOSE_YML).toContain(`${h.buildArg}: \${${h.buildArg}:-false}`);
expect(h.installUser).toBe("sandbox");
expect(h.installArgv.join("\n")).toMatch(/\/home\/sandbox\/\.local|\$HOME\/\.local|uv/);
},
);

it.each(flagged.map((h) => [h.id, h] as const))(
"%s: key ships documented in docs/configuration.md, the oh.json field reference",
it.each(optional.map((h) => [h.id, h] as const))(
"%s: its oh.json key stays documented in docs/configuration.md",
(_id, h) => {
const arg = `INSTALL_${(h.harnessKey as string).toUpperCase()}`;
expect(CONFIG_DOC).toMatch(
new RegExp(`^\\| \`install\\.[A-Za-z]+\` \\|.*\`${h.buildArg}\``, "m"),
new RegExp(`^\\| \`install\\.[A-Za-z]+\` \\|.*\`${arg}\``, "m"),
);
},
);

it.each(flagged.map((h) => [h.id, h] as const))(
"%s: every pinned version appears verbatim in the Dockerfile",
(_id, h) => {
for (const pin of versionPins(h.installArgv)) {
expect(DOCKERFILE).toContain(pin);
}
},
);

it("grok-build keeps the Dockerfile's exact pin", () => {
it("keeps the grok-build pin in the catalog, now that the Dockerfile has none", () => {
const grok = findHarness("grok-build");
expect(versionPins(grok!.installArgv)).toEqual(["0.2.39"]);
expect(DOCKERFILE).toContain("bash -s 0.2.39");
expect(DOCKERFILE).not.toContain("bash -s 0.2.39");
});

// INSTALL_HERMES survives as a RUNTIME flag: link-providers.sh vendors the
// Hermes skill pack from it and entrypoint.sh wires auth.json. Only its
// build-arg role is gone.
it("keeps INSTALL_HERMES as a container environment variable", () => {
expect(COMPOSE_YML).toContain("- INSTALL_HERMES=${INSTALL_HERMES:-false}");
expect(DOCKERFILE).not.toContain("INSTALL_HERMES");
});

it("installs deepagents and pi as the sandbox user, not root", () => {
expect(findHarness("deepagents")!.installUser).toBe("sandbox");
expect(findHarness("pi")!.installUser).toBe("sandbox");
it("installs every harness as the sandbox user, never root", () => {
for (const h of HARNESS_CATALOG) {
expect(h.installUser, h.id).toBe("sandbox");
}
expect(DOCKERFILE).toContain("UV_TOOL_DIR=/home/sandbox");
});
});
Expand Down
19 changes: 16 additions & 3 deletions .oh/cli/src/__tests__/harness.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -272,8 +272,17 @@ describe("runHarnessInstall against the container", () => {
const install = execCalls(calls).find((c) => c.args.includes("opencode-ai"));
expect(install).toBeDefined();
expect(install!.args).toContain("-u");
expect(install!.args).toContain("root");
expect(install!.args.slice(-4)).toEqual(["npm", "install", "-g", "opencode-ai"]);
// #908: every harness installs as the sandbox user into the home mount.
expect(install!.args).toContain("sandbox");
expect(install!.args).not.toContain("root");
expect(install!.args.slice(-6)).toEqual([
"npm",
"--prefix",
"/home/sandbox/.local",
"install",
"-g",
"opencode-ai",
]);
expect(text(out)).toContain("installed");
expect(text(out)).toContain(
"https://github.com/mifunedev/openharness/blob/main/docs/harnesses/opencode.md",
Expand Down Expand Up @@ -508,7 +517,11 @@ describe("oh harness — inside the sandbox", () => {
const { io, out } = makeIo();
expect(await runHarnessInstall("opencode", { cwd: root, run, env: INSIDE }, io)).toBe(0);
expect(text(out)).not.toContain("skipping the live install");
expect(calls.some((c) => c.cmd === "sudo" && c.args.includes("opencode-ai"))).toBe(true);
// #908: this previously asserted `cmd === "sudo"`, codifying the very defect
// that made `oh harness install opencode` hang inside the sandbox —
// stdio:"inherit" selects plain `sudo --`, and sandbox has no NOPASSWD.
expect(calls.some((c) => c.cmd === "sudo")).toBe(false);
expect(calls.some((c) => c.args.includes("opencode-ai"))).toBe(true);
expect(installFlag(root, "opencode")).toBe(true);
});

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

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);
// A `bash -lc` script body legitimately contains ${...} for the shell IN the
// container to expand, so that one token is exempt. A source-level scan for
// an interpolating template literal was tried and removed: it cannot tell a
// JS backtick from a backtick inside prose (`notInstallableReason` has
// several), so whether it fired depended on catalog ORDER, not the hazard.
for (const t of TOOL_CATALOG) {
for (const argv of [t.installArgv, t.verifyArgv, t.versionArgv]) {
if (!argv) continue;
Expand Down
Loading
Loading