Files

927 lines
28 KiB
Go

package agentcli
import (
"archive/zip"
"bytes"
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"net/url"
"os"
"path/filepath"
"strings"
"sync"
"sync/atomic"
"testing"
"time"
)
type configPushCapture struct {
payload map[string]any
upload []byte
}
func newConfigPushServer(t *testing.T) (*httptest.Server, *configPushCapture) {
t.Helper()
capture := &configPushCapture{}
var server *httptest.Server
server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/agent-stub/files/upload-request":
_ = json.NewEncoder(w).Encode(map[string]string{"upload_url": server.URL + "/uploads/config-asset"})
case "/uploads/config-asset":
file, _, err := r.FormFile("file")
if err != nil {
http.Error(w, "missing upload", http.StatusBadRequest)
return
}
defer func() { _ = file.Close() }()
capture.upload, err = io.ReadAll(file)
if err != nil {
http.Error(w, "bad upload", http.StatusBadRequest)
return
}
_ = json.NewEncoder(w).Encode(map[string]string{"id": "tool-file-1"})
case "/agent-stub/config/push":
if err := json.NewDecoder(r.Body).Decode(&capture.payload); err != nil {
http.Error(w, "bad config", http.StatusBadRequest)
return
}
_ = json.NewEncoder(w).Encode(map[string]string{"result": "success"})
default:
http.NotFound(w, r)
}
}))
return server, capture
}
func assertConfigPushItem(t *testing.T, payload map[string]any, key string, name string) {
t.Helper()
items, ok := payload[key].([]any)
if !ok || len(items) != 1 {
t.Fatalf("%s = %#v, want one item", key, payload[key])
}
item, ok := items[0].(map[string]any)
if !ok || item["name"] != name {
t.Fatalf("%s item = %#v", key, items[0])
}
fileRef, ok := item["file_ref"].(map[string]any)
if !ok || fileRef["kind"] != "tool_file" || fileRef["id"] != "tool-file-1" {
t.Fatalf("file_ref = %#v", item["file_ref"])
}
}
func TestConfigFilesPushUploadsFileAndPushesToolFileRef(t *testing.T) {
server, capture := newConfigPushServer(t)
defer server.Close()
filePath := filepath.Join(t.TempDir(), "guide.txt")
if err := os.WriteFile(filePath, []byte("guide"), 0o644); err != nil {
t.Fatalf("write config file: %v", err)
}
if err := RunConfigFilesPush(
&Environment{URL: server.URL + "/agent-stub", AuthJWE: "token"},
[]string{filePath},
); err != nil {
t.Fatalf("push config file: %v", err)
}
if string(capture.upload) != "guide" {
t.Fatalf("uploaded file = %q", capture.upload)
}
assertConfigPushItem(t, capture.payload, "files", "guide.txt")
}
func TestConfigSkillsPushUploadsArchiveAndPushesToolFileRef(t *testing.T) {
server, capture := newConfigPushServer(t)
defer server.Close()
skillDir := filepath.Join(t.TempDir(), "alpha")
if err := os.Mkdir(skillDir, 0o755); err != nil {
t.Fatalf("create skill directory: %v", err)
}
if err := os.WriteFile(filepath.Join(skillDir, "SKILL.md"), []byte("# Alpha\n"), 0o644); err != nil {
t.Fatalf("write SKILL.md: %v", err)
}
if err := RunConfigSkillsPush(
&Environment{URL: server.URL + "/agent-stub", AuthJWE: "token"},
[]string{skillDir},
); err != nil {
t.Fatalf("push config skill: %v", err)
}
archive, err := zip.NewReader(bytes.NewReader(capture.upload), int64(len(capture.upload)))
if err != nil {
t.Fatalf("open uploaded skill archive: %v", err)
}
foundSkillMD := false
for _, file := range archive.File {
if file.Name == "SKILL.md" {
foundSkillMD = true
break
}
}
if !foundSkillMD {
t.Fatalf("uploaded skill archive does not contain SKILL.md")
}
assertConfigPushItem(t, capture.payload, "skills", "alpha")
}
func TestConfigPullRequestsURLThenDownloadsFromDataPlane(t *testing.T) {
skillArchive := zipFixture(t, map[string]string{"SKILL.md": "# Alpha\n", "reference.md": "guide"})
tests := []struct {
name string
kind string
assetName string
payload []byte
run func(*Environment, string) error
assertFiles func(*testing.T, string)
}{
{
name: "file",
kind: "file",
assetName: "guide.txt",
payload: []byte("guide"),
run: func(env *Environment, targetDir string) error {
return RunConfigFilesPull(env, []string{"guide.txt"}, targetDir, true)
},
assertFiles: func(t *testing.T, targetDir string) {
data, err := os.ReadFile(filepath.Join(targetDir, "guide.txt"))
if err != nil {
t.Fatalf("read pulled config file: %v", err)
}
if string(data) != "guide" {
t.Fatalf("pulled config file = %q", data)
}
},
},
{
name: "skill",
kind: "skill",
assetName: "alpha",
payload: skillArchive,
run: func(env *Environment, targetDir string) error {
return RunConfigSkillsPull(env, []string{"alpha"}, targetDir, true)
},
assertFiles: func(t *testing.T, targetDir string) {
data, err := os.ReadFile(filepath.Join(targetDir, "alpha", "SKILL.md"))
if err != nil {
t.Fatalf("read pulled skill: %v", err)
}
if string(data) != "# Alpha\n" {
t.Fatalf("pulled SKILL.md = %q", data)
}
},
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
var controlPayload map[string]any
dataPlaneCalls := 0
var server *httptest.Server
server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/agent-stub/files/download-request":
if err := json.NewDecoder(r.Body).Decode(&controlPayload); err != nil {
http.Error(w, "bad request", http.StatusBadRequest)
return
}
_ = json.NewEncoder(w).Encode(map[string]any{
"filename": test.assetName,
"mime_type": "application/octet-stream",
"size": len(test.payload),
"download_url": server.URL + "/files/config-asset",
})
case "/files/config-asset":
dataPlaneCalls++
_, _ = w.Write(test.payload)
default:
http.NotFound(w, r)
}
}))
defer server.Close()
targetDir := t.TempDir()
err := test.run(&Environment{URL: server.URL + "/agent-stub", AuthJWE: "token"}, targetDir)
if err != nil {
t.Fatalf("pull config %s: %v", test.kind, err)
}
config, ok := controlPayload["config"].(map[string]any)
if !ok {
t.Fatalf("control payload config = %#v", controlPayload["config"])
}
if config["kind"] != test.kind || config["name"] != test.assetName {
t.Fatalf("control payload config = %#v", config)
}
if controlPayload["for_frontend"] != false {
t.Fatalf("for_frontend = %#v", controlPayload["for_frontend"])
}
if dataPlaneCalls != 1 {
t.Fatalf("data-plane calls = %d, want 1", dataPlaneCalls)
}
test.assertFiles(t, targetDir)
})
}
}
func TestConfigPullsDownloadConcurrentlyAndPreserveOutputOrder(t *testing.T) {
tests := []struct {
name string
kind string
names []string
payloads map[string][]byte
run func(*Environment, []string, string) error
assertOutput func(*testing.T, string, string)
}{
{
name: "files JSON output",
kind: "file",
names: []string{"first.txt", "second.txt"},
payloads: map[string][]byte{
"first.txt": []byte("first file"),
"second.txt": []byte("second file"),
},
run: func(env *Environment, names []string, targetDir string) error {
return RunConfigFilesPull(env, names, targetDir, true)
},
assertOutput: func(t *testing.T, targetDir string, output string) {
t.Helper()
var result struct {
Items []struct {
Name string `json:"name"`
Path string `json:"path"`
} `json:"items"`
}
if err := json.Unmarshal([]byte(output), &result); err != nil {
t.Fatalf("parse JSON output %q: %v", output, err)
}
if len(result.Items) != 2 {
t.Fatalf("output items = %#v, want two items", result.Items)
}
for index, name := range []string{"first.txt", "second.txt"} {
if result.Items[index].Name != name {
t.Errorf("item %d name = %q, want %q", index, result.Items[index].Name, name)
}
wantPath := filepath.Join(targetDir, name)
if result.Items[index].Path != wantPath {
t.Errorf("item %d path = %q, want %q", index, result.Items[index].Path, wantPath)
}
}
},
},
{
name: "skills text output",
kind: "skill",
names: []string{"alpha", "beta"},
payloads: map[string][]byte{
"alpha": zipFixture(t, map[string]string{"SKILL.md": "# Alpha\n"}),
"beta": zipFixture(t, map[string]string{"SKILL.md": "# Beta\n"}),
},
run: func(env *Environment, names []string, targetDir string) error {
return RunConfigSkillsPull(env, names, targetDir, false)
},
assertOutput: func(t *testing.T, targetDir string, output string) {
t.Helper()
want := filepath.Join(targetDir, "alpha") + "\n# Alpha\n\n" +
filepath.Join(targetDir, "beta") + "\n# Beta\n"
if output != want {
t.Errorf("text output = %q, want %q", output, want)
}
},
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
var server *httptest.Server
bothDownloadsStarted := make(chan struct{})
secondResponseWritten := make(chan struct{})
var startMu sync.Mutex
started := 0
server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/agent-stub/files/download-request":
var request struct {
Config struct {
Kind string `json:"kind"`
Name string `json:"name"`
} `json:"config"`
}
if err := json.NewDecoder(r.Body).Decode(&request); err != nil {
http.Error(w, "bad request", http.StatusBadRequest)
return
}
if request.Config.Kind != test.kind {
t.Errorf("config kind = %q, want %q", request.Config.Kind, test.kind)
}
if _, ok := test.payloads[request.Config.Name]; !ok {
http.Error(w, "unknown target", http.StatusBadRequest)
return
}
_ = json.NewEncoder(w).Encode(map[string]any{
"filename": request.Config.Name,
"size": len(test.payloads[request.Config.Name]),
"download_url": server.URL + "/files/config-asset?name=" + url.QueryEscape(request.Config.Name),
})
case "/files/config-asset":
name := r.URL.Query().Get("name")
payload, ok := test.payloads[name]
if !ok {
http.Error(w, "unknown target", http.StatusNotFound)
return
}
startMu.Lock()
started++
if started == len(test.names) {
close(bothDownloadsStarted)
}
startMu.Unlock()
select {
case <-bothDownloadsStarted:
case <-time.After(2 * time.Second):
http.Error(w, "downloads did not overlap", http.StatusGatewayTimeout)
return
}
if name == test.names[0] {
select {
case <-secondResponseWritten:
case <-time.After(2 * time.Second):
http.Error(w, "second response did not finish first", http.StatusGatewayTimeout)
return
}
}
_, _ = w.Write(payload)
if name == test.names[1] {
close(secondResponseWritten)
}
default:
http.NotFound(w, r)
}
}))
defer server.Close()
targetDir := t.TempDir()
output, err := captureConfigStdout(t, func() error {
return test.run(
&Environment{URL: server.URL + "/agent-stub", AuthJWE: "token"},
test.names,
targetDir,
)
})
if err != nil {
t.Fatalf("pull config %s: %v", test.kind, err)
}
test.assertOutput(t, targetDir, output)
})
}
}
func TestConfigFilesPullLimitsActiveRequestsAndRunsQueuedTarget(t *testing.T) {
names := []string{"one.txt", "two.txt", "three.txt", "four.txt", "five.txt"}
entered := make(chan string, len(names))
release := make(chan struct{})
var releaseOnce sync.Once
unblock := func() { releaseOnce.Do(func() { close(release) }) }
var server *httptest.Server
server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/agent-stub/files/download-request":
var request struct {
Config struct {
Name string `json:"name"`
} `json:"config"`
}
if err := json.NewDecoder(r.Body).Decode(&request); err != nil {
http.Error(w, "bad request", http.StatusBadRequest)
return
}
entered <- request.Config.Name
select {
case <-release:
case <-time.After(2 * time.Second):
http.Error(w, "request was not released", http.StatusGatewayTimeout)
return
}
_ = json.NewEncoder(w).Encode(map[string]any{
"filename": request.Config.Name,
"download_url": server.URL + "/files/config-asset?name=" + url.QueryEscape(request.Config.Name),
})
case "/files/config-asset":
_, _ = w.Write([]byte(r.URL.Query().Get("name")))
default:
http.NotFound(w, r)
}
}))
defer func() {
unblock()
server.Close()
}()
done := make(chan error, 1)
targetDir := t.TempDir()
go func() {
done <- RunConfigFilesPull(
&Environment{URL: server.URL + "/agent-stub", AuthJWE: "token"},
names,
targetDir,
true,
)
}()
for range 4 {
select {
case <-entered:
case <-time.After(2 * time.Second):
t.Fatal("four config pull requests did not enter concurrently")
}
}
select {
case <-entered:
t.Fatal("a fifth request entered above the four-request limit")
case <-time.After(100 * time.Millisecond):
}
unblock()
select {
case <-entered:
case <-time.After(2 * time.Second):
t.Fatal("queued config pull request did not proceed after release")
}
select {
case err := <-done:
if err != nil {
t.Fatalf("pull config files: %v", err)
}
case <-time.After(2 * time.Second):
t.Fatal("config files pull did not finish")
}
}
func TestConfigFilesPullReturnsAllFailuresInInputOrderAfterLaterFailureCompletesFirst(t *testing.T) {
names := []string{"earlier-failure.txt", "later-failure.txt"}
laterFailureEmitted := make(chan struct{})
var server *httptest.Server
server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/agent-stub/files/download-request":
var request struct {
Config struct {
Name string `json:"name"`
} `json:"config"`
}
if err := json.NewDecoder(r.Body).Decode(&request); err != nil {
http.Error(w, "bad request", http.StatusBadRequest)
return
}
switch request.Config.Name {
case names[0]:
_ = json.NewEncoder(w).Encode(map[string]any{
"filename": request.Config.Name,
"download_url": server.URL + "/files/config-asset",
})
case names[1]:
http.Error(w, "later input failed first", http.StatusServiceUnavailable)
close(laterFailureEmitted)
return
default:
http.Error(w, "unknown target", http.StatusBadRequest)
}
case "/files/config-asset":
select {
case <-laterFailureEmitted:
case <-time.After(2 * time.Second):
http.Error(w, "later failure was not emitted", http.StatusGatewayTimeout)
return
}
http.Error(w, "earlier input failed later", http.StatusBadGateway)
default:
http.NotFound(w, r)
}
}))
defer server.Close()
done := make(chan error, 1)
targetDir := t.TempDir()
go func() {
done <- RunConfigFilesPull(
&Environment{URL: server.URL + "/agent-stub", AuthJWE: "token"},
names,
targetDir,
true,
)
}()
select {
case err := <-done:
if err == nil {
t.Fatal("error = nil, want both config pull failures")
}
errorText := err.Error()
earlierWrapper := `download config file "earlier-failure.txt"`
earlierMessage := "earlier input failed later"
laterWrapper := `request config file "later-failure.txt" download URL`
laterMessage := "later input failed first"
earlierWrapperIndex := strings.Index(errorText, earlierWrapper)
earlierMessageIndex := strings.Index(errorText, earlierMessage)
laterWrapperIndex := strings.Index(errorText, laterWrapper)
laterMessageIndex := strings.Index(errorText, laterMessage)
if earlierWrapperIndex == -1 || earlierMessageIndex == -1 || laterWrapperIndex == -1 || laterMessageIndex == -1 {
t.Fatalf(
"error = %q, want both wrapped failures with messages %q and %q",
errorText,
earlierMessage,
laterMessage,
)
}
if earlierWrapperIndex >= earlierMessageIndex || earlierMessageIndex >= laterWrapperIndex || laterWrapperIndex >= laterMessageIndex {
t.Fatalf("error = %q, want failures in input order", errorText)
}
case <-time.After(3 * time.Second):
t.Fatal("config files pull did not finish")
}
}
func TestConfigPullMultiItemFailuresIdentifyItemAndStage(t *testing.T) {
skillArchive := zipFixture(t, map[string]string{"SKILL.md": "# Alpha\n"})
tests := []struct {
name string
kind string
names []string
payload []byte
failureStage string
wantStage string
}{
{
name: "file control-plane URL acquisition",
kind: "file",
names: []string{"first.txt", "second.txt"},
payload: []byte("first file"),
failureStage: "control",
wantStage: `request config file "second.txt" download URL`,
},
{
name: "file signed-URL download",
kind: "file",
names: []string{"first.txt", "second.txt"},
payload: []byte("first file"),
failureStage: "data",
wantStage: `download config file "second.txt"`,
},
{
name: "skill control-plane URL acquisition",
kind: "skill",
names: []string{"alpha", "beta"},
payload: skillArchive,
failureStage: "control",
wantStage: `request config skill "beta" download URL`,
},
{
name: "skill signed-URL download",
kind: "skill",
names: []string{"alpha", "beta"},
payload: skillArchive,
failureStage: "data",
wantStage: `download config skill "beta"`,
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
var controlCalls atomic.Int32
var dataPlaneCalls atomic.Int32
var server *httptest.Server
server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/agent-stub/files/download-request":
controlCalls.Add(1)
var request struct {
Config struct {
Kind string `json:"kind"`
Name string `json:"name"`
} `json:"config"`
}
if err := json.NewDecoder(r.Body).Decode(&request); err != nil {
t.Errorf("decode download request: %v", err)
http.Error(w, "bad request", http.StatusBadRequest)
return
}
if request.Config.Kind != test.kind {
t.Errorf("config kind = %q, want %q", request.Config.Kind, test.kind)
}
if test.failureStage == "control" && request.Config.Name == test.names[1] {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusUnauthorized)
_, _ = w.Write([]byte(`{"detail":{"code":"agent_stub_authorization_expired","message":"expired"}}`))
return
}
_ = json.NewEncoder(w).Encode(map[string]any{
"filename": request.Config.Name,
"size": len(test.payload),
"download_url": server.URL + "/files/config-asset?name=" + url.QueryEscape(request.Config.Name),
})
case "/files/config-asset":
dataPlaneCalls.Add(1)
if test.failureStage == "data" && r.URL.Query().Get("name") == test.names[1] {
http.Error(w, "data plane unavailable", http.StatusBadGateway)
return
}
_, _ = w.Write(test.payload)
default:
http.NotFound(w, r)
}
}))
defer server.Close()
targetDir := t.TempDir()
env := &Environment{URL: server.URL + "/agent-stub", AuthJWE: "token"}
var err error
if test.kind == "file" {
err = RunConfigFilesPull(env, test.names, targetDir, true)
} else {
err = RunConfigSkillsPull(env, test.names, targetDir, true)
}
if err == nil {
t.Fatal("config pull succeeded, want second-item failure")
}
if !strings.Contains(err.Error(), test.wantStage) {
t.Errorf("error = %q, want stage %q", err, test.wantStage)
}
if controlCalls.Load() != 2 {
t.Errorf("control-plane calls = %d, want 2", controlCalls.Load())
}
wantDataPlaneCalls := 2
if test.failureStage == "control" {
wantDataPlaneCalls = 1
for _, want := range []string{
"expired after 5 minutes",
"will not refresh automatically",
"start a new shell tool call",
"retry the command",
} {
if !strings.Contains(err.Error(), want) {
t.Errorf("error = %q, want expiration guidance %q", err, want)
}
}
}
if int(dataPlaneCalls.Load()) != wantDataPlaneCalls {
t.Errorf("data-plane calls = %d, want %d", dataPlaneCalls.Load(), wantDataPlaneCalls)
}
if test.kind == "file" {
data, readErr := os.ReadFile(filepath.Join(targetDir, test.names[0]))
if readErr != nil || !bytes.Equal(data, test.payload) {
t.Errorf("first file was not completed: data=%q err=%v", data, readErr)
}
} else {
data, readErr := os.ReadFile(filepath.Join(targetDir, test.names[0], "SKILL.md"))
if readErr != nil || string(data) != "# Alpha\n" {
t.Errorf("first skill was not completed: data=%q err=%v", data, readErr)
}
}
})
}
}
func TestConfigPushMultiItemUploadFailuresIdentifyItemAndStage(t *testing.T) {
tests := []struct {
name string
failureStage string
makeSources func(*testing.T) []string
run func(*Environment, []string) error
wantItem string
}{
{
name: "file control-plane URL acquisition",
failureStage: "request upload URL",
makeSources: makeConfigFileSources,
run: RunConfigFilesPush,
wantItem: `config file "second.txt"`,
},
{
name: "file signed URL data transfer",
failureStage: "upload data",
makeSources: makeConfigFileSources,
run: RunConfigFilesPush,
wantItem: `config file "second.txt"`,
},
{
name: "skill control-plane URL acquisition",
failureStage: "request upload URL",
makeSources: makeConfigSkillSources,
run: RunConfigSkillsPush,
wantItem: `config skill "beta"`,
},
{
name: "skill signed URL data transfer",
failureStage: "upload data",
makeSources: makeConfigSkillSources,
run: RunConfigSkillsPush,
wantItem: `config skill "beta"`,
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
uploadRequestCalls := 0
dataPlaneCalls := 0
var server *httptest.Server
server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/agent-stub/files/upload-request":
uploadRequestCalls++
if test.failureStage == "request upload URL" && uploadRequestCalls == 2 {
http.Error(w, "control plane unavailable", http.StatusServiceUnavailable)
return
}
_ = json.NewEncoder(w).Encode(map[string]string{"upload_url": server.URL + "/upload"})
case "/upload":
_, _ = io.Copy(io.Discard, r.Body)
dataPlaneCalls++
if test.failureStage == "upload data" && dataPlaneCalls == 2 {
http.Error(w, "data plane unavailable", http.StatusBadGateway)
return
}
_, _ = w.Write([]byte(`{"id":"tool-file-1"}`))
case "/agent-stub/config/push":
t.Error("final config push must not run after an item upload failure")
default:
http.NotFound(w, r)
}
}))
defer server.Close()
err := test.run(
&Environment{URL: server.URL + "/agent-stub", AuthJWE: "token"},
test.makeSources(t),
)
if err == nil {
t.Fatal("config push succeeded, want second-item upload failure")
}
if !strings.Contains(err.Error(), test.wantItem) {
t.Errorf("error = %q, want current item %q", err, test.wantItem)
}
if !strings.Contains(err.Error(), test.failureStage) {
t.Errorf("error = %q, want stage %q", err, test.failureStage)
}
if uploadRequestCalls != 2 {
t.Errorf("upload request calls = %d, want 2", uploadRequestCalls)
}
wantDataPlaneCalls := 2
if test.failureStage == "request upload URL" {
wantDataPlaneCalls = 1
}
if dataPlaneCalls != wantDataPlaneCalls {
t.Errorf("data-plane calls = %d, want %d", dataPlaneCalls, wantDataPlaneCalls)
}
})
}
}
func TestConfigPushFinalFailureIdentifiesOperationAndExplainsExpiry(t *testing.T) {
tests := []struct {
name string
makeSource func(*testing.T) string
run func(*Environment, string) error
want string
}{
{
name: "file",
makeSource: func(t *testing.T) string {
path := filepath.Join(t.TempDir(), "guide.txt")
if err := os.WriteFile(path, []byte("guide"), 0o600); err != nil {
t.Fatalf("write config file: %v", err)
}
return path
},
run: func(env *Environment, path string) error { return RunConfigFilesPush(env, []string{path}) },
want: "push config files",
},
{
name: "skill",
makeSource: func(t *testing.T) string {
dir := filepath.Join(t.TempDir(), "alpha")
if err := os.MkdirAll(dir, 0o755); err != nil {
t.Fatalf("create config skill: %v", err)
}
if err := os.WriteFile(filepath.Join(dir, "SKILL.md"), []byte("# Alpha\n"), 0o600); err != nil {
t.Fatalf("write config skill: %v", err)
}
return dir
},
run: func(env *Environment, path string) error { return RunConfigSkillsPush(env, []string{path}) },
want: "push config skills",
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
var server *httptest.Server
server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/agent-stub/files/upload-request":
_ = json.NewEncoder(w).Encode(map[string]string{"upload_url": server.URL + "/upload"})
case "/upload":
_, _ = w.Write([]byte(`{"id":"tool-file-1"}`))
case "/agent-stub/config/push":
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusUnauthorized)
_, _ = w.Write([]byte(`{"detail":{"code":"agent_stub_authorization_expired","message":"expired"}}`))
default:
http.NotFound(w, r)
}
}))
defer server.Close()
err := test.run(
&Environment{URL: server.URL + "/agent-stub", AuthJWE: "token"},
test.makeSource(t),
)
if err == nil || !strings.Contains(err.Error(), test.want) {
t.Fatalf("error = %v, want operation %q", err, test.want)
}
if !strings.Contains(err.Error(), "start a new shell tool call") {
t.Fatalf("error = %v, want expiry recovery guidance", err)
}
})
}
}
func makeConfigFileSources(t *testing.T) []string {
t.Helper()
dir := t.TempDir()
paths := []string{filepath.Join(dir, "first.txt"), filepath.Join(dir, "second.txt")}
for _, path := range paths {
if err := os.WriteFile(path, []byte(filepath.Base(path)), 0o600); err != nil {
t.Fatalf("write config file: %v", err)
}
}
return paths
}
func makeConfigSkillSources(t *testing.T) []string {
t.Helper()
dir := t.TempDir()
paths := []string{filepath.Join(dir, "alpha"), filepath.Join(dir, "beta")}
for _, path := range paths {
if err := os.MkdirAll(path, 0o755); err != nil {
t.Fatalf("create config skill: %v", err)
}
if err := os.WriteFile(filepath.Join(path, "SKILL.md"), []byte("# "+filepath.Base(path)+"\n"), 0o600); err != nil {
t.Fatalf("write config skill: %v", err)
}
}
return paths
}
func zipFixture(t *testing.T, files map[string]string) []byte {
t.Helper()
var buffer bytes.Buffer
archive := zip.NewWriter(&buffer)
for name, content := range files {
writer, err := archive.Create(name)
if err != nil {
t.Fatalf("create zip member: %v", err)
}
if _, err := writer.Write([]byte(content)); err != nil {
t.Fatalf("write zip member: %v", err)
}
}
if err := archive.Close(); err != nil {
t.Fatalf("close zip fixture: %v", err)
}
return buffer.Bytes()
}
func captureConfigStdout(t *testing.T, run func() error) (string, error) {
t.Helper()
outputFile, err := os.CreateTemp(t.TempDir(), "config-stdout-*")
if err != nil {
t.Fatalf("create stdout capture: %v", err)
}
originalStdout := os.Stdout
var runErr error
func() {
defer func() { os.Stdout = originalStdout }()
os.Stdout = outputFile
runErr = run()
}()
if err := outputFile.Close(); err != nil {
t.Fatalf("close stdout capture: %v", err)
}
output, err := os.ReadFile(outputFile.Name())
if err != nil {
t.Fatalf("read stdout capture: %v", err)
}
return string(output), runErr
}