diff --git a/README.md b/README.md index b1db09e..00c41ea 100644 --- a/README.md +++ b/README.md @@ -16,9 +16,9 @@ Go module: github.com/pdurlej/things-cloud-sdk Preferred CLI: things-cloud-cli Compat CLI: things-cli MCP server: things-mcp -Latest release: v0.2.3 +Latest release: v0.2.4 Focus: safe writes, stable JSON, MCP tools, persistent sync -Maintainer: https://github.com/pdurlej +Co-authors: Piotr Durlej + OpenAI Codex ``` ## What You Can Do @@ -111,8 +111,8 @@ go install github.com/pdurlej/things-cloud-sdk/cmd/things-mcp@latest Use a pinned tag for reproducible agent environments: ```bash -go install github.com/pdurlej/things-cloud-sdk/cmd/things-cloud-cli@v0.2.3 -go install github.com/pdurlej/things-cloud-sdk/cmd/things-mcp@v0.2.3 +go install github.com/pdurlej/things-cloud-sdk/cmd/things-cloud-cli@v0.2.4 +go install github.com/pdurlej/things-cloud-sdk/cmd/things-mcp@v0.2.4 ``` Use the SDK from Go: diff --git a/cmd/things-mcp/main.go b/cmd/things-mcp/main.go index 3bf4690..c414b2e 100644 --- a/cmd/things-mcp/main.go +++ b/cmd/things-mcp/main.go @@ -18,7 +18,7 @@ import ( ) const protocolVersion = "2025-06-18" -const serverVersion = "0.2.3" +const serverVersion = "0.2.4" type rpcRequest struct { JSONRPC string `json:"jsonrpc"` diff --git a/docs/integrations/openclaw-publishing.md b/docs/integrations/openclaw-publishing.md index 0a5fc56..be76917 100644 --- a/docs/integrations/openclaw-publishing.md +++ b/docs/integrations/openclaw-publishing.md @@ -50,9 +50,9 @@ Publish the skill: clawhub skill publish ./skills/things-cloud \ --slug things-cloud \ --name "Things Cloud" \ - --version 0.1.1 \ + --version 0.1.2 \ --tags latest \ - --changelog "Document OpenClaw, Codex, and Claude Code support via MCP with CLI fallback." \ + --changelog "Pin v0.2.4 binaries with fail-closed HTTP parsing and serialized persistent sync." \ --clawscan-note "This skill installs and calls the maintained things-cloud-cli and things-mcp Go binaries. It requires Things Cloud credentials through THINGS_USERNAME plus THINGS_TOKEN or THINGS_PASSWORD. Agent writes should use dry-run before execution." ``` @@ -99,7 +99,7 @@ Things Cloud. ## Links - SDK/CLI/MCP: https://github.com/pdurlej/things-cloud-sdk -- Release: https://github.com/pdurlej/things-cloud-sdk/releases/tag/v0.2.3 +- Release: https://github.com/pdurlej/things-cloud-sdk/releases/tag/v0.2.4 - Skill: https://github.com/pdurlej/things-cloud-sdk/tree/main/skills/things-cloud ## Capabilities @@ -115,8 +115,8 @@ Things Cloud. ## Setup ```bash -go install github.com/pdurlej/things-cloud-sdk/cmd/things-cloud-cli@v0.2.3 -go install github.com/pdurlej/things-cloud-sdk/cmd/things-mcp@v0.2.3 +go install github.com/pdurlej/things-cloud-sdk/cmd/things-cloud-cli@v0.2.4 +go install github.com/pdurlej/things-cloud-sdk/cmd/things-mcp@v0.2.4 export THINGS_USERNAME="you@example.com" export THINGS_TOKEN="your-things-cloud-password-or-token-alias" ``` diff --git a/histories.go b/histories.go index 78cebf1..aeb0732 100644 --- a/histories.go +++ b/histories.go @@ -258,7 +258,7 @@ func (h *History) Write(items ...Identifiable) error { } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { - return fmt.Errorf("write failed: %s", resp.Status) + return fmt.Errorf("Write failed: %s", resp.Status) } rs, err := io.ReadAll(resp.Body) if err != nil { diff --git a/histories_test.go b/histories_test.go index b0532db..f607802 100644 --- a/histories_test.go +++ b/histories_test.go @@ -4,6 +4,7 @@ import ( "fmt" "net/http" "net/http/httptest" + "strings" "testing" ) @@ -149,6 +150,18 @@ func TestHistory_WriteRejectsInvalidHistoryID(t *testing.T) { } } +func TestHistory_WritePreservesErrorPrefix(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusConflict) + })) + defer server.Close() + + err := New(server.URL, "test@example.com", "secret").HistoryWithID("history-id").Write() + if err == nil || !strings.HasPrefix(err.Error(), "Write failed:") { + t.Fatalf("Write error = %v, want legacy prefix", err) + } +} + func TestHistoryOperationsRejectMalformedJSON(t *testing.T) { tests := []struct { name string diff --git a/items.go b/items.go index 59388f5..a9eb76c 100644 --- a/items.go +++ b/items.go @@ -40,14 +40,13 @@ type ItemsOptions struct { // Note that if a item was changed multiple times it will be present multiple times in the result too. func (h *History) Items(opts ItemsOptions) ([]Item, bool, error) { req, err := http.NewRequest("GET", fmt.Sprintf("/version/1/history/%s/items", h.ID), nil) + if err != nil { + return nil, false, err + } values := req.URL.Query() values.Set("start-index", strconv.Itoa(opts.StartIndex)) req.URL.RawQuery = values.Encode() - - if err != nil { - return nil, false, err - } resp, err := h.Client.do(req) if err != nil { return nil, false, err @@ -64,12 +63,24 @@ func (h *History) Items(opts ItemsOptions) ([]Item, bool, error) { } var v itemsResponse if err := json.Unmarshal(bs, &v); err != nil { - return nil, false, err + return nil, false, fmt.Errorf("decoding items response: %w", err) + } + if v.Items == nil { + return nil, false, fmt.Errorf("items response is missing items") + } + if len(v.Items) == 0 && opts.StartIndex < v.CurrentItemIndex { + return nil, false, fmt.Errorf("items response stalled at index %d before server index %d", opts.StartIndex, v.CurrentItemIndex) } var items = []Item{} for offset, m := range v.Items { + if len(m) == 0 { + return nil, false, fmt.Errorf("items response contains an empty batch at index %d", opts.StartIndex+offset) + } serverIndex := opts.StartIndex + offset for id, item := range m { + if id == "" || item.Kind == "" { + return nil, false, fmt.Errorf("items response contains an invalid item at index %d", serverIndex) + } item.UUID = id item.ServerIndex = serverIndex item.HasServerIndex = true diff --git a/items_test.go b/items_test.go index 1ba161e..9880857 100644 --- a/items_test.go +++ b/items_test.go @@ -98,4 +98,73 @@ func TestHistory_Items(t *testing.T) { t.Errorf("task-c ServerIndex = %d, want 11", indexByUUID["task-c"]) } }) + + t.Run("InvalidHistoryID", func(t *testing.T) { + c := New("https://example.com", "test@example.com", "secret") + h := c.HistoryWithID("invalid\nvalue") + if _, _, err := h.Items(ItemsOptions{}); err == nil { + t.Fatal("Items succeeded with an invalid history ID") + } + }) + + t.Run("RejectsMalformedPages", func(t *testing.T) { + tests := []struct { + name string + body string + }{ + {"missing items", `{"current-item-index":0,"schema":301}`}, + {"stalled page", `{"items":[],"current-item-index":2,"schema":301}`}, + {"empty batch", `{"items":[{}],"current-item-index":1,"schema":301}`}, + {"missing kind", `{"items":[{"task-a":{"t":0,"p":{}}}],"current-item-index":1,"schema":301}`}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + fmt.Fprint(w, tt.body) + })) + defer server.Close() + + h := New(server.URL, "test@example.com", "secret").HistoryWithID("history-id") + if _, _, err := h.Items(ItemsOptions{}); err == nil { + t.Fatal("Items accepted a malformed page") + } + }) + } + }) + + t.Run("AllowsEmptyCaughtUpPage", func(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + fmt.Fprint(w, `{"items":[],"current-item-index":4,"schema":301}`) + })) + defer server.Close() + + h := New(server.URL, "test@example.com", "secret").HistoryWithID("history-id") + items, more, err := h.Items(ItemsOptions{StartIndex: 4}) + if err != nil { + t.Fatalf("Items rejected a valid caught-up page: %v", err) + } + if len(items) != 0 || more { + t.Fatalf("Items returned items=%d more=%v, want empty and caught up", len(items), more) + } + }) + + t.Run("AcceptsDeletionAndTombstoneKinds", func(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + fmt.Fprint(w, `{"items":[{"deleted-task":{"e":"Task6","t":2,"p":{}}},{"tombstone":{"e":"Tombstone2","t":0,"p":{"do":"deleted-task"}}}],"current-item-index":2,"schema":301}`) + })) + defer server.Close() + + h := New(server.URL, "test@example.com", "secret").HistoryWithID("history-id") + items, _, err := h.Items(ItemsOptions{}) + if err != nil { + t.Fatalf("Items rejected valid deletion kinds: %v", err) + } + if len(items) != 2 || items[0].Kind != ItemKindTask || items[1].Kind != ItemKindTombstone { + t.Fatalf("Items returned unexpected deletion kinds: %#v", items) + } + }) } diff --git a/skills/things-cloud/SKILL.md b/skills/things-cloud/SKILL.md index 4cb65fd..8b3a1bf 100644 --- a/skills/things-cloud/SKILL.md +++ b/skills/things-cloud/SKILL.md @@ -1,7 +1,7 @@ --- name: things-cloud description: Manage Things 3 tasks through Things Cloud using the maintained things-cloud-sdk CLI and MCP server, with dry-run safety for agent writes. -version: 0.1.1 +version: 0.1.2 metadata: openclaw: requires: @@ -27,11 +27,11 @@ metadata: description: Optional path to the CLI read-state cache. install: - kind: go - package: github.com/pdurlej/things-cloud-sdk/cmd/things-cloud-cli@v0.2.3 + package: github.com/pdurlej/things-cloud-sdk/cmd/things-cloud-cli@v0.2.4 bins: - things-cloud-cli - kind: go - package: github.com/pdurlej/things-cloud-sdk/cmd/things-mcp@v0.2.3 + package: github.com/pdurlej/things-cloud-sdk/cmd/things-mcp@v0.2.4 bins: - things-mcp homepage: https://github.com/pdurlej/things-cloud-sdk @@ -53,8 +53,8 @@ when the user asks for explicit shell commands. Install the maintained CLI and MCP server: ```bash -go install github.com/pdurlej/things-cloud-sdk/cmd/things-cloud-cli@v0.2.3 -go install github.com/pdurlej/things-cloud-sdk/cmd/things-mcp@v0.2.3 +go install github.com/pdurlej/things-cloud-sdk/cmd/things-cloud-cli@v0.2.4 +go install github.com/pdurlej/things-cloud-sdk/cmd/things-mcp@v0.2.4 ``` Set credentials: diff --git a/sync/process.go b/sync/process.go index e9814c5..e3e7f5f 100644 --- a/sync/process.go +++ b/sync/process.go @@ -24,10 +24,12 @@ func (s *Syncer) processItems(items []things.Item, baseIndex int) ([]Change, err } defer tx.Rollback() // No-op if committed - // Store original db, swap in transaction - origDB := s.db - s.db = tx - defer func() { s.db = origDB }() + batch := &Syncer{ + rawDB: s.rawDB, + db: tx, + client: s.client, + history: s.history, + } var allChanges []Change @@ -38,7 +40,7 @@ func (s *Syncer) processItems(items []things.Item, baseIndex int) ([]Change, err } ts := time.Now() - changes, err := s.processItem(item, serverIndex, ts) + changes, err := batch.processItem(item, serverIndex, ts) if err != nil { return nil, fmt.Errorf("processing item %s: %w", item.UUID, err) } @@ -46,7 +48,7 @@ func (s *Syncer) processItems(items []things.Item, baseIndex int) ([]Change, err // Log each change for _, change := range changes { payload, _ := json.Marshal(item.P) - if err := s.logChange(serverIndex, change, string(payload)); err != nil { + if err := batch.logChange(serverIndex, change, string(payload)); err != nil { return nil, fmt.Errorf("logging change: %w", err) } } diff --git a/sync/sync.go b/sync/sync.go index 8534fb1..90edfb3 100644 --- a/sync/sync.go +++ b/sync/sync.go @@ -5,6 +5,7 @@ package sync import ( "database/sql" "strings" + "sync" "time" things "github.com/pdurlej/things-cloud-sdk" @@ -29,6 +30,7 @@ type Syncer struct { db dbExecutor // current executor (db or tx) client *things.Client history *things.History + syncMu sync.Mutex } type syncOptions struct { @@ -93,6 +95,9 @@ func (s *Syncer) QuickSync() ([]Change, error) { } func (s *Syncer) syncWithOptions(opts syncOptions) ([]Change, error) { + s.syncMu.Lock() + defer s.syncMu.Unlock() + // Get current sync state first storedHistoryID, startIndex, err := s.getSyncState() if err != nil { diff --git a/verify.go b/verify.go index b957773..7a84ba5 100644 --- a/verify.go +++ b/verify.go @@ -28,10 +28,10 @@ type VerifyResponse struct { // Verify checks that the provided API credentials are valid. func (c *Client) Verify() (*VerifyResponse, error) { req, err := http.NewRequest("GET", fmt.Sprintf("/version/1/account/%s", c.EMail), nil) - req.Header.Set("Authorization", fmt.Sprintf("Password %s", c.password)) if err != nil { return nil, err } + req.Header.Set("Authorization", fmt.Sprintf("Password %s", c.password)) resp, err := c.do(req) if err != nil { return nil, err @@ -48,6 +48,8 @@ func (c *Client) Verify() (*VerifyResponse, error) { if err != nil { return nil, err } - json.Unmarshal(bs, &v) + if err := json.Unmarshal(bs, &v); err != nil { + return nil, fmt.Errorf("decoding verify response: %w", err) + } return &v, nil } diff --git a/verify_test.go b/verify_test.go index 06c05b9..49fa4f3 100644 --- a/verify_test.go +++ b/verify_test.go @@ -2,6 +2,8 @@ package thingscloud import ( "fmt" + "net/http" + "net/http/httptest" "testing" ) @@ -32,4 +34,23 @@ func TestClient_Verify(t *testing.T) { t.Error("Expected Verification to fail, but didn't") } }) + + t.Run("InvalidEmail", func(t *testing.T) { + c := New("https://example.com", "invalid\nemail", "secret") + if _, err := c.Verify(); err == nil { + t.Fatal("Verify succeeded with an invalid email") + } + }) + + t.Run("MalformedJSON", func(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte("not-json")) + })) + defer server.Close() + + if _, err := New(server.URL, "test@example.com", "secret").Verify(); err == nil { + t.Fatal("Verify accepted malformed JSON") + } + }) }