Files

2336 lines
92 KiB
Go
Raw Permalink Normal View History

package service
import (
"archive/tar"
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"path"
"sort"
"strings"
"sync"
"time"
"github.com/google/uuid"
"gorm.io/gorm"
"github.com/Tencent/WeKnora/internal/agent/skills"
"github.com/Tencent/WeKnora/internal/agent/tools"
apperrors "github.com/Tencent/WeKnora/internal/errors"
"github.com/Tencent/WeKnora/internal/event"
"github.com/Tencent/WeKnora/internal/logger"
"github.com/Tencent/WeKnora/internal/models/chat"
"github.com/Tencent/WeKnora/internal/sandbox"
"github.com/Tencent/WeKnora/internal/types"
"github.com/Tencent/WeKnora/internal/types/interfaces"
)
const (
installCommandTimeout = 10 * time.Minute
// installCleanupTimeout bounds the compensating work that must outlive the
// install context: destroying the sandbox and writing the terminal row.
installCleanupTimeout = 30 * time.Second
// The terminal "ready" write happens after the pointer has moved, so it is
// retried: the skill is already installed and serving, and the only thing
// still missing is the row that says so.
readySkillWriteAttempts = 3
readySkillWriteDelay = 100 * time.Millisecond
// skillSeedArchivePath is the single remote write used to land a skill's
// files. Writing each file with MakeDir+WriteFile is two round trips per
// entry, which is why a 50-file skill crawled through "seeding 12/56".
skillSeedArchivePath = sandbox.SkillsImageRoot + "/.weknora-seed.tar"
// skillInstallVerifyRounds bounds the installer conversation.
//
// The first round works from SKILL.md. A second is driven by the gate's own
// findings, and exists because those are two different descriptions of what
// a skill needs: the official office toolkit imports defusedxml and lxml at
// module level while its SKILL.md names neither, so an installer working
// from prose alone cannot know to install them. One round of reconciliation
// closes that gap; more would only be a retry loop over an answer that is
// not going to change.
skillInstallVerifyRounds = 2
)
// InstallSkill validates an uploaded archive, records it, and kicks off the
// install in the background. It returns the skill ID so the caller can answer
// 202 and let the UI subscribe to progress.
func (s *TenantSkillService) InstallSkill(
ctx context.Context, tenantID uint64, configID string, archive []byte,
) (string, error) {
cfgEntity, err := s.configs.GetByID(ctx, tenantID, configID)
if err != nil {
return "", err
}
if cfgEntity == nil {
return "", apperrors.NewNotFoundError("sandbox config not found")
}
bundle, err := ParseSkillBundle(archive)
if err != nil {
return "", err
}
return s.installParsedSkill(ctx, tenantID, configID, bundle, archive)
}
func (s *TenantSkillService) installParsedSkill(
ctx context.Context, tenantID uint64, configID string, bundle *SkillBundle, archive []byte,
) (string, error) {
if bundle == nil {
return "", fmt.Errorf("skill bundle is required")
}
// Re-uploading a skill by the same name is an upgrade of that skill, not a
// second row: the unique (config, name) index would reject the insert, and
// the image directory is the name, so it stays put.
existing, err := s.skills.GetSkillByName(ctx, tenantID, configID, bundle.Name)
if err != nil {
return "", err
}
if s.canSkipInstall(ctx, existing, bundle) {
catalog, catalogErr := s.upsertCatalogFromBundle(ctx, tenantID, bundle, archive, true)
if catalogErr != nil {
return "", fmt.Errorf("store bundle for skill %s: %w", existing.ID, catalogErr)
}
if err := s.pointInstallAtCatalog(ctx, existing, catalog); err != nil {
return "", fmt.Errorf("store bundle for skill %s: %w", existing.ID, err)
}
return existing.ID, nil
}
skillID := uuid.NewString()
now := s.now()
if existing != nil {
skillID = existing.ID
takeSkillRowForInstall(existing, bundle, now)
if err := s.skills.UpdateSkill(ctx, existing); err != nil {
return "", err
}
} else {
if err := s.skills.CreateSkill(ctx, &types.TenantSkillEntity{
ID: skillID, TenantID: tenantID, SandboxConfigID: configID,
Name: bundle.Name, Version: bundle.Version,
Description: bundle.Description, Instructions: bundle.Instructions,
BundleSHA256: bundle.SHA256, Enabled: true,
Status: types.SkillStatusInstalling, InstallingSince: &now,
}); err != nil {
if !isSkillNameConflict(err) {
return "", err
}
// Two first-time uploads of the same name raced the unique index.
// Take the row that won rather than surfacing a 500.
winner, lookupErr := s.skills.GetSkillByName(ctx, tenantID, configID, bundle.Name)
if lookupErr != nil {
return "", lookupErr
}
if winner == nil {
return "", err
}
skillID = winner.ID
takeSkillRowForInstall(winner, bundle, now)
if err := s.skills.UpdateSkill(ctx, winner); err != nil {
return "", err
}
}
}
// The zip lives on the catalog, not on this sandbox: uninstalling from
// the last config must not take the definition's files with it. The
// install row only stores CatalogID; readers follow that to the zip.
catalog, err := s.upsertCatalogFromBundle(ctx, tenantID, bundle, archive, true)
if err != nil {
failCtx, cancelFail := s.cleanupContext(ctx)
defer cancelFail()
storeErr := fmt.Errorf("store bundle: %w", err)
logger.Errorf(ctx, "[skill] store bundle failed tenant=%d config=%s skill=%s name=%s: %v",
tenantID, configID, skillID, bundle.Name, err)
s.failSkill(failCtx, tenantID, configID, skillID, bundle, storeErr)
return "", fmt.Errorf("store bundle for skill %s: %w", skillID, err)
}
if err := s.pointInstallAtCatalog(ctx, &types.TenantSkillEntity{
ID: skillID, TenantID: tenantID, SandboxConfigID: configID,
}, catalog); err != nil {
failCtx, cancelFail := s.cleanupContext(ctx)
defer cancelFail()
storeErr := fmt.Errorf("store bundle: %w", err)
logger.Errorf(ctx, "[skill] store bundle failed tenant=%d config=%s skill=%s name=%s: %v",
tenantID, configID, skillID, bundle.Name, err)
s.failSkill(failCtx, tenantID, configID, skillID, bundle, storeErr)
return "", fmt.Errorf("store bundle for skill %s: %w", skillID, err)
}
s.publishProgress(ctx, tenantID, configID, skillID, SkillProgress{
Percent: 10, Stage: "accepted", Status: types.SkillStatusInstalling,
})
// The install outlives the HTTP request, so it must not inherit its
// cancellation. It is not durable across a restart either - StopSkill
// rewrites the row, and the stuck-run reaper is the backup.
go func() {
bgCtx := context.WithoutCancel(ctx)
if err := s.withSkillRunLock(bgCtx, tenantID, configID, skillID, func(lockCtx context.Context) error {
return s.runInstall(lockCtx, tenantID, configID, skillID, bundle)
}); err != nil {
logger.Errorf(bgCtx, "[skill] install %s failed: %v", skillID, err)
}
}()
return skillID, nil
}
// takeSkillRowForInstall hands an existing row to the run about to start.
//
// The transcript locators are cleared along with the error. They name the
// session and message of the run that just ended, and a row that says
// "installing" while still pointing at them tells every reader that the
// finished conversation is this one's live output — which is how a retry came
// to replay the previous attempt's report before its own agent had started.
// They are rewritten by beginInstallTranscript once this run has a message of
// its own; until then the honest answer is that there is nothing to show yet.
func takeSkillRowForInstall(row *types.TenantSkillEntity, bundle *SkillBundle, now time.Time) {
row.Version = bundle.Version
row.Description = bundle.Description
row.Instructions = bundle.Instructions
row.BundleSHA256 = bundle.SHA256
row.Status = types.SkillStatusInstalling
row.Error = ""
row.InstallingSince = &now
row.InstallSessionID = ""
row.InstallMessageID = ""
}
// ReinstallSkill runs the install again from the archive already stored for
// this skill. Most failed installs have nothing to do with the archive — an
// unreachable sandbox, a package index that timed out, a checker that has
// since been corrected — and making the operator find the original zip again
// (or the registry URL it came from) is a poor answer to any of them.
//
// It deliberately goes through InstallSkill rather than jumping to runInstall.
// That path owns the in-flight check, the row ownership handover and the
// per-config lock, and a retry is exactly the moment two installs of one
// config are most likely to overlap.
func (s *TenantSkillService) ReinstallSkill(
ctx context.Context, tenantID uint64, configID, skillID string,
) (string, error) {
skill, err := s.skills.GetSkill(ctx, tenantID, configID, skillID)
if err != nil {
return "", err
}
if skill == nil {
return "", apperrors.NewNotFoundError("skill not found")
}
// The zip is owned by the catalog. An empty install BundleRef is not
// itself a failure — fall through to skillBundleArchive, which is what
// reports a definition whose files are actually gone.
archive, err := s.skillBundleArchive(ctx, tenantID, configID, skillID)
if err != nil {
return "", err
}
if len(archive) == 0 {
return "", apperrors.NewBadRequestError(
"the archive of this skill is no longer stored; install it again from the original bundle",
)
}
return s.InstallSkill(ctx, tenantID, configID, archive)
}
func (s *TenantSkillService) runInstall(
ctx context.Context, tenantID uint64, configID, skillID string, bundle *SkillBundle,
) (err error) {
ctx = context.WithValue(ctx, types.TenantIDContextKey, tenantID)
ctx = types.WithSandboxTenantID(ctx, tenantID)
// Cleanup runs on a context that cannot be cancelled by whatever it is
// compensating for. withConfigLock cancels ctx the moment lock renewal
// fails, and the two things that must still happen then are a provider
// call (destroy the sandbox, or it stays running and billed) and a DB write
// (mark the skill failed, or the row sits at "installing" with the cause
// only in a log line).
//
// Only cancellation is detached here; the deadline is NOT. An install runs
// for minutes — a single agent command may take installCommandTimeout — so
// a timeout started at this line would already be spent by the time any
// compensating work begins. Each consumer calls cleanupContext to start its
// own budget at the moment it needs one.
cleanupBase := context.WithoutCancel(ctx)
handle := s.lookupSkillRun(tenantID, configID, skillID)
// pointerSwitched marks the point of no return. Past it the skill is
// installed, snapshotted and serving every new session, so a later failure
// is a bookkeeping failure and must not be reported as a failed install.
pointerSwitched := false
defer func() {
if err == nil {
return
}
if pointerSwitched {
logger.Errorf(cleanupBase,
"[skill] %s is installed and serving but its bookkeeping is incomplete: %v",
skillID, err)
return
}
// StopSkill (or a retry) may already own the row; stamping failed on
// that owner would hide the run the operator just started.
if !s.skillRunStillBound(tenantID, configID, skillID, handle) {
return
}
// The image pointer is deliberately untouched on failure: the previous
// snapshot keeps serving every session.
failCtx, cancelFail := s.cleanupContext(cleanupBase)
defer cancelFail()
s.failSkill(failCtx, tenantID, configID, skillID, bundle, err)
}()
// InstallSkill queues this run before the per-config lock. A remove that
// won the lock (or a newer upload of the same name) already owns the row;
// snapshotting anyway would bake a skill the ledger no longer names.
owned, err := s.installStillOwnsTheRow(ctx, tenantID, configID, skillID, bundle)
if err != nil {
return err
}
if !owned {
return nil
}
// From here on this run is the row's owner, and everything below can take
// minutes. The heartbeat is what tells a second upload of the same archive
// (and the reaper) that those minutes are work rather than a dead process.
// It is deferred before it is stopped explicitly below, so a failure path
// still stops it ahead of the deferred failSkill.
stopHeartbeat := s.startInstallHeartbeat(ctx, tenantID, configID, skillID)
defer stopHeartbeat()
// The name comes from SKILL.md and is already validated on parse, so a
// rejection here means the bundle was accepted by a looser rule than the
// one the image path enforces. Failing before any sandbox work keeps that
// disagreement cheap.
skillDir, err := sandbox.SkillDirFor(bundle.Name)
if err != nil {
return err
}
cfgEntity, err := s.configs.GetByID(ctx, tenantID, configID)
if err != nil {
return fmt.Errorf("load sandbox config %s: %w", configID, err)
}
if cfgEntity == nil {
return fmt.Errorf("sandbox config %s not found", configID)
}
if err := ensureUsableImage(cfgEntity); err != nil {
return err
}
// Snapshots are private to the provider account that created them. The
// fingerprint is captured here, from the credentials this sandbox will
// actually use, and switchImagePointer refuses to stamp a later rotation
// onto that ID — that would make the session layer trust a snapshot the
// live key cannot resolve.
builtFingerprint := skillOwnerFingerprint(cfgEntity.Config)
if strings.TrimSpace(builtFingerprint) == "" {
return fmt.Errorf(
"sandbox config %s has no usable owner fingerprint for a skill image", configID,
)
}
// 1. Install session + sandbox. ResolveEffectiveConfig has already turned
// the current snapshot into the template, so this sandbox boots from the
// existing image and this install stacks on top of it.
sess, mgr, err := s.startMaintenanceSession(ctx, tenantID, configID, "install")
if err != nil {
return err
}
// Only the sandbox is released. The session and its messages stay so the
// agent's install transcript can be read back when something goes wrong.
defer func() {
releaseCtx, cancelRelease := s.cleanupContext(cleanupBase)
defer cancelRelease()
s.releaseSandbox(releaseCtx, mgr, sess.ID)
}()
s.publishProgress(ctx, tenantID, configID, skillID, SkillProgress{Percent: 25, Stage: "sandbox_ready"})
// 2. Provision the target directory, then seed the source files
// server-side. The agent only installs dependencies; it never has to
// reconstruct the skill itself.
if err := s.resetSkillDir(ctx, mgr, sess.ID, skillDir); err != nil {
return err
}
// Locators must land before the file seed. A large skill is copied file by
// file over the sandbox API and can take minutes; the console attaches to
// the transcript as soon as the directory is ready, not after that copy.
transcript, prompt := s.beginInstallTranscript(ctx, tenantID, skillID, sess, mgr, skillDir, bundle)
fileCount := 0
if bundle != nil {
fileCount = len(bundle.Files)
}
if fileCount > 0 {
logger.Infof(ctx, "[skill] seeding %d files for %s as one archive", fileCount, skillID)
s.publishProgress(ctx, tenantID, configID, skillID, SkillProgress{
Percent: 28, Stage: "seeding",
Log: fmt.Sprintf("seeding %d files", fileCount),
})
}
if err := s.seedSkillFiles(ctx, mgr, sess.ID, skillDir, bundle); err != nil {
return err
}
s.publishProgress(ctx, tenantID, configID, skillID, SkillProgress{Percent: 35, Stage: "seeded"})
// 3-5. Install dependencies and verify the result. "The agent said it
// worked" is a sentence, not evidence, and this is the last gate
// before the image is switched — but the gate and the agent must not
// answer "what does this skill need" separately, so a failure it can
// still fix goes back to the same agent before the run is failed.
if err := s.installDependenciesAndVerify(ctx, installerJob{
tenantID: tenantID, configID: configID, skillID: skillID,
sess: sess, mgr: mgr, transcript: transcript,
prompt: prompt, skillDir: skillDir, bundle: bundle,
}); err != nil {
return err
}
if err := s.writeManifestEntry(ctx, mgr, sess.ID, skillID, bundle); err != nil {
return err
}
// Read before the scratch wipe. requirements.json lives under skillDir and
// is not scratch, but reading it first removes an implicit dependency on
// what cleanImageScratch happens to delete.
s.recordEnvDeclaration(ctx, mgr, sess.ID, tenantID, configID, skillID, bundle)
s.publishProgress(ctx, tenantID, configID, skillID, SkillProgress{Percent: 90, Stage: "verified"})
// 6. Wipe the scratch state. It must happen BEFORE the snapshot, or the
// per-session workspace and every package cache land in the image.
if err := s.cleanImageScratch(ctx, mgr, sess.ID); err != nil {
return err
}
// 7. Snapshot. This is the point of no return: everything above is
// confined to a sandbox we are about to destroy, everything below
// creates provider resources and moves the pointer. withConfigLock
// cancels this context when the lock's renewal fails, and a lost lock
// means another install may already be building on the same config, so
// stop here rather than race it. The ledger row is written before the
// snapshot: a snapshot with no ledger entry is a resource nobody knows
// exists.
if err := ctx.Err(); err != nil {
return fmt.Errorf("install lock lost before snapshot: %w", err)
}
// Re-check after the minutes-long agent run: InstallSkill writes the row
// outside this lock, so a newer upload or a queued remove may already own
// it. Snapshotting anyway would bake a tree the ledger no longer names.
if !s.skillRunStillBound(tenantID, configID, skillID, handle) {
return nil
}
owned, err = s.installStillOwnsTheRow(ctx, tenantID, configID, skillID, bundle)
if err != nil {
return err
}
if !owned {
return nil
}
// Generation is max(live pointer, ledger)+1 so a build that died after
// the commit but before the pointer moved cannot share a name with the
// next install. withConfigLock still serialises writers of SkillImage;
// the ledger read is what closes the crash window the lock cannot see.
ledger, err := s.skills.ListSnapshotsByConfig(ctx, tenantID, configID)
if err != nil {
return fmt.Errorf("list snapshots of config %s: %w", configID, err)
}
generation := nextSnapshotGeneration(currentGeneration(cfgEntity), ledger)
installRowID := uuid.NewString()
// The name is recorded with the row rather than derived at the call below,
// so a run that dies during the commit still leaves the ledger able to
// name what it was building. Without it the snapshot would be a provider
// resource nothing could ever address, let alone reclaim. The row id is
// in the name so two rows of the same generation cannot share a tag.
snapshotName := skillSnapshotBuildName(tenantID, configID, generation, installRowID)
if err := s.skills.CreateSnapshotRow(ctx, &types.TenantSkillSnapshotEntity{
ID: installRowID, TenantID: tenantID, SandboxConfigID: configID, SkillID: skillID,
ParentSnapshotID: currentSnapshotID(cfgEntity), Generation: generation,
Trigger: types.SkillSnapshotTriggerInstall, State: types.SkillSnapshotStateBuilding,
PlannedName: snapshotName,
}); err != nil {
return err
}
ref, err := s.createSnapshot(ctx, mgr, sess.ID, snapshotName)
if err != nil {
return err
}
if err := s.skills.MarkSnapshotState(
ctx, tenantID, installRowID, types.SkillSnapshotStateActive, ref.ID,
); err != nil {
// The snapshot's ID exists nowhere but this function's locals now, so
// it is as unreachable as one nothing points at.
s.abandonSnapshot(cleanupBase, tenantID, mgr, installRowID, ref.ID)
return err
}
// 8. Switch the pointer. One DB write; everything after this is cleanup.
if err := s.switchImagePointer(ctx, tenantID, configID, ref.ID, generation, builtFingerprint); err != nil {
s.abandonSnapshot(cleanupBase, tenantID, mgr, installRowID, ref.ID)
return err
}
pointerSwitched = true
// The heartbeat writes the whole row, so it must be gone before the
// terminal "ready" write below: a beat landing after it would put the row
// back to installing and have the reaper fail a skill that is serving.
stopHeartbeat()
s.markPreviousSnapshotsSuperseded(ctx, tenantID, configID, installRowID)
// The terminal write is the one that must not be best-effort: the pointer
// already moved, so a row left at "installing" would be reaped as failed
// even though the skill is installed and serving.
readyCtx, cancelReady := s.cleanupContext(cleanupBase)
defer cancelReady()
if err := s.writeReadySkillState(readyCtx, tenantID, configID, skillID, ref.ID, bundle); err != nil {
return err
}
s.markConfigSandboxesStale(ctx, tenantID, configID)
s.publishProgress(ctx, tenantID, configID, skillID, SkillProgress{
Percent: 100, Stage: "done", Status: types.SkillStatusReady,
})
return nil
}
// ensureUsableImage refuses to grow the image when the stored snapshot is one
// the live credentials cannot resolve.
//
// Snapshots are private to a provider account, so a rotated credential turns
// the recorded pointer into an ID that no longer exists for us: the session
// layer notices via the fingerprint and boots the BASE TEMPLATE instead. A run
// that ignored that would do its work in a sandbox carrying none of the
// tenant's skills, snapshot that, and switch the pointer to it with a
// fingerprint that now matches - so it sticks, while every skill row still
// claims ready. Until then the old image is still recoverable by restoring the
// credential, which is why both flows stop here instead of committing the loss.
func ensureUsableImage(cfgEntity *types.TenantSandboxConfigEntity) error {
if cfgEntity == nil || cfgEntity.Config == nil {
return nil
}
image := cfgEntity.Config.SkillImage
if image == nil || strings.TrimSpace(image.SnapshotID) == "" {
return nil
}
// An empty fingerprint on either side is the same condition: the session
// layer keeps the base template for both.
live := skillOwnerFingerprint(cfgEntity.Config)
if live != "" && image.OwnerFingerprint == live {
return nil
}
return fmt.Errorf(
"skill image %s of sandbox config %s belongs to another provider account; "+
"restore the credentials it was built with or rebuild the image",
image.SnapshotID, cfgEntity.ID,
)
}
// abandonSnapshot disposes of a snapshot that was created but never became
// reachable - either because the ledger could not be told its ID or because
// the pointer switch failed. Both leave a provider resource that is billed and
// that nothing can ever boot again, and this is the only snapshot delete
// either flow performs: an ordinary switch leaves the previous image reachable
// for the ledger.
//
// It runs on a detached context because a cancelled one is among the reasons
// the caller failed, and that is exactly when a leaked snapshot is most likely.
func (s *TenantSkillService) abandonSnapshot(
cleanupBase context.Context, tenantID uint64, mgr sandbox.Manager, rowID, snapshotID string,
) {
ctx, cancel := s.cleanupContext(cleanupBase)
defer cancel()
s.deleteSnapshotBestEffort(ctx, mgr, snapshotID)
_ = s.skills.MarkSnapshotState(
ctx, tenantID, rowID, types.SkillSnapshotStateDeleted, snapshotID)
}
// cleanupContext bounds one piece of compensating work, starting the budget
// when that work starts. Anchoring it earlier would hand every consumer an
// already-expired deadline, because an install takes minutes to reach them.
func (s *TenantSkillService) cleanupContext(
base context.Context,
) (context.Context, context.CancelFunc) {
timeout := s.cleanupTimeout
if timeout <= 0 {
timeout = installCleanupTimeout
}
return context.WithTimeout(base, timeout)
}
// resetSkillDir makes the target directory an empty, writable directory owned
// by the execution user.
//
// Emptying it is what makes a re-install an install of exactly the uploaded
// bundle: the sandbox boots from the inherited image, so a file that the
// previous version shipped and this one dropped would otherwise live on in
// every later snapshot while BundleSHA256 claims the tree equals the archive.
//
// Creating and chowning it is what makes the FIRST install work at all:
// seeding goes through the provider file API, which runs as the default exec
// user, and the skills root is not part of the base image.
func (s *TenantSkillService) resetSkillDir(
ctx context.Context, mgr sandbox.Manager, sessionID, skillDir string,
) error {
if err := guardSkillDir(skillDir); err != nil {
return err
}
root := sandbox.ShellQuote(sandbox.SkillsImageRoot)
dir := sandbox.ShellQuote(skillDir)
user := sandbox.DefaultSandboxExecUser
cmd := fmt.Sprintf(
"rm -rf %s && mkdir -p %s %s && chown %s:%s %s %s && chmod 755 %s %s",
dir, root, dir, user, user, root, dir, root, dir,
)
if _, err := s.execInstall(ctx, mgr, sessionID, cmd); err != nil {
return fmt.Errorf("reset skill directory %s: %w", skillDir, err)
}
return nil
}
// guardSkillDir refuses the one path no destructive skill command may target.
// SkillDirFor("") collapses to the skills root, so an empty skill name would
// turn any "rm -rf <dir>" into a wipe of every skill in the image. No caller
// can pass an empty name today; this is the invariant that keeps that true,
// because the failure is unrecoverable.
func guardSkillDir(skillDir string) error {
if path.Clean(skillDir) == path.Clean(sandbox.SkillsImageRoot) {
return fmt.Errorf("refusing to use the skills root %q as a skill directory", skillDir)
}
return nil
}
// seedSkillFiles writes the archive contents into the image. Doing it
// server-side rather than asking the agent to unpack keeps the source of truth
// byte-identical to what was uploaded. Provisioning the directory is
// runInstall's step 2, not this function's job.
//
// The files travel as one tar: each remote WriteFile is a MakeDir plus an
// upload, and a skill with dozens of files was spending minutes on that
// round-trip tax. Extracting inside the sandbox is one local untar.
func (s *TenantSkillService) seedSkillFiles(
ctx context.Context, mgr sandbox.Manager, sessionID, skillDir string, bundle *SkillBundle,
) error {
if bundle == nil || len(bundle.Files) == 0 {
return nil
}
store, err := installFileStore(mgr)
if err != nil {
return err
}
archive, err := packSkillTar(bundle)
if err != nil {
return err
}
if err := store.WriteSessionFile(ctx, sessionID, skillSeedArchivePath, archive); err != nil {
return fmt.Errorf("seed skill archive: %w", err)
}
if _, err := s.execInstall(ctx, mgr, sessionID, seedExtractCommand(skillDir)); err != nil {
return fmt.Errorf("extract skill archive: %w", err)
}
return nil
}
func seedExtractCommand(skillDir string) string {
tarPath := sandbox.ShellQuote(skillSeedArchivePath)
dir := sandbox.ShellQuote(skillDir)
return fmt.Sprintf("tar -xf %s -C %s && rm -f %s", tarPath, dir, tarPath)
}
func packSkillTar(bundle *SkillBundle) ([]byte, error) {
if bundle == nil {
return nil, nil
}
names := make([]string, 0, len(bundle.Files))
for rel := range bundle.Files {
names = append(names, rel)
}
sort.Strings(names)
var buf bytes.Buffer
tw := tar.NewWriter(&buf)
for _, rel := range names {
name := path.Clean(rel)
if name == "." || name == ".." || strings.HasPrefix(name, "../") || path.IsAbs(name) {
return nil, fmt.Errorf("skill file %q escapes the archive root", rel)
}
content := bundle.Files[rel]
if err := tw.WriteHeader(&tar.Header{
Name: name,
Mode: 0o644,
Size: int64(len(content)),
}); err != nil {
return nil, err
}
if _, err := tw.Write(content); err != nil {
return nil, err
}
}
if err := tw.Close(); err != nil {
return nil, err
}
return buf.Bytes(), nil
}
// beginInstallTranscript writes the locators and opening prompt after the
// skill directory is reset, so a console that opens during the file seed
// finds something to follow instead of 404-polling for minutes.
func (s *TenantSkillService) beginInstallTranscript(
ctx context.Context, tenantID uint64, skillID string,
sess *types.Session, mgr sandbox.Manager, skillDir string, bundle *SkillBundle,
) (*installTranscript, string) {
assistantMessageID := uuid.NewString()
prompt := buildInstallPrompt(skillDir, bundle, s.probeInstallTools(ctx, mgr, sess.ID))
transcript := newInstallTranscript(ctx, event.NewEventBus(), s.streams, s.messages, sess.ID, assistantMessageID,
// Asymptotic activity progress: every installer command advances the
// bar within the 35→79 span, so the number the admin watches moves
// while the agent works instead of sitting at seeded until agent_done.
func(steps int, lastCmd string) {
s.publishProgress(ctx, tenantID, sess.SandboxConfigID, skillID, SkillProgress{
Percent: asymptoticInstallPercent(steps),
Stage: "agent",
Log: lastCmd,
})
})
if err := transcript.Create(ctx, prompt); err != nil {
logger.Warnf(ctx, "[skill] seed install transcript for %s failed: %v", skillID, err)
}
transcript.Subscribe()
if err := s.updateSkillFields(ctx, tenantID, sess.SandboxConfigID, skillID,
func(e *types.TenantSkillEntity) {
e.InstallSessionID = sess.ID
e.InstallMessageID = assistantMessageID
}); err != nil {
logger.Warnf(ctx, "[skill] record transcript locators for %s failed: %v", skillID, err)
}
return transcript, prompt
}
// installerJob is everything the install-and-verify step needs. It is a struct
// because the step takes the union of what the agent side and the image side
// each need, and a nine-parameter call tells the reader nothing.
type installerJob struct {
tenantID uint64
configID string
skillID string
sess *types.Session
mgr sandbox.Manager
transcript *installTranscript
prompt string
skillDir string
bundle *SkillBundle
}
// installDependenciesAndVerify runs the installer conversation and the gate as
// one decision.
//
// They used to be two, and that is what let a working skill be rejected: the
// installer derived what to install from SKILL.md prose while the gate derived
// what must resolve from the imports every file executes. Those disagree on any
// skill whose library modules import something its documentation never names,
// and the install died with the answer already in hand. The gate is the only
// authority here, so its own findings become the next round's instruction, and
// nothing in this path derives that list a second time.
//
// A failure the gate marked unrepairable — a syntax error, a file the execution
// user cannot read — ends the run immediately. Another round cannot change it,
// and the bundle has to change instead.
func (s *TenantSkillService) installDependenciesAndVerify(
ctx context.Context, job installerJob,
) (err error) {
run, err := s.openInstallerRun(ctx, job.tenantID, job.sess, job.skillDir, job.transcript)
if err != nil {
job.transcript.Finish(context.WithoutCancel(ctx), err)
return err
}
// Detached from cancellation: when the install lock's renewal fails the
// context dies, and the transcript of the run that just died is precisely
// what someone will want to read.
defer func() { job.transcript.Finish(context.WithoutCancel(ctx), err) }()
prompt := job.prompt
for round := 1; ; round++ {
if err = run.round(ctx, prompt); err != nil {
return err
}
s.publishProgress(ctx, job.tenantID, job.configID, job.skillID,
SkillProgress{Percent: 80, Stage: "agent_done"})
// The agent's part of this round is over: mute the asymptotic activity
// progress so verification and any repair round — which publish their
// own stage anchors (80 / 82) — cannot be dragged back below them by
// the next round's tool calls.
job.transcript.muteActivityProgress()
// The tree is handed to the execution user BEFORE it is verified. The
// agent created these files as root, and the language passes
// deliberately run as the ordinary user: verifying first would test
// permissions that never reach the image, and a restrictive root umask
// would fail a perfectly good install because the .venv interpreter
// was unreadable.
if err = s.normalizeSkillPermissions(ctx, job.mgr, job.sess.ID, job.skillDir); err != nil {
return err
}
notes, verifyErr := s.verifySkill(ctx, job.mgr, job.sess.ID, job.skillDir, job.bundle)
s.reportVerificationNotes(ctx, job, notes)
if verifyErr == nil {
return nil
}
var gate *skillVerificationError
if round >= skillInstallVerifyRounds || !errors.As(verifyErr, &gate) || !gate.Repairable {
err = verifyErr
return err
}
logger.Infof(ctx, "[skill] %s failed %s verification with %d fixable finding(s); "+
"handing them back to the installer", job.skillID, gate.Language, len(gate.Problems))
s.publishProgress(ctx, job.tenantID, job.configID, job.skillID, SkillProgress{
Percent: 82, Stage: "repairing",
Log: fmt.Sprintf("%s verification found %d missing dependency/dependencies; "+
"asking the installer to add them", gate.Language, len(gate.Problems)),
})
if err = s.reopenSkillDirForRepair(ctx, job.mgr, job.sess.ID, job.skillDir); err != nil {
return err
}
prompt = buildRepairPrompt(job.skillDir, gate)
job.transcript.RecordPrompt(prompt)
}
}
// installerRun is one installer conversation, held open across rounds. A repair
// has to reach the same root shell, in the same sandbox, and be readable in the
// same transcript as the install it is repairing.
type installerRun struct {
engine interfaces.AgentEngine
transcript *installTranscript
sessionID string
}
// openInstallerRun builds the engine the conversation runs on. It calls the
// engine directly instead of going through sessionService.AgentQA, because
// AgentQA swallows engine failures (it emits an error event and returns nil)
// and we need a reliable signal before switching the image.
func (s *TenantSkillService) openInstallerRun(
ctx context.Context,
tenantID uint64,
sess *types.Session,
skillDir string,
transcript *installTranscript,
) (*installerRun, error) {
if s.installerAgents == nil {
return nil, errors.New("custom agent service is not configured")
}
if transcript == nil {
return nil, errors.New("install transcript was not seeded")
}
// The record is tenant-writable: updateBuiltinAgent lets a tenant persist a
// Config for any built-in ID, this one included. It is therefore read for
// the model choice only. What the root shell is told to do comes from the
// platform's own registry entry.
record, err := s.installerAgents.GetAgentByID(ctx, types.BuiltinSkillInstallerID)
if err != nil {
return nil, fmt.Errorf("load installer agent: %w", err)
}
agentConfig := installerAgentConfig(
installerAgentDefaults(ctx, tenantID), sess.SandboxConfigID, skillDir)
chatModel, err := s.resolveInstallerModel(ctx, tenantID, record)
if err != nil {
return nil, err
}
engine, err := s.agents.CreateAgentEngine(
ctx, agentConfig, chatModel, nil, transcript.bus, sess.ID, transcript.assistantMessageID,
)
if err != nil {
return nil, fmt.Errorf("create installer engine: %w", err)
}
return &installerRun{
engine: engine, transcript: transcript, sessionID: sess.ID,
}, nil
}
// round runs one installer turn.
//
// No conversation history is replayed. The engine is stateless across turns, so
// a repair round could be handed the first round's transcript — but what the
// gate reported is both shorter and more exact than anything that could be
// inferred from it, and re-reading the round that already missed a dependency
// is not what makes the next one find it.
func (r *installerRun) round(ctx context.Context, prompt string) error {
state, err := r.engine.Execute(ctx, r.sessionID, r.transcript.assistantMessageID, prompt, nil)
if err != nil {
return fmt.Errorf("installer agent failed: %w", err)
}
if state == nil || !state.IsComplete {
return errors.New("installer agent stopped without completing")
}
return nil
}
// reportVerificationNotes surfaces what the gate noticed but did not refuse: an
// auxiliary file that cannot load, a requirement whose environment marker
// cannot be evaluated here, an import that resolves only from a directory the
// skill ships no script in. None is evidence of a broken install, and all of
// them are what someone debugging this skill later will wish they had been
// told, so they are logged and streamed rather than discarded.
func (s *TenantSkillService) reportVerificationNotes(
ctx context.Context, job installerJob, notes []string,
) {
if len(notes) == 0 {
return
}
for _, note := range notes {
logger.Infof(ctx, "[skill] %s verification note: %s", job.skillID, note)
}
// One event, not one per note. Progress keeps only the latest value, so
// publishing them separately would leave a late subscriber holding whichever
// note happened to be last.
s.publishProgress(ctx, job.tenantID, job.configID, job.skillID, SkillProgress{
Percent: 80, Stage: "verify_note", Log: strings.Join(notes, "\n"),
})
}
// reopenSkillDirForRepair undoes normalizeSkillPermissions for another round.
//
// Verification runs as the ordinary user, so the tree is locked to 555 and root
// before it; a repair then has to install into .venv again. Install commands
// run as root and would get away with writing through those modes, but relying
// on that makes the next reader work out whether it still holds — restoring the
// state the seed left is the same two commands and needs no such argument.
func (s *TenantSkillService) reopenSkillDirForRepair(
ctx context.Context, mgr sandbox.Manager, sessionID, skillDir string,
) error {
if err := guardSkillDir(skillDir); err != nil {
return err
}
dir := sandbox.ShellQuote(skillDir)
user := sandbox.DefaultSandboxExecUser
cmd := fmt.Sprintf("chown -R %s:%s %s && chmod -R u+rwX,go+rX %s", user, user, dir, dir)
if _, err := s.execInstall(ctx, mgr, sessionID, cmd); err != nil {
return fmt.Errorf("reopen skill directory %s for a repair round: %w", skillDir, err)
}
return nil
}
// buildRepairPrompt turns the gate's own findings into the next round's brief.
//
// It carries no analysis of its own, deliberately: the gate is the only
// authority on what has to resolve in this image, and a second description
// written here could disagree with it. Every repairable finding names a
// distribution one of the skill's manifests declares and pip did not land, so
// the brief is short — install what the lines name, change nothing else.
func buildRepairPrompt(skillDir string, gate *skillVerificationError) string {
var findings strings.Builder
for _, problem := range gate.Problems {
findings.WriteString("- ")
findings.WriteString(problem)
findings.WriteString("\n")
}
return fmt.Sprintf(`Verification of the skill you just installed failed. Fix only this and stop.
The %s check reported:
%s
Every line above names a dependency one of this skill's own manifests declares
and that is not installed in this image. Install it.
- Python packages go into %s/.venv (`+"`uv pip install`"+`, or
%s/.venv/bin/python -m pip install). Node packages go under %s/node_modules.
- Do NOT edit SKILL.md, requirements.txt, pyproject.toml or package.json to
make the check pass. Those files are what read_skill serves, so weakening a
declaration here makes the installed skill differ from what everyone else
sees — and the dependency would still be missing at run time.
- If a package genuinely cannot be installed in this image, say so plainly in
your summary rather than working around it.
The same verification runs again as soon as you finish.
`, gate.Language, findings.String(), skillDir, skillDir, skillDir)
}
// normalizeSkillPermissions makes the skill tree readable and executable by
// the non-root execution user, and writable by nobody. Installs run as root,
// so without the mode change the user that actually runs skills could not read
// them at all — which is also why this runs before verification: the checks
// read every script as that user, so they have to see the permissions the
// snapshot will carry.
//
// Ownership stays with root rather than moving to the execution user because
// this tree is baked into an image every session of the config inherits. A
// session that could write here would be editing the skills every other
// session runs. 555 leaves reads and execs to the "other" bits, which is all a
// skill run needs; the cost is that Python cannot write __pycache__ into the
// tree and silently skips bytecode caching.
func (s *TenantSkillService) normalizeSkillPermissions(
ctx context.Context, mgr sandbox.Manager, sessionID, skillDir string,
) error {
dir := sandbox.ShellQuote(skillDir)
cmd := fmt.Sprintf("chmod -R 555 %s && chown -R root:root %s", dir, dir)
if _, err := s.execInstall(ctx, mgr, sessionID, cmd); err != nil {
return fmt.Errorf("normalize skill permissions (%s): %w", cmd, err)
}
return nil
}
// skillCacheBudgetMB caps the package download caches one image carries into
// its next generation, as one total across both accounts. Retaining the caches
// lets a future install resolve a package a previous install already fetched
// without touching the network — minutes of the agent phase. Letting them grow
// unbounded would bake every version ever downloaded into every future
// snapshot, so the total is measured and anything over the budget is wiped
// whole.
const skillCacheBudgetMB = 256
// cleanImageScratch resets the state that must not reach the snapshot and
// trims what may. The per-session workspace is wiped unconditionally: a
// snapshot carrying it would hand every future session this run's scratch
// files, and the wipe takes the base image's own input/output directories with
// it, so the same command restores them with the ownership the session account
// needs — the only step of an install that runs with the privileges to do it.
// That half is strict; failing it fails the install.
//
// The package download caches are the other half, and they are retained rather
// than wiped: pip, uv, npm and pnpm caches are first trimmed with the tools'
// own collectors, then measured as one total across both accounts and wiped
// whole when they exceed skillCacheBudgetMB. That half is best-effort by
// construction — every statement in it is guarded, and a prune that failed or
// a size that could not be measured only means a larger image, never a failed
// install.
func (s *TenantSkillService) cleanImageScratch(
ctx context.Context, mgr sandbox.Manager, sessionID string,
) error {
res, err := s.execInstall(ctx, mgr, sessionID, cleanImageScratchCommand())
if err != nil {
return fmt.Errorf("clean image scratch: %w", err)
}
// The retention half reports what the image will actually carry. Logged so
// the budget constant is tuned from data rather than guesses.
if res != nil && strings.TrimSpace(res.Stdout) != "" {
logger.Infof(ctx, "[skill] cache retention: %s", strings.TrimSpace(res.Stdout))
}
return nil
}
// cleanImageScratchCommand folds the workspace reset and the cache retention
// into one command, one round trip. The workspace half decides the exit code:
// its status is captured right after it runs and is what the command exits
// with, so merging cannot soften the old semantics. The retention half follows
// and cannot change that code — it is written to always succeed, and the shell
// it lands in has no set -e to turn a swallowed prune failure into an abort.
func cleanImageScratchCommand() string {
user := sandbox.DefaultSandboxExecUser
inputRoot := sandbox.ShellQuote(sandbox.SessionInputRoot)
outputRoot := sandbox.ShellQuote(sandbox.SessionOutputRoot)
var b strings.Builder
b.WriteString("rm -rf /workspace/* /workspace/.[!.]* || true")
fmt.Fprintf(&b, "; mkdir -p %s %s && chown %s:%s %s %s && chmod 775 %s %s; status=$?",
inputRoot, outputRoot,
user, user, inputRoot, outputRoot,
inputRoot, outputRoot,
)
// The prunes run as root, so they only ever trim root's caches. The budget
// guard below covers both accounts on purpose — it needs nothing but du and
// rm, and this runs as root.
for _, prune := range []struct{ tool, subcommand string }{
{"uv", "cache prune"},
{"npm", "cache verify"},
{"pnpm", "store prune"},
} {
fmt.Fprintf(&b, "; command -v %s >/dev/null 2>&1 && %s %s >/dev/null 2>&1 || true",
prune.tool, prune.tool, prune.subcommand)
}
paths := append(
packageCachePaths("/root"),
packageCachePaths(path.Join("/home", sandbox.DefaultSandboxExecUser))...,
)
b.WriteString("; ")
b.WriteString(cacheBudgetGuardCommand(paths, skillCacheBudgetMB*1024))
b.WriteString("; exit $status")
return b.String()
}
// cacheBudgetGuardCommand emits the shell that measures the given cache
// directories as one total and wipes them whole when they exceed budgetKB.
// Keeping the caches is the point, so every way the measurement can fail keeps
// them: no du, no directories, an unreadable size — all leave the caches in
// place. The total is echoed in both cases so the install log records what the
// image carries, which is the data the budget is tuned from.
func cacheBudgetGuardCommand(paths []string, budgetKB int) string {
quoted := make([]string, 0, len(paths))
for _, p := range paths {
quoted = append(quoted, sandbox.ShellQuote(p))
}
list := strings.Join(quoted, " ")
return fmt.Sprintf(
"total=$(du -skc %s 2>/dev/null | tail -n1 | cut -f1); "+
"echo \"cache total: ${total:-0}KB\"; "+
"[ \"${total:-0}\" -gt %d ] && { rm -rf %s; echo 'cache wiped: over budget'; }; true",
list, budgetKB, list)
}
// packageCachePaths lists the download caches the three package managers we
// support keep under a home directory. Retained up to the budget these days,
// but still capped: a cache is a means to a faster install, never the payload
// of the image.
func packageCachePaths(home string) []string {
return []string{
path.Join(home, ".cache", "pip"),
path.Join(home, ".cache", "uv"),
path.Join(home, ".npm"),
path.Join(home, ".local", "share", "pnpm", "store"),
}
}
// describeExecFailure renders everything the executor knows about a failed
// command. Transport failures and timeouts arrive as ExitCode -1 with an empty
// Stderr and the cause in Error, so formatting Stderr alone leaves the admin
// with "command failed (exit -1): " and nothing else.
func describeExecFailure(res *sandbox.ExecuteResult) string {
if res == nil {
return "no result from the sandbox"
}
parts := []string{fmt.Sprintf("exit %d", res.ExitCode)}
if res.Killed {
parts = append(parts, "killed")
}
if cause := strings.TrimSpace(res.Error); cause != "" {
parts = append(parts, "error: "+cause)
}
if stderr := strings.TrimSpace(res.Stderr); stderr != "" {
parts = append(parts, "stderr: "+stderr)
}
return strings.Join(parts, "; ")
}
// installExecutor resolves the one executor every command of an install runs
// through. It goes through the capability accessor rather than a bare type
// assertion so a manager that cannot run install-mode shell reports no
// capability instead of attempting the install on the WeKnora host.
func installExecutor(mgr sandbox.Manager) (sandbox.SessionInstallShellExecutor, error) {
executor := sessionSandboxInstallShellExecutor(mgr)
if executor == nil {
return nil, errors.New("sandbox backend does not support install-mode shell")
}
return executor, nil
}
// execInstall runs one command as root with the skills root allowed.
func (s *TenantSkillService) execInstall(
ctx context.Context, mgr sandbox.Manager, sessionID, command string,
) (*sandbox.ExecuteResult, error) {
executor, err := installExecutor(mgr)
if err != nil {
return nil, err
}
res, err := executor.ExecShellCommandWithOptions(ctx, sessionID, command,
sandbox.ShellExecOptions{AsRoot: true, AllowSkillsRoot: true, Timeout: installCommandTimeout})
if err != nil {
return nil, err
}
if res.ExitCode != 0 {
return res, fmt.Errorf("command failed (%s)", describeExecFailure(res))
}
return res, nil
}
func (s *TenantSkillService) tenantForStorage(ctx context.Context, tenantID uint64) *types.Tenant {
if info, ok := types.TenantInfoFromContext(ctx); ok && info.ID == tenantID {
return info
}
return &types.Tenant{ID: tenantID}
}
func (s *TenantSkillService) fileServiceForTenant(
ctx context.Context, tenantID uint64,
) (interfaces.FileService, error) {
if s.resolver == nil {
return nil, errors.New("storage resolver is not configured")
}
fs, _, err := s.resolver.ResolveFileService(ctx, s.tenantForStorage(ctx, tenantID), "", "", "")
if err != nil {
return nil, err
}
if fs == nil {
return nil, errors.New("file service is not configured")
}
return fs, nil
}
// pointInstallAtCatalog attaches the sandbox row to the definition that owns
// the zip. The install does not copy BundleRef: readers follow CatalogID, and
// uninstalling this sandbox must not be able to delete the definition object.
func (s *TenantSkillService) pointInstallAtCatalog(
ctx context.Context, skill *types.TenantSkillEntity, catalog *types.TenantSkillCatalogEntity,
) error {
if skill == nil {
return nil
}
if catalog == nil || strings.TrimSpace(catalog.BundleRef) == "" {
return fmt.Errorf("catalog archive is missing")
}
superseded := ""
if err := s.updateSkillFields(ctx, skill.TenantID, skill.SandboxConfigID, skill.ID,
func(e *types.TenantSkillEntity) {
superseded = strings.TrimSpace(e.BundleRef)
e.CatalogID = catalog.ID
e.BundleRef = ""
}); err != nil {
return err
}
// This install now reads the definition's copy, so whatever it named before
// — a pre-catalog object of its own, or an archive pinned by an earlier
// replacement — has one reader fewer.
if superseded != "" && superseded != strings.TrimSpace(catalog.BundleRef) {
s.releaseInstallBundle(ctx, skill.TenantID, superseded)
}
return nil
}
// updateSkillFields loads, mutates and writes back one skill row. It logs on
// failure for the progress-only call sites and returns the error for the one
// call site where the row is the record of a completed install.
func (s *TenantSkillService) updateSkillFields(
ctx context.Context,
tenantID uint64,
configID, skillID string,
mutate func(*types.TenantSkillEntity),
) error {
skill, err := s.skills.GetSkill(ctx, tenantID, configID, skillID)
if err != nil {
logger.Warnf(ctx, "[skill] load %s for update failed: %v", skillID, err)
return fmt.Errorf("load skill %s: %w", skillID, err)
}
if skill == nil {
return fmt.Errorf("skill %s not found", skillID)
}
mutate(skill)
if err := s.skills.UpdateSkill(ctx, skill); err != nil {
logger.Warnf(ctx, "[skill] update %s failed: %v", skillID, err)
return fmt.Errorf("update skill %s: %w", skillID, err)
}
return nil
}
// writeReadySkillState records the finished install. It is retried because it
// runs after the pointer switch: the skill is installed, snapshotted and being
// served, so a transient write failure here is a bookkeeping gap, not a failed
// install, and re-running the whole install to fix a row would be absurd.
//
// The write is skipped when a newer upload or a queued remove already owns
// the row: the pointer still moved, but stamping ready (or this run's
// snapshot id) on that row would lie about which bundle is serving.
func (s *TenantSkillService) writeReadySkillState(
ctx context.Context, tenantID uint64, configID, skillID, snapshotID string, bundle *SkillBundle,
) error {
if err := s.retrySkillBookkeeping(ctx, func() error {
owned, err := s.installStillOwnsTheRow(ctx, tenantID, configID, skillID, bundle)
if err != nil {
return err
}
if !owned {
return nil
}
return s.updateSkillFields(ctx, tenantID, configID, skillID,
func(e *types.TenantSkillEntity) {
e.Status = types.SkillStatusReady
e.Error = ""
e.InstalledSnapshotID = snapshotID
e.InstallingSince = nil
})
}); err != nil {
return fmt.Errorf("skill %s is installed and serving but could not be marked ready: %w",
skillID, err)
}
return nil
}
// retrySkillBookkeeping runs one terminal bookkeeping write past the point of
// no return, where the image already changed and only the row recording it is
// missing. Both callers are in that position, so both retry instead of
// reporting the whole run as failed.
func (s *TenantSkillService) retrySkillBookkeeping(
ctx context.Context, write func() error,
) error {
var lastErr error
for attempt := 1; attempt <= readySkillWriteAttempts; attempt++ {
if attempt > 1 {
select {
case <-time.After(readySkillWriteDelay):
case <-ctx.Done():
return ctx.Err()
}
}
if lastErr = write(); lastErr == nil {
return nil
}
}
return lastErr
}
func (s *TenantSkillService) failSkill(
ctx context.Context, tenantID uint64, configID, skillID string, bundle *SkillBundle, cause error,
) {
owned, err := s.installStillOwnsTheRow(ctx, tenantID, configID, skillID, bundle)
if err != nil || !owned {
return
}
_ = s.updateSkillFields(ctx, tenantID, configID, skillID, func(e *types.TenantSkillEntity) {
e.Status = types.SkillStatusFailed
e.Error = cause.Error()
e.InstallingSince = nil
})
s.publishProgress(ctx, tenantID, configID, skillID, SkillProgress{
Percent: 100, Stage: "failed", Status: types.SkillStatusFailed, Log: cause.Error(),
})
}
// installStillOwnsTheRow is the lock-side counterpart of InstallSkill's
// optimistic row write. A remove that ran first deleted the row; StopSkill
// flipped it to failed; a newer upload of the same name replaced BundleSHA256;
// a queued remove flipped the status; a sibling retry of the same archive
// found the first run had already landed in the live image. Any of those means
// this run must not snapshot — failSkill would stamp the newer owner's row,
// and a snapshot with no matching row is an orphan the ledger cannot name.
func (s *TenantSkillService) installStillOwnsTheRow(
ctx context.Context, tenantID uint64, configID, skillID string, bundle *SkillBundle,
) (bool, error) {
current, err := s.skills.GetSkill(ctx, tenantID, configID, skillID)
if err != nil {
return false, fmt.Errorf("load skill %s: %w", skillID, err)
}
if current == nil {
return false, nil
}
if current.Status == types.SkillStatusRemoving || current.Status == types.SkillStatusFailed {
return false, nil
}
if bundle != nil && current.BundleSHA256 != "" && current.BundleSHA256 != bundle.SHA256 {
return false, nil
}
if current.Status == types.SkillStatusReady {
_, inImage, ok := s.skillFilesInLiveImage(ctx, current)
if ok && inImage {
return false, nil
}
}
return true, nil
}
// canSkipInstall reports whether this upload is a no-op. Re-uploading the
// exact archive of a skill that is already ready (and still in the live image)
// must not boot a billed sandbox or grow a new snapshot. An install of the
// same bytes that is still beating is the same situation: the first run owns
// the work.
//
// Only a ready row is answered from the image. An installing row is answered
// from the heartbeat alone, deliberately: the ledger records which skill an
// install snapshotted, not which archive, so a row that is installing bundle
// B while the image still carries the earlier bundle A would look "already
// installed" and this upload would report a success that never happened.
//
// A failed skill with the same digest is a retry: the previous attempt never
// made it into the image. A removal in flight is not a skip either — taking
// the row back to installing is how an upload cancels it.
func (s *TenantSkillService) canSkipInstall(
ctx context.Context, existing *types.TenantSkillEntity, bundle *SkillBundle,
) bool {
if existing == nil || bundle == nil {
return false
}
if existing.BundleSHA256 == "" || existing.BundleSHA256 != bundle.SHA256 {
return false
}
switch existing.Status {
case types.SkillStatusInstalling:
return s.installIsInFlight(existing)
case types.SkillStatusReady:
_, inImage, ok := s.skillFilesInLiveImage(ctx, existing)
return ok && inImage
default:
return false
}
}
// installIsInFlight reports whether an installing row still belongs to a live
// process. The answer is the heartbeat: a running install restamps
// InstallingSince every skillInstallHeartbeatInterval, so silence past
// skillInstallInFlightSkip means the process is gone and the next upload must
// be allowed to start a new run rather than wait for the stuck-run reaper.
//
// Reading the submission time instead would force a choice between calling a
// slow install dead — a single agent command may take installCommandTimeout,
// and an install runs several — and leaving a dead one unrecoverable.
func (s *TenantSkillService) installIsInFlight(existing *types.TenantSkillEntity) bool {
if existing == nil || existing.InstallingSince == nil {
return false
}
return !existing.InstallingSince.Before(s.clock()().Add(-skillInstallInFlightSkip))
}
// startInstallHeartbeat keeps this run's liveness visible while it works, and
// returns the stop function that must be called before any terminal write.
//
// The heartbeat writes the whole row, so it would otherwise race the "ready"
// write past the pointer switch and revive an installing status. Both callers
// stop it before that point: runInstall stops it the moment the pointer moves,
// and the deferred stop runs before the deferred failSkill.
func (s *TenantSkillService) startInstallHeartbeat(
ctx context.Context, tenantID uint64, configID, skillID string,
) func() {
interval := s.installHeartbeat
if interval <= 0 {
interval = skillInstallHeartbeatInterval
}
beatCtx, stop := context.WithCancel(ctx)
done := make(chan struct{})
go func() {
defer close(done)
ticker := time.NewTicker(interval)
defer ticker.Stop()
for {
select {
case <-beatCtx.Done():
return
case <-ticker.C:
s.beatInstallHeartbeat(beatCtx, tenantID, configID, skillID)
}
}
}()
var once sync.Once
return func() {
once.Do(func() {
stop()
<-done
})
}
}
// beatInstallHeartbeat stamps InstallingSince for a row this run still owns.
// A row that has left the installing status belongs to a newer upload, a
// queued removal, or a finished run, and reviving its timestamp would hide
// one of those from the reaper.
func (s *TenantSkillService) beatInstallHeartbeat(
ctx context.Context, tenantID uint64, configID, skillID string,
) {
current, err := s.skills.GetSkill(ctx, tenantID, configID, skillID)
if err != nil {
logger.Warnf(ctx, "[skill] load %s for install heartbeat failed: %v", skillID, err)
return
}
if current == nil || current.Status != types.SkillStatusInstalling {
return
}
at := s.clock()()
current.InstallingSince = &at
if err := s.skills.UpdateSkill(ctx, current); err != nil {
logger.Warnf(ctx, "[skill] install heartbeat for %s failed: %v", skillID, err)
}
}
// startMaintenanceSession opens the session one image operation runs in. The
// operation name is carried into the session because the transcript is kept
// deliberately, for troubleshooting: filing a removal's under "Skill install"
// would send whoever reads it looking at the wrong operation.
//
// The description marker is what keeps this row out of the console's session
// list, and the owner is the admin who started the operation, so a transcript
// is readable by the person who caused it rather than by the whole workspace.
func (s *TenantSkillService) startMaintenanceSession(
ctx context.Context, tenantID uint64, configID, operation string,
) (*types.Session, sandbox.Manager, error) {
if s.sessions == nil {
return nil, nil, errors.New("session service is not configured")
}
// Honour the workspace kill switch before creating a billed sandbox or a
// session row. resolveTenantSandboxForConfig is the same choke point every
// other sandbox caller uses; going through TenantSandboxResolver.Resolve
// directly would let an install run while scripts are disabled.
mgr, err := resolveTenantSandboxForConfig(ctx, s.sandboxes, nil, tenantID, configID, s.sandboxPolicy)
if err != nil {
return nil, nil, fmt.Errorf("resolve sandbox config: %w", err)
}
if mgr == nil {
return nil, nil, errors.New("sandbox resolver returned nil manager")
}
if mgr.GetType() == sandbox.SandboxTypeDisabled {
return nil, nil, errors.New("sandbox execution is disabled for this workspace")
}
sess, err := s.sessions.CreateSession(ctx, &types.Session{
TenantID: tenantID,
UserID: sessionUserIDFromContext(ctx),
Title: "Skill " + operation,
Description: types.SkillMaintenanceSessionMarker + operation,
SandboxConfigID: configID,
})
if err != nil {
return nil, nil, fmt.Errorf("create %s session: %w", operation, err)
}
if sess == nil {
return nil, nil, fmt.Errorf("create %s session returned nil", operation)
}
return sess, mgr, nil
}
func (s *TenantSkillService) releaseSandbox(ctx context.Context, mgr sandbox.Manager, sessionID string) {
destroyer, ok := mgr.(sandbox.SessionDestroyer)
if !ok {
return
}
if err := destroyer.DestroySession(ctx, sessionID); err != nil {
logger.Warnf(ctx, "[skill] destroy install sandbox for session %s failed: %v", sessionID, err)
}
}
func (s *TenantSkillService) createSnapshot(
ctx context.Context, mgr sandbox.Manager, sessionID, name string,
) (sandbox.RemoteSnapshotRef, error) {
snapshots, ok := mgr.(sandbox.RemoteSnapshotManager)
if !ok {
return sandbox.RemoteSnapshotRef{}, errors.New("sandbox backend does not support snapshots")
}
ref, err := snapshots.CreateSnapshot(ctx, sessionID, name)
if err != nil {
return sandbox.RemoteSnapshotRef{}, fmt.Errorf("create snapshot: %w", err)
}
if strings.TrimSpace(ref.ID) == "" {
return sandbox.RemoteSnapshotRef{}, errors.New("create snapshot returned empty id")
}
return ref, nil
}
func (s *TenantSkillService) deleteSnapshotBestEffort(
ctx context.Context, mgr sandbox.Manager, snapshotID string,
) {
snapshots, ok := mgr.(sandbox.RemoteSnapshotManager)
if !ok {
return
}
if err := snapshots.DeleteSnapshot(ctx, snapshotID); err != nil {
logger.Warnf(ctx, "[skill] delete orphan snapshot %s failed: %v", snapshotID, err)
}
}
// switchImagePointer is the single DB write that makes the new snapshot the
// image every future session boots from.
//
// The config is re-read here rather than reused from the read at the top of
// runInstall, which happened minutes and one whole agent conversation ago. The
// repository Update overwrites name, description, sandbox type and the entire
// config blob, and config edits are serialised by the config service's cordon
// rather than by this task's lock — so writing back the stale entity would
// silently revert an admin's rename or credential rotation.
//
// builtFingerprint is the account the maintenance sandbox (and therefore the
// snapshot) actually belongs to. A live fingerprint that no longer matches
// means the credentials rotated while this run was in flight: stamping the
// new fingerprint onto the old snapshot would make the session layer trust an
// ID the live key cannot resolve. Abort instead, and leave the previous image
// in place.
func (s *TenantSkillService) switchImagePointer(
ctx context.Context,
tenantID uint64,
configID string,
snapshotID string,
generation int,
builtFingerprint string,
) error {
cfgEntity, err := s.configs.GetByID(ctx, tenantID, configID)
if err != nil {
return fmt.Errorf("re-read sandbox config %s: %w", configID, err)
}
if cfgEntity == nil || cfgEntity.Config == nil {
return fmt.Errorf("sandbox config %s disappeared during the install", configID)
}
if strings.TrimSpace(builtFingerprint) == "" {
return fmt.Errorf(
"sandbox config %s has no usable owner fingerprint for a skill image", configID,
)
}
live := skillOwnerFingerprint(cfgEntity.Config)
if live != builtFingerprint {
return fmt.Errorf(
"sandbox config %s credentials changed during the image build; "+
"the snapshot belongs to the previous provider account",
configID,
)
}
// Only the SkillImage portion is touched; everything else in the entity is
// whatever the latest read says it is.
cfgEntity.Config.SkillImage = &types.SkillImageConfig{
SnapshotID: snapshotID,
Generation: generation,
BuiltAt: s.now(),
BaseTemplateID: effectiveBaseTemplate(cfgEntity),
OwnerFingerprint: builtFingerprint,
}
cfgEntity.TenantID = tenantID
return s.configs.Update(ctx, cfgEntity)
}
// effectiveBaseTemplate returns the template the image chain grew from. Once
// recorded it is never re-derived, because the config's own template field is
// overwritten with the snapshot ID whenever a session resolves the config.
func effectiveBaseTemplate(cfgEntity *types.TenantSandboxConfigEntity) string {
if cfgEntity == nil || cfgEntity.Config == nil {
return ""
}
if image := cfgEntity.Config.SkillImage; image != nil &&
strings.TrimSpace(image.BaseTemplateID) != "" {
return image.BaseTemplateID
}
return currentBaseTemplate(cfgEntity.Config)
}
func (s *TenantSkillService) markPreviousSnapshotsSuperseded(
ctx context.Context, tenantID uint64, configID, currentRowID string,
) {
rows, err := s.skills.ListSnapshotsByConfig(ctx, tenantID, configID)
if err != nil {
logger.Warnf(ctx, "[skill] list snapshots for supersede failed: %v", err)
return
}
for _, row := range rows {
if row == nil || row.ID == currentRowID || row.State != types.SkillSnapshotStateActive {
continue
}
if err := s.skills.MarkSnapshotState(
ctx, tenantID, row.ID, types.SkillSnapshotStateSuperseded, row.SnapshotID,
); err != nil {
logger.Warnf(ctx, "[skill] mark snapshot %s superseded failed: %v", row.ID, err)
}
}
}
func currentGeneration(cfgEntity *types.TenantSandboxConfigEntity) int {
if cfgEntity == nil || cfgEntity.Config == nil || cfgEntity.Config.SkillImage == nil {
return 0
}
return cfgEntity.Config.SkillImage.Generation
}
func currentSnapshotID(cfgEntity *types.TenantSandboxConfigEntity) string {
if cfgEntity == nil || cfgEntity.Config == nil || cfgEntity.Config.SkillImage == nil {
return ""
}
return cfgEntity.Config.SkillImage.SnapshotID
}
func skillSnapshotNamePrefix(tenantID uint64, configID string) string {
return fmt.Sprintf("weknora-sk-t%d-%s", tenantID, compactConfigID(configID))
}
// nextSnapshotGeneration is one past both the live pointer and every ledger
// row. An abandoned building row still occupies its generation; reusing it
// would mint the same planned name and let the reaper delete a later install's
// snapshot.
func nextSnapshotGeneration(live int, rows []*types.TenantSkillSnapshotEntity) int {
highest := live
for _, row := range rows {
if row != nil && row.Generation > highest {
highest = row.Generation
}
}
if highest < 0 {
highest = 0
}
return highest + 1
}
func compactSnapshotToken(id string) string {
s := compactConfigID(id)
const n = 8
if len(s) > n {
return s[:n]
}
if s == "" {
return "row"
}
return s
}
// skillSnapshotBuildName is the name every generation of a config's image
// chain is committed under. It is recorded on the ledger row before the
// provider call so an abandoned build stays identifiable. Tenant and the
// full config id are in the name because Cube, E2B and Docker all list
// snapshots across a shared account or daemon; the row token stops two
// builds of the same generation from sharing a tag.
func skillSnapshotBuildName(tenantID uint64, configID string, generation int, rowID string) string {
prefix := skillSnapshotNamePrefix(tenantID, configID)
return fmt.Sprintf("%s-g%d-%s", prefix, generation, compactSnapshotToken(rowID))
}
func compactConfigID(id string) string {
return strings.ToLower(strings.ReplaceAll(strings.TrimSpace(id), "-", ""))
}
// weknoraSkillSnapshotName pulls the weknora-sk-… token out of a provider
// listing. Cube and E2B echo it in Names; Docker embeds it in the image tag.
func weknoraSkillSnapshotName(raw string) string {
s := strings.TrimSpace(raw)
s = strings.TrimPrefix(s, "docker.io/")
if i := strings.LastIndex(s, "/"); i >= 0 {
s = s[i+1:]
}
if cut := strings.IndexByte(s, ':'); cut >= 0 {
s = s[:cut]
}
if strings.HasPrefix(s, "weknora-sk-") {
return s
}
return ""
}
// snapshotsNotFromOtherConfig drops provider listings that already name a
// different WeKnora config. Cube, E2B and Docker all ListSnapshots across the
// whole account/daemon, so without this a reconcile of one config would treat
// every other config's image as an extra, and an abandoned-build match could
// bind to the wrong snapshot.
func snapshotsNotFromOtherConfig(
listed []sandbox.RemoteSnapshotRef, prefix string,
) []sandbox.RemoteSnapshotRef {
if strings.TrimSpace(prefix) == "" {
return listed
}
out := make([]sandbox.RemoteSnapshotRef, 0, len(listed))
for _, snap := range listed {
if snapshotBelongsToOtherConfig(snap, prefix) {
continue
}
out = append(out, snap)
}
return out
}
func snapshotBelongsToOtherConfig(snap sandbox.RemoteSnapshotRef, prefix string) bool {
if strings.TrimSpace(prefix) == "" {
return false
}
needle := prefix + "-g"
sawForeign := false
for _, candidate := range append([]string{snap.ID}, snap.Names...) {
name := weknoraSkillSnapshotName(candidate)
if name == "" {
continue
}
if strings.HasPrefix(name, needle) {
return false
}
// New-format names are weknora-sk-t<tenant>-<config>-gN. Legacy
// weknora-sk-<short>-gN names are left alone so a row written before
// the prefix existed can still be matched.
rest := strings.TrimPrefix(name, "weknora-sk-")
if len(rest) > 1 && rest[0] == 't' && rest[1] >= '0' && rest[1] <= '9' {
sawForeign = true
}
}
return sawForeign
}
func buildInstallPrompt(skillDir string, bundle *SkillBundle, tools map[string]string) string {
skillMD := ""
requirementsPath := ""
if bundle != nil {
skillMD = string(bundle.Files["SKILL.md"])
requirementsPath = sandbox.SkillRequirementsPath(bundle.Name)
}
return fmt.Sprintf(`Install this WeKnora skill into the sandbox image.
Skill directory: %s
%s
Hard requirements:
- Install dependencies for exactly this one skill.
- Python dependencies must go into %s/.venv. Do not install into system Python.
- Node dependencies must go under %s/node_modules. Do not install global packages unless no local alternative exists.
- shell_exec already starts every command in %s. Use relative paths
(`+"`ls -la scripts/`"+`, `+"`uv venv --seed .venv`"+`) and do NOT prefix
`+"`cd <skill-dir> &&`"+` onto them.
- To create or change a file in this tree use write_skill_file /
edit_skill_file, NOT a shell heredoc or `+"`cat`"+`: those truncate at the
command-length cap and mangle quoting.
write_sandbox_file only writes /workspace, which is wiped before the
snapshot, so it cannot help you here.
- Each command has a 10-minute budget; you do not need to set timeout_sec.
- When finished, report what you installed and any global/system packages you changed.
- Declare the environment variables this skill needs AT RUN TIME. Decide from the SKILL.md text
at the end of this message: declare what it documents as needed to run the skill. Ignore
anything only the installation itself needed. Write the declaration with write_skill_file to %s, as JSON of this exact shape:
{"env":[{"name":"TAVILY_API_KEY","description":"what the skill uses it for","required":true}]}
Each name must be UPPER_SNAKE_CASE and must appear literally somewhere in the skill's own files.
Never write any value, placeholder or example credential: this file declares what is needed, and
a value you invent would be stored as this workspace's real credential. If one environment
variable is required, set required to true; if it is optional, set required to false. If the
skill needs no environment variables, write {"env":[]}.
Do not declare WEKNORA_SKILL_DIR, WEKNORA_SKILL_OUTPUT_DIR, WEKNORA_SKILL_HISTORY_ROOT or
WEKNORA_SESSION_INPUT_DIR: the sandbox injects those. Other WEKNORA_* names the skill reads
(WEKNORA_API_KEY, WEKNORA_BASE_URL, WEKNORA_HOST, WEKNORA_TOKEN, WEKNORA_KB_ID) MUST be declared.
On-demand / optional extras MUST be installed now. After you finish, this
tree is made read-only and session agents cannot pip/npm into it (uv venv also
has no pip). Skills that ship scripts/install_deps.py or say "pip install when
the user needs Word/PPT" will fail at chat time unless those packages are
already in the venv.
- Create the venv with pip present: `+"`uv venv --seed %s/.venv`"+` (or `+"`python3 -m venv`"+`).
- Install requirements.txt / pyproject.toml with `+"`uv pip install`"+`.
- Read SKILL.md and any on-demand installer for extra packages (python-docx,
python-pptx, …) and `+"`uv pip install`"+` every extra, not only the default set.
%s
- If an installer script needs --yes / --all / every extra flag, pass them.
%s
Before you finish, PROVE the skill's imports resolve. Do not reason about it —
run it. The server's own check cannot: it parses files without executing them,
so it never learns whether an import would have worked. You have the real
interpreter, so this is your job and yours only.
- For each script the skill offers, run the import the way the skill would:
`+"`%s/.venv/bin/python -c 'import x'`"+`, or the script's own
`+"`--help`"+` if it has one.
- A failure here is usually one of two things. A missing distribution: install
it. Or a module the skill ships that Python cannot find — then the script
needs the directory on sys.path, and you fix the script with edit_skill_file
rather than installing anything.
- Do not declare success until every entry point imports cleanly.
The server then checks what it can before the image is kept, so report what you
did rather than whether it passed. It confirms every file parses with the
interpreter that would run it, and that every distribution named in
requirements.txt / pyproject.toml is installed in the venv. It never runs the
skill's code and never judges an import.
Lazy imports and install_deps.py extras are invisible to that check — you still
have to install them.
SKILL.md:
%s
`, skillDir, formatToolchainSection(tools), skillDir, skillDir, skillDir,
requirementsPath, skillDir, formatOnDemandInstallers(bundle),
formatFrontmatterRepairNote(bundle), skillDir, skillMD)
}
// formatOnDemandInstallers names bundle files that install extras at first
// use, so the installer agent does not have to discover them by grepping.
func formatOnDemandInstallers(bundle *SkillBundle) string {
names := bundleOnDemandInstallers(bundle)
if len(names) == 0 {
return "- Look for scripts named install_deps.py (or similar) even if they are not listed here."
}
quoted := make([]string, len(names))
for i, name := range names {
quoted[i] = "`" + name + "`"
}
return "- This archive ships on-demand installer(s): " + strings.Join(quoted, ", ") +
". Run each one now with non-interactive flags covering every extra."
}
func bundleOnDemandInstallers(bundle *SkillBundle) []string {
if bundle == nil {
return nil
}
var names []string
for rel := range bundle.Files {
if skills.IsOnDemandInstallerPath(rel) {
names = append(names, rel)
}
}
sort.Strings(names)
return names
}
func formatFrontmatterRepairNote(bundle *SkillBundle) string {
if bundle == nil || !bundle.FrontmatterRepaired {
return ""
}
return "\nThe SKILL.md YAML frontmatter was automatically repaired " +
"(keys nested under name, or an unquoted colon). Extra or still-broken " +
"keys were not reconstructed. Mention this in your summary so the user " +
"can fix the file.\n"
}
// installProbeTools are the executables the installer agent reaches for. The
// probe resolves each one's absolute path so the prompt can hand them over
// instead of leaving the agent to burn a turn on `which` — or to gamble that
// the shell_exec PATH contains, say, /root/.local/bin.
var installProbeTools = []string{"uv", "npm", "pnpm", "pip3", "pip", "python3", "node"}
// installToolsProbeCommand locates every probe tool in one shell pass. The if
// guards the not-found case so the loop exits 0 whatever is missing: a tool
// that is absent is a fact for the prompt, not an error.
func installToolsProbeCommand() string {
var b strings.Builder
b.WriteString("for t in")
for _, t := range installProbeTools {
b.WriteByte(' ')
b.WriteString(t)
}
b.WriteString(`; do if p=$(command -v "$t" 2>/dev/null); then printf '%s=%s\n' "$t" "$p"; fi; done`)
return b.String()
}
// parseToolProbeOutput reads the probe's `name=path` lines. Anything else on
// stdout is ignored: a tool line is the only thing the command emits, but the
// prompt is better served by dropping noise than by failing the probe over it.
func parseToolProbeOutput(stdout string) map[string]string {
tools := make(map[string]string)
for _, line := range strings.Split(stdout, "\n") {
name, p, found := strings.Cut(strings.TrimSpace(line), "=")
if found && name != "" && p != "" {
tools[name] = p
}
}
return tools
}
// probeInstallTools resolves the absolute paths of the package managers the
// installer may reach for. One command, one round trip — the same probe
// `uv available` used to cost, carrying every tool instead of one bit. A
// failed probe is not an install failure: the prompt falls back to telling
// the agent to discover the toolchain itself.
func (s *TenantSkillService) probeInstallTools(
ctx context.Context, mgr sandbox.Manager, sessionID string,
) map[string]string {
res, err := s.execInstall(ctx, mgr, sessionID, installToolsProbeCommand())
if err != nil || res == nil {
return nil
}
return parseToolProbeOutput(res.Stdout)
}
// formatToolchainSection renders the probe result for the prompt: one line
// per tool with its absolute path, one line grouping whatever is missing.
// An empty result — a probe that failed, or an image with none of the tools —
// reads the same to the agent: locate them yourself before relying on PATH.
func formatToolchainSection(tools map[string]string) string {
if len(tools) == 0 {
return "Toolchain: could not be probed in advance; " +
"locate tools with `command -v <tool>` before relying on PATH."
}
var b strings.Builder
b.WriteString("Toolchain (absolute paths as resolved in this image; prefer them over PATH):")
var missing []string
for _, t := range installProbeTools {
if p, ok := tools[t]; ok {
fmt.Fprintf(&b, "\n- %s: %s", t, p)
} else {
missing = append(missing, t)
}
}
if len(missing) > 0 {
b.WriteString("\nnot found: ")
b.WriteString(strings.Join(missing, ", "))
}
return b.String()
}
// installerAgentDefaults returns the platform's own definition of the installer
// agent. It is deliberately not the record GetAgentByID serves, which a tenant
// can overwrite. When the registry has not been loaded, an ID-only agent yields
// the hardcoded platform defaults below rather than improvised settings.
func installerAgentDefaults(ctx context.Context, tenantID uint64) *types.CustomAgent {
if defaults := types.GetBuiltinAgentWithContext(
ctx, types.BuiltinSkillInstallerID, tenantID,
); defaults != nil {
return defaults
}
return &types.CustomAgent{ID: types.BuiltinSkillInstallerID}
}
// installerAgentConfig builds the config the install session runs under.
//
// `defaults` must be the built-in registry entry, never the tenant-editable
// record. Everything that decides what the root shell is asked to do — system
// prompt, tool whitelist, iteration budget — is fixed by the platform here.
// A tenant can edit any built-in agent's stored Config, and "can edit an
// agent" must not become "can script a root shell whose output is baked into
// the shared sandbox image": that is a different permission from "can upload a
// skill". The model is the one choice still taken from the stored record, in
// resolveInstallerModel.
//
// skillDir scopes the skill file tools to the one skill this install owns. The
// installer's shell already runs as root in the shared image, so the tools add
// no reach — they replace `cat` with a heredoc, whose command-length cap and
// double quoting truncated or mangled every file the agent tried to write.
func installerAgentConfig(
defaults *types.CustomAgent, configID, skillDir string,
) *types.AgentConfig {
memoryOff := false
thinkingOff := false
installTools := []string{
tools.ToolShellExec, tools.ToolWriteSkillFile, tools.ToolEditSkillFile,
}
cfg := &types.AgentConfig{
MaxIterations: 30,
AllowedTools: append([]string(nil), installTools...),
Temperature: 0.2,
WebSearchEnabled: false,
MCPSelectionMode: "none",
MemoryEnabled: &memoryOff,
SandboxConfigID: configID,
// Installs are short shell-command rounds, not reasoning tasks: pin
// thinking off instead of deferring to the model's own default, which
// burns latency and completion tokens on every ReAct round.
Thinking: &thinkingOff,
}
if defaults == nil {
return cfg
}
// The installer's shell_exec must run as root inside the skills image
// root; the prompt below asks for exactly that. The grant is keyed on the
// built-in agent ID and refused for anything else.
cfg.EnableSkillInstallMode(defaults.ID, skillDir)
custom := defaults.Config
cfg.MaxIterations = custom.MaxIterations
if cfg.MaxIterations < 0 {
cfg.MaxIterations = types.UnlimitedMaxIterations
} else if cfg.MaxIterations == 0 {
cfg.MaxIterations = 30
}
// The install tools are unioned in rather than read off the registry entry.
// An install that cannot write its own skill directory cannot record what
// it did, and the platform YAML predates these tools — a deployment that
// has not re-synced it must not silently lose them.
cfg.AllowedTools = unionTools(custom.AllowedTools, installTools)
cfg.Temperature = custom.Temperature
cfg.SystemPrompt = custom.SystemPrompt
cfg.UseCustomSystemPrompt = custom.SystemPrompt != ""
cfg.WebSearchEnabled = false
cfg.WebSearchMaxResults = 0
cfg.MCPSelectionMode = "none"
cfg.MemoryEnabled = &memoryOff
cfg.MultiTurnEnabled = custom.MultiTurnEnabled
cfg.LLMCallTimeout = custom.LLMCallTimeout
return cfg
}
// unionTools returns configured plus every name in required that it is missing,
// preserving the configured order so a deployment's own list still reads as it
// was written.
func unionTools(configured, required []string) []string {
seen := make(map[string]struct{}, len(configured)+len(required))
out := make([]string, 0, len(configured)+len(required))
for _, name := range append(append([]string(nil), configured...), required...) {
if name = strings.TrimSpace(name); name == "" {
continue
}
if _, ok := seen[name]; ok {
continue
}
seen[name] = struct{}{}
out = append(out, name)
}
return out
}
// resolveInstallerModel prefers the model the installer agent is configured
// with. Whoever set that model chose it for this job; the workspace default is
// only the fallback for a record that names no model or names one this
// workspace can no longer resolve.
func (s *TenantSkillService) resolveInstallerModel(
ctx context.Context, tenantID uint64, agent *types.CustomAgent,
) (chat.Chat, error) {
if s.models == nil {
return nil, errors.New("model service is not configured")
}
if agent != nil {
if modelID := strings.TrimSpace(agent.Config.ModelID); modelID != "" {
model, err := s.models.GetChatModel(ctx, modelID)
if err == nil && model != nil {
return model, nil
}
logger.Warnf(ctx,
"[skill] installer agent model %s is unusable (%v); falling back to the workspace default",
modelID, err)
}
}
models, err := s.models.ListModels(ctx)
if err != nil {
return nil, fmt.Errorf("list models for installer: %w", err)
}
for _, model := range models {
if model != nil && model.Type == types.ModelTypeKnowledgeQA &&
model.Status == types.ModelStatusActive && model.IsDefault {
return s.models.GetChatModel(ctx, model.ID)
}
}
for _, model := range models {
if model != nil && model.Type == types.ModelTypeKnowledgeQA &&
model.Status == types.ModelStatusActive {
return s.models.GetChatModel(ctx, model.ID)
}
}
return nil, fmt.Errorf("workspace %d has no active chat model for skill installer", tenantID)
}
func (s *TenantSkillService) writeManifestEntry(
ctx context.Context, mgr sandbox.Manager, sessionID, skillID string, bundle *SkillBundle,
) error {
store, err := installFileStore(mgr)
if err != nil {
return err
}
manifest := skillImageManifest{}
if reader, ok := mgr.(sandbox.SessionFileReader); ok {
if raw, err := reader.ReadSessionFile(ctx, sessionID, sandbox.SkillsManifestPath); err == nil {
_ = json.Unmarshal(raw, &manifest)
}
}
entry := skillImageManifestEntry{
ID: skillID,
Name: bundle.Name,
Version: bundle.Version,
SHA256: bundle.SHA256,
InstalledAt: s.now(),
}
replaced := false
for i := range manifest.Skills {
if manifest.Skills[i].ID == skillID {
manifest.Skills[i] = entry
replaced = true
break
}
}
if !replaced {
manifest.Skills = append(manifest.Skills, entry)
}
payload, err := json.MarshalIndent(manifest, "", " ")
if err != nil {
return err
}
payload = append(payload, '\n')
return store.WriteSessionFile(ctx, sessionID, sandbox.SkillsManifestPath, payload)
}
// recordEnvDeclaration reads the environment variables the installer agent
// declared and stores the ones that survive validation.
//
// It returns nothing because none of its failures is a failed install. The
// agent may not have written the file, may have written prose, or may have
// listed nothing that exists in the bundle; in every case the skill is
// installed and working, and the missing declaration costs an admin one manual
// entry in the settings page. Failing the install over it would throw away the
// minutes of dependency installation that already succeeded.
func (s *TenantSkillService) recordEnvDeclaration(
ctx context.Context, mgr sandbox.Manager, sessionID string,
tenantID uint64, configID, skillID string, bundle *SkillBundle,
) {
if bundle == nil {
return
}
reader, ok := mgr.(sandbox.SessionFileReader)
if !ok {
logger.Warnf(ctx,
"[skill] sandbox backend cannot read files back; skill %s keeps no env declaration",
skillID)
return
}
requirementsPath := sandbox.SkillRequirementsPath(bundle.Name)
if requirementsPath == "" {
return
}
raw, err := reader.ReadSessionFile(ctx, sessionID, requirementsPath)
if err != nil {
// A skill that needs no credentials writes no file at all, so an
// absent one is normal. Any other read failure means a declaration
// may exist and was lost, which an operator has to be able to see.
if sandbox.IsRemoteNotFound(err) {
logger.Infof(ctx, "[skill] %s declared no environment variables (no %s)",
skillID, requirementsPath)
return
}
logger.Warnf(ctx, "[skill] %s: reading its env declaration at %s failed: %v",
skillID, requirementsPath, err)
return
}
declared, err := parseEnvDeclaration(raw)
if err != nil {
logger.Warnf(ctx, "[skill] %s wrote an unreadable env declaration: %v", skillID, err)
return
}
envs := validateEnvDeclarations(declared, bundle)
if len(envs) == 0 && len(declared) > 0 {
// The original count is the only clue an admin has for "why is my
// variable not in the list": every name was rejected, not ignored.
// Learning nothing usable must not erase a value already stored.
logger.Warnf(ctx,
"[skill] all %d environment variable(s) declared for %s were rejected "+
"(bad name, not mentioned anywhere in the bundle, or reserved)",
len(declared), skillID)
return
}
skill, err := s.skills.GetSkill(ctx, tenantID, configID, skillID)
if err != nil || skill == nil {
logger.Warnf(ctx, "[skill] load %s to store its env declaration failed: %v", skillID, err)
return
}
merged := mergeEnvDeclaration(skill.Envs, envs)
if err := s.skills.UpdateSkillEnvs(ctx, tenantID, configID, skillID, merged); err != nil {
logger.Warnf(ctx, "[skill] store the env declaration of %s failed: %v", skillID, err)
}
}
type skillImageManifest struct {
Skills []skillImageManifestEntry `json:"skills"`
}
type skillImageManifestEntry struct {
ID string `json:"id"`
Name string `json:"name"`
Version string `json:"version,omitempty"`
SHA256 string `json:"sha256"`
InstalledAt time.Time `json:"installed_at"`
}
// sessionInstallFileStore is the narrow write surface the install flow needs:
// SessionBoundManager.WriteSessionFile only permits paths under the skills
// image root.
type sessionInstallFileStore interface {
WriteSessionFile(ctx context.Context, sessionID, filePath string, content []byte) error
}
// installFileStore is the single place the image-write capability is checked.
// Local and disabled managers do not implement it, so an install that resolved
// to one fails here rather than half-writing an image.
func installFileStore(mgr sandbox.Manager) (sessionInstallFileStore, error) {
store, ok := mgr.(sessionInstallFileStore)
if !ok || store == nil {
return nil, errors.New("sandbox backend cannot write files into the skills image")
}
return store, nil
}
// installerAgentSource is the one thing the install flow needs from the agent
// side: the stored installer record, read for its model choice.
// interfaces.AgentService does not carry this method — it belongs to
// interfaces.CustomAgentService — so the dependency is injected separately and
// narrowed to this method here rather than fished out of the agent service at
// runtime, which could only ever fail.
type installerAgentSource interface {
GetAgentByID(ctx context.Context, id string) (*types.CustomAgent, error)
}
// The concrete type the container wires must keep satisfying the narrow
// contract, so a future move of GetAgentByID off the custom agent service
// breaks the build instead of the install flow.
var _ installerAgentSource = (*customAgentService)(nil)
func currentBaseTemplate(cfg *types.TenantSandboxConfig) string {
if cfg == nil {
return ""
}
switch sandbox.SandboxType(cfg.SandboxType) {
case sandbox.SandboxTypeCube:
if cfg.Cube != nil {
return cfg.Cube.TemplateID
}
case sandbox.SandboxTypeE2B:
if cfg.E2B != nil {
return cfg.E2B.TemplateID
}
case sandbox.SandboxTypeDocker:
if cfg.Docker != nil {
return cfg.Docker.Image
}
}
return ""
}
// isSkillNameConflict recognises the unique (config, name) violation two
// concurrent first-time uploads of the same skill produce.
func isSkillNameConflict(err error) bool {
if err == nil {
return false
}
if errors.Is(err, gorm.ErrDuplicatedKey) {
return true
}
msg := strings.ToLower(err.Error())
return strings.Contains(msg, "duplicate") || strings.Contains(msg, "unique constraint")
}
func skillOwnerFingerprint(cfg *types.TenantSandboxConfig) string {
return sandbox.SkillOwnerFingerprint(cfg)
}
// configSandboxInvalidator is the narrow capability marking bound sandboxes
// needs. It is reached by type assertion rather than declared on Manager
// because only the session-bound remote manager owns bindings to mark: a
// disabled backend has none, and for that doing nothing is correct.
type configSandboxInvalidator interface {
InvalidateConfigSandboxes(ctx context.Context, tenantID uint64, configID string) (int, error)
}
// The manager the resolver hands out must keep satisfying this, so a rename on
// the sandbox side breaks the build instead of silently skipping every mark.
var _ configSandboxInvalidator = (*sandbox.SessionBoundManager)(nil)
// markConfigSandboxesStale tells every session already holding a sandbox of
// this config that the image underneath it has been replaced.
//
// The pointer switch alone only reaches FUTURE sessions: one that is open right
// now is bound to a sandbox booted from the previous image, and without this it
// would keep serving that image until the session ends - so a skill installed
// minutes ago would be missing from exactly the sessions whose user just asked
// for it. Nothing is destroyed here; the binding is marked and the session's
// next resolve rebuilds it.
//
// It is best-effort on purpose. It runs after the pointer switch, so the
// install has already succeeded, and a binding that could not be marked is a
// session serving a stale image - an annoyance, not a corruption. Turning that
// into a failed install would be a lie about an image that is live and serving.
//
// The context is detached because the caller's is cancelled exactly when this
// matters most: withConfigLock cancels the run's context the moment lock
// renewal fails, and marking is ordinary Redis traffic that a dead context
// fails outright.
func (s *TenantSkillService) markConfigSandboxesStale(
ctx context.Context, tenantID uint64, configID string,
) {
if s.sandboxes == nil {
return
}
markCtx, cancel := s.cleanupContext(context.WithoutCancel(ctx))
defer cancel()
if s.configs != nil {
entity, err := s.configs.GetByID(markCtx, tenantID, configID)
if err != nil {
logger.Warnf(markCtx,
"[skill] read config %s skill_rollout before marking sandboxes stale failed: %v",
configID, err)
} else if entity != nil && !entity.Config.RebuildsExistingOnSkillChange() {
logger.Infof(markCtx,
"[skill] config %s skill_rollout=%s; leaving live sandboxes on the previous image",
configID, types.SkillRolloutNewSession)
return
}
}
mgr, err := s.sandboxes.Resolve(markCtx, tenantID, configID)
if err != nil || mgr == nil {
logger.Warnf(markCtx,
"[skill] resolve sandbox config %s to mark its sandboxes stale failed: %v",
configID, err)
return
}
invalidator, ok := mgr.(configSandboxInvalidator)
if !ok {
return
}
marked, err := invalidator.InvalidateConfigSandboxes(markCtx, tenantID, configID)
if err != nil {
logger.Warnf(markCtx,
"[skill] mark live sandboxes of config %s stale failed: %v", configID, err)
return
}
if marked == 0 {
return
}
logger.Infof(markCtx,
"[skill] marked %d live sandbox binding(s) of config %s stale "+
"(this run's own maintenance session included); each remaining session "+
"rebuilds from the new image on its next use",
marked, configID)
}