Compare commits
12 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 872df8e619 | |||
| 48ad38e795 | |||
| 4bdb10b5e2 | |||
| 6452456f75 | |||
| 4978601032 | |||
| ec3e27a4ec | |||
| 4cc0e32a53 | |||
| e9693e12ff | |||
| bcca139caa | |||
| 6cf6e608d8 | |||
| 6947774e1b | |||
| 9afecfdfc7 |
@@ -118,17 +118,19 @@ _DIRECTIVE_RE = re.compile(
|
||||
def parse_directives(
|
||||
comment_body: str,
|
||||
numeric_aliases: dict[int, str],
|
||||
) -> list[tuple[str, str, str]]:
|
||||
) -> tuple[list[tuple[str, str, str]], list]:
|
||||
"""Extract /sop-ack and /sop-revoke directives from a comment body.
|
||||
|
||||
Returns a list of (kind, canonical_slug, note) tuples where:
|
||||
kind is "sop-ack" or "sop-revoke"
|
||||
canonical_slug is the normalized form (or "" if unparseable)
|
||||
note is the trailing free-text (may be "")
|
||||
Returns (directives, na_directives) where:
|
||||
directives is a list of (kind, canonical_slug, note) tuples
|
||||
kind is "sop-ack" or "sop-revoke"
|
||||
canonical_slug is the normalized form (or "" if unparseable)
|
||||
note is the trailing free-text (may be "")
|
||||
na_directives is reserved for future N/A handling (always [] for now)
|
||||
"""
|
||||
out: list[tuple[str, str, str]] = []
|
||||
if not comment_body:
|
||||
return out
|
||||
return out, []
|
||||
for m in _DIRECTIVE_RE.finditer(comment_body):
|
||||
kind = m.group(1)
|
||||
raw_slug = (m.group(2) or "").strip()
|
||||
@@ -159,7 +161,7 @@ def parse_directives(
|
||||
# If we collapsed multi-word slug into kebab and there's a
|
||||
# trailing-text group too, append it.
|
||||
out.append((kind, canonical, note_from_group))
|
||||
return out
|
||||
return out, []
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -249,7 +251,8 @@ def compute_ack_state(
|
||||
user = (c.get("user") or {}).get("login", "")
|
||||
if not user:
|
||||
continue
|
||||
for kind, slug, _note in parse_directives(body, numeric_aliases):
|
||||
directives, _na = parse_directives(body, numeric_aliases)
|
||||
for kind, slug, _note in directives:
|
||||
if not slug:
|
||||
unparseable_per_user[user] = unparseable_per_user.get(user, 0) + 1
|
||||
continue
|
||||
|
||||
+20
-21
@@ -133,7 +133,6 @@ jobs:
|
||||
# the name match works on PRs that don't touch workspace-server/).
|
||||
platform-build:
|
||||
name: Platform (Go)
|
||||
needs: changes
|
||||
runs-on: ubuntu-latest
|
||||
# mc#774 (closed 2026-05-14): Phase 4 flip of the platform-build job.
|
||||
# Phase 4 (#656) originally flipped this to continue-on-error: false based on
|
||||
@@ -154,29 +153,29 @@ jobs:
|
||||
run:
|
||||
working-directory: workspace-server
|
||||
steps:
|
||||
- if: needs.changes.outputs.platform != 'true'
|
||||
- if: false
|
||||
working-directory: .
|
||||
run: echo "No platform/** changes — skipping real build steps; this job always runs to satisfy the required-check name on branch protection."
|
||||
- if: needs.changes.outputs.platform == 'true'
|
||||
- if: always()
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
- if: needs.changes.outputs.platform == 'true'
|
||||
- if: always()
|
||||
uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # v5
|
||||
with:
|
||||
go-version: 'stable'
|
||||
- if: needs.changes.outputs.platform == 'true'
|
||||
- if: always()
|
||||
run: go mod download
|
||||
- if: needs.changes.outputs.platform == 'true'
|
||||
- if: always()
|
||||
run: go build ./cmd/server
|
||||
# CLI (molecli) moved to standalone repo: git.moleculesai.app/molecule-ai/molecule-cli
|
||||
- if: needs.changes.outputs.platform == 'true'
|
||||
- if: always()
|
||||
run: go vet ./...
|
||||
- if: needs.changes.outputs.platform == 'true'
|
||||
- if: always()
|
||||
name: Install golangci-lint
|
||||
run: go install github.com/golangci/golangci-lint/v2/cmd/golangci-lint@v2.12.2
|
||||
- if: needs.changes.outputs.platform == 'true'
|
||||
- if: always()
|
||||
name: Run golangci-lint
|
||||
run: $(go env GOPATH)/bin/golangci-lint run --timeout 3m ./...
|
||||
- if: needs.changes.outputs.platform == 'true'
|
||||
- if: always()
|
||||
name: Diagnostic — per-package verbose 60s
|
||||
run: |
|
||||
set +e
|
||||
@@ -192,7 +191,7 @@ jobs:
|
||||
echo "::endgroup::"
|
||||
# mc#774: pre-existing continue-on-error mask; root-fix and remove, do not renew silently.
|
||||
continue-on-error: true
|
||||
- if: needs.changes.outputs.platform == 'true'
|
||||
- if: always()
|
||||
name: Run tests with race detection and coverage
|
||||
# Explicit timeout: cold runner cache causes OOM kills at ~4m39s on the
|
||||
# full ./... suite with race detection + coverage. A 10m per-step timeout
|
||||
@@ -200,7 +199,7 @@ jobs:
|
||||
# instead of OOM-killing. The job-level timeout (15m) is a backstop.
|
||||
run: go test -race -timeout 10m -coverprofile=coverage.out ./...
|
||||
|
||||
- if: needs.changes.outputs.platform == 'true'
|
||||
- if: always()
|
||||
name: Per-file coverage report
|
||||
# Advisory — lists every source file with its coverage so reviewers
|
||||
# can see at-a-glance where gaps are. Sorted ascending so the worst
|
||||
@@ -214,7 +213,7 @@ jobs:
|
||||
END {for (f in s) printf "%6.1f%% %s\n", s[f]/c[f], f}' \
|
||||
| sort -n
|
||||
|
||||
- if: needs.changes.outputs.platform == 'true'
|
||||
- if: always()
|
||||
name: Check coverage thresholds
|
||||
# Enforces two gates from #1823 Layer 1:
|
||||
# 1. Total floor (25% — ratchet plan in COVERAGE_FLOOR.md).
|
||||
@@ -302,28 +301,28 @@ jobs:
|
||||
# siblings — verified empirically on PR #2314).
|
||||
canvas-build:
|
||||
name: Canvas (Next.js)
|
||||
needs: changes
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 20
|
||||
# Phase 4 (RFC #219 §1): confirmed green on main 2026-05-12.
|
||||
continue-on-error: false
|
||||
defaults:
|
||||
run:
|
||||
working-directory: canvas
|
||||
steps:
|
||||
- if: needs.changes.outputs.canvas != 'true'
|
||||
- if: false
|
||||
working-directory: .
|
||||
run: echo "No canvas/** changes — skipping real build steps; this job always runs to satisfy the required-check name on branch protection."
|
||||
- if: needs.changes.outputs.canvas == 'true'
|
||||
- if: always()
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
- if: needs.changes.outputs.canvas == 'true'
|
||||
- if: always()
|
||||
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||
with:
|
||||
node-version: '22'
|
||||
- if: needs.changes.outputs.canvas == 'true'
|
||||
- if: always()
|
||||
run: rm -f package-lock.json && npm install
|
||||
- if: needs.changes.outputs.canvas == 'true'
|
||||
- if: always()
|
||||
run: npm run build
|
||||
- if: needs.changes.outputs.canvas == 'true'
|
||||
- if: always()
|
||||
name: Run tests with coverage
|
||||
# Coverage instrumentation is configured in canvas/vitest.config.ts
|
||||
# (provider: v8, reporters: text + html + json-summary). Step 2 of
|
||||
@@ -332,7 +331,7 @@ jobs:
|
||||
# tracked in #1815) after the team sees what current coverage is.
|
||||
run: npx vitest run --coverage
|
||||
- name: Upload coverage summary as artifact
|
||||
if: needs.changes.outputs.canvas == 'true' && always()
|
||||
if: always()
|
||||
# Pinned to v3 for Gitea act_runner v0.6 compatibility — v4+ uses
|
||||
# the GHES 3.10+ artifact protocol that Gitea 1.22.x does NOT
|
||||
# implement, surfacing as `GHESNotSupportedError: @actions/artifact
|
||||
|
||||
@@ -63,6 +63,31 @@ func TestSessionSearchReturnsActivityAndMemory(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestSessionSearch_DBError(t *testing.T) {
|
||||
mock := setupTestDB(t)
|
||||
setupTestRedis(t)
|
||||
broadcaster := newTestBroadcaster()
|
||||
handler := NewActivityHandler(broadcaster)
|
||||
|
||||
mock.ExpectQuery("WITH session_items AS").
|
||||
WillReturnError(context.DeadlineExceeded)
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
c.Request = httptest.NewRequest("GET", "/workspaces/ws-123/session-search?q=test", bytes.NewBufferString(""))
|
||||
c.Request.Header.Set("Content-Type", "application/json")
|
||||
c.Params = gin.Params{{Key: "id", Value: "ws-123"}}
|
||||
|
||||
handler.SessionSearch(c)
|
||||
|
||||
if w.Code != http.StatusInternalServerError {
|
||||
t.Errorf("expected 500 on DB error, got %d", w.Code)
|
||||
}
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Errorf("unmet sqlmock expectations: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- Activity List source filter ----------
|
||||
|
||||
func TestActivityList_SourceCanvas(t *testing.T) {
|
||||
|
||||
@@ -116,6 +116,9 @@ func (h *ApprovalsHandler) ListAll(c *gin.Context) {
|
||||
"created_at": createdAt,
|
||||
})
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
log.Printf("ListPendingApprovals rows.Err: %v", err)
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, approvals)
|
||||
}
|
||||
@@ -155,6 +158,9 @@ func (h *ApprovalsHandler) List(c *gin.Context) {
|
||||
"created_at": createdAt,
|
||||
})
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
log.Printf("ListApprovals rows.Err workspace=%s: %v", workspaceID, err)
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, approvals)
|
||||
}
|
||||
|
||||
@@ -543,6 +543,33 @@ func TestDelegationRecord_RejectsInvalidUUID(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestDelegationRecord_DBInsertFails(t *testing.T) {
|
||||
mock := setupTestDB(t)
|
||||
setupTestRedis(t)
|
||||
broadcaster := newTestBroadcaster()
|
||||
wh := NewWorkspaceHandler(broadcaster, nil, "http://localhost:8080", t.TempDir())
|
||||
h := NewDelegationHandler(wh, broadcaster)
|
||||
|
||||
mock.ExpectExec("INSERT INTO activity_logs").
|
||||
WillReturnError(fmt.Errorf("connection refused"))
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
c.Params = gin.Params{{Key: "id", Value: "550e8400-e29b-41d4-a716-446655440000"}}
|
||||
body := `{"target_id":"550e8400-e29b-41d4-a716-446655440001","task":"hello","delegation_id":"del-xyz"}`
|
||||
c.Request = httptest.NewRequest("POST", "/delegations/record", bytes.NewBufferString(body))
|
||||
c.Request.Header.Set("Content-Type", "application/json")
|
||||
|
||||
h.Record(c)
|
||||
|
||||
if w.Code != http.StatusInternalServerError {
|
||||
t.Errorf("expected 500 on DB insert failure, got %d", w.Code)
|
||||
}
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Errorf("unmet expectations: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDelegationUpdateStatus_CompletedInsertsResultRow(t *testing.T) {
|
||||
mock := setupTestDB(t)
|
||||
setupTestRedis(t)
|
||||
|
||||
@@ -248,6 +248,9 @@ func (h *InstructionsHandler) Resolve(c *gin.Context) {
|
||||
b.WriteString(content)
|
||||
b.WriteString("\n\n")
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
log.Printf("ResolveInstructions rows.Err workspace=%s: %v", workspaceID, err)
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"workspace_id": workspaceID,
|
||||
@@ -258,6 +261,7 @@ func (h *InstructionsHandler) Resolve(c *gin.Context) {
|
||||
func scanInstructions(rows interface {
|
||||
Next() bool
|
||||
Scan(dest ...interface{}) error
|
||||
Err() error
|
||||
}) []Instruction {
|
||||
var instructions []Instruction
|
||||
for rows.Next() {
|
||||
@@ -269,6 +273,9 @@ func scanInstructions(rows interface {
|
||||
}
|
||||
instructions = append(instructions, inst)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
log.Printf("scanInstructions rows.Err: %v", err)
|
||||
}
|
||||
if instructions == nil {
|
||||
instructions = []Instruction{}
|
||||
}
|
||||
|
||||
@@ -287,7 +287,7 @@ func TestRenderCategoryRoutingYAML_StableOrdering(t *testing.T) {
|
||||
if ai <= 0 || zi <= 0 || mi <= 0 {
|
||||
t.Fatalf("could not locate all keys in output: %s", out)
|
||||
}
|
||||
if !(ai < mi && mi < zi) {
|
||||
if ai >= mi || mi >= zi {
|
||||
t.Errorf("keys not sorted: alpha=%d middle=%d zebra=%d, output:\n%s", ai, mi, zi, out)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -67,6 +67,9 @@ func (h *TokenHandler) List(c *gin.Context) {
|
||||
}
|
||||
tokens = append(tokens, t)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
log.Printf("ListTokens rows.Err workspace=%s: %v", workspaceID, err)
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"tokens": tokens,
|
||||
|
||||
@@ -805,6 +805,9 @@ func loadWorkspaceSecrets(ctx context.Context, workspaceID string) (map[string]s
|
||||
envVars[k] = string(decrypted)
|
||||
}
|
||||
}
|
||||
if err := globalRows.Err(); err != nil {
|
||||
log.Printf("Provisioner: global_secrets rows.Err workspace=%s: %v", workspaceID, err)
|
||||
}
|
||||
}
|
||||
wsRows, err := db.DB.QueryContext(ctx,
|
||||
`SELECT key, encrypted_value, encryption_version FROM workspace_secrets WHERE workspace_id = $1`, workspaceID)
|
||||
@@ -823,6 +826,9 @@ func loadWorkspaceSecrets(ctx context.Context, workspaceID string) (map[string]s
|
||||
envVars[k] = string(decrypted)
|
||||
}
|
||||
}
|
||||
if err := wsRows.Err(); err != nil {
|
||||
log.Printf("Provisioner: workspace_secrets rows.Err workspace=%s: %v", workspaceID, err)
|
||||
}
|
||||
}
|
||||
return envVars, ""
|
||||
}
|
||||
|
||||
@@ -4,12 +4,14 @@ import (
|
||||
"bytes"
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@@ -156,6 +158,11 @@ type cpProvisionRequest struct {
|
||||
Tier int `json:"tier"`
|
||||
PlatformURL string `json:"platform_url"`
|
||||
Env map[string]string `json:"env"`
|
||||
// ConfigFiles are template + generated config files to write into the
|
||||
// EC2 instance's /configs directory. OFFSEC-010: collected by
|
||||
// collectCPConfigFiles which rejects symlinks and non-regular files
|
||||
// before including them. Serialised as base64 to avoid JSON escaping.
|
||||
ConfigFiles map[string]string `json:"config_files,omitempty"`
|
||||
}
|
||||
|
||||
type cpProvisionResponse struct {
|
||||
@@ -179,6 +186,16 @@ func (p *CPProvisioner) Start(ctx context.Context, cfg WorkspaceConfig) (string,
|
||||
}
|
||||
env["ADMIN_TOKEN"] = p.adminToken
|
||||
}
|
||||
// Collect template files and generated configs, with OFFSEC-010 guards:
|
||||
// - Rejects symlinks at the template root (prevents bypass via symlink traversal)
|
||||
// - Skips symlinks during WalkDir (prevents /etc/passwd etc. inclusion)
|
||||
// - Validates all paths are relative and non-escaping
|
||||
// - Caps total size at 12 KiB to prevent payload bloat
|
||||
configFiles, err := collectCPConfigFiles(cfg)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("cp provisioner: collect config files: %w", err)
|
||||
}
|
||||
|
||||
req := cpProvisionRequest{
|
||||
OrgID: p.orgID,
|
||||
WorkspaceID: cfg.WorkspaceID,
|
||||
@@ -186,6 +203,7 @@ func (p *CPProvisioner) Start(ctx context.Context, cfg WorkspaceConfig) (string,
|
||||
Tier: cfg.Tier,
|
||||
PlatformURL: cfg.PlatformURL,
|
||||
Env: env,
|
||||
ConfigFiles: configFiles,
|
||||
}
|
||||
|
||||
body, err := json.Marshal(req)
|
||||
@@ -237,6 +255,94 @@ func (p *CPProvisioner) Start(ctx context.Context, cfg WorkspaceConfig) (string,
|
||||
return result.InstanceID, nil
|
||||
}
|
||||
|
||||
const cpConfigFilesMaxBytes = 12 << 10
|
||||
|
||||
// isCPTemplateConfigFile restricts which files from a template directory are
|
||||
// eligible for transport to the control plane. Only config.yaml (the runtime
|
||||
// entrypoint config) and files under prompts/ (system prompts) are needed;
|
||||
// shipping arbitrary files (e.g. adapter.py, Dockerfile) is both unnecessary
|
||||
// and a potential data-exfiltration surface.
|
||||
func isCPTemplateConfigFile(name string) bool {
|
||||
name = filepath.ToSlash(filepath.Clean(name))
|
||||
return name == "config.yaml" || strings.HasPrefix(name, "prompts/")
|
||||
}
|
||||
|
||||
func collectCPConfigFiles(cfg WorkspaceConfig) (map[string]string, error) {
|
||||
files := make(map[string]string)
|
||||
total := 0
|
||||
addFile := func(name string, data []byte) error {
|
||||
name = filepath.ToSlash(filepath.Clean(name))
|
||||
if name == "." || strings.HasPrefix(name, "../") || strings.HasPrefix(name, "/") || strings.Contains(name, "/../") {
|
||||
return fmt.Errorf("invalid config file path %q", name)
|
||||
}
|
||||
total += len(data)
|
||||
if total > cpConfigFilesMaxBytes {
|
||||
return fmt.Errorf("config files exceed %d bytes", cpConfigFilesMaxBytes)
|
||||
}
|
||||
files[name] = base64.StdEncoding.EncodeToString(data)
|
||||
return nil
|
||||
}
|
||||
|
||||
if cfg.TemplatePath != "" {
|
||||
// Reject symlinks on the root itself — WalkDir follows symlinks,
|
||||
// so a symlink TemplatePath that escapes the intended root directory
|
||||
// would bypass the subsequent path-relativization checks below.
|
||||
rootInfo, err := os.Lstat(cfg.TemplatePath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("collectCPConfigFiles: lstat template path: %w", err)
|
||||
}
|
||||
if rootInfo.Mode()&os.ModeSymlink != 0 {
|
||||
return nil, fmt.Errorf("collectCPConfigFiles: template path must not be a symlink")
|
||||
}
|
||||
err = filepath.WalkDir(cfg.TemplatePath, func(path string, d os.DirEntry, walkErr error) error {
|
||||
if walkErr != nil {
|
||||
return walkErr
|
||||
}
|
||||
// Skip symlinks — WalkDir follows them by default, which means
|
||||
// a symlink inside the template dir pointing to /etc/passwd
|
||||
// would be traversed even though the resulting relative-path
|
||||
// check would correctly reject it. Defense-in-depth: don't
|
||||
// follow symlinks at all. (OFFSEC-010)
|
||||
if d.Type()&os.ModeSymlink != 0 {
|
||||
return nil
|
||||
}
|
||||
if d.IsDir() {
|
||||
return nil
|
||||
}
|
||||
info, err := d.Info()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !info.Mode().IsRegular() {
|
||||
return nil
|
||||
}
|
||||
rel, err := filepath.Rel(cfg.TemplatePath, path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !isCPTemplateConfigFile(rel) {
|
||||
return nil
|
||||
}
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return addFile(rel, data)
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
for name, data := range cfg.ConfigFiles {
|
||||
if err := addFile(name, data); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
if len(files) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
return files, nil
|
||||
}
|
||||
// Stop terminates the workspace's EC2 instance via the control plane.
|
||||
//
|
||||
// Looks up the actual EC2 instance_id from the workspaces table before
|
||||
|
||||
@@ -1,11 +1,15 @@
|
||||
package provisioner
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
@@ -279,6 +283,105 @@ func TestStart_TransportFailureSurfaces(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestStart_CollectsConfigFiles — verify that collectCPConfigFiles is called and
|
||||
// its result is included in the cpProvisionRequest sent to the control plane.
|
||||
// Tests the OFFSEC-010 wiring: the function's symlink guards are only effective
|
||||
// if the call site actually invokes it.
|
||||
func TestStart_CollectsConfigFiles(t *testing.T) {
|
||||
tmpl := t.TempDir()
|
||||
if err := os.WriteFile(filepath.Join(tmpl, "config.yaml"), []byte("name: test\n"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// adapter.py is within the size limit but is NOT config.yaml or prompts/,
|
||||
// so isCPTemplateConfigFile must exclude it from the transport.
|
||||
if err := os.WriteFile(filepath.Join(tmpl, "adapter.py"), bytes.Repeat([]byte("x"), cpConfigFilesMaxBytes), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
var gotBody cpProvisionRequest
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
_ = json.NewDecoder(r.Body).Decode(&gotBody)
|
||||
w.WriteHeader(http.StatusCreated)
|
||||
_, _ = io.WriteString(w, `{"instance_id":"i-abc123","state":"pending"}`)
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
p := &CPProvisioner{baseURL: srv.URL, orgID: "org-1", httpClient: srv.Client()}
|
||||
_, err := p.Start(context.Background(), WorkspaceConfig{
|
||||
WorkspaceID: "ws-1",
|
||||
Runtime: "python",
|
||||
Tier: 1,
|
||||
PlatformURL: "http://tenant",
|
||||
TemplatePath: tmpl,
|
||||
ConfigFiles: map[string][]byte{"generated.json": []byte(`{"key":"value"}`)},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Start: %v", err)
|
||||
}
|
||||
|
||||
// config.yaml from TemplatePath must be base64-encoded in ConfigFiles
|
||||
if len(gotBody.ConfigFiles) == 0 {
|
||||
t.Fatal("ConfigFiles is empty: collectCPConfigFiles was not called")
|
||||
}
|
||||
|
||||
// Find config.yaml entry and verify it's valid base64 + correct content
|
||||
var foundTemplate, foundGenerated bool
|
||||
for name, encoded := range gotBody.ConfigFiles {
|
||||
decoded, err := base64.StdEncoding.DecodeString(encoded)
|
||||
if err != nil {
|
||||
t.Errorf("ConfigFiles[%q] is not valid base64: %v", name, err)
|
||||
continue
|
||||
}
|
||||
if name == "config.yaml" && string(decoded) == "name: test\n" {
|
||||
foundTemplate = true
|
||||
}
|
||||
if name == "generated.json" && string(decoded) == `{"key":"value"}` {
|
||||
foundGenerated = true
|
||||
}
|
||||
}
|
||||
if !foundTemplate {
|
||||
t.Errorf("ConfigFiles missing config.yaml from TemplatePath")
|
||||
}
|
||||
if !foundGenerated {
|
||||
t.Errorf("ConfigFiles missing generated.json from ConfigFiles")
|
||||
}
|
||||
// adapter.py must NOT be in ConfigFiles — isCPTemplateConfigFile filters it out
|
||||
for name := range gotBody.ConfigFiles {
|
||||
if name == "adapter.py" {
|
||||
t.Errorf("adapter.py should not be in ConfigFiles — isCPTemplateConfigFile must filter it out")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestStart_SymlinkTemplatePathError — a symlink TemplatePath should cause
|
||||
// collectCPConfigFiles to return an error, which Start must propagate.
|
||||
// Without this wiring, OFFSEC-010's root-symlink guard is dead code.
|
||||
func TestStart_SymlinkTemplatePathError(t *testing.T) {
|
||||
// Create a temp file and a symlink pointing to it
|
||||
tmp := t.TempDir()
|
||||
realFile := filepath.Join(tmp, "real")
|
||||
if err := os.WriteFile(realFile, []byte("data"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
symlink := filepath.Join(tmp, "template_link")
|
||||
if err := os.Symlink(realFile, symlink); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
p := &CPProvisioner{baseURL: "http://unused", orgID: "org-1", httpClient: &http.Client{Timeout: time.Second}}
|
||||
_, err := p.Start(context.Background(), WorkspaceConfig{
|
||||
WorkspaceID: "ws-1",
|
||||
Runtime: "python",
|
||||
TemplatePath: symlink, // symlink root → OFFSEC-010 guard should fire
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected error for symlink TemplatePath, got nil")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "symlink") {
|
||||
t.Errorf("error should mention symlink, got %q", err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
// TestStop_SendsBothAuthHeaders — verify #118/#130 compliance on the
|
||||
// teardown path. Any call to /cp/workspaces/:id must carry both the
|
||||
// platform-wide shared secret AND the per-tenant admin token, or the
|
||||
@@ -842,3 +945,67 @@ func TestIsRunning_EmptyInstanceIDReturnsFalse(t *testing.T) {
|
||||
t.Errorf("IsRunning with empty instance_id should return running=false, got true")
|
||||
}
|
||||
}
|
||||
|
||||
// TestCollectCPConfigFiles_SkipsSymlinks — WalkDir follows symlinks by default,
|
||||
// but collectCPConfigFiles must skip them so a symlink inside a template dir
|
||||
// pointing outside (e.g. ln -s /etc snapshot) cannot be traversed.
|
||||
// Verifies OFFSEC-010 defense-in-depth fix. (OFFSEC-010)
|
||||
func TestCollectCPConfigFiles_SkipsSymlinks(t *testing.T) {
|
||||
tmpl := t.TempDir()
|
||||
// Write a real file that should be included.
|
||||
if err := os.WriteFile(filepath.Join(tmpl, "config.yaml"), []byte("name: real\n"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// Create a subdir with a file that will be symlinked-outside.
|
||||
sensitiveDir := t.TempDir()
|
||||
if err := os.WriteFile(filepath.Join(sensitiveDir, "secret.txt"), []byte("SENSITIVE\n"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// Symlink inside template dir pointing to outside path.
|
||||
symlinkPath := filepath.Join(tmpl, "snapshot")
|
||||
if err := os.Symlink(sensitiveDir, symlinkPath); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
files, err := collectCPConfigFiles(WorkspaceConfig{TemplatePath: tmpl})
|
||||
if err != nil {
|
||||
t.Fatalf("collectCPConfigFiles: %v", err)
|
||||
}
|
||||
if files == nil {
|
||||
t.Fatal("files should not be nil")
|
||||
}
|
||||
// config.yaml must be present.
|
||||
if _, ok := files["config.yaml"]; !ok {
|
||||
t.Errorf("config.yaml missing from files")
|
||||
}
|
||||
// The symlinked path must NOT be included (even though WalkDir would
|
||||
// traverse it, the d.Type()&os.ModeSymlink guard skips the entry).
|
||||
for k := range files {
|
||||
if strings.Contains(k, "snapshot") || strings.Contains(k, "secret") {
|
||||
t.Errorf("symlink path %q should not be in files — OFFSEC-010 regression", k)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestCollectCPConfigFiles_RejectsRootSymlink — if cfg.TemplatePath itself is
|
||||
// a symlink, WalkDir would follow it to an arbitrary directory, bypassing the
|
||||
// cfg.TemplatePath boundary. The function must reject this case explicitly.
|
||||
// (OFFSEC-010)
|
||||
func TestCollectCPConfigFiles_RejectsRootSymlink(t *testing.T) {
|
||||
real := t.TempDir()
|
||||
if err := os.WriteFile(filepath.Join(real, "config.yaml"), []byte("name: real\n"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
link := filepath.Join(t.TempDir(), "template-link")
|
||||
if err := os.Symlink(real, link); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
_, err := collectCPConfigFiles(WorkspaceConfig{TemplatePath: link})
|
||||
if err == nil {
|
||||
t.Error("collectCPConfigFiles with symlink TemplatePath should return error")
|
||||
}
|
||||
if err != nil && !strings.Contains(err.Error(), "symlink") {
|
||||
t.Errorf("expected symlink-related error, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,9 +3,57 @@
|
||||
import logging
|
||||
import os
|
||||
from abc import ABC, abstractmethod
|
||||
from collections.abc import Mapping
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Provider routing — type alias + resolver used by individual adapters.
|
||||
# Each adapter defines its own ProviderRegistry with the providers it accepts.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
# Maps prefix → (ordered_auth_env_vars, default_base_url).
|
||||
ProviderRegistry = dict[str, tuple[tuple[str, ...], str]]
|
||||
|
||||
|
||||
def resolve_provider_routing(
|
||||
model_str: str,
|
||||
env: Mapping[str, str],
|
||||
*,
|
||||
registry: ProviderRegistry,
|
||||
runtime_config: dict[str, Any] | None = None,
|
||||
) -> tuple[str, str, str]:
|
||||
"""Resolve a ``provider:model`` string to ``(api_key, base_url, bare_model_id)``.
|
||||
|
||||
URL precedence (highest to lowest):
|
||||
1. ``<PREFIX>_BASE_URL`` env var
|
||||
2. ``runtime_config["provider_url"]``
|
||||
3. registry default for the prefix
|
||||
|
||||
Unknown prefixes fall back to OPENAI_API_KEY + api.openai.com.
|
||||
Raises RuntimeError when no API key env var is set for the prefix.
|
||||
"""
|
||||
if ":" in model_str:
|
||||
prefix, model_id = model_str.split(":", 1)
|
||||
else:
|
||||
prefix, model_id = "openai", model_str
|
||||
|
||||
env_vars, default_url = registry.get(
|
||||
prefix, (("OPENAI_API_KEY",), "https://api.openai.com/v1")
|
||||
)
|
||||
api_key = next((env[v] for v in env_vars if env.get(v)), "")
|
||||
if not api_key:
|
||||
raise RuntimeError(
|
||||
f"No API key found for provider {prefix!r} "
|
||||
f"(checked: {', '.join(env_vars)}). Set one in workspace secrets."
|
||||
)
|
||||
|
||||
env_url = env.get(f"{prefix.upper()}_BASE_URL", "")
|
||||
config_url = (runtime_config or {}).get("provider_url", "")
|
||||
base_url = env_url or config_url or default_url
|
||||
|
||||
return api_key, base_url, model_id
|
||||
|
||||
from a2a.server.agent_execution import AgentExecutor
|
||||
|
||||
from event_log import DisabledEventLog, EventLogBackend
|
||||
|
||||
@@ -1,153 +1,141 @@
|
||||
"""Unit tests for OpenClaw adapter env-var key selection and provider URL routing.
|
||||
"""Unit tests for resolve_provider_routing in adapter_base.
|
||||
|
||||
The key-selection and URL-routing logic lives inline in OpenClawAdapter.setup()
|
||||
(adapter.py lines 84-92). Since setup() carries heavy subprocess dependencies,
|
||||
these tests isolate the selection logic by reproducing the exact Python expressions
|
||||
from the adapter source — if the adapter's logic changes, these tests must be kept
|
||||
in sync.
|
||||
|
||||
Organisation:
|
||||
TestEnvKeyChain — priority order of the 3 currently supported keys
|
||||
TestProviderUrlMapping — model-prefix → provider URL dict correctness
|
||||
TestNegativeAndFallback — no keys set / unsupported keys
|
||||
xfail stubs — AISTUDIO + QIANFAN documented as not-yet-implemented
|
||||
Covers provider routing, URL-override precedence, and the missing-key error path.
|
||||
Each adapter defines its own registry; this test file defines one inline that
|
||||
mirrors what the openclaw adapter uses.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
|
||||
from adapter_base import ProviderRegistry, resolve_provider_routing
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers — mirror the exact expressions from adapter.py lines 84-92.
|
||||
# Must be kept in sync with the adapter source.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _select_key(env: dict) -> str:
|
||||
"""Mirror line 84: nested os.environ.get priority chain."""
|
||||
return env.get("OPENAI_API_KEY",
|
||||
env.get("GROQ_API_KEY",
|
||||
env.get("OPENROUTER_API_KEY", "")))
|
||||
|
||||
|
||||
_PROVIDER_URLS: dict[str, str] = {
|
||||
"openai": "https://api.openai.com/v1",
|
||||
"groq": "https://api.groq.com/openai/v1",
|
||||
"openrouter": "https://openrouter.ai/api/v1",
|
||||
# Mirror of the registry in openclaw's adapter.py — kept in sync manually.
|
||||
PROVIDER_REGISTRY: ProviderRegistry = {
|
||||
"openai": (("OPENAI_API_KEY",), "https://api.openai.com/v1"),
|
||||
"groq": (("GROQ_API_KEY",), "https://api.groq.com/openai/v1"),
|
||||
"openrouter": (("OPENROUTER_API_KEY",), "https://openrouter.ai/api/v1"),
|
||||
"qianfan": (("QIANFAN_API_KEY", "AISTUDIO_API_KEY"), "https://qianfan.baidubce.com/v2"),
|
||||
"minimax": (("MINIMAX_API_KEY",), "https://api.minimaxi.com/v1"),
|
||||
"moonshot": (("KIMI_API_KEY",), "https://api.moonshot.ai/v1"),
|
||||
}
|
||||
|
||||
|
||||
def _select_url(model: str, runtime_config: dict | None = None) -> str:
|
||||
"""Mirror lines 86-92: model-prefix → provider URL with optional override."""
|
||||
prefix = model.split(":")[0] if ":" in model else "openai"
|
||||
return (runtime_config or {}).get(
|
||||
"provider_url",
|
||||
_PROVIDER_URLS.get(prefix, "https://api.openai.com/v1"),
|
||||
)
|
||||
class TestProviderRouting:
|
||||
|
||||
def test_openai_key_and_url(self):
|
||||
api_key, base_url, model_id = resolve_provider_routing(
|
||||
"openai:gpt-4o", {"OPENAI_API_KEY": "sk-openai"}, registry=PROVIDER_REGISTRY
|
||||
)
|
||||
assert api_key == "sk-openai"
|
||||
assert base_url == "https://api.openai.com/v1"
|
||||
assert model_id == "gpt-4o"
|
||||
|
||||
def test_groq_key_and_url(self):
|
||||
api_key, base_url, model_id = resolve_provider_routing(
|
||||
"groq:llama-3.3-70b", {"GROQ_API_KEY": "sk-groq"}, registry=PROVIDER_REGISTRY
|
||||
)
|
||||
assert api_key == "sk-groq"
|
||||
assert base_url == "https://api.groq.com/openai/v1"
|
||||
assert model_id == "llama-3.3-70b"
|
||||
|
||||
def test_openrouter_key_and_url(self):
|
||||
api_key, base_url, model_id = resolve_provider_routing(
|
||||
"openrouter:anthropic/claude-sonnet-4-5", {"OPENROUTER_API_KEY": "sk-or"}, registry=PROVIDER_REGISTRY
|
||||
)
|
||||
assert api_key == "sk-or"
|
||||
assert base_url == "https://openrouter.ai/api/v1"
|
||||
assert model_id == "anthropic/claude-sonnet-4-5"
|
||||
|
||||
def test_qianfan_primary_key(self):
|
||||
api_key, _, _ = resolve_provider_routing(
|
||||
"qianfan:ernie-4.5", {"QIANFAN_API_KEY": "sk-qf", "AISTUDIO_API_KEY": "sk-ai"}, registry=PROVIDER_REGISTRY
|
||||
)
|
||||
assert api_key == "sk-qf"
|
||||
|
||||
def test_qianfan_fallback_to_aistudio(self):
|
||||
api_key, base_url, _ = resolve_provider_routing(
|
||||
"qianfan:ernie-4.5", {"AISTUDIO_API_KEY": "sk-ai"}, registry=PROVIDER_REGISTRY
|
||||
)
|
||||
assert api_key == "sk-ai"
|
||||
assert base_url == "https://qianfan.baidubce.com/v2"
|
||||
|
||||
def test_minimax_key_and_url(self):
|
||||
api_key, base_url, model_id = resolve_provider_routing(
|
||||
"minimax:MiniMax-M2.7", {"MINIMAX_API_KEY": "sk-mm"}, registry=PROVIDER_REGISTRY
|
||||
)
|
||||
assert api_key == "sk-mm"
|
||||
assert base_url == "https://api.minimaxi.com/v1"
|
||||
assert model_id == "MiniMax-M2.7"
|
||||
|
||||
def test_moonshot_key_and_url(self):
|
||||
api_key, base_url, model_id = resolve_provider_routing(
|
||||
"moonshot:kimi-k2.5", {"KIMI_API_KEY": "sk-kimi"}, registry=PROVIDER_REGISTRY
|
||||
)
|
||||
assert api_key == "sk-kimi"
|
||||
assert base_url == "https://api.moonshot.ai/v1"
|
||||
assert model_id == "kimi-k2.5"
|
||||
|
||||
def test_bare_model_id_defaults_to_openai(self):
|
||||
api_key, base_url, model_id = resolve_provider_routing(
|
||||
"gpt-4o", {"OPENAI_API_KEY": "sk-openai"}, registry=PROVIDER_REGISTRY
|
||||
)
|
||||
assert base_url == "https://api.openai.com/v1"
|
||||
assert model_id == "gpt-4o"
|
||||
|
||||
def test_unknown_prefix_falls_back_to_openai_url(self):
|
||||
api_key, base_url, model_id = resolve_provider_routing(
|
||||
"custom-shim:my-model", {"OPENAI_API_KEY": "sk-openai"}, registry=PROVIDER_REGISTRY
|
||||
)
|
||||
assert base_url == "https://api.openai.com/v1"
|
||||
assert model_id == "my-model"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 1. Env-var key priority chain (3 keys currently in adapter.py)
|
||||
# ---------------------------------------------------------------------------
|
||||
class TestUrlOverridePrecedence:
|
||||
|
||||
class TestEnvKeyChain:
|
||||
def test_env_base_url_beats_registry_default(self):
|
||||
_, base_url, _ = resolve_provider_routing(
|
||||
"minimax:MiniMax-M2.7",
|
||||
{"MINIMAX_API_KEY": "sk-mm", "MINIMAX_BASE_URL": "https://api.minimax.chat/v1"},
|
||||
registry=PROVIDER_REGISTRY,
|
||||
)
|
||||
assert base_url == "https://api.minimax.chat/v1"
|
||||
|
||||
def test_openai_key_selected(self):
|
||||
with patch.dict(os.environ, {"OPENAI_API_KEY": "sk-openai-test"}, clear=True):
|
||||
assert _select_key(os.environ) == "sk-openai-test"
|
||||
def test_runtime_config_provider_url_beats_registry_default(self):
|
||||
_, base_url, _ = resolve_provider_routing(
|
||||
"openai:gpt-4o",
|
||||
{"OPENAI_API_KEY": "sk-openai"},
|
||||
registry=PROVIDER_REGISTRY,
|
||||
runtime_config={"provider_url": "https://proxy.example.com/v1"},
|
||||
)
|
||||
assert base_url == "https://proxy.example.com/v1"
|
||||
|
||||
def test_groq_key_selected_when_openai_absent(self):
|
||||
with patch.dict(os.environ, {"GROQ_API_KEY": "sk-groq-test"}, clear=True):
|
||||
assert _select_key(os.environ) == "sk-groq-test"
|
||||
|
||||
def test_openrouter_key_selected_when_openai_and_groq_absent(self):
|
||||
with patch.dict(os.environ, {"OPENROUTER_API_KEY": "sk-or-test"}, clear=True):
|
||||
assert _select_key(os.environ) == "sk-or-test"
|
||||
|
||||
def test_openai_beats_groq_when_both_set(self):
|
||||
with patch.dict(os.environ, {"OPENAI_API_KEY": "openai", "GROQ_API_KEY": "groq"}, clear=True):
|
||||
assert _select_key(os.environ) == "openai"
|
||||
|
||||
def test_groq_beats_openrouter_when_openai_absent(self):
|
||||
with patch.dict(os.environ, {"GROQ_API_KEY": "groq", "OPENROUTER_API_KEY": "or"}, clear=True):
|
||||
assert _select_key(os.environ) == "groq"
|
||||
def test_env_base_url_beats_runtime_config(self):
|
||||
_, base_url, _ = resolve_provider_routing(
|
||||
"openai:gpt-4o",
|
||||
{"OPENAI_API_KEY": "sk-openai", "OPENAI_BASE_URL": "https://env-wins.com/v1"},
|
||||
registry=PROVIDER_REGISTRY,
|
||||
runtime_config={"provider_url": "https://config-loses.com/v1"},
|
||||
)
|
||||
assert base_url == "https://env-wins.com/v1"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 2. Model-prefix → provider URL routing
|
||||
# ---------------------------------------------------------------------------
|
||||
class TestMissingKey:
|
||||
|
||||
class TestProviderUrlMapping:
|
||||
def test_raises_when_no_key_set(self):
|
||||
with pytest.raises(RuntimeError, match="No API key found for provider 'minimax'"):
|
||||
resolve_provider_routing("minimax:MiniMax-M2.7", {}, registry=PROVIDER_REGISTRY)
|
||||
|
||||
def test_openai_prefix_routes_to_openai(self):
|
||||
assert _select_url("openai:gpt-4o") == "https://api.openai.com/v1"
|
||||
|
||||
def test_groq_prefix_routes_to_groq(self):
|
||||
assert _select_url("groq:llama3-70b") == "https://api.groq.com/openai/v1"
|
||||
|
||||
def test_openrouter_prefix_routes_to_openrouter(self):
|
||||
assert _select_url("openrouter:meta-llama/llama-3.3-70b") == "https://openrouter.ai/api/v1"
|
||||
|
||||
def test_runtime_config_override_wins_over_prefix(self):
|
||||
url = _select_url("openai:gpt-4o", {"provider_url": "https://custom.example.com/v1"})
|
||||
assert url == "https://custom.example.com/v1"
|
||||
|
||||
def test_unknown_prefix_falls_back_to_openai(self):
|
||||
assert _select_url("some-unknown-model") == "https://api.openai.com/v1"
|
||||
def test_raises_lists_checked_vars_in_message(self):
|
||||
with pytest.raises(RuntimeError, match="MINIMAX_API_KEY"):
|
||||
resolve_provider_routing("minimax:MiniMax-M2.7", {}, registry=PROVIDER_REGISTRY)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 3. Negative / fallback cases
|
||||
# ---------------------------------------------------------------------------
|
||||
class TestRegistryCompleteness:
|
||||
"""Smoke-check that every provider in the registry has a non-empty entry."""
|
||||
|
||||
class TestNegativeAndFallback:
|
||||
|
||||
def test_no_keys_returns_empty_string(self):
|
||||
with patch.dict(os.environ, {}, clear=True):
|
||||
assert _select_key(os.environ) == ""
|
||||
|
||||
def test_unsupported_aistudio_key_returns_empty(self):
|
||||
"""Documents that AISTUDIO_API_KEY is NOT yet in the adapter's key chain."""
|
||||
with patch.dict(os.environ, {"AISTUDIO_API_KEY": "sk-ai"}, clear=True):
|
||||
assert _select_key(os.environ) == ""
|
||||
|
||||
def test_unsupported_qianfan_key_returns_empty(self):
|
||||
"""Documents that QIANFAN_API_KEY is NOT yet in the adapter's key chain."""
|
||||
with patch.dict(os.environ, {"QIANFAN_API_KEY": "sk-qf"}, clear=True):
|
||||
assert _select_key(os.environ) == ""
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 4. AISTUDIO + QIANFAN — xfail stubs (not yet implemented in adapter.py)
|
||||
# These fail now; they should be promoted to passing tests once the adapter
|
||||
# adds AISTUDIO_API_KEY and QIANFAN_API_KEY to its key chain and provider_urls.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@pytest.mark.xfail(
|
||||
strict=True,
|
||||
reason=(
|
||||
"AISTUDIO_API_KEY not yet in openclaw adapter env-var chain — "
|
||||
"add to adapter.py line 84 and provider_urls dict with "
|
||||
"URL https://generativelanguage.googleapis.com/v1beta/openai"
|
||||
),
|
||||
)
|
||||
def test_aistudio_key_routes_to_aistudio_url():
|
||||
with patch.dict(os.environ, {"AISTUDIO_API_KEY": "sk-ai-test"}, clear=True):
|
||||
assert _select_key(os.environ) == "sk-ai-test"
|
||||
assert _select_url("gemini-2.5-flash") == "https://generativelanguage.googleapis.com/v1beta/openai"
|
||||
|
||||
|
||||
@pytest.mark.xfail(
|
||||
strict=True,
|
||||
reason=(
|
||||
"QIANFAN_API_KEY not yet in openclaw adapter env-var chain — "
|
||||
"add to adapter.py line 84 and provider_urls dict with "
|
||||
"URL https://qianfan.baidubce.com/v2"
|
||||
),
|
||||
)
|
||||
def test_qianfan_key_routes_to_qianfan_url():
|
||||
with patch.dict(os.environ, {"QIANFAN_API_KEY": "sk-qf-test"}, clear=True):
|
||||
assert _select_key(os.environ) == "sk-qf-test"
|
||||
assert _select_url("ernie-4.5") == "https://qianfan.baidubce.com/v2"
|
||||
@pytest.mark.parametrize("prefix", PROVIDER_REGISTRY)
|
||||
def test_all_providers_have_key_vars_and_url(self, prefix):
|
||||
env_vars, base_url = PROVIDER_REGISTRY[prefix]
|
||||
assert env_vars, f"{prefix}: env_vars is empty"
|
||||
assert base_url.startswith("https://"), f"{prefix}: base_url looks wrong: {base_url}"
|
||||
|
||||
Reference in New Issue
Block a user