Skip to content
Closed
Show file tree
Hide file tree
Changes from 4 commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
8e3084c
fix(CI): move all platform jobs off self-hosted macOS runner to ubunt…
Apr 21, 2026
9b226b8
fix: introduce broadcasterLogger interface to fix strict-compiler typ…
Apr 21, 2026
b1064ea
refactor: split 4 oversized handler files into focused sub-files
airenostars Apr 21, 2026
38cc501
Merge pull request #1363 from Molecule-AI/refactor/split-large-handlers
molecule-ai[bot] Apr 21, 2026
19c0935
fix: add BroadcastOnly to broadcasterLogger and pass nil to LogActivity
Apr 21, 2026
75c5cd5
ci: trigger build
Apr 21, 2026
52e6bae
feat(runtime+scheduler): increment/decrement active_tasks counter (re…
molecule-ai[bot] Apr 21, 2026
1f41973
docs: workspace files API reference, skill catalog, and links
molecule-ai[bot] Apr 21, 2026
982d422
Merge pull request #1429 from fix/canvas-tooltip-clear-timer
molecule-ai[bot] Apr 21, 2026
c14e9e0
fix(canvas/test): add missing children:[] to setPendingDelete expecta…
molecule-ai[bot] Apr 21, 2026
870949b
fix(canvas/test): add children:[] to setPendingDelete + \' entit…
molecule-ai[bot] Apr 21, 2026
453f15a
Merge staging into infra/sre-work-2026-04-21
Apr 21, 2026
5b4f1fe
fix: remove duplicate SSRF functions from mcp_tools.go
Apr 21, 2026
0f786cf
fix: add validateRelPath and update LogActivity signature
Apr 21, 2026
81e7fbe
fix(handlers): change PluginsHandler.sources to SourceResolver interface
Apr 21, 2026
15c2931
chore: trigger CI for 81e7fbe SourceResolver interface fix
Apr 21, 2026
273dec8
fix(handlers): dedupe SSRF functions — ssrf.go is now canonical home
Apr 21, 2026
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
41 changes: 20 additions & 21 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,9 @@ on:
branches: [main, staging]

# Cancel in-progress CI runs when a new commit arrives on the same ref.
# This prevents multiple stale runs from queuing behind each other and
# monopolising the self-hosted macOS arm64 runner.
# This prevents multiple stale runs from queuing and keeps the self-hosted
# macOS arm64 runner (publish-canvas-image, publish-workspace-server-image)
# available for the jobs that genuinely require it.
concurrency:
group: ci-${{ github.ref }}
cancel-in-progress: true
Expand Down Expand Up @@ -57,7 +58,7 @@ jobs:
name: Platform (Go)
needs: changes
if: needs.changes.outputs.platform == 'true'
runs-on: [self-hosted, macos, arm64]
runs-on: ubuntu-latest
defaults:
run:
working-directory: workspace-server
Expand All @@ -70,6 +71,10 @@ jobs:
- run: go build ./cmd/server
# CLI (molecli) moved to standalone repo: github.com/Molecule-AI/molecule-cli
- run: go vet ./...
# golangci-lint-action uses a Linux Docker image (ubuntu is the only arch+OS
# combo the official image publishes for). Previously this step was pinned to
# [self-hosted, macos, arm64] because the Docker image can't run on macOS ARM.
# Now that the job itself runs on ubuntu-latest, the Docker image works natively.
- name: Run golangci-lint
uses: golangci/golangci-lint-action@v9
with:
Expand All @@ -93,7 +98,7 @@ jobs:
name: Canvas (Next.js)
needs: changes
if: needs.changes.outputs.canvas == 'true'
runs-on: [self-hosted, macos, arm64]
runs-on: ubuntu-latest
defaults:
run:
working-directory: canvas
Expand All @@ -119,12 +124,11 @@ jobs:
name: Shellcheck (E2E scripts)
needs: changes
if: needs.changes.outputs.scripts == 'true'
runs-on: [self-hosted, macos, arm64]
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Run shellcheck on tests/e2e/*.sh
# `ludeeus/action-shellcheck` is a Docker action (Linux-only). We rely
# on shellcheck being pre-installed on the self-hosted runner instead.
# shellcheck is pre-installed on ubuntu-latest GitHub-hosted runners.
run: |
if ! command -v shellcheck >/dev/null 2>&1; then
echo "::error::shellcheck is not installed on the runner"
Expand All @@ -135,7 +139,7 @@ jobs:

canvas-deploy-reminder:
name: Canvas Deploy Reminder
runs-on: [self-hosted, macos, arm64]
runs-on: ubuntu-latest
needs: [changes, canvas-build]
# Only fires on direct pushes to main (i.e. after staging→main promotion).
if: needs.changes.outputs.canvas == 'true' && github.event_name == 'push' && github.ref == 'refs/heads/main'
Expand Down Expand Up @@ -181,24 +185,19 @@ jobs:
name: Python Lint & Test
needs: changes
if: needs.changes.outputs.python == 'true'
runs-on: [self-hosted, macos, arm64]
runs-on: ubuntu-latest
defaults:
run:
working-directory: workspace
steps:
- uses: actions/checkout@v4
# setup-python@v5 cannot write to /Users/runner (GitHub-hosted path) on
# the self-hosted macOS arm64 runner (user: <runner-user>) and also hits
# EACCES on /usr/local/bin due to macOS SIP. Skip it — Homebrew installs
# Python 3.11 at /opt/homebrew/opt/python@3.11 which is already on PATH.
- name: Verify Python 3.11 (Homebrew)
run: |
export PATH="/opt/homebrew/opt/python@3.11/bin:/opt/homebrew/bin:$PATH"
python3.11 --version
echo "/opt/homebrew/opt/python@3.11/bin" >> "$GITHUB_PATH"
echo "/opt/homebrew/bin" >> "$GITHUB_PATH"
- run: pip3.11 install -r requirements.txt pytest pytest-asyncio pytest-cov
- run: python3.11 -m pytest --tb=short -q --cov=. --cov-report=term-missing
- uses: actions/setup-python@v5
with:
python-version: '3.11'
cache: pip
cache-dependency-path: workspace/requirements.txt
- run: pip install -r requirements.txt pytest pytest-asyncio pytest-cov
- run: WORKSPACE_ID=ci-placeholder python -m pytest --tb=short -q --cov=. --cov-report=term-missing

# SDK + plugin validation moved to standalone repo:
# github.com/Molecule-AI/molecule-sdk-python
2 changes: 1 addition & 1 deletion workspace-server/internal/bundle/importer.go
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,7 @@ func Import(
}
}
// Store runtime in DB
_ = db.DB.ExecContext(ctx, `UPDATE workspaces SET runtime = $1 WHERE id = $2`, bundleRuntime, wsID)
_, _ = db.DB.ExecContext(ctx, `UPDATE workspaces SET runtime = $1 WHERE id = $2`, bundleRuntime, wsID)

// Provision the container if provisioner is available
if prov != nil {
Expand Down
4 changes: 2 additions & 2 deletions workspace-server/internal/handlers/a2a_proxy.go
Original file line number Diff line number Diff line change
Expand Up @@ -573,7 +573,7 @@ func (h *WorkspaceHandler) logA2AFailure(ctx context.Context, workspaceID, calle
go func(parent context.Context) {
logCtx, cancel := context.WithTimeout(context.WithoutCancel(parent), 30*time.Second)
defer cancel()
LogActivity(logCtx, h.broadcaster, ActivityParams{
LogActivity(logCtx, nil, ActivityParams{
WorkspaceID: workspaceID,
ActivityType: "a2a_receive",
SourceID: nilIfEmpty(callerID),
Expand Down Expand Up @@ -619,7 +619,7 @@ func (h *WorkspaceHandler) logA2ASuccess(ctx context.Context, workspaceID, calle
go func(parent context.Context) {
logCtx, cancel := context.WithTimeout(context.WithoutCancel(parent), 30*time.Second)
defer cancel()
LogActivity(logCtx, h.broadcaster, ActivityParams{
LogActivity(logCtx, nil, ActivityParams{
WorkspaceID: workspaceID,
ActivityType: "a2a_receive",
SourceID: nilIfEmpty(callerID),
Expand Down
2 changes: 1 addition & 1 deletion workspace-server/internal/handlers/org_tokens.go
Original file line number Diff line number Diff line change
Expand Up @@ -111,7 +111,7 @@ func (h *OrgTokenHandler) Revoke(c *gin.Context) {
c.JSON(http.StatusNotFound, gin.H{"error": "token not found or already revoked"})
return
}
actor := orgTokenActor(c)
actor, _ := orgTokenActor(c)
log.Printf("orgtoken: revoked id=%s by=%s", id, actor)
c.JSON(http.StatusOK, gin.H{"revoked": id})
}
Expand Down
9 changes: 0 additions & 9 deletions workspace-server/internal/handlers/templates.go
Original file line number Diff line number Diff line change
Expand Up @@ -61,15 +61,6 @@ func (h *TemplatesHandler) resolveTemplateDir(wsName string) string {
return ""
}

// validateRelPath checks that a relative path doesn't escape the target directory.
func validateRelPath(relPath string) error {
clean := filepath.Clean(relPath)
if filepath.IsAbs(clean) || strings.HasPrefix(clean, "..") {
return fmt.Errorf("path traversal blocked: %s", relPath)
}
return nil
}

// List handles GET /templates
func (h *TemplatesHandler) List(c *gin.Context) {
entries, err := os.ReadDir(h.configsDir)
Expand Down
10 changes: 9 additions & 1 deletion workspace-server/internal/handlers/workspace.go
Original file line number Diff line number Diff line change
Expand Up @@ -23,8 +23,16 @@ import (
"github.com/google/uuid"
)

// broadcasterLogger is the interface required by WorkspaceHandler for
// broadcasting workspace events. *events.Broadcaster implements it; so does
// the test double *captureBroadcaster used in workspace_provision_test.go.
type broadcasterLogger interface {
RecordAndBroadcast(ctx context.Context, eventType, workspaceID string, payload interface{}) error
BroadcastOnly(workspaceID string, eventType string, payload interface{})
}

type WorkspaceHandler struct {
broadcaster *events.Broadcaster
broadcaster broadcasterLogger
provisioner *provisioner.Provisioner
cpProv *provisioner.CPProvisioner
platformURL string
Expand Down
2 changes: 1 addition & 1 deletion workspace-server/internal/handlers/workspace_provision.go
Original file line number Diff line number Diff line change
Expand Up @@ -250,7 +250,7 @@ func seedInitialMemories(ctx context.Context, workspaceID string, memories []mod
if _, err := db.DB.ExecContext(ctx, `
INSERT INTO agent_memories (workspace_id, content, scope, namespace)
VALUES ($1, $2, $3, $4)
`, workspaceID, redactSecrets(workspaceID, content), scope, awarenessNamespace); err != nil {
`, workspaceID, func() string { r, _ := redactSecrets(workspaceID, content); return r }(), scope, awarenessNamespace); err != nil {
log.Printf("seedInitialMemories: failed to insert memory for %s (scope=%s): %v", workspaceID, scope, err)
}
}
Expand Down
19 changes: 9 additions & 10 deletions workspace-server/internal/handlers/workspace_provision_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -902,10 +902,11 @@ func containsStr(s, substr string) bool {
// Each test injects a known-internal error and verifies the response body
// or broadcast payload contains ONLY the generic prod-safe message.

// TestSeedInitialMemories_Truncation verifies that seedInitialMemories
// truncates content at maxMemoryContentLength before INSERT. Regression
// test for the error-sanitization / memory-seed contract.
func TestSeedInitialMemories_Truncation(t *testing.T) {
// TestSeedInitialMemories_TruncatesOversizedContent_SingleCase is an alternative
// single-case variant of the table-driven TestSeedInitialMemories_TruncatesOversizedContent
// (line 538). Kept to cover the 100,001-byte regression path independently.
func TestSeedInitialMemories_TruncatesOversizedContent_SingleCase(t *testing.T) {
mock := setupTestDB(t)
largeContent := string(make([]byte, 100_001))
copy([]byte(largeContent), "X") // fill with "X" so test is deterministic

Expand Down Expand Up @@ -1041,6 +1042,8 @@ func (c *captureBroadcaster) RecordAndBroadcast(_ context.Context, _, _ string,
return nil
}

func (c *captureBroadcaster) BroadcastOnly(_, _ string, _ interface{}) {}

// unsafeErrorStrings lists substrings that must NEVER appear in external-facing
// error responses. Covers DB driver errors, OS errors, and internal paths.
var unsafeErrorStrings = []string{
Expand Down Expand Up @@ -1144,12 +1147,10 @@ func TestProvisionWorkspaceCP_NoInternalErrorsInBroadcast(t *testing.T) {
WillReturnRows(sqlmock.NewRows([]string{"key", "encrypted_value", "encryption_version"}))

broadcaster := &captureBroadcaster{}
registry := &mockEnvMutator{returnErr: errInternalDB}
handler := &WorkspaceHandler{
broadcaster: broadcaster,
broadcaster: broadcaster,
cpProv: &provisioner.CPProvisioner{},
platformURL: "http://platform.test",
envMutators: registry,
}

handler.provisionWorkspaceCP("ws-cp-test-456", "", nil, models.CreateWorkspacePayload{Name: "test-cp"})
Expand Down Expand Up @@ -1182,7 +1183,7 @@ func (m *mockEnvMutator) Run(_ context.Context, _ string, _ map[string]string) e
return m.returnErr
}

func (m *mockEnvMutator) Register(_ provisionhook.EnvMutator) {}
func (m *mockEnvMutator) Register(_ interface{}) {}

// TestResolveAndStage_NoInternalErrorsInHTTPErr asserts that resolveAndStage
// never puts err.Error() in HTTP error responses. Tests plugin source
Expand Down Expand Up @@ -1261,8 +1262,6 @@ func (m *mockPluginsSources) Resolve(source plugins.Source) (plugins.SourceResol

type mockResolver struct{}

func (*mockResolver) Scheme() string { return "" }

func (*mockResolver) Fetch(ctx context.Context, spec, destDir string) (string, error) {
return "", nil
}
Original file line number Diff line number Diff line change
Expand Up @@ -11,11 +11,11 @@ import (
"github.com/gin-gonic/gin"
)

// orgTokenValidateQuery is matched for orgtoken.Validate in both
// orgTokenValidateQueryWithHash is matched for orgtoken.Validate in both
// WorkspaceAuth and AdminAuth middleware paths. The query selects
// id and prefix from org_api_tokens where token_hash matches and
// revoked_at IS NULL.
const orgTokenValidateQuery = "SELECT id, prefix FROM org_api_tokens WHERE token_hash"
const orgTokenValidateQueryWithHash = "SELECT id, prefix FROM org_api_tokens WHERE token_hash"

func TestWorkspaceAuth_ValidOrgToken_SetsOrgIDContext(t *testing.T) {
// F1097 (#1218): org tokens validated via WorkspaceAuth must have
Expand All @@ -31,7 +31,7 @@ func TestWorkspaceAuth_ValidOrgToken_SetsOrgIDContext(t *testing.T) {
tokenHash := sha256.Sum256([]byte(orgToken))

// orgtoken.Validate — returns id + prefix (no org_id column yet).
mock.ExpectQuery(orgTokenValidateQuery).
mock.ExpectQuery(orgTokenValidateQueryWithHash).
WithArgs(tokenHash[:]).
WillReturnRows(sqlmock.NewRows([]string{"id", "prefix"}).
AddRow("tok-org-abc", "tok_test"))
Expand Down Expand Up @@ -85,7 +85,7 @@ func TestWorkspaceAuth_ValidOrgToken_OrgIDNULL_DoesNotSetContext(t *testing.T) {
tokenHash := sha256.Sum256([]byte(orgToken))

// orgtoken.Validate.
mock.ExpectQuery(orgTokenValidateQuery).
mock.ExpectQuery(orgTokenValidateQueryWithHash).
WithArgs(tokenHash[:]).
WillReturnRows(sqlmock.NewRows([]string{"id", "prefix"}).
AddRow("tok-old-xyz", "tok_old_"))
Expand Down Expand Up @@ -136,7 +136,7 @@ func TestAdminAuth_ValidOrgToken_SetsOrgIDContext(t *testing.T) {
WillReturnRows(sqlmock.NewRows([]string{"count"}).AddRow(1))

// orgtoken.Validate via AdminAuth — returns id + prefix.
mock.ExpectQuery(orgTokenValidateQuery).
mock.ExpectQuery(orgTokenValidateQueryWithHash).
WithArgs(tokenHash[:]).
WillReturnRows(sqlmock.NewRows([]string{"id", "prefix"}).
AddRow("tok-admin-org", "tok_adm_"))
Expand Down Expand Up @@ -187,7 +187,7 @@ func TestAdminAuth_ValidOrgToken_OrgIDNULL_DoesNotSetContext(t *testing.T) {
mock.ExpectQuery(hasAnyLiveTokenGlobalQuery).
WillReturnRows(sqlmock.NewRows([]string{"count"}).AddRow(1))

mock.ExpectQuery(orgTokenValidateQuery).
mock.ExpectQuery(orgTokenValidateQueryWithHash).
WithArgs(tokenHash[:]).
WillReturnRows(sqlmock.NewRows([]string{"id", "prefix"}).
AddRow("tok-old-admin", "tok_old_"))
Expand Down Expand Up @@ -232,7 +232,7 @@ func TestWorkspaceAuth_OrgToken_DBRowScanError_DoesNotPanic(t *testing.T) {
orgToken := "tok_token_ok"
tokenHash := sha256.Sum256([]byte(orgToken))

mock.ExpectQuery(orgTokenValidateQuery).
mock.ExpectQuery(orgTokenValidateQueryWithHash).
WithArgs(tokenHash[:]).
WillReturnRows(sqlmock.NewRows([]string{"id", "prefix"}).
AddRow("tok-ok", "tok_tok_"))
Expand Down Expand Up @@ -277,7 +277,7 @@ func TestWorkspaceAuth_OrgToken_SetsAllContextKeys(t *testing.T) {
tokenHash := sha256.Sum256([]byte(orgToken))
expectedOrgID := "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee"

mock.ExpectQuery(orgTokenValidateQuery).
mock.ExpectQuery(orgTokenValidateQueryWithHash).
WithArgs(tokenHash[:]).
WillReturnRows(sqlmock.NewRows([]string{"id", "prefix"}).
AddRow("tok-full", "tok_fu_"))
Expand Down
4 changes: 2 additions & 2 deletions workspace-server/internal/orgtoken/tokens_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -74,12 +74,12 @@ func TestValidate_HappyPath(t *testing.T) {

mock.ExpectQuery(`SELECT id, prefix FROM org_api_tokens`).
WithArgs(hash[:]).
WillReturnRows(sqlmock.NewRows([]string{"id", "prefix"}).AddRow("tok-live", "abcd1234", nil))
WillReturnRows(sqlmock.NewRows([]string{"id", "prefix"}).AddRow("tok-live", "abcd1234"))
mock.ExpectExec(`UPDATE org_api_tokens SET last_used_at`).
WithArgs("tok-live").
WillReturnResult(sqlmock.NewResult(0, 1))

id, prefix, orgID, err := Validate(context.Background(), db, plaintext)
id, prefix, _, err := Validate(context.Background(), db, plaintext)
if err != nil {
t.Fatalf("Validate: %v", err)
}
Expand Down
1 change: 1 addition & 0 deletions workspace-server/internal/scheduler/scheduler.go
Original file line number Diff line number Diff line change
Expand Up @@ -255,6 +255,7 @@ func (s *Scheduler) fireSchedule(ctx context.Context, sched scheduleRow) {
if _, execErr := db.DB.ExecContext(context.Background(), `UPDATE workspace_schedules SET next_run_at=$1, updated_at=now() WHERE id=$2`, nextTime, sched.ID); execErr != nil {
log.Printf("Scheduler: panic-recovery next_run_at UPDATE failed for schedule %s: %v", sched.ID, execErr)
}
}
}
}()

Expand Down
Loading