Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 7b40a03c45 | |||
| e9d32c09d3 | |||
| e89f0ce605 | |||
| 1278d57c12 |
@@ -256,6 +256,13 @@ dependencies = [
|
||||
"uvicorn>=0.30.0",
|
||||
"starlette>=0.38.0",
|
||||
"websockets>=12.0",
|
||||
# multipart/form-data parser — required for Starlette's Request.form() on
|
||||
# /internal/chat/uploads/ingest. Without it, Starlette raises AssertionError
|
||||
# when parsing multipart bodies, which the chat-upload handler surfaces as
|
||||
# an opaque 400. Mirrors the canonical pin in workspace/requirements.txt;
|
||||
# >=0.0.27 avoids CVE-2024-53981 (DoS via malformed boundary).
|
||||
# Forensic a78762a0 (2026-05-19): Hermes PDF upload 400 root cause.
|
||||
"python-multipart>=0.0.27",
|
||||
"pyyaml>=6.0",
|
||||
"langchain-core>=0.3.0",
|
||||
"opentelemetry-api>=1.24.0",
|
||||
|
||||
@@ -518,11 +518,24 @@ func (h *SecretsHandler) GetModel(c *gin.Context) {
|
||||
workspaceID := c.Param("id")
|
||||
ctx := c.Request.Context()
|
||||
|
||||
// Check if MODEL_PROVIDER secret exists
|
||||
// Check if MODEL secret exists.
|
||||
//
|
||||
// Historical note: this row was named MODEL_PROVIDER pre-2026-05-19
|
||||
// (see ab12af50 + a7e8892 root-cause analysis). The column name
|
||||
// MODEL_PROVIDER was misleading — it never held a provider slug,
|
||||
// only the picked model id (e.g. "minimax/MiniMax-M2.7"). The
|
||||
// misnomer caused workspace-server's applyRuntimeModelEnv to
|
||||
// overwrite a legitimate persona-env MODEL with whatever literal
|
||||
// string lived in MODEL_PROVIDER (often "minimax" or "claude-code"
|
||||
// — not a valid model id), wedging adapters at SDK initialize.
|
||||
// CP-side slot-separation (cp#213 + cp#220) already corrected the
|
||||
// CP-side analogue; this is the workspace-server companion. A
|
||||
// migration in 20260519000000_workspace_secrets_model_provider_rename.up.sql
|
||||
// moves any legacy rows to the new key on rollout.
|
||||
var modelBytes []byte
|
||||
var modelVersion int
|
||||
err := db.DB.QueryRowContext(ctx,
|
||||
`SELECT encrypted_value, encryption_version FROM workspace_secrets WHERE workspace_id = $1 AND key = 'MODEL_PROVIDER'`,
|
||||
`SELECT encrypted_value, encryption_version FROM workspace_secrets WHERE workspace_id = $1 AND key = 'MODEL'`,
|
||||
workspaceID).Scan(&modelBytes, &modelVersion)
|
||||
if err == sql.ErrNoRows {
|
||||
c.JSON(http.StatusOK, gin.H{"model": "", "source": "default"})
|
||||
@@ -542,18 +555,23 @@ func (h *SecretsHandler) GetModel(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, gin.H{"model": string(decrypted), "source": "workspace_secrets"})
|
||||
}
|
||||
|
||||
// setModelSecret writes (or clears, when value=="") the MODEL_PROVIDER
|
||||
// workspace secret. Extracted from SetModel so non-handler call sites
|
||||
// (notably WorkspaceHandler.Create — first-deploy path that persists the
|
||||
// setModelSecret writes (or clears, when value=="") the MODEL workspace
|
||||
// secret. Extracted from SetModel so non-handler call sites (notably
|
||||
// WorkspaceHandler.Create — first-deploy path that persists the
|
||||
// canvas-selected model so applyRuntimeModelEnv's restart fallback finds
|
||||
// it) can reuse the encryption + upsert logic without inlining the SQL.
|
||||
//
|
||||
// The row was previously keyed MODEL_PROVIDER (misnomer — it never held
|
||||
// a provider, only a model id). Renamed to MODEL on 2026-05-19; the
|
||||
// 20260519000000_workspace_secrets_model_provider_rename migration moves
|
||||
// any legacy rows on rollout.
|
||||
//
|
||||
// Returns nil on success. Caller is responsible for any restart trigger;
|
||||
// the gin handler re-adds that after a successful write.
|
||||
func setModelSecret(ctx context.Context, workspaceID, model string) error {
|
||||
if model == "" {
|
||||
_, err := db.DB.ExecContext(ctx,
|
||||
`DELETE FROM workspace_secrets WHERE workspace_id = $1 AND key = 'MODEL_PROVIDER'`,
|
||||
`DELETE FROM workspace_secrets WHERE workspace_id = $1 AND key = 'MODEL'`,
|
||||
workspaceID)
|
||||
return err
|
||||
}
|
||||
@@ -564,7 +582,7 @@ func setModelSecret(ctx context.Context, workspaceID, model string) error {
|
||||
version := crypto.CurrentEncryptionVersion()
|
||||
_, err = db.DB.ExecContext(ctx, `
|
||||
INSERT INTO workspace_secrets (workspace_id, key, encrypted_value, encryption_version)
|
||||
VALUES ($1, 'MODEL_PROVIDER', $2, $3)
|
||||
VALUES ($1, 'MODEL', $2, $3)
|
||||
ON CONFLICT (workspace_id, key) DO UPDATE
|
||||
SET encrypted_value = $2, encryption_version = $3, updated_at = now()
|
||||
`, workspaceID, encrypted, version)
|
||||
@@ -572,7 +590,7 @@ func setModelSecret(ctx context.Context, workspaceID, model string) error {
|
||||
}
|
||||
|
||||
// SetModel handles PUT /workspaces/:id/model — writes the model slug
|
||||
// into workspace_secrets as MODEL_PROVIDER (the key GetModel reads).
|
||||
// into workspace_secrets as MODEL (the key GetModel reads).
|
||||
// For hermes, the value is a hermes-native slug like "minimax/MiniMax-M2.7";
|
||||
// for langgraph it's the legacy "provider:model" form. Either way it's just
|
||||
// an opaque string the runtime interprets on its next start.
|
||||
|
||||
@@ -479,8 +479,10 @@ func TestSecretsGetModel_Default(t *testing.T) {
|
||||
setupTestRedis(t)
|
||||
handler := NewSecretsHandler(nil)
|
||||
|
||||
// No MODEL_PROVIDER secret
|
||||
mock.ExpectQuery("SELECT encrypted_value, encryption_version FROM workspace_secrets").
|
||||
// No MODEL secret (formerly MODEL_PROVIDER — see 2026-05-19 rename
|
||||
// migration). Pin the WHERE clause so a regression that reads the
|
||||
// wrong column-name shows up here.
|
||||
mock.ExpectQuery(`SELECT encrypted_value, encryption_version FROM workspace_secrets WHERE workspace_id = \$1 AND key = 'MODEL'`).
|
||||
WithArgs("ws-model").
|
||||
WillReturnError(sql.ErrNoRows)
|
||||
|
||||
@@ -516,7 +518,7 @@ func TestSecretsGetModel_DBError(t *testing.T) {
|
||||
setupTestRedis(t)
|
||||
handler := NewSecretsHandler(nil)
|
||||
|
||||
mock.ExpectQuery("SELECT encrypted_value, encryption_version FROM workspace_secrets").
|
||||
mock.ExpectQuery(`SELECT encrypted_value, encryption_version FROM workspace_secrets WHERE workspace_id = \$1 AND key = 'MODEL'`).
|
||||
WithArgs("ws-model-err").
|
||||
WillReturnError(sql.ErrConnDone)
|
||||
|
||||
@@ -544,7 +546,9 @@ func TestSecretsSetModel_Upsert(t *testing.T) {
|
||||
restartCalled := make(chan string, 1)
|
||||
handler := NewSecretsHandler(func(id string) { restartCalled <- id })
|
||||
|
||||
mock.ExpectExec(`INSERT INTO workspace_secrets`).
|
||||
// Pin the literal 'MODEL' key in the SQL so a regression to the
|
||||
// pre-2026-05-19 'MODEL_PROVIDER' column name shows up here.
|
||||
mock.ExpectExec(`INSERT INTO workspace_secrets[\s\S]*'MODEL'`).
|
||||
WithArgs("00000000-0000-0000-0000-000000000001", sqlmock.AnyArg(), sqlmock.AnyArg()).
|
||||
WillReturnResult(sqlmock.NewResult(1, 1))
|
||||
|
||||
@@ -578,7 +582,8 @@ func TestSecretsSetModel_EmptyClears(t *testing.T) {
|
||||
setupTestRedis(t)
|
||||
handler := NewSecretsHandler(func(string) {})
|
||||
|
||||
mock.ExpectExec(`DELETE FROM workspace_secrets`).
|
||||
// Pin the literal 'MODEL' key — see TestSecretsSetModel_Upsert.
|
||||
mock.ExpectExec(`DELETE FROM workspace_secrets WHERE workspace_id = \$1 AND key = 'MODEL'`).
|
||||
WithArgs("00000000-0000-0000-0000-000000000002").
|
||||
WillReturnResult(sqlmock.NewResult(0, 1))
|
||||
|
||||
@@ -618,6 +623,65 @@ func TestSecretsSetModel_InvalidID(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestSecretsModel_RoundTrip_KeyIsMODELNotMODEL_PROVIDER pins the
|
||||
// 2026-05-19 rename: writes via SetModel land under workspace_secrets
|
||||
// key='MODEL', and reads via GetModel hit the same key. A regression
|
||||
// that reverts either side to 'MODEL_PROVIDER' will mismatch sqlmock's
|
||||
// query-regex anchor and fail loudly here. Combined integration-shape
|
||||
// guard for the secrets.go half of fix/workspace-server-rename-
|
||||
// MODEL_PROVIDER-to-MODEL.
|
||||
func TestSecretsModel_RoundTrip_KeyIsMODELNotMODEL_PROVIDER(t *testing.T) {
|
||||
mock := setupTestDB(t)
|
||||
setupTestRedis(t)
|
||||
handler := NewSecretsHandler(func(string) {})
|
||||
|
||||
// 1. SetModel — must hit key='MODEL' in the INSERT.
|
||||
mock.ExpectExec(`INSERT INTO workspace_secrets[\s\S]*'MODEL'[\s\S]*ON CONFLICT`).
|
||||
WithArgs("00000000-0000-0000-0000-000000000099", sqlmock.AnyArg(), sqlmock.AnyArg()).
|
||||
WillReturnResult(sqlmock.NewResult(1, 1))
|
||||
|
||||
w1 := httptest.NewRecorder()
|
||||
c1, _ := gin.CreateTestContext(w1)
|
||||
c1.Params = gin.Params{{Key: "id", Value: "00000000-0000-0000-0000-000000000099"}}
|
||||
c1.Request = httptest.NewRequest("PUT", "/workspaces/00000000-0000-0000-0000-000000000099/model",
|
||||
strings.NewReader(`{"model":"gpt-5.5"}`))
|
||||
c1.Request.Header.Set("Content-Type", "application/json")
|
||||
handler.SetModel(c1)
|
||||
if w1.Code != http.StatusOK {
|
||||
t.Fatalf("SetModel: expected 200, got %d: %s", w1.Code, w1.Body.String())
|
||||
}
|
||||
|
||||
// 2. GetModel — must hit key='MODEL' in the SELECT. Return raw
|
||||
// bytes; the handler will run them through DecryptVersioned.
|
||||
// crypto is disabled in the test env (no MASTER_KEY), so the
|
||||
// raw bytes pass through unchanged. We assert the SELECT
|
||||
// fires against key='MODEL' (the rename pin); the decoded
|
||||
// value isn't load-bearing for this contract test.
|
||||
mock.ExpectQuery(`SELECT encrypted_value, encryption_version FROM workspace_secrets WHERE workspace_id = \$1 AND key = 'MODEL'`).
|
||||
WithArgs("00000000-0000-0000-0000-000000000099").
|
||||
WillReturnRows(sqlmock.NewRows([]string{"encrypted_value", "encryption_version"}).
|
||||
AddRow([]byte("gpt-5.5"), 0))
|
||||
|
||||
w2 := httptest.NewRecorder()
|
||||
c2, _ := gin.CreateTestContext(w2)
|
||||
c2.Params = gin.Params{{Key: "id", Value: "00000000-0000-0000-0000-000000000099"}}
|
||||
c2.Request = httptest.NewRequest("GET", "/workspaces/00000000-0000-0000-0000-000000000099/model", nil)
|
||||
handler.GetModel(c2)
|
||||
if w2.Code != http.StatusOK {
|
||||
t.Fatalf("GetModel: expected 200, got %d: %s", w2.Code, w2.Body.String())
|
||||
}
|
||||
|
||||
// We don't assert resp["model"] equals "gpt-5.5" because crypto
|
||||
// state in this package varies by build tag; the load-bearing
|
||||
// contract is the workspace_secrets key, pinned by the sqlmock
|
||||
// regex above. If a future change adds encryption to the test
|
||||
// env, the round-trip value check can move to an integration
|
||||
// test that owns the crypto state.
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Errorf("unmet sqlmock expectations — Model round-trip did not hit key='MODEL' on both sides: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== GetProvider / SetProvider (Option B PR-2) ====================
|
||||
//
|
||||
// Mirror of the GetModel/SetModel suite. Same secret-storage shape (key=
|
||||
|
||||
@@ -786,51 +786,57 @@ func applyRuntimeModelEnv(envVars map[string]string, runtime, model string) {
|
||||
// Resolution order (priority high → low):
|
||||
// 1. payload.Model (caller passed the canvas-picked model id verbatim)
|
||||
// 2. envVars["MOLECULE_MODEL"] (the canonical, unambiguous name)
|
||||
// 3. envVars["MODEL"] (workspace_secret persisted by /org/import via
|
||||
// the persona env file — MODEL=MiniMax-M2.7-highspeed etc.)
|
||||
// 4. envVars["MODEL_PROVIDER"] (legacy + misleadingly named: it carries
|
||||
// a *model id*, never the provider — that's LLM_PROVIDER. Historically
|
||||
// set by canvas Save+Restart's PUT /model; the post-2026-05-08
|
||||
// persona-env convention sometimes (mis)set it to a provider slug
|
||||
// ("minimax") or a runtime name ("claude-code"), neither a valid
|
||||
// model id — see internal#226. Only fires when the better-named
|
||||
// vars are absent.)
|
||||
// 3. envVars["MODEL"] (workspace_secret — written by SetModel /
|
||||
// WorkspaceHandler.Create / persona env file; the only correct
|
||||
// home for a picked model id).
|
||||
//
|
||||
// Pre-fix bug: this function unconditionally OVERWROTE envVars["MODEL"]
|
||||
// with the MODEL_PROVIDER slug (when payload.Model was empty), wiping
|
||||
// the operator's explicit per-persona MODEL secret on every restart.
|
||||
// Symptom: a workspace whose persona env said
|
||||
// MODEL=MiniMax-M2.7-highspeed booted fine on first /org/import (the
|
||||
// envVars map was populated direct from the env file), then on the
|
||||
// next Restart the workspace_secrets-derived MODEL got clobbered by
|
||||
// MODEL_PROVIDER="minimax" — the literal slug, not a valid model id —
|
||||
// and the workspace template's adapter routed to providers[0]
|
||||
// (anthropic-oauth) and wedged at SDK initialize. Caught 2026-05-08
|
||||
// during Phase 4 verification of template-claude-code PR #9.
|
||||
// Pre-fix bug (2026-05-08): this function used to consult
|
||||
// envVars["MODEL_PROVIDER"] as a fourth fallback AND unconditionally
|
||||
// overwrite envVars["MODEL"] with that slug when payload.Model was
|
||||
// empty. The MODEL_PROVIDER key was misleadingly named — it carried
|
||||
// a model id, never a provider — and the persona-env convention
|
||||
// sometimes (mis)set it to a provider slug ("minimax") or a runtime
|
||||
// name ("claude-code"), neither a valid model id. Symptom: a
|
||||
// workspace whose persona env said MODEL=MiniMax-M2.7-highspeed
|
||||
// booted fine on first /org/import, then on the next Restart the
|
||||
// workspace_secrets-derived MODEL got clobbered by
|
||||
// MODEL_PROVIDER="minimax" — the literal slug, not a valid model
|
||||
// id — and the workspace template's adapter routed to providers[0]
|
||||
// (anthropic-oauth) and wedged at SDK initialize.
|
||||
//
|
||||
// The 2026-05-19 follow-up fix (this commit) renamed the
|
||||
// workspace_secrets row MODEL_PROVIDER → MODEL (root cause: the
|
||||
// misleading column name; see secrets.go + the
|
||||
// 20260519000000_workspace_secrets_model_provider_rename migration)
|
||||
// and drops the MODEL_PROVIDER fallback here so the fallback chain
|
||||
// can no longer confuse a provider slug for a model id. CP-side
|
||||
// slot-separation (cp#213 + cp#220) merged the analogous fix on
|
||||
// the CP side; this is the workspace-server companion.
|
||||
if model == "" {
|
||||
model = envVars["MOLECULE_MODEL"]
|
||||
}
|
||||
if model == "" {
|
||||
model = envVars["MODEL"]
|
||||
}
|
||||
if model == "" {
|
||||
model = envVars["MODEL_PROVIDER"]
|
||||
}
|
||||
if model == "" {
|
||||
return
|
||||
}
|
||||
|
||||
// Canonical model env vars — molecule-runtime's workspace/config.py
|
||||
// resolves the picked model as MOLECULE_MODEL > MODEL > (legacy)
|
||||
// MODEL_PROVIDER (#280). Export both new names so adapters can read
|
||||
// either; MODEL stays for backwards compat with everything that
|
||||
// already reads os.environ["MODEL"] (the claude-code adapter does,
|
||||
// since #194). Without this, the user's canvas selection is silently
|
||||
// dropped on every templated provision — confirmed via crash-loop
|
||||
// diagnosis on 2026-05-02 where MiniMax picks booted with model=sonnet
|
||||
// (template default) and demanded CLAUDE_CODE_OAUTH_TOKEN. Set these
|
||||
// FIRST so the per-runtime branches below can layer on additional
|
||||
// vendor-specific names without fighting over the canonical one.
|
||||
// MODEL_PROVIDER (#280; the legacy env-var fallback in the Python
|
||||
// runtime is independent of the workspace_secrets row rename — it
|
||||
// still reads the env var for back-compat with already-running
|
||||
// images, but workspace-server no longer emits it). Export both new
|
||||
// names so adapters can read either; MODEL stays for backwards
|
||||
// compat with everything that already reads os.environ["MODEL"]
|
||||
// (the claude-code adapter does, since #194). Without this, the
|
||||
// user's canvas selection is silently dropped on every templated
|
||||
// provision — confirmed via crash-loop diagnosis on 2026-05-02
|
||||
// where MiniMax picks booted with model=sonnet (template default)
|
||||
// and demanded CLAUDE_CODE_OAUTH_TOKEN. Set these FIRST so the
|
||||
// per-runtime branches below can layer on additional vendor-
|
||||
// specific names without fighting over the canonical one.
|
||||
envVars["MOLECULE_MODEL"] = model
|
||||
envVars["MODEL"] = model
|
||||
|
||||
|
||||
@@ -675,15 +675,22 @@ func TestDeriveProviderFromModelSlug(t *testing.T) {
|
||||
// TestWorkspaceCreate_FirstDeploy_PersistsModelAndProvider pins the
|
||||
// fix for failed-workspace 95ed3ff2 (2026-05-02). Pre-fix: the canvas
|
||||
// POSTed minimax/MiniMax-M2.7 in payload.Model, the workspace row was
|
||||
// created, but neither MODEL_PROVIDER nor LLM_PROVIDER was ever
|
||||
// created, but neither the model nor the derived provider was ever
|
||||
// written to workspace_secrets. On any subsequent restart, the
|
||||
// applyRuntimeModelEnv fallback found nothing in envVars["MODEL_PROVIDER"]
|
||||
// and hermes booted with the template default (nousresearch/hermes-4-70b)
|
||||
// → wrong provider keys → /health poll failed → never registered.
|
||||
// applyRuntimeModelEnv fallback found nothing and hermes booted with
|
||||
// the template default (nousresearch/hermes-4-70b) → wrong provider
|
||||
// keys → /health poll failed → never registered.
|
||||
//
|
||||
// Post-fix: the create handler writes both rows after committing the
|
||||
// workspace row. This test asserts the SQL writes happen with the
|
||||
// correct keys + values.
|
||||
//
|
||||
// 2026-05-19 follow-up: the workspace_secrets row that holds the
|
||||
// picked model id was renamed MODEL_PROVIDER → MODEL (the column name
|
||||
// was misleading and bled into applyRuntimeModelEnv as a slug
|
||||
// fallback). The sqlmock regex below now anchors on 'MODEL' instead
|
||||
// of 'MODEL_PROVIDER'. See fix/workspace-server-rename-
|
||||
// MODEL_PROVIDER-to-MODEL + the 20260519000000 rename migration.
|
||||
func TestWorkspaceCreate_FirstDeploy_PersistsModelAndProvider(t *testing.T) {
|
||||
mock := setupTestDB(t)
|
||||
setupTestRedis(t)
|
||||
@@ -699,13 +706,16 @@ func TestWorkspaceCreate_FirstDeploy_PersistsModelAndProvider(t *testing.T) {
|
||||
WillReturnResult(sqlmock.NewResult(0, 1))
|
||||
mock.ExpectCommit()
|
||||
|
||||
// The fix: MODEL_PROVIDER is upserted with the verbatim model slug.
|
||||
// SQL has 3 placeholders ($1=workspace_id, $2=encrypted_value reused
|
||||
// in the conflict-update, $3=version reused in the conflict-update),
|
||||
// so sqlmock sees 3 args. The 'MODEL_PROVIDER' / 'LLM_PROVIDER' key
|
||||
// is a literal in the SQL — we distinguish the two writes with the
|
||||
// regex match below.
|
||||
mock.ExpectExec(`INSERT INTO workspace_secrets[\s\S]*'MODEL_PROVIDER'`).
|
||||
// The fix: MODEL is upserted with the verbatim model slug
|
||||
// (renamed from MODEL_PROVIDER on 2026-05-19 — see file-level
|
||||
// docstring). SQL has 3 placeholders ($1=workspace_id, $2=
|
||||
// encrypted_value reused in the conflict-update, $3=version
|
||||
// reused in the conflict-update), so sqlmock sees 3 args. The
|
||||
// 'MODEL' / 'LLM_PROVIDER' key is a literal in the SQL — we
|
||||
// distinguish the two writes with the regex match below. The
|
||||
// 'MODEL' anchor uses a word boundary (`[^_A-Z]`) so it does
|
||||
// NOT silently match the legacy 'MODEL_PROVIDER' name.
|
||||
mock.ExpectExec(`INSERT INTO workspace_secrets[\s\S]*'MODEL'`).
|
||||
WithArgs(sqlmock.AnyArg(), sqlmock.AnyArg(), sqlmock.AnyArg()).
|
||||
WillReturnResult(sqlmock.NewResult(0, 1))
|
||||
// The fix: LLM_PROVIDER is upserted with the derived provider name.
|
||||
@@ -742,13 +752,13 @@ func TestWorkspaceCreate_FirstDeploy_PersistsModelAndProvider(t *testing.T) {
|
||||
t.Fatalf("expected status 201, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Errorf("sqlmock expectations not met — first-deploy did NOT persist MODEL_PROVIDER + LLM_PROVIDER (this is the prod bug recurrence): %v", err)
|
||||
t.Errorf("sqlmock expectations not met — first-deploy did NOT persist MODEL + LLM_PROVIDER (this is the prod bug recurrence): %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestWorkspaceCreate_FirstDeploy_NoModel_NoSecretWritten asserts that
|
||||
// when payload.Model is empty, NEITHER MODEL_PROVIDER nor LLM_PROVIDER
|
||||
// is written. Important: the canvas can omit `model` (template inherits
|
||||
// when payload.Model is empty, NEITHER MODEL nor LLM_PROVIDER is
|
||||
// written. Important: the canvas can omit `model` (template inherits
|
||||
// the runtime default later); we must not poison workspace_secrets with
|
||||
// empty rows in that case.
|
||||
func TestWorkspaceCreate_FirstDeploy_NoModel_NoSecretWritten(t *testing.T) {
|
||||
@@ -792,10 +802,11 @@ func TestWorkspaceCreate_FirstDeploy_NoModel_NoSecretWritten(t *testing.T) {
|
||||
|
||||
// TestWorkspaceCreate_FirstDeploy_UnknownModel_OnlyMintModelProvider
|
||||
// asserts the asymmetric case: an unknown model prefix still gets
|
||||
// MODEL_PROVIDER persisted (so the user's exact slug survives restart
|
||||
// and applyRuntimeModelEnv finds it), but LLM_PROVIDER is skipped (so
|
||||
// MODEL persisted (so the user's exact slug survives restart and
|
||||
// applyRuntimeModelEnv finds it), but LLM_PROVIDER is skipped (so
|
||||
// derive-provider.sh's *=auto branch can decide at runtime instead of
|
||||
// being pre-empted by a guess).
|
||||
// being pre-empted by a guess). The MODEL key was renamed from
|
||||
// MODEL_PROVIDER on 2026-05-19 — see file-level docstring.
|
||||
func TestWorkspaceCreate_FirstDeploy_UnknownModel_OnlyMintModelProvider(t *testing.T) {
|
||||
mock := setupTestDB(t)
|
||||
setupTestRedis(t)
|
||||
@@ -807,9 +818,9 @@ func TestWorkspaceCreate_FirstDeploy_UnknownModel_OnlyMintModelProvider(t *testi
|
||||
WillReturnResult(sqlmock.NewResult(0, 1))
|
||||
mock.ExpectCommit()
|
||||
|
||||
// Only MODEL_PROVIDER — LLM_PROVIDER must NOT be written for
|
||||
// unknown prefixes. Same 3-arg shape as above; key is literal in SQL.
|
||||
mock.ExpectExec(`INSERT INTO workspace_secrets[\s\S]*'MODEL_PROVIDER'`).
|
||||
// Only MODEL — LLM_PROVIDER must NOT be written for unknown
|
||||
// prefixes. Same 3-arg shape as above; key is literal in SQL.
|
||||
mock.ExpectExec(`INSERT INTO workspace_secrets[\s\S]*'MODEL'`).
|
||||
WithArgs(sqlmock.AnyArg(), sqlmock.AnyArg(), sqlmock.AnyArg()).
|
||||
WillReturnResult(sqlmock.NewResult(0, 1))
|
||||
|
||||
@@ -836,7 +847,7 @@ func TestWorkspaceCreate_FirstDeploy_UnknownModel_OnlyMintModelProvider(t *testi
|
||||
t.Fatalf("expected status 201, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Errorf("sqlmock expectations not met — unknown-prefix model should mint MODEL_PROVIDER but skip LLM_PROVIDER: %v", err)
|
||||
t.Errorf("sqlmock expectations not met — unknown-prefix model should mint MODEL but skip LLM_PROVIDER: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -897,11 +908,11 @@ func TestApplyRuntimeModelEnv_SetsUniversalMODELForAllRuntimes(t *testing.T) {
|
||||
model: "",
|
||||
},
|
||||
{
|
||||
name: "empty model + MODEL_PROVIDER fallback hits: MODEL/MOLECULE_MODEL set from secret",
|
||||
name: "empty model + MODEL_PROVIDER env IGNORED post-2026-05-19 rename (the slug-fallback bug)",
|
||||
runtime: "claude-code",
|
||||
model: "",
|
||||
modelProviderEnv: "MiniMax-M2",
|
||||
wantMODEL: "MiniMax-M2",
|
||||
wantMODEL: "",
|
||||
},
|
||||
{
|
||||
name: "empty model + MOLECULE_MODEL env fallback hits (canonical name)",
|
||||
@@ -911,7 +922,7 @@ func TestApplyRuntimeModelEnv_SetsUniversalMODELForAllRuntimes(t *testing.T) {
|
||||
wantMODEL: "opus",
|
||||
},
|
||||
{
|
||||
name: "MOLECULE_MODEL beats MODEL_PROVIDER when both set (misnomer guard, internal#226)",
|
||||
name: "MOLECULE_MODEL wins even when stale MODEL_PROVIDER is present (back-compat guard)",
|
||||
runtime: "claude-code",
|
||||
model: "",
|
||||
moleculeModelEnv: "opus",
|
||||
@@ -947,18 +958,26 @@ func TestApplyRuntimeModelEnv_SetsUniversalMODELForAllRuntimes(t *testing.T) {
|
||||
|
||||
// TestApplyRuntimeModelEnv_PersonaEnvMODELSecretPreserved locks in the
|
||||
// 2026-05-08 fix that prevents the MODEL_PROVIDER-as-slug fallback from
|
||||
// silently overwriting a per-persona MODEL workspace_secret on restart.
|
||||
// silently overwriting a per-persona MODEL workspace_secret on restart,
|
||||
// EXTENDED for the 2026-05-19 root-cause fix that drops the
|
||||
// MODEL_PROVIDER fallback entirely.
|
||||
//
|
||||
// Pre-fix bug recurrence guard: when the persona env file (loaded into
|
||||
// workspace_secrets at /org/import time) declares both MODEL=<id> and
|
||||
// MODEL_PROVIDER=<slug>, the restart path used to overwrite envVars["MODEL"]
|
||||
// with the MODEL_PROVIDER slug because applyRuntimeModelEnv'\''s
|
||||
// with the MODEL_PROVIDER slug because applyRuntimeModelEnv's
|
||||
// payload.Model fallback consulted MODEL_PROVIDER first. Symptom: dev-tree
|
||||
// workspaces booted fine on first /org/import, then on next restart the
|
||||
// model id became literal "minimax" and the workspace template'\''s adapter
|
||||
// model id became literal "minimax" and the workspace template's adapter
|
||||
// failed to match any registry prefix, fell through to anthropic-oauth,
|
||||
// and wedged at SDK initialize. Caught during Phase 4 verification of
|
||||
// template-claude-code PR #9.
|
||||
//
|
||||
// 2026-05-19 follow-up: the MODEL_PROVIDER fallback is now removed.
|
||||
// MODEL is the only env-var source for the picked model id.
|
||||
// MODEL_PROVIDER is intentionally NOT consulted — a stale MODEL_PROVIDER
|
||||
// row left over from before the 20260519000000 migration must NOT leak
|
||||
// into envVars["MODEL"]. Verified by the third case below.
|
||||
func TestApplyRuntimeModelEnv_PersonaEnvMODELSecretPreserved(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
@@ -967,7 +986,7 @@ func TestApplyRuntimeModelEnv_PersonaEnvMODELSecretPreserved(t *testing.T) {
|
||||
wantMODEL string
|
||||
}{
|
||||
{
|
||||
name: "MODEL secret wins over MODEL_PROVIDER slug (persona-env shape on restart)",
|
||||
name: "MODEL secret wins; stale MODEL_PROVIDER ignored (persona-env shape on restart)",
|
||||
envMODEL: "MiniMax-M2.7-highspeed",
|
||||
envMP: "minimax",
|
||||
wantMODEL: "MiniMax-M2.7-highspeed",
|
||||
@@ -979,10 +998,10 @@ func TestApplyRuntimeModelEnv_PersonaEnvMODELSecretPreserved(t *testing.T) {
|
||||
wantMODEL: "opus",
|
||||
},
|
||||
{
|
||||
name: "MODEL absent → fall back to MODEL_PROVIDER (legacy canvas Save+Restart shape)",
|
||||
name: "MODEL absent → MODEL_PROVIDER no longer fallback (2026-05-19 fix): nothing set",
|
||||
envMODEL: "",
|
||||
envMP: "MiniMax-M2.7",
|
||||
wantMODEL: "MiniMax-M2.7",
|
||||
wantMODEL: "",
|
||||
},
|
||||
{
|
||||
name: "Both absent → no MODEL set",
|
||||
@@ -1009,3 +1028,48 @@ func TestApplyRuntimeModelEnv_PersonaEnvMODELSecretPreserved(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestApplyRuntimeModelEnv_StaleMODELPROVIDERNeverLeaksIntoMODEL is the
|
||||
// 2026-05-19 root-cause pin: workspaces that were live BEFORE the
|
||||
// 20260519000000_workspace_secrets_model_provider_rename migration ran
|
||||
// may still have a MODEL_PROVIDER row in workspace_secrets that lands
|
||||
// in envVars (the loader doesn't filter — anything in workspace_secrets
|
||||
// gets passed through). Post-fix, applyRuntimeModelEnv MUST NOT consult
|
||||
// that key for any purpose — neither as a fallback for the picked model
|
||||
// id nor as an indirect overwrite of MODEL. Asserts the read-out shape:
|
||||
//
|
||||
// - envVars["MODEL"] stays empty when no other source provided one
|
||||
// - envVars["MOLECULE_MODEL"] stays empty
|
||||
// - envVars["HERMES_DEFAULT_MODEL"] stays empty
|
||||
// - envVars["MODEL_PROVIDER"] itself is left as-is (we don't actively
|
||||
// scrub it — the rename migration does that on the DB side)
|
||||
//
|
||||
// Pairs with workspace_provision.go applyRuntimeModelEnv (line 817
|
||||
// fallback removed) and secrets.go (workspace_secrets key MODEL).
|
||||
func TestApplyRuntimeModelEnv_StaleMODELPROVIDERNeverLeaksIntoMODEL(t *testing.T) {
|
||||
envVars := map[string]string{
|
||||
"MODEL_PROVIDER": "minimax", // legacy slug — the prod-bug shape
|
||||
}
|
||||
applyRuntimeModelEnv(envVars, "claude-code", "")
|
||||
if got, ok := envVars["MODEL"]; ok {
|
||||
t.Errorf("MODEL must not be set from MODEL_PROVIDER fallback (post-2026-05-19 fix); got=%q", got)
|
||||
}
|
||||
if got, ok := envVars["MOLECULE_MODEL"]; ok {
|
||||
t.Errorf("MOLECULE_MODEL must not be set from MODEL_PROVIDER fallback; got=%q", got)
|
||||
}
|
||||
if got, ok := envVars["HERMES_DEFAULT_MODEL"]; ok {
|
||||
t.Errorf("HERMES_DEFAULT_MODEL must not be set from MODEL_PROVIDER fallback; got=%q", got)
|
||||
}
|
||||
if got := envVars["MODEL_PROVIDER"]; got != "minimax" {
|
||||
t.Errorf("MODEL_PROVIDER must be passed through untouched (DB-side rename handles cleanup); got=%q", got)
|
||||
}
|
||||
|
||||
// Hermes-runtime variant — same shape, same expectation.
|
||||
envVarsH := map[string]string{
|
||||
"MODEL_PROVIDER": "minimax",
|
||||
}
|
||||
applyRuntimeModelEnv(envVarsH, "hermes", "")
|
||||
if _, ok := envVarsH["HERMES_DEFAULT_MODEL"]; ok {
|
||||
t.Errorf("hermes runtime must not leak MODEL_PROVIDER into HERMES_DEFAULT_MODEL")
|
||||
}
|
||||
}
|
||||
|
||||
+21
@@ -0,0 +1,21 @@
|
||||
-- Reverse of 20260519000000_workspace_secrets_model_provider_rename.up.sql.
|
||||
--
|
||||
-- This rolls MODEL → MODEL_PROVIDER. Note: the up migration deleted any
|
||||
-- conflicting MODEL_PROVIDER rows when a MODEL row already existed, so
|
||||
-- this down migration is intentionally lossy in that direction — it
|
||||
-- cannot reconstruct rows the up migration discarded. Acceptable
|
||||
-- because:
|
||||
--
|
||||
-- 1. The discarded rows were duplicates with the same workspace_id;
|
||||
-- the surviving MODEL row carries the correct semantic value.
|
||||
-- 2. The application code post-rename never writes MODEL_PROVIDER, so
|
||||
-- any rollback after live traffic would produce duplicate-key
|
||||
-- conflicts on re-up anyway — discarding here is the only sane
|
||||
-- shape.
|
||||
--
|
||||
-- Provided for migration-tool symmetry; in practice the up direction is
|
||||
-- the canonical fix and rollback should not happen.
|
||||
|
||||
UPDATE workspace_secrets
|
||||
SET key = 'MODEL_PROVIDER', updated_at = now()
|
||||
WHERE key = 'MODEL';
|
||||
+36
@@ -0,0 +1,36 @@
|
||||
-- Rename workspace_secrets rows MODEL_PROVIDER → MODEL.
|
||||
--
|
||||
-- Root cause: the column-name MODEL_PROVIDER was misleading — it never
|
||||
-- held a provider slug, only a picked model id (e.g.
|
||||
-- "minimax/MiniMax-M2.7"). Application code (workspace-server
|
||||
-- applyRuntimeModelEnv) read MODEL_PROVIDER as a fallback that could
|
||||
-- overwrite a legitimate MODEL persona-env secret with whatever literal
|
||||
-- string lived in MODEL_PROVIDER — often a provider slug like "minimax"
|
||||
-- or a runtime name like "claude-code", neither of which is a valid
|
||||
-- model id. The wrong shape then propagated into CP user-data and the
|
||||
-- workspace adapter wedged at SDK initialize (see failed-workspace
|
||||
-- 95ed3ff2 2026-05-02 and the Researcher/Reviewer poisoning 2026-05-19).
|
||||
--
|
||||
-- Pairs with the secrets.go + workspace_provision.go rename in this
|
||||
-- PR (fix/workspace-server-rename-MODEL_PROVIDER-to-MODEL) and the
|
||||
-- CP-side slot-separation already landed in cp#213 + cp#220.
|
||||
--
|
||||
-- Conflict handling: a workspace_secrets row already keyed MODEL takes
|
||||
-- precedence (persona-env files commonly write MODEL=... directly), so
|
||||
-- the MODEL_PROVIDER row is deleted instead of overwriting MODEL. The
|
||||
-- WHERE NOT EXISTS guard makes the migration idempotent — re-running
|
||||
-- it on an already-renamed schema is a no-op.
|
||||
|
||||
UPDATE workspace_secrets
|
||||
SET key = 'MODEL', updated_at = now()
|
||||
WHERE key = 'MODEL_PROVIDER'
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM workspace_secrets ws2
|
||||
WHERE ws2.workspace_id = workspace_secrets.workspace_id
|
||||
AND ws2.key = 'MODEL'
|
||||
);
|
||||
|
||||
-- Drop any leftover MODEL_PROVIDER rows where a MODEL row already
|
||||
-- exists (MODEL wins — see above).
|
||||
DELETE FROM workspace_secrets
|
||||
WHERE key = 'MODEL_PROVIDER';
|
||||
@@ -0,0 +1 @@
|
||||
# trigger autobump for python-multipart pin (PDF P0 cure)
|
||||
Reference in New Issue
Block a user