feat: drop shared_context — use memory v2 team namespace instead

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:<id> 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) <noreply@anthropic.com>
This commit is contained in:
Hongming Wang 2026-05-04 16:30:26 -07:00
parent 095171f163
commit 2f7beb9bce
21 changed files with 68 additions and 579 deletions

View File

@ -890,7 +890,6 @@ export function ConfigTab({ workspaceId }: Props) {
<TagList label="Skills" values={config.skills || []} onChange={(v) => update("skills", v)} placeholder="e.g. code-review" />
<TagList label="Tools" values={config.tools || []} onChange={(v) => update("tools", v)} placeholder="e.g. web_search, filesystem" />
<TagList label="Prompt Files" values={config.prompt_files || []} onChange={(v) => update("prompt_files", v)} placeholder="e.g. system-prompt.md" />
<TagList label="Shared Context" values={config.shared_context || []} onChange={(v) => update("shared_context", v)} placeholder="e.g. architecture.md" />
</Section>
<Section title="A2A Protocol" defaultOpen={false}>

View File

@ -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 },

View File

@ -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<string, unknown>);

View File

@ -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:<id> 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.

View File

@ -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:<id>` 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

View File

@ -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 |

View File

@ -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. |

View File

@ -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:<id> namespace (recall_memory MCP tool). See RFC #2789 for shared files.
a2a:
port: 8000

View File

@ -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:<id> 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) ──────────────────────

View File

@ -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

View File

@ -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)
}

View File

@ -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)
}
}

View File

@ -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)

View File

@ -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:<id>
# 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,
)

View File

@ -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),

View File

@ -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:

View File

@ -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")

View File

@ -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"

View File

@ -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) =====

View File

@ -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: parentchild knowledge
sharing now flows through memory v2's team:<id> 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
# ---------------------------------------------------------------------------

View File

@ -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 parentchild knowledge sharing
now flows through memory v2's team:<id> 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
# ---------------------------------------------------------------------------