From 2f7beb9bce9048dffb3dc12ba58daf9349f907f9 Mon Sep 17 00:00:00 2001 From: Hongming Wang Date: Mon, 4 May 2026 16:30:26 -0700 Subject: [PATCH] =?UTF-8?q?feat:=20drop=20shared=5Fcontext=20=E2=80=94=20u?= =?UTF-8?q?se=20memory=20v2=20team=20namespace=20instead?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Parent → child knowledge sharing previously lived behind a `shared_context` list in config.yaml: at boot, every child workspace HTTP-fetched its parent's listed files via GET /workspaces/:id/shared-context and prepended them as a "## Parent Context" block. That paid the full transfer cost on every boot regardless of whether the agent needed it, single-parent SPOF, no team or org scope, and broken if the parent was unreachable. Replace with memory v2's team: namespace: agents call recall_memory on demand. For large blob-shaped artefacts see RFC #2789 (platform-owned shared file storage). Removed: - workspace/coordinator.py: get_parent_context() - workspace/prompt.py: parent_context arg + injection block - workspace/adapter_base.py: import + call + arg pass - workspace/config.py: shared_context field + parser entry - workspace-server/internal/handlers/templates.go: SharedContext handler - workspace-server/internal/router/router.go: GET /shared-context route - canvas/src/components/tabs/ConfigTab.tsx: Shared Context tag input - canvas/src/components/tabs/config/form-inputs.tsx: schema field + default - canvas/src/components/tabs/config/yaml-utils.ts: serializer entry - 6 tests pinning the removed behavior; 5 doc references Added regression gates so any reintroduction is loud: - workspace/tests/test_prompt.py: build_system_prompt must NOT emit "## Parent Context" - workspace/tests/test_config.py: legacy YAML key loads cleanly but shared_context attr must NOT exist on WorkspaceConfig - tests/e2e/test_staging_full_saas.sh §9d: GET /shared-context must NOT return 200 against a live tenant Co-Authored-By: Claude Opus 4.7 (1M context) --- canvas/src/components/tabs/ConfigTab.tsx | 1 - .../components/tabs/config/form-inputs.tsx | 2 - .../src/components/tabs/config/yaml-utils.ts | 1 - docs/agent-runtime/config-format.md | 12 +- docs/agent-runtime/system-prompt-structure.md | 24 ++- docs/api-protocol/platform-api.md | 1 - docs/api-reference.md | 1 - docs/architecture/molecule-technical-doc.md | 3 +- tests/e2e/test_staging_full_saas.sh | 16 +- .../internal/handlers/handlers_test.go | 110 ------------ .../internal/handlers/templates.go | 87 ---------- .../internal/handlers/templates_test.go | 160 +----------------- workspace-server/internal/router/router.go | 1 - workspace/adapter_base.py | 14 +- workspace/config.py | 2 - workspace/coordinator.py | 23 --- workspace/prompt.py | 13 -- workspace/tests/conftest.py | 1 - workspace/tests/test_config.py | 22 +-- workspace/tests/test_coordinator_parent.py | 78 +-------- workspace/tests/test_prompt.py | 75 +------- 21 files changed, 68 insertions(+), 579 deletions(-) diff --git a/canvas/src/components/tabs/ConfigTab.tsx b/canvas/src/components/tabs/ConfigTab.tsx index 700a267d..fdb8eb38 100644 --- a/canvas/src/components/tabs/ConfigTab.tsx +++ b/canvas/src/components/tabs/ConfigTab.tsx @@ -890,7 +890,6 @@ export function ConfigTab({ workspaceId }: Props) { update("skills", v)} placeholder="e.g. code-review" /> update("tools", v)} placeholder="e.g. web_search, filesystem" /> update("prompt_files", v)} placeholder="e.g. system-prompt.md" /> - update("shared_context", v)} placeholder="e.g. architecture.md" />
diff --git a/canvas/src/components/tabs/config/form-inputs.tsx b/canvas/src/components/tabs/config/form-inputs.tsx index 31bcf93d..65cc9be7 100644 --- a/canvas/src/components/tabs/config/form-inputs.tsx +++ b/canvas/src/components/tabs/config/form-inputs.tsx @@ -22,7 +22,6 @@ export interface ConfigData { // task_budget maps to output_config.task_budget.total (requires beta header task-budgets-2026-03-13) task_budget?: number; prompt_files: string[]; - shared_context: string[]; skills: string[]; tools: string[]; a2a: { port: number; streaming: boolean; push_notifications: boolean }; @@ -40,7 +39,6 @@ export const DEFAULT_CONFIG: ConfigData = { effort: "", task_budget: 0, prompt_files: [], - shared_context: [], skills: [], tools: [], a2a: { port: 8000, streaming: true, push_notifications: true }, diff --git a/canvas/src/components/tabs/config/yaml-utils.ts b/canvas/src/components/tabs/config/yaml-utils.ts index 77ffff2d..0df0c453 100644 --- a/canvas/src/components/tabs/config/yaml-utils.ts +++ b/canvas/src/components/tabs/config/yaml-utils.ts @@ -120,7 +120,6 @@ export function toYaml(config: ConfigData): string { if (config.effort) { lines.push(""); simple("effort", config.effort); } if (config.task_budget && config.task_budget > 0) { simple("task_budget", config.task_budget); } if (config.prompt_files?.length) { lines.push(""); list("prompt_files", config.prompt_files); } - if (config.shared_context?.length) { lines.push(""); list("shared_context", config.shared_context); } lines.push(""); list("skills", config.skills); if (config.tools?.length) { list("tools", config.tools); } lines.push(""); obj("a2a", config.a2a as unknown as Record); diff --git a/docs/agent-runtime/config-format.md b/docs/agent-runtime/config-format.md index 9ccbaf19..d8866102 100644 --- a/docs/agent-runtime/config-format.md +++ b/docs/agent-runtime/config-format.md @@ -27,11 +27,11 @@ prompt_files: # AGENTS.md-style example: # prompt_files: [AGENTS.md] -# Files to share with direct children (1-level inheritance) -# Children fetch these at startup via GET /workspaces/:id/shared-context -shared_context: - - architecture.md - - conventions.md +# NOTE: `shared_context` (parent → child file injection at boot) was removed. +# To share knowledge across a team, use memory v2's team: namespace via +# the recall_memory MCP tool — the agent pulls it on demand instead of +# paying for it at every boot. For large blob-shaped artefacts, see RFC +# #2789 (platform-owned shared file storage). # Skills to load -- folder names under skills/ skills: @@ -123,7 +123,6 @@ env: | `runtime` | No | Adapter to use: `langgraph` (default), `claude-code`, `crewai`, `autogen`, `deepagents`, `openclaw`. See [Agent Runtime Adapters](./cli-runtime.md). | | `model` | Yes | LangChain-compatible provider string (e.g. `anthropic:claude-sonnet-4-6`). Overridden by `MODEL_PROVIDER` env var if set. | | `prompt_files` | No | Ordered list of markdown files to load as system prompt. Defaults to `["system-prompt.md"]` if omitted. `MEMORY.md` and `USER.md` are auto-appended when present so frozen memory snapshots do not need to be duplicated here. Supports any agent framework's file structure (OpenClaw, Claude Code, etc.) | -| `shared_context` | No | Files from this workspace's config dir to share with direct children. Children fetch these at startup and inject into their system prompt as `## Parent Context`. 1-level inheritance only (grandchildren don't see grandparent's context). | | `skills` | Yes | List of skill folder names to load from `skills/` | | `tools` | No | Built-in tools from workspace-template | | `memory` | No | Memory backend config (defaults to filesystem) | @@ -157,7 +156,6 @@ The file watcher monitors the entire config directory. When `config.yaml` change | `name`, `description`, `version` | Yes | Rebuild Agent Card with new metadata | | `a2a` | **No** | Port and protocol changes require container restart | | `delegation` | Yes | Retry/timeout defaults take effect on next delegation call | -| `shared_context` | Yes | Children fetch on next prompt rebuild; no restart needed | | `sub_workspaces` | **No** | Team structure changes go through `POST /workspaces/:id/expand` | See [Skills — Live Reload](./skills.md#live-reload) for the full file watcher flow. diff --git a/docs/agent-runtime/system-prompt-structure.md b/docs/agent-runtime/system-prompt-structure.md index 995c4d2b..8bdf345f 100644 --- a/docs/agent-runtime/system-prompt-structure.md +++ b/docs/agent-runtime/system-prompt-structure.md @@ -24,21 +24,19 @@ When you receive a task, break it into sub-tasks and delegate to your team. Always review work before reporting completion to the caller. ``` -### 2. Parent Context (if child workspace) +### 2. Team-shared knowledge (on demand) -If this workspace was created via team expansion (has a `PARENT_ID` env var), it fetches its parent's shared context files at startup via `GET /workspaces/{parent_id}/shared-context`. The parent declares which files to share in its `config.yaml`: +Team-scoped knowledge is no longer injected at boot. The previous +`shared_context` field + `GET /workspaces/{parent_id}/shared-context` +fetch was removed; agents now pull team-shared knowledge on demand via +memory v2's `team:` namespace using the `recall_memory` MCP tool. -```yaml -shared_context: - - architecture.md - - conventions.md -``` - -These files are injected as a `## Parent Context` section, with each file rendered under a `### {filename}` heading. This gives children the parent's project knowledge (architecture, conventions, API schemas) without exposing the parent's system prompt or full config. - -**1-level inheritance only:** A grandchild sees its direct parent's shared context, not its grandparent's. This mirrors the L2 Team Memory scope. - -**Graceful degradation:** If the parent is offline or the endpoint returns an error, the child starts normally without parent context. +This shifts cost from "every boot, always" to "only when the agent +asks", and lets team members write to the shared store from anywhere +that can resolve the namespace (canvas Memory tab, agent +`commit_memory`, admin import). For large blob-shaped artefacts (full +architecture docs, brand assets, PDFs) see RFC #2789 (platform-owned +shared file storage). ### 3. Skill Instructions diff --git a/docs/api-protocol/platform-api.md b/docs/api-protocol/platform-api.md index 3781d62f..ffcfa810 100644 --- a/docs/api-protocol/platform-api.md +++ b/docs/api-protocol/platform-api.md @@ -199,7 +199,6 @@ Install safeguards bound the cost of a single install (env-tunable via `PLUGIN_I | `GET` | `/templates` | List available templates. **Requires AdminAuth** (PR #701). | | `GET` | `/org/templates` | List available org templates. **Requires AdminAuth** (PR #701). | | `POST` | `/templates/import` | Import an agent folder as a new template | -| `GET` | `/workspaces/:id/shared-context` | Read parent shared-context files | | `GET` | `/workspaces/:id/files` | List files under an allowed root | | `GET` | `/workspaces/:id/files/*path` | Read a file | | `PUT` | `/workspaces/:id/files/*path` | Write a file | diff --git a/docs/api-reference.md b/docs/api-reference.md index 561d43aa..e1a75668 100644 --- a/docs/api-reference.md +++ b/docs/api-reference.md @@ -68,7 +68,6 @@ Full contract: `docs/runbooks/admin-auth.md`. | GET | /channels/adapters | channels.go (list available platforms) | | POST | /channels/discover | channels.go (auto-detect chats for a bot token) | | POST | /webhooks/:type | channels.go (incoming social webhook) | -| GET | /workspaces/:id/shared-context | templates.go | | GET/PUT/DELETE | /workspaces/:id/files[/*path] | templates.go | | GET | /canvas/viewport | viewport.go — open, no auth required (cosmetic, bootstrap-friendly) | | PUT | /canvas/viewport | viewport.go — `CanvasOrBearer` middleware; accepts bearer OR Origin matching `CORS_ORIGINS`. Cosmetic-only route — worst case viewport corruption, recovered by page refresh. | diff --git a/docs/architecture/molecule-technical-doc.md b/docs/architecture/molecule-technical-doc.md index 77f2117a..0d9c653c 100644 --- a/docs/architecture/molecule-technical-doc.md +++ b/docs/architecture/molecule-technical-doc.md @@ -523,7 +523,8 @@ runtime_config: # Runtime-specific settings skills: ["skill1", "skill2"] # Folder names under skills/ tools: ["web_search", "filesystem"] # Built-in tool names prompt_files: ["system-prompt.md"] # Additional prompt text files -shared_context: [] # Files from parent workspace +# `shared_context` was removed; team-shared knowledge now lives in memory v2's +# team: namespace (recall_memory MCP tool). See RFC #2789 for shared files. a2a: port: 8000 diff --git a/tests/e2e/test_staging_full_saas.sh b/tests/e2e/test_staging_full_saas.sh index 1223012a..64fdb547 100755 --- a/tests/e2e/test_staging_full_saas.sh +++ b/tests/e2e/test_staging_full_saas.sh @@ -706,8 +706,22 @@ print(json.dumps({ d=json.load(sys.stdin) print(len(d if isinstance(d, list) else d.get('events', [])))" 2>/dev/null || echo 0) log " Activity events observed: $ACTIVITY_COUNT" + + # ─── 9d. shared_context removal gate ───────────────────────────────── + # Pin the deletion of GET /workspaces/:id/shared-context. The route + handler + # were removed; team-shared knowledge now flows through memory v2's + # team: namespace. If anyone re-introduces a shared-context endpoint + # without going through RFC #2789, this gate fires. + set +e + SC_CODE=$(tenant_call GET "/workspaces/$PARENT_ID/shared-context" \ + -o /dev/null -w "%{http_code}" 2>/dev/null || echo "000") + set -e + if [ "$SC_CODE" = "200" ]; then + fail "shared-context route should be gone but returned 200 — regression. See task #304." + fi + ok "shared-context route confirmed removed (HTTP $SC_CODE)" else - log "9/11 Canary mode — skipping HMA / peers / activity" + log "9/11 Canary mode — skipping HMA / peers / activity / shared-context-gone" fi # ─── 10. Delegation mechanics (full mode + child) ────────────────────── diff --git a/workspace-server/internal/handlers/handlers_test.go b/workspace-server/internal/handlers/handlers_test.go index 85cff897..d57e5811 100644 --- a/workspace-server/internal/handlers/handlers_test.go +++ b/workspace-server/internal/handlers/handlers_test.go @@ -8,8 +8,6 @@ import ( "fmt" "net/http" "net/http/httptest" - "os" - "path/filepath" "testing" "time" @@ -569,67 +567,6 @@ func TestProxyA2A_WorkspaceOffline(t *testing.T) { } } -// ---------- TestSharedContext ---------- - -func TestSharedContext(t *testing.T) { - mock := setupTestDB(t) - - // Create a temp configs directory with a workspace config - tmpDir := t.TempDir() - wsDir := filepath.Join(tmpDir, "test-workspace") - if err := os.MkdirAll(wsDir, 0755); err != nil { - t.Fatalf("failed to create config dir: %v", err) - } - - // Write config.yaml with shared_context - configYAML := "name: Test Workspace\nshared_context:\n - test.md\n" - if err := os.WriteFile(filepath.Join(wsDir, "config.yaml"), []byte(configYAML), 0644); err != nil { - t.Fatalf("failed to write config.yaml: %v", err) - } - - // Write the shared context file - testContent := "# Shared Context\nThis is shared context content." - if err := os.WriteFile(filepath.Join(wsDir, "test.md"), []byte(testContent), 0644); err != nil { - t.Fatalf("failed to write test.md: %v", err) - } - - handler := NewTemplatesHandler(tmpDir, nil) - - // Mock DB returning workspace name that normalizes to "test-workspace" - mock.ExpectQuery("SELECT name FROM workspaces WHERE id ="). - WithArgs("ws-ctx"). - WillReturnRows(sqlmock.NewRows([]string{"name"}).AddRow("Test Workspace")) - - w := httptest.NewRecorder() - c, _ := gin.CreateTestContext(w) - c.Params = gin.Params{{Key: "id", Value: "ws-ctx"}} - c.Request = httptest.NewRequest("GET", "/workspaces/ws-ctx/shared-context", nil) - - handler.SharedContext(c) - - if w.Code != http.StatusOK { - t.Errorf("expected status 200, got %d: %s", w.Code, w.Body.String()) - } - - var resp []map[string]interface{} - if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil { - t.Fatalf("failed to parse response: %v", err) - } - if len(resp) != 1 { - t.Fatalf("expected 1 file, got %d", len(resp)) - } - if resp[0]["path"] != "test.md" { - t.Errorf("expected path 'test.md', got %v", resp[0]["path"]) - } - if resp[0]["content"] != testContent { - t.Errorf("expected content %q, got %v", testContent, resp[0]["content"]) - } - - if err := mock.ExpectationsWereMet(); err != nil { - t.Errorf("unmet sqlmock expectations: %v", err) - } -} - // ---------- TestHeartbeatHandler_TaskChanged ---------- func TestHeartbeatHandler_TaskChanged(t *testing.T) { @@ -1218,53 +1155,6 @@ func TestWorkspaceGet_CurrentTask(t *testing.T) { } } -func TestSharedContext_NoSharedFiles(t *testing.T) { - mock := setupTestDB(t) - - // Create a temp configs directory with a workspace config that has no shared_context - tmpDir := t.TempDir() - wsDir := filepath.Join(tmpDir, "empty-workspace") - if err := os.MkdirAll(wsDir, 0755); err != nil { - t.Fatalf("failed to create config dir: %v", err) - } - - // Write config.yaml without shared_context - configYAML := "name: Empty Workspace\ndescription: No shared context\n" - if err := os.WriteFile(filepath.Join(wsDir, "config.yaml"), []byte(configYAML), 0644); err != nil { - t.Fatalf("failed to write config.yaml: %v", err) - } - - handler := NewTemplatesHandler(tmpDir, nil) - - // Mock DB returning workspace name that normalizes to "empty-workspace" - mock.ExpectQuery("SELECT name FROM workspaces WHERE id ="). - WithArgs("ws-empty"). - WillReturnRows(sqlmock.NewRows([]string{"name"}).AddRow("Empty Workspace")) - - w := httptest.NewRecorder() - c, _ := gin.CreateTestContext(w) - c.Params = gin.Params{{Key: "id", Value: "ws-empty"}} - c.Request = httptest.NewRequest("GET", "/workspaces/ws-empty/shared-context", nil) - - handler.SharedContext(c) - - if w.Code != http.StatusOK { - t.Errorf("expected status 200, got %d: %s", w.Code, w.Body.String()) - } - - var resp []interface{} - if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil { - t.Fatalf("failed to parse response: %v", err) - } - if len(resp) != 0 { - t.Errorf("expected empty array, got %d items", len(resp)) - } - - if err := mock.ExpectationsWereMet(); err != nil { - t.Errorf("unmet sqlmock expectations: %v", err) - } -} - // TestActivityHandler_Report_SourceIDSpoofRejected verifies the #209 spoof // guard: a workspace authenticated for :id cannot inject activity rows with // source_id pointing at a different workspace. Bearer-auth middleware would diff --git a/workspace-server/internal/handlers/templates.go b/workspace-server/internal/handlers/templates.go index 49b04db8..d51dabcd 100644 --- a/workspace-server/internal/handlers/templates.go +++ b/workspace-server/internal/handlers/templates.go @@ -548,90 +548,3 @@ func (h *TemplatesHandler) DeleteFile(c *gin.Context) { c.JSON(http.StatusOK, gin.H{"status": "deleted", "path": filePath}) } -// SharedContext handles GET /workspaces/:id/shared-context -// Returns the files listed in the workspace's config.yaml shared_context field. -func (h *TemplatesHandler) SharedContext(c *gin.Context) { - workspaceID := c.Param("id") - ctx := c.Request.Context() - - var wsName string - if err := db.DB.QueryRowContext(ctx, `SELECT name FROM workspaces WHERE id = $1`, workspaceID).Scan(&wsName); err != nil { - c.JSON(http.StatusNotFound, gin.H{"error": "workspace not found"}) - return - } - - type contextFile struct { - Path string `json:"path"` - Content string `json:"content"` - } - - // Try reading from running container first - if containerName := h.findContainer(ctx, workspaceID); containerName != "" { - configData, err := h.execInContainer(ctx, containerName, []string{"cat", "/configs/config.yaml"}) - if err != nil { - c.JSON(http.StatusOK, []interface{}{}) - return - } - - var cfg struct { - SharedContext []string `yaml:"shared_context"` - } - if err := yaml.Unmarshal([]byte(configData), &cfg); err != nil || len(cfg.SharedContext) == 0 { - c.JSON(http.StatusOK, []interface{}{}) - return - } - - files := make([]contextFile, 0, len(cfg.SharedContext)) - for _, relPath := range cfg.SharedContext { - if err := validateRelPath(relPath); err != nil { - continue - } - // CWE-78: pass path components as separate exec args instead of - // concatenating into a single string. validateRelPath above is the - // primary guard; separate args is defence-in-depth (no shell - // interpolation possible in exec form). - content, err := h.execInContainer(ctx, containerName, []string{"cat", "/configs", relPath}) - if err != nil { - continue - } - files = append(files, contextFile{Path: relPath, Content: content}) - } - c.JSON(http.StatusOK, files) - return - } - - // Fallback to host-side template dir - configDir := h.resolveTemplateDir(wsName) - if configDir == "" { - c.JSON(http.StatusOK, []interface{}{}) - return - } - - configData, err := os.ReadFile(filepath.Join(configDir, "config.yaml")) - if err != nil { - c.JSON(http.StatusOK, []interface{}{}) - return - } - - var cfg struct { - SharedContext []string `yaml:"shared_context"` - } - if err := yaml.Unmarshal(configData, &cfg); err != nil || len(cfg.SharedContext) == 0 { - c.JSON(http.StatusOK, []interface{}{}) - return - } - - files := make([]contextFile, 0, len(cfg.SharedContext)) - for _, relPath := range cfg.SharedContext { - if err := validateRelPath(relPath); err != nil { - continue - } - data, err := os.ReadFile(filepath.Join(configDir, relPath)) - if err != nil { - continue - } - files = append(files, contextFile{Path: relPath, Content: string(data)}) - } - - c.JSON(http.StatusOK, files) -} diff --git a/workspace-server/internal/handlers/templates_test.go b/workspace-server/internal/handlers/templates_test.go index f956ce95..cbae8069 100644 --- a/workspace-server/internal/handlers/templates_test.go +++ b/workspace-server/internal/handlers/templates_test.go @@ -1126,107 +1126,6 @@ func TestDeleteFile_WorkspaceNotFound(t *testing.T) { } } -// ==================== GET /workspaces/:id/shared-context ==================== - -func TestSharedContext_WorkspaceNotFound(t *testing.T) { - mock := setupTestDB(t) - setupTestRedis(t) - - handler := NewTemplatesHandler(t.TempDir(), nil) - - mock.ExpectQuery("SELECT name FROM workspaces WHERE id ="). - WithArgs("ws-sc-nf"). - WillReturnError(sql.ErrNoRows) - - w := httptest.NewRecorder() - c, _ := gin.CreateTestContext(w) - c.Params = gin.Params{{Key: "id", Value: "ws-sc-nf"}} - c.Request = httptest.NewRequest("GET", "/workspaces/ws-sc-nf/shared-context", nil) - - handler.SharedContext(c) - - if w.Code != http.StatusNotFound { - t.Errorf("expected 404, got %d: %s", w.Code, w.Body.String()) - } - - if err := mock.ExpectationsWereMet(); err != nil { - t.Errorf("unmet sqlmock expectations: %v", err) - } -} - -func TestSharedContext_NoTemplate(t *testing.T) { - mock := setupTestDB(t) - setupTestRedis(t) - - tmpDir := t.TempDir() - handler := NewTemplatesHandler(tmpDir, nil) // no docker - - mock.ExpectQuery("SELECT name FROM workspaces WHERE id ="). - WithArgs("ws-sc-nt"). - WillReturnRows(sqlmock.NewRows([]string{"name"}).AddRow("Unknown Agent")) - - w := httptest.NewRecorder() - c, _ := gin.CreateTestContext(w) - c.Params = gin.Params{{Key: "id", Value: "ws-sc-nt"}} - c.Request = httptest.NewRequest("GET", "/workspaces/ws-sc-nt/shared-context", nil) - - handler.SharedContext(c) - - if w.Code != http.StatusOK { - t.Errorf("expected 200, got %d: %s", w.Code, w.Body.String()) - } - - // Should return empty array - var resp []interface{} - json.Unmarshal(w.Body.Bytes(), &resp) - if len(resp) != 0 { - t.Errorf("expected empty list, got %d items", len(resp)) - } - - if err := mock.ExpectationsWereMet(); err != nil { - t.Errorf("unmet sqlmock expectations: %v", err) - } -} - -func TestSharedContext_WithFiles(t *testing.T) { - mock := setupTestDB(t) - setupTestRedis(t) - - tmpDir := t.TempDir() - tmplDir := filepath.Join(tmpDir, "ctx-agent") - os.MkdirAll(tmplDir, 0755) - os.WriteFile(filepath.Join(tmplDir, "config.yaml"), []byte("name: Ctx Agent\nshared_context:\n - rules.md\n - style.md\n"), 0644) - os.WriteFile(filepath.Join(tmplDir, "rules.md"), []byte("# Rules\nBe nice"), 0644) - os.WriteFile(filepath.Join(tmplDir, "style.md"), []byte("# Style\nBe clear"), 0644) - - handler := NewTemplatesHandler(tmpDir, nil) - - mock.ExpectQuery("SELECT name FROM workspaces WHERE id ="). - WithArgs("ws-sc-ok"). - WillReturnRows(sqlmock.NewRows([]string{"name"}).AddRow("Ctx Agent")) - - w := httptest.NewRecorder() - c, _ := gin.CreateTestContext(w) - c.Params = gin.Params{{Key: "id", Value: "ws-sc-ok"}} - c.Request = httptest.NewRequest("GET", "/workspaces/ws-sc-ok/shared-context", nil) - - handler.SharedContext(c) - - if w.Code != http.StatusOK { - t.Errorf("expected 200, got %d: %s", w.Code, w.Body.String()) - } - - var resp []map[string]interface{} - json.Unmarshal(w.Body.Bytes(), &resp) - if len(resp) != 2 { - t.Fatalf("expected 2 context files, got %d", len(resp)) - } - - if err := mock.ExpectationsWereMet(); err != nil { - t.Errorf("unmet sqlmock expectations: %v", err) - } -} - // ==================== resolveTemplateDir ==================== func TestResolveTemplateDir_ByNormalizedName(t *testing.T) { @@ -1253,7 +1152,7 @@ func TestResolveTemplateDir_NotFound(t *testing.T) { } // ==================== CWE-78 hardening regression (issue #2011) ==================== -// These tests lock in the defence-in-depth guards for DeleteFile and SharedContext. +// These tests lock in the defence-in-depth guards for DeleteFile. // The primary guard is validateRelPath (fires before any exec/file-read path); // the exec-form path construction (filepath.Join / separate args) is defence-in-depth. @@ -1298,60 +1197,3 @@ func TestCWE78_DeleteFile_TraversalVariants(t *testing.T) { } } -// TestCWE78_SharedContext_SkipsTraversalPaths asserts that when a workspace's -// config.yaml lists traversal paths in shared_context, SharedContext skips them -// via validateRelPath rather than passing them to exec or os.ReadFile. -// Uses the filesystem fallback path (no docker client) so no container mock needed. -func TestCWE78_SharedContext_SkipsTraversalPaths(t *testing.T) { - mock := setupTestDB(t) - setupTestRedis(t) - - tmpDir := t.TempDir() - // Create a template directory that SharedContext will resolve for "Cwe Agent". - tmplDir := filepath.Join(tmpDir, "cwe-agent") - os.MkdirAll(tmplDir, 0755) - // config.yaml with a mix of safe and traversal-attack paths. - configYAML := "name: Cwe Agent\nshared_context:\n - safe-file.md\n - ../../etc/passwd\n - ../shadow\n - another-safe.md\n" - os.WriteFile(filepath.Join(tmplDir, "config.yaml"), []byte(configYAML), 0644) - // Only write the safe files — traversal paths must not be reachable. - os.WriteFile(filepath.Join(tmplDir, "safe-file.md"), []byte("# safe"), 0644) - os.WriteFile(filepath.Join(tmplDir, "another-safe.md"), []byte("# also safe"), 0644) - - mock.ExpectQuery("SELECT name FROM workspaces WHERE id ="). - WithArgs("ws-cwe78-sc"). - WillReturnRows(sqlmock.NewRows([]string{"name"}).AddRow("Cwe Agent")) - - w := httptest.NewRecorder() - c, _ := gin.CreateTestContext(w) - c.Params = gin.Params{{Key: "id", Value: "ws-cwe78-sc"}} - c.Request = httptest.NewRequest("GET", "/workspaces/ws-cwe78-sc/shared-context", nil) - - handler := NewTemplatesHandler(tmpDir, nil) // nil docker → filesystem fallback - handler.SharedContext(c) - - if w.Code != http.StatusOK { - t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String()) - } - - var files []struct { - Path string `json:"path"` - Content string `json:"content"` - } - if err := json.Unmarshal(w.Body.Bytes(), &files); err != nil { - t.Fatalf("failed to decode response: %v", err) - } - - // Only the two safe files must appear; traversal paths must be absent. - if len(files) != 2 { - t.Errorf("expected 2 safe files, got %d: %v", len(files), files) - } - for _, f := range files { - if strings.Contains(f.Path, "..") || strings.Contains(f.Path, "etc") || strings.Contains(f.Path, "shadow") { - t.Errorf("traversal path %q must not appear in shared-context response", f.Path) - } - } - - if err := mock.ExpectationsWereMet(); err != nil { - t.Errorf("unmet sqlmock expectations: %v", err) - } -} diff --git a/workspace-server/internal/router/router.go b/workspace-server/internal/router/router.go index 0c0bc928..516aa99d 100644 --- a/workspace-server/internal/router/router.go +++ b/workspace-server/internal/router/router.go @@ -505,7 +505,6 @@ func Setup(hub *ws.Hub, broadcaster *events.Broadcaster, prov *provisioner.Provi tmplAdmin.GET("/templates", tmplh.List) tmplAdmin.POST("/templates/import", tmplh.Import) } - wsAuth.GET("/shared-context", tmplh.SharedContext) wsAuth.PUT("/files", tmplh.ReplaceFiles) wsAuth.GET("/files", tmplh.ListFiles) wsAuth.GET("/files/*path", tmplh.ReadFile) diff --git a/workspace/adapter_base.py b/workspace/adapter_base.py index 0102bb39..c3fb8bdb 100644 --- a/workspace/adapter_base.py +++ b/workspace/adapter_base.py @@ -444,7 +444,7 @@ class BaseAdapter(ABC): """ from plugins import load_plugins from skill_loader.loader import load_skills - from coordinator import get_children, get_parent_context, build_children_description + from coordinator import get_children, build_children_description from prompt import build_system_prompt, get_peer_capabilities, get_platform_instructions from builtin_tools.approval import request_approval from builtin_tools.delegation import delegate_task, delegate_task_async, check_task_status @@ -500,10 +500,13 @@ class BaseAdapter(ABC): logger.info(f"Coordinator mode: {len(children)} children") all_tools.append(route_task_to_team) - # Parent context (if this is a child workspace) - parent_context = await get_parent_context() - - # Build system prompt with all context + # Build system prompt with all context. Parent→child knowledge sharing + # was previously handled by `shared_context` (parent's config.yaml file + # paths injected into the child's prompt at boot). That path was removed + # — agents now pull team-scoped knowledge via memory v2's team: + # namespace (recall_memory) on demand instead of paying for it on every + # boot regardless of need. See RFC #2789 for the future shared-file + # storage that complements this for large blob-shaped artefacts. peers = await get_peer_capabilities(platform_url, config.workspace_id) platform_instructions = await get_platform_instructions(platform_url, config.workspace_id) coordinator_prompt = build_children_description(children) if is_coordinator else "" @@ -516,7 +519,6 @@ class BaseAdapter(ABC): prompt_files=config.prompt_files, plugin_rules=plugins.rules, plugin_prompts=extra_prompts, - parent_context=parent_context, platform_instructions=platform_instructions, ) diff --git a/workspace/config.py b/workspace/config.py index dce5e8e9..b830fdc1 100644 --- a/workspace/config.py +++ b/workspace/config.py @@ -347,7 +347,6 @@ class WorkspaceConfig: plugins: list[str] = field(default_factory=list) # installed plugin names tools: list[str] = field(default_factory=list) prompt_files: list[str] = field(default_factory=list) - shared_context: list[str] = field(default_factory=list) a2a: A2AConfig = field(default_factory=A2AConfig) delegation: DelegationConfig = field(default_factory=DelegationConfig) sandbox: SandboxConfig = field(default_factory=SandboxConfig) @@ -555,7 +554,6 @@ def load_config(config_path: Optional[str] = None) -> WorkspaceConfig: plugins=raw.get("plugins", []), tools=raw.get("tools", []), prompt_files=raw.get("prompt_files", []), - shared_context=raw.get("shared_context", []), a2a=A2AConfig( port=a2a_raw.get("port", 8000), streaming=a2a_raw.get("streaming", True), diff --git a/workspace/coordinator.py b/workspace/coordinator.py index 954ea2f3..12d317ef 100644 --- a/workspace/coordinator.py +++ b/workspace/coordinator.py @@ -32,29 +32,6 @@ if not _WORKSPACE_ID_raw: WORKSPACE_ID = _WORKSPACE_ID_raw -async def get_parent_context() -> list[dict]: - """Fetch shared context files from this workspace's parent. - - Returns a list of {"path": str, "content": str} dicts. - Returns empty list if no parent, parent unreachable, or no shared context. - """ - parent_id = os.environ.get("PARENT_ID", "") - if not parent_id: - return [] - - try: - async with httpx.AsyncClient(timeout=10.0) as client: - resp = await client.get( - f"{PLATFORM_URL}/workspaces/{parent_id}/shared-context", - headers={"X-Workspace-ID": WORKSPACE_ID}, - ) - if resp.status_code == 200: - return resp.json() - except Exception as e: - logger.warning("Failed to fetch parent context: %s", e) - return [] - - async def get_children() -> list[dict]: """Fetch this workspace's children from the platform.""" try: diff --git a/workspace/prompt.py b/workspace/prompt.py index 6a80ab05..484a07c0 100644 --- a/workspace/prompt.py +++ b/workspace/prompt.py @@ -71,7 +71,6 @@ def build_system_prompt( prompt_files: list[str] | None = None, plugin_rules: list[str] | None = None, plugin_prompts: list[str] | None = None, - parent_context: list[dict] | None = None, platform_instructions: str = "", a2a_mcp: bool = True, ) -> str: @@ -135,18 +134,6 @@ def build_system_prompt( if content: parts.append(content) - # Inject parent's shared context (if this workspace is a child) - if parent_context: - parts.append("\n## Parent Context\n") - parts.append("The following context was shared by your parent workspace:\n") - for ctx_file in parent_context: - path = ctx_file.get("path", "unknown") - content = ctx_file.get("content", "") - if content.strip(): - parts.append(f"### {path}") - parts.append(content.strip()) - parts.append("") - # Inject plugin rules (always-on guidelines from ECC, Superpowers, etc.) if plugin_rules: parts.append("\n## Platform Rules\n") diff --git a/workspace/tests/conftest.py b/workspace/tests/conftest.py index 3bbb0eee..abae4168 100644 --- a/workspace/tests/conftest.py +++ b/workspace/tests/conftest.py @@ -437,7 +437,6 @@ if "coordinator" not in sys.modules: except (ImportError, RuntimeError): coordinator_mod = ModuleType("coordinator") coordinator_mod.get_children = MagicMock() - coordinator_mod.get_parent_context = MagicMock() coordinator_mod.build_children_description = MagicMock() coordinator_mod.route_task_to_team = MagicMock() coordinator_mod.route_task_to_team.name = "route_task_to_team" diff --git a/workspace/tests/test_config.py b/workspace/tests/test_config.py index 1b6b1ee3..c9341449 100644 --- a/workspace/tests/test_config.py +++ b/workspace/tests/test_config.py @@ -496,24 +496,24 @@ def test_initial_prompt_file_missing(tmp_path): assert cfg.initial_prompt == "" -def test_shared_context_default(tmp_path): - """shared_context defaults to empty list when not specified in YAML.""" - config_yaml = tmp_path / "config.yaml" - config_yaml.write_text(yaml.dump({})) +def test_shared_context_field_removed(tmp_path): + """Drop-shared_context regression gate: a config.yaml that still uses + the legacy `shared_context` key must load without crashing AND must + NOT carry it onto the WorkspaceConfig dataclass. - cfg = load_config(str(tmp_path)) - assert cfg.shared_context == [] - - -def test_shared_context_from_yaml(tmp_path): - """shared_context reads file paths from YAML.""" + The field was removed; YAML files in the wild may still mention it + until operators migrate. Loader silently ignores unknown YAML keys — + we pin the behavior so a future re-introduction is loud.""" config_yaml = tmp_path / "config.yaml" config_yaml.write_text( yaml.dump({"shared_context": ["guidelines.md", "architecture.md"]}) ) cfg = load_config(str(tmp_path)) - assert cfg.shared_context == ["guidelines.md", "architecture.md"] + assert not hasattr(cfg, "shared_context"), ( + "shared_context is removed; reintroducing it requires a new design " + "(see RFC #2789 for platform-owned shared file storage)" + ) # ===== Compliance default lock (#2059) ===== diff --git a/workspace/tests/test_coordinator_parent.py b/workspace/tests/test_coordinator_parent.py index a1f53154..8027a53f 100644 --- a/workspace/tests/test_coordinator_parent.py +++ b/workspace/tests/test_coordinator_parent.py @@ -1,79 +1,15 @@ -"""Tests for coordinator.py — get_parent_context() and get_children() functions.""" +"""Tests for coordinator.get_children() and build_children_description(). + +shared_context / get_parent_context was removed: parent→child knowledge +sharing now flows through memory v2's team: namespace via recall_memory +on demand, not through file paths injected at boot. +""" -import asyncio from unittest.mock import AsyncMock, patch, MagicMock import pytest -from coordinator import get_parent_context, get_children, build_children_description - - -@pytest.mark.asyncio -async def test_get_parent_context_no_env(monkeypatch): - """Returns empty list when PARENT_ID is not set.""" - monkeypatch.delenv("PARENT_ID", raising=False) - result = await get_parent_context() - assert result == [] - - -@pytest.mark.asyncio -async def test_get_parent_context_success(monkeypatch): - """Fetches shared context files from parent workspace via httpx.""" - monkeypatch.setenv("PARENT_ID", "parent-123") - monkeypatch.setenv("WORKSPACE_ID", "child-456") - monkeypatch.setenv("PLATFORM_URL", "http://localhost:8080") - - # Reload module-level constants after env change - import coordinator - monkeypatch.setattr(coordinator, "PLATFORM_URL", "http://localhost:8080") - monkeypatch.setattr(coordinator, "WORKSPACE_ID", "child-456") - - mock_response = MagicMock() - mock_response.status_code = 200 - mock_response.json.return_value = [ - {"path": "guidelines.md", "content": "Be concise."}, - {"path": "arch.md", "content": "Use microservices."}, - ] - - mock_client = AsyncMock() - mock_client.get.return_value = mock_response - mock_client.__aenter__ = AsyncMock(return_value=mock_client) - mock_client.__aexit__ = AsyncMock(return_value=False) - - with patch("coordinator.httpx.AsyncClient", return_value=mock_client): - result = await get_parent_context() - - assert len(result) == 2 - assert result[0]["path"] == "guidelines.md" - assert result[0]["content"] == "Be concise." - assert result[1]["path"] == "arch.md" - - # Verify the correct URL was called - mock_client.get.assert_called_once_with( - "http://localhost:8080/workspaces/parent-123/shared-context", - headers={"X-Workspace-ID": "child-456"}, - ) - - -@pytest.mark.asyncio -async def test_get_parent_context_failure(monkeypatch): - """Returns empty list when httpx raises an exception.""" - monkeypatch.setenv("PARENT_ID", "parent-123") - monkeypatch.setenv("WORKSPACE_ID", "child-456") - - import coordinator - monkeypatch.setattr(coordinator, "PLATFORM_URL", "http://localhost:8080") - monkeypatch.setattr(coordinator, "WORKSPACE_ID", "child-456") - - mock_client = AsyncMock() - mock_client.get.side_effect = Exception("Connection refused") - mock_client.__aenter__ = AsyncMock(return_value=mock_client) - mock_client.__aexit__ = AsyncMock(return_value=False) - - with patch("coordinator.httpx.AsyncClient", return_value=mock_client): - result = await get_parent_context() - - assert result == [] +from coordinator import get_children, build_children_description # --------------------------------------------------------------------------- diff --git a/workspace/tests/test_prompt.py b/workspace/tests/test_prompt.py index 054db163..50ee302f 100644 --- a/workspace/tests/test_prompt.py +++ b/workspace/tests/test_prompt.py @@ -254,33 +254,14 @@ def test_delegation_failure_section_always_present(tmp_path): assert "Retry transient failures" in result -def test_parent_context_injection(tmp_path): - """parent_context creates a '## Parent Context' section with file contents.""" - (tmp_path / "system-prompt.md").write_text("Base.") +def test_no_parent_context_section_after_shared_context_removal(tmp_path): + """Drop-shared_context regression gate: build_system_prompt must NOT + emit a '## Parent Context' section, since parent→child knowledge sharing + now flows through memory v2's team: namespace via recall_memory. - parent_context = [ - {"path": "guidelines.md", "content": "Always use type hints."}, - {"path": "architecture.md", "content": "We use hexagonal architecture."}, - ] - - result = build_system_prompt( - config_path=str(tmp_path), - workspace_id="ws-1", - loaded_skills=[], - peers=[], - parent_context=parent_context, - ) - - assert "## Parent Context" in result - assert "shared by your parent workspace" in result - assert "### guidelines.md" in result - assert "Always use type hints." in result - assert "### architecture.md" in result - assert "We use hexagonal architecture." in result - - -def test_parent_context_empty(tmp_path): - """No '## Parent Context' section when parent_context is an empty list.""" + The previous parent_context= kwarg was removed wholesale; if anyone + re-introduces a path that injects parent files at boot, this gate + fails so the regression is visible in CI.""" (tmp_path / "system-prompt.md").write_text("Base.") result = build_system_prompt( @@ -288,50 +269,10 @@ def test_parent_context_empty(tmp_path): workspace_id="ws-1", loaded_skills=[], peers=[], - parent_context=[], ) assert "## Parent Context" not in result - - -def test_parent_context_none(tmp_path): - """No '## Parent Context' section when parent_context is None.""" - (tmp_path / "system-prompt.md").write_text("Base.") - - result = build_system_prompt( - config_path=str(tmp_path), - workspace_id="ws-1", - loaded_skills=[], - peers=[], - parent_context=None, - ) - - assert "## Parent Context" not in result - - -def test_parent_context_skips_empty_content(tmp_path): - """Files with empty/whitespace-only content are skipped.""" - (tmp_path / "system-prompt.md").write_text("Base.") - - parent_context = [ - {"path": "empty.md", "content": ""}, - {"path": "whitespace.md", "content": " \n "}, - {"path": "real.md", "content": "Real content here."}, - ] - - result = build_system_prompt( - config_path=str(tmp_path), - workspace_id="ws-1", - loaded_skills=[], - peers=[], - parent_context=parent_context, - ) - - assert "## Parent Context" in result - assert "### empty.md" not in result - assert "### whitespace.md" not in result - assert "### real.md" in result - assert "Real content here." in result + assert "shared by your parent workspace" not in result # ---------------------------------------------------------------------------