Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 39 additions & 3 deletions protocol/messages.go
Original file line number Diff line number Diff line change
Expand Up @@ -108,9 +108,11 @@ const (
TaskOpGrep TaskOperation = "grep"
TaskOpBash TaskOperation = "bash"
TaskOpWebFetch TaskOperation = "webfetch"
TaskOpMCPCall TaskOperation = "mcp_call"
TaskOpMCPListTools TaskOperation = "mcp_list_tools"
TaskOpSyncSkill TaskOperation = "sync_skill"
TaskOpMCPCall TaskOperation = "mcp_call"
TaskOpMCPListTools TaskOperation = "mcp_list_tools"
TaskOpSyncSkill TaskOperation = "sync_skill"
TaskOpStageKnowledgeFiles TaskOperation = "stage_knowledge_files"
TaskOpDeleteKnowledgeFiles TaskOperation = "delete_knowledge_files"
)

// TaskRequestPayload is the payload for task request messages.
Expand Down Expand Up @@ -323,3 +325,37 @@ type MCPResultPayload struct {
Result json.RawMessage `json:"result,omitempty"`
Error string `json:"error,omitempty"`
}

// KnowledgeFile is a single file entry in a stage_knowledge_files request.
type KnowledgeFile struct {
RelPath string `json:"rel_path"`
Checksum string `json:"checksum"`
ContentB64 string `json:"content_b64"`
}

// StageKnowledgeFilesArgs are the arguments for stage_knowledge_files operation.
type StageKnowledgeFilesArgs struct {
Files []KnowledgeFile `json:"files"`
}

// KnowledgeFileStatus is the per-file result entry in the stage ack.
type KnowledgeFileStatus struct {
RelPath string `json:"rel_path"`
Success bool `json:"success"`
Error string `json:"error,omitempty"`
}

// StageKnowledgeFilesResult is the result of a stage_knowledge_files operation.
type StageKnowledgeFilesResult struct {
Files []KnowledgeFileStatus `json:"files"`
}

// DeleteKnowledgeFilesArgs are the arguments for delete_knowledge_files operation.
type DeleteKnowledgeFilesArgs struct {
RelPaths []string `json:"rel_paths"`
}

// DeleteKnowledgeFilesResult is the result of a delete_knowledge_files operation.
type DeleteKnowledgeFilesResult struct {
Success bool `json:"success"`
}
249 changes: 249 additions & 0 deletions workspace/knowledge.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,249 @@
package workspace

import (
"context"
"encoding/base64"
"encoding/json"
"fmt"
"log/slog"
"os"
"path/filepath"
"strings"
"syscall"

"github.com/flashcatcloud/flashduty-runner/protocol"
)

const (
// sentinelName is the hidden JSON map that tracks staged-file checksums.
// Safari reads this to decide which knowledge pack files are already current.
sentinelName = ".safari-knowledge-sentinel.json"
)

// validateKnowledgeRelPath enforces the path rules for knowledge file operations.
//
// Rules (from the Safari-side contract):
// - Must not contain path separators or double-dot components — the runner
// only writes flat files in the workspace root, never in sub-directories.
// - Leading-dot filenames are rejected because they are hidden by convention;
// the sentinel is written by the runner itself and is never staged by clients.
func validateKnowledgeRelPath(relPath string) error {
if relPath == "" {
return fmt.Errorf("rel_path must not be empty")
}
if strings.ContainsAny(relPath, `/\`) {
return fmt.Errorf("rel_path must not contain path separators: %q", relPath)
}
// Reject the bare ".." token. Slash-separated traversal like "foo/../bar"
// is already blocked above, but a plain ".." with no slashes still escapes.
if relPath == ".." {
return fmt.Errorf("rel_path must not be '..': %q", relPath)
}
if strings.HasPrefix(relPath, ".") {
// Hidden files (including the sentinel itself) cannot be staged by clients.
// The runner owns the sentinel exclusively.
return fmt.Errorf("rel_path must not start with '.': %q", relPath)
}
return nil
}

// atomicWriteFile writes data to path using a temp-file + rename so readers
// never see a partially-written file. The temp file is created in the same
// directory as the target to guarantee the rename stays on the same filesystem.
func atomicWriteFile(path string, data []byte, perm os.FileMode) error {
dir := filepath.Dir(path)
tmp, err := os.CreateTemp(dir, ".tmp-knowledge-*")
if err != nil {
return fmt.Errorf("failed to create temp file: %w", err)
}
tmpName := tmp.Name()

// Clean up the temp file on any error path.
var writeErr error
defer func() {
if writeErr != nil {
_ = os.Remove(tmpName)
}
}()

if _, writeErr = tmp.Write(data); writeErr != nil {
_ = tmp.Close()
return fmt.Errorf("failed to write temp file: %w", writeErr)
}
if writeErr = tmp.Chmod(perm); writeErr != nil {
_ = tmp.Close()
return fmt.Errorf("failed to chmod temp file: %w", writeErr)
}
if writeErr = tmp.Close(); writeErr != nil {
return fmt.Errorf("failed to close temp file: %w", writeErr)
}

if writeErr = os.Rename(tmpName, path); writeErr != nil {
return fmt.Errorf("failed to rename temp file: %w", writeErr)
}
return nil
}

// withSentinelLock opens (or creates) the sentinel file, acquires an exclusive
// advisory flock on it, calls fn, then releases the lock. The advisory lock
// protects concurrent read-modify-write cycles across BYOC sessions that share
// the same filesystem (e.g. multiple Safari instances writing to the same
// worknode workspace root).
//
// Note: syscall.Flock is available on Linux and macOS. The runner is deployed
// on those platforms only; Windows is not supported today.
func withSentinelLock(sentinelPath string, fn func() error) error {
// Open or create the sentinel file just to acquire the lock fd.
// We do NOT read/write through this fd to keep flock + atomic-write
// concerns separate.
lockFile, err := os.OpenFile(sentinelPath, os.O_RDWR|os.O_CREATE, 0o600)
if err != nil {
return fmt.Errorf("failed to open sentinel for locking: %w", err)
}
defer func() {
_ = lockFile.Close()
}()

fd := int(lockFile.Fd()) //nolint:gosec // os.File.Fd returns uintptr but the underlying OS fd is always a valid int on unix
if err := syscall.Flock(fd, syscall.LOCK_EX); err != nil {

Check failure on line 108 in workspace/knowledge.go

View workflow job for this annotation

GitHub Actions / build (windows-latest)

undefined: syscall.LOCK_EX

Check failure on line 108 in workspace/knowledge.go

View workflow job for this annotation

GitHub Actions / build (windows-latest)

undefined: syscall.Flock

Check failure on line 108 in workspace/knowledge.go

View workflow job for this annotation

GitHub Actions / build (windows-latest)

undefined: syscall.LOCK_EX

Check failure on line 108 in workspace/knowledge.go

View workflow job for this annotation

GitHub Actions / build (windows-latest)

undefined: syscall.Flock
return fmt.Errorf("failed to acquire sentinel lock: %w", err)
}
defer func() {
_ = syscall.Flock(fd, syscall.LOCK_UN)

Check failure on line 112 in workspace/knowledge.go

View workflow job for this annotation

GitHub Actions / build (windows-latest)

undefined: syscall.LOCK_UN

Check failure on line 112 in workspace/knowledge.go

View workflow job for this annotation

GitHub Actions / build (windows-latest)

undefined: syscall.Flock

Check failure on line 112 in workspace/knowledge.go

View workflow job for this annotation

GitHub Actions / build (windows-latest)

undefined: syscall.LOCK_UN

Check failure on line 112 in workspace/knowledge.go

View workflow job for this annotation

GitHub Actions / build (windows-latest)

undefined: syscall.Flock
}()

return fn()
}

// readSentinel reads the sentinel JSON map. Missing file or empty file →
// empty map (both are expected states on first use). Corrupt JSON → log a
// warning, return empty map (safe rebuild on next stage).
func readSentinel(sentinelPath string) map[string]string {
data, err := os.ReadFile(sentinelPath)
if err != nil {
if !os.IsNotExist(err) {
slog.Warn("failed to read sentinel, treating as empty", "error", err)
}
return make(map[string]string)
}
// An empty file is the initial state created by withSentinelLock; treat it
// as an empty map without logging a warning.
if len(data) == 0 {
return make(map[string]string)
}
var m map[string]string
if err := json.Unmarshal(data, &m); err != nil {
slog.Warn("sentinel JSON corrupt, treating as empty", "error", err)
return make(map[string]string)
}
return m
}

// writeSentinel atomically rewrites the sentinel with the given map.
func writeSentinel(sentinelPath string, m map[string]string) error {
data, err := json.Marshal(m)
if err != nil {
return fmt.Errorf("failed to marshal sentinel: %w", err)
}
return atomicWriteFile(sentinelPath, data, 0o644)
}

// StageKnowledgeFiles writes the supplied files into the workspace root and
// updates the sentinel checksum map.
func (w *Workspace) StageKnowledgeFiles(ctx context.Context, args *protocol.StageKnowledgeFilesArgs) (*protocol.StageKnowledgeFilesResult, error) {
result := &protocol.StageKnowledgeFilesResult{
Files: make([]protocol.KnowledgeFileStatus, 0, len(args.Files)),
}

// validated collects (relPath, checksum) for files that landed successfully;
// we only merge these into the sentinel.
type staged struct{ relPath, checksum string }
var succeeded []staged

for _, f := range args.Files {
status := protocol.KnowledgeFileStatus{RelPath: f.RelPath}

if err := validateKnowledgeRelPath(f.RelPath); err != nil {
status.Success = false
status.Error = err.Error()
result.Files = append(result.Files, status)
continue
}

content, err := base64.StdEncoding.DecodeString(f.ContentB64)
if err != nil {
status.Success = false
status.Error = fmt.Sprintf("failed to decode content_b64: %v", err)
result.Files = append(result.Files, status)
continue
}

targetPath := filepath.Join(w.root, f.RelPath)
if err := atomicWriteFile(targetPath, content, 0o644); err != nil {
status.Success = false
status.Error = err.Error()
result.Files = append(result.Files, status)
continue
}

status.Success = true
result.Files = append(result.Files, status)
succeeded = append(succeeded, staged{f.RelPath, f.Checksum})
}

// Update sentinel for successfully written files under advisory lock.
if len(succeeded) > 0 {
sentinelPath := filepath.Join(w.root, sentinelName)
if err := withSentinelLock(sentinelPath, func() error {
m := readSentinel(sentinelPath)
for _, s := range succeeded {
m[s.relPath] = s.checksum
}
return writeSentinel(sentinelPath, m)
}); err != nil {
// Sentinel write failure is non-fatal for the already-written files,
// but we log it clearly so operators can investigate.
slog.Error("failed to update sentinel after staging", "error", err)
}
}

return result, nil
}

// DeleteKnowledgeFiles removes the supplied files from the workspace root and
// scrubs their entries from the sentinel.
func (w *Workspace) DeleteKnowledgeFiles(ctx context.Context, args *protocol.DeleteKnowledgeFilesArgs) (*protocol.DeleteKnowledgeFilesResult, error) {
// toRemove collects validated rel_paths so we can clean the sentinel in one
// locked pass after the file removals.
toRemove := make(map[string]struct{}, len(args.RelPaths))

for _, relPath := range args.RelPaths {
if err := validateKnowledgeRelPath(relPath); err != nil {
slog.Warn("skipping invalid rel_path in delete_knowledge_files", "rel_path", relPath, "error", err)
continue
}
toRemove[relPath] = struct{}{}

targetPath := filepath.Join(w.root, relPath)
if err := os.Remove(targetPath); err != nil && !os.IsNotExist(err) {
slog.Warn("failed to remove knowledge file", "rel_path", relPath, "error", err)
}
}

// Remove entries from sentinel for all valid paths (idempotent — missing
// sentinel entries are simply no-ops in the delete loop).
if len(toRemove) > 0 {
sentinelPath := filepath.Join(w.root, sentinelName)
if err := withSentinelLock(sentinelPath, func() error {
m := readSentinel(sentinelPath)
for rp := range toRemove {
delete(m, rp)
}
return writeSentinel(sentinelPath, m)
}); err != nil {
slog.Error("failed to update sentinel after deletion", "error", err)
}
}

return &protocol.DeleteKnowledgeFilesResult{Success: true}, nil
}
Loading
Loading