diff --git a/cmd/odek/bg_tools.go b/cmd/odek/bg_tools.go index 9291ec2a..7b230858 100644 --- a/cmd/odek/bg_tools.go +++ b/cmd/odek/bg_tools.go @@ -53,7 +53,8 @@ type bgRuntime struct { // container is the sandbox container name, readable after construction // (surfaces that start the sandbox after building tools bind it late, // before the agent runs — jobs only spawn once the agent iterates). - container atomic.Value + container atomic.Value + serveSandbox *serveSandboxLease } // backgroundSettingsFromResolved maps the resolved background config onto @@ -111,8 +112,8 @@ func newBackgroundRuntime(s BackgroundSettings, sessionID, containerName string, if name == "" { return []string{"sh", "-c", command}, nil, nil } - argv, followUp := wrapSandboxCommand(name, command) - return argv, followUp, nil + argv, followUp := wrapBackgroundSandboxCommand(name, command) + return append([]string{"docker"}, argv...), followUp, nil } var obs bgproc.Observer if emit != nil { @@ -379,7 +380,15 @@ func (t *bgStartTool) Call(args string) (string, error) { if err := t.shell.checkApproval(p.Command, "background job"); err != nil { return "", err } - job, err := t.rt.mgr.Start(t.rt.session, p.Command, "", time.Duration(p.TimeoutSeconds)*time.Second) + opts := bgproc.SpawnOptions{} + if t.rt.serveSandbox != nil { + var err error + opts, err = t.rt.serveSandbox.acquire() + if err != nil { + return "", err + } + } + job, err := t.rt.mgr.StartWithOptions(t.rt.session, p.Command, "", time.Duration(p.TimeoutSeconds)*time.Second, opts) if err != nil { return "", err } @@ -555,3 +564,14 @@ func jobRuntimeSeconds(j bgproc.Job) float64 { } return end.Sub(j.StartedAt).Seconds() } + +// Background commands need their own process group inside the container so +// the pidfile follow-up can kill descendants without affecting other jobs. +// Run setsid as a child of a waiting shell so it is not already a process-group +// leader and does not fork away from docker exec. This also supports BusyBox +// setsid, which has no --wait option. +func wrapBackgroundSandboxCommand(name, command string) ([]string, func()) { + argv, followUp := wrapSandboxCommand(name, command) + argv = append(append(append([]string{}, argv[:4]...), "sh", "-c", `setsid "$@" & wait $!`, "odek-bg"), argv[4:]...) + return argv, followUp +} diff --git a/cmd/odek/hermetic_test.go b/cmd/odek/hermetic_test.go index 4694bf50..5b8c335e 100644 --- a/cmd/odek/hermetic_test.go +++ b/cmd/odek/hermetic_test.go @@ -42,6 +42,7 @@ func TestHermetic_TestProcessIsolatedFromOperatorConfig(t *testing.T) { keep := map[string]bool{ "ODEK_NO_SANDBOX": true, "ODEK_E2E": true, + "ODEK_BG_SANDBOX_TEST_IMAGE": true, "ODEK_TEST_HOME": true, "ODEK_TEST_KEEP_REAL_HOME": true, "ODEK_SUPPRESS_SANDBOX_WARNING": true, diff --git a/cmd/odek/main.go b/cmd/odek/main.go index febcd778..1d264f38 100644 --- a/cmd/odek/main.go +++ b/cmd/odek/main.go @@ -2352,10 +2352,9 @@ func setupSandbox(tools []odek.Tool, cfg sandboxConfig) (containerName string, c return "", nil, fmt.Errorf("failed to create sandbox container %q: %w\n hint: make sure Docker is running, or disable sandbox with --no-sandbox", containerName, err) } - cleanup = func() error { - fmt.Fprintf(os.Stderr, "odek: destroying sandbox container %s...\n", containerName) - return exec.Command("docker", "rm", "-f", containerName).Run() - } + cleanup = newSandboxCleanup(containerName, func(ctx context.Context) error { + return exec.CommandContext(ctx, "docker", "rm", "-f", containerName).Run() + }) applySandboxToolBindings(tools, containerName) return containerName, cleanup, nil diff --git a/cmd/odek/sandbox_cleanup.go b/cmd/odek/sandbox_cleanup.go new file mode 100644 index 00000000..453eaa13 --- /dev/null +++ b/cmd/odek/sandbox_cleanup.go @@ -0,0 +1,51 @@ +package main + +import ( + "context" + "fmt" + "os" + "sync" + "time" +) + +// newSandboxCleanup is shared by every execution surface. Successful cleanup +// is idempotent; failure remains retryable and is reported even when a deferred +// Agent.Close caller cannot return the error to its caller. +func newSandboxCleanup(container string, remove func(context.Context) error) func() error { + var mu sync.Mutex + var removed bool + return func() error { + mu.Lock() + defer mu.Unlock() + if removed { + return nil + } + fmt.Fprintf(os.Stderr, "odek: destroying sandbox container %s...\n", container) + err := retrySandboxRemoval(remove, 5*time.Second) + if err != nil { + err = fmt.Errorf("sandbox cleanup failed for %s after 3 attempts: %w", container, err) + fmt.Fprintf(os.Stderr, "odek: %v; container may still be running. Remove it with: docker rm -f %s\n", err, container) + return err + } + removed = true + return nil + } +} + +// Each attempt has its own deadline so a timed-out Docker client can recover +// on the next attempt. Callers must honor ctx; production uses CommandContext. +func retrySandboxRemoval(remove func(context.Context) error, timeout time.Duration) error { + var err error + for attempt := 0; attempt < 3; attempt++ { + ctx, cancel := context.WithTimeout(context.Background(), timeout) + err = remove(ctx) + cancel() + if err == nil { + return nil + } + if attempt < 2 { + time.Sleep(100 * time.Millisecond) + } + } + return err +} diff --git a/cmd/odek/sandbox_cleanup_test.go b/cmd/odek/sandbox_cleanup_test.go new file mode 100644 index 00000000..404fc621 --- /dev/null +++ b/cmd/odek/sandbox_cleanup_test.go @@ -0,0 +1,92 @@ +package main + +import ( + "context" + "errors" + "strings" + "sync" + "sync/atomic" + "testing" + "time" +) + +func TestStandaloneSandboxCleanupRetriesAndIsIdempotent(t *testing.T) { + var attempts atomic.Int32 + cleanup := newSandboxCleanup("standalone-test", func(ctx context.Context) error { + if _, ok := ctx.Deadline(); !ok { + t.Error("removal has no deadline") + } + if attempts.Add(1) < 3 { + return errors.New("Docker temporarily unavailable") + } + return nil + }) + var wg sync.WaitGroup + for i := 0; i < 4; i++ { + wg.Add(1) + go func() { + defer wg.Done() + if err := cleanup(); err != nil { + t.Error(err) + } + }() + } + wg.Wait() + if got := attempts.Load(); got != 3 { + t.Fatalf("removal attempts: %d, want 3", got) + } +} + +func TestStandaloneSandboxCleanupReportsExhaustionAndCanRecover(t *testing.T) { + failure := errors.New("Docker unavailable") + attempts := 0 + fail := true + cleanup := newSandboxCleanup("standalone-test", func(context.Context) error { + attempts++ + if fail { + return failure + } + return nil + }) + var got error + diagnostic := captureStderrDuring(t, func() { got = cleanup() }) + if !errors.Is(got, failure) || attempts != 3 { + t.Fatalf("cleanup: %v, attempts %d", got, attempts) + } + if !strings.Contains(diagnostic, "after 3 attempts") || !strings.Contains(diagnostic, "docker rm -f standalone-test") { + t.Fatalf("missing actionable cleanup failure: %s", diagnostic) + } + fail = false + if err := cleanup(); err != nil { + t.Fatal(err) + } + if attempts != 4 { + t.Fatalf("failed cleanup could not retry: %d", attempts) + } +} + +func TestSandboxRemovalRetriesTimedOutAttemptWithFreshContext(t *testing.T) { + attempts := 0 + var previous context.Context + err := retrySandboxRemoval(func(ctx context.Context) error { + attempts++ + if previous != nil && previous.Err() == nil { + t.Error("previous attempt context not cancelled") + } + previous = ctx + if ctx.Err() != nil { + t.Error("new attempt already cancelled") + } + if attempts == 1 { + <-ctx.Done() + return ctx.Err() + } + return nil + }, 10*time.Millisecond) + if err != nil || attempts != 2 { + t.Fatalf("timeout recovery: %v, attempts %d", err, attempts) + } + if previous.Err() == nil { + t.Fatal("successful attempt context not released") + } +} diff --git a/cmd/odek/serve.go b/cmd/odek/serve.go index 28383081..033b0ea6 100644 --- a/cmd/odek/serve.go +++ b/cmd/odek/serve.go @@ -26,6 +26,7 @@ import ( "time" "github.com/BackendStack21/odek" + "github.com/BackendStack21/odek/internal/artifact" "github.com/BackendStack21/odek/internal/bgproc" "github.com/BackendStack21/odek/internal/budget" "github.com/BackendStack21/odek/internal/config" @@ -509,7 +510,7 @@ func serveCmd(args []string) error { // ONE background-command manager for the whole serve process: agents // are per connection/run, but jobs must outlive them. Nil when the - // feature is disabled (or in sandbox mode — see newServeBGManager). + // feature is disabled. Sandbox routing is bound per agent. bgMgr := newServeBGManager(resolved) setServeBGManager(bgMgr) @@ -555,6 +556,8 @@ func serveCmd(args []string) error { maintCtx, maintCancel := context.WithCancel(context.Background()) defer maintCancel() startStorageMaintenance(maintCtx, resolved) + workspace, _ := os.Getwd() + startServeRetention(maintCtx, store, workspace, bgMgr) return serveOnListener(listener, mux) } @@ -614,6 +617,15 @@ func newServeMux(d serveMuxDeps) *http.ServeMux { h.ServeHTTP(w, r) })))) } + workspace, _ := os.Getwd() + wireServeSessionCleanup(store, d.BGManager, workspace) + mux.Handle("/api/uploads", apiAuth(handleBrowserUpload(store, resolved.Model, workspace))) + mux.Handle("/api/artifacts", apiAuth(handleBrowserArtifacts(store, &browserArtifacts))) + mux.Handle("/api/artifacts/", apiAuth(handleBrowserArtifacts(store, &browserArtifacts))) + mux.Handle("/api/capabilities", apiAuth(http.HandlerFunc(handleCapabilities))) + mux.Handle("/api/schedules", apiAuth(handleSchedules(expandHome("~/.odek")))) + mux.Handle("/api/schedules/", apiAuth(handleSchedules(expandHome("~/.odek")))) + mux.Handle("/api/maintenance", apiAuth(handleMaintenance(expandHome("~/.odek"), resolved))) mux.Handle("/api/resources", apiAuth(handleResourceSearch(resourceReg))) mux.Handle("/api/sessions", apiAuth(handleSessionListPaged(store))) mux.Handle("/api/sessions/", apiAuth(handleSessionByID(store, resolved.TrustedProxies, wsToken))) @@ -746,6 +758,7 @@ func serveOnListener(listener net.Listener, mux *http.ServeMux) error { // Catch Ctrl-C and SIGTERM. quit := make(chan os.Signal, 1) signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM) + defer signal.Stop(quit) serveErr := make(chan error, 1) go func() { @@ -754,9 +767,10 @@ func serveOnListener(listener net.Listener, mux *http.ServeMux) error { } }() + var servingError error select { - case err := <-serveErr: - return err + case servingError = <-serveErr: + fmt.Fprintf(os.Stderr, "odek serve: listener failed: %v; shutting down...\n", servingError) case sig := <-quit: fmt.Fprintf(os.Stderr, "\nodek serve: %s received, shutting down...\n", sig) case <-serveShutdownCh: @@ -799,8 +813,9 @@ func serveOnListener(listener net.Listener, mux *http.ServeMux) error { fmt.Fprintln(os.Stderr, "odek serve: drain timeout — some containers may still be running") } + retrySandboxCleanup() fmt.Fprintln(os.Stderr, "odek serve: stopped") - return nil + return servingError } // drainServeWork waits (bounded) for all live WebSocket handler goroutines @@ -929,7 +944,11 @@ func newServeAgent(resolved config.ResolvedConfig, system string, runKey string, if sandboxErr != nil { return nil, nil, nil, nil, nil, nil, approver, fmt.Errorf("sandbox: %w", sandboxErr) } - _ = sbContainerName // not used in serve mode + if bgRT != nil { + bgRT.SetContainer(sbContainerName) + bgRT.serveSandbox = &serveSandboxLease{container: sbContainerName, cleanup: sandboxCleanup} + sandboxCleanup = bgRT.serveSandbox.close + } } else { warnSandboxDisabled() } @@ -954,6 +973,12 @@ func newServeAgent(resolved config.ResolvedConfig, system string, runKey string, // Build the shared prompt-injection guard for this connection. injectionGuard, err := guard.New(&resolved.Guard) if err != nil { + if sandboxCleanup != nil { + _ = sandboxCleanup() + } + if mcpCleanup != nil { + mcpCleanup() + } return nil, nil, nil, nil, nil, nil, approver, fmt.Errorf("guard: %w", err) } guardCleanup := func() error { @@ -1011,13 +1036,12 @@ func newServeAgent(resolved config.ResolvedConfig, system string, runKey string, EventHandler: func(ev events.Event) { recordPlanUsage(ev) serveEvents.add(ev) + if ev.Type == events.TypeIterationCompleted || ev.Type == events.TypeBudgetExceeded { + sendFn(map[string]any{"type": "runtime_event", "event": ev}) + } }, - ToolEventHandler: func(event, name, data string) { - sendFn(map[string]any{ - "type": event, - "name": name, - "data": data, - }) + ToolDetailHandler: func(event loop.ToolDetailEvent) { + sendFn(map[string]any{"type": event.Type, "name": event.Name, "data": event.Data, "call_id": event.CallID, "outcome": event.Outcome}) }, SkillEventHandler: func(event skills.SkillEvent) { sendFn(map[string]any{ @@ -1183,6 +1207,7 @@ func snapshotServerConfig(resolved config.ResolvedConfig) wsServerSnapshot { // immutable wsServerSnapshot, never from the live resolved config. func wsServerInfoEvent(startedAt time.Time, snap wsServerSnapshot) map[string]any { return map[string]any{ + "capabilities": workspaceCapabilities(), "version": version, "model": snap.model, "sandbox": snap.sandbox, @@ -1195,8 +1220,9 @@ func wsServerInfoEvent(startedAt time.Time, snap wsServerSnapshot) map[string]an // ── WebSocket Types ──────────────────────────────────────────────────── type wsAttachment struct { - Name string `json:"name"` - Content string `json:"content"` + UploadID string `json:"upload_id,omitempty"` + Name string `json:"name"` + Content string `json:"content"` } type wsClientMsg struct { @@ -1236,12 +1262,14 @@ func newTurnID() string { // is active (R3). Lifecycle and sub-agent frames stay untouched so old // clients see byte-identical shapes for them. var turnTaggedFrames = map[string]bool{ - "thinking": true, - "token": true, - "tool_call": true, - "tool_result": true, - "done": true, - "error": true, + "thinking": true, + "token": true, + "tool_call": true, + "tool_result": true, + "runtime_event": true, + "artifact": true, + "done": true, + "error": true, } // wsTurnAnnotator tags outbound frames with the active turn id (R3) so a @@ -1925,6 +1953,21 @@ func handlePrompt( var total int var wrapped []string for _, att := range msg.Attachments { + if att.UploadID != "" { + upload, release, ok := acquireBrowserUpload(att.UploadID, msg.SessionID) + if !ok { + sendError(send, "attachment unavailable or belongs to another session") + return currSess + } + defer release() + total += upload.size + if total > maxTotalAttachmentBytes { + sendError(send, "total attachment size exceeds 10 MB") + return currSess + } + wrapped = append(wrapped, wrapUntrusted(ctx, "attachment:"+upload.name, "User-uploaded file: "+upload.name+"\nLocal path: "+upload.path)) + continue + } if att.Name == "" || att.Content == "" { continue } @@ -2112,6 +2155,15 @@ func handlePrompt( } else if auditSessID != "" { ctx = withReadLedger(ctx, auditSessID) } + previewAllowance := &previewBudget{remaining: previewCacheLimit} + ctx = artifact.WithObserver(ctx, func(ref artifact.Ref, roots []string) { + if sid == "" { + return + } + if item, err := browserArtifacts.captureBudget(sid, ref, roots, previewAllowance); err == nil { + send(map[string]any{"type": "artifact", "artifact": item}) + } + }) _, allMessages, err := agent.RunWithMessages(ctx, messages) latency := time.Since(start) if auditSessID != "" { @@ -3260,7 +3312,12 @@ var staticFiles = map[string][2]string{ "/app.js": {"ui/app.js", "application/javascript; charset=utf-8"}, // Self-hosted font (variable weight 100–700) so the UI works offline and // does not depend on the Google Fonts CDN. - "/fonts/azeret-mono.woff2": {"ui/fonts/azeret-mono.woff2", "font/woff2"}, + "/fonts/geist.woff2": {"ui/fonts/geist.woff2", "font/woff2"}, + "/fonts/geist-mono.woff2": {"ui/fonts/geist-mono.woff2", "font/woff2"}, + "/fonts/geist-LICENSE.txt": {"ui/fonts/geist-LICENSE.txt", "text/plain; charset=utf-8"}, + "/fonts/manrope.ttf": {"ui/fonts/manrope.ttf", "font/ttf"}, + "/fonts/manrope-LICENSE.txt": {"ui/fonts/manrope-LICENSE.txt", "text/plain; charset=utf-8"}, + "/fonts/azeret-mono.woff2": {"ui/fonts/azeret-mono.woff2", "font/woff2"}, } func handleStatic(wsToken string) http.HandlerFunc { @@ -3351,7 +3408,7 @@ func handleStatic(wsToken string) http.HandlerFunc { // Strict CSP: no inline scripts (all handlers are addEventListener / // delegation), styles only from self + the few style="" attributes in // index.html. frame-ancestors replaces the old standalone CSP line. - w.Header().Set("Content-Security-Policy", "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; font-src 'self'; connect-src 'self' ws: wss:; frame-ancestors 'none'; base-uri 'none'; form-action 'none'") + w.Header().Set("Content-Security-Policy", "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; media-src 'self' blob:; frame-src blob:; object-src 'none'; font-src 'self'; connect-src 'self' ws: wss:; frame-ancestors 'none'; base-uri 'none'; form-action 'none'") w.Write(data) } } diff --git a/cmd/odek/serve_api.go b/cmd/odek/serve_api.go index 03592664..a4bc17e4 100644 --- a/cmd/odek/serve_api.go +++ b/cmd/odek/serve_api.go @@ -32,6 +32,9 @@ import ( "sync/atomic" "time" + "github.com/BackendStack21/odek" + "github.com/BackendStack21/odek/internal/danger" + "github.com/BackendStack21/odek/internal/config" "github.com/BackendStack21/odek/internal/guard" "github.com/BackendStack21/odek/internal/llmclient" @@ -581,7 +584,7 @@ func handleSkills(sc skills.SkillsConfig) http.HandlerFunc { http.Error(w, "method not allowed", http.StatusMethodNotAllowed) return } - extra := sc.Dirs + extra := append([]string(nil), sc.Dirs...) for i := range extra { extra[i] = expandHome(extra[i]) } @@ -589,6 +592,13 @@ func handleSkills(sc skills.SkillsConfig) http.HandlerFunc { var out []skillSummary for _, s := range append(append([]skills.Skill{}, res.AutoLoad...), res.Lazy...) { + if name := r.URL.Query().Get("name"); name != "" { + if s.Name == name { + writeAPIJSON(w, http.StatusOK, s) + return + } + continue + } out = append(out, skillSummary{ Name: s.Name, Description: s.Description, @@ -599,6 +609,10 @@ func handleSkills(sc skills.SkillsConfig) http.HandlerFunc { Untrusted: s.Provenance.Untrusted, }) } + if r.URL.Query().Get("name") != "" { + http.Error(w, "skill not found", http.StatusNotFound) + return + } sort.Slice(out, func(i, j int) bool { return out[i].Name < out[j].Name }) if out == nil { out = []skillSummary{} @@ -612,8 +626,11 @@ func handleSkills(sc skills.SkillsConfig) http.HandlerFunc { // toolSummary names one tool and whether the resolved tool filter exposes it // to the model. type toolSummary struct { - Name string `json:"name"` - Enabled bool `json:"enabled"` + Description string `json:"description,omitempty"` + Schema any `json:"schema,omitempty"` + Reason string `json:"reason,omitempty"` + Name string `json:"name"` + Enabled bool `json:"enabled"` } // handleTools lists the built-in tool registry with its enabled/disabled @@ -642,11 +659,24 @@ func handleTools(resolved config.ResolvedConfig) http.HandlerFunc { } whitelistActive := resolved.Tools.Enabled != nil + pc := config.DefaultPlanningConfig() + descriptors := map[string]odek.Tool{} + for _, t := range builtinTools(danger.DangerousConfig{}, nil, nil, 1, "", toolConfig{Planning: &pc}, nil) { + descriptors[t.Name()] = t + } out := make([]toolSummary, 0, len(names)) for _, n := range names { // Shared filter rule (introspect.go) — the same one the // agent-facing list_tools tool applies. - out = append(out, toolSummary{Name: n, Enabled: toolEnabled(n, enabledSet, disabledSet, whitelistActive)}) + entry := toolSummary{Name: n, Enabled: toolEnabled(n, enabledSet, disabledSet, whitelistActive)} + if t := descriptors[n]; t != nil { + entry.Description = t.Description() + entry.Schema = t.Schema() + } + if !entry.Enabled { + entry.Reason = "Disabled by the operator tool filter" + } + out = append(out, entry) } writeAPIJSON(w, http.StatusOK, map[string]any{ "tools": out, @@ -799,7 +829,9 @@ func handleMemoryConsolidate(memoryDir string, resolved config.ResolvedConfig) h return } var body struct { - Target string `json:"target"` + Target string `json:"target"` + Mode string `json:"mode"` + Preview memory.ConsolidationPreview `json:"preview"` } if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 1<<20)).Decode(&body); err != nil { http.Error(w, "invalid JSON", http.StatusBadRequest) @@ -813,6 +845,15 @@ func handleMemoryConsolidate(memoryDir string, resolved config.ResolvedConfig) h if resolved.LLM.RequestTimeoutSeconds > 0 { timeout = resolved.LLM.RequestTimeoutSeconds } + if body.Mode == "apply" { + mm := memory.NewMemoryManager(memoryDir, nil, resolved.Memory) + if err := mm.ApplyConsolidation(body.Target, body.Preview); err != nil { + http.Error(w, err.Error(), http.StatusConflict) + return + } + w.WriteHeader(http.StatusNoContent) + return + } client, err := llmclient.Dial(resolved.Provider, resolved.Model, resolved.APIKey, resolved.BaseURL) if err != nil { http.Error(w, err.Error(), http.StatusBadRequest) @@ -825,7 +866,21 @@ func handleMemoryConsolidate(memoryDir string, resolved config.ResolvedConfig) h t := true cfg.Enabled = &t } - if err := memory.NewMemoryManager(memoryDir, client, cfg).Consolidate(body.Target); err != nil { + mm := memory.NewMemoryManager(memoryDir, client, cfg) + if body.Mode == "preview" { + preview, err := mm.PreviewConsolidation(body.Target) + if err != nil { + http.Error(w, err.Error(), 400) + return + } + writeAPIJSON(w, 200, preview) + return + } + if body.Mode != "" { + http.Error(w, "invalid consolidation mode", 400) + return + } + if err := mm.Consolidate(body.Target); err != nil { http.Error(w, err.Error(), http.StatusBadRequest) return } diff --git a/cmd/odek/serve_artifacts.go b/cmd/odek/serve_artifacts.go new file mode 100644 index 00000000..d2ad717e --- /dev/null +++ b/cmd/odek/serve_artifacts.go @@ -0,0 +1,332 @@ +package main + +import ( + "bytes" + "crypto/sha256" + "encoding/hex" + "fmt" + "io" + "mime" + "net/http" + "os" + "path/filepath" + "sort" + "strings" + "sync" + "time" + + "github.com/BackendStack21/odek/internal/artifact" + "github.com/BackendStack21/odek/internal/session" +) + +const previewArtifactLimit = 10 << 20 +const previewCacheLimit = 40 << 20 + +type browserArtifact struct { + ID string `json:"id"` + SessionID string `json:"session_id"` + Name string `json:"name"` + MediaType string `json:"media_type"` + Size int `json:"size_bytes"` + SHA256 string `json:"sha256"` + Created time.Time `json:"created_at"` + data []byte +} +type browserArtifactStore struct { + sync.Mutex + entries map[string]browserArtifact + bytes int +} + +var browserArtifacts = browserArtifactStore{entries: map[string]browserArtifact{}} + +// capture takes an immutable, bounded copy through an allowed filesystem root. +// Later downloads cannot race a tool replacing a file or changing a symlink. +func (cache *browserArtifactStore) capture(sid string, ref artifact.Ref, roots []string) (browserArtifact, error) { + return cache.captureBudget(sid, ref, roots, nil) +} + +// previewBudget bounds aggregate content reads across a turn, including failed +// captures, so many references cannot amplify preview work without limit. +type previewBudget struct { + sync.Mutex + remaining int64 +} + +func (b *previewBudget) reserve(n int64) bool { + if b == nil { + return true + } + b.Lock() + defer b.Unlock() + if n > b.remaining { + return false + } + b.remaining -= n + return true +} + +func (cache *browserArtifactStore) captureBudget(sid string, ref artifact.Ref, roots []string, budget *previewBudget) (browserArtifact, error) { + path, err := artifact.ValidateMetadata(ref, roots) + if err != nil { + return browserArtifact{}, err + } + var data []byte + for _, dir := range roots { + abs, e := filepath.EvalSymlinks(dir) + if e != nil { + continue + } + abs, e = filepath.Abs(abs) + if e != nil { + continue + } + rel, e := filepath.Rel(abs, path) + if e != nil || !filepath.IsLocal(rel) { + continue + } + root, e := os.OpenRoot(abs) + if e != nil { + continue + } + f, e := root.Open(rel) + if e != nil { + root.Close() + continue + } + stat, e := f.Stat() + if e != nil || !stat.Mode().IsRegular() || stat.Size() > previewArtifactLimit { + f.Close() + root.Close() + return browserArtifact{}, fmt.Errorf("artifact exceeds preview limit or is not a regular file") + } + if !budget.reserve(stat.Size() + 1) { + f.Close() + root.Close() + return browserArtifact{}, fmt.Errorf("turn preview budget exhausted") + } + data, e = io.ReadAll(io.LimitReader(f, stat.Size()+1)) + if e == nil && int64(len(data)) != stat.Size() { + e = fmt.Errorf("artifact changed during capture") + } + f.Close() + root.Close() + if e != nil { + return browserArtifact{}, e + } + break + } + if data == nil || len(data) > previewArtifactLimit { + return browserArtifact{}, fmt.Errorf("artifact unavailable") + } + digest := sha256.Sum256(data) + sha := hex.EncodeToString(digest[:]) + if (ref.SHA256 != "" && ref.SHA256 != sha) || (ref.SizeBytes != nil && *ref.SizeBytes != int64(len(data))) { + return browserArtifact{}, fmt.Errorf("artifact changed during capture") + } + // Content sniffing determines display type; never trust an extension's MIME. + media := http.DetectContentType(data) + item := browserArtifact{ID: newTurnID(), SessionID: sid, Name: filepath.Base(path), MediaType: media, Size: len(data), SHA256: sha, Created: time.Now().UTC(), data: data} + cache.Lock() + defer cache.Unlock() + if cache.entries == nil { + cache.entries = map[string]browserArtifact{} + } + for cache.bytes+len(data) > previewCacheLimit || len(cache.entries) >= 128 { + var oldest string + var when time.Time + for id, v := range cache.entries { + if oldest == "" || v.Created.Before(when) { + oldest = id + when = v.Created + } + } + if oldest == "" { + break + } + cache.bytes -= len(cache.entries[oldest].data) + delete(cache.entries, oldest) + } + cache.entries[item.ID] = item + cache.bytes += len(data) + return item, nil +} +func handleBrowserArtifacts(store *session.Store, cache *browserArtifactStore) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + sess, code, msg := authenticateJobsRequest(store, r) + if code != 0 { + http.Error(w, msg, code) + return + } + id := strings.TrimPrefix(r.URL.Path, "/api/artifacts") + id = strings.TrimPrefix(id, "/") + cache.Lock() + if id == "" { + items := []browserArtifact{} + for _, item := range cache.entries { + if item.SessionID == sess.ID { + items = append(items, item) + } + } + cache.Unlock() + sort.Slice(items, func(i, j int) bool { return items[i].Created.Before(items[j].Created) }) + writeAPIJSON(w, 200, map[string]any{"artifacts": items, "retention": "Preview cache: up to 40 MiB, cleared on server restart. Save files you want to keep."}) + return + } + item, ok := cache.entries[id] + cache.Unlock() + if !ok || item.SessionID != sess.ID { + http.Error(w, "artifact unavailable", 404) + return + } + w.Header().Set("X-Content-Type-Options", "nosniff") + w.Header().Set("Content-Security-Policy", "default-src 'none'; sandbox") + w.Header().Set("Content-Type", item.MediaType) + w.Header().Set("Content-Disposition", mime.FormatMediaType("attachment", map[string]string{"filename": item.Name})) + http.ServeContent(w, r, item.Name, item.Created, bytes.NewReader(item.data)) + } +} + +// Uploads are addressed by opaque IDs in prompts. Client-supplied paths never +// become attachment paths; only the server-created, session-bound file is used. +type browserUpload struct { + restored bool + pins int + sessionID, path, name string + workspace string + created time.Time + size int +} + +var browserUploads = struct { + sync.Mutex + entries map[string]browserUpload +}{entries: map[string]browserUpload{}} + +func handleBrowserUpload(store *session.Store, model, workspace string) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + var sess *session.Session + if r.URL.Query().Get("session_id") != "" { + var code int + var msg string + sess, code, msg = authenticateJobsRequest(store, r) + if code != 0 { + http.Error(w, msg, code) + return + } + } + data, err := io.ReadAll(http.MaxBytesReader(w, r.Body, 5<<20)) + if err != nil || len(data) == 0 { + http.Error(w, "upload must contain 1 byte to 5 MiB", 400) + return + } + name := filepath.Base(r.URL.Query().Get("name")) + if name == "" || name == "." || len(name) > 200 { + http.Error(w, "invalid filename", 400) + return + } + // MIME is inspected from bytes. Store only passive media/document types; + // executable formats and HTML cannot be introduced as media attachments. + media := strings.Split(http.DetectContentType(data), ";")[0] + switch media { + case "image/png", "image/jpeg", "image/gif", "image/webp", "audio/mpeg", "audio/wave", "audio/x-wav", "audio/ogg", "application/ogg", "application/pdf": + default: + http.Error(w, "unsupported binary attachment type", http.StatusUnsupportedMediaType) + return + } + if sess == nil { + sess, err = store.Create(nil, model, "Uploaded files") + if err != nil { + http.Error(w, "cannot create upload session", 500) + return + } + } + browserUploads.Lock() + defer browserUploads.Unlock() + if _, err := store.Load(sess.ID); err != nil { + http.Error(w, "upload session unavailable", http.StatusConflict) + return + } + if err := pruneBrowserUploadsLocked(workspace, len(data), time.Now()); err != nil { + http.Error(w, "upload retention cleanup failed", http.StatusServiceUnavailable) + return + } + id := newTurnID() + suffix := map[string]string{"image/png": ".png", "image/jpeg": ".jpg", "image/gif": ".gif", "image/webp": ".webp", "audio/mpeg": ".mp3", "audio/wave": ".wav", "audio/x-wav": ".wav", "audio/ogg": ".ogg", "application/ogg": ".ogg", "application/pdf": ".pdf"}[media] + rel := filepath.Join(".odek-artifacts", "uploads", sess.ID, id+suffix) + root, err := openUploadRoot(workspace, true) + if err != nil { + http.Error(w, "upload storage unavailable", 500) + return + } + defer root.Close() + sessionRoot, err := openUploadDir(root, sess.ID, true) + if err != nil { + http.Error(w, "upload storage unavailable", 500) + return + } + defer sessionRoot.Close() + f, err := sessionRoot.OpenFile(filepath.Base(rel), os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0600) + if err != nil { + http.Error(w, "cannot store upload", 500) + return + } + _, err = f.Write(data) + closeErr := f.Close() + if err != nil || closeErr != nil { + _ = sessionRoot.Remove(filepath.Base(rel)) + http.Error(w, "cannot store upload", 500) + return + } + full := filepath.Join(workspace, rel) + browserUploads.entries[id] = browserUpload{sessionID: sess.ID, path: full, name: name, size: len(data), workspace: workspace, created: time.Now().UTC()} + digest := sha256.Sum256(data) + size := int64(len(data)) + ref := artifact.Ref{Schema: artifact.SchemaArtifactRef, ID: id, URI: "file://" + full, MediaType: media, SHA256: hex.EncodeToString(digest[:]), SizeBytes: &size} + if item, e := browserArtifacts.capture(sess.ID, ref, []string{filepath.Dir(full)}); e == nil { + browserArtifacts.Lock() + saved := browserArtifacts.entries[item.ID] + saved.Name = name + browserArtifacts.entries[item.ID] = saved + browserArtifacts.Unlock() + } + writeAPIJSON(w, 201, map[string]any{"upload_id": id, "session_id": sess.ID, "auth_token": sess.AuthToken, "name": name, "media_type": media, "size_bytes": len(data)}) + } +} +func resolveBrowserUpload(id, sid string) (browserUpload, bool) { + browserUploads.Lock() + defer browserUploads.Unlock() + item, ok := browserUploads.entries[id] + return item, ok && !item.restored && item.sessionID == sid +} + +// acquireBrowserUpload protects an accepted attachment from retention for a turn. +func acquireBrowserUpload(id, sid string) (browserUpload, func(), bool) { + browserUploads.Lock() + defer browserUploads.Unlock() + item, ok := browserUploads.entries[id] + if !ok || item.restored || item.sessionID != sid { + return browserUpload{}, nil, false + } + item.pins++ + browserUploads.entries[id] = item + var once sync.Once + return item, func() { + once.Do(func() { + browserUploads.Lock() + defer browserUploads.Unlock() + if current, ok := browserUploads.entries[id]; ok { + current.pins-- + browserUploads.entries[id] = current + } + }) + }, true +} diff --git a/cmd/odek/serve_bg_sandbox.go b/cmd/odek/serve_bg_sandbox.go new file mode 100644 index 00000000..33076b11 --- /dev/null +++ b/cmd/odek/serve_bg_sandbox.go @@ -0,0 +1,101 @@ +package main + +import ( + "context" + "fmt" + "os" + "sync" + "time" + + "github.com/BackendStack21/odek/internal/bgproc" +) + +// serveSandboxLease retains an agent's container until both its owner and +// every background job have released it. A job keeps its original routing +// across session switches, reconnects, and headless-run completion. +type serveSandboxLease struct { + mu sync.Mutex + container string + cleanup func() error + closed bool + jobs int +} + +func (l *serveSandboxLease) acquire() (bgproc.SpawnOptions, error) { + l.mu.Lock() + defer l.mu.Unlock() + if l.closed || l.container == "" { + return bgproc.SpawnOptions{}, fmt.Errorf("background sandbox is unavailable") + } + l.jobs++ + name := l.container + var once sync.Once + return bgproc.SpawnOptions{ + Wrap: func(command string) ([]string, func(), error) { + argv, followUp := wrapBackgroundSandboxCommand(name, command) + return append([]string{"docker"}, argv...), followUp, nil + }, + Release: func() { once.Do(l.release) }, + }, nil +} + +// cleanupClosed serializes attempts and retains ownership after failure. +// Production callbacks own bounded retries; the lease must not multiply them. +func (l *serveSandboxLease) cleanupClosed() error { + l.mu.Lock() + defer l.mu.Unlock() + if !l.closed || l.jobs != 0 || l.cleanup == nil { + return nil + } + // Publish before any blocking removal so shutdown can always find us. + pendingSandboxCleanup.Store(l, struct{}{}) + err := l.cleanup() + if err == nil { + l.cleanup = nil + pendingSandboxCleanup.Delete(l) + } + return err +} + +func (l *serveSandboxLease) release() { + l.mu.Lock() + l.jobs-- + l.mu.Unlock() + if err := l.cleanupClosed(); err != nil { + fmt.Fprintf(os.Stderr, "odek: background sandbox cleanup pending: %v\n", err) + } +} + +func (l *serveSandboxLease) close() error { + l.mu.Lock() + l.closed = true + l.mu.Unlock() + return l.cleanupClosed() +} + +// Failed removals remain discoverable for maintenance and final shutdown. +var pendingSandboxCleanup sync.Map + +func retrySandboxCleanup() { + ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second) + defer cancel() + var wg sync.WaitGroup + slots := make(chan struct{}, 4) + pendingSandboxCleanup.Range(func(key, _ any) bool { + select { + case slots <- struct{}{}: + case <-ctx.Done(): + return false + } + wg.Add(1) + go func(l *serveSandboxLease) { defer wg.Done(); defer func() { <-slots }(); _ = l.cleanupClosed() }(key.(*serveSandboxLease)) + return true + }) + done := make(chan struct{}) + go func() { wg.Wait(); close(done) }() + select { + case <-done: + case <-ctx.Done(): + fmt.Fprintln(os.Stderr, "odek: sandbox cleanup retry deadline exceeded") + } +} diff --git a/cmd/odek/serve_bg_sandbox_test.go b/cmd/odek/serve_bg_sandbox_test.go new file mode 100644 index 00000000..a759ee4e --- /dev/null +++ b/cmd/odek/serve_bg_sandbox_test.go @@ -0,0 +1,248 @@ +package main + +import ( + "context" + "encoding/json" + "fmt" + "os" + "os/exec" + "strings" + "sync" + "sync/atomic" + "testing" + "time" + + "github.com/BackendStack21/odek/internal/bgproc" + "github.com/BackendStack21/odek/internal/config" + "github.com/BackendStack21/odek/internal/danger" +) + +func TestServeSandboxLeaseLifetime(t *testing.T) { + var cleaned atomic.Int32 + l := &serveSandboxLease{container: "isolated-a", cleanup: func() error { cleaned.Add(1); return nil }} + a, err := l.acquire() + if err != nil { + t.Fatal(err) + } + b, err := l.acquire() + if err != nil { + t.Fatal(err) + } + argv, _, err := a.Wrap("echo marker") + if err != nil || argv[0] != "docker" || !strings.Contains(strings.Join(argv, " "), "isolated-a") { + t.Fatalf("route: %v %v", argv, err) + } + if err = l.close(); err != nil { + t.Fatal(err) + } + if cleaned.Load() != 0 { + t.Fatal("container removed with active jobs") + } + if _, err = l.acquire(); err == nil { + t.Fatal("closed owner accepted spawn") + } + a.Release() + a.Release() + if cleaned.Load() != 0 { + t.Fatal("container removed before final release") + } + b.Release() + _ = l.close() + if cleaned.Load() != 1 { + t.Fatalf("cleanup count %d", cleaned.Load()) + } +} + +func TestServeSandboxLeaseConcurrentClose(t *testing.T) { + var cleaned atomic.Int32 + l := &serveSandboxLease{container: "isolated", cleanup: func() error { cleaned.Add(1); return nil }} + var wg sync.WaitGroup + for i := 0; i < 20; i++ { + opts, err := l.acquire() + if err != nil { + t.Fatal(err) + } + wg.Add(1) + go func() { defer wg.Done(); opts.Release() }() + } + _ = l.close() + wg.Wait() + if cleaned.Load() != 1 { + t.Fatalf("cleanup count %d", cleaned.Load()) + } +} + +func TestServeSandboxManagerRequiresRouting(t *testing.T) { + var cfg config.ResolvedConfig + cfg.Sandbox = true + cfg.Background.Enabled = true + m := newServeBGManager(cfg) + if m == nil { + t.Fatal("sandbox background manager disabled") + } + defer m.Shutdown() + if _, err := m.Start("s", "echo forbidden", "", 0); err == nil { + t.Fatal("unrouted host launch accepted") + } +} + +// Opt-in real-container test: no provider credentials or network required. +func TestServeSandboxBackgroundDocker(t *testing.T) { + image := os.Getenv("ODEK_BG_SANDBOX_TEST_IMAGE") + if image == "" { + t.Skip("set ODEK_BG_SANDBOX_TEST_IMAGE to a locally available sandbox image") + } + var cfg config.ResolvedConfig + cfg.Sandbox = true + cfg.Background.Enabled = true + m := newServeBGManager(cfg) + defer m.Shutdown() + names := []string{fmt.Sprintf("odek-bg-test-%d-a", os.Getpid()), fmt.Sprintf("odek-bg-test-%d-b", os.Getpid())} + leases := make([]*serveSandboxLease, 2) + jobs := make([]*bgproc.Job, 2) + for i, name := range names { + if out, err := exec.Command("docker", "run", "-d", "--network", "none", "--workdir", "/workspace", "--name", name, "--entrypoint", "sh", image, "-c", "sleep 120").CombinedOutput(); err != nil { + t.Fatalf("container: %v %s", err, out) + } + name := name + t.Cleanup(func() { _ = exec.Command("docker", "rm", "-f", name).Run() }) + leases[i] = &serveSandboxLease{container: name, cleanup: func() error { return exec.Command("docker", "rm", "-f", name).Run() }} + + command := "hostname; sleep 30" + rt := newServeBGRuntime(m, false) + bindBGRuntime(rt, name) + rt.serveSandbox = leases[i] + tool := &bgStartTool{rt: rt, shell: &shellTool{dangerousConfig: danger.DangerousConfig{Allowlist: []string{command}}}} + args, _ := json.Marshal(map[string]string{"command": command}) + result, err := tool.Call(string(args)) + if err != nil { + t.Fatal(err) + } + var response struct { + JobID string `json:"job_id"` + } + if err = json.Unmarshal([]byte(result), &response); err != nil { + t.Fatal(err) + } + job, ok := m.Get(name, response.JobID) + if !ok { + t.Fatal("tool job missing") + } + jobs[i] = &job + + } + + // Stop must kill descendants even while the owner keeps the container. + opts, err := leases[0].acquire() + if err != nil { + t.Fatal(err) + } + child, err := m.StartWithOptions(names[0], "(sleep 1; touch /tmp/odek-child-survived) & echo ready; wait", "", 0, opts) + if err != nil { + t.Fatal(err) + } + deadline := time.Now().Add(5 * time.Second) + for { + out, _, _ := m.Output(names[0], child.ID, 0, 0) + if strings.Contains(out, "ready") { + break + } + if time.Now().After(deadline) { + t.Fatalf("child not ready: %s", out) + } + time.Sleep(10 * time.Millisecond) + } + m.Stop(names[0], child.ID) + if out, err := exec.Command("docker", "exec", names[0], "sh", "-c", "sleep 1.2; test ! -e /tmp/odek-child-survived").CombinedOutput(); err != nil { + t.Fatalf("stopped descendant survived: %v %s", err, out) + } + for i, name := range names { + deadline := time.Now().Add(10 * time.Second) + for { + out, _, _ := m.Output(name, jobs[i].ID, 0, 0) + if strings.TrimSpace(out) != "" { + break + } + if time.Now().After(deadline) { + t.Fatal("no container output") + } + time.Sleep(20 * time.Millisecond) + } + expected, err := exec.Command("docker", "exec", name, "hostname").Output() + if err != nil { + t.Fatal(err) + } + out, _, _ := m.Output(name, jobs[i].ID, 0, 0) + if strings.TrimSpace(out) != strings.TrimSpace(string(expected)) { + t.Fatalf("wrong container: %q vs %q", out, expected) + } + if _, ok := m.Get(names[1-i], jobs[i].ID); ok { + t.Fatal("foreign job exposed") + } + if err := leases[i].close(); err != nil { + t.Fatal(err) + } + if err := exec.Command("docker", "inspect", name).Run(); err != nil { + t.Fatal("owner disconnect removed running container") + } + } + m.Stop(names[0], jobs[0].ID) + if err := exec.Command("docker", "inspect", names[0]).Run(); err == nil { + t.Fatal("stopped job leaked container") + } + if err := exec.Command("docker", "inspect", names[1]).Run(); err != nil { + t.Fatal("stopping first job removed second container") + } + m.Shutdown() + if err := exec.Command("docker", "inspect", names[1]).Run(); err == nil { + t.Fatal("shutdown leaked container") + } +} + +func TestServeSandboxCleanupRetriesAndRetainsFailure(t *testing.T) { + attempts := 0 + fail := true + l := &serveSandboxLease{container: "retry", cleanup: newSandboxCleanup("retry", func(context.Context) error { + attempts++ + if fail { + return fmt.Errorf("temporary Docker failure") + } + return nil + })} + if err := l.close(); err == nil { + t.Fatal("expected cleanup failure") + } + if attempts != 3 { + t.Fatalf("attempts: %d", attempts) + } + if _, ok := pendingSandboxCleanup.Load(l); !ok { + t.Fatal("lost failed cleanup ownership") + } + fail = false + if err := l.close(); err != nil { + t.Fatal(err) + } + if attempts != 4 { + t.Fatalf("no retry: %d", attempts) + } + if _, ok := pendingSandboxCleanup.Load(l); ok { + t.Fatal("completed cleanup retained") + } +} + +func TestSandboxCleanupRegisteredBeforeRemovalCompletes(t *testing.T) { + entered, unblock := make(chan struct{}), make(chan struct{}) + l := &serveSandboxLease{container: "delayed", cleanup: func() error { close(entered); <-unblock; return nil }} + done := make(chan struct{}) + go func() { defer close(done); _ = l.close() }() + <-entered + _, registered := pendingSandboxCleanup.Load(l) + close(unblock) + <-done + if !registered { + t.Fatal("in-flight removal invisible to shutdown") + } + if _, exists := pendingSandboxCleanup.Load(l); exists { + t.Fatal("successful removal retained") + } +} diff --git a/cmd/odek/serve_jobs.go b/cmd/odek/serve_jobs.go index d7c10414..d89932fa 100644 --- a/cmd/odek/serve_jobs.go +++ b/cmd/odek/serve_jobs.go @@ -63,15 +63,10 @@ func backgroundSettingsFrom(resolved config.ResolvedConfig) BackgroundSettings { } } -// newServeBGManager builds the shared manager from resolved config. It -// returns nil when background commands are disabled — or when sandbox mode -// is on: the manager's SandboxWrap bakes in ONE container name, but serve -// creates a fresh container per connection, so bg spawns cannot be routed -// through the agent's container today. Rather than letting bg_start run on -// the host while the operator believes everything is confined, the feature -// stays off in serve sandbox mode. +// newServeBGManager shares job accounting across agents; sandbox routing is +// supplied per launch by the originating agent and required in sandbox mode. func newServeBGManager(resolved config.ResolvedConfig) *bgproc.Manager { - if !resolved.Background.Enabled || resolved.Sandbox { + if !resolved.Background.Enabled { return nil } s := backgroundSettingsFrom(resolved) @@ -82,9 +77,13 @@ func newServeBGManager(resolved config.ResolvedConfig) *bgproc.Manager { s.MaxOutputBytes = 1 << 20 } cfg := bgproc.Config{ + RequireSandbox: resolved.Sandbox, MaxJobsPerSession: s.MaxJobs, MaxOutputBytes: s.MaxOutputBytes, } + if resolved.Dangerous.StripSecretsEnvChildrenEnabled() { + cfg.StripEnvNames = secretsEnvNames() + } if s.MaxTimeoutSeconds > 0 { cfg.MaxTimeout = time.Duration(s.MaxTimeoutSeconds) * time.Second } diff --git a/cmd/odek/serve_retention.go b/cmd/odek/serve_retention.go new file mode 100644 index 00000000..1f4d2e08 --- /dev/null +++ b/cmd/odek/serve_retention.go @@ -0,0 +1,340 @@ +package main + +import ( + "context" + "errors" + "fmt" + "io" + "io/fs" + "os" + "path/filepath" + "sort" + "strings" + "time" + + "github.com/BackendStack21/odek/internal/bgproc" + "github.com/BackendStack21/odek/internal/session" +) + +const uploadDiskLimit = 256 << 20 +const uploadHandleLimit = 128 +const uploadMaxAge = 7 * 24 * time.Hour + +// openUploadDir pins each directory and rejects symlinks, including a swap +// between inspection and opening. Retention never walks an arbitrary subtree. +func openUploadDir(parent *os.Root, name string, create bool) (*os.Root, error) { + if create { + if err := parent.Mkdir(name, 0700); err != nil && !errors.Is(err, fs.ErrExist) { + return nil, err + } + } + info, err := parent.Lstat(name) + if err != nil { + return nil, err + } + if !info.IsDir() { + return nil, fmt.Errorf("upload directory is not a regular directory") + } + child, err := parent.OpenRoot(name) + if err != nil { + return nil, err + } + opened, err := fs.Stat(child.FS(), ".") + if err != nil || !os.SameFile(info, opened) { + child.Close() + return nil, fmt.Errorf("upload directory changed") + } + return child, nil +} +func openUploadRoot(workspace string, create bool) (*os.Root, error) { + root, err := os.OpenRoot(workspace) + if err != nil { + return nil, err + } + defer root.Close() + artifacts, err := openUploadDir(root, ".odek-artifacts", create) + if err != nil { + return nil, err + } + defer artifacts.Close() + return openUploadDir(artifacts, "uploads", create) +} + +func removeBrowserUploadLocked(id string, item browserUpload) error { + root, err := openUploadRoot(item.workspace, false) + if errors.Is(err, fs.ErrNotExist) { + delete(browserUploads.entries, id) + return nil + } + if err != nil { + return err + } + defer root.Close() + dir, err := openUploadDir(root, item.sessionID, false) + if errors.Is(err, fs.ErrNotExist) { + delete(browserUploads.entries, id) + return nil + } + if err != nil { + return err + } + err = dir.Remove(filepath.Base(item.path)) + dir.Close() + if err != nil && !errors.Is(err, fs.ErrNotExist) { + return err + } + delete(browserUploads.entries, id) + _ = root.Remove(item.sessionID) // remove an empty session directory only + return nil +} + +// The caller holds browserUploads so eviction cannot race a new handle. +func pruneBrowserUploadsLocked(workspace string, incoming int, now time.Time) error { + type entry struct { + id string + item browserUpload + } + var entries []entry + var bytes int + for id, item := range browserUploads.entries { + if item.workspace == workspace { + entries = append(entries, entry{id, item}) + bytes += item.size + } + } + sort.Slice(entries, func(i, j int) bool { return entries[i].item.created.Before(entries[j].item.created) }) + count := len(entries) + for _, e := range entries { + if e.item.pins > 0 { + continue + } + if now.Sub(e.item.created) <= uploadMaxAge && bytes+incoming <= uploadDiskLimit && count+boolInt(incoming > 0) <= uploadHandleLimit { + continue + } + if err := removeBrowserUploadLocked(e.id, e.item); err != nil { + return err + } + bytes -= e.item.size + count-- + } + if incoming > 0 && (bytes+incoming > uploadDiskLimit || count+1 > uploadHandleLimit) { + return fmt.Errorf("upload capacity is held by active turns") + } + return nil +} +func boolInt(v bool) int { + if v { + return 1 + } + return 0 +} + +// Recover disk accounting after a restart, never authenticated upload handles. +// Workspace files are mutable, so only uploads received by this process resolve. +// Only the server's two-level session/file layout is read; symlinks are rejected. +func sweepBrowserUploads(store *session.Store, workspace string, now time.Time) error { + browserUploads.Lock() + defer browserUploads.Unlock() + root, err := openUploadRoot(workspace, false) + if errors.Is(err, fs.ErrNotExist) { + return nil + } + if err != nil { + return err + } + defer root.Close() + + sessions, err := root.Open(".") + if err != nil { + return err + } + defer sessions.Close() + for { + dirs, readErr := sessions.ReadDir(64) + for _, d := range dirs { + sid := d.Name() + if !d.IsDir() || session.ValidateSessionID(sid) != nil { + continue + } + dir, err := openUploadDir(root, sid, false) + if errors.Is(err, fs.ErrNotExist) { + continue + } + if err != nil { + return err + } + files, err := dir.Open(".") + if err != nil { + dir.Close() + return err + } + _, sessionErr := os.Stat(store.Path(sid)) + if sessionErr != nil && !errors.Is(sessionErr, fs.ErrNotExist) { + files.Close() + dir.Close() + return sessionErr + } + err = scanUploadFilesLocked(files, dir, workspace, sid, sessionErr != nil, now) + files.Close() + dir.Close() + if err != nil { + return err + } + _ = root.Remove(sid) // only empty directories + } + if errors.Is(readErr, io.EOF) { + break + } + if readErr != nil { + return readErr + } + } + + // Expired files removed during the walk may still have in-memory handles. + for id, item := range browserUploads.entries { + if item.workspace == workspace { + if _, err := root.Stat(filepath.Join(item.sessionID, filepath.Base(item.path))); errors.Is(err, fs.ErrNotExist) { + delete(browserUploads.entries, id) + } + } + } + return pruneBrowserUploadsLocked(workspace, 0, now) +} + +// ReadDir batches bound memory even when stale files accumulated before limits. +func scanUploadFilesLocked(files *os.File, dir *os.Root, workspace, sid string, deleted bool, now time.Time) error { + for { + entries, readErr := files.ReadDir(64) + for _, entry := range entries { + info, err := entry.Info() + if errors.Is(err, fs.ErrNotExist) { + continue + } + if err != nil { + return err + } + if !info.Mode().IsRegular() { + continue + } + full := filepath.Join(workspace, ".odek-artifacts", "uploads", sid, entry.Name()) + id := strings.TrimSuffix(entry.Name(), filepath.Ext(entry.Name())) + if item, ok := browserUploads.entries[id]; ok && item.path == full && item.pins > 0 && !deleted { + continue + } + if deleted || info.Size() > 5<<20 || now.Sub(info.ModTime()) > uploadMaxAge { + if err = dir.Remove(entry.Name()); err != nil && !errors.Is(err, fs.ErrNotExist) { + return err + } + continue + } + if item, ok := browserUploads.entries[id]; ok && item.path == full { + item.size = int(info.Size()) + browserUploads.entries[id] = item + } else { + key := "disk:" + sid + "/" + entry.Name() + browserUploads.entries[key] = browserUpload{sessionID: sid, path: full, name: entry.Name(), size: int(info.Size()), workspace: workspace, created: info.ModTime(), restored: true} + } + if err = pruneBrowserUploadsLocked(workspace, 0, now); err != nil { + return err + } + } + if errors.Is(readErr, io.EOF) { + return nil + } + if readErr != nil { + return readErr + } + } +} + +func deleteBrowserSession(workspace, sid string) error { + if err := session.ValidateSessionID(sid); err != nil { + return err + } + browserUploads.Lock() + defer browserUploads.Unlock() + root, err := openUploadRoot(workspace, false) + if err != nil && !errors.Is(err, fs.ErrNotExist) { + return err + } + if root != nil { + defer root.Close() + if err = root.RemoveAll(sid); err != nil { + return err + } + } + for id, item := range browserUploads.entries { + if item.workspace == workspace && item.sessionID == sid { + delete(browserUploads.entries, id) + } + } + browserArtifacts.Lock() + defer browserArtifacts.Unlock() + for id, item := range browserArtifacts.entries { + if item.SessionID == sid { + browserArtifacts.bytes -= len(item.data) + delete(browserArtifacts.entries, id) + } + } + return nil +} + +func wireServeSessionCleanup(store *session.Store, mgr *bgproc.Manager, workspace string) { + previous := store.OnDelete + store.OnDelete = func(sid string) { + cancelPrompt(sid) + if mgr != nil { + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + err := mgr.DeleteSession(ctx, sid) + cancel() + if err != nil { + fmt.Fprintf(os.Stderr, "odek: session job cleanup: %v\n", err) + } + } + if err := deleteBrowserSession(workspace, sid); err != nil { + fmt.Fprintf(os.Stderr, "odek: session upload cleanup: %v\n", err) + } + if previous != nil { + previous(sid) + } + } +} + +func sweepDeletedJobSessions(ctx context.Context, store *session.Store, mgr *bgproc.Manager) { + if mgr == nil { + return + } + for _, sid := range mgr.ActiveSessions() { + if _, err := os.Stat(store.Path(sid)); errors.Is(err, fs.ErrNotExist) { + cancelPrompt(sid) + if err := mgr.DeleteSession(ctx, sid); err != nil { + fmt.Fprintf(os.Stderr, "odek: deleted session job cleanup: %v\n", err) + } + } + } +} + +func startServeRetention(ctx context.Context, store *session.Store, workspace string, mgr *bgproc.Manager) { + sweep := func() { + drainCtx, cancel := context.WithTimeout(ctx, 30*time.Second) + sweepDeletedJobSessions(drainCtx, store, mgr) + cancel() + if err := sweepBrowserUploads(store, workspace, time.Now()); err != nil { + fmt.Fprintf(os.Stderr, "odek: upload retention: %v\n", err) + } + retrySandboxCleanup() + } + sweep() + go func() { + ticker := time.NewTicker(time.Minute) + defer ticker.Stop() + for { + select { + case <-ctx.Done(): + return + case <-ticker.C: + sweep() + } + } + }() +} diff --git a/cmd/odek/serve_retention_test.go b/cmd/odek/serve_retention_test.go new file mode 100644 index 00000000..a947922a --- /dev/null +++ b/cmd/odek/serve_retention_test.go @@ -0,0 +1,295 @@ +package main + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "net/http/httptest" + "net/url" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/BackendStack21/odek/internal/artifact" + "github.com/BackendStack21/odek/internal/bgproc" + "github.com/BackendStack21/odek/internal/config" + "github.com/BackendStack21/odek/internal/session" +) + +func TestServeDeletedSessionStopsJobsAndUploads(t *testing.T) { + workspace := t.TempDir() + store, err := session.NewStoreWithDir(t.TempDir()) + if err != nil { + t.Fatal(err) + } + sess, err := store.Create(nil, "model", "test") + if err != nil { + t.Fatal(err) + } + mgr := bgproc.NewManager(bgproc.Config{}, nil) + defer mgr.Shutdown() + wireServeSessionCleanup(store, mgr, workspace) + turnCtx, cancelTurn := context.WithCancel(context.Background()) + defer cancelTurn() + unregister := registerPromptCancel(sess.ID, cancelTurn) + defer unregister() + png := []byte{137, 80, 78, 71, 13, 10, 26, 10, 0, 0, 0, 0} + req := httptest.NewRequest("POST", "/api/uploads?name=image.png&session_id="+sess.ID, bytes.NewReader(png)) + req.Header.Set("X-Session-Token", sess.AuthToken) + out := httptest.NewRecorder() + handleBrowserUpload(store, "model", workspace)(out, req) + if out.Code != 201 { + t.Fatal(out.Code, out.Body.String()) + } + var upload struct { + ID string `json:"upload_id"` + } + _ = json.Unmarshal(out.Body.Bytes(), &upload) + item, ok := resolveBrowserUpload(upload.ID, sess.ID) + if !ok { + t.Fatal("upload unavailable") + } + job, err := mgr.Start(sess.ID, "sleep 30", "", 0) + if err != nil { + t.Fatal(err) + } + if err = store.Delete(sess.ID); err != nil { + t.Fatal(err) + } + if turnCtx.Err() == nil { + t.Fatal("deleted session turn not cancelled") + } + sess.Messages = append(sess.Messages, session.Message{Role: "assistant", Content: "Turn aborted"}) + if err := store.SaveNoIndex(sess); !errors.Is(err, os.ErrNotExist) { + t.Fatalf("cancelled turn recreated deleted session: %v", err) + } + if _, ok = resolveBrowserUpload(upload.ID, sess.ID); ok { + t.Fatal("deleted session upload still resolves") + } + if _, err = os.Stat(item.path); !os.IsNotExist(err) { + t.Fatal("deleted upload still on disk") + } + got, _ := mgr.Get(sess.ID, job.ID) + if got.Status == bgproc.StatusRunning { + t.Fatal("deleted session job running") + } + if _, err = mgr.Start(sess.ID, "true", "", 0); err == nil { + t.Fatal("stale tool launched after deletion") + } +} + +func TestUploadSweepBoundsRestartStorageAndRejectsSymlinks(t *testing.T) { + workspace := t.TempDir() + store, err := session.NewStoreWithDir(t.TempDir()) + if err != nil { + t.Fatal(err) + } + sess, err := store.Create(nil, "model", "test") + if err != nil { + t.Fatal(err) + } + dir := filepath.Join(workspace, ".odek-artifacts", "uploads", sess.ID) + if err = os.MkdirAll(dir, 0700); err != nil { + t.Fatal(err) + } + for i := 0; i < 130; i++ { + if err = os.WriteFile(filepath.Join(dir, fmt.Sprintf("restored-%03d.png", i)), []byte("image"), 0600); err != nil { + t.Fatal(err) + } + } + old := filepath.Join(dir, "expired.png") + _ = os.WriteFile(old, []byte("old"), 0600) + past := time.Now().Add(-8 * 24 * time.Hour) + _ = os.Chtimes(old, past, past) + if err = sweepBrowserUploads(store, workspace, time.Now()); err != nil { + t.Fatal(err) + } + files, _ := os.ReadDir(dir) + if len(files) != uploadHandleLimit { + t.Fatalf("retained %d", len(files)) + } + if _, err = os.Stat(old); !os.IsNotExist(err) { + t.Fatal("expired file retained") + } + if _, ok := resolveBrowserUpload("restored-129", sess.ID); ok { + t.Fatal("disk file became authenticated upload") + } + // A directory substitution must never point retention at another subtree. + outside := t.TempDir() + marker := filepath.Join(outside, "keep") + _ = os.WriteFile(marker, []byte("safe"), 0600) + hostile := t.TempDir() + _ = os.Symlink(outside, filepath.Join(hostile, ".odek-artifacts")) + if err = sweepBrowserUploads(store, hostile, time.Now()); err == nil { + t.Fatal("symlink accepted") + } + if _, err = os.Stat(marker); err != nil { + t.Fatal("retention escaped its root") + } + _ = deleteBrowserSession(workspace, sess.ID) +} + +func TestArtifactCaptureBudgetAndDigest(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "result.txt") + _ = os.WriteFile(path, []byte("four"), 0600) + ref := artifact.Ref{Schema: artifact.SchemaArtifactRef, ID: "a", URI: (&url.URL{Scheme: "file", Path: path}).String(), MediaType: "text/plain"} + cache := &browserArtifactStore{} + budget := &previewBudget{remaining: 5} + if _, err := cache.captureBudget("s", ref, []string{dir}, budget); err != nil { + t.Fatal(err) + } + if _, err := cache.captureBudget("s", ref, []string{dir}, budget); err == nil { + t.Fatal("aggregate budget ignored") + } + ref.SHA256 = strings.Repeat("0", 64) + if _, err := cache.captureBudget("s", ref, []string{dir}, &previewBudget{remaining: 10}); err == nil { + t.Fatal("digest verification skipped") + } +} + +func TestMemoryApplyDoesNotRequireProvider(t *testing.T) { + cfg := config.ResolvedConfig{} + cfg.Provider = "missing-provider-for-local-apply" + cfg.Model = "missing-model" + req := httptest.NewRequest("POST", "/api/memory/consolidate", strings.NewReader(`{"target":"user","mode":"apply","preview":{"before":[],"after":["Prefers concise answers"]}}`)) + w := httptest.NewRecorder() + handleMemoryConsolidate(t.TempDir(), cfg)(w, req) + if w.Code != 204 { + t.Fatalf("local apply: %d %s", w.Code, w.Body.String()) + } +} + +func TestUploadSweepEnforcesPersistentByteCap(t *testing.T) { + workspace := t.TempDir() + store, err := session.NewStoreWithDir(t.TempDir()) + if err != nil { + t.Fatal(err) + } + sess, err := store.Create(nil, "model", "test") + if err != nil { + t.Fatal(err) + } + dir := filepath.Join(workspace, ".odek-artifacts", "uploads", sess.ID) + _ = os.MkdirAll(dir, 0700) + for i := 0; i < 54; i++ { + f, err := os.Create(filepath.Join(dir, fmt.Sprintf("large-%d.png", i))) + if err != nil { + t.Fatal(err) + } + err = f.Truncate(5 << 20) + _ = f.Close() + if err != nil { + t.Fatal(err) + } + } + if err = sweepBrowserUploads(store, workspace, time.Now()); err != nil { + t.Fatal(err) + } + files, err := os.ReadDir(dir) + if err != nil { + t.Fatal(err) + } + var size int64 + for _, f := range files { + info, err := f.Info() + if err != nil { + t.Fatal(err) + } + size += info.Size() + } + if size > uploadDiskLimit { + t.Fatalf("disk cap exceeded: %d", size) + } + _ = deleteBrowserSession(workspace, sess.ID) +} + +func TestRetentionStopsJobsDeletedByAnotherStore(t *testing.T) { + store, err := session.NewStoreWithDir(t.TempDir()) + if err != nil { + t.Fatal(err) + } + sess, err := store.Create(nil, "model", "test") + if err != nil { + t.Fatal(err) + } + mgr := bgproc.NewManager(bgproc.Config{}, nil) + defer mgr.Shutdown() + job, err := mgr.Start(sess.ID, "sleep 30", "", 0) + if err != nil { + t.Fatal(err) + } + other, err := session.NewStoreWithDir(store.Dir()) + if err != nil { + t.Fatal(err) + } + if err = other.Delete(sess.ID); err != nil { + t.Fatal(err) + } + ctx, cancel := context.WithTimeout(context.Background(), time.Second) + defer cancel() + sweepDeletedJobSessions(ctx, store, mgr) + got, _ := mgr.Get(sess.ID, job.ID) + if got.Status == bgproc.StatusRunning { + t.Fatal("janitor-deleted session still running") + } + if _, err = mgr.Start(sess.ID, "true", "", 0); err == nil { + t.Fatal("late launch after janitor deletion") + } +} + +func TestRetentionPinsActiveAttachmentAndRejectsOverflow(t *testing.T) { + workspace := t.TempDir() + store, err := session.NewStoreWithDir(t.TempDir()) + if err != nil { + t.Fatal(err) + } + sess, err := store.Create(nil, "model", "test") + if err != nil { + t.Fatal(err) + } + dir := filepath.Join(workspace, ".odek-artifacts", "uploads", sess.ID) + if err := os.MkdirAll(dir, 0700); err != nil { + t.Fatal(err) + } + path := filepath.Join(dir, "pinned.pdf") + if err := os.WriteFile(path, []byte("attachment"), 0600); err != nil { + t.Fatal(err) + } + old := time.Now().Add(-2 * uploadMaxAge) + if err := os.Chtimes(path, old, old); err != nil { + t.Fatal(err) + } + browserUploads.Lock() + browserUploads.entries["pinned"] = browserUpload{sessionID: sess.ID, path: path, workspace: workspace, created: old, size: 10} + browserUploads.Unlock() + defer func() { browserUploads.Lock(); delete(browserUploads.entries, "pinned"); browserUploads.Unlock() }() + _, release, ok := acquireBrowserUpload("pinned", sess.ID) + if !ok { + t.Fatal("acquire failed") + } + defer release() + if err := sweepBrowserUploads(store, workspace, time.Now()); err != nil { + t.Fatal(err) + } + if _, err := os.Stat(path); err != nil { + t.Fatalf("active attachment removed: %v", err) + } + browserUploads.Lock() + err = pruneBrowserUploadsLocked(workspace, uploadDiskLimit, time.Now()) + browserUploads.Unlock() + if err == nil { + t.Fatal("capacity exceeded with pinned upload") + } + release() + if err := sweepBrowserUploads(store, workspace, time.Now()); err != nil { + t.Fatal(err) + } + if _, err := os.Stat(path); !os.IsNotExist(err) { + t.Fatal("released expired attachment retained") + } +} diff --git a/cmd/odek/serve_runs.go b/cmd/odek/serve_runs.go index 77a1f7a1..6b12a3df 100644 --- a/cmd/odek/serve_runs.go +++ b/cmd/odek/serve_runs.go @@ -568,6 +568,7 @@ func (r *serveRun) snapshot(includeEvents bool) map[string]any { "id": p.ID, "risk": p.Risk, "command": p.Command, "description": p.Description, "allow_trust": p.AllowTrust, "friction": p.Friction, "friction_approvals": p.FrictionApprovals, + "requires_confirmation": restApprovalFrictionEnabled || p.Friction, }) } out["pending_approvals"] = pending diff --git a/cmd/odek/serve_shutdown_failure_test.go b/cmd/odek/serve_shutdown_failure_test.go new file mode 100644 index 00000000..c1a4b95d --- /dev/null +++ b/cmd/odek/serve_shutdown_failure_test.go @@ -0,0 +1,39 @@ +package main + +import ( + "errors" + "net" + "net/http" + "testing" + "time" + + "github.com/BackendStack21/odek/internal/bgproc" +) + +type failedServeListener struct{ err error } + +func (l failedServeListener) Accept() (net.Conn, error) { return nil, l.err } +func (l failedServeListener) Close() error { return nil } +func (l failedServeListener) Addr() net.Addr { return &net.TCPAddr{} } + +func TestServeListenerFailureDrainsBackgroundJobs(t *testing.T) { + mgr := bgproc.NewManager(bgproc.Config{}, nil) + previous := serveBG.Swap(mgr) + defer serveBG.Store(previous) + defer mgr.Shutdown() + job, err := mgr.Start("listener-test", "sleep 30", "", 0) + if err != nil { + t.Fatal(err) + } + failure := errors.New("permanent accept failure") + if err = serveOnListener(failedServeListener{failure}, http.NewServeMux()); !errors.Is(err, failure) { + t.Fatalf("listener error lost: %v", err) + } + got, ok := mgr.Get("listener-test", job.ID) + if !ok || got.Status == bgproc.StatusRunning { + t.Fatal("job survived listener failure") + } + if _, err := mgr.Start("another", "true", "", time.Second); err == nil { + t.Fatal("manager not shut down") + } +} diff --git a/cmd/odek/serve_test.go b/cmd/odek/serve_test.go index edb24323..4883fb63 100644 --- a/cmd/odek/serve_test.go +++ b/cmd/odek/serve_test.go @@ -1272,8 +1272,10 @@ func TestServe_E2E_MultiToolCall(t *testing.T) { var events []map[string]any var sawSession, sawToken, sawToolCall, sawToolResult, sawDone bool var toolCallCount, toolResultCount int + callIDs := map[string]bool{} - for i := 0; i < 15; i++ { + // Additive protocol frames may precede done; the read deadline bounds the wait. + for i := 0; i < 100; i++ { var raw []byte if err := golangws.Message.Receive(conn, &raw); err != nil { t.Fatalf("Receive event %d: %v (collected %d events)", i, err, len(events)) @@ -1294,9 +1296,18 @@ func TestServe_E2E_MultiToolCall(t *testing.T) { case "tool_call": sawToolCall = true toolCallCount++ + id, _ := evt["call_id"].(string) + if id == "" || callIDs[id] { + t.Fatalf("tool call has missing or duplicate identity: %v", evt) + } + callIDs[id] = true case "tool_result": sawToolResult = true toolResultCount++ + id, _ := evt["call_id"].(string) + if !callIDs[id] || evt["outcome"] != "completed" { + t.Fatalf("tool result lacks correlated successful outcome: %v", evt) + } case "done": sawDone = true goto multiDone @@ -1662,7 +1673,8 @@ func TestServe_E2E_LiveToolEvents(t *testing.T) { var eventOrder []string var doneAt time.Time - for i := 0; i < 10; i++ { + // Additive protocol frames may precede done; the read deadline bounds the wait. + for i := 0; i < 100; i++ { var raw []byte if err := golangws.Message.Receive(conn, &raw); err != nil { t.Fatalf("Receive event %d: %v", i, err) diff --git a/cmd/odek/serve_workspace.go b/cmd/odek/serve_workspace.go new file mode 100644 index 00000000..36239bac --- /dev/null +++ b/cmd/odek/serve_workspace.go @@ -0,0 +1,142 @@ +package main + +import ( + "encoding/json" + "net/http" + "path/filepath" + "strings" + "time" + + "github.com/BackendStack21/odek/internal/config" + "github.com/BackendStack21/odek/internal/maintenance" + "github.com/BackendStack21/odek/internal/schedule" +) + +// workspaceCapabilities is additive: clients must tolerate missing features. +func workspaceCapabilities() map[string]any { + return map[string]any{"version": 1, "features": map[string]bool{"media_uploads": true, "artifact_previews": true, "tool_identity": true, "tool_outcomes": true, "skill_review": true, "schedules": true, "maintenance": true, "tool_schemas": true}, "result_renderers": []string{"code", "diff", "terminal", "search", "json", "sources", "text"}} +} +func handleCapabilities(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + writeAPIJSON(w, 200, workspaceCapabilities()) +} + +// handleSchedules shares the CLI store and file locks. serve manages definitions; +// a scheduler daemon or Telegram host executes them and publishes runtime state. +func handleSchedules(home string) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet && r.Method != http.MethodPost && r.Method != http.MethodDelete { + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + store, err := schedule.NewStoreAt(home) + if err != nil { + http.Error(w, "schedule store unavailable", 500) + return + } + id := strings.TrimPrefix(r.URL.Path, "/api/schedules") + id = strings.TrimPrefix(id, "/") + if r.Method == http.MethodGet { + jobs, err := store.List() + if err != nil { + http.Error(w, "cannot read schedules", 500) + return + } + states, err := store.LoadState() + if err != nil { + http.Error(w, "cannot read schedule state", 500) + return + } + if jobs == nil { + jobs = []schedule.Job{} + } + next := map[string]time.Time{} + for _, j := range jobs { + loc := time.UTC + if j.Timezone != "" { + if l, e := time.LoadLocation(j.Timezone); e == nil { + loc = l + } + } + if expr, e := schedule.ParseInLocation(j.Cron, loc); e == nil { + next[j.ID] = expr.Next(time.Now()) + } + } + writeAPIJSON(w, 200, map[string]any{"jobs": jobs, "states": states, "next": next, "execution_host": "schedule daemon or Telegram"}) + return + } + if r.Method == http.MethodDelete { + if id == "" { + http.Error(w, "schedule id required", 400) + return + } + if err := store.Remove(id); err != nil { + http.Error(w, err.Error(), 400) + return + } + w.WriteHeader(204) + return + } + var job schedule.Job + dec := json.NewDecoder(http.MaxBytesReader(w, r.Body, 64<<10)) + dec.DisallowUnknownFields() + if err := dec.Decode(&job); err != nil { + http.Error(w, "invalid schedule", 400) + return + } + if len(job.Task) > 32000 || len(job.Name) > 200 { + http.Error(w, "schedule too large", 400) + return + } + if id != "" { + old, found, e := store.Get(id) + if e != nil || !found { + http.Error(w, "schedule not found", 404) + return + } + job.ID = id + job.CreatedAt = old.CreatedAt + err = store.Put(job) + } else { + job.ID = "" + job.CreatedAt = time.Time{} + job, err = store.Add(job) + } + if err != nil { + http.Error(w, err.Error(), 400) + return + } + writeAPIJSON(w, 200, job) + } +} + +// Maintenance uses only the operator-resolved policy; request bodies cannot +// broaden retention or choose a filesystem root. +func handleMaintenance(home string, resolved config.ResolvedConfig) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + if r.Method == http.MethodGet { + writeAPIJSON(w, 200, map[string]any{"policy": resolved.Maintenance, "description": "Cleanup uses the server's retention policy. Zero retention keeps that category indefinitely."}) + return + } + if r.Method != http.MethodPost { + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + var body struct { + Confirm string `json:"confirm"` + } + if json.NewDecoder(http.MaxBytesReader(w, r.Body, 1024)).Decode(&body) != nil || body.Confirm != "cleanup" { + http.Error(w, "type cleanup to confirm retention cleanup", 400) + return + } + report, err := maintenance.Sweep(r.Context(), filepath.Clean(home), resolved.Maintenance) + if err != nil { + writeAPIJSON(w, 500, map[string]any{"error": "cleanup partially failed", "report": report}) + return + } + writeAPIJSON(w, 200, report) + } +} diff --git a/cmd/odek/serve_workspace_test.go b/cmd/odek/serve_workspace_test.go new file mode 100644 index 00000000..0dae630d --- /dev/null +++ b/cmd/odek/serve_workspace_test.go @@ -0,0 +1,141 @@ +package main + +import ( + "bytes" + "encoding/json" + "net/http" + "net/http/httptest" + "net/url" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/BackendStack21/odek/internal/artifact" + "github.com/BackendStack21/odek/internal/session" +) + +func TestWorkspaceSchedulesCRUD(t *testing.T) { + h := handleSchedules(t.TempDir()) + call := func(method, path, body string) *httptest.ResponseRecorder { + r := httptest.NewRequest(method, path, strings.NewReader(body)) + w := httptest.NewRecorder() + h(w, r) + return w + } + body := `{"name":"Daily review","cron":"0 9 * * 1-5","task":"Review changes","deliver":{"kind":"log"},"enabled":false,"timezone":"Europe/Berlin"}` + w := call("POST", "/api/schedules", body) + if w.Code != 200 { + t.Fatal(w.Code, w.Body.String()) + } + var job map[string]any + _ = json.Unmarshal(w.Body.Bytes(), &job) + id := job["id"].(string) + job["enabled"] = true + b, _ := json.Marshal(job) + if w = call("POST", "/api/schedules/"+id, string(b)); w.Code != 200 { + t.Fatal(w.Body.String()) + } + if w = call("GET", "/api/schedules", ""); !strings.Contains(w.Body.String(), `"enabled":true`) { + t.Fatal(w.Body.String()) + } + if w = call("DELETE", "/api/schedules/"+id, ""); w.Code != 204 { + t.Fatal(w.Body.String()) + } + if w = call("POST", "/api/schedules", `{"cron":"invalid"}`); w.Code != 400 { + t.Fatal(w.Code) + } +} + +func TestWorkspaceArtifactCaptureIsImmutableAndScoped(t *testing.T) { + dir := t.TempDir() + file := filepath.Join(dir, "result.txt") + if err := os.WriteFile(file, []byte("original"), 0600); err != nil { + t.Fatal(err) + } + cache := &browserArtifactStore{} + ref := artifact.Ref{Schema: artifact.SchemaArtifactRef, ID: "a", URI: (&url.URL{Scheme: "file", Path: file}).String(), MediaType: "text/plain"} + store, err := session.NewStoreWithDir(filepath.Join(t.TempDir(), "sessions")) + if err != nil { + t.Fatal(err) + } + a, err := store.Create(nil, "fixture", "A") + if err != nil { + t.Fatal(err) + } + b, err := store.Create(nil, "fixture", "B") + if err != nil { + t.Fatal(err) + } + item, err := cache.capture(a.ID, ref, []string{dir}) + if err != nil { + t.Fatal(err) + } + _ = os.WriteFile(file, []byte("changed"), 0600) + h := handleBrowserArtifacts(store, cache) + req := httptest.NewRequest("GET", "/api/artifacts/"+item.ID+"?session_id="+a.ID, nil) + req.Header.Set("X-Session-Token", a.AuthToken) + w := httptest.NewRecorder() + h(w, req) + if w.Code != 200 || w.Body.String() != "original" { + t.Fatalf("%d %s", w.Code, w.Body.String()) + } + req = httptest.NewRequest("GET", "/api/artifacts/"+item.ID+"?session_id="+b.ID, nil) + req.Header.Set("X-Session-Token", b.AuthToken) + w = httptest.NewRecorder() + h(w, req) + if w.Code != 404 { + t.Fatal(w.Code) + } + if _, err := cache.capture(a.ID, ref, nil); err == nil { + t.Fatal("accepted artifact without allowed roots") + } + ref.SHA256 = strings.Repeat("0", 64) + if _, err := cache.capture(a.ID, ref, []string{dir}); err == nil { + t.Fatal("accepted wrong digest") + } +} + +func TestWorkspaceUploadRejectsExecutableAndBindsSession(t *testing.T) { + store, err := session.NewStoreWithDir(filepath.Join(t.TempDir(), "sessions")) + if err != nil { + t.Fatal(err) + } + workspace := t.TempDir() + h := handleBrowserUpload(store, "fixture", workspace) + req := httptest.NewRequest("POST", "/api/uploads?name=script.html", strings.NewReader("")) + w := httptest.NewRecorder() + h(w, req) + if w.Code != http.StatusUnsupportedMediaType { + t.Fatal(w.Code, w.Body.String()) + } + // PNG signature is sufficient for MIME detection; bytes are never executed. + png := []byte{137, 80, 78, 71, 13, 10, 26, 10, 0, 0, 0, 0} + req = httptest.NewRequest("POST", "/api/uploads?name=image.png", bytes.NewReader(png)) + w = httptest.NewRecorder() + h(w, req) + if w.Code != 201 { + t.Fatal(w.Code, w.Body.String()) + } + var result map[string]any + _ = json.Unmarshal(w.Body.Bytes(), &result) + id := result["upload_id"].(string) + sid := result["session_id"].(string) + upload, ok := resolveBrowserUpload(id, sid) + if !ok { + t.Fatal("missing upload") + } + rel, err := filepath.Rel(workspace, upload.path) + if err != nil || !filepath.IsLocal(rel) || filepath.Ext(upload.path) != ".png" { + t.Fatalf("upload is not accessible inside workspace: %s", upload.path) + } + if data, err := os.ReadFile(upload.path); err != nil || !bytes.Equal(data, png) { + t.Fatalf("upload bytes unavailable: %v", err) + } + if _, ok := resolveBrowserUpload(id, "different"); ok { + t.Fatal("cross-session upload accepted") + } + if strings.Contains(w.Body.String(), store.Dir()) { + t.Fatal("leaked storage path") + } +} diff --git a/cmd/odek/subagent_e2e_test.go b/cmd/odek/subagent_e2e_test.go index d58ac67a..8c599a60 100644 --- a/cmd/odek/subagent_e2e_test.go +++ b/cmd/odek/subagent_e2e_test.go @@ -92,6 +92,7 @@ func TestMain(m *testing.M) { keep := map[string]bool{ "ODEK_NO_SANDBOX": true, "ODEK_E2E": true, + "ODEK_BG_SANDBOX_TEST_IMAGE": true, "ODEK_TEST_HOME": true, "ODEK_TEST_KEEP_REAL_HOME": true, "ODEK_SUPPRESS_SANDBOX_WARNING": true, diff --git a/cmd/odek/ui/fonts/geist-LICENSE.txt b/cmd/odek/ui/fonts/geist-LICENSE.txt new file mode 100644 index 00000000..04e95fc5 --- /dev/null +++ b/cmd/odek/ui/fonts/geist-LICENSE.txt @@ -0,0 +1,93 @@ +Copyright 2024 The Geist Project Authors (https://github.com/vercel/geist-font) + +This Font Software is licensed under the SIL Open Font License, Version 1.1. +This license is copied below, and is also available with a FAQ at: +https://openfontlicense.org + + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. \ No newline at end of file diff --git a/cmd/odek/ui/fonts/geist-mono.woff2 b/cmd/odek/ui/fonts/geist-mono.woff2 new file mode 100644 index 00000000..0e746fc1 Binary files /dev/null and b/cmd/odek/ui/fonts/geist-mono.woff2 differ diff --git a/cmd/odek/ui/fonts/geist.woff2 b/cmd/odek/ui/fonts/geist.woff2 new file mode 100644 index 00000000..71dab8be Binary files /dev/null and b/cmd/odek/ui/fonts/geist.woff2 differ diff --git a/cmd/odek/ui/fonts/manrope-LICENSE.txt b/cmd/odek/ui/fonts/manrope-LICENSE.txt new file mode 100644 index 00000000..472064af --- /dev/null +++ b/cmd/odek/ui/fonts/manrope-LICENSE.txt @@ -0,0 +1,93 @@ +Copyright 2018 The Manrope Project Authors (https://github.com/sharanda/manrope) + +This Font Software is licensed under the SIL Open Font License, Version 1.1. +This license is copied below, and is also available with a FAQ at: +http://scripts.sil.org/OFL + + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/cmd/odek/ui/fonts/manrope.ttf b/cmd/odek/ui/fonts/manrope.ttf new file mode 100644 index 00000000..23dcf5e0 Binary files /dev/null and b/cmd/odek/ui/fonts/manrope.ttf differ diff --git a/cmd/odek/ui/index.html b/cmd/odek/ui/index.html index 77a3c045..59b19ea0 100644 --- a/cmd/odek/ui/index.html +++ b/cmd/odek/ui/index.html @@ -6,7 +6,8 @@ odek - + +
@@ -72,6 +73,7 @@
speed
cost
+
@@ -79,13 +81,17 @@ +