Compare commits
9 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 74297485c0 | |||
| c53ca971c3 | |||
| 8e8334795e | |||
| f3bd81244e | |||
| 9e53a426ef | |||
| e3cc0adcd5 | |||
| 6ef7aa7361 | |||
| 6cfb0bf5bf | |||
| 0036d94ec2 |
+20
-21
@@ -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
|
||||
@@ -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
|
||||
@@ -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:
|
||||
@@ -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
|
||||
@@ -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"
|
||||
@@ -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'
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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})
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -570,7 +570,7 @@ func TestSeedInitialMemories_TruncatesOversizedContent(t *testing.T) {
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
mock.ExpectExpectations()
|
||||
mock.ExpectationsWereMet()
|
||||
workspaceID := "ws-trunc-" + tt.name
|
||||
content := strings.Repeat("X", tt.contentLen)
|
||||
memories := []models.MemorySeed{{Content: content, Scope: "LOCAL"}}
|
||||
@@ -624,7 +624,7 @@ func TestSeedInitialMemories_RedactsSecrets(t *testing.T) {
|
||||
// unrecognized scope value are silently skipped (not inserted).
|
||||
func TestSeedInitialMemories_InvalidScopeSkipped(t *testing.T) {
|
||||
mock := setupTestDB(t)
|
||||
mock.ExpectExpectations() // no DB calls expected for invalid scope
|
||||
mock.ExpectationsWereMet() // no DB calls expected for invalid scope
|
||||
|
||||
memories := []models.MemorySeed{
|
||||
{Content: "this should be skipped", Scope: "NOT_A_REAL_SCOPE"},
|
||||
@@ -641,7 +641,7 @@ func TestSeedInitialMemories_InvalidScopeSkipped(t *testing.T) {
|
||||
// is handled without error (no DB calls).
|
||||
func TestSeedInitialMemories_EmptyMemoriesNil(t *testing.T) {
|
||||
mock := setupTestDB(t)
|
||||
mock.ExpectExpectations()
|
||||
mock.ExpectationsWereMet()
|
||||
|
||||
seedInitialMemories(context.Background(), "ws-nil", nil, "test-ns")
|
||||
|
||||
@@ -901,6 +901,12 @@ 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_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
|
||||
|
||||
@@ -1092,7 +1098,7 @@ func TestProvisionWorkspace_NoInternalErrorsInBroadcast(t *testing.T) {
|
||||
|
||||
broadcaster := &captureBroadcaster{}
|
||||
handler := &WorkspaceHandler{
|
||||
broadcaster: broadcaster,
|
||||
broadcaster: (*events.Broadcaster)(broadcaster),
|
||||
provisioner: &provisioner.Provisioner{},
|
||||
cpProv: &provisioner.CPProvisioner{},
|
||||
platformURL: "http://platform.test",
|
||||
@@ -1141,7 +1147,7 @@ func TestProvisionWorkspaceCP_NoInternalErrorsInBroadcast(t *testing.T) {
|
||||
broadcaster := &captureBroadcaster{}
|
||||
registry := &mockEnvMutator{returnErr: errInternalDB}
|
||||
handler := &WorkspaceHandler{
|
||||
broadcaster: broadcaster,
|
||||
broadcaster: (*events.Broadcaster)(broadcaster),
|
||||
cpProv: &provisioner.CPProvisioner{},
|
||||
platformURL: "http://platform.test",
|
||||
envMutators: registry,
|
||||
|
||||
@@ -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
|
||||
@@ -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"))
|
||||
@@ -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_"))
|
||||
@@ -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_"))
|
||||
@@ -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_"))
|
||||
@@ -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_"))
|
||||
@@ -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_"))
|
||||
|
||||
@@ -79,7 +79,7 @@ func TestValidate_HappyPath(t *testing.T) {
|
||||
WithArgs("tok-live").
|
||||
WillReturnResult(sqlmock.NewResult(0, 1))
|
||||
|
||||
id, prefix, err := Validate(context.Background(), db, plaintext)
|
||||
id, prefix, _, err := Validate(context.Background(), db, plaintext)
|
||||
if err != nil {
|
||||
t.Fatalf("Validate: %v", err)
|
||||
}
|
||||
@@ -94,7 +94,7 @@ func TestValidate_HappyPath(t *testing.T) {
|
||||
func TestValidate_EmptyPlaintextRejected(t *testing.T) {
|
||||
db, _, _ := sqlmock.New()
|
||||
defer db.Close()
|
||||
if _, _, err := Validate(context.Background(), db, ""); !errors.Is(err, ErrInvalidToken) {
|
||||
if _, _, _, err := Validate(context.Background(), db, ""); !errors.Is(err, ErrInvalidToken) {
|
||||
t.Errorf("empty plaintext should be ErrInvalidToken, got %v", err)
|
||||
}
|
||||
}
|
||||
@@ -110,7 +110,7 @@ func TestValidate_UnknownHashErrInvalid(t *testing.T) {
|
||||
WithArgs(sqlmock.AnyArg()).
|
||||
WillReturnError(sql.ErrNoRows)
|
||||
|
||||
if _, _, err := Validate(context.Background(), db, "ghost"); !errors.Is(err, ErrInvalidToken) {
|
||||
if _, _, _, err := Validate(context.Background(), db, "ghost"); !errors.Is(err, ErrInvalidToken) {
|
||||
t.Errorf("unknown hash should be ErrInvalidToken, got %v", err)
|
||||
}
|
||||
}
|
||||
@@ -127,7 +127,7 @@ func TestValidate_RevokedTokenNotAccepted(t *testing.T) {
|
||||
WithArgs(sqlmock.AnyArg()).
|
||||
WillReturnError(sql.ErrNoRows)
|
||||
|
||||
if _, _, err := Validate(context.Background(), db, "revoked-plaintext"); !errors.Is(err, ErrInvalidToken) {
|
||||
if _, _, _, err := Validate(context.Background(), db, "revoked-plaintext"); !errors.Is(err, ErrInvalidToken) {
|
||||
t.Errorf("revoked token should be ErrInvalidToken, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
|
||||
Reference in New Issue
Block a user