Skip to content
Merged
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
10 changes: 10 additions & 0 deletions .golangci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,16 @@ linters:
- G301
- G304
- G306
exclusions:
rules:
# G706 (log injection via taint) is added in newer gosec versions and
# not in our pinned CI golangci-lint v2.4 — listing it under
# gosec.excludes fails `config verify` there. Match by text instead so
# newer versions also stay quiet: slog's structured key/value logging
# is not a format-string injection vector.
- linters:
- gosec
text: "G706"

formatters:
enable:
Expand Down
13 changes: 11 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -203,7 +203,7 @@ Create `/etc/flashduty-runner/env`:
```bash
FLASHDUTY_RUNNER_TOKEN=ent_xxx
# FLASHDUTY_RUNNER_URL=wss://custom.example.com/safari/environment/ws
# FLASHDUTY_RUNNER_WORKSPACE=/var/flashduty/workspace
# FLASHDUTY_RUNNER_HOME=/var/flashduty
```

```bash
Expand All @@ -213,6 +213,15 @@ sudo systemctl daemon-reload
sudo systemctl enable --now flashduty-runner
```

## Skills

Skills are materialized lazily. When the agent calls the `skill` tool, the cloud sends `sync_skill` with `(skill_name, checksum, zip_data?)`:

- **Empty `zip_data`** = probe. The runner returns `{cached: true, path}` if `<home>/skills/<name>/.checksum` already matches the requested checksum and `SKILL.md` is present; otherwise `{cached: false}` and the cloud will retry with the bundle attached.
- **Non-empty `zip_data`** = install. The runner wipes `<home>/skills/<name>/`, unzips, and writes `.checksum`. Each skill name has exactly one slot — no version retention. Builtin skills go through the same flow with their bundle sourced from the cloud's embedded FS instead of S3.

Inspect installed skills with `ls ~/.flashduty/skills/`.

## Configuration Reference

Configuration is via command-line flags or environment variables (flags take precedence).
Expand All @@ -221,7 +230,7 @@ Configuration is via command-line flags or environment variables (flags take pre
|------|-------------|----------|---------|-------------|
| `--token` | `FLASHDUTY_RUNNER_TOKEN` | Yes | - | Authentication token |
| `--url` | `FLASHDUTY_RUNNER_URL` | No | `wss://api.flashcat.cloud/safari/environment/ws` | WebSocket endpoint |
| `--workspace` | `FLASHDUTY_RUNNER_WORKSPACE` | No | `~/.flashduty-runner/workspace` | Workspace root directory |
| `--workspace` | `FLASHDUTY_RUNNER_HOME` | No | `~/.flashduty` | Runner home directory. Skills land at `<home>/skills/<name>/`. `FLASHDUTY_RUNNER_WORKSPACE` is accepted as a deprecated alias for back-compat. |
| `--log-level` | `FLASHDUTY_RUNNER_LOG_LEVEL` | No | `info` | Log level: debug, info, warn, error |

## Troubleshooting
Expand Down
48 changes: 44 additions & 4 deletions cmd/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@ import (
"context"
"fmt"
"log/slog"
"net"
"net/http"
"os"
"os/signal"
"path/filepath"
Expand Down Expand Up @@ -38,6 +40,11 @@ var (
const (
defaultURL = "wss://api.flashcat.cloud/safari/environment/ws"
defaultLogLevel = "info"
// healthAddr is bound by the runner so cloud sandbox platforms (e.g. Tencent
// AGS) can satisfy their HTTP readiness probe — the runner only opens
// outbound WebSockets otherwise, leaving no inbound port for the platform
// to probe.
healthAddr = ":49983"
)

func main() {
Expand Down Expand Up @@ -79,7 +86,8 @@ Examples:
Environment variables:
FLASHDUTY_RUNNER_TOKEN - Authentication token (required if --token not provided)
FLASHDUTY_RUNNER_URL - WebSocket endpoint URL
FLASHDUTY_RUNNER_WORKSPACE - Workspace root directory`,
FLASHDUTY_RUNNER_HOME - Home directory for skills and data (default: ~/.flashduty)
FLASHDUTY_RUNNER_WORKSPACE - Deprecated alias for FLASHDUTY_RUNNER_HOME`,
RunE: func(cmd *cobra.Command, args []string) error {
return runRunner()
},
Expand Down Expand Up @@ -137,17 +145,20 @@ func loadConfig() (*Config, error) {
cfg.URL = defaultURL
}

// Workspace: flag > env > default
// Home directory (skills, future knowledge/output): flag > FLASHDUTY_RUNNER_HOME > deprecated FLASHDUTY_RUNNER_WORKSPACE > default ~/.flashduty
cfg.WorkspaceRoot = flagWorkspace
if cfg.WorkspaceRoot == "" {
cfg.WorkspaceRoot = os.Getenv("FLASHDUTY_RUNNER_WORKSPACE")
cfg.WorkspaceRoot = os.Getenv("FLASHDUTY_RUNNER_HOME")
}
if cfg.WorkspaceRoot == "" {
cfg.WorkspaceRoot = os.Getenv("FLASHDUTY_RUNNER_WORKSPACE") // deprecated alias
}
if cfg.WorkspaceRoot == "" {
homeDir, err := os.UserHomeDir()
if err != nil {
return nil, fmt.Errorf("failed to get home directory: %w", err)
}
cfg.WorkspaceRoot = filepath.Join(homeDir, ".flashduty-runner", "workspace")
cfg.WorkspaceRoot = filepath.Join(homeDir, ".flashduty")
}

// Log level: flag > env > default
Expand Down Expand Up @@ -214,6 +225,8 @@ func runRunner() error {
ctx, cancel := context.WithCancel(context.Background())
defer cancel()

startHealthServer(ctx)

sigCh := make(chan os.Signal, 1)
signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM)

Expand Down Expand Up @@ -274,6 +287,33 @@ func setupLogging(levelStr string) {
slog.SetDefault(slog.New(handler))
}

// startHealthServer binds a tiny HTTP listener that always answers /health 200.
// Required by Tencent AGS (and similar serverless sandbox platforms) which gate
// instance start on an HTTP readiness probe within ~30s.
func startHealthServer(ctx context.Context) {
// AGS readiness probes hit the pod from outside, so binding to all
// interfaces is required — the listener only serves a fixed 200 on /health.
var lc net.ListenConfig
ln, err := lc.Listen(ctx, "tcp", healthAddr) // #nosec G102 -- intentional: external readiness probe

if err != nil {
slog.Warn("health server skipped (port already in use)", "addr", healthAddr, "err", err)
return
}
mux := http.NewServeMux()
mux.HandleFunc("/health", func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte("ok"))
})
srv := &http.Server{Handler: mux, ReadHeaderTimeout: 5 * time.Second}
go func() {
if err := srv.Serve(ln); err != nil && err != http.ErrServerClosed {
slog.Warn("health server stopped", "err", err)
}
}()
slog.Info("health server listening", "addr", healthAddr)
}

func parseLogLevel(levelStr string) slog.Level {
switch levelStr {
case "debug":
Expand Down
114 changes: 114 additions & 0 deletions environment/bash_output_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
package environment

import (
"bytes"
"context"
"errors"
"strings"
"testing"

"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"

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

func TestLimitedWriter_HitReturnsErrShortWrite(t *testing.T) {
var buf bytes.Buffer
w := &LimitedWriter{W: &buf, Limit: 5}

// First write fits exactly within the cap.
n, err := w.Write([]byte("hello"))
require.NoError(t, err)
assert.Equal(t, 5, n)
assert.False(t, w.Hit(), "cap not yet hit when curr==Limit and next write hasn't happened")

// Second write hits the cap. Contract: report ErrOutputCapped, not nil.
n, err = w.Write([]byte("world"))
assert.Equal(t, 0, n, "no bytes accepted past the cap")
require.Error(t, err)
assert.True(t, errors.Is(err, ErrOutputCapped))
assert.True(t, w.Hit())
assert.Contains(t, buf.String(), "[output capped at 10MB]", "marker appended exactly once")

// Subsequent writes also report cap, marker not duplicated.
prev := buf.String()
_, err = w.Write([]byte("more"))
assert.True(t, errors.Is(err, ErrOutputCapped))
assert.Equal(t, prev, buf.String(), "marker only appended once")
}

func TestLimitedWriter_PartialWriteAtBoundary(t *testing.T) {
var buf bytes.Buffer
w := &LimitedWriter{W: &buf, Limit: 3}

// 5-byte write straddles the boundary: 3 bytes accepted, marker appended,
// ErrOutputCapped surfaced.
n, err := w.Write([]byte("abcde"))
assert.Equal(t, 3, n)
require.Error(t, err)
assert.True(t, errors.Is(err, ErrOutputCapped))
assert.True(t, w.Hit())
assert.True(t, strings.HasPrefix(buf.String(), "abc"))
assert.Contains(t, buf.String(), "[output capped at 10MB]")
}

func TestLimitedWriter_BelowCap(t *testing.T) {
var buf bytes.Buffer
w := &LimitedWriter{W: &buf, Limit: 100}

n, err := w.Write([]byte("hi"))
require.NoError(t, err)
assert.Equal(t, 2, n)
assert.False(t, w.Hit())
assert.NotContains(t, buf.String(), "capped")
}

func TestEnvironment_Bash_StderrTruncatedSeparately(t *testing.T) {
ws := newTestEnvironment(t)
ctx := context.Background()

// Generate >30k bytes to stderr, nothing on stdout. Using awk so the
// subprocess exits cleanly once the byte budget is met (no SIGPIPE chain).
cmd := `awk 'BEGIN{ for(i=0;i<5000;i++) printf("ERROR_LINE_%d\n", i) > "/dev/stderr" }'`
result, err := ws.Bash(ctx, &protocol.BashArgs{Command: cmd})
require.NoError(t, err)

assert.True(t, result.TruncatedStderr, "stderr should be truncated when over the cap")
assert.NotEmpty(t, result.StderrFilePath, "stderr file should be written for full content")
assert.Greater(t, result.StderrTotalSize, int64(30000))
assert.False(t, result.TruncatedStdout, "stdout should be untruncated when empty")
assert.Empty(t, result.StdoutFilePath)
}

func TestEnvironment_Bash_NormalOutputUnchanged(t *testing.T) {
ws := newTestEnvironment(t)
ctx := context.Background()

result, err := ws.Bash(ctx, &protocol.BashArgs{Command: "echo small"})
require.NoError(t, err)
assert.Equal(t, "small\n", result.Stdout)
assert.False(t, result.TruncatedStdout)
assert.False(t, result.TruncatedStderr)
assert.Empty(t, result.StdoutFilePath)
assert.Empty(t, result.StderrFilePath)
}

func TestEnvironment_Bash_StdoutAtHardCapMarker(t *testing.T) {
ws := newTestEnvironment(t)
ctx := context.Background()

// Push past the LimitedWriter's 10MB cap. dd writes a fixed budget then
// exits, so we don't depend on SIGPIPE propagation to terminate `yes`.
cmd := "dd if=/dev/zero bs=1048576 count=11 2>/dev/null"
result, err := ws.Bash(ctx, &protocol.BashArgs{Command: cmd})
require.NoError(t, err)

assert.True(t, result.TruncatedStdout, "10MB cap must have been hit")
// The cap marker is appended to the captured buffer; after
// processLargeOutput it lives in the spilled file (the in-context preview
// only carries the first ~20 lines).
if !strings.Contains(result.Stdout, "[output capped") {
assert.NotEmpty(t, result.StdoutFilePath, "spilled file must exist when cap was hit")
}
}
Loading
Loading