diff --git a/deploy/pi-agent/Dockerfile b/deploy/pi-agent/Dockerfile index b3638fad..7700e599 100644 --- a/deploy/pi-agent/Dockerfile +++ b/deploy/pi-agent/Dockerfile @@ -22,9 +22,10 @@ RUN pi --version WORKDIR /app -# 复制 runner、扩展工具以及入口脚本 +# 复制 runner、其相对 import 运行时模块、扩展工具以及入口脚本 COPY deploy/pi-agent/extensions/ /app/extensions/ COPY deploy/pi-agent/runner.mjs /app/runner.mjs +COPY deploy/pi-agent/settle-policy.mjs /app/settle-policy.mjs COPY deploy/pi-agent/entrypoint.sh /app/entrypoint.sh RUN chmod +x /app/entrypoint.sh /app/runner.mjs diff --git a/internal/agent/pi_runner_image_test.go b/internal/agent/pi_runner_image_test.go new file mode 100644 index 00000000..1666135c --- /dev/null +++ b/internal/agent/pi_runner_image_test.go @@ -0,0 +1,195 @@ +package agent + +import ( + "os" + "os/exec" + "path/filepath" + "regexp" + "runtime" + "strings" + "testing" + "time" +) + +func piAgentRepoRoot(t *testing.T) string { + t.Helper() + _, file, _, ok := runtime.Caller(0) + if !ok { + t.Fatal("runtime.Caller") + } + return filepath.Clean(filepath.Join(filepath.Dir(file), "..", "..")) +} + +func TestPiAgentDockerfileCopiesRunnerRelativeImports(t *testing.T) { + root := piAgentRepoRoot(t) + runnerRel := filepath.ToSlash("deploy/pi-agent/runner.mjs") + df, err := os.ReadFile(filepath.Join(root, "deploy", "pi-agent", "Dockerfile")) + if err != nil { + t.Fatal(err) + } + copied := dockerCopyDestinations(string(df)) + if len(copied) == 0 { + t.Fatal("deploy/pi-agent/Dockerfile has no COPY lines") + } + + seen := map[string]bool{} + var missing []string + var walk func(repoRel string) + walk = func(repoRel string) { + repoRel = filepath.ToSlash(repoRel) + if seen[repoRel] { + return + } + seen[repoRel] = true + abs := filepath.Join(root, filepath.FromSlash(repoRel)) + body, err := os.ReadFile(abs) + if err != nil { + t.Fatalf("read %s: %v", repoRel, err) + } + dir := filepath.ToSlash(filepath.Dir(repoRel)) + for _, spec := range relativeModuleSpecs(string(body)) { + child := filepath.ToSlash(filepath.Clean(dir + "/" + strings.TrimPrefix(spec, "./"))) + imageDest := runnerImportImagePath(child) + if !dockerCopyCovers(copied, imageDest) { + missing = append(missing, spec+" -> "+imageDest+" (from "+repoRel+")") + } + walk(child) + } + } + walk(runnerRel) + if len(missing) > 0 { + t.Fatalf("pi-agent image COPY set is missing runner runtime modules:\n %s", strings.Join(missing, "\n ")) + } + if !dockerCopyCovers(copied, "/app/settle-policy.mjs") { + t.Fatal("deploy/pi-agent/Dockerfile must COPY settle-policy.mjs to /app/settle-policy.mjs") + } +} + +func TestPiAgentDockerBuildContext_RunnerModulesPresent(t *testing.T) { + if _, err := exec.LookPath("docker"); err != nil { + t.Skip("docker CLI not available") + } + root := piAgentRepoRoot(t) + dfBody, err := os.ReadFile(filepath.Join(root, "deploy", "pi-agent", "Dockerfile")) + if err != nil { + t.Fatal(err) + } + var copies []string + for _, raw := range strings.Split(string(dfBody), "\n") { + line := strings.TrimSpace(raw) + if strings.HasPrefix(line, "COPY deploy/pi-agent/") && !strings.Contains(line, "--from=") { + copies = append(copies, line) + } + } + if len(copies) == 0 { + t.Fatal("no deploy/pi-agent COPY lines to replay") + } + slim := "FROM debian:bookworm-slim\nWORKDIR /app\n" + strings.Join(copies, "\n") + "\n" + df := filepath.Join(t.TempDir(), "Dockerfile") + if err := os.WriteFile(df, []byte(slim), 0o644); err != nil { + t.Fatal(err) + } + tag := "agent-system-pi-runner-modules-smoke:local" + build := exec.Command("docker", "build", "-f", df, "-t", tag, ".") + build.Dir = root + out, err := runCmdTimed(build, 3*time.Minute) + if err != nil { + t.Fatalf("docker build context failed: %v\n%s", err, out) + } + t.Cleanup(func() { + _ = exec.Command("docker", "rmi", "-f", tag).Run() + }) + for _, path := range []string{"/app/runner.mjs", "/app/settle-policy.mjs", "/app/extensions/tavily-search.mjs"} { + cat := exec.Command("docker", "run", "--rm", "--entrypoint", "test", tag, "-f", path) + if body, err := runCmdTimed(cat, 30*time.Second); err != nil { + t.Fatalf("image missing %s: %v\n%s", path, err, body) + } + } +} + +func runCmdTimed(cmd *exec.Cmd, d time.Duration) ([]byte, error) { + var buf strings.Builder + cmd.Stdout = &buf + cmd.Stderr = &buf + if err := cmd.Start(); err != nil { + return []byte(buf.String()), err + } + done := make(chan error, 1) + go func() { done <- cmd.Wait() }() + select { + case err := <-done: + return []byte(buf.String()), err + case <-time.After(d): + _ = cmd.Process.Kill() + return []byte(buf.String()), os.ErrDeadlineExceeded + } +} + +var relativeFromRe = regexp.MustCompile(`(?m)from\s+["'](\./[^"']+)["']`) + +func relativeModuleSpecs(src string) []string { + var out []string + for _, m := range relativeFromRe.FindAllStringSubmatch(src, -1) { + if len(m) > 1 { + out = append(out, m[1]) + } + } + return out +} + +func runnerImportImagePath(repoRel string) string { + const prefix = "deploy/pi-agent/" + rel := filepath.ToSlash(repoRel) + if strings.HasPrefix(rel, prefix) { + return "/app/" + strings.TrimPrefix(rel, prefix) + } + return "/app/" + filepath.Base(rel) +} + +type dockerCopy struct { + src string + dest string +} + +func dockerCopyDestinations(df string) []dockerCopy { + var out []dockerCopy + for _, raw := range strings.Split(df, "\n") { + line := strings.TrimSpace(raw) + if !strings.HasPrefix(line, "COPY ") || strings.HasPrefix(line, "COPY --from=") { + continue + } + fields := strings.Fields(line) + if len(fields) < 3 { + continue + } + src := filepath.ToSlash(fields[1]) + dest := filepath.ToSlash(fields[len(fields)-1]) + out = append(out, dockerCopy{src: src, dest: dest}) + } + return out +} + +func dockerCopyCovers(copies []dockerCopy, imagePath string) bool { + imagePath = filepath.ToSlash(imagePath) + for _, c := range copies { + dest := c.dest + src := c.src + if dest == imagePath { + return true + } + if strings.HasSuffix(dest, "/") && strings.HasPrefix(imagePath, dest) { + return true + } + if strings.HasSuffix(src, "/") && strings.HasSuffix(dest, "/") { + relSrc := strings.TrimPrefix(src, "deploy/pi-agent/") + if strings.HasPrefix(imagePath, dest+relSrc) || strings.HasPrefix(imagePath, dest) { + // /app/extensions/ + tavily-search.mjs + base := strings.TrimPrefix(imagePath, dest) + if base != imagePath && !strings.Contains(base, "..") { + return true + } + } + } + } + return false +}