Merge pull request #2792 from Molecule-AI/feat/drop-shared-context
feat: drop shared_context — use memory v2 team namespace
This commit is contained in:
commit
872b781f64
@ -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}>
|
||||
|
||||
@ -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 },
|
||||
|
||||
@ -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>);
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
@ -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 |
|
||||
|
||||
@ -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. |
|
||||
|
||||
@ -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
|
||||
|
||||
@ -751,8 +751,22 @@ print(len(d if isinstance(d, list) else d.get('events', [])))" 2>/dev/null || ec
|
||||
# cleanup
|
||||
tenant_call DELETE "/workspaces/$PARENT_ID/memory/$EDIT_KEY" >/dev/null 2>&1 || true
|
||||
ok "Memory KV Edit round-trip + 409 gate passed"
|
||||
|
||||
# ─── 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 / memory-edit"
|
||||
log "9/11 Canary mode — skipping HMA / peers / activity / memory-edit / shared-context-gone"
|
||||
fi
|
||||
|
||||
# ─── 10. Delegation mechanics (full mode + child) ──────────────────────
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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)
|
||||
}
|
||||
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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,
|
||||
)
|
||||
|
||||
|
||||
@ -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),
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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")
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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) =====
|
||||
|
||||
@ -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:<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
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@ -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:<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
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
Loading…
Reference in New Issue
Block a user