-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathzz_error_paths_test.go
More file actions
441 lines (416 loc) · 15.2 KB
/
Copy pathzz_error_paths_test.go
File metadata and controls
441 lines (416 loc) · 15.2 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
// SPDX-License-Identifier: AGPL-3.0-or-later
//go:build !no_skillinject
// +build !no_skillinject
// Targets the hardest-to-reach disk error branches by leveraging
// permission denial (chmod 0500), un-stat-able paths, and config-file
// targets that are not regular files.
//
// - mergePluginAllowList: parent-dir mkdir failure surfaces error
// (config path under a non-writable ancestor that's a regular file)
// - mergePluginAllowList: writeFileAtomic backup failure (bakPath
// points into a non-writable dir while live file is in a writable
// sibling — we wedge by chmod 0500 on the parent)
// - mergePluginAllowList: read plugin config returns non-IsNotExist
// error (config path is a directory, not a file)
// - mergePluginAllowList: writeFileAtomic for the live config swap
// fails (parent chmod 0500)
// - mergePluginAllowList: post-swap verifyOnDiskResult rollback path
// (covered by directly invoking verifyOnDiskResult + writeFileAtomic
// in a unit-level integration that mirrors the rollback machinery)
// - writeFile: mkdir failure (parent is a regular file)
// - writeMarker: ReadFile non-IsNotExist error (path is a dir)
// - SetEnabled: mkdir failure when parent is a file
// - writeCache: mkdir failure when parent is a file
// - get(): http.NewRequestWithContext fails on a control-character URL
//
// We skip permission-based tests when running as root since chmod can't
// restrict root.
package skillinject
import (
"context"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"runtime"
"strings"
"testing"
)
func skipIfRoot(t *testing.T) {
t.Helper()
if os.Geteuid() == 0 {
t.Skip("permission-based test requires non-root euid")
}
}
// writeFile: parent is a REGULAR FILE → MkdirAll returns ENOTDIR. We
// pick a path whose dirname is an existing regular file, which makes
// os.MkdirAll fail with "not a directory" without needing chmod tricks.
func TestWriteFile_MkdirFailsWhenParentIsFile(t *testing.T) {
t.Parallel()
dir := t.TempDir()
// Create a regular file, then ask writeFile to put a child under it.
parentAsFile := filepath.Join(dir, "iam-a-file")
if err := os.WriteFile(parentAsFile, []byte("x"), 0o644); err != nil {
t.Fatalf("seed: %v", err)
}
err := writeFile(filepath.Join(parentAsFile, "child"), []byte("body"))
if err == nil {
t.Fatal("expected mkdir error when parent is a regular file")
}
}
// writeMarker: passing a path whose dirname is a regular file causes
// the post-read mkdir to fail (and is well-defined cross-platform).
func TestWriteMarker_MkdirFails(t *testing.T) {
t.Parallel()
dir := t.TempDir()
parentAsFile := filepath.Join(dir, "x")
if err := os.WriteFile(parentAsFile, []byte(""), 0o644); err != nil {
t.Fatalf("seed: %v", err)
}
// Path is under a regular file → ReadFile returns ENOTDIR (a
// non-IsNotExist error), so writeMarker returns immediately.
err := writeMarker(filepath.Join(parentAsFile, "AGENT.md"), "ref", "abc123")
if err == nil {
t.Fatal("expected error reading or making path under a file")
}
}
// mergePluginAllowList: parent of config path is a regular file →
// mkdir fails inside merge. Note: the function first tries to ReadFile
// the configPath, which will return ENOTDIR (path traversal hit a
// non-dir); since that's not IsNotExist, merge returns
// "read plugin config" error early.
func TestMergePluginAllowList_ReadPluginConfigErrors(t *testing.T) {
t.Parallel()
dir := t.TempDir()
parentAsFile := filepath.Join(dir, "iam-a-file")
if err := os.WriteFile(parentAsFile, []byte("x"), 0o644); err != nil {
t.Fatalf("seed: %v", err)
}
cfgPath := filepath.Join(parentAsFile, "openclaw.json")
err := mergePluginAllowList(cfgPath, "plugins.allow", "plugins.entries", "pilot")
if err == nil {
t.Fatal("expected error reading config path under a file")
}
if !strings.Contains(err.Error(), "read plugin config") {
t.Errorf("error not wrapped as expected: %v", err)
}
}
// mergePluginAllowList: config exists but parent dir becomes read-only
// AFTER the read. Hard to do without monkey patching; instead, we
// exercise the writeFileAtomic-fails branch by making the temp file
// path uncreatable. We chmod the parent dir to 0o500 (read+exec only)
// — file writes inside will fail, the backup write fails first.
func TestMergePluginAllowList_BackupWriteFailsWhenDirReadOnly(t *testing.T) {
t.Parallel()
skipIfRoot(t)
if runtime.GOOS == "windows" {
t.Skip("chmod-based test not applicable on windows")
}
dir := t.TempDir()
// Seed config inside `dir`.
cfgPath := filepath.Join(dir, "openclaw.json")
if err := os.WriteFile(cfgPath, []byte(`{"x": 1}`), 0o644); err != nil {
t.Fatalf("seed: %v", err)
}
// Make the directory read-only (no write/create).
if err := os.Chmod(dir, 0o500); err != nil {
t.Fatalf("chmod: %v", err)
}
t.Cleanup(func() { _ = os.Chmod(dir, 0o700) })
err := mergePluginAllowList(cfgPath, "plugins.allow", "plugins.entries", "pilot")
if err == nil {
t.Fatal("expected merge error when parent dir is read-only")
}
// Should mention either backup or write — both are reasonable failure points.
msg := err.Error()
if !strings.Contains(msg, "backup") && !strings.Contains(msg, "write plugin config") {
t.Errorf("error not about backup/write: %v", err)
}
}
// SetEnabled: parent of ~/.pilot is a regular file → mkdir fails.
func TestSetEnabled_MkdirFails(t *testing.T) {
t.Parallel()
dir := t.TempDir()
// Put a regular file at the slot where the ~/.pilot dir would live.
pilotSlot := filepath.Join(dir, ".pilot")
if err := os.WriteFile(pilotSlot, []byte("nope"), 0o644); err != nil {
t.Fatalf("seed: %v", err)
}
// Now SetEnabled wants to create ~/.pilot/ but it's a file.
// os.MkdirAll on an existing regular file returns an error.
err := SetEnabled(dir, false)
if err == nil {
t.Fatal("expected SetEnabled to fail when ~/.pilot is a regular file")
}
}
// writeCache: parent of the cache slot is a regular file → mkdir fails
// inside writeCache. Verifies the err-return branch.
func TestWriteCache_MkdirFails(t *testing.T) {
t.Parallel()
home := t.TempDir()
// Block the cache root by putting a regular file at .pilot.
if err := os.WriteFile(filepath.Join(home, ".pilot"), []byte(""), 0o644); err != nil {
t.Fatalf("seed: %v", err)
}
err := writeCache(home, "some/relative.json", []byte("x"))
if err == nil {
t.Fatal("expected mkdir error in writeCache")
}
}
// get(): URL with embedded control character makes
// http.NewRequestWithContext fail before any IO happens. Covers the
// `if err != nil { return nil, err }` branch in get().
func TestGet_NewRequestFails(t *testing.T) {
t.Parallel()
f := newFetcher(Config{})
// A control-char (0x7f DEL) in the URL is rejected by net/url
// inside NewRequestWithContext.
_, err := f.get(context.Background(), "http://example.com/\x7fbad")
if err == nil {
t.Fatal("expected NewRequestWithContext error")
}
}
// reconcilePluginAllowList: when merge fails on a malformed config,
// already covered. Here we cover the symmetric case where merge fails
// AT the disk level (parent read-only) and the error surfaces through
// the Outcome. This exercises the line that records the err.Error()
// into Outcome.Err.
func TestReconcilePluginAllowList_DiskErrorSurfacesAsOutcome(t *testing.T) {
t.Parallel()
skipIfRoot(t)
if runtime.GOOS == "windows" {
t.Skip("chmod-based test not applicable on windows")
}
home := t.TempDir()
openclawDir := filepath.Join(home, ".openclaw")
if err := os.MkdirAll(openclawDir, 0o755); err != nil {
t.Fatalf("mkdir: %v", err)
}
cfgPath := filepath.Join(openclawDir, "openclaw.json")
// Seed in a Drifted state so the merge path is taken.
if err := os.WriteFile(cfgPath, []byte(`{"unrelated": true}`), 0o644); err != nil {
t.Fatalf("seed: %v", err)
}
// Lock the dir so the backup + swap fail.
if err := os.Chmod(openclawDir, 0o500); err != nil {
t.Fatalf("chmod: %v", err)
}
t.Cleanup(func() { _ = os.Chmod(openclawDir, 0o700) })
p := &ManifestPlugin{
ID: "pilot",
AllowList: &ManifestPluginAllowList{
ConfigPath: "~/.openclaw/openclaw.json",
AllowListJsonPath: "plugins.allow",
EntriesJsonPath: "plugins.entries",
},
}
out := reconcilePluginAllowList(p, home, false)
if out.Action != ActionError {
t.Errorf("action = %v; want error", out.Action)
}
if out.Err == "" {
t.Errorf("expected non-empty Err: %+v", out)
}
}
// Direct unit on rollback machinery: writeFileAtomic + verifyOnDiskResult
// together — this exercises the same combo used in the rollback branch
// of mergePluginAllowList (writeFileAtomic from in-memory originalBytes).
// We seed a valid file, run verify (passes), then overwrite with garbage
// and verify (fails), then writeFileAtomic the original bytes back
// (recovers the file), then verify (passes). Mirrors the rollback step
// in mergePluginAllowList without monkey-patching.
func TestRollbackMachinery_RestoreFromInMemorySnapshot(t *testing.T) {
t.Parallel()
dir := t.TempDir()
cfgPath := filepath.Join(dir, "openclaw.json")
originalBytes := []byte(`{
"plugins": {
"allow": ["pilot"],
"entries": { "pilot": { "enabled": true } }
}
}
`)
if err := os.WriteFile(cfgPath, originalBytes, 0o644); err != nil {
t.Fatalf("seed: %v", err)
}
originalTopKeys := map[string]struct{}{"plugins": {}}
// Step 1: verify passes.
if err := verifyOnDiskResult(cfgPath, originalTopKeys, "pilot", "plugins.allow", "plugins.entries"); err != nil {
t.Fatalf("baseline verify: %v", err)
}
// Step 2: corrupt; verify fails.
if err := os.WriteFile(cfgPath, []byte("scrambled garbage"), 0o644); err != nil {
t.Fatalf("corrupt: %v", err)
}
if err := verifyOnDiskResult(cfgPath, originalTopKeys, "pilot", "plugins.allow", "plugins.entries"); err == nil {
t.Fatal("verify should fail on corrupted bytes")
}
// Step 3: rollback via writeFileAtomic (same call merge() uses).
if err := writeFileAtomic(cfgPath, originalBytes, 0o644); err != nil {
t.Fatalf("rollback writeFileAtomic: %v", err)
}
// Step 4: post-rollback verify passes again.
if err := verifyOnDiskResult(cfgPath, originalTopKeys, "pilot", "plugins.allow", "plugins.entries"); err != nil {
t.Errorf("post-rollback verify: %v", err)
}
got, _ := os.ReadFile(cfgPath)
if string(got) != string(originalBytes) {
t.Errorf("rollback file content differs from original")
}
}
// Round-out reconcilePluginFiles: helper-fetch returns 404 → error
// outcome (one of two cov0 lines in reconcilePluginFiles, the other
// being the unreachable writeFile failure).
func TestReconcilePluginFiles_FetchErrorSurfaces(t *testing.T) {
t.Parallel()
home := t.TempDir()
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Always 404 — every fetchRepoFile call errors out.
http.NotFound(w, r)
}))
defer srv.Close()
f := newFetcher(Config{RepoBaseURL: srv.URL + "/"})
p := &ManifestPlugin{
ID: "pilot",
InstallPath: "~/.openclaw/extensions/pilot",
Files: []ManifestPluginFile{
{Name: "openclaw.plugin.json", Src: "plugin/openclaw.plugin.json"},
},
}
outs := reconcilePluginFiles(f, context.Background(), p, home, false)
if len(outs) != 1 {
t.Fatalf("expected 1 outcome; got %d", len(outs))
}
if outs[0].Action != ActionError {
t.Errorf("action = %v; want error on 404 fetch", outs[0].Action)
}
if outs[0].Err == "" {
t.Errorf("expected non-empty Err")
}
}
// Tick: heartbeat template fetch fails (404) → marker outcome carries
// ActionError, doesn't block other tools.
func TestTick_HeartbeatTemplateFetchFails(t *testing.T) {
t.Parallel()
home := t.TempDir()
if err := os.MkdirAll(filepath.Join(home, ".claude"), 0o755); err != nil {
t.Fatalf("mkdir: %v", err)
}
mux := http.NewServeMux()
mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
path := strings.TrimPrefix(r.URL.Path, "/")
switch path {
case "inject-manifest.json":
_, _ = w.Write([]byte(`{"version":1,"entrypoint":"pilotctl","tools":[
{"name":"claude-code","rootDir":"~/.claude","skillsDir":"~/.claude/skills",
"heartbeatPath":"~/.claude/CLAUDE.md","heartbeatTemplate":"heartbeats/MISSING.md"}
]}`))
case "skills/pilotctl/SKILL.md":
_, _ = w.Write([]byte("# skill body"))
default:
http.NotFound(w, r)
}
})
srv := httptest.NewServer(mux)
defer srv.Close()
cfg := Config{
Home: home,
ManifestURL: srv.URL + "/inject-manifest.json",
RepoBaseURL: srv.URL + "/",
}
rep, err := Tick(context.Background(), cfg)
if err != nil {
t.Fatalf("Tick: %v", err)
}
// Should have one marker error outcome (the heartbeat fetch failed).
var markerErr bool
for _, o := range rep.Outcomes {
if o.Kind == KindMarker && o.Action == ActionError {
markerErr = true
}
}
if !markerErr {
t.Errorf("expected marker error outcome on failed heartbeat fetch: %+v", rep.Outcomes)
}
}
// Tick: entrypoint SKILL.md fetch fails → Tick returns an error.
func TestTick_EntrypointSkillFetchFails(t *testing.T) {
t.Parallel()
home := t.TempDir()
if err := os.MkdirAll(filepath.Join(home, ".claude"), 0o755); err != nil {
t.Fatalf("mkdir: %v", err)
}
mux := http.NewServeMux()
mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
if strings.HasSuffix(r.URL.Path, "inject-manifest.json") {
_, _ = w.Write([]byte(`{"version":1,"entrypoint":"pilotctl","tools":[
{"name":"claude-code","rootDir":"~/.claude","skillsDir":"~/.claude/skills"}
]}`))
return
}
// Every other fetch (incl. skills/pilotctl/SKILL.md) 404s.
http.NotFound(w, r)
})
srv := httptest.NewServer(mux)
defer srv.Close()
cfg := Config{
Home: home,
ManifestURL: srv.URL + "/inject-manifest.json",
RepoBaseURL: srv.URL + "/",
}
_, err := Tick(context.Background(), cfg)
if err == nil {
t.Fatal("expected Tick error when entrypoint SKILL.md 404s")
}
if !strings.Contains(err.Error(), "fetch entrypoint") {
t.Errorf("error not wrapped: %v", err)
}
}
// Tick with a manifest that points heartbeatTemplate at a template
// containing an Execute-time error (referencing an unknown field).
// Covers the renderHeartbeat error branch inside Tick.
func TestTick_HeartbeatTemplateRenderError(t *testing.T) {
t.Parallel()
home := t.TempDir()
if err := os.MkdirAll(filepath.Join(home, ".claude"), 0o755); err != nil {
t.Fatalf("mkdir: %v", err)
}
mux := http.NewServeMux()
mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
path := strings.TrimPrefix(r.URL.Path, "/")
switch path {
case "inject-manifest.json":
_, _ = w.Write([]byte(`{"version":1,"entrypoint":"pilotctl","tools":[
{"name":"claude-code","rootDir":"~/.claude","skillsDir":"~/.claude/skills",
"heartbeatPath":"~/.claude/CLAUDE.md","heartbeatTemplate":"heartbeats/bad.md"}
]}`))
case "skills/pilotctl/SKILL.md":
_, _ = w.Write([]byte("# skill"))
case "heartbeats/bad.md":
// Field doesn't exist on heartbeatVars → Execute error.
_, _ = w.Write([]byte(`{{.NonExistent}}`))
default:
http.NotFound(w, r)
}
})
srv := httptest.NewServer(mux)
defer srv.Close()
cfg := Config{
Home: home,
ManifestURL: srv.URL + "/inject-manifest.json",
RepoBaseURL: srv.URL + "/",
}
rep, err := Tick(context.Background(), cfg)
if err != nil {
t.Fatalf("Tick: %v", err)
}
var sawErr bool
for _, o := range rep.Outcomes {
if o.Kind == KindMarker && o.Action == ActionError {
sawErr = true
}
}
if !sawErr {
t.Errorf("expected marker render error outcome: %+v", rep.Outcomes)
}
}