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 CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
## Unreleased

- Add bounded AWS provisioning transport diagnostics for credential preparation, signing and SDK request time, including retry-loop signing counts, without changing retry behavior. [PR 1968](https://github.com/openclaw/crabbox/pull/1968). Thanks @steipete.
- Islo: report failed stdout/stderr delivery instead of silently succeeding after losing command output; preserve remote exit codes when stream decoding and delivery finish successfully. [PR 1969](https://github.com/openclaw/crabbox/pull/1969).

## 0.52.0 - 2026-09-07

Expand Down
7 changes: 5 additions & 2 deletions docs/providers/islo.md
Original file line number Diff line number Diff line change
Expand Up @@ -167,8 +167,11 @@ Post-admission Tailscale errors retain their actual causes while preserving the
existing public codes and messages, including fallback `1` for opaque validation
errors. A status code alone does not create a context-cancellation cause.

Observed SSE command exits keep their exact codes. Exec transport/cancellation
failures return `1` with their known failure origin and reachable context cause.
Observed SSE command exits keep their exact codes when stream decoding and output
delivery complete successfully. A stream read, decode, or stdout/stderr delivery
failure returns `1` even after an exit event was observed. Transport and cancellation
failures also return `1` with their known failure origin and reachable cause.
Closing the stream does not establish that the remote process stopped.
Setup/helper failures preserve their public code without being mislabeled as
user-command exits. Required-artifact and download failures after command success
are provider errors: required-artifact failures keep `7`, and local download-write
Expand Down
102 changes: 102 additions & 0 deletions internal/providers/islo/backend_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import (
"strconv"
"strings"
"testing"
"testing/iotest"
"time"

gosdk "github.com/islo-labs/go-sdk"
Expand Down Expand Up @@ -108,6 +109,107 @@ func TestParseIsloSSERejectsInvalidExitEvent(t *testing.T) {
}
}

type isloOutputFailureWriter struct{ err error }

func (w isloOutputFailureWriter) Write([]byte) (int, error) { return 0, w.err }

func TestParseIsloSSEPropagatesOutputWriteFailure(t *testing.T) {
writeErr := errors.New("output destination rejected bytes")
for _, stream := range []string{"stdout", "stderr"} {
for _, position := range []string{"before exit", "after exit", "final EOF flush"} {
t.Run(stream+"/"+position, func(t *testing.T) {
output := "event: " + stream + "\ndata: command output"
body := output + "\n\nevent: exit\ndata: 0\n\n"
if position == "after exit" {
body = "event: exit\ndata: 23\n\n" + output + "\n\n"
} else if position == "final EOF flush" {
body = "event: exit\ndata: 137\n\n" + output
}
var stdout, stderr io.Writer = io.Discard, io.Discard
if stream == "stdout" {
stdout = isloOutputFailureWriter{writeErr}
} else {
stderr = isloOutputFailureWriter{writeErr}
}
code, err := parseIsloSSE(strings.NewReader(body), stdout, stderr)
if code != 1 || err != writeErr {
t.Fatalf("code=%d err=%v, want code 1 and original writer error", code, err)
}
})
}
}
}

func TestParseIsloSSEReadOnlyOutputFile(t *testing.T) {
path := filepath.Join(t.TempDir(), "output")
if err := os.WriteFile(path, nil, 0600); err != nil {
t.Fatal(err)
}
file, err := os.Open(path)
if err != nil {
t.Fatal(err)
}
defer file.Close()
_, writeErr := file.Write([]byte("probe"))
var pathErr *os.PathError
if !errors.As(writeErr, &pathErr) {
t.Fatalf("read-only file write error=%v", writeErr)
}
for _, stream := range []string{"stdout", "stderr"} {
t.Run(stream, func(t *testing.T) {
var stdout, stderr io.Writer = io.Discard, io.Discard
if stream == "stdout" {
stdout = file
} else {
stderr = file
}
body := "event: " + stream + "\ndata: command output\n\nevent: exit\ndata: 0\n\n"
code, err := parseIsloSSE(strings.NewReader(body), stdout, stderr)
if code != 1 || !errors.Is(err, pathErr.Err) {
t.Fatalf("code=%d err=%v, want code 1 and file error %v", code, err, pathErr.Err)
}
})
}
}

func TestParseIsloSSEPreservesCompletionRules(t *testing.T) {
readErr := errors.New("stream disconnected")
for _, tc := range []struct {
name string
body string
readErr error
code int
output string
errText string
}{
{name: "multiline comments and final EOF", body: ": keepalive\r\nevent: stdout\r\ndata: first\r\nid: ignored\r\ndata: second\r\n\r\nevent: exit\ndata: -1", code: -1, output: "first\nsecond"},
{name: "last exit wins", body: "event: exit\ndata: 7\n\nevent: stdout\ndata: between\n\nevent: exit\ndata: 23", code: 23, output: "between"},
{name: "error event with exit", body: "event: exit\ndata: 0\n\nevent: error\ndata: diagnostic", code: 0},
{name: "read error after exit", body: "event: exit\ndata: 23\n\n", readErr: readErr, code: 1, errText: "stream disconnected"},
{name: "invalid exit after exit", body: "event: exit\ndata: 23\n\nevent: exit\ndata: invalid", code: 1, errText: "invalid exit event"},
{name: "final decode error precedes read error", body: "event: exit\ndata: invalid", readErr: readErr, code: 1, errText: "invalid exit event"},
} {
t.Run(tc.name, func(t *testing.T) {
var reader io.Reader = strings.NewReader(tc.body)
if tc.readErr != nil {
reader = io.MultiReader(reader, iotest.ErrReader(tc.readErr))
}
var stdout bytes.Buffer
code, err := parseIsloSSE(reader, &stdout, io.Discard)
if code != tc.code || stdout.String() != tc.output {
t.Fatalf("code=%d output=%q, want %d/%q", code, stdout.String(), tc.code, tc.output)
}
if tc.errText == "" {
if err != nil {
t.Fatal(err)
}
} else if err == nil || !strings.Contains(err.Error(), tc.errText) {
t.Fatalf("err=%v, want %q", err, tc.errText)
}
})
}
}

func TestIsloExecCommandPreservesShellString(t *testing.T) {
got, err := isloExecCommand([]string{"pnpm install && pnpm test"}, true)
if err != nil {
Expand Down
8 changes: 6 additions & 2 deletions internal/providers/islo/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -511,9 +511,13 @@ func parseIsloSSE(r io.Reader, stdout, stderr io.Writer, secrets ...string) (int
payload := strings.Join(data, "\n")
switch event {
case "stdout":
_, _ = stdout.Write([]byte(payload))
if _, err := stdout.Write([]byte(payload)); err != nil {
return err
}
case "stderr":
_, _ = stderr.Write([]byte(payload))
if _, err := stderr.Write([]byte(payload)); err != nil {
return err
}
case "exit":
n, err := strconv.Atoi(strings.TrimSpace(payload))
if err != nil {
Expand Down
Loading