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
24 changes: 24 additions & 0 deletions .github/workflows/skill-bundle-tests.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
name: Sandbox Skill bundle tests
on:
pull_request:
paths:
- 'base-images/frameworks/sandbox/v1/**'
- '.github/workflows/skill-bundle-tests.yaml'
push:
branches: [main]
paths:
- 'base-images/frameworks/sandbox/v1/**'
- '.github/workflows/skill-bundle-tests.yaml'
permissions:
contents: read
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '22'
- run: node --test base-images/frameworks/sandbox/v1/skill-bundle*.test.mjs
- name: Build and verify every Skill from the actual pinned repository
run: node base-images/frameworks/sandbox/v1/verify-pinned-source.mjs
16 changes: 16 additions & 0 deletions base-images/frameworks/sandbox/v1/Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,18 @@ ARG NODE_IMAGE_VERSION=v0.0.1-alpha.1-${L10N_NORMALIZED}

FROM ${REGISTRY}/${REPO_CODEX_GATEWAY}:${CODEX_GATEWAY_IMAGE_TAG} AS codex-gateway

FROM ${REGISTRY}/${REPO}/node.js-22:${NODE_IMAGE_VERSION} AS skills
ARG SEALOS_SKILLS_REPOSITORY=https://github.com/norberia/sealos-skills-next.git
ARG SEALOS_SKILLS_REVISION=bdd824cf2fd6c72896f8e201f32259cc8aed3f98
RUN apt-get update && apt-get install -y --no-install-recommends git ca-certificates && rm -rf /var/lib/apt/lists/*
COPY skill-bundle.mjs /build/skill-bundle.mjs
RUN git init /source && git -C /source remote add origin "$SEALOS_SKILLS_REPOSITORY" && \
git -C /source fetch --depth 1 origin "$SEALOS_SKILLS_REVISION" && \
git -C /source checkout --detach FETCH_HEAD && \
test "$(git -C /source rev-parse HEAD)" = "$SEALOS_SKILLS_REVISION" && \
/usr/bin/node /build/skill-bundle.mjs build /source/plugins/sealos /bundle "$SEALOS_SKILLS_REVISION" && \
/usr/bin/node /build/skill-bundle.mjs verify /bundle

FROM ${REGISTRY}/${REPO}/node.js-22:${NODE_IMAGE_VERSION}
ARG L10N
LABEL org.opencontainers.image.authors="The Devbox Authors"
Expand Down Expand Up @@ -52,6 +64,9 @@ ENV KANIKO_CONTEXT_S3_BASE=s3://kaniko-contexts/contexts
ENV KANIKO_CONTEXT_POSIX_DIR=/home/${DEFAULT_DEVBOX_USER}/workspace/.versitygw-s3/kaniko-contexts/contexts

COPY --from=codex-gateway /usr/local/bin/codex-gateway /usr/local/bin/codex-gateway
COPY --from=skills /bundle /opt/sealai/skill-bundle
COPY skill-bundle.mjs /opt/sealai/skill-bundle.mjs
COPY --chmod=755 prepare-skills /usr/local/bin/sealai-prepare-skills

# Add build assets and execute them.
COPY codex-gateway /tmp/codex-gateway-service
Expand All @@ -64,6 +79,7 @@ RUN chmod +x \
/tmp/versitygw-service/run \
/tmp/versitygw-service/finish && \
/build.sh && \
test -x /usr/bin/node && test -x /usr/bin/flock && test -x /usr/bin/timeout && \
rm -f /build.sh && \
rm -rf /tmp/codex-gateway-service /tmp/versitygw-service

Expand Down
39 changes: 39 additions & 0 deletions base-images/frameworks/sandbox/v1/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
# Offline managed Skills

This image owns the Sealos Skill bundle; codex-gateway only supplies its existing binary. Network access to GitHub is needed during image build, never during Skill preparation.

The `skills` build stage fetches `SEALOS_SKILLS_REPOSITORY` at the full `SEALOS_SKILLS_REVISION` commit. The default source is `https://github.com/norberia/sealos-skills-next.git`, pinned to `bdd824cf2fd6c72896f8e201f32259cc8aed3f98`, not a floating runtime branch. This preserves the repository selected by the former Brain `DEPLOY_SKILL_SOURCE` setting. Change these build arguments for a reviewed preview revision. Do not put private repository credentials in build arguments.

The build reads every Skill in the repository's canonical `plugins/sealos/skills` tree, not the top-level `skills` symlink aliases. There is no Skill-name allowlist or fixed Skill count. The source tree determines the manifest; build/verify reject empty, malformed or incomplete bundles. Updating the pinned commit picks up the source's full Skill set. Repository overrides must provide the same plugin layout.

The image contains:

- `/opt/sealai/skill-bundle`: complete Skill resource trees and a schema-1 manifest (source commit, Skill names, file SHA-256).
- `/opt/sealai/skill-bundle.mjs`: build, verify and local preparation implementation using the existing Node runtime.
- `/usr/local/bin/sealai-prepare-skills`: fixed entry point for Brain, serialized with `flock`. Lock waiting and preparation share a 28-second budget (1-second kill grace), below Brain's 30-second RPC cap. Node, flock and timeout use absolute system paths; Node environment overrides are cleared.

Run the entry point as the Devbox user after repository cloning; the workspace root must already exist. Chat has no repository clone and explicitly passes `--init-workspace`. Both modes prepare `/home/devbox/project/.agents/skills`, preserving unrelated Skills and `skills-lock.json`. Symlinks are rejected, and user files are type-checked without reading/hashing their contents.

Each replacement uses an exclusive `mkdtemp` transaction under `.sealai-skill-transactions`, outside `.agents` and on the workspace filesystem. If killed between renames, the next invocation restores the backup before creating any live Skill directory. Stages are never promoted during recovery because they may be incomplete. After publication, cleanup is best-effort; ambiguous recovery state fails closed and preserves files for inspection. Legacy PID-named backups are restored only when there is one backup and no live tree. This helper is not a security boundary against concurrent malicious filesystem mutation by the workspace owner, nor a power-loss durability guarantee.

The wrapper parses and validates success before printing schema, status, revision, bundle digest and Skill count as JSON. Empty/malformed output is failure. Failures use fixed codes, including preparation timeout, never file contents or raw exceptions. Missing or corrupt bundles fail closed. There is no npx/download fallback.

## Verification

From the repository root:

```sh
node --test base-images/frameworks/sandbox/v1/skill-bundle*.test.mjs
node base-images/frameworks/sandbox/v1/verify-pinned-source.mjs
```

The existing Runtime Smoke workflow can run `tests/runtime-smoke/frameworks/sandbox/v1/smoke.sh` in the final sandbox image. For an offline acceptance check, run that script in a disposable image container with `--network none` as the `devbox` user. The real Devbox check is separate: clone a repository first, prepare as the actual user, and exercise Chat loadSkill/loadSkillResource and a GitHub Deployment Task.

## Rollout

1. Build and publish the sandbox/v1 base image with this bundle, then its runtime image if that is the deployment target. Use the existing runtime build pipeline; do not change the Gateway image for this feature.
2. Inspect the built image's manifest and run the offline smoke check. Retain the source revision and immutable image digest in release evidence.
3. Finish active/blocked Brain Deployment Tasks. Set Brain's `DEVBOX_RUNTIME_IMAGE` to the verified immutable sandbox image and remove nonempty `DEPLOY_SKILL_SOURCE` overrides while deploying the matching Brain change.
4. Confirm fresh Chat and GitHub runtimes prepare the same revision. Old Chat runtimes are not deleted; their existing lifecycle handles retention.

Rollback restores the previous Brain/runtime image pair. Never compensate for a broken image by re-enabling a runtime internet installer. Image registry availability is still required to start a new Devbox; this change removes only Skill-install network dependencies.
2 changes: 1 addition & 1 deletion base-images/frameworks/sandbox/v1/build.sh
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,7 @@ esac

apt-get update && \
apt-get install -y wget build-essential libncursesw5-dev libssl-dev bubblewrap \
ripgrep \
ripgrep util-linux \
libsqlite3-dev tk-dev libgdbm-dev libc6-dev libbz2-dev libffi-dev zlib1g-dev && \
apt-get clean && \
rm -rf /var/lib/apt/lists/*
Expand Down
23 changes: 23 additions & 0 deletions base-images/frameworks/sandbox/v1/prepare-skills
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
#!/bin/sh
set -eu
# Do not inherit repository-selected Node options or interpreter lookup paths.
unset NODE_OPTIONS NODE_PATH
operation=prepare
case "$#:$*" in
0:) ;;
1:--init-workspace) operation=prepare-chat ;;
*) printf '%s\n' '{"schema":1,"status":"failed","reason":"invalid_arguments"}' >&2; exit 1 ;;
esac
# One shared wait + work budget, leaving room under Brain's 30-second RPC cap.
if output=$(/usr/bin/timeout --kill-after=1 28 /usr/bin/flock /tmp/sealai-runtime-skills.lock /usr/bin/node /opt/sealai/skill-bundle.mjs "$operation" /opt/sealai/skill-bundle /home/devbox/project 2>/dev/null); then
if printf '%s' "$output" | /usr/bin/node /opt/sealai/skill-bundle.mjs validate-ready 2>/dev/null; then
exit 0
fi
reason=invalid_ready
else
code=$?
reason=skill_bundle_unavailable
if [ "$code" = 124 ] || [ "$code" = 137 ]; then reason=skill_prepare_timeout; fi
fi
printf '{"schema":1,"status":"failed","reason":"%s"}\n' "$reason" >&2
exit 1
154 changes: 154 additions & 0 deletions base-images/frameworks/sandbox/v1/skill-bundle-regression.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,154 @@
import assert from 'node:assert/strict';
import { mkdtemp, mkdir, readFile, realpath, rename, rm, symlink, writeFile } from 'node:fs/promises';
import { spawnSync } from 'node:child_process';
import os from 'node:os';
import path from 'node:path';
import test from 'node:test';
import { buildBundle, prepareBundle, validateReady, verifyBundle } from './skill-bundle.mjs';

const revision = 'b'.repeat(40);
async function fixture(t, names = ['sealos-deploy']) {
const root = await mkdtemp(path.join(await realpath(os.tmpdir()), 'skills-regression-'));
t.after(() => rm(root, { recursive: true, force: true }));
const source = path.join(root, 'source');
const bundle = path.join(root, 'bundle');
const workspace = path.join(root, 'workspace');
await mkdir(workspace);
for (const name of names) {
await mkdir(path.join(source, 'skills', name), { recursive: true });
await writeFile(path.join(source, 'skills', name, 'SKILL.md'), `---\nname: ${name}\ndescription: >\n A Skill.\n---\n# Instructions`);
}
await buildBundle(source, bundle, revision);
return { root, source, bundle, workspace };
}

test('all source Skills are bundled without a name or count allowlist', async t => {
const f = await fixture(t, ['new-tool', 'another-tool']);
assert.deepEqual((await verifyBundle(f.bundle)).skills, ['another-tool', 'new-tool']);
assert.equal((await prepareBundle(f.bundle, f.workspace)).skillCount, 2);
});

test('dirty build output cannot produce a successful but invalid bundle', async t => {
const f = await fixture(t);
await mkdir(path.join(f.bundle, 'skills/stale'));
await writeFile(path.join(f.bundle, 'skills/stale/SKILL.md'), 'stale');
await buildBundle(f.source, f.bundle, revision);
assert.deepEqual((await verifyBundle(f.bundle)).skills, ['sealos-deploy']);
});

test('retry restores unrelated Skills from a legacy interrupted replacement', async t => {
const f = await fixture(t);
await prepareBundle(f.bundle, f.workspace);
const target = path.join(f.workspace, '.agents/skills');
await mkdir(path.join(target, 'custom'));
await writeFile(path.join(target, 'custom/keep'), 'user content');
await rename(target, path.join(f.workspace, '.agents/skills-backup-99999'));
await mkdir(path.join(f.workspace, '.agents/skills-stage-99999'));
assert.equal((await prepareBundle(f.bundle, f.workspace)).status, 'ready');
assert.equal(await readFile(path.join(target, 'custom/keep'), 'utf8'), 'user content');
});

test('deployment preparation requires an existing workspace', async t => {
const f = await fixture(t);
await assert.rejects(prepareBundle(f.bundle, path.join(f.root, 'not-cloned')));
});

test('CLI invocation through a symlink must execute, not exit with empty success', async t => {
const f = await fixture(t);
const alias = path.join(f.root, 'bundle-cli.mjs');
await symlink(new URL('./skill-bundle.mjs', import.meta.url).pathname, alias);
const result = spawnSync(process.execPath, [alias, 'verify', f.bundle], { encoding: 'utf8' });
assert.equal(result.status, 0);
assert.equal(JSON.parse(result.stdout).revision, revision);
});

for (const checkpoint of ['backup', 'published']) {
test(`SIGKILL after ${checkpoint} preserves custom Skills on retry`, async t => {
const f = await fixture(t);
await prepareBundle(f.bundle, f.workspace);
const target = path.join(f.workspace, '.agents/skills');
await mkdir(path.join(target, 'custom'));
await writeFile(path.join(target, 'custom/keep'), 'user content');
const child = spawnSync(process.execPath, ['--input-type=module', '-e', `
import fs from 'node:fs';
import { syncBuiltinESMExports } from 'node:module';
const rename = fs.promises.rename;
fs.promises.rename = async (from, to) => {
await rename(from, to);
if (${JSON.stringify(checkpoint)} === 'backup' ? to.endsWith('/backup') : from.endsWith('/stage')) process.kill(process.pid, 'SIGKILL');
};
syncBuiltinESMExports();
const { prepareBundle } = await import(${JSON.stringify(new URL('./skill-bundle.mjs', import.meta.url).href)});
await prepareBundle(${JSON.stringify(f.bundle)}, ${JSON.stringify(f.workspace)});
`], { encoding: 'utf8' });
assert.equal(child.signal, 'SIGKILL', child.stderr);
assert.equal((await prepareBundle(f.bundle, f.workspace)).status, 'ready');
assert.equal(await readFile(path.join(target, 'custom/keep'), 'utf8'), 'user content');
});
}

test('Chat explicitly creates its workspace without weakening the deployment precondition', async t => {
const f = await fixture(t);
const workspace = path.join(f.root, 'chat');
const child = spawnSync(process.execPath, [new URL('./skill-bundle.mjs', import.meta.url).pathname, 'prepare-chat', f.bundle, workspace], { encoding: 'utf8' });
assert.equal(child.status, 0, child.stderr);
assert.equal(validateReady(child.stdout).status, 'ready');
});

test('success output must match the ready contract', () => {
for (const raw of ['', '{}', 'null', '{"schema":1,"status":"ready"}', '{"status":"failed"}']) {
assert.throws(() => validateReady(raw), /invalid_ready/);
}
const ready = { schema: 1, status: 'ready', revision, digest: 'c'.repeat(64), skillCount: 4 };
assert.deepEqual(validateReady(JSON.stringify({ ...ready, untrusted: 'discard' })), ready);
});

test('preparation does not read or hash workspace file bodies', async t => {
const f = await fixture(t);
await prepareBundle(f.bundle, f.workspace);
const child = spawnSync(process.execPath, ['--input-type=module', '-e', `
import fs from 'node:fs';
import { syncBuiltinESMExports } from 'node:module';
const readFile = fs.promises.readFile;
fs.promises.readFile = async (file, options) => {
if (String(file).startsWith(${JSON.stringify(f.workspace)})) throw new Error('workspace content read');
return readFile(file, options);
};
syncBuiltinESMExports();
const { prepareBundle } = await import(${JSON.stringify(new URL('./skill-bundle.mjs', import.meta.url).href)});
console.log(JSON.stringify(await prepareBundle(${JSON.stringify(f.bundle)}, ${JSON.stringify(f.workspace)})));
`], { encoding: 'utf8' });
assert.equal(child.status, 0, child.stderr);
assert.equal(validateReady(child.stdout).status, 'ready');
});

test('ambiguous legacy backups fail closed without hiding preserved content', async t => {
const f = await fixture(t);
const agent = path.join(f.workspace, '.agents');
for (const id of ['1', '2']) {
await mkdir(path.join(agent, 'skills-backup-' + id), { recursive: true });
await writeFile(path.join(agent, 'skills-backup-' + id, 'keep'), id);
}
await assert.rejects(prepareBundle(f.bundle, f.workspace), /recovery_ambiguous/);
assert.equal(await readFile(path.join(agent, 'skills-backup-1/keep'), 'utf8'), '1');
await assert.rejects(readFile(path.join(agent, 'skills/SKILL.md')));
});

test('backup cleanup failure after publication does not turn readiness into failure', async t => {
const f = await fixture(t);
const child = spawnSync(process.execPath, ['--input-type=module', '-e', `
import fs from 'node:fs';
import { syncBuiltinESMExports } from 'node:module';
const rm = fs.promises.rm;
fs.promises.rm = async (entry, options) => {
if (entry.split('/').at(-1).startsWith('txn-')) throw new Error('cleanup failure');
return rm(entry, options);
};
syncBuiltinESMExports();
const { prepareBundle } = await import(${JSON.stringify(new URL('./skill-bundle.mjs', import.meta.url).href)});
console.log(JSON.stringify(await prepareBundle(${JSON.stringify(f.bundle)}, ${JSON.stringify(f.workspace)})));
`], { encoding: 'utf8' });
assert.equal(child.status, 0, child.stderr);
assert.equal(validateReady(child.stdout).status, 'ready');
assert.equal((await prepareBundle(f.bundle, f.workspace)).status, 'ready');
});
Loading
Loading