Skip to content

fix(sandbox): bundle pinned Sealos skills for offline preparation - #154

Open
zjy365 wants to merge 3 commits into
mainfrom
codex/bundled-runtime-skills
Open

fix(sandbox): bundle pinned Sealos skills for offline preparation#154
zjy365 wants to merge 3 commits into
mainfrom
codex/bundled-runtime-skills

Conversation

@zjy365

@zjy365 zjy365 commented Sep 7, 2026

Copy link
Copy Markdown
Collaborator

Summary

Companion Brain PR: labring/brain#340

  • Build Skills from https://github.com/norberia/sealos-skills-next.git at commit bdd824cf2fd6c72896f8e201f32259cc8aed3f98 (canonical plugins/sealos/skills directory) into the sandbox/v1 image with a versioned SHA-256 manifest.
  • Add a local, locked preparation helper that verifies the bundle, preserves unrelated project Skills, and rejects symlink destinations. No runtime npm/GitHub installation or download fallback.
  • Add Node behavior tests, CI, and offline Skill checks to the existing sandbox smoke script. codex-gateway is unchanged.

Validation

  • 20 Node behavior tests passed, including SIGKILL at both replacement boundaries, retry recovery, dirty rebuilds, arbitrary Skill names, CLI symlinks, invalid readiness output, and cleanup failures.
  • Shell syntax and git diff checks passed.
  • Real norberia source bundle built and prepared twice successfully locally: 3 Skills (use-sealos, sealos-deploy, k8s-kaniko-job).
  • Docker image build and actual Devbox smoke have NOT run: local Docker daemon is unavailable. These remain release gates, not claimed coverage.

Rollout / rollback

Publish and validate the bundled sandbox image before rolling out the companion Brain change. Brain must select its immutable image reference and remove nonempty DEPLOY_SKILL_SOURCE overrides. Drain active/blocked deployment tasks before switching; roll back the Brain/runtime image pair together.

Recovery journals outside .agents restore backups before preparing after a killed process; ambiguous state fails closed. Post-publication cleanup is best-effort. This is an operational integrity check, not an adversarial filesystem boundary or power-loss durability guarantee.

Review follow-up

Per maintainer direction, install every Skill in the pinned repository. No fixed Skill names or counts are required. The source directory determines the manifest, and smoke compares installed files/count against that manifest. CI now fetches the actual Dockerfile pin and performs build, verify, repeated preparation, and byte-for-byte source comparison (passed locally: 3 Skills, 13 files).

The wrapper uses absolute system binaries, clears Node environment overrides, validates readiness JSON, and shares a 28-second budget across lock waiting and preparation (one-second kill grace). Deployment preparation requires an existing workspace; Chat explicitly opts into initialization with --init-workspace in the companion PR. The build stage reuses the GHCR Node base and verifies the final bundle.

Base-image build, derived runtime-image build, and actual Devbox smoke remain pending release gates. No image was published or deployment changed in this update.

@norberia

norberia commented Sep 7, 2026

Copy link
Copy Markdown
Collaborator

@cursoragent review this

@norberia norberia left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Request changes

Direction is right: pin the Sealos Skill bundle in the sandbox image, prepare offline, fail closed, no runtime npm/GitHub fallback. I reproduced the 8 Node tests, git fetch --depth 1 origin bdd824cf… against GitHub, and a real plugins/sealos build (3 skills, 13 files, prepare twice with identical digest).

Do not pair this with Brain until the items below land, and a sandbox base then runtime image is actually built and smoked. Node tests on a fixture are not that gate. Companion Brain PR is out of scope here; the contract this image exports is /usr/local/bin/sealai-prepare-skills/home/devbox/project/.agents/skills.

Must fix

  1. Kill/retry can drop unrelated Skills and still return ready. Death between the two renames leaves skills-backup-<oldpid> and no live skills. The next prepare mkdir’s an empty tree, overlays only bundled names, and reports success. Restore leftovers before creating skills; mkdtemp outside .agents.
  2. buildBundle can emit a bundle verifyBundle rejects if destination is dirty. Wipe dest, then verify at end of build (and in the Dockerfile RUN).
  3. Only sealos-deploy is required. That skill depends on sibling use-sealos and k8s-kaniko-job. Lock all three in build/verify/smoke.
  4. CI never sees the real pin. Fetch the SHA and build + verify in this workflow. Then dispatch base sandbox/v1 → runtime sandbox/v1 smoke before Brain (runtime-images/.../sandbox/v1 still FROM sandbox-v1:v0.0.1-alpha.1-…).

Same pass

  • Do not SHA-256 workspace skill bodies in prepareBundle (hashes unused; 30s cap).
  • prepare-skills: absolute node/flock; parse success JSON; flock wait vs Brain 30s; CLI main-guard needs realpath or empty stdout is treated as success.
  • After the second rename, rm(backup) is best-effort.
  • Do not mkdir /home/devbox/project if clone did not; require the workspace root.
  • Skills stage: drop unpinned Docker Hub node:22-bookworm-slim; reuse GHCR node.js-22 or pin a digest.

Already good

Full-SHA fetch + rev-parse check, canonical plugins/sealos (top-level skills/ are aliases at this pin), verify-before-write, symlink rejection, preserve siblings and root skills-lock.json, fail-closed generic errors, honest “Docker/Devbox not run” note.

Rollout

Publish sandbox-v1 base, then runtime, record the digest. Point Brain at that digest and remove nonempty DEPLOY_SKILL_SOURCE in the same deploy. Roll the pair back together. Clone, then prepare — not the reverse.

Comment on lines +66 to +92
export async function prepareBundle(bundle, workspace) {
const manifest = await verifyBundle(bundle);
await safeDirectory(workspace);
const agentRoot = path.join(workspace, '.agents');
await safeDirectory(agentRoot);
const target = path.join(agentRoot, 'skills');
await safeDirectory(target);
await files(target);
const stage = path.join(agentRoot, `skills-stage-${process.pid}`);
const backup = path.join(agentRoot, `skills-backup-${process.pid}`);
// Exclusive mkdir avoids following paths placed by a repository.
await mkdir(stage);
let backedUp = false;
try {
await cp(target, stage, { recursive: true });
for (const name of manifest.skills) {
await rm(path.join(stage, name), { recursive: true, force: true });
await cp(path.join(bundle, 'skills', name), path.join(stage, name), { recursive: true });
}
// Reserve backup name; refuse any existing entry.
await mkdir(backup);
await rename(target, backup);
backedUp = true;
await rename(stage, target);
backedUp = false;
await rm(backup, { recursive: true, force: true });
} catch (error) {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

prepareBundle creates skills if it is missing (safeDirectory), then overlays only manifest.skills.

If this process is killed between rename(target, backup) and rename(stage, target) (Brain’s 30s cap), the next call mkdir’s an empty skills, copies bundled names, and returns {status:"ready"}. Unrelated project Skills are left in skills-backup-<oldpid> and look like they were never there. Reproduced with leftover backup/stage dirs and no live skills.

That breaks the preserve-unrelated-Skills invariant: retry looks successful, so nobody inspects leftovers.

Before creating skills, restore skills-backup-* (prefer) or skills-stage-*; fail closed if both exist and differ. Keep stage/backup via mkdtemp outside .agents (not process.pid). After the second rename, rm(backup) must be best-effort — do not throw once target is in place.

await files(directory);
names.push(entry);
}
if (!names.includes('sealos-deploy')) fail('missing_deploy_skill');

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

This only requires sealos-deploy. At pin bdd824cf that skill is a single SKILL.md whose implementation is ../use-sealos/ and ../k8s-kaniko-job/. A later revision can drop either and still produce a green image that fails managed deploy at runtime.

Require the sorted set k8s-kaniko-job, sealos-deploy, use-sealos in both buildBundle and verifyBundle. Smoke should assert skillCount == 3 and all three SKILL.md files.

Comment on lines +39 to +42
await mkdir(path.join(destination, 'skills'), { recursive: true });
for (const name of names) await cp(path.join(sourceSkills, name), path.join(destination, 'skills', name), { recursive: true });
const manifest = { schema: 1, revision, skills: names, files: await files(path.join(destination, 'skills')) };
await writeFile(path.join(destination, 'manifest.json'), JSON.stringify(manifest));

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

buildBundle does not wipe destination. Rebuilding into a dirty dir leaves extra skill trees in manifest.files while skills stays the current name list; verifyBundle then fails invalid_manifest. Reproduced: build exit 0, verify skill_bundle_unavailable.

Docker RUN on empty /bundle hides this. rm destination first (or only hash copied names) and call verifyBundle(destination) at the end of build — including in the Dockerfile RUN.

await safeDirectory(agentRoot);
const target = path.join(agentRoot, 'skills');
await safeDirectory(target);
await files(target);

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

files() SHA-256s every regular file. Here the return value is discarded — this is only a symlink/special-file scan of the workspace. A custom skill with node_modules or large blobs still pays full-content hashing under Brain’s 30s cap, then cps the same tree.

Split a lstat-only walker for prepare. Keep hashing for verifyBundle / buildBundle.

Comment on lines +57 to +68
async function safeDirectory(directory) {
const parent = path.dirname(directory);
if (parent !== directory) await safeDirectory(parent);
await mkdir(directory).catch(error => { if (error.code !== 'EEXIST') throw error; });
if (!(await lstat(directory)).isDirectory()) fail('workspace_symlink');
}

// Caller holds flock for the whole operation. Stage before replacing; preserve
// unrelated project skills and reject symlinks before any copy or rename.
export async function prepareBundle(bundle, workspace) {
const manifest = await verifyBundle(bundle);
await safeDirectory(workspace);

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

safeDirectory(workspace) mkdir’s /home/devbox/project if clone did not happen. Prepare then returns ready against an empty project.

lstat the workspace root and require an existing directory. Only create .agents / skills under it.

Comment on lines +4 to +8
if output=$(flock --wait 10 /tmp/sealai-runtime-skills.lock node /opt/sealai/skill-bundle.mjs prepare /opt/sealai/skill-bundle /home/devbox/project 2>/dev/null); then
printf '%s\n' "$output"
else
printf '%s\n' '{"schema":1,"status":"failed","reason":"skill_bundle_unavailable"}' >&2
exit 1

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Three wrapper holes:

  1. Unqualified node / flock on PATH. After clone, a repo-fronted PATH can plant a binary that prints fake ready JSON. Use /usr/bin/flock and the real Node path.
  2. 2>/dev/null plus a catch-all skill_bundle_unavailable collapses flock timeout, missing bundle, and CLI no-op. Require stdout to parse as {schema:1,status:"ready",...} or fail closed.
  3. --wait 10 vs Brain’s 30s cap: a second concurrent prepare fails at 10s. Raise wait to just under the cap, or wait and let Brain kill.

Also: the CLI main-guard uses path.resolve(argv[1]) === fileURLToPath(import.meta.url) without realpath. Invoking via a symlink exits 0 with empty stdout; this wrapper then reports success.

Comment on lines +17 to +26
FROM node:22-bookworm-slim 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" && \
node /build/skill-bundle.mjs build /source/plugins/sealos /bundle "$SEALOS_SKILLS_REVISION"

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Every other stage in this file is GHCR. This one pulls unpinned Docker Hub node:22-bookworm-slim (tag float + Hub rate limit + extra compromise path). The bundler Node never ships, but it still writes /bundle.

Reuse ${REGISTRY}/${REPO}/node.js-22:${NODE_IMAGE_VERSION} (git is already on that image) or pin a digest. git fetch --depth 1 origin $SHA against this pin does work; I ran the same sequence.

- uses: actions/setup-node@v4
with:
node-version: '22'
- run: node --test base-images/frameworks/sandbox/v1/skill-bundle.test.mjs

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

This job never fetches the pinned source. The 8 tests use a one-skill fixture, so they cannot see the real description: > frontmatter, script tree, or top-level skills/ aliases.

Add a step: git fetch --depth 1 bdd824cf…node skill-bundle.mjs build /source/plugins/sealos …verify. Cheap, no Docker, and it is the first time CI would look at the tree the image actually ships.

Image build + tests/runtime-smoke/frameworks/sandbox/v1/smoke.sh remain workflow_dispatch against published runtimes. Dispatch base sandbox/v1 then runtime sandbox/v1 smoke before Brain; this PR does not change runtime-images/frameworks/sandbox/v1 (FROM sandbox-v1:v0.0.1-alpha.1-…).

@zjy365
zjy365 requested a review from norberia September 7, 2026 10:15
@zjy365

zjy365 commented Sep 7, 2026

Copy link
Copy Markdown
Collaborator Author

@norberia thanks for the review — please take another look at b11ff58: the code fixes are pushed, including kill/retry recovery and real-source validation; per the requested behavior, the installer packages every Skill in the pinned norberia repository rather than requiring fixed names or counts. Checks are green, but base/runtime image builds and actual Devbox smoke are still pending before the paired Brain rollout, so this is a request for code re-review, not release approval.

@norberia norberia left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Request changes (re-review of b11ff58)

The previous round is largely addressed. I re-ran the 20 Node tests and verify-pinned-source.mjs (3 Skills, 13 files, digest 1ad10ccb…). SIGKILL at both rename boundaries, dirty rebuilds, absolute wrapper binaries, ready-JSON validation, realpath CLI guard, GHCR node.js-22, and “no workspace mkdir unless --init-workspace” all check out. The no-allowlist decision is accepted as stated.

Do not pair this with Brain until the items below land and sandbox base then runtime is actually built and smoked. That last part is still a release gate, not claimed coverage.

Still must fix

  1. recoverTransactions turns post-publish leftover txn-* into a hard fail on the next run. cleanup swallows rm errors, then exists(transaction) throws recovery_cleanup_failed even when live skills already exists. Same-process mocked rm: first prepare ready, second recovery_cleanup_failed. The regression test mocks rm only in a child, then retries in the parent with a real rm. If target exists, leftover txns are trash — best-effort rm only.

  2. buildBundle finally can delete the new tree after wiping the old dest. rm(destination) then rename(staging → dest) then finally rm(staging). If rename fails, both copies are gone. Docker RUN failure is not committed; local/CI rebuild into the same dest is not safe. Don’t rm(staging) after a successful promote; prefer rename-to-old + rename-to-dest + best-effort delete of old.

Should fix in the same pass

  • Restore backup without walking an incomplete stage. A symlink/special file under stage currently throws bundle_symlink and never restores custom Skills.
  • Journal sits in the git clone. git clean -fd between kill and retry returns ready with only bundled Skills. Keep same-FS; move it out of the worktree (or under .agents/) and document git clean as journal loss.
  • If live skills exists, leftover skills-backup-N / skills-stage-N should be stale, not recovery_ambiguous forever.

Already good on this commit

Kill/retry restore, dirty-build staging+verify, CI against the real Dockerfile pin, no workspace hashing, /usr/bin/{timeout,flock,node} + 28s shared budget + validate-ready, Chat --init-workspace vs deploy requires an existing workspace, GHCR Node builder.

Rollout (unchanged)

Publish sandbox-v1 base, then runtime (FROM sandbox-v1:v0.0.1-alpha.1-…, unchanged in this PR). Point Brain at the digest and drop nonempty DEPLOY_SKILL_SOURCE in the same deploy. Roll the pair back together.

Comment on lines +97 to +111
async function recoverTransactions(state, target) {
const transactions = (await readdir(state)).filter(name => name.startsWith('txn-'));
if (transactions.length > 1) fail('recovery_ambiguous');
for (const name of transactions) {
const transaction = path.join(state, name);
await files(transaction, '', false);
const backup = path.join(transaction, 'backup');
if (!(await exists(target))) {
if (!(await exists(backup))) fail('recovery_ambiguous');
await rename(backup, target);
}
await cleanup(transaction);
if (await exists(transaction)) fail('recovery_cleanup_failed');
}
}

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

This reopens the previous “cleanup after publication must be best-effort” item.

cleanup swallows rm errors, then exists(transaction) still fails closed — including when live skills already exists (post-publish leftover). Same-process mocked rm on txn-*: first prepareBundle returns ready, the next call throws recovery_cleanup_failed.

The regression test only mocks rm in a child, then retries in the parent with a real rm, so it never sees this.

If target exists, leftover txn-* is trash. Best-effort rm only; do not throw. Fail closed only when skills is missing and backup restore cannot finish. Keep the mock across both prepares so the test actually covers retry.

Comment on lines +55 to +67
const staging = await mkdtemp(path.join(path.dirname(destination), '.skill-build-'));
try {
await mkdir(path.join(staging, 'skills'));
for (const name of names) await cp(path.join(sourceSkills, name), path.join(staging, 'skills', name), { recursive: true });
const manifest = { schema: 1, revision, skills: names, files: await files(path.join(staging, 'skills')) };
await writeFile(path.join(staging, 'manifest.json'), JSON.stringify(manifest));
await verifyBundle(staging);
await rm(destination, { recursive: true, force: true });
await rename(staging, destination);
return (await verifyBundle(destination)).digest;
} finally {
await rm(staging, { recursive: true, force: true });
}

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

On success staging was renamed away, so finally is a no-op. If rename fails after rm(destination), finally still deletes staging. Probe against an existing dest: previous bundle and new staging both gone (destExists: false).

Docker RUN failure is not committed, so the image path is mostly safe. Local/CI rebuild into the same dest is not.

Do not rm(staging) after a successful promote. Better: rename(dest → dest.old), rename(staging → dest), best-effort rm(dest.old) so a failed promote can still recover the previous bundle.

Comment on lines +101 to +107
const transaction = path.join(state, name);
await files(transaction, '', false);
const backup = path.join(transaction, 'backup');
if (!(await exists(target))) {
if (!(await exists(backup))) fail('recovery_ambiguous');
await rename(backup, target);
}

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

files(transaction) walks the whole txn, including an incomplete stage, before restoring backup. README says stages may be incomplete and must not be promoted. A symlink/special file under stage throws bundle_symlink and never rename(backup → skills).

Probe: target gone, backup has custom/keep, stage has a symlink → bundle_symlink, custom not restored.

Type-scan and restore backup only. Delete stage best-effort. Do not require the stage tree to be a valid Skill dir.

const agentRoot = path.join(workspace, '.agents');
await safeDirectory(agentRoot, true);
const target = path.join(agentRoot, 'skills');
const state = path.join(workspace, '.sealai-skill-transactions');

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Journal is inside the git clone (/home/devbox/project/.sealai-skill-transactions) so same-FS rename works. After SIGKILL-at-backup, git status shows backup/stage as untracked; git clean -fd removes both .agents/ and the journal. The next prepare returns ready with only bundled Skills — same silent custom-Skill loss as the original bug, if the worktree is cleaned between kill and retry.

Keep same-FS, move the journal out of the clone (e.g. /home/devbox/.sealai-skill-transactions if that device matches project). If it must stay in the project, put it under .agents/ and treat git clean as journal loss in the README.

Related: recoverLegacy fail-closes when live skills exists alongside leftover skills-backup-N / skills-stage-N. If target exists, those names are stale; ignore or delete them. Restore only when skills is missing.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants