Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,7 @@ Current top-level commands are:
- `claw inspect`
- `claw doctor`
- `claw init`
- `claw api` (`schedule` subcommands)
- `claw agent add`
- `claw compose` (use this liberally instead of invoking docker directly)

Expand Down
111 changes: 111 additions & 0 deletions cmd/claw-api/client.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
package main

import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"strings"
"time"

"github.com/mostlydev/clawdapus/internal/clawapi"
)

func runLocalRequest(cfg config, stdout io.Writer, method, requestPath, requestBody, principalName string, timeout time.Duration) error {
store, err := clawapi.LoadStore(cfg.PrincipalsPath)
if err != nil {
return err
}
token, err := principalTokenByName(store, principalName)
if err != nil {
return err
}
if timeout <= 0 {
timeout = 10 * time.Second
}

ctx, cancel := context.WithTimeout(context.Background(), timeout)
defer cancel()

bodyReader := io.Reader(nil)
trimmedBody := strings.TrimSpace(requestBody)
if trimmedBody != "" {
bodyReader = strings.NewReader(trimmedBody)
}
req, err := http.NewRequestWithContext(ctx, strings.ToUpper(strings.TrimSpace(method)), localAPIURL(cfg.Addr, requestPath), bodyReader)
if err != nil {
return fmt.Errorf("build local request: %w", err)
}
if trimmedBody != "" {
req.Header.Set("Content-Type", "application/json")
}
req.Header.Set("Authorization", "Bearer "+token)

resp, err := (&http.Client{}).Do(req)
if err != nil {
return fmt.Errorf("execute local request: %w", err)
}
defer resp.Body.Close()

body, err := io.ReadAll(resp.Body)
if err != nil {
return fmt.Errorf("read local response: %w", err)
}
if err := writeFormattedResponse(stdout, body); err != nil {
return err
}
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return quietExitError{}
}
return nil
}

func principalTokenByName(store *clawapi.Store, principalName string) (string, error) {
if store == nil {
return "", fmt.Errorf("principal store not configured")
}
principalName = strings.TrimSpace(principalName)
if principalName == "" {
return "", fmt.Errorf("principal name must not be empty")
}
for _, principal := range store.Principals {
if strings.TrimSpace(principal.Name) == principalName {
return principal.Token, nil
}
}
return "", fmt.Errorf("principal %q not found", principalName)
}

func localAPIURL(addr, requestPath string) string {
base := strings.TrimSuffix(healthcheckURL(addr), "/health")
requestPath = strings.TrimSpace(requestPath)
if requestPath == "" {
requestPath = "/"
} else if !strings.HasPrefix(requestPath, "/") {
requestPath = "/" + requestPath
}
return base + requestPath
}

func writeFormattedResponse(stdout io.Writer, body []byte) error {
if stdout == nil || len(body) == 0 {
return nil
}
trimmed := bytes.TrimSpace(body)
if len(trimmed) == 0 {
return nil
}
var pretty bytes.Buffer
if json.Indent(&pretty, trimmed, "", " ") == nil {
pretty.WriteByte('\n')
_, err := stdout.Write(pretty.Bytes())
return err
}
if !bytes.HasSuffix(body, []byte{'\n'}) {
body = append(body, '\n')
}
_, err := stdout.Write(body)
return err
}
94 changes: 94 additions & 0 deletions cmd/claw-api/client_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
package main

import (
"bytes"
"io"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
"time"
)

func TestRunLocalRequestUsesNamedPrincipal(t *testing.T) {
var gotAuth string
var gotBody string
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
gotAuth = r.Header.Get("Authorization")
body, err := io.ReadAll(r.Body)
if err != nil {
t.Fatalf("read request body: %v", err)
}
gotBody = string(body)
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"ok":true}`))
}))
defer srv.Close()

principalsPath := writePrincipalsFixture(t, `{"principals":[{"name":"claw-scheduler","token":"capi_sched","verbs":["schedule.read"],"pods":["ops"]}]}`)
cfg := config{
Addr: strings.TrimPrefix(srv.URL, "http://"),
PrincipalsPath: principalsPath,
}

var stdout bytes.Buffer
err := runLocalRequest(cfg, &stdout, http.MethodPost, "/schedule/test/fire", `{"bypass_when":true}`, "claw-scheduler", time.Second)
if err != nil {
t.Fatalf("runLocalRequest: %v", err)
}
if gotAuth != "Bearer capi_sched" {
t.Fatalf("unexpected auth header: %q", gotAuth)
}
if gotBody != `{"bypass_when":true}` {
t.Fatalf("unexpected request body: %q", gotBody)
}
if !strings.Contains(stdout.String(), "\n \"ok\": true\n") {
t.Fatalf("expected pretty JSON output, got %q", stdout.String())
}
}

func TestRunLocalRequestRejectsUnknownPrincipal(t *testing.T) {
principalsPath := writePrincipalsFixture(t, `{"principals":[{"name":"claw-scheduler","token":"capi_sched","verbs":["schedule.read"],"pods":["ops"]}]}`)
cfg := config{Addr: "127.0.0.1:8080", PrincipalsPath: principalsPath}
err := runLocalRequest(cfg, io.Discard, http.MethodGet, "/schedule", "", "missing", time.Second)
if err == nil || !strings.Contains(err.Error(), `principal "missing" not found`) {
t.Fatalf("expected missing principal error, got %v", err)
}
}

func TestRunRequestModeSkipsManifestLoad(t *testing.T) {
var gotAuth string
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
gotAuth = r.Header.Get("Authorization")
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"ok":true}`))
}))
defer srv.Close()

principalsPath := writePrincipalsFixture(t, `{"principals":[{"name":"claw-scheduler","token":"capi_sched","verbs":["schedule.read"],"pods":["ops"]}]}`)
t.Setenv("CLAW_API_ADDR", strings.TrimPrefix(srv.URL, "http://"))
t.Setenv("CLAW_API_PRINCIPALS", principalsPath)
t.Setenv("CLAW_API_MANIFEST", filepath.Join(t.TempDir(), "missing-manifest.json"))

var stdout bytes.Buffer
var stderr bytes.Buffer
err := run([]string{"-request-method", "GET", "-request-path", "/schedule", "-request-principal", "claw-scheduler"}, &stdout, &stderr)
if err != nil {
t.Fatalf("run request mode: %v stderr=%s", err, stderr.String())
}
if gotAuth != "Bearer capi_sched" {
t.Fatalf("unexpected auth header: %q", gotAuth)
}
}

func writePrincipalsFixture(t *testing.T, raw string) string {
t.Helper()
dir := t.TempDir()
path := filepath.Join(dir, "principals.json")
if err := os.WriteFile(path, []byte(raw), 0o644); err != nil {
t.Fatalf("write principals fixture: %v", err)
}
return path
}
23 changes: 23 additions & 0 deletions cmd/claw-api/handler.go
Original file line number Diff line number Diff line change
Expand Up @@ -267,6 +267,8 @@ func (h *apiHandler) handleScheduleControl(w http.ResponseWriter, r *http.Reques
h.handleScheduleResume(w, r, view)
case "skip-next":
h.handleScheduleSkipNext(w, r, view)
case "clear-skip-next":
h.handleScheduleClearSkipNext(w, r, view)
case "fire":
h.handleScheduleFire(w, r, view)
default:
Expand Down Expand Up @@ -398,6 +400,25 @@ func (h *apiHandler) handleScheduleSkipNext(w http.ResponseWriter, r *http.Reque
h.writeScheduleInvocation(w, view.ID)
}

func (h *apiHandler) handleScheduleClearSkipNext(w http.ResponseWriter, r *http.Request, view scheduleInvocationView) {
if h.scheduleState == nil {
writeJSONError(w, http.StatusServiceUnavailable, "schedule state unavailable")
return
}
if err := requireEmptyOrJSONBody(r); err != nil {
writeJSONError(w, http.StatusBadRequest, err.Error())
return
}
if _, err := h.scheduleState.UpdateInvocation(view.ID, func(state *schedulepkg.InvocationState) error {
state.SkipNext = false
return nil
}); err != nil {
writeJSONError(w, http.StatusInternalServerError, err.Error())
return
}
h.writeScheduleInvocation(w, view.ID)
}

func (h *apiHandler) handleScheduleFire(w http.ResponseWriter, r *http.Request, view scheduleInvocationView) {
if h.scheduler == nil {
writeJSONError(w, http.StatusServiceUnavailable, "scheduler unavailable")
Expand Down Expand Up @@ -441,6 +462,8 @@ func decodeOptionalJSONBody(r *http.Request, dst any) error {
return fmt.Errorf("invalid request body")
}
var extra any
// A second successful decode means the stream contained multiple JSON
// documents (for example "{}{}"), which we reject as an invalid body.
if err := dec.Decode(&extra); err != nil {
if errors.Is(err, io.EOF) {
return nil
Expand Down
49 changes: 49 additions & 0 deletions cmd/claw-api/handler_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -497,6 +497,55 @@ func TestHandlerScheduleSkipNextSetsFlag(t *testing.T) {
}
}

func TestHandlerScheduleClearSkipNextClearsFlagIdempotently(t *testing.T) {
manifest := sampleScheduleManifest()
state := newTestScheduleStateStore(t, manifest)
if _, err := state.UpdateInvocation("westin-open", func(state *schedulepkg.InvocationState) error {
state.SkipNext = true
return nil
}); err != nil {
t.Fatalf("seed state: %v", err)
}
h := newScheduleTestHandler(t, manifest, state, nil, clawapi.Principal{
Name: "westin-ops",
Token: "capi_westin_ops",
Verbs: []string{clawapi.VerbScheduleControl},
Services: []string{"westin"},
})

first := postJSON(t, h, "/schedule/westin-open/clear-skip-next", map[string]any{}, "capi_westin_ops")
if first.Code != http.StatusOK {
t.Fatalf("expected 200 on first clear, got %d body=%s", first.Code, first.Body.String())
}
if inv := decodeScheduleInvocationResponse(t, first); inv.State.SkipNext {
t.Fatalf("expected skip_next cleared on first call, got %+v", inv.State)
}

second := postJSON(t, h, "/schedule/westin-open/clear-skip-next", map[string]any{}, "capi_westin_ops")
if second.Code != http.StatusOK {
t.Fatalf("expected 200 on second clear, got %d body=%s", second.Code, second.Body.String())
}
if inv := decodeScheduleInvocationResponse(t, second); inv.State.SkipNext {
t.Fatalf("expected skip_next to remain cleared, got %+v", inv.State)
}
}

func TestHandlerScheduleClearSkipNextHonorsScope(t *testing.T) {
manifest := sampleScheduleManifest()
state := newTestScheduleStateStore(t, manifest)
h := newScheduleTestHandler(t, manifest, state, nil, clawapi.Principal{
Name: "analyst-ops",
Token: "capi_analyst_ops",
Verbs: []string{clawapi.VerbScheduleControl},
Services: []string{"analyst"},
})

w := postJSON(t, h, "/schedule/westin-open/clear-skip-next", map[string]any{}, "capi_analyst_ops")
if w.Code != http.StatusNotFound {
t.Fatalf("expected 404 for out-of-scope clear, got %d body=%s", w.Code, w.Body.String())
}
}

func TestHandlerScheduleFireRespectsPauseWithoutBypass(t *testing.T) {
manifest := sampleScheduleManifest()
state := newTestScheduleStateStore(t, manifest)
Expand Down
19 changes: 19 additions & 0 deletions cmd/claw-api/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import (
"strings"
"syscall"
"time"
_ "time/tzdata"

"github.com/docker/docker/client"

Expand All @@ -31,8 +32,15 @@ type config struct {
GovernanceDir string
}

type quietExitError struct{}

func (quietExitError) Error() string { return "" }

func main() {
if err := run(os.Args[1:], os.Stdout, os.Stderr); err != nil {
if _, ok := err.(quietExitError); ok {
os.Exit(1)
}
log.Fatalf("claw-api: %v", err)
}
}
Expand All @@ -41,6 +49,11 @@ func run(args []string, stdout, stderr io.Writer) error {
fs := flag.NewFlagSet("claw-api", flag.ContinueOnError)
fs.SetOutput(stderr)
healthcheck := fs.Bool("healthcheck", false, "check HTTP server health and exit")
requestMethod := fs.String("request-method", "", "issue a local authenticated request instead of serving HTTP")
requestPath := fs.String("request-path", "", "path to request in local client mode")
requestBody := fs.String("request-body", "", "raw JSON request body for local client mode")
requestPrincipal := fs.String("request-principal", "claw-scheduler", "principal name to use for local client mode")
requestTimeout := fs.Duration("request-timeout", 10*time.Second, "timeout for local client mode requests")
if err := fs.Parse(args); err != nil {
return err
}
Expand All @@ -49,6 +62,12 @@ func run(args []string, stdout, stderr io.Writer) error {
if *healthcheck {
return runHealthcheck(cfg.Addr)
}
if strings.TrimSpace(*requestMethod) != "" || strings.TrimSpace(*requestPath) != "" || strings.TrimSpace(*requestBody) != "" {
if strings.TrimSpace(*requestMethod) == "" || strings.TrimSpace(*requestPath) == "" {
return fmt.Errorf("request-method and request-path are both required for local client mode")
}
return runLocalRequest(cfg, stdout, *requestMethod, *requestPath, *requestBody, *requestPrincipal, *requestTimeout)
}

manifest, err := loadManifest(cfg.ManifestPath)
if err != nil {
Expand Down
Loading
Loading