openvibers/openvibe.link Commits

Keep plan logins out of skill runner jobs (#154)

OP OpenVibers committed 2 days ago · 359ff3b240 · 5 files, +71 −4

Changes

ddiff --git a/docs/worker.md b/docs/worker.md
iindex 7d38fed..c60a9b4 100644
---- a/docs/worker.md
++++ b/docs/worker.md
@@@ -95,7 +95,8 @@ write any file (in the job directory included), read anything outside the job di
The runner adds no network of its own: the tool has the job sandbox's network namespace, so under `net: deny` (the
default) there is no network at all, and under `public` or `openvibe-only` only what the job's `net` and the owner's
egress allow. The tool also has the job's uid/gid, private root, seccomp filter, cgroups and rlimits; it sees no Link
--credential, no owner home and no state.
++credential, no owner home and no state. The worker does not stage `worker.plan_logins` for skill runner jobs, even when
++the owner enabled plan logins for other functions.
The tool writes **one JSON value to stdout, at most 64 KiB**. More is stopped and the result says `limit`; stderr is
kept for the person, cut at 16 KiB. The runner writes the job's result to fd 3:
ddiff --git a/internal/worker/planlogins.go b/internal/worker/planlogins.go
iindex b470ec5..b509aec 100644
---- a/internal/worker/planlogins.go
++++ b/internal/worker/planlogins.go
@@@ -6,8 +6,18 @@ import (
"os"
"path/filepath"
"strings"
++
++ "github.com/OpenVibers/OpenVibe.Link/internal/config"
)
++// Skill code can read its entire job directory. It must never receive an owner's plan-login files.
++func stageJobPlanLogins(fn config.FunctionConfig, logins []string, root string) ([]string, error) {
++ if fn.Name == "openvibe-skill-runner" {
++ return nil, nil
++ }
++ return stagePlanLogins(logins, root)
++}
++
// The plan logins: the owner's own coding-CLI sign-in files, copied read-only into a job's private home.
//
// A CLI signs in with a file in the owner's home (~/.claude/.credentials.json, ~/.codex/auth.json,
ddiff --git a/internal/worker/planlogins_test.go b/internal/worker/planlogins_test.go
iindex 428d2a3..405adc5 100644
---- a/internal/worker/planlogins_test.go
++++ b/internal/worker/planlogins_test.go
@@@ -4,8 +4,34 @@ import (
"os"
"path/filepath"
"testing"
++
++ "github.com/OpenVibers/OpenVibe.Link/internal/config"
)
++func TestSkillRunnerNeverStagesPlanLogins(t *testing.T) {
++ home := t.TempDir()
++ setHome(t, home)
++ login := filepath.Join(home, ".codex", "auth.json")
++ if err := os.MkdirAll(filepath.Dir(login), 0o700); err != nil {
++ t.Fatal(err)
++ }
++ if err := os.WriteFile(login, []byte(`{"test":"login"}`), 0o600); err != nil {
++ t.Fatal(err)
++ }
++ root := t.TempDir()
++ names, err := stageJobPlanLogins(config.FunctionConfig{Name: "openvibe-skill-runner", Version: "1.0.0"}, []string{login}, root)
++ if err != nil || len(names) != 0 {
++ t.Fatalf("skill runner staged a login: names=%v err=%v", names, err)
++ }
++ if _, err := os.Stat(filepath.Join(root, ".codex", "auth.json")); !os.IsNotExist(err) {
++ t.Fatalf("skill runner left a login in its job directory: %v", err)
++ }
++ names, err = stageJobPlanLogins(config.FunctionConfig{Name: "openvibe-codes"}, []string{login}, root)
++ if err != nil || len(names) != 1 {
++ t.Fatalf("ordinary function lost the owner's opt-in: names=%v err=%v", names, err)
++ }
++}
++
// The plan logins: the copy, the read-only mode, the cleanup and the opt-in.
// setHome points the Link user's home at dir: os.UserHomeDir reads HOME on Linux and macOS, USERPROFILE on Windows.
ddiff --git a/internal/worker/skill_runner_linux_test.go b/internal/worker/skill_runner_linux_test.go
iindex 9b7813f..001e8ef 100644
---- a/internal/worker/skill_runner_linux_test.go
++++ b/internal/worker/skill_runner_linux_test.go
@@@ -5,6 +5,7 @@ package worker
import (
"encoding/json"
"os"
++ "path/filepath"
"strings"
"testing"
@@@ -53,6 +54,37 @@ func skillRunnerWorker(t *testing.T, m *mediaFake) (*Worker, *sink) {
return w, s
}
++// The owner may share coding CLI logins with other functions. Skill code must never receive those files.
++func TestSkillRunnerJobHasNoPlanLogins(t *testing.T) {
++ home := t.TempDir()
++ setHome(t, home)
++ login := filepath.Join(home, ".codex", "auth.json")
++ if err := os.MkdirAll(filepath.Dir(login), 0o700); err != nil {
++ t.Fatal(err)
++ }
++ if err := os.WriteFile(login, []byte(`{"test":"login"}`), 0o600); err != nil {
++ t.Fatal(err)
++ }
++ tool := []byte(`const fs = require('fs'); process.stdout.write(JSON.stringify({present: fs.existsSync('.codex/auth.json')}));`)
++ m := newMediaFake(t)
++ m.objects[inputID] = tool
++ t.Setenv(testMediaEnv, testMediaToken)
++ w, s := skillRunnerWorker(t, m)
++ w.cfg.PlanLogins = []string{login}
++ j := skillJob(4, tool, digestOf(tool), `{"tool":"check","entry":"code.js","input":{}}`)
++ run(t, w, j)
++ ex := s.exit(t, j.ID)
++ var got struct {
++ OK bool `json:"ok"`
++ Output struct {
++ Present bool `json:"present"`
++ } `json:"output"`
++ }
++ if err := json.Unmarshal(ex.Result, &got); err != nil || !got.OK || got.Output.Present {
++ t.Fatalf("the skill job received a plan login: %+v result %s: %v", ex, ex.Result, err)
++ }
++}
++
// skillJob is one job of the declared runner: code.js is the tool's code, pinned by digest, and args names it.
func skillJob(n int, tool []byte, digest string, args string) protocol.Job {
j := testJob(n, skills.Name)
ddiff --git a/internal/worker/worker.go b/internal/worker/worker.go
iindex 8bc7642..582f4de 100644
---- a/internal/worker/worker.go
++++ b/internal/worker/worker.go
@@@ -429,9 +429,7 @@ func (w *Worker) execute(jb *job) protocol.JobExit {
}
return never(protocol.ExitFailed, err)
}
-- // The owner's plan logins, staged for this job only: the copy goes with the job's directory and its content
-- // never leaves the machine.
-- planLogins, err := stagePlanLogins(w.cfg.PlanLogins, dir)
++ planLogins, err := stageJobPlanLogins(jb.fn, w.cfg.PlanLogins, dir)
if err != nil {
return never(protocol.ExitFailed, err)
}