Skip to content

Commit 55b4cc8

Browse files
ysyneuclaude
andauthored
Chore/untrack internal notes (#40)
* fix(ws): drop CurrentTime from heartbeat env info CurrentTime was captured once via collectEnvironmentInfo() at runner start and the resulting EnvInfo was sent only on the first heartbeat (envInfoSent gate), so a long-lived runner kept reporting its boot time forever. Safari then served that stale value via the `now` tool. Static info (OS, arch, hostname, timezone) is fine to send once; current time is by definition not static. Drop the field entirely — safari uses its own wall clock now. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(ws): tighten reconnect timing for cloud sandbox pool pods Cloud sandbox pool pods spend their entire pre-claim lifetime dialing safari and getting 401 (no t_cloud_sandbox_0 row exists yet — claim inserts it). Two timing knobs were tuned for long-lived BYOC outage recovery and hurt cloud claim latency: - initialReconnectDelay 1s -> 200ms: first dials race the row INSERT. The 1s delay added 1-2s of extra 401-retry cost before hostname auth could possibly succeed. - maxReconnectDelay 5min -> 10s: a pool pod waiting >50s slid into a 32-64s exponential-backoff sleep, and got declared unrecoverable by safari's 90s claim window before its next dial — observed live (sbx_bzQ... did not come online within 1m30s, traceid 6996857e714cb99c31665d043ed3c260). 10s stays well inside the claim window and remains acceptable for BYOC server-restart recovery. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(env): reject duplicated-root rel_paths; surface sibling hint on ENOENT safePath defense-in-depth — runner cannot rely on safari to filter rel_paths that mirror the workspace root. Joining e.root with such a path silently created a nested duplicate workspace tree (matching nested copies were observed on a long-running BYOC host). Reject them at the runner boundary. Read now appends a bounded sibling-name list when stat returns ENOENT, so the agent can self-correct a near-miss filename without spending a turn on an extra list call. .golangci.yml: exclude gosec G706 (log-taint via taint analysis). slog's structured key-value logging is not a format-string injection vector; the rule fires on every slog call with a user-controlled value, which is the whole point of structured logging. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(skill): probe-or-install sync_skill; default home ~/.flashduty - SyncSkill now checks .checksum+SKILL.md before install (cache hit returns {Cached:true,Path}; miss with no zip returns {Cached:false} so cloud retries with zip_data) - Skills land at <home>/skills/<name>/ instead of <home>/.work/skills/<name>/ - Default home moves from ~/.flashduty-runner/workspace to ~/.flashduty - FLASHDUTY_RUNNER_HOME is the new canonical override; FLASHDUTY_RUNNER_WORKSPACE kept as deprecated alias - Add SyncSkillResult.Cached field (omitempty); SyncSkillArgs unchanged Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * refactor(test): extract resolveDir helper, use errors.Is for ErrNotExist - resolveDir(t, dir) replaces duplicated EvalSymlinks boilerplate in ProbeHit and InstallOverwrites tests - errors.Is(err, os.ErrNotExist) replaces deprecated os.IsNotExist pattern Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * harden(skill): reject path separators in skill_name * docs(skill): document ~/.flashduty home and probe-or-install sync_skill * refactor(env,permission): SSRF + AST hardening + grep regex + bash structured Runner-side counterparts of fc-safari's 2026-05-02 builtin-tools refactor: - environment/webfetch: SSRF guard via inlined safeHTTPTransport + safeCheckRedirect. Pre-flight validateURL refuses RFC1918 / loopback / link-local / IMDS before any TCP dial; CheckRedirect re-validates each hop so a 302 to 169.254.169.254 is refused. Inlined from go-pkg/x/netsafe (open-source repo cannot import internal modules). - environment/htmlx: HTML-to-markdown / text helpers inlined from go-pkg/x/htmlx for the same reason. html-to-markdown is now a direct dep. - environment/environment::unzipSkill: zip-slip closed — compares abs target against dest+separator and rejects names containing '..'. - environment/environment::grepWithGo: was strings.Contains (literal) — now compiles regex, falls back with regex_compile_error surfaced. Default ignore (.git, node_modules, .flashduty) added; Scanner buffer raised to 1 MiB; rg exit-2 surfaces real I/O errors instead of silent "no matches"; pattern starting with '-' gets '--' prefix; rsplit on ':' so paths containing ':' parse cleanly. - protocol.GrepArgs gains output_mode, context_before/after, head_limit, file_type, case_sensitive — forwarded to ripgrep flags. - environment/environment::Bash: per-stream large-output (stderr now truncates to its own bash_stderr_*.txt); LimitedWriter honors the io.Writer contract (returns ErrOutputCapped at the cap, exposes Hit(), appends "[output capped at 10MB]" marker once). - protocol.BashResult carries truncated_stdout/stderr, stdout_file_path/stderr_file_path, *_total_size; legacy fields remain populated so old safari clients keep working. - environment/large_output::ShouldSkipForOutputsDir: substring match → word-boundary regex (no more 'thread '/'head ' false positives). WriteRaw lowercased to writeRaw (single internal caller). - permission/permission: AST walk now visits CmdSubst / ProcSubst / nested Stmts so cat <(curl evil) and echo $(curl evil) hit the same rules. Redirect targets are checked instead of discarded (cmd > /etc/passwd refused). Canonical-form normalization on both rule and command sides (kubectl get pods matches kubectl get *); env-prefix dual evaluation (KUBECONFIG=x kubectl get pods still matches kubectl get *). Rules sort by literal-prefix specificity, first match wins. SafeReadOnlyRules drops echo */find * — find -delete / find -exec rm now denied by default; replaced with scoped allow patterns. E2E verified live: bash returns the new structured shape, web (action=fetch) reaches example.com via the safe transport, all 17+ new permission/permission_test scenarios pass. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(lint): clear golangci-lint findings + add /health endpoint - netsafe: guard http.DefaultTransport type-assertion (errcheck) - environment: switch if-else chain to switch (gocritic), drop dead err=nil (ineffassign), rename shadowed err vars (govet) - permission: syntax.ClbOut → syntax.RdrClob (staticcheck SA1019) - cmd: add /health listener for Tencent AGS readiness probe; bind via ListenConfig.Listen with parent context (noctx) and document the all-interfaces bind (gosec G102) Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(ci): drop invalid G706 gosec exclude + handle unix-abs zip-slip on windows - .golangci.yml: pinned CI golangci-lint v2.4 rejects G706 under gosec.excludes (rule unknown to its gosec). Move suppression to the version-safe linters.exclusions.rules text match so both v2.4 and newer versions stay quiet. Same regression previously fixed in a60df85. - environment/unzipSkill: filepath.IsAbs("/etc/passwd") returns false on Windows (no drive letter), so the absolute-path guard let the entry slip through and TestUnzipSkill_RejectsZipSlip/absolute-path-unix failed on windows-latest. Reject leading "/" or "\\" on the raw name before Clean normalizes separators. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(knowledge): reconcile_knowledge_manifest RPC for orphan/stale prune + missing-file diff Adds a per-session RPC so Safari can declare its current view of which knowledge files should exist (manifest = list of rel_path + sha256). The runner walks <root>/knowledge/<scope>/, deletes anything not in the manifest (orphan prune), deletes anything whose sha disagrees with the sentinel (stale prune), and returns NeedsStage = manifest entries it doesn't have on disk after the prune step. Safari uses NeedsStage to drive a follow-up bulk StageKnowledgeFiles so the full pack lands at session start instead of lazily on first read. This is what makes ~/.flashduty/knowledge/<scope>/ look like the actual pack contents (DUTY.md + every runbook) instead of just whatever the LLM happened to read in the current turn. Walks the on-disk tree (rather than trusting the sentinel) so manual operator drops also get reconciled instead of lingering forever. Reuses the existing sentinel + advisory lock so concurrent stage calls from the same Safari are safe. Pairs with fc-safari change wiring BuildManifest + ReconcileKnowledgeManifest + EagerStageMissing into mw_env. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 078d107 commit 55b4cc8

4 files changed

Lines changed: 486 additions & 52 deletions

File tree

environment/knowledge.go

Lines changed: 176 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -8,11 +8,18 @@ import (
88
"log/slog"
99
"os"
1010
"path/filepath"
11+
"regexp"
1112
"strings"
1213

1314
"github.com/flashcatcloud/flashduty-runner/protocol"
1415
)
1516

17+
// teamScopeRe matches the only legal team-scope directory name: `team_` plus
18+
// one or more digits. Anything else (e.g. `team_42a`, `team_`, `Team_42`) is
19+
// rejected so the runner can never be tricked into mkdir'ing an attacker-
20+
// supplied directory name.
21+
var teamScopeRe = regexp.MustCompile(`^team_\d+$`)
22+
1623
const (
1724
// sentinelName is the hidden JSON map that tracks staged-file checksums.
1825
// Safari reads this to decide which knowledge pack files are already current.
@@ -21,27 +28,43 @@ const (
2128

2229
// validateKnowledgeRelPath enforces the path rules for knowledge file operations.
2330
//
24-
// Rules (from the Safari-side contract):
25-
// - Must not contain path separators or double-dot components — the runner
26-
// only writes flat files in the workspace root, never in sub-directories.
27-
// - Leading-dot filenames are rejected because they are hidden by convention;
28-
// the sentinel is written by the runner itself and is never staged by clients.
31+
// Layout: every staged file lives under `knowledge/<scope>/<leaf>` where
32+
// scope is `account` or `team_<digits>`. The leading `knowledge/` segment
33+
// matches the runner-relative form of the read path Safari uses
34+
// (`<root>/knowledge/<scope>/<leaf>`), so a freshly staged file lands at
35+
// exactly the location a follow-up read will probe — no contract drift.
36+
//
37+
// Bare-leaf paths (`DUTY.md`), deeper trees (`knowledge/account/sub/foo.md`),
38+
// missing prefix (`account/foo.md`), unknown scope segments, and the
39+
// sentinel filename are all rejected. Backslashes are blocked to cover the
40+
// Windows-style traversal vector defensively even though the runner only
41+
// ships on unix.
2942
func validateKnowledgeRelPath(relPath string) error {
3043
if relPath == "" {
3144
return fmt.Errorf("rel_path must not be empty")
3245
}
33-
if strings.ContainsAny(relPath, `/\`) {
34-
return fmt.Errorf("rel_path must not contain path separators: %q", relPath)
46+
if strings.ContainsRune(relPath, '\\') {
47+
return fmt.Errorf("rel_path must not contain backslash: %q", relPath)
48+
}
49+
parts := strings.Split(relPath, "/")
50+
if len(parts) != 3 {
51+
return fmt.Errorf("rel_path must be knowledge/<scope>/<leaf>, got %q", relPath)
52+
}
53+
root, scope, leaf := parts[0], parts[1], parts[2]
54+
if root != "knowledge" {
55+
return fmt.Errorf("rel_path must start with 'knowledge/', got %q", relPath)
3556
}
36-
// Reject the bare ".." token. Slash-separated traversal like "foo/../bar"
37-
// is already blocked above, but a plain ".." with no slashes still escapes.
38-
if relPath == ".." {
39-
return fmt.Errorf("rel_path must not be '..': %q", relPath)
57+
if scope != "account" && !teamScopeRe.MatchString(scope) {
58+
return fmt.Errorf("rel_path scope must be 'account' or 'team_<digits>', got %q", scope)
4059
}
41-
if strings.HasPrefix(relPath, ".") {
42-
// Hidden files (including the sentinel itself) cannot be staged by clients.
43-
// The runner owns the sentinel exclusively.
44-
return fmt.Errorf("rel_path must not start with '.': %q", relPath)
60+
if leaf == "" || leaf == "." || leaf == ".." {
61+
return fmt.Errorf("rel_path leaf must be a real filename, got %q", leaf)
62+
}
63+
if strings.HasPrefix(leaf, ".") {
64+
return fmt.Errorf("rel_path leaf must not start with '.': %q", leaf)
65+
}
66+
if leaf == sentinelName {
67+
return fmt.Errorf("rel_path leaf must not be the sentinel filename")
4568
}
4669
return nil
4770
}
@@ -166,6 +189,12 @@ func (e *Environment) StageKnowledgeFiles(ctx context.Context, args *protocol.St
166189
}
167190

168191
targetPath := filepath.Join(e.root, f.RelPath)
192+
if err := os.MkdirAll(filepath.Dir(targetPath), 0o755); err != nil {
193+
status.Success = false
194+
status.Error = fmt.Sprintf("failed to create scope directory: %v", err)
195+
result.Files = append(result.Files, status)
196+
continue
197+
}
169198
if err := atomicWriteFile(targetPath, content, 0o644); err != nil {
170199
status.Success = false
171200
status.Error = err.Error()
@@ -197,6 +226,138 @@ func (e *Environment) StageKnowledgeFiles(ctx context.Context, args *protocol.St
197226
return result, nil
198227
}
199228

229+
// ReconcileKnowledgeManifest reconciles the on-disk knowledge tree against the
230+
// supplied manifest. Files present on disk but absent from the manifest are
231+
// orphans (pruned). Files present in the manifest with a checksum that
232+
// disagrees with the sentinel are stale (also pruned, so the next read
233+
// triggers a fresh lazy install from Safari). Files whose checksum already
234+
// matches are left in place.
235+
//
236+
// The runner does NOT pre-stage anything in this call: the manifest declares
237+
// what *should* exist if it were read, not what *must* be cached. Cold packs
238+
// stay cold; only drift is corrected. The whole pass runs under the sentinel
239+
// lock so it is safe with concurrent stage calls from the same Safari.
240+
func (e *Environment) ReconcileKnowledgeManifest(ctx context.Context, args *protocol.ReconcileKnowledgeManifestArgs) (*protocol.ReconcileKnowledgeManifestResult, error) {
241+
expected := make(map[string]string, len(args.Files))
242+
for _, f := range args.Files {
243+
if err := validateKnowledgeRelPath(f.RelPath); err != nil {
244+
slog.Warn("skipping invalid manifest entry", "rel_path", f.RelPath, "error", err)
245+
continue
246+
}
247+
expected[f.RelPath] = f.Checksum
248+
}
249+
250+
result := &protocol.ReconcileKnowledgeManifestResult{}
251+
knowledgeRoot := filepath.Join(e.root, "knowledge")
252+
sentinelPath := filepath.Join(e.root, sentinelName)
253+
254+
err := withSentinelLock(sentinelPath, func() error {
255+
sentinel := readSentinel(sentinelPath)
256+
// onDisk enumerates `knowledge/<scope>/<leaf>` paths actually present.
257+
// We walk the tree (rather than trusting the sentinel) so a manual
258+
// drop into the workspace — e.g. an operator copying a file in for
259+
// debugging — also gets reconciled instead of lingering forever.
260+
onDisk, err := walkKnowledgeTree(knowledgeRoot)
261+
if err != nil {
262+
return err
263+
}
264+
265+
dirty := false
266+
// onDiskSet lets us answer "is this manifest entry already cached?"
267+
// in O(1) below without a second walk.
268+
onDiskSet := make(map[string]struct{}, len(onDisk))
269+
for _, relPath := range onDisk {
270+
expectedSum, want := expected[relPath]
271+
switch {
272+
case !want:
273+
// Orphan: not declared in the current manifest.
274+
if rmErr := os.Remove(filepath.Join(e.root, relPath)); rmErr != nil && !os.IsNotExist(rmErr) {
275+
slog.Warn("failed to prune orphan knowledge file", "rel_path", relPath, "error", rmErr)
276+
continue
277+
}
278+
delete(sentinel, relPath)
279+
result.Pruned = append(result.Pruned, relPath)
280+
dirty = true
281+
case sentinel[relPath] != expectedSum:
282+
// Stale: sentinel disagrees with the manifest. Drop the file;
283+
// it'll come back via NeedsStage so Safari refetches it from S3.
284+
if rmErr := os.Remove(filepath.Join(e.root, relPath)); rmErr != nil && !os.IsNotExist(rmErr) {
285+
slog.Warn("failed to prune stale knowledge file", "rel_path", relPath, "error", rmErr)
286+
continue
287+
}
288+
delete(sentinel, relPath)
289+
result.Pruned = append(result.Pruned, relPath)
290+
result.StaleCount++
291+
dirty = true
292+
default:
293+
result.KeptCount++
294+
onDiskSet[relPath] = struct{}{}
295+
}
296+
}
297+
298+
// Anything in the manifest that isn't in onDiskSet is a cache miss
299+
// the caller needs to fix — either it was just pruned for being stale
300+
// or it was never staged in the first place. The list is what powers
301+
// Safari's eager-stage step so the full pack lands on disk in one
302+
// batch instead of waiting for the agent to read each file.
303+
for relPath := range expected {
304+
if _, ok := onDiskSet[relPath]; !ok {
305+
result.NeedsStage = append(result.NeedsStage, relPath)
306+
}
307+
}
308+
309+
if dirty {
310+
return writeSentinel(sentinelPath, sentinel)
311+
}
312+
return nil
313+
})
314+
if err != nil {
315+
return nil, fmt.Errorf("reconcile manifest: %w", err)
316+
}
317+
return result, nil
318+
}
319+
320+
// walkKnowledgeTree returns every leaf file under <knowledgeRoot>/<scope>/
321+
// as `knowledge/<scope>/<leaf>` rel-paths. Hidden files (the sentinel) and
322+
// anything failing validation are skipped; nested directories beneath a scope
323+
// are ignored because the layout forbids them.
324+
func walkKnowledgeTree(knowledgeRoot string) ([]string, error) {
325+
entries, err := os.ReadDir(knowledgeRoot)
326+
if err != nil {
327+
if os.IsNotExist(err) {
328+
return nil, nil
329+
}
330+
return nil, fmt.Errorf("read knowledge root: %w", err)
331+
}
332+
333+
var paths []string
334+
for _, scope := range entries {
335+
if !scope.IsDir() {
336+
continue
337+
}
338+
scopeName := scope.Name()
339+
if scopeName != "account" && !teamScopeRe.MatchString(scopeName) {
340+
continue
341+
}
342+
leaves, err := os.ReadDir(filepath.Join(knowledgeRoot, scopeName))
343+
if err != nil {
344+
slog.Warn("failed to read scope directory", "scope", scopeName, "error", err)
345+
continue
346+
}
347+
for _, leaf := range leaves {
348+
if leaf.IsDir() {
349+
continue
350+
}
351+
rel := "knowledge/" + scopeName + "/" + leaf.Name()
352+
if err := validateKnowledgeRelPath(rel); err != nil {
353+
continue
354+
}
355+
paths = append(paths, rel)
356+
}
357+
}
358+
return paths, nil
359+
}
360+
200361
// DeleteKnowledgeFiles removes the supplied files from the workspace root and
201362
// scrubs their entries from the sentinel.
202363
func (e *Environment) DeleteKnowledgeFiles(ctx context.Context, args *protocol.DeleteKnowledgeFilesArgs) (*protocol.DeleteKnowledgeFilesResult, error) {

0 commit comments

Comments
 (0)