* fix(security): call redactSecrets before seeding workspace memories (F1085) seedInitialMemories() in workspace_provision.go was inserting template/config memories directly into agent_memories without scrubbing credential patterns. A workspace provisioned from a template containing API keys, tokens, or other secrets would store them in plain text — the same class of issue as #838. Fix: call redactSecrets(workspaceID, content) on the truncated memory content before the INSERT. The truncation (maxMemoryContentLength = 100 KiB, CWE-400) is preserved — redaction runs after truncation so the size limit still applies. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * test(workspace_provision): add seedInitialMemories coverage for #1208 Cover the truncate-at-100k boundary (PR #1167, CWE-400) and the redactSecrets call (F1085 / #1132), both identified as untested in #1208. - TestSeedInitialMemories_TruncatesOversizedContent: boundary at exactly 100k, 1 byte over, far over, and well under. Verifies INSERT receives exactly maxMemoryContentLength bytes. - TestSeedInitialMemories_RedactsSecrets: verifies redactSecrets runs before INSERT, regression test for F1085. - TestSeedInitialMemories_InvalidScopeSkipped: invalid scope is silently skipped, no INSERT called. - TestSeedInitialMemories_EmptyMemoriesNil: nil slice is handled without DB calls. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * docs(marketing): Discord adapter launch visual assets (#1209) Squash-merge: Discord adapter launch visual assets (3 PNGs) + social copy. Acceptance: assets on staging. * fix(ci): golangci-lint errcheck failures on staging Suppress errcheck warnings for calls where the return value is safely ignored: - resp.Body.Close() (artifacts/client.go): deferred cleanup — failure to close a response body is non-critical; the defer itself is what matters for connection reuse. - rows.Close() (bundle/exporter.go): deferred cleanup in a loop where rows.Err() already handles query errors. - filepath.Walk (bundle/exporter.go): top-level walk call; errors in sub-directory traversal are handled by the inner callback (which returns nil for err != nil). - broadcaster.RecordAndBroadcast (bundle/importer.go): fire-and-forget event broadcast; errors are logged internally by the broadcaster. - db.DB.ExecContext (bundle/importer.go): best-effort runtime column update; non-critical auxiliary data that the provisioner re-extracts if needed. Fixes: #1143 * test(artifacts): suppress w.Write return values to satisfy errcheck All httptest.ResponseWriter.Write calls in client_test.go now discard the byte count and error return with _, _ = prefix. The Write method is safe to discard in test handlers — httptest.ResponseWriter.Write never returns an error for in-memory buffers. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix(CI): move changes job off self-hosted runner + add workflow concurrency Cherry-pick from staging PR #1194 for main. Two changes to relieve macOS arm64 runner saturation: 1. `changes` job: runs on ubuntu-latest instead of [self-hosted, macos, arm64]. This job does a plain `git diff` with zero macOS dependencies — moving it off the runner frees a slot immediately on every workflow trigger. 2. Add workflow-level concurrency: concurrency: group: ci-${{ github.ref }}; cancel-in-progress: true Prevents multiple stale in-flight CI runs from queuing on the same ref when new commits arrive. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix(security): call redactSecrets before seeding workspace memories (F1085) (#1203) seedInitialMemories() in workspace_provision.go was inserting template/config memories directly into agent_memories without scrubbing credential patterns. A workspace provisioned from a template containing API keys, tokens, or other secrets would store them in plain text — the same class of issue as #838. Fix: call redactSecrets(workspaceID, content) on the truncated memory content before the INSERT. The truncation (maxMemoryContentLength = 100 KiB, CWE-400) is preserved — redaction runs after truncation so the size limit still applies. Co-authored-by: Molecule AI Core-BE <core-be@agents.moleculesai.app> Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com> * tick: 2026-04-21 ~03:40Z — CI stalled 59+ min, GH_TOKEN 4th rotation, PR reviews done * fix(tenant-guard): allowlist /registry/register + /registry/heartbeat Final layer of today's stuck-provisioning saga. With the private-IP platform_url fix and the intra-VPC :8080 SG rule in place, workspace EC2s finally reached the tenant on the right port — only to have every POST bounced with a synthetic 404 by TenantGuard. TenantGuard is the SaaS hook that rejects cross-tenant routing. It demands X-Molecule-Org-Id on every request, but CP's workspace user- data doesn't export MOLECULE_ORG_ID (only WORKSPACE_ID, PLATFORM_URL, RUNTIME, PORT), so the runtime can't attach the header. Net effect: every workspace's first heartbeat to /registry/heartbeat was a silent 404, and the workspace sat in 'provisioning' until the platform sweeper timed it out. Allowlist the two workspace-boot paths: - /registry/register — one-shot at runtime startup - /registry/heartbeat — every 30s Both are still gated by wsauth.HasAnyLiveToken (workspaces with a token on file must present it; legacy tokenless workspaces are grandfathered). And the tenant SG already scopes :8080 to the VPC CIDR, so only intra-VPC callers can reach these paths in the first place. The allowlist bypasses cross-org routing, not auth. Follow-up: passing MOLECULE_ORG_ID into the workspace env would let the runtime attach the header and drop this allowlist entry. Tracked separately; not urgent since the multi-layer auth above is already adequate. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Molecule AI Core-BE <core-be@agents.moleculesai.app> Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com> Co-authored-by: Molecule AI Infra-SRE <infra-sre@agents.moleculesai.app> Co-authored-by: molecule-ai[bot] <276602405+molecule-ai[bot]@users.noreply.github.com> Co-authored-by: Molecule AI Core-DevOps <core-devops@agents.moleculesai.app> Co-authored-by: Molecule AI Core-UIUX <core-uiux@agents.moleculesai.app> Co-authored-by: Hongming Wang <hongmingwang.rabbit@users.noreply.github.com>
371 lines
11 KiB
Go
371 lines
11 KiB
Go
package artifacts_test
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/Molecule-AI/molecule-monorepo/platform/internal/artifacts"
|
|
)
|
|
|
|
// cfEnvelope wraps a result value in the Cloudflare v4 response envelope.
|
|
func cfEnvelope(t *testing.T, result interface{}) []byte {
|
|
t.Helper()
|
|
b, err := json.Marshal(result)
|
|
if err != nil {
|
|
t.Fatalf("cfEnvelope: marshal result: %v", err)
|
|
}
|
|
env := map[string]interface{}{
|
|
"success": true,
|
|
"result": json.RawMessage(b),
|
|
"errors": []interface{}{},
|
|
}
|
|
out, err := json.Marshal(env)
|
|
if err != nil {
|
|
t.Fatalf("cfEnvelope: marshal envelope: %v", err)
|
|
}
|
|
return out
|
|
}
|
|
|
|
// cfError returns a Cloudflare v4 error envelope.
|
|
func cfError(t *testing.T, statusCode, code int, message string) ([]byte, int) {
|
|
t.Helper()
|
|
env := map[string]interface{}{
|
|
"success": false,
|
|
"result": nil,
|
|
"errors": []map[string]interface{}{
|
|
{"code": code, "message": message},
|
|
},
|
|
}
|
|
b, _ := json.Marshal(env)
|
|
return b, statusCode
|
|
}
|
|
|
|
func newTestClient(t *testing.T, mux *http.ServeMux) *artifacts.Client {
|
|
t.Helper()
|
|
srv := httptest.NewServer(mux)
|
|
t.Cleanup(srv.Close)
|
|
return artifacts.NewWithBaseURL("test-token", "test-ns", srv.URL)
|
|
}
|
|
|
|
// ---- CreateRepo ----------------------------------------------------------
|
|
|
|
func TestCreateRepo_Success(t *testing.T) {
|
|
mux := http.NewServeMux()
|
|
mux.HandleFunc("/namespaces/test-ns/repos", func(w http.ResponseWriter, r *http.Request) {
|
|
if r.Method != http.MethodPost {
|
|
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
|
return
|
|
}
|
|
// Verify auth header
|
|
if r.Header.Get("Authorization") != "Bearer test-token" {
|
|
http.Error(w, "unauthorized", http.StatusUnauthorized)
|
|
return
|
|
}
|
|
// Decode request body
|
|
var req map[string]interface{}
|
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
http.Error(w, "bad request", http.StatusBadRequest)
|
|
return
|
|
}
|
|
if req["name"] != "my-workspace-repo" {
|
|
http.Error(w, "unexpected name", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
repo := artifacts.Repo{
|
|
Name: "my-workspace-repo",
|
|
ID: "repo-abc123",
|
|
RemoteURL: "https://x:tok@hash.artifacts.cloudflare.net/git/repo-abc123.git",
|
|
CreatedAt: time.Now(),
|
|
}
|
|
w.Header().Set("Content-Type", "application/json")
|
|
_, _ = w.Write(cfEnvelope(t, repo))
|
|
})
|
|
|
|
client := newTestClient(t, mux)
|
|
repo, err := client.CreateRepo(context.Background(), artifacts.CreateRepoRequest{
|
|
Name: "my-workspace-repo",
|
|
Description: "Molecule AI workspace snapshot",
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("CreateRepo: unexpected error: %v", err)
|
|
}
|
|
if repo.Name != "my-workspace-repo" {
|
|
t.Errorf("repo.Name = %q, want %q", repo.Name, "my-workspace-repo")
|
|
}
|
|
if repo.ID != "repo-abc123" {
|
|
t.Errorf("repo.ID = %q, want %q", repo.ID, "repo-abc123")
|
|
}
|
|
if repo.RemoteURL == "" {
|
|
t.Error("repo.RemoteURL is empty, want non-empty")
|
|
}
|
|
}
|
|
|
|
func TestCreateRepo_APIError(t *testing.T) {
|
|
mux := http.NewServeMux()
|
|
mux.HandleFunc("/namespaces/test-ns/repos", func(w http.ResponseWriter, r *http.Request) {
|
|
body, status := cfError(t, http.StatusConflict, 1009, "repo already exists")
|
|
w.Header().Set("Content-Type", "application/json")
|
|
w.WriteHeader(status)
|
|
_, _ = w.Write(body)
|
|
})
|
|
|
|
client := newTestClient(t, mux)
|
|
_, err := client.CreateRepo(context.Background(), artifacts.CreateRepoRequest{Name: "dup"})
|
|
if err == nil {
|
|
t.Fatal("expected error, got nil")
|
|
}
|
|
apiErr, ok := err.(*artifacts.APIError)
|
|
if !ok {
|
|
t.Fatalf("expected *APIError, got %T: %v", err, err)
|
|
}
|
|
if apiErr.StatusCode != http.StatusConflict {
|
|
t.Errorf("StatusCode = %d, want %d", apiErr.StatusCode, http.StatusConflict)
|
|
}
|
|
if apiErr.Message != "repo already exists" {
|
|
t.Errorf("Message = %q, want %q", apiErr.Message, "repo already exists")
|
|
}
|
|
}
|
|
|
|
// ---- GetRepo -------------------------------------------------------------
|
|
|
|
func TestGetRepo_Success(t *testing.T) {
|
|
mux := http.NewServeMux()
|
|
mux.HandleFunc("/namespaces/test-ns/repos/my-repo", func(w http.ResponseWriter, r *http.Request) {
|
|
if r.Method != http.MethodGet {
|
|
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
|
return
|
|
}
|
|
repo := artifacts.Repo{
|
|
Name: "my-repo",
|
|
ID: "repo-xyz",
|
|
RemoteURL: "https://x:tok@hash.artifacts.cloudflare.net/git/repo-xyz.git",
|
|
}
|
|
w.Header().Set("Content-Type", "application/json")
|
|
_, _ = w.Write(cfEnvelope(t, repo))
|
|
})
|
|
|
|
client := newTestClient(t, mux)
|
|
repo, err := client.GetRepo(context.Background(), "my-repo")
|
|
if err != nil {
|
|
t.Fatalf("GetRepo: unexpected error: %v", err)
|
|
}
|
|
if repo.Name != "my-repo" {
|
|
t.Errorf("repo.Name = %q, want %q", repo.Name, "my-repo")
|
|
}
|
|
}
|
|
|
|
func TestGetRepo_NotFound(t *testing.T) {
|
|
mux := http.NewServeMux()
|
|
mux.HandleFunc("/namespaces/test-ns/repos/missing", func(w http.ResponseWriter, r *http.Request) {
|
|
body, status := cfError(t, http.StatusNotFound, 1004, "repo not found")
|
|
w.Header().Set("Content-Type", "application/json")
|
|
w.WriteHeader(status)
|
|
_, _ = w.Write(body)
|
|
})
|
|
|
|
client := newTestClient(t, mux)
|
|
_, err := client.GetRepo(context.Background(), "missing")
|
|
if err == nil {
|
|
t.Fatal("expected error, got nil")
|
|
}
|
|
apiErr, ok := err.(*artifacts.APIError)
|
|
if !ok {
|
|
t.Fatalf("expected *APIError, got %T", err)
|
|
}
|
|
if apiErr.StatusCode != http.StatusNotFound {
|
|
t.Errorf("StatusCode = %d, want %d", apiErr.StatusCode, http.StatusNotFound)
|
|
}
|
|
}
|
|
|
|
// ---- ForkRepo ------------------------------------------------------------
|
|
|
|
func TestForkRepo_Success(t *testing.T) {
|
|
mux := http.NewServeMux()
|
|
mux.HandleFunc("/namespaces/test-ns/repos/source-repo/fork", func(w http.ResponseWriter, r *http.Request) {
|
|
if r.Method != http.MethodPost {
|
|
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
|
return
|
|
}
|
|
var req map[string]interface{}
|
|
json.NewDecoder(r.Body).Decode(&req)
|
|
if req["name"] != "forked-repo" {
|
|
http.Error(w, "unexpected fork name", http.StatusBadRequest)
|
|
return
|
|
}
|
|
result := artifacts.ForkResult{
|
|
Repo: artifacts.Repo{
|
|
Name: "forked-repo",
|
|
ID: "repo-fork-1",
|
|
RemoteURL: "https://x:tok@hash.artifacts.cloudflare.net/git/repo-fork-1.git",
|
|
},
|
|
ObjectCount: 42,
|
|
}
|
|
w.Header().Set("Content-Type", "application/json")
|
|
_, _ = w.Write(cfEnvelope(t, result))
|
|
})
|
|
|
|
client := newTestClient(t, mux)
|
|
result, err := client.ForkRepo(context.Background(), "source-repo", artifacts.ForkRepoRequest{
|
|
Name: "forked-repo",
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("ForkRepo: unexpected error: %v", err)
|
|
}
|
|
if result.Repo.Name != "forked-repo" {
|
|
t.Errorf("Repo.Name = %q, want %q", result.Repo.Name, "forked-repo")
|
|
}
|
|
if result.ObjectCount != 42 {
|
|
t.Errorf("ObjectCount = %d, want 42", result.ObjectCount)
|
|
}
|
|
}
|
|
|
|
// ---- ImportRepo ----------------------------------------------------------
|
|
|
|
func TestImportRepo_Success(t *testing.T) {
|
|
mux := http.NewServeMux()
|
|
mux.HandleFunc("/namespaces/test-ns/repos/imported/import", func(w http.ResponseWriter, r *http.Request) {
|
|
if r.Method != http.MethodPost {
|
|
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
|
return
|
|
}
|
|
var req map[string]interface{}
|
|
json.NewDecoder(r.Body).Decode(&req)
|
|
if req["url"] == "" {
|
|
http.Error(w, "url required", http.StatusBadRequest)
|
|
return
|
|
}
|
|
repo := artifacts.Repo{
|
|
Name: "imported",
|
|
ID: "repo-imp-1",
|
|
RemoteURL: "https://x:tok@hash.artifacts.cloudflare.net/git/repo-imp-1.git",
|
|
}
|
|
w.Header().Set("Content-Type", "application/json")
|
|
_, _ = w.Write(cfEnvelope(t, repo))
|
|
})
|
|
|
|
client := newTestClient(t, mux)
|
|
repo, err := client.ImportRepo(context.Background(), "imported", artifacts.ImportRepoRequest{
|
|
URL: "https://github.com/Molecule-AI/molecule-core.git",
|
|
Branch: "main",
|
|
Depth: 1,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("ImportRepo: unexpected error: %v", err)
|
|
}
|
|
if repo.Name != "imported" {
|
|
t.Errorf("repo.Name = %q, want %q", repo.Name, "imported")
|
|
}
|
|
}
|
|
|
|
// ---- DeleteRepo ----------------------------------------------------------
|
|
|
|
func TestDeleteRepo_Success(t *testing.T) {
|
|
mux := http.NewServeMux()
|
|
mux.HandleFunc("/namespaces/test-ns/repos/to-delete", func(w http.ResponseWriter, r *http.Request) {
|
|
if r.Method != http.MethodDelete {
|
|
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
|
return
|
|
}
|
|
deleted := map[string]string{"id": "repo-del-1"}
|
|
w.Header().Set("Content-Type", "application/json")
|
|
w.WriteHeader(http.StatusAccepted)
|
|
_, _ = w.Write(cfEnvelope(t, deleted))
|
|
})
|
|
|
|
client := newTestClient(t, mux)
|
|
if err := client.DeleteRepo(context.Background(), "to-delete"); err != nil {
|
|
t.Fatalf("DeleteRepo: unexpected error: %v", err)
|
|
}
|
|
}
|
|
|
|
// ---- CreateToken ---------------------------------------------------------
|
|
|
|
func TestCreateToken_Success(t *testing.T) {
|
|
expiry := time.Now().Add(24 * time.Hour).UTC().Truncate(time.Second)
|
|
mux := http.NewServeMux()
|
|
mux.HandleFunc("/namespaces/test-ns/tokens", func(w http.ResponseWriter, r *http.Request) {
|
|
if r.Method != http.MethodPost {
|
|
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
|
return
|
|
}
|
|
var req map[string]interface{}
|
|
json.NewDecoder(r.Body).Decode(&req)
|
|
if req["repo"] != "my-repo" {
|
|
http.Error(w, "unexpected repo", http.StatusBadRequest)
|
|
return
|
|
}
|
|
tok := artifacts.RepoToken{
|
|
ID: "tok-123",
|
|
Token: "plaintext-secret-abc",
|
|
Scope: "write",
|
|
ExpiresAt: expiry,
|
|
}
|
|
w.Header().Set("Content-Type", "application/json")
|
|
_, _ = w.Write(cfEnvelope(t, tok))
|
|
})
|
|
|
|
client := newTestClient(t, mux)
|
|
tok, err := client.CreateToken(context.Background(), artifacts.CreateTokenRequest{
|
|
Repo: "my-repo",
|
|
Scope: "write",
|
|
TTL: 86400,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("CreateToken: unexpected error: %v", err)
|
|
}
|
|
if tok.ID != "tok-123" {
|
|
t.Errorf("ID = %q, want %q", tok.ID, "tok-123")
|
|
}
|
|
if tok.Token != "plaintext-secret-abc" {
|
|
t.Errorf("Token = %q, want %q", tok.Token, "plaintext-secret-abc")
|
|
}
|
|
if tok.Scope != "write" {
|
|
t.Errorf("Scope = %q, want %q", tok.Scope, "write")
|
|
}
|
|
}
|
|
|
|
// ---- RevokeToken ---------------------------------------------------------
|
|
|
|
func TestRevokeToken_Success(t *testing.T) {
|
|
mux := http.NewServeMux()
|
|
mux.HandleFunc("/namespaces/test-ns/tokens/tok-456", func(w http.ResponseWriter, r *http.Request) {
|
|
if r.Method != http.MethodDelete {
|
|
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
|
return
|
|
}
|
|
deleted := map[string]string{"id": "tok-456"}
|
|
w.Header().Set("Content-Type", "application/json")
|
|
_, _ = w.Write(cfEnvelope(t, deleted))
|
|
})
|
|
|
|
client := newTestClient(t, mux)
|
|
if err := client.RevokeToken(context.Background(), "tok-456"); err != nil {
|
|
t.Fatalf("RevokeToken: unexpected error: %v", err)
|
|
}
|
|
}
|
|
|
|
// ---- Context cancellation ------------------------------------------------
|
|
|
|
func TestCreateRepo_ContextCancelled(t *testing.T) {
|
|
// Server that never responds (simulates a hung connection)
|
|
mux := http.NewServeMux()
|
|
mux.HandleFunc("/namespaces/test-ns/repos", func(w http.ResponseWriter, r *http.Request) {
|
|
// Block until the client gives up
|
|
<-r.Context().Done()
|
|
})
|
|
|
|
client := newTestClient(t, mux)
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
cancel() // cancel immediately
|
|
|
|
_, err := client.CreateRepo(ctx, artifacts.CreateRepoRequest{Name: "x"})
|
|
if err == nil {
|
|
t.Fatal("expected error from cancelled context, got nil")
|
|
}
|
|
}
|