@@ -4,6 +4,7 @@ package skillinject
44
55import (
66 "context"
7+ "net/http"
78 "os"
89 "path/filepath"
910 "strings"
@@ -334,6 +335,167 @@ func TestMarker_IsDirective(t *testing.T) {
334335 }
335336}
336337
338+ // Concurrent Tick calls don't corrupt outputs. Two writers using the
339+ // same path+".tmp" could race and leave a half-written file or a
340+ // rename-source-not-found. We don't *protect* against this in the
341+ // daemon (only one Run loop fires Tick at a time), but pilotctl skills
342+ // check + a tick coincide can race; this test verifies the worst that
343+ // happens is one writer's content wins, never corruption.
344+ func TestTick_ConcurrentDoesNotCorrupt (t * testing.T ) {
345+ home := t .TempDir ()
346+ mustMkdirAll (t , filepath .Join (home , ".claude" ))
347+
348+ r := newFakeRepo (t )
349+ r .withTools (claudeOnly ())
350+ r .manifest .Helpers = []ManifestHelper {{
351+ Name : "pilot-ask" ,
352+ Src : "workflow-injection/pilot-ask" ,
353+ Dst : "~/.pilot/bin/pilot-ask" ,
354+ Mode : "0755" ,
355+ }}
356+ r .files ["workflow-injection/pilot-ask" ] = []byte ("#!/usr/bin/env bash\n echo concurrent\n " )
357+
358+ const N = 8
359+ errCh := make (chan error , N )
360+ for i := 0 ; i < N ; i ++ {
361+ go func () {
362+ _ , err := Tick (context .Background (), r .cfg (home ))
363+ errCh <- err
364+ }()
365+ }
366+ for i := 0 ; i < N ; i ++ {
367+ if err := <- errCh ; err != nil {
368+ t .Errorf ("concurrent Tick %d: %v" , i , err )
369+ }
370+ }
371+
372+ // Final content matches one consistent body.
373+ got := mustRead (t , filepath .Join (home , ".pilot" , "bin" , "pilot-ask" ))
374+ if got != "#!/usr/bin/env bash\n echo concurrent\n " {
375+ t .Errorf ("corrupted helper content: %q" , got )
376+ }
377+ skill := mustRead (t , filepath .Join (home , ".claude" , "skills" , "pilotctl" , "SKILL.md" ))
378+ if skill != testContent {
379+ t .Errorf ("corrupted skill content: %q" , skill [:min (80 , len (skill ))])
380+ }
381+ }
382+
383+ // Malformed manifest JSON: tick returns an error and writes nothing.
384+ func TestTick_MalformedManifest (t * testing.T ) {
385+ home := t .TempDir ()
386+ mustMkdirAll (t , filepath .Join (home , ".claude" ))
387+
388+ r := newFakeRepo (t )
389+ r .files ["inject-manifest.json" ] = []byte ("not json {{{" )
390+ // Override default JSON marshaling: register a raw handler.
391+ r .srv .Config .Handler = http .HandlerFunc (func (w http.ResponseWriter , req * http.Request ) {
392+ path := strings .TrimPrefix (req .URL .Path , "/" )
393+ if body , ok := r .files [path ]; ok {
394+ w .Write (body )
395+ return
396+ }
397+ http .NotFound (w , req )
398+ })
399+
400+ rep , err := Tick (context .Background (), r .cfg (home ))
401+ if err == nil {
402+ t .Errorf ("expected error on malformed manifest, got nil" )
403+ }
404+ if rep != nil && len (rep .Outcomes ) > 0 {
405+ t .Errorf ("expected no outcomes on parse failure, got %+v" , rep .Outcomes )
406+ }
407+ mustNotExist (t , filepath .Join (home , ".pilot" , "bin" , "pilot-ask" ))
408+ mustNotExist (t , filepath .Join (home , ".claude" , "skills" , "pilotctl" , "SKILL.md" ))
409+ }
410+
411+ // Helper fetch fails mid-tick — record an error outcome, but still
412+ // continue with skill+marker reconciliation for the tools.
413+ func TestTick_HelperFetchFails_DoesNotBlockTools (t * testing.T ) {
414+ home := t .TempDir ()
415+ mustMkdirAll (t , filepath .Join (home , ".claude" ))
416+
417+ r := newFakeRepo (t )
418+ r .withTools (claudeOnly ())
419+ // Manifest references a helper src path that's NOT served.
420+ r .manifest .Helpers = []ManifestHelper {{
421+ Name : "pilot-ask" ,
422+ Src : "workflow-injection/missing-helper" ,
423+ Dst : "~/.pilot/bin/pilot-ask" ,
424+ Mode : "0755" ,
425+ }}
426+
427+ rep , err := Tick (context .Background (), r .cfg (home ))
428+ if err != nil {
429+ t .Fatalf ("Tick: %v" , err )
430+ }
431+ var helperErr , skillCreate bool
432+ for _ , o := range rep .Outcomes {
433+ if o .Kind == KindHelper && o .Action == ActionError {
434+ helperErr = true
435+ }
436+ if o .Kind == KindSkill && o .Action == ActionCreate {
437+ skillCreate = true
438+ }
439+ }
440+ if ! helperErr {
441+ t .Errorf ("expected helper error outcome, got %+v" , rep .Outcomes )
442+ }
443+ if ! skillCreate {
444+ t .Errorf ("skill should still be created despite helper failure: %+v" , rep .Outcomes )
445+ }
446+ mustNotExist (t , filepath .Join (home , ".pilot" , "bin" , "pilot-ask" ))
447+ mustExist (t , filepath .Join (home , ".claude" , "skills" , "pilotctl" , "SKILL.md" ))
448+ }
449+
450+ // Mode field defaults to 0755 when absent or invalid.
451+ func TestParseFileMode_Defaults (t * testing.T ) {
452+ cases := map [string ]os.FileMode {
453+ "" : 0o755 ,
454+ "0755" : 0o755 ,
455+ "0644" : 0o644 ,
456+ "0700" : 0o700 ,
457+ "garbage" : 0o755 ,
458+ "0" : 0o755 , // zero is treated as default to avoid silently writing unreadable files
459+ }
460+ for input , want := range cases {
461+ if got := parseFileMode (input ); got != want {
462+ t .Errorf ("parseFileMode(%q) = %o, want %o" , input , got , want )
463+ }
464+ }
465+ }
466+
467+ // Helper mode is preserved across ticks: chmod down then back up should
468+ // settle at the manifest-declared mode without rewriting content.
469+ func TestHelper_ModeDriftRewritesFile (t * testing.T ) {
470+ home := t .TempDir ()
471+ mustMkdirAll (t , filepath .Join (home , ".claude" ))
472+
473+ r := newFakeRepo (t )
474+ r .withTools (claudeOnly ())
475+ r .manifest .Helpers = []ManifestHelper {{
476+ Name : "pilot-ask" , Src : "workflow-injection/pilot-ask" ,
477+ Dst : "~/.pilot/bin/pilot-ask" , Mode : "0755" ,
478+ }}
479+ r .files ["workflow-injection/pilot-ask" ] = []byte ("#!/usr/bin/env bash\n echo ok\n " )
480+
481+ if _ , err := Tick (context .Background (), r .cfg (home )); err != nil {
482+ t .Fatalf ("Tick 1: %v" , err )
483+ }
484+ dst := filepath .Join (home , ".pilot" , "bin" , "pilot-ask" )
485+ // User chmods down to 0644
486+ if err := os .Chmod (dst , 0o644 ); err != nil {
487+ t .Fatalf ("chmod: %v" , err )
488+ }
489+ // Next tick should detect mode drift and restore 0755.
490+ if _ , err := Tick (context .Background (), r .cfg (home )); err != nil {
491+ t .Fatalf ("Tick 2: %v" , err )
492+ }
493+ st , _ := os .Stat (dst )
494+ if got := st .Mode ().Perm (); got != 0o755 {
495+ t .Errorf ("mode after rewrite = %o, want 0755" , got )
496+ }
497+ }
498+
337499// Helpers (~/.pilot/bin/pilot-ask) are installed once per tick with the
338500// requested mode, then become noop on subsequent ticks while content
339501// is unchanged, and rewrite when the upstream content drifts.
0 commit comments