From 944ba4ccfffb31f85ab3a9a015a212922c03c30b Mon Sep 17 00:00:00 2001 From: Duc Nguyen Date: Tue, 26 May 2026 08:01:01 +0700 Subject: [PATCH 001/105] fix(tts): wire dashboard tts.timeout_ms through ApplySystemConfigs + seed MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ApplySystemConfigs and seedConfigForContext both handle tts.provider, tts.auto, tts.mode, tts.max_length — but neither carries tts.timeout_ms. Result: the dashboard /tts page Timeout (ms) input is inert; cfg.Tts.TimeoutMs stays at config.json default; setupTTS passes 0 to provider constructors; each provider falls back to its hardcoded default (e.g. edge = 30s). Two-line fix to mirror the existing pattern for the other tts.* keys. --- cmd/gateway_system_config_sync.go | 1 + internal/config/config_system.go | 1 + 2 files changed, 2 insertions(+) diff --git a/cmd/gateway_system_config_sync.go b/cmd/gateway_system_config_sync.go index dbff536f..0dfd8374 100644 --- a/cmd/gateway_system_config_sync.go +++ b/cmd/gateway_system_config_sync.go @@ -121,6 +121,7 @@ func seedConfigForContext(ctx context.Context, sc store.SystemConfigStore, cfg * set("tts.auto", cfg.Tts.Auto) set("tts.mode", cfg.Tts.Mode) setInt("tts.max_length", cfg.Tts.MaxLength) + setInt("tts.timeout_ms", cfg.Tts.TimeoutMs) // Cron setInt("cron.max_retries", cfg.Cron.MaxRetries) diff --git a/internal/config/config_system.go b/internal/config/config_system.go index 3d225ece..84974c0d 100644 --- a/internal/config/config_system.go +++ b/internal/config/config_system.go @@ -67,6 +67,7 @@ func (c *Config) ApplySystemConfigs(configs map[string]string) { str("tts.auto", &c.Tts.Auto) str("tts.mode", &c.Tts.Mode) integer("tts.max_length", &c.Tts.MaxLength) + integer("tts.timeout_ms", &c.Tts.TimeoutMs) // Cron integer("cron.max_retries", &c.Cron.MaxRetries) From cb0567cc708bf32a0788c3baca484ebee0520609 Mon Sep 17 00:00:00 2001 From: Goon Date: Thu, 28 May 2026 18:53:59 +0700 Subject: [PATCH 002/105] feat(plan): outline passive channel memory extraction --- ...-01-research-and-characterization-tests.md | 87 +++++++++++++ .../phase-02-schema-and-store-contracts.md | 100 +++++++++++++++ ...phase-03-extractor-worker-and-redaction.md | 114 ++++++++++++++++++ ...e-04-memory-kg-review-queue-integration.md | 99 +++++++++++++++ ...-channel-settings-ui-and-admin-controls.md | 111 +++++++++++++++++ ...cation-documentation-and-ship-readiness.md | 99 +++++++++++++++ .../plan.md | 92 ++++++++++++++ 7 files changed, 702 insertions(+) create mode 100644 plans/260528-1844-passive-channel-memory-extraction/phase-01-research-and-characterization-tests.md create mode 100644 plans/260528-1844-passive-channel-memory-extraction/phase-02-schema-and-store-contracts.md create mode 100644 plans/260528-1844-passive-channel-memory-extraction/phase-03-extractor-worker-and-redaction.md create mode 100644 plans/260528-1844-passive-channel-memory-extraction/phase-04-memory-kg-review-queue-integration.md create mode 100644 plans/260528-1844-passive-channel-memory-extraction/phase-05-channel-settings-ui-and-admin-controls.md create mode 100644 plans/260528-1844-passive-channel-memory-extraction/phase-06-verification-documentation-and-ship-readiness.md create mode 100644 plans/260528-1844-passive-channel-memory-extraction/plan.md diff --git a/plans/260528-1844-passive-channel-memory-extraction/phase-01-research-and-characterization-tests.md b/plans/260528-1844-passive-channel-memory-extraction/phase-01-research-and-characterization-tests.md new file mode 100644 index 00000000..2c3883b0 --- /dev/null +++ b/plans/260528-1844-passive-channel-memory-extraction/phase-01-research-and-characterization-tests.md @@ -0,0 +1,87 @@ +--- +phase: 1 +title: "Research and Characterization Tests" +status: pending +priority: P1 +effort: "1d" +dependencies: [] +--- + +# Phase 1: Research and Characterization Tests + +## Context Links + +- Issue: `digitopvn/goclaw#64` +- Existing docs: `docs/06-store-data-model.md`, `docs/07-bootstrap-skills-memory.md`, `docs/09-security.md`, `docs/18-http-api.md` +- Channel buffer: `internal/channels/history.go`, `internal/store/pending_message_store.go` +- Memory/KG: `internal/consolidation/episodic_worker.go`, `internal/consolidation/semantic_worker.go` + +## Overview + +Lock current behavior with tests before changing schemas or workers. This phase proves the feature can reuse existing channel pending-message buffers and Memory/KG pipeline without breaking session memory. + +## Requirements + +- Functional: document exact current data flow for group pending messages, channel instance config update, episodic creation, KG extraction, and activity logging. +- Non-functional: tests first, no implementation until expected failures describe the desired behavior. + +## Architecture + +No runtime architecture added yet. The output is a verified map and failing tests for v1 contracts: + +1. channel instance config can carry `passive_memory` +2. pending messages are tenant/channel/history-key scoped +3. extraction candidates exclude DMs/private history in v1 +4. session-created episodic flow remains unchanged + +## Related Code Files + +- Read: `internal/channels/history.go` +- Read: `internal/channels/history_compaction.go` +- Read: `internal/store/pending_message_store.go` +- Read: `internal/store/channel_instance_store.go` +- Read: `internal/consolidation/episodic_worker.go` +- Read: `internal/consolidation/semantic_worker.go` +- Read: `internal/http/channel_instances.go` +- Create: `internal/consolidation/channel_memory_config_test.go` +- Create: `internal/consolidation/channel_memory_worker_test.go` +- Create: `internal/http/channel_memory_handlers_test.go` +- Create or extend: store tests under `internal/store/pg/` and `internal/store/sqlitestore/` + +## Implementation Steps + +1. Re-scout channel adapters to list which currently call `PendingHistory.Record`. +2. Re-scout `channel_pending_messages` PG/SQLite implementations and confirm tenant predicates. +3. Write failing config parser tests for default-disabled `passive_memory`. +4. Write failing candidate-selection tests: group-only, enabled-only, interval-or-cap trigger, minimum useful content gate. +5. Write failing redaction tests using representative secret/PII strings. +6. Write failing API tests for unauthorized settings/run/review actions. +7. Write a short `plans/.../reports/scout-report.md` with verified file/line anchors. + +## Todo List + +- [ ] Enumerate all pending-history recording call sites. +- [ ] Enumerate HTTP + WS channel instance update surfaces. +- [ ] Add failing config parser tests. +- [ ] Add failing worker trigger tests. +- [ ] Add failing redaction tests. +- [ ] Add failing admin API permission tests. +- [ ] Save scout report under this plan. + +## Success Criteria + +- [ ] Tests fail for missing passive-memory config, worker, queue, and handlers. +- [ ] Scout report lists exact files to modify and no fabricated APIs. +- [ ] Plan assumptions match live code after re-grep. + +## Risk Assessment + +Main risk is planning from stale scout notes. Mitigation: every claim in later phases must cite paths found in this phase, especially schema versions and route wiring. + +## Security Considerations + +Tests must include cross-tenant pending-message isolation and reject non-admin write access before any handler implementation exists. + +## Next Steps + +Proceed to schema/store only after tests describe the contracts. diff --git a/plans/260528-1844-passive-channel-memory-extraction/phase-02-schema-and-store-contracts.md b/plans/260528-1844-passive-channel-memory-extraction/phase-02-schema-and-store-contracts.md new file mode 100644 index 00000000..71af39b5 --- /dev/null +++ b/plans/260528-1844-passive-channel-memory-extraction/phase-02-schema-and-store-contracts.md @@ -0,0 +1,100 @@ +--- +phase: 2 +title: "Schema and Store Contracts" +status: pending +priority: P1 +effort: "2d" +dependencies: [1] +--- + +# Phase 2: Schema and Store Contracts + +## Context Links + +- Phase 1 tests and scout report +- PG migration latest during planning: `000073_secure_cli_credential_type.up.sql` +- PG schema version: `internal/upgrade/version.go` +- SQLite schema: `internal/store/sqlitestore/schema.sql`, `internal/store/sqlitestore/schema.go` +- Store registry: `internal/store/stores.go`, `internal/store/pg/factory.go`, `internal/store/sqlitestore/factory.go` + +## Overview + +Add durable run and review-item stores for passive extraction. Keep channel settings inside `channel_instances.config` to avoid another settings subsystem. + +## Requirements + +- Functional: persist extraction runs, pending extracted items, approval/rejection/deletion state, source ranges, redaction stats, and write metadata. +- Non-functional: tenant-scoped SQL, PG + SQLite parity, deterministic source IDs, no raw message body in extraction item after redaction. + +## Architecture + +Use two new store concepts: + +1. `ChannelMemoryExtractionRun`: one batch attempt per channel/history key. +2. `ChannelMemoryExtractionItem`: one extracted candidate fact or KG relation bundle pending review. + +`channel_instances.config.passive_memory` carries settings. Store layer exposes typed config helpers so HTTP/WS/UI do not mutate arbitrary JSON blindly. + +## Related Code Files + +- Create: `migrations/000074_channel_memory_extraction.up.sql` +- Create: `migrations/000074_channel_memory_extraction.down.sql` +- Modify: `internal/upgrade/version.go` +- Modify: `internal/store/sqlitestore/schema.sql` +- Modify: `internal/store/sqlitestore/schema.go` +- Create: `internal/store/channel_memory_extraction_store.go` +- Create: `internal/store/pg/channel_memory_extraction.go` +- Create: `internal/store/sqlitestore/channel_memory_extraction.go` +- Modify: `internal/store/stores.go` +- Modify: `internal/store/pg/factory.go` +- Modify: `internal/store/sqlitestore/factory.go` + +## Implementation Steps + +1. Add PG migration `000074` with tenant-scoped tables and indexes: + - `channel_memory_extraction_runs` + - `channel_memory_extraction_items` +2. Add unique constraints for idempotency: + - run: `(tenant_id, channel_instance_id, history_key, source_start_id, source_end_id)` + - item: `(tenant_id, run_id, item_hash)` +3. Add statuses: + - run: `pending`, `running`, `completed`, `failed`, `cancelled` + - item: `pending_review`, `approved`, `rejected`, `written`, `deleted` +4. Add source metadata fields: channel instance ID/name, history key, platform message ID range, time range, extractor run ID. +5. Add redaction summary fields: count, categories, skipped reason. +6. Add SQLite schema and migration map patch; bump `SchemaVersion` from 42 to 43. +7. Bump PG `RequiredSchemaVersion` from 73 to 74. +8. Implement PG and SQLite stores using existing scope patterns. +9. Wire stores into `store.Stores` and factories. +10. Make Phase 1 store tests pass. + +## Todo List + +- [ ] Add PG migration + version bump. +- [ ] Add SQLite schema + migration map + version bump. +- [ ] Add store interface types. +- [ ] Add PG store implementation. +- [ ] Add SQLite store implementation. +- [ ] Wire factories. +- [ ] Add idempotency tests. +- [ ] Add tenant isolation tests. + +## Success Criteria + +- [ ] Fresh PG migration applies and rolls back. +- [ ] Fresh SQLite DB contains tables. +- [ ] Existing SQLite DB migrates 42 to 43. +- [ ] Store tests pass for create/list/update/claim/status transitions. +- [ ] No SQL string concatenation with user input. + +## Risk Assessment + +Migration conflict risk is high on active `dev`. Before implementation, re-run `ls migrations/*.up.sql | sort | tail` and renumber if another migration claims `000074`. + +## Security Considerations + +All read/write methods require tenant context unless explicitly cross-tenant. Review item payload must store redacted text only; raw body stays in existing pending-message table subject to retention. + +## Next Steps + +Worker can claim runs only after store contracts exist. diff --git a/plans/260528-1844-passive-channel-memory-extraction/phase-03-extractor-worker-and-redaction.md b/plans/260528-1844-passive-channel-memory-extraction/phase-03-extractor-worker-and-redaction.md new file mode 100644 index 00000000..f15c5ca8 --- /dev/null +++ b/plans/260528-1844-passive-channel-memory-extraction/phase-03-extractor-worker-and-redaction.md @@ -0,0 +1,114 @@ +--- +phase: 3 +title: "Extractor Worker and Redaction" +status: pending +priority: P1 +effort: "2d" +dependencies: [1, 2] +--- + +# Phase 3: Extractor Worker and Redaction + +## Context Links + +- Existing compaction: `internal/channels/history_compaction.go` +- Existing KG extractor: `internal/knowledgegraph/extractor.go` +- Background provider resolver: `internal/providerresolve` +- Usage caps: `internal/usage/caps` +- Runtime wiring: `cmd/gateway.go` + +## Overview + +Build the passive extraction worker. It scans enabled channel configs, selects eligible message batches, redacts sensitive content, calls LLM extraction/summarization once per batch, and stores review items. + +## Requirements + +- Functional: interval/message-cap/manual trigger, group-only v1, minimum useful content gate, redaction before durable writes, retry-safe run status. +- Non-functional: bounded worker concurrency, cost controlled by batching, no panic on provider failure, graceful shutdown. + +## Architecture + +Add `internal/consolidation/channel_memory_worker.go` or a focused `internal/channelmemory/` package if the file would exceed 200 LOC. + +Data flow: + +1. scan `ChannelInstanceStore` for enabled channels with `config.passive_memory.enabled=true` +2. inspect `PendingMessageStore.ListGroups` +3. choose groups where interval elapsed or message cap reached +4. load `PendingMessageStore.ListByKey` +5. redact +6. create/claim extraction run +7. call extractor prompt for durable facts only +8. persist `pending_review` items +9. log activity + `slog.Info/Warn` + +## Related Code Files + +- Create: `internal/consolidation/channel_memory_config.go` +- Create: `internal/consolidation/channel_memory_redactor.go` +- Create: `internal/consolidation/channel_memory_worker.go` +- Create: `internal/consolidation/channel_memory_prompt.go` +- Modify: `internal/consolidation/workers.go` or gateway wiring if worker stays separate from existing worker bundle +- Modify: `cmd/gateway.go` +- Modify: `cmd/gateway_managed.go` if managed gateway has separate wiring + +## Implementation Steps + +1. Implement `PassiveMemoryConfig` parser with defaults: + - disabled + - review mode true + - interval 360 minutes + - cap 100 messages + - retention 168 hours + - allowed types default set +2. Implement redactor with tests for: + - API keys/tokens/passwords + - env-like secrets + - connection strings + - payment/banking numbers + - phone/address-like PII + - configured exclude user IDs and regex patterns +3. Implement candidate selector with tests: + - skip disabled + - skip DM/private keys by v1 policy + - skip too few useful messages + - trigger on interval or message cap +4. Implement LLM prompt and parser for durable facts: + - output JSON array of items + - allowed types only + - confidence score + - concise memory text + - optional entity/relation hints +5. Use `usageCaps.Chat` with purpose `channel-memory-extraction`. +6. Store run/item status transitions. +7. Emit audit activity for run started/completed/failed. +8. Wire worker start/stop in gateway with conservative ticker and concurrency. + +## Todo List + +- [ ] Config parser tests pass. +- [ ] Redactor tests pass. +- [ ] Candidate selector tests pass. +- [ ] Worker run lifecycle tests pass. +- [ ] Provider failure leaves run `failed` and does not block gateway. +- [ ] Usage cap purpose covered. +- [ ] Gateway wiring starts worker only when channels + stores are available. + +## Success Criteria + +- [ ] Worker creates review items from eligible group messages. +- [ ] No raw unredacted message text stored in new tables. +- [ ] Duplicate scans do not create duplicate runs/items. +- [ ] Provider failures are non-fatal and auditable. + +## Risk Assessment + +The redactor can false-positive and reduce extraction quality. Accept for v1. Better to drop sensitive content than store it. + +## Security Considerations + +Prompt must explicitly reject secrets and low-confidence guesses. Logs must include counts and IDs, not raw message body. + +## Next Steps + +Approved review items can write to Memory/KG after queue semantics are stable. diff --git a/plans/260528-1844-passive-channel-memory-extraction/phase-04-memory-kg-review-queue-integration.md b/plans/260528-1844-passive-channel-memory-extraction/phase-04-memory-kg-review-queue-integration.md new file mode 100644 index 00000000..f14be8f8 --- /dev/null +++ b/plans/260528-1844-passive-channel-memory-extraction/phase-04-memory-kg-review-queue-integration.md @@ -0,0 +1,99 @@ +--- +phase: 4 +title: "Memory KG Review Queue Integration" +status: pending +priority: P1 +effort: "2d" +dependencies: [2, 3] +--- + +# Phase 4: Memory KG Review Queue Integration + +## Context Links + +- Episodic store: `internal/store/episodic_store.go`, `internal/store/pg/episodic_summaries.go`, `internal/store/sqlitestore/episodic.go` +- Semantic worker: `internal/consolidation/semantic_worker.go` +- KG store: `internal/store/knowledge_graph_store.go` +- Activity store: `internal/store/activity_store.go` + +## Overview + +Add approve/reject/delete semantics and connect approved channel extraction items to existing episodic Memory and KG ingestion. Keep review queue default-on. + +## Requirements + +- Functional: list pending items, approve, reject, delete, manual write, idempotent re-approve, source metadata preserved. +- Non-functional: approved summaries become searchable like episodic memory; KG receives only approved/redacted text; all writes are tenant/agent scoped. + +## Architecture + +Approval writes one episodic summary with: + +- `SourceType`: `channel` +- `SourceID`: deterministic `channel::` +- `SessionKey`: synthetic, e.g. `channel::` +- `Summary`: approved redacted memory text +- `KeyTopics`: extracted normalized topics/entities +- `UserID`: approving admin or configured channel agent owner user, decided in implementation after re-checking current auth model + +Then publish `EventEpisodicCreated` so `semanticWorker` extracts KG facts through the existing path. + +## Related Code Files + +- Modify: `internal/consolidation/episodic_worker.go` only if helpers can be reused without changing session behavior. +- Create: `internal/consolidation/channel_memory_writer.go` +- Modify: `internal/eventbus/event_types.go` only if channel-specific payload is genuinely needed; prefer existing `EventEpisodicCreated`. +- Modify: new `ChannelMemoryExtractionStore` from Phase 2. +- Create: tests in `internal/consolidation/channel_memory_writer_test.go`. + +## Implementation Steps + +1. Write failing tests for approve idempotency: + - first approve creates episodic and marks item written/approved + - second approve no-ops + - rejected/deleted item cannot be approved unless restored is explicitly implemented, which v1 does not need +2. Implement writer service that depends on: + - `ChannelMemoryExtractionStore` + - `EpisodicStore` + - `DomainEventBus` + - `ActivityStore` +3. Create episodic summary from approved item with deterministic source ID. +4. Publish existing `EventEpisodicCreated` with redacted summary. +5. Record activity: + - `channel_memory.item.approved` + - `channel_memory.item.rejected` + - `channel_memory.item.deleted` + - `channel_memory.item.written` +6. Add delete/forget behavior: + - v1 deletes queued item if not written + - if written, mark deleted and delete associated episodic summary when source ID matches + - KG deletion for already-ingested facts may be best-effort by source metadata if existing KG store supports it; otherwise flag as v1 limitation in UI/docs + +## Todo List + +- [ ] Add writer service tests. +- [ ] Implement approve/reject/delete transitions. +- [ ] Implement episodic write. +- [ ] Publish existing semantic event. +- [ ] Add activity logs. +- [ ] Verify session episodic tests remain unchanged. + +## Success Criteria + +- [ ] Approved item appears in episodic search. +- [ ] KG extraction path is triggered through existing event bus. +- [ ] Re-approve cannot duplicate episodic/KG data. +- [ ] Reject/delete are auditable. +- [ ] Tenant mismatch tests fail closed. + +## Risk Assessment + +Deleting KG facts after approval may require source-level relation tracking not present today. If exact deletion is not supported, v1 must be honest: delete queued item + episodic source, and mark KG cleanup as best-effort/manual dedup. + +## Security Considerations + +Approval endpoint must require tenant admin for tenant-scoped channel rows. Master-scope owner is not required because rows are tenant-scoped. + +## Next Steps + +Expose the review queue and settings through API/UI. diff --git a/plans/260528-1844-passive-channel-memory-extraction/phase-05-channel-settings-ui-and-admin-controls.md b/plans/260528-1844-passive-channel-memory-extraction/phase-05-channel-settings-ui-and-admin-controls.md new file mode 100644 index 00000000..a251b7eb --- /dev/null +++ b/plans/260528-1844-passive-channel-memory-extraction/phase-05-channel-settings-ui-and-admin-controls.md @@ -0,0 +1,111 @@ +--- +phase: 5 +title: "Channel Settings UI and Admin Controls" +status: pending +priority: P2 +effort: "2d" +dependencies: [2, 3, 4] +--- + +# Phase 5: Channel Settings UI and Admin Controls + +## Context Links + +- HTTP channel handler: `internal/http/channel_instances.go` +- WS channel methods: `internal/gateway/methods/channel_instances.go` +- Channel UI: `ui/web/src/pages/channels/channels-page.tsx`, `ui/web/src/pages/channels/channel-detail/` +- UI types: `ui/web/src/types/channel.ts` +- i18n: `ui/web/src/i18n/locales/{en,vi,zh}/channels.json` + +## Overview + +Expose passive memory settings and review queue in channel detail. Keep the UI operator-focused: no global dashboard in v1. + +## Requirements + +- Functional: configure, manually run extraction, show last run, show counts, approve/reject/delete pending items. +- Non-functional: mobile-safe controls, no raw secret/PII display beyond already-redacted item text, i18n in EN/VI/ZH. + +## Architecture + +HTTP endpoints under channel instance scope: + +- `GET /v1/channels/instances/{id}/memory-extraction` +- `PUT /v1/channels/instances/{id}/memory-extraction/settings` +- `POST /v1/channels/instances/{id}/memory-extraction/run` +- `GET /v1/channels/instances/{id}/memory-extraction/items` +- `POST /v1/channels/instances/{id}/memory-extraction/items/{itemID}/approve` +- `POST /v1/channels/instances/{id}/memory-extraction/items/{itemID}/reject` +- `DELETE /v1/channels/instances/{id}/memory-extraction/items/{itemID}` + +WS methods are optional in v1. Prefer HTTP because current web hooks already use HTTP for channel instances. + +## Related Code Files + +- Modify: `internal/http/channel_instances.go` +- Create: `internal/http/channel_memory_extraction_handlers.go` +- Create: `internal/http/channel_memory_extraction_handlers_test.go` +- Modify: `ui/web/src/types/channel.ts` +- Create: `ui/web/src/pages/channels/channel-detail/passive-memory-section.tsx` +- Create: `ui/web/src/pages/channels/hooks/use-channel-memory-extraction.ts` +- Modify: `ui/web/src/pages/channels/channel-detail/channel-detail-page.tsx` +- Modify: locale files in `ui/web/src/i18n/locales/en/`, `vi/`, `zh/` + +## Implementation Steps + +1. Add HTTP handler tests first: + - viewer can read status/items + - admin required for settings/run/approve/reject/delete + - tenant mismatch returns not found/forbidden without leaking row existence +2. Add settings validation: + - interval min/max + - message cap min/max + - retention min/max + - allowed types from enum + - exclude patterns bounded length/count +3. Add routes to `ChannelInstancesHandler.RegisterRoutes`. +4. Implement UI hook with TanStack Query and invalidation on mutation. +5. Build `PassiveMemorySection`: + - toggle + - interval and cap numeric inputs + - review mode toggle + - allowed type checkboxes + - last run summary + - manual run button + - pending extracted items list + - approve/reject/delete buttons +6. Add i18n keys to EN/VI/ZH channel namespace. +7. Ensure mobile rules: + - inputs use `text-base md:text-sm` + - tables/lists horizontally safe + - buttons have sufficient hit area + +## Todo List + +- [ ] Add HTTP route tests. +- [ ] Implement handler validation. +- [ ] Add UI hook. +- [ ] Add channel detail section. +- [ ] Add i18n strings. +- [ ] Add basic UI tests if existing setup supports it; otherwise rely on build + manual browser verification. + +## Success Criteria + +- [ ] Admin can enable passive memory for a channel. +- [ ] Admin can force a run. +- [ ] Pending items appear after extraction. +- [ ] Admin can approve/reject/delete items. +- [ ] Viewer cannot mutate settings/items. +- [ ] Web build passes. + +## Risk Assessment + +UI can balloon. Keep it as one compact channel-detail section. Do not add global review inbox until v1 proves useful. + +## Security Considerations + +UI must show redaction status and avoid displaying raw source messages. If source preview is needed later, it must be separate permissioned work. + +## Next Steps + +Run full verification, docs, and issue handoff. diff --git a/plans/260528-1844-passive-channel-memory-extraction/phase-06-verification-documentation-and-ship-readiness.md b/plans/260528-1844-passive-channel-memory-extraction/phase-06-verification-documentation-and-ship-readiness.md new file mode 100644 index 00000000..ea932228 --- /dev/null +++ b/plans/260528-1844-passive-channel-memory-extraction/phase-06-verification-documentation-and-ship-readiness.md @@ -0,0 +1,99 @@ +--- +phase: 6 +title: "Verification Documentation and Ship Readiness" +status: pending +priority: P1 +effort: "1d" +dependencies: [1, 2, 3, 4, 5] +--- + +# Phase 6: Verification Documentation and Ship Readiness + +## Context Links + +- Post-implementation checklist in `CLAUDE.md` +- Docs to update: `docs/05-channels-messaging.md`, `docs/06-store-data-model.md`, `docs/07-bootstrap-skills-memory.md`, `docs/09-security.md`, `docs/18-http-api.md`, `docs/project-changelog.md` +- Local verification memory: `go build ./...`, `go build -tags sqliteonly ./...`, `go vet ./...` + +## Overview + +Prove the feature is safe, documented, and ready for implementation PR review. This phase owns the final consistency sweep and release notes. + +## Requirements + +- Functional: all new tests pass; user-facing docs describe opt-in, review queue, redaction, retention, deletion limits. +- Non-functional: compile in PG and SQLite builds, no known tenant isolation gaps, no stale plan references in code comments. + +## Architecture + +No new architecture. This phase verifies all layers together: + +1. schema migration +2. store contracts +3. worker batching/redaction +4. review queue writer +5. HTTP/UI controls +6. docs + +## Related Code Files + +- Modify: `docs/05-channels-messaging.md` +- Modify: `docs/06-store-data-model.md` +- Modify: `docs/07-bootstrap-skills-memory.md` +- Modify: `docs/09-security.md` +- Modify: `docs/18-http-api.md` +- Modify: `docs/project-changelog.md` +- Possibly modify: `docs/codebase-summary.md` + +## Implementation Steps + +1. Run focused Go tests: + - `go test ./internal/consolidation` + - `go test ./internal/http` + - `go test ./internal/store/pg` + - `go test -tags sqliteonly ./internal/store/sqlitestore` +2. Run compile/static checks: + - `go build ./...` + - `go build -tags sqliteonly ./...` + - `go vet ./...` +3. Run web checks: + - `cd ui/web && pnpm build` +4. If UI changed, run local dev server and browser-check channel detail at desktop/mobile widths. +5. Update docs listed above. +6. Update `docs/project-changelog.md`. +7. Whole-plan consistency sweep: + - search code and docs for stale names such as `passiveExtraction`, `channel_memory`, `memory-extraction` + - verify route names match docs and UI hooks + - verify schema version numbers match migrations +8. Prepare PR summary with test results and privacy caveats. + +## Todo List + +- [ ] Focused tests pass. +- [ ] PG build passes. +- [ ] SQLite build passes. +- [ ] Vet passes. +- [ ] Web build passes. +- [ ] Browser verification done if UI implemented. +- [ ] Docs updated. +- [ ] Changelog updated. +- [ ] Whole-plan consistency sweep clean. + +## Success Criteria + +- [ ] Feature satisfies all issue #64 acceptance criteria selected for v1. +- [ ] No failing tests ignored. +- [ ] Any unsupported v1 behavior documented explicitly. +- [ ] PR is ready for code review. + +## Risk Assessment + +Integration tests with PG/pgvector may require local service. If unavailable, document exactly which integration tests were skipped and why; do not claim full verification. + +## Security Considerations + +Final review must include a targeted scan for raw message body persistence, secrets in logs, missing tenant predicates, and unauthorized mutation paths. + +## Next Steps + +After plan approval, execute with `/ck:cook ` or equivalent implementation workflow. diff --git a/plans/260528-1844-passive-channel-memory-extraction/plan.md b/plans/260528-1844-passive-channel-memory-extraction/plan.md new file mode 100644 index 00000000..68330428 --- /dev/null +++ b/plans/260528-1844-passive-channel-memory-extraction/plan.md @@ -0,0 +1,92 @@ +--- +title: "Passive Channel Memory Extraction" +description: "Add disabled-by-default, reviewable passive extraction from group channel buffers into episodic Memory and Knowledge Graph." +status: pending +priority: P1 +issue: 64 +branch: "codex/issue-64-passive-channel-memory-extraction" +tags: [channels, memory, knowledge-graph, privacy, tdd, issue-64] +blockedBy: [] +blocks: [] +created: "2026-05-28T11:45:20.128Z" +createdBy: "ck:plan" +source: skill +--- + +# Passive Channel Memory Extraction + +## Overview + +Implement issue #64 as a privacy-first v1. Agents may learn durable context from group/channel conversations they already receive, but only after a channel admin enables the feature. Extraction is batched, redacted, reviewable, tenant-scoped, and writes to Memory/KG only after approval or explicit high-confidence auto-approve. + +Hard boundary: no realtime per-message extraction, no default enablement, no permanent raw full-history store, and no cross-channel memory sharing. + +## Current Codebase Anchors + +| Area | Existing anchor | +|------|-----------------| +| Channel instances | `channel_instances.config`, `internal/store/channel_instance_store.go`, `internal/http/channel_instances.go`, `internal/gateway/methods/channel_instances.go` | +| Group buffer | `channel_pending_messages`, `internal/store/pending_message_store.go`, `internal/channels/history.go`, `internal/channels/history_compaction.go` | +| Memory/KG pipeline | `internal/consolidation/episodic_worker.go`, `internal/consolidation/semantic_worker.go`, `internal/knowledgegraph/extractor.go` | +| Activity audit | `internal/store/activity_store.go`, `internal/http/activity.go`, `emitAudit(...)` call sites | +| Runtime wiring | `cmd/gateway.go`, `cmd/gateway_http_handlers.go`, `cmd/gateway_http_wiring.go`, `cmd/gateway_channels_setup.go` | +| Web UI | `ui/web/src/pages/channels/`, `ui/web/src/pages/memory/`, `ui/web/src/types/channel.ts` | +| Schema versions | PG next migration `000074`; bump `internal/upgrade/version.go` from 73. SQLite bump `internal/store/sqlitestore/schema.go` from 42 to 43 | + +## Phases + +| Phase | Name | Status | +|-------|------|--------| +| 1 | [Research and Characterization Tests](./phase-01-research-and-characterization-tests.md) | Pending | +| 2 | [Schema and Store Contracts](./phase-02-schema-and-store-contracts.md) | Pending | +| 3 | [Extractor Worker and Redaction](./phase-03-extractor-worker-and-redaction.md) | Pending | +| 4 | [Memory KG Review Queue Integration](./phase-04-memory-kg-review-queue-integration.md) | Pending | +| 5 | [Channel Settings UI and Admin Controls](./phase-05-channel-settings-ui-and-admin-controls.md) | Pending | +| 6 | [Verification Documentation and Ship Readiness](./phase-06-verification-documentation-and-ship-readiness.md) | Pending | + +## Dependencies + +- GitHub issue: `digitopvn/goclaw#64` +- Existing group pending-message persistence must stay intact for channel context. +- Existing Memory/KG workers must keep session behavior unchanged. +- Standard edition only for channels. Lite has no channels, so SQLite migrations still must compile/apply, but UI should naturally hide channel surfaces through existing edition gates. + +## Recommended Defaults + +| Setting | Default | Reason | +|---------|---------|--------| +| `enabled` | `false` | Sensitive feature; explicit opt-in only | +| `review_mode` | `true` | Prevent accidental durable PII/secret storage | +| `interval_minutes` | `360` | Cheap daily-ish cadence without long wait | +| `message_cap` | `100` | Batching per issue request | +| `retention_hours` | `168` | 7-day raw buffer cap, same spirit as pending-message TTL | +| `allowed_types` | people, projects, decisions, todos, preferences, events | Durable work context only | +| `group_only` | `true` for v1 | Avoid DM privacy ambiguity | + +## Success Criteria + +- [ ] Passive extraction is disabled by default and opt-in per channel instance. +- [ ] Extraction runs only by interval, message cap, or manual trigger. +- [ ] Raw channel messages are read from tenant-scoped pending buffers and are not stored permanently outside existing retention. +- [ ] Redaction blocks common secrets, tokens, credentials, payment/banking data, phone/address patterns, and configurable excluded users/patterns before durable writes. +- [ ] Extracted items go to a review queue by default. +- [ ] Approved items write to `episodic_summaries` with `source_type='channel'` and deterministic `source_id`. +- [ ] KG ingestion receives only approved/redacted summaries and records source metadata. +- [ ] Review reject/delete removes queued items and prevents later writes. +- [ ] Tenant/channel/agent boundaries covered by tests. +- [ ] PG and SQLite schemas both migrate and compile. +- [ ] UI exposes settings, last run status, pending items, approve/reject/delete, and manual run. + +## Key Risks + +| Risk | Mitigation | +|------|------------| +| Durable sensitive-data leak | Redaction before queue, review default, no raw full-history durable copy | +| Duplicate facts | Deterministic run/item source IDs, store-level uniqueness, reuse KG dedup worker | +| Cross-tenant leak | Tenant-scoped queries and tests before implementation | +| Cost spike | Batched triggers, minimum content gate, usage caps purpose string | +| UX overbuild | Channel detail section only in v1; no separate global console | + +## Open Questions + +- None for v1. Assumptions: review queue required, group channels first, UI in channel detail. From 947c4fa054768daf1b5306df6b8f92469a50a740 Mon Sep 17 00:00:00 2001 From: Goon Date: Thu, 28 May 2026 18:56:10 +0700 Subject: [PATCH 003/105] docs(plan): add issue 66 channel admin roadmap --- .../phase-01-research-and-contracts.md | 99 +++++++++++++++++ .../phase-02-read-only-context-surface.md | 91 ++++++++++++++++ .../phase-03-effective-capability-matrix.md | 87 +++++++++++++++ .../phase-04-context-scoped-grants.md | 101 ++++++++++++++++++ .../phase-05-context-scoped-credentials.md | 101 ++++++++++++++++++ ...-06-discord-enrichment-and-verification.md | 91 ++++++++++++++++ .../plan.md | 50 +++++++++ 7 files changed, 620 insertions(+) create mode 100644 plans/260528-issue-66-channel-context-admin-surface/phase-01-research-and-contracts.md create mode 100644 plans/260528-issue-66-channel-context-admin-surface/phase-02-read-only-context-surface.md create mode 100644 plans/260528-issue-66-channel-context-admin-surface/phase-03-effective-capability-matrix.md create mode 100644 plans/260528-issue-66-channel-context-admin-surface/phase-04-context-scoped-grants.md create mode 100644 plans/260528-issue-66-channel-context-admin-surface/phase-05-context-scoped-credentials.md create mode 100644 plans/260528-issue-66-channel-context-admin-surface/phase-06-discord-enrichment-and-verification.md create mode 100644 plans/260528-issue-66-channel-context-admin-surface/plan.md diff --git a/plans/260528-issue-66-channel-context-admin-surface/phase-01-research-and-contracts.md b/plans/260528-issue-66-channel-context-admin-surface/phase-01-research-and-contracts.md new file mode 100644 index 00000000..5cb688ef --- /dev/null +++ b/plans/260528-issue-66-channel-context-admin-surface/phase-01-research-and-contracts.md @@ -0,0 +1,99 @@ +--- +phase: 1 +title: "Research and Contracts" +status: pending +effort: "M" +--- + +# Phase 1: Research and Contracts + +## Context Links + +- Issue: https://github.com/digitopvn/goclaw/issues/66 +- Current page: `ui/web/src/pages/channels/channel-detail/channel-detail-page.tsx` +- Current groups tab: `ui/web/src/pages/channels/channel-detail/channel-groups-tab.tsx` +- Current credentials tab: `ui/web/src/pages/channels/channel-detail/channel-credentials-tab.tsx` +- Channel routes: `internal/http/channel_instances.go` +- MCP store/routes: `internal/store/mcp_store.go`, `internal/http/mcp_grants.go`, `internal/http/mcp_user_credentials.go` +- Secure CLI store/routes: `internal/store/secure_cli_store.go`, `internal/http/secure_cli_agent_grants.go`, `internal/http/secure_cli_user_credentials.go` +- Docs: `docs/05-channels-messaging.md`, `docs/23-ai-agent-permission-matrix.md`, `docs/09-security.md` + +## Overview + +Lock the API contracts and threat model before implementation. This phase should produce failing tests or contract snapshots for the expected DTOs, plus a concise scope note that separates channel login credentials from MCP/CLI scoped credentials. + +## Key Insights + +- Existing `groups` tab is Telegram config overrides, not a general group/member view. +- Existing channel credentials are encrypted channel-login data; they must not become MCP/CLI runtime credentials. +- MCP access currently resolves server defaults + per-user credentials only. +- Secure CLI access currently resolves global binary + agent grant + user credential only. +- `agent_config_permissions` is for config/file-writer grants and should not be stretched into MCP/CLI grant storage. +- Discord currently captures guild/channel metadata but does not expose full live members/roles without extra intent work. + +## Requirements + +- Define canonical context scope model for `channel`, `group`, and `member` rows. +- Define masked credential DTOs that never include raw secret values. +- Define source labels for effective capability and credential rows: global, agent, channel, group, user. +- Define permission requirements for read vs write endpoints. +- Document resolver precedence: user > group/member-role > channel > agent > global. + +## Architecture + +- Add HTTP DTOs near existing channel/admin route files; keep store structs separate from UI response structs. +- Keep MCP and Secure CLI resolver changes behind explicit scope inputs instead of reading mutable process-global state. +- Represent channel context with stable fields: `channelInstanceID`, `channelType`, `scopeType`, `scopeKey`, `displayName`, `source`, `lastSeenAt`. +- Treat Discord role/member support as a provider capability flag until verified. + +## Related Code Files + +- Modify later: `internal/http/channel_instances.go` +- Modify later: `internal/store/channel_contact_store.go` +- Modify later: `internal/store/mcp_store.go` +- Modify later: `internal/store/secure_cli_store.go` +- Modify later: `ui/web/src/types/channel.ts` +- Modify later: `ui/web/src/pages/channels/channel-detail/*` +- Create later only if needed: focused DTO/test files with kebab-case or existing Go naming convention. + +## Implementation Steps + +1. Write failing backend tests for channel context DTO masking and tenant-scoped route authorization. +2. Write failing resolver contract tests that encode credential precedence without creating new tables yet. +3. Add a short issue-scope note in this plan directory if implementation discovers a contract gap. +4. Verify all planned endpoint names against existing route structure before coding handlers. +5. Verify current UI route/i18n conventions before adding visible strings. + +## Todo List + +- [ ] Add backend contract tests for context list shape and permission checks. +- [ ] Add resolver precedence characterization tests for MCP and Secure CLI. +- [ ] Confirm UI i18n key placement for new channel detail tabs. +- [ ] Confirm Discord provider capability and permissions needed for live member/role sync. + +## Success Criteria + +- [ ] Tests fail for the intended missing behavior before implementation. +- [ ] Contract fields and precedence rules are explicit and cited in later phase code. +- [ ] No plan step relies on raw credential reveal. +- [ ] No plan step reuses `channel_instances.credentials` for MCP/CLI secrets. + +## Risk Assessment + +- Risk: resolver scope leaks across tenants. Mitigation: tenant ID on every new table/query and route-level tenant checks. +- Risk: UI implies Discord support that backend cannot provide. Mitigation: capability flags and documented fallback. +- Risk: scope strings become ad hoc. Mitigation: typed scope model plus tests. + +## Security Considerations + +- Never serialize raw secrets. +- Audit all write operations in later phases. +- Use existing encrypted storage patterns and parameterized SQL only. + +## Next Steps + +- Proceed to Phase 2 after route/DTO tests are committed or queued in the implementation branch. + +## Unresolved Questions + +- None for planning; Discord depth remains a phase gate. diff --git a/plans/260528-issue-66-channel-context-admin-surface/phase-02-read-only-context-surface.md b/plans/260528-issue-66-channel-context-admin-surface/phase-02-read-only-context-surface.md new file mode 100644 index 00000000..2ba28a54 --- /dev/null +++ b/plans/260528-issue-66-channel-context-admin-surface/phase-02-read-only-context-surface.md @@ -0,0 +1,91 @@ +--- +phase: 2 +title: "Read-only Context Surface" +status: pending +effort: "L" +--- + +# Phase 2: Read-only Context Surface + +## Context Links + +- UI shell: `ui/web/src/pages/channels/channel-detail/channel-detail-page.tsx` +- Current hooks: `ui/web/src/pages/channels/hooks/use-channel-detail.ts` +- Channel contacts: `internal/store/channel_contact_store.go`, `internal/store/pg/channel_contacts.go` +- Live members abstraction: `internal/channels/channel.go`, `internal/channels/manager.go` +- Feishu member implementation: `internal/channels/feishu/feishu.go` +- Discord metadata source: `internal/channels/discord/handler.go` + +## Overview + +Add a read-only admin view for channel contexts and members/users. This should make the channel detail page useful before introducing new grant or credential mutations. + +## Requirements + +- Show channel contexts/groups discovered from contacts and supported provider APIs. +- Show members/users for a selected context when available. +- Show source and freshness: stored contact, live provider, config writer scope, or unsupported. +- Preserve existing Telegram override editing until it is intentionally replaced or renamed. +- Enforce tenant isolation and admin/operator read permissions. + +## Architecture + +- Add a channel context service that aggregates stored contacts plus provider-supported group/member APIs. +- Add HTTP endpoints under existing channel instance routes: + - `GET /v1/channels/instances/{id}/contexts` + - `GET /v1/channels/instances/{id}/contexts/{scopeType}/{scopeKey}/members` +- Extend channel contact store filters to support channel instance and context filters. +- UI adds a general `Contexts` tab for all channel types and keeps Telegram overrides as a separate advanced section if still needed. + +## Related Code Files + +- `internal/http/channel_instances.go` +- `internal/store/channel_contact_store.go` +- `internal/store/pg/channel_contacts.go` +- `internal/store/sqlitestore/*channel*` +- `internal/channels/manager.go` +- `ui/web/src/pages/channels/channel-detail/channel-detail-page.tsx` +- `ui/web/src/pages/channels/hooks/use-channel-detail.ts` +- `ui/web/src/types/channel.ts` + +## Implementation Steps + +1. TDD: add backend tests for context listing, member listing, tenant mismatch, and unsupported provider responses. +2. Add channel-instance filtering to contact store interfaces and PG/SQLite implementations. +3. Implement read-only context/member HTTP handlers using existing auth middleware and masked DTOs. +4. Add UI query hooks and type definitions for contexts and members. +5. Add a general channel detail tab with loading, empty, error, and unsupported states. +6. Add i18n keys to all web locale files before rendering new user-facing strings. +7. Keep Telegram override editing intact and avoid changing channel config behavior in this phase. + +## Todo List + +- [ ] Backend tests fail first for `/contexts` and `/members`. +- [ ] Store filters support `channel_instance` without full scans. +- [ ] UI tests cover tab visibility, empty state, and unsupported provider state. +- [ ] Existing channel detail behavior remains unchanged for general/credentials/managers tabs. + +## Success Criteria + +- [ ] Admin can see groups/contexts and members/users where data exists. +- [ ] Unsupported live member sync is explicit in the UI instead of silently blank. +- [ ] Tenant isolation tests pass for both PG and SQLite paths where applicable. +- [ ] No mutation endpoints are added in this phase. + +## Risk Assessment + +- Risk: `channel_contacts` has incomplete rows for some providers. Mitigation: label source/freshness and use provider capability flags. +- Risk: adding filters breaks contact search. Mitigation: preserve existing default list/search behavior with regression tests. + +## Security Considerations + +- Read endpoints must scope by authenticated tenant and selected channel instance. +- Do not include provider tokens or channel credentials in responses. + +## Next Steps + +- Phase 3 can consume the selected context model to display effective MCP/CLI capability rows. + +## Unresolved Questions + +- None. diff --git a/plans/260528-issue-66-channel-context-admin-surface/phase-03-effective-capability-matrix.md b/plans/260528-issue-66-channel-context-admin-surface/phase-03-effective-capability-matrix.md new file mode 100644 index 00000000..4492ccdb --- /dev/null +++ b/plans/260528-issue-66-channel-context-admin-surface/phase-03-effective-capability-matrix.md @@ -0,0 +1,87 @@ +--- +phase: 3 +title: "Effective Capability Matrix" +status: pending +effort: "L" +--- + +# Phase 3: Effective Capability Matrix + +## Context Links + +- MCP grants API: `internal/http/mcp_grants.go` +- MCP store: `internal/store/mcp_store.go`, `internal/store/pg/mcp_servers.go`, `internal/store/pg/mcp_servers_access.go` +- Secure CLI grants API: `internal/http/secure_cli_agent_grants.go` +- Secure CLI store: `internal/store/secure_cli_store.go`, `internal/store/pg/secure_cli.go` +- Channel detail UI: `ui/web/src/pages/channels/channel-detail/` + +## Overview + +Show the effective MCP servers/tools and Secure CLI packages for a channel context before allowing edits. This phase is read-only and should expose why access exists, not just whether access exists. + +## Requirements + +- Display MCP servers with tool allow/deny summaries and source labels. +- Display Secure CLI packages/binaries with source labels and masked env availability. +- Show future channel/group override slots as "not configured" until Phase 4/5 tables exist. +- Keep read endpoint tenant-scoped and usable by admins/operators with appropriate channel access. + +## Architecture + +- Add an effective capability aggregation service that composes existing MCP and Secure CLI store calls. +- Add endpoint under channel instance routes: + - `GET /v1/channels/instances/{id}/contexts/{scopeType}/{scopeKey}/capabilities` +- Response groups rows by capability type: `mcp_server`, `mcp_tool`, `secure_cli`. +- Avoid resolver mutation in this phase; this is visibility over existing agent/user/global grants plus planned context placeholder metadata. + +## Related Code Files + +- `internal/http/channel_instances.go` +- `internal/http/mcp_grants.go` +- `internal/http/secure_cli_agent_grants.go` +- `internal/store/mcp_store.go` +- `internal/store/secure_cli_store.go` +- `ui/web/src/pages/channels/channel-detail/` +- `ui/web/src/types/channel.ts` + +## Implementation Steps + +1. TDD: add backend tests for effective capability response shape using existing agent/user grants. +2. TDD: add UI tests for capability matrix source labels and masked secret indicators. +3. Implement a read-only aggregator that calls existing MCP and Secure CLI stores. +4. Add handler under channel instance route group with tenant/channel authorization. +5. Add UI matrix with filters for MCP, CLI, and source. +6. Add i18n keys in all web locale files. +7. Document that edit controls remain disabled until Phase 4/5. + +## Todo List + +- [ ] Backend tests prove source labels for global, agent, and user-derived access. +- [ ] UI tests cover empty, partial, and mixed MCP/CLI rows. +- [ ] No new schema is introduced in this phase. +- [ ] No raw credential values appear in capability responses. + +## Success Criteria + +- [ ] Admin can see granted MCP servers/tools for a selected channel context. +- [ ] Admin can see granted Secure CLI packages/binaries for a selected channel context. +- [ ] Source labels are visible and consistent with resolver precedence. +- [ ] Read-only endpoint has tenant isolation tests. + +## Risk Assessment + +- Risk: read model diverges from actual runtime resolver. Mitigation: use existing store access methods where possible and add characterization tests before later resolver edits. +- Risk: UI becomes noisy. Mitigation: default grouped display and simple filters. + +## Security Considerations + +- Mask all environment and credential indicators. +- Return only metadata required to administer access. + +## Next Steps + +- Phase 4 can add mutation tables and update the matrix source labels to include channel/group grants. + +## Unresolved Questions + +- None. diff --git a/plans/260528-issue-66-channel-context-admin-surface/phase-04-context-scoped-grants.md b/plans/260528-issue-66-channel-context-admin-surface/phase-04-context-scoped-grants.md new file mode 100644 index 00000000..f4d6544f --- /dev/null +++ b/plans/260528-issue-66-channel-context-admin-surface/phase-04-context-scoped-grants.md @@ -0,0 +1,101 @@ +--- +phase: 4 +title: "Context-scoped Grants" +status: pending +effort: "XL" +--- + +# Phase 4: Context-scoped Grants + +## Context Links + +- MCP grant schema: `migrations/000001_init_schema.up.sql`, later MCP migrations, `internal/store/pg/mcp_servers_access.go` +- Secure CLI grant schema: `migrations/000058_secure_cli_binaries.up.sql`, `migrations/000073_secure_cli_typed_credentials.up.sql` +- SQLite schema: `internal/store/sqlitestore/schema.sql`, `internal/store/sqlitestore/schema.go` +- Upgrade version: `internal/upgrade/version.go` +- Grant APIs: `internal/http/mcp_grants.go`, `internal/http/secure_cli_agent_grants.go` + +## Overview + +Add channel/group scoped grants for MCP and Secure CLI. This is the first write phase and requires dual database migrations, resolver changes, audit logs, and UI mutation controls. + +## Requirements + +- Add dedicated context grant tables; do not overload `agent_config_permissions`. +- Scope every row by tenant, channel instance, scope type, and scope key. +- Support grant sources: channel and group/member-role. +- Preserve existing agent/user grant behavior. +- Audit create/update/delete operations. + +## Architecture + +- Proposed tables: + - `mcp_context_grants` + - `secure_cli_context_grants` +- Common columns: `id`, `tenant_id`, `channel_instance_id`, `scope_type`, `scope_key`, resource ID, allow/deny or config overrides, `granted_by`, timestamps. +- Add PG migrations and SQLite full schema + incremental migrations in the same implementation PR. +- Extend resolver input with channel context. Prefer explicit params where call sites already carry channel metadata; use context values only when explicit threading would create broad churn. +- Runtime grant precedence: user > group/member-role > channel > agent > global. + +## Related Code Files + +- `migrations/*` +- `internal/upgrade/version.go` +- `internal/store/sqlitestore/schema.sql` +- `internal/store/sqlitestore/schema.go` +- `internal/store/mcp_store.go` +- `internal/store/secure_cli_store.go` +- `internal/store/pg/mcp_servers_access.go` +- `internal/store/pg/secure_cli.go` +- `internal/http/mcp_grants.go` +- `internal/http/secure_cli_agent_grants.go` +- `ui/web/src/pages/channels/channel-detail/` + +## Implementation Steps + +1. TDD: add migration tests or store tests that fail until context grant tables exist in PG and SQLite. +2. TDD: add resolver tests proving group grant overrides channel grant, and user grant overrides both. +3. Add PG up/down migrations and bump `RequiredSchemaVersion`. +4. Add SQLite schema and incremental migration map updates with `SchemaVersion` bump. +5. Add store interfaces and PG/SQLite implementations for context grants. +6. Extend MCP and Secure CLI resolvers to include context grants without breaking existing call sites. +7. Add admin-only HTTP endpoints for create/update/delete/list context grants. +8. Add UI controls to grant/revoke MCP/CLI rows for selected channel context. +9. Emit security/audit logs on every write. +10. Run dual DB compile and focused store/resolver tests. + +## Todo List + +- [ ] Add failing PG and SQLite tests for context grant persistence. +- [ ] Add failing resolver precedence tests. +- [ ] Add dual DB migrations and version bumps. +- [ ] Add audited admin endpoints. +- [ ] Add UI mutation controls with disabled/loading/error states. + +## Success Criteria + +- [ ] Channel/group scoped grants can be created, listed, updated, and revoked. +- [ ] Effective capability matrix shows channel/group grant sources. +- [ ] Existing agent/user MCP and CLI flows keep passing. +- [ ] PG and SQLite schema versions are both updated. +- [ ] Audit logs include actor, tenant, channel instance, scope, resource, and action. + +## Risk Assessment + +- Risk: resolver signature churn touches many runtime paths. Mitigation: enumerate all callers before editing and add compile-time failures as guide. +- Risk: partial migration coverage breaks desktop. Mitigation: SQLite schema update is mandatory in the same phase. +- Risk: broad grants accidentally cross tenant. Mitigation: tenant in primary queries and route authorization tests. + +## Security Considerations + +- Admin writes must use tenant admin or master-scope checks according to table ownership. +- SQL must be parameterized and indexed on tenant/channel/scope/resource columns. +- Never log secret material from overrides. + +## Next Steps + +- Phase 5 adds scoped credential storage after grant resolver behavior is stable. + +## Unresolved Questions + +- Decide exact DB constraint names during implementation after checking existing migration naming style. diff --git a/plans/260528-issue-66-channel-context-admin-surface/phase-05-context-scoped-credentials.md b/plans/260528-issue-66-channel-context-admin-surface/phase-05-context-scoped-credentials.md new file mode 100644 index 00000000..3fec559a --- /dev/null +++ b/plans/260528-issue-66-channel-context-admin-surface/phase-05-context-scoped-credentials.md @@ -0,0 +1,101 @@ +--- +phase: 5 +title: "Context-scoped Credentials" +status: pending +effort: "XL" +--- + +# Phase 5: Context-scoped Credentials + +## Context Links + +- MCP credentials: `internal/http/mcp_user_credentials.go`, `internal/store/pg/mcp_user_credentials.go`, `internal/mcp/manager.go` +- Secure CLI credentials: `internal/http/secure_cli_user_credentials.go`, `internal/store/pg/secure_cli.go`, `internal/tools/secure_cli*` +- Encryption patterns: `internal/crypto/`, existing encrypted store columns +- Security docs: `docs/09-security.md`, `docs/20-api-keys-auth.md` + +## Overview + +Add channel/group scoped credential overrides for MCP and Secure CLI. This phase must be stricter than grants because it stores encrypted secrets and changes runtime credential resolution. + +## Requirements + +- Store MCP and CLI context credentials in dedicated encrypted tables. +- Never return raw secret values through list/detail endpoints. +- Support create/update/delete with audit logs. +- Apply precedence: user > group/member-role > channel > agent > global. +- Clearly label whether a capability is using global, agent, channel, group, or user credentials. + +## Architecture + +- Proposed tables: + - `mcp_context_credentials` + - `secure_cli_context_credentials` +- Scope columns match Phase 4 grant tables. +- MCP credential payload follows current per-user model: API key, headers, env. +- Secure CLI credential payload follows current typed credential model and encrypted env behavior. +- Runtime resolvers should merge defaults first, then progressively apply agent/channel/group/user overrides. +- UI uses masked fields, "configured" flags, and explicit reset/delete actions. + +## Related Code Files + +- `migrations/*` +- `internal/upgrade/version.go` +- `internal/store/sqlitestore/schema.sql` +- `internal/store/sqlitestore/schema.go` +- `internal/store/mcp_store.go` +- `internal/store/secure_cli_store.go` +- `internal/mcp/manager.go` +- `internal/http/mcp_user_credentials.go` +- `internal/http/secure_cli_user_credentials.go` +- `ui/web/src/pages/channels/channel-detail/` + +## Implementation Steps + +1. TDD: add credential masking tests for HTTP list/detail responses. +2. TDD: add resolver precedence tests for global, agent, channel, group, and user credentials. +3. TDD: add audit tests or log assertions for credential create/update/delete where existing test patterns allow. +4. Add PG and SQLite encrypted credential tables and version bumps. +5. Add store interfaces and implementations using existing encryption helpers. +6. Add admin endpoints for scoped MCP credentials and scoped Secure CLI credentials. +7. Update MCP manager credential resolution with context scope input. +8. Update Secure CLI lookup/credential resolution with context scope input. +9. Add UI credential forms with masked values, configured flags, and delete/reset flows. +10. Update docs to document precedence and masking behavior. + +## Todo List + +- [ ] Add failing tests proving no raw secrets are serialized. +- [ ] Add failing runtime resolver precedence tests. +- [ ] Add PG and SQLite schema changes together. +- [ ] Add audited credential write endpoints. +- [ ] Add UI forms with masked values only. +- [ ] Update security/API docs. + +## Success Criteria + +- [ ] Admin can configure channel/group scoped MCP credentials. +- [ ] Admin can configure channel/group scoped Secure CLI credentials. +- [ ] Runtime uses scoped credentials according to documented precedence. +- [ ] UI labels credential source and override state. +- [ ] All list/detail endpoints return masked metadata only. + +## Risk Assessment + +- Risk: raw secret leakage in UI/API/logs. Mitigation: masking tests and no reveal endpoint in this phase. +- Risk: agent/global config accidentally overwritten by channel edits. Mitigation: dedicated tables and explicit scope keys. +- Risk: resolver behavior differs between MCP and CLI. Mitigation: shared precedence test cases. + +## Security Considerations + +- Encrypt all stored secret payloads using existing crypto patterns. +- Audit create/update/delete, but never audit payload values. +- Rate-limit or omit reveal flows; default is no raw reveal. + +## Next Steps + +- Phase 6 verifies Discord-specific behavior and closes docs gaps. + +## Unresolved Questions + +- Whether to support one-time reveal for newly-created secrets is intentionally out of scope unless explicitly approved. diff --git a/plans/260528-issue-66-channel-context-admin-surface/phase-06-discord-enrichment-and-verification.md b/plans/260528-issue-66-channel-context-admin-surface/phase-06-discord-enrichment-and-verification.md new file mode 100644 index 00000000..d0d3ff8b --- /dev/null +++ b/plans/260528-issue-66-channel-context-admin-surface/phase-06-discord-enrichment-and-verification.md @@ -0,0 +1,91 @@ +--- +phase: 6 +title: "Discord Enrichment and Verification" +status: pending +effort: "M" +--- + +# Phase 6: Discord Enrichment and Verification + +## Context Links + +- Discord channel: `internal/channels/discord/discord.go`, `internal/channels/discord/handler.go` +- Channel docs: `docs/05-channels-messaging.md` +- Permission matrix docs: `docs/23-ai-agent-permission-matrix.md` +- Web channel detail UI: `ui/web/src/pages/channels/channel-detail/` + +## Overview + +Validate Discord-specific group/member behavior after the generic channel context surface works. Ship either real Discord enrichment or a documented follow-up, depending on verified Discord intents and permission requirements. + +## Requirements + +- Support Discord guild/channel concepts in the channel detail page. +- If live member/role listing is available, implement it behind capability checks. +- If required intents are not available, show stored contacts and document the limitation. +- Run final backend, SQLite, and web validation. + +## Architecture + +- Reuse the Phase 2 context/member endpoints. +- Add Discord provider capability flags rather than hardcoding UI assumptions. +- Use stored contact metadata for baseline Discord user rows. +- Only add live role/member sync after verifying gateway intents, bot permissions, and API rate limits. + +## Related Code Files + +- `internal/channels/discord/discord.go` +- `internal/channels/discord/handler.go` +- `internal/channels/channel.go` +- `internal/channels/manager.go` +- `ui/web/src/pages/channels/channel-detail/` +- `docs/05-channels-messaging.md` +- `docs/23-ai-agent-permission-matrix.md` + +## Implementation Steps + +1. TDD: add Discord capability tests for stored-contact fallback and unsupported live members. +2. Verify required Discord intents and permissions against the current channel setup. +3. If safe, implement Discord `GroupMemberProvider` support for guild/channel/role views. +4. If not safe, keep fallback UI and add a follow-up issue/note with exact missing permissions. +5. Add docs for channel scoped grant and credential precedence. +6. Run focused tests: + - `go test ./internal/http ./internal/channels ./internal/store/pg` + - `go test -tags sqliteonly ./internal/store/sqlitestore` + - `go build ./...` + - `go build -tags sqliteonly ./...` + - relevant `pnpm` checks in `ui/web/` +7. Perform manual UI smoke for channel detail tabs. + +## Todo List + +- [ ] Add Discord fallback tests before behavior change. +- [ ] Verify intents/permissions before live sync. +- [ ] Implement or document Discord live member/role support. +- [ ] Update docs for final shipped behavior. +- [ ] Run backend, SQLite, and web validation. + +## Success Criteria + +- [ ] Discord channel details show guild/channel/user context without cross-tenant leakage. +- [ ] Live Discord member/role support is either implemented with verified permissions or documented as a precise follow-up. +- [ ] Final docs describe precedence: user > group/member-role > channel > agent > global. +- [ ] Final verification commands pass or failures are documented with root cause. + +## Risk Assessment + +- Risk: Discord member intent requires privileged configuration. Mitigation: capability gate and documented follow-up. +- Risk: Discord API rate limits from live member fetch. Mitigation: cache/stored contacts first; live fetch only where safe. + +## Security Considerations + +- Do not expose Discord tokens or raw provider credentials. +- Keep all context/member rows tenant and channel-instance scoped. + +## Next Steps + +- Open implementation PR after all phases pass and issue acceptance criteria are checked. + +## Unresolved Questions + +- Discord live role/member support depends on verified bot intents and permissions. diff --git a/plans/260528-issue-66-channel-context-admin-surface/plan.md b/plans/260528-issue-66-channel-context-admin-surface/plan.md new file mode 100644 index 00000000..35b8395d --- /dev/null +++ b/plans/260528-issue-66-channel-context-admin-surface/plan.md @@ -0,0 +1,50 @@ +--- +title: "Issue 66 Channel Context Admin Surface" +description: "Add channel details admin views for contexts, members, granted MCP/CLI capabilities, and scoped credential overrides." +status: pending +priority: P2 +issue: 66 +branch: "codex-issue-66-channel-details-admin-design" +tags: [channels, web-ui, security, tdd, issue-66] +blockedBy: [] +blocks: [] +created: "2026-05-28T11:49:55.757Z" +createdBy: "ck:plan" +source: skill +--- + +# Issue 66 Channel Context Admin Surface + +## Overview + +Implement issue #66 as an incremental channel-context admin surface, not a one-shot RBAC rewrite. + +The first usable milestone is read-only: channel groups/contexts, members/users, and effective MCP/CLI capability visibility. Mutations come after contracts and tests prove tenant isolation, masking, audit logging, and resolver precedence. + +Credential precedence is: user > group/member-role > channel > agent > global. This keeps user credentials most specific and avoids shared channel secrets overriding a user's explicit credential. + +Out of scope for the first implementation pass: raw credential reveal, storing MCP/CLI secrets in `channel_instances.credentials`, and full Discord role/member sync without validating required Discord intents and permissions. + +## Phases + +| Phase | Name | Status | +|-------|------|--------| +| 1 | [Research and Contracts](./phase-01-research-and-contracts.md) | Pending | +| 2 | [Read-only Context Surface](./phase-02-read-only-context-surface.md) | Pending | +| 3 | [Effective Capability Matrix](./phase-03-effective-capability-matrix.md) | Pending | +| 4 | [Context-scoped Grants](./phase-04-context-scoped-grants.md) | Pending | +| 5 | [Context-scoped Credentials](./phase-05-context-scoped-credentials.md) | Pending | +| 6 | [Discord Enrichment and Verification](./phase-06-discord-enrichment-and-verification.md) | Pending | + +## Dependencies + +- GitHub issue: https://github.com/digitopvn/goclaw/issues/66 +- Existing web UI: `ui/web/src/pages/channels/channel-detail/` +- Existing HTTP routes: `internal/http/channel_instances.go`, `internal/http/mcp_grants.go`, `internal/http/secure_cli_agent_grants.go` +- Existing stores: `internal/store/pg/`, `internal/store/sqlitestore/`, `internal/store/*mcp*`, `internal/store/*secure_cli*` +- Migration rule: PostgreSQL migrations and SQLite schema/migration map must stay in sync. +- Verification baseline: focused Go tests first, then `go build ./...`, `go build -tags sqliteonly ./...`, and relevant web checks with `pnpm` in `ui/web/`. + +## Unresolved Questions + +- Confirm whether Discord live role/member enrichment ships in v1 or stays documented after read-only context visibility. From a7b58c693377edf2bf478f7ff091465f16bc5ce4 Mon Sep 17 00:00:00 2001 From: Goon Date: Fri, 29 May 2026 09:35:14 +0700 Subject: [PATCH 004/105] fix(ci): push beta repair tags to origin --- .github/workflows/dev-beta-release.yaml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/workflows/dev-beta-release.yaml b/.github/workflows/dev-beta-release.yaml index b72e7d17..26862fa3 100644 --- a/.github/workflows/dev-beta-release.yaml +++ b/.github/workflows/dev-beta-release.yaml @@ -105,6 +105,8 @@ jobs: git config user.email "41898282+github-actions[bot]@users.noreply.github.com" if ! git rev-parse "$TAG" >/dev/null 2>&1; then git tag -a "$TAG" -m "Release $TAG" + fi + if ! git ls-remote --exit-code --tags origin "refs/tags/$TAG" >/dev/null 2>&1; then git push origin "$TAG" fi - name: Upload release notes From ea107aebc95cab12af348ee6f89c33d8b2a58477 Mon Sep 17 00:00:00 2001 From: Goon Date: Fri, 29 May 2026 10:10:07 +0700 Subject: [PATCH 005/105] test(ci): guard semantic beta versioning --- .github/workflows/ci.yaml | 10 +++ .github/workflows/dev-beta-release.yaml | 2 + scripts/ci/semantic-beta-version.test.mjs | 99 +++++++++++++++++++++++ 3 files changed, 111 insertions(+) create mode 100644 scripts/ci/semantic-beta-version.test.mjs diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index b6f0d394..4be46789 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -10,6 +10,16 @@ permissions: contents: read jobs: + release-versioning: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: 22 + - name: Release versioning tests + run: node --test scripts/ci/semantic-beta-version.test.mjs + go: runs-on: ubuntu-latest # Postgres service container for integration tests. pgvector/pgvector:pg18 diff --git a/.github/workflows/dev-beta-release.yaml b/.github/workflows/dev-beta-release.yaml index 26862fa3..8bdfdef6 100644 --- a/.github/workflows/dev-beta-release.yaml +++ b/.github/workflows/dev-beta-release.yaml @@ -91,6 +91,8 @@ jobs: - uses: actions/checkout@v4 with: fetch-depth: 0 + - name: Release versioning tests + run: node --test scripts/ci/semantic-beta-version.test.mjs - name: Fetch upstream release tags run: git fetch --force --tags https://github.com/nextlevelbuilder/goclaw.git "refs/tags/v*:refs/tags/v*" - name: Compute semantic beta version diff --git a/scripts/ci/semantic-beta-version.test.mjs b/scripts/ci/semantic-beta-version.test.mjs new file mode 100644 index 00000000..90006ead --- /dev/null +++ b/scripts/ci/semantic-beta-version.test.mjs @@ -0,0 +1,99 @@ +import assert from "node:assert/strict"; +import { execFileSync } from "node:child_process"; +import { copyFileSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { dirname, join, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; +import test from "node:test"; + +const here = dirname(fileURLToPath(import.meta.url)); +const repoRoot = resolve(here, "../.."); +const scriptPath = join(repoRoot, "scripts/ci/semantic-beta-version.mjs"); + +function run(cwd, args, env = {}) { + return execFileSync(args[0], args.slice(1), { + cwd, + encoding: "utf8", + env: { ...process.env, ...env }, + stdio: ["ignore", "pipe", "pipe"], + }).trim(); +} + +function git(cwd, ...args) { + return run(cwd, ["git", ...args]); +} + +function writeAndCommit(cwd, name, message) { + writeFileSync(join(cwd, name), `${message}\n`); + git(cwd, "add", name); + git(cwd, "commit", "-m", message); +} + +function tag(cwd, name) { + git(cwd, "tag", "-a", name, "-m", `Release ${name}`); +} + +function createRepo() { + const cwd = mkdtempSync(join(tmpdir(), "goclaw-beta-version-")); + mkdirSync(join(cwd, "scripts/ci"), { recursive: true }); + copyFileSync(scriptPath, join(cwd, "scripts/ci/semantic-beta-version.mjs")); + git(cwd, "init", "-b", "main"); + git(cwd, "config", "user.name", "Version Test"); + git(cwd, "config", "user.email", "version-test@example.com"); + + writeAndCommit(cwd, "base.txt", "fix(base): initial stable"); + tag(cwd, "v3.11.3"); + + git(cwd, "checkout", "-b", "dev"); + writeAndCommit(cwd, "old-beta.txt", "fix(runtime): old beta fix"); + tag(cwd, "v3.12.0-beta.41"); + + git(cwd, "checkout", "main"); + writeAndCommit(cwd, "stable.txt", "feat(release): stable milestone"); + tag(cwd, "v3.12.0"); + + git(cwd, "checkout", "dev"); + writeAndCommit(cwd, "current.txt", "feat(api): new work after stable"); + return cwd; +} + +function runVersionScript(cwd) { + return run(cwd, ["node", "scripts/ci/semantic-beta-version.mjs"], { + INITIAL_VERSION: "3.11.3", + PRERELEASE_ID: "beta", + }); +} + +test("bumps beta above latest stable even when stable tag is not merged into dev", () => { + const cwd = createRepo(); + try { + const output = runVersionScript(cwd); + assert.match(output, /version=3\.13\.0-beta\.1/); + assert.match(output, /tag=v3\.13\.0-beta\.1/); + } finally { + rmSync(cwd, { recursive: true, force: true }); + } +}); + +test("continues prerelease numbering when upstream already has the next beta base", () => { + const cwd = createRepo(); + try { + git(cwd, "checkout", "-b", "upstream-dev"); + writeAndCommit(cwd, "upstream.txt", "fix(vault): upstream beta work"); + tag(cwd, "v3.13.0-beta.2"); + git(cwd, "checkout", "dev"); + + const output = runVersionScript(cwd); + assert.match(output, /version=3\.13\.0-beta\.3/); + assert.match(output, /tag=v3\.13\.0-beta\.3/); + } finally { + rmSync(cwd, { recursive: true, force: true }); + } +}); + +test("dev beta workflow pushes missing origin tags even if a fetched tag exists locally", () => { + const workflow = readFileSync(join(repoRoot, ".github/workflows/dev-beta-release.yaml"), "utf8"); + assert.match(workflow, /git rev-parse "\$TAG"/); + assert.match(workflow, /git ls-remote --exit-code --tags origin "refs\/tags\/\$TAG"/); + assert.match(workflow, /git push origin "\$TAG"/); +}); From 5017f7ca5d1524624e2c2687d87a4dd2dec755e0 Mon Sep 17 00:00:00 2001 From: Goon Date: Fri, 29 May 2026 10:11:30 +0700 Subject: [PATCH 006/105] test(ci): isolate beta version output env --- scripts/ci/semantic-beta-version.test.mjs | 1 + 1 file changed, 1 insertion(+) diff --git a/scripts/ci/semantic-beta-version.test.mjs b/scripts/ci/semantic-beta-version.test.mjs index 90006ead..473323f2 100644 --- a/scripts/ci/semantic-beta-version.test.mjs +++ b/scripts/ci/semantic-beta-version.test.mjs @@ -59,6 +59,7 @@ function createRepo() { function runVersionScript(cwd) { return run(cwd, ["node", "scripts/ci/semantic-beta-version.mjs"], { + GITHUB_OUTPUT: "", INITIAL_VERSION: "3.11.3", PRERELEASE_ID: "beta", }); From d2302282176e8e6f447b73a4e706e252852102cf Mon Sep 17 00:00:00 2001 From: Goon Date: Fri, 29 May 2026 19:11:08 +0700 Subject: [PATCH 007/105] feat(channels): add channel context scope contracts --- internal/store/channel_context_store.go | 136 ++++++++++++++++++++++++ internal/store/contact_store.go | 13 +-- internal/store/context.go | 2 + internal/store/pg/channel_contacts.go | 6 +- internal/store/run_context.go | 1 + internal/store/sqlitestore/contacts.go | 4 + 6 files changed, 155 insertions(+), 7 deletions(-) create mode 100644 internal/store/channel_context_store.go diff --git a/internal/store/channel_context_store.go b/internal/store/channel_context_store.go new file mode 100644 index 00000000..c6775e9e --- /dev/null +++ b/internal/store/channel_context_store.go @@ -0,0 +1,136 @@ +package store + +import ( + "context" + "encoding/json" + "time" + + "github.com/google/uuid" +) + +const ( + ChannelScopeTypeChannel = "channel" + ChannelScopeTypeGroup = "group" + ChannelScopeTypeUser = "user" + ChannelScopeTypeRole = "role" +) + +// ChannelContextScope identifies the channel-level context used for scoped +// grants and credentials. ChannelInstanceName lets runtime code resolve scope +// without a channel_instances lookup at the call site. +type ChannelContextScope struct { + ChannelInstanceID uuid.UUID `json:"channel_instance_id,omitempty"` + ChannelInstanceName string `json:"channel_instance_name,omitempty"` + ScopeType string `json:"scope_type"` + ScopeKey string `json:"scope_key"` +} + +func (s ChannelContextScope) Valid() bool { + if s.ScopeType == "" { + return false + } + if s.ChannelInstanceID == uuid.Nil && s.ChannelInstanceName == "" { + return false + } + return s.ScopeType == ChannelScopeTypeChannel || s.ScopeKey != "" +} + +func WithChannelContextScope(ctx context.Context, scope ChannelContextScope) context.Context { + return context.WithValue(ctx, ChannelContextScopeKey, scope) +} + +func ChannelContextScopeFromContext(ctx context.Context) (ChannelContextScope, bool) { + if v, ok := ctx.Value(ChannelContextScopeKey).(ChannelContextScope); ok && v.Valid() { + return v, true + } + if rc := RunContextFromCtx(ctx); rc != nil && rc.ChannelContextScope.Valid() { + return rc.ChannelContextScope, true + } + return ChannelContextScope{}, false +} + +type MCPContextGrant struct { + ID uuid.UUID `json:"id" db:"id"` + ChannelInstanceID uuid.UUID `json:"channel_instance_id" db:"channel_instance_id"` + ScopeType string `json:"scope_type" db:"scope_type"` + ScopeKey string `json:"scope_key" db:"scope_key"` + ServerID uuid.UUID `json:"server_id" db:"server_id"` + Enabled bool `json:"enabled" db:"enabled"` + ToolAllow json.RawMessage `json:"tool_allow,omitempty" db:"tool_allow"` + ToolDeny json.RawMessage `json:"tool_deny,omitempty" db:"tool_deny"` + ConfigOverrides json.RawMessage `json:"config_overrides,omitempty" db:"config_overrides"` + GrantedBy string `json:"granted_by" db:"granted_by"` + CreatedAt time.Time `json:"created_at" db:"created_at"` + UpdatedAt time.Time `json:"updated_at" db:"updated_at"` +} + +type MCPContextCredentials struct { + ID uuid.UUID `json:"id" db:"id"` + ChannelInstanceID uuid.UUID `json:"channel_instance_id" db:"channel_instance_id"` + ScopeType string `json:"scope_type" db:"scope_type"` + ScopeKey string `json:"scope_key" db:"scope_key"` + ServerID uuid.UUID `json:"server_id" db:"server_id"` + APIKey string `json:"api_key,omitempty" db:"-"` + Headers map[string]string `json:"headers,omitempty" db:"-"` + Env map[string]string `json:"env,omitempty" db:"-"` + CreatedBy string `json:"created_by" db:"created_by"` + CreatedAt time.Time `json:"created_at" db:"created_at"` + UpdatedAt time.Time `json:"updated_at" db:"updated_at"` +} + +type SecureCLIContextGrant struct { + ID uuid.UUID `json:"id" db:"id"` + ChannelInstanceID uuid.UUID `json:"channel_instance_id" db:"channel_instance_id"` + ScopeType string `json:"scope_type" db:"scope_type"` + ScopeKey string `json:"scope_key" db:"scope_key"` + BinaryID uuid.UUID `json:"binary_id" db:"binary_id"` + Enabled bool `json:"enabled" db:"enabled"` + DenyArgs *json.RawMessage `json:"deny_args,omitempty" db:"deny_args"` + DenyVerbose *json.RawMessage `json:"deny_verbose,omitempty" db:"deny_verbose"` + TimeoutSeconds *int `json:"timeout_seconds,omitempty" db:"timeout_seconds"` + Tips *string `json:"tips,omitempty" db:"tips"` + EncryptedEnv []byte `json:"-" db:"encrypted_env"` + EnvKeys []string `json:"env_keys,omitempty" db:"-"` + Env map[string]SecureCLIEnvResponseEntry `json:"env,omitempty" db:"-"` + EnvSet bool `json:"env_set" db:"-"` + GrantedBy string `json:"granted_by" db:"granted_by"` + CreatedAt time.Time `json:"created_at" db:"created_at"` + UpdatedAt time.Time `json:"updated_at" db:"updated_at"` +} + +type SecureCLIContextCredentials struct { + ID uuid.UUID `json:"id" db:"id"` + ChannelInstanceID uuid.UUID `json:"channel_instance_id" db:"channel_instance_id"` + ScopeType string `json:"scope_type" db:"scope_type"` + ScopeKey string `json:"scope_key" db:"scope_key"` + BinaryID uuid.UUID `json:"binary_id" db:"binary_id"` + EncryptedEnv []byte `json:"-" db:"encrypted_env"` + Metadata json.RawMessage `json:"metadata,omitempty" db:"metadata"` + CredentialType *string `json:"credential_type,omitempty" db:"credential_type"` + HostScope *string `json:"host_scope,omitempty" db:"host_scope"` + CreatedBy string `json:"created_by" db:"created_by"` + CreatedAt time.Time `json:"created_at" db:"created_at"` + UpdatedAt time.Time `json:"updated_at" db:"updated_at"` +} + +type MCPContextAdminStore interface { + UpsertContextGrant(ctx context.Context, grant *MCPContextGrant) error + DeleteContextGrant(ctx context.Context, channelInstanceID uuid.UUID, scopeType, scopeKey string, serverID uuid.UUID) error + ListContextGrants(ctx context.Context, channelInstanceID uuid.UUID, scopeType, scopeKey string) ([]MCPContextGrant, error) + + SetContextCredentials(ctx context.Context, creds *MCPContextCredentials) error + GetContextCredentials(ctx context.Context, channelInstanceID uuid.UUID, scopeType, scopeKey string, serverID uuid.UUID) (*MCPContextCredentials, error) + DeleteContextCredentials(ctx context.Context, channelInstanceID uuid.UUID, scopeType, scopeKey string, serverID uuid.UUID) error + ListContextCredentials(ctx context.Context, channelInstanceID uuid.UUID, scopeType, scopeKey string) ([]MCPContextCredentials, error) +} + +type SecureCLIContextAdminStore interface { + UpsertContextGrant(ctx context.Context, grant *SecureCLIContextGrant) error + DeleteContextGrant(ctx context.Context, channelInstanceID uuid.UUID, scopeType, scopeKey string, binaryID uuid.UUID) error + ListContextGrants(ctx context.Context, channelInstanceID uuid.UUID, scopeType, scopeKey string) ([]SecureCLIContextGrant, error) + + SetContextCredentials(ctx context.Context, creds *SecureCLIContextCredentials) error + GetContextCredentials(ctx context.Context, channelInstanceID uuid.UUID, scopeType, scopeKey string, binaryID uuid.UUID) (*SecureCLIContextCredentials, error) + DeleteContextCredentials(ctx context.Context, channelInstanceID uuid.UUID, scopeType, scopeKey string, binaryID uuid.UUID) error + ListContextCredentials(ctx context.Context, channelInstanceID uuid.UUID, scopeType, scopeKey string) ([]SecureCLIContextCredentials, error) +} diff --git a/internal/store/contact_store.go b/internal/store/contact_store.go index 62b6ff5b..584d3d13 100644 --- a/internal/store/contact_store.go +++ b/internal/store/contact_store.go @@ -29,12 +29,13 @@ type ChannelContact struct { // ContactListOpts holds pagination and filter options for listing contacts. type ContactListOpts struct { - Search string // ILIKE on display_name, username, sender_id - ChannelType string // filter by platform (telegram, discord, etc.) - PeerKind string // "direct" or "group" - ContactType string // "user" or "group" - Limit int - Offset int + Search string // ILIKE on display_name, username, sender_id + ChannelType string // filter by platform (telegram, discord, etc.) + ChannelInstance string // filter by channel instance name + PeerKind string // "direct" or "group" + ContactType string // "user" or "group" + Limit int + Offset int } // ContactStore manages channel contacts (auto-collected user info). diff --git a/internal/store/context.go b/internal/store/context.go index 4b0f3a78..50111216 100644 --- a/internal/store/context.go +++ b/internal/store/context.go @@ -50,6 +50,8 @@ const ( CredentialUserIDKey contextKey = "goclaw_credential_user_id" // SenderNameKey is the display name from channel metadata (for bootstrap auto-contact). SenderNameKey contextKey = "goclaw_sender_name" + // ChannelContextScopeKey carries the channel/group/user scope for runtime grants and credentials. + ChannelContextScopeKey contextKey = "goclaw_channel_context_scope" // AgentAudioKey carries the immutable agent audio snapshot for TTS tool dispatch. AgentAudioKey contextKey = "goclaw_agent_audio" ) diff --git a/internal/store/pg/channel_contacts.go b/internal/store/pg/channel_contacts.go index 2563b090..92788091 100644 --- a/internal/store/pg/channel_contacts.go +++ b/internal/store/pg/channel_contacts.go @@ -11,7 +11,6 @@ import ( "github.com/nextlevelbuilder/goclaw/internal/store" ) - // PGContactStore implements store.ContactStore backed by Postgres. type PGContactStore struct { db *sql.DB @@ -64,6 +63,11 @@ func contactWhereClause(ctx context.Context, opts store.ContactListOpts) (string args = append(args, opts.ChannelType) argIdx++ } + if opts.ChannelInstance != "" { + conditions = append(conditions, fmt.Sprintf("channel_instance = $%d", argIdx)) + args = append(args, opts.ChannelInstance) + argIdx++ + } if opts.PeerKind != "" { conditions = append(conditions, fmt.Sprintf("peer_kind = $%d", argIdx)) args = append(args, opts.PeerKind) diff --git a/internal/store/run_context.go b/internal/store/run_context.go index 2e05a356..2c825571 100644 --- a/internal/store/run_context.go +++ b/internal/store/run_context.go @@ -40,6 +40,7 @@ type RunContext struct { // Tool configuration BuiltinToolSettings map[string][]byte ChannelType string + ChannelContextScope ChannelContextScope SubagentsCfg *config.SubagentsConfig ParentModel string ParentProvider string diff --git a/internal/store/sqlitestore/contacts.go b/internal/store/sqlitestore/contacts.go index a48aabe7..f0db1857 100644 --- a/internal/store/sqlitestore/contacts.go +++ b/internal/store/sqlitestore/contacts.go @@ -71,6 +71,10 @@ func contactWhereSQLite(ctx context.Context, opts store.ContactListOpts) (string conditions = append(conditions, "channel_type = ?") args = append(args, opts.ChannelType) } + if opts.ChannelInstance != "" { + conditions = append(conditions, "channel_instance = ?") + args = append(args, opts.ChannelInstance) + } if opts.PeerKind != "" { conditions = append(conditions, "peer_kind = ?") args = append(args, opts.PeerKind) From 29f3eb3874791d36bead6e58bd4f268439169f2f Mon Sep 17 00:00:00 2001 From: Goon Date: Fri, 29 May 2026 19:11:17 +0700 Subject: [PATCH 008/105] feat(channels): expose read-only channel contexts --- internal/http/channel_instance_contexts.go | 215 ++++++++++++++++++ .../http/channel_instance_contexts_test.go | 189 +++++++++++++++ internal/http/channel_instances.go | 8 + 3 files changed, 412 insertions(+) create mode 100644 internal/http/channel_instance_contexts.go create mode 100644 internal/http/channel_instance_contexts_test.go diff --git a/internal/http/channel_instance_contexts.go b/internal/http/channel_instance_contexts.go new file mode 100644 index 00000000..a122bf69 --- /dev/null +++ b/internal/http/channel_instance_contexts.go @@ -0,0 +1,215 @@ +package http + +import ( + "log/slog" + "net/http" + "strings" + "time" + + "github.com/google/uuid" + + "github.com/nextlevelbuilder/goclaw/internal/i18n" + "github.com/nextlevelbuilder/goclaw/internal/store" + "github.com/nextlevelbuilder/goclaw/pkg/protocol" +) + +type channelContextDTO struct { + ChannelInstanceID string `json:"channel_instance_id"` + ChannelType string `json:"channel_type"` + ScopeType string `json:"scope_type"` + ScopeKey string `json:"scope_key"` + DisplayName string `json:"display_name"` + Source string `json:"source"` + PeerKind string `json:"peer_kind,omitempty"` + ContactType string `json:"contact_type,omitempty"` + LastSeenAt *time.Time `json:"last_seen_at,omitempty"` + MemberCount int `json:"member_count,omitempty"` + LiveMembers bool `json:"live_members_supported"` +} + +type channelContextMemberDTO struct { + UserID string `json:"user_id"` + DisplayName string `json:"display_name,omitempty"` + Username string `json:"username,omitempty"` + PlatformID string `json:"platform_id"` + Source string `json:"source"` + Status string `json:"membership_status"` + LastSeenAt *time.Time `json:"last_seen_at,omitempty"` +} + +func (h *ChannelInstancesHandler) handleListContexts(w http.ResponseWriter, r *http.Request) { + inst, ok := h.resolveInstance(w, r) + if !ok { + return + } + if h.contactStore == nil { + writeJSON(w, http.StatusOK, map[string]any{ + "contexts": []channelContextDTO{baseChannelContextDTO(*inst)}, + }) + return + } + + opts := store.ContactListOpts{ + ChannelType: inst.ChannelType, + ChannelInstance: inst.Name, + ContactType: "group", + Limit: 200, + } + contacts, err := h.contactStore.ListContacts(r.Context(), opts) + if err != nil { + slog.Error("channel_instances.contexts", "error", err) + locale := store.LocaleFromContext(r.Context()) + writeError(w, http.StatusInternalServerError, protocol.ErrInternal, i18n.T(locale, i18n.MsgFailedToList, "channel contexts")) + return + } + + contexts := []channelContextDTO{baseChannelContextDTO(*inst)} + for _, c := range contacts { + ctxDTO := contextDTOFromContact(inst.ID, c) + contexts = append(contexts, ctxDTO) + } + + writeJSON(w, http.StatusOK, map[string]any{"contexts": contexts}) +} + +func (h *ChannelInstancesHandler) handleListContextMembers(w http.ResponseWriter, r *http.Request) { + inst, ok := h.resolveInstance(w, r) + if !ok { + return + } + + scopeType := strings.TrimSpace(r.PathValue("scopeType")) + scopeKey := strings.TrimSpace(r.PathValue("scopeKey")) + if scopeType == "" || scopeKey == "" { + locale := store.LocaleFromContext(r.Context()) + writeError(w, http.StatusBadRequest, protocol.ErrInvalidRequest, i18n.T(locale, i18n.MsgRequired, "scopeType and scopeKey")) + return + } + + var liveErr string + members := make([]channelContextMemberDTO, 0) + if h.channelMgr != nil && scopeType == store.ChannelScopeTypeGroup { + if live, err := h.channelMgr.ListGroupMembers(r.Context(), inst.Name, scopeKey); err == nil { + for _, m := range live { + members = append(members, channelContextMemberDTO{ + UserID: m.MemberID, + DisplayName: m.Name, + PlatformID: m.MemberID, + Source: "live_provider", + Status: "visible", + }) + } + writeJSON(w, http.StatusOK, map[string]any{ + "members": members, + "live_members_supported": true, + "source": "live_provider", + }) + return + } else { + liveErr = err.Error() + } + } + + if h.contactStore != nil { + contacts, err := h.contactStore.ListContacts(r.Context(), store.ContactListOpts{ + ChannelType: inst.ChannelType, + ChannelInstance: inst.Name, + ContactType: "user", + PeerKind: peerKindForScope(scopeType), + Limit: 200, + }) + if err != nil { + slog.Error("channel_instances.context_members", "error", err) + locale := store.LocaleFromContext(r.Context()) + writeError(w, http.StatusInternalServerError, protocol.ErrInternal, i18n.T(locale, i18n.MsgFailedToList, "channel members")) + return + } + for _, c := range contacts { + members = append(members, memberDTOFromContact(c)) + } + } + + writeJSON(w, http.StatusOK, map[string]any{ + "members": members, + "live_members_supported": false, + "source": "stored_contacts", + "unsupported_reason": liveErr, + }) +} + +func baseChannelContextDTO(inst store.ChannelInstanceData) channelContextDTO { + name := inst.DisplayName + if name == "" { + name = inst.Name + } + return channelContextDTO{ + ChannelInstanceID: inst.ID.String(), + ChannelType: inst.ChannelType, + ScopeType: store.ChannelScopeTypeChannel, + ScopeKey: inst.Name, + DisplayName: name, + Source: "channel_instance", + PeerKind: "direct", + ContactType: "channel", + LiveMembers: false, + } +} + +func contextDTOFromContact(instanceID uuid.UUID, c store.ChannelContact) channelContextDTO { + scopeType := store.ChannelScopeTypeGroup + if c.ContactType == "topic" { + scopeType = "topic" + } + displayName := c.SenderID + if c.DisplayName != nil && *c.DisplayName != "" { + displayName = *c.DisplayName + } + peerKind := "" + if c.PeerKind != nil { + peerKind = *c.PeerKind + } + lastSeen := c.LastSeenAt + return channelContextDTO{ + ChannelInstanceID: instanceID.String(), + ChannelType: c.ChannelType, + ScopeType: scopeType, + ScopeKey: c.SenderID, + DisplayName: displayName, + Source: "stored_contact", + PeerKind: peerKind, + ContactType: c.ContactType, + LastSeenAt: &lastSeen, + } +} + +func memberDTOFromContact(c store.ChannelContact) channelContextMemberDTO { + displayName := "" + if c.DisplayName != nil { + displayName = *c.DisplayName + } + username := "" + if c.Username != nil { + username = *c.Username + } + userID := c.SenderID + if c.UserID != nil && *c.UserID != "" { + userID = *c.UserID + } + lastSeen := c.LastSeenAt + return channelContextMemberDTO{ + UserID: userID, + DisplayName: displayName, + Username: username, + PlatformID: c.SenderID, + Source: "stored_contact", + Status: "visible", + LastSeenAt: &lastSeen, + } +} + +func peerKindForScope(scopeType string) string { + if scopeType == store.ChannelScopeTypeGroup || scopeType == "topic" { + return "group" + } + return "direct" +} diff --git a/internal/http/channel_instance_contexts_test.go b/internal/http/channel_instance_contexts_test.go new file mode 100644 index 00000000..6a9ae77b --- /dev/null +++ b/internal/http/channel_instance_contexts_test.go @@ -0,0 +1,189 @@ +package http + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/google/uuid" + + "github.com/nextlevelbuilder/goclaw/internal/crypto" + "github.com/nextlevelbuilder/goclaw/internal/store" +) + +type fakeChannelContextContactStore struct { + contacts []store.ChannelContact + seenOpts []store.ContactListOpts +} + +func (s *fakeChannelContextContactStore) UpsertContact(context.Context, string, string, string, string, string, string, string, string, string, string) error { + return nil +} + +func (s *fakeChannelContextContactStore) ListContacts(_ context.Context, opts store.ContactListOpts) ([]store.ChannelContact, error) { + s.seenOpts = append(s.seenOpts, opts) + var out []store.ChannelContact + for _, c := range s.contacts { + if opts.ChannelType != "" && c.ChannelType != opts.ChannelType { + continue + } + if opts.ChannelInstance != "" && (c.ChannelInstance == nil || *c.ChannelInstance != opts.ChannelInstance) { + continue + } + if opts.ContactType != "" && c.ContactType != opts.ContactType { + continue + } + if opts.PeerKind != "" && (c.PeerKind == nil || *c.PeerKind != opts.PeerKind) { + continue + } + out = append(out, c) + } + return out, nil +} + +func (s *fakeChannelContextContactStore) CountContacts(context.Context, store.ContactListOpts) (int, error) { + return len(s.contacts), nil +} + +func (s *fakeChannelContextContactStore) GetContactsBySenderIDs(context.Context, []string) (map[string]store.ChannelContact, error) { + return map[string]store.ChannelContact{}, nil +} + +func (s *fakeChannelContextContactStore) GetContactByID(context.Context, uuid.UUID) (*store.ChannelContact, error) { + return nil, nil +} + +func (s *fakeChannelContextContactStore) GetSenderIDsByContactIDs(context.Context, []uuid.UUID) ([]string, error) { + return nil, nil +} + +func (s *fakeChannelContextContactStore) MergeContacts(context.Context, []uuid.UUID, uuid.UUID) error { + return nil +} + +func (s *fakeChannelContextContactStore) UnmergeContacts(context.Context, []uuid.UUID) error { + return nil +} + +func (s *fakeChannelContextContactStore) GetContactsByMergedID(context.Context, uuid.UUID) ([]store.ChannelContact, error) { + return nil, nil +} + +func (s *fakeChannelContextContactStore) ResolveTenantUserID(context.Context, string, string) (string, error) { + return "", nil +} + +func TestChannelContextsListUsesStoredGroupsAndMasksToContextShape(t *testing.T) { + token := "channel-contexts-key" + setupTestCache(t, map[string]*store.APIKeyData{ + crypto.HashAPIKey(token): {Scopes: []string{"operator.write"}, OwnerID: "caller"}, + }) + + instID := uuid.New() + channelName := "discord-admin" + displayName := "Ops Discord" + peerKind := "group" + lastSeen := time.Date(2026, 5, 29, 10, 0, 0, 0, time.UTC) + contacts := &fakeChannelContextContactStore{contacts: []store.ChannelContact{{ + ID: uuid.New(), + ChannelType: "discord", + ChannelInstance: &channelName, + SenderID: "guild-123/channel-456", + DisplayName: &displayName, + PeerKind: &peerKind, + ContactType: "group", + LastSeenAt: lastSeen, + }}} + handler := NewChannelInstancesHandler( + &stubChannelInstanceStore{inst: &store.ChannelInstanceData{BaseModel: store.BaseModel{ID: instID}, Name: channelName, DisplayName: "Discord Admin", ChannelType: "discord", AgentID: uuid.New()}}, + nil, nil, contacts, nil, nil, + ) + mux := http.NewServeMux() + handler.RegisterRoutes(mux) + + req := httptest.NewRequest(http.MethodGet, "/v1/channels/instances/"+instID.String()+"/contexts", nil) + req.Header.Set("Authorization", "Bearer "+token) + rec := httptest.NewRecorder() + mux.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want 200; body=%s", rec.Code, rec.Body.String()) + } + var body struct { + Contexts []channelContextDTO `json:"contexts"` + } + if err := json.NewDecoder(rec.Body).Decode(&body); err != nil { + t.Fatal(err) + } + if len(body.Contexts) != 2 { + t.Fatalf("contexts len = %d, want 2: %+v", len(body.Contexts), body.Contexts) + } + if body.Contexts[0].ScopeType != store.ChannelScopeTypeChannel || body.Contexts[0].ScopeKey != channelName { + t.Fatalf("base context = %+v", body.Contexts[0]) + } + gotGroup := body.Contexts[1] + if gotGroup.ScopeType != store.ChannelScopeTypeGroup || gotGroup.ScopeKey != "guild-123/channel-456" || gotGroup.Source != "stored_contact" { + t.Fatalf("group context = %+v", gotGroup) + } + if len(contacts.seenOpts) == 0 || contacts.seenOpts[0].ChannelInstance != channelName { + t.Fatalf("expected channel_instance filter %q, got %+v", channelName, contacts.seenOpts) + } +} + +func TestChannelContextMembersUseStoredContactsWhenLiveProviderUnavailable(t *testing.T) { + token := "channel-context-members-key" + setupTestCache(t, map[string]*store.APIKeyData{ + crypto.HashAPIKey(token): {Scopes: []string{"operator.write"}, OwnerID: "caller"}, + }) + + instID := uuid.New() + channelName := "telegram-main" + peerKind := "group" + displayName := "Duy" + username := "duy" + userID := "tenant-user-1" + contacts := &fakeChannelContextContactStore{contacts: []store.ChannelContact{{ + ID: uuid.New(), + ChannelType: "telegram", + ChannelInstance: &channelName, + SenderID: "386246614", + UserID: &userID, + DisplayName: &displayName, + Username: &username, + PeerKind: &peerKind, + ContactType: "user", + LastSeenAt: time.Date(2026, 5, 29, 10, 5, 0, 0, time.UTC), + }}} + handler := NewChannelInstancesHandler( + &stubChannelInstanceStore{inst: &store.ChannelInstanceData{BaseModel: store.BaseModel{ID: instID}, Name: channelName, ChannelType: "telegram", AgentID: uuid.New()}}, + nil, nil, contacts, nil, nil, + ) + mux := http.NewServeMux() + handler.RegisterRoutes(mux) + + req := httptest.NewRequest(http.MethodGet, "/v1/channels/instances/"+instID.String()+"/contexts/group/group:telegram:-100123/members", nil) + req.Header.Set("Authorization", "Bearer "+token) + rec := httptest.NewRecorder() + mux.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want 200; body=%s", rec.Code, rec.Body.String()) + } + var body struct { + Members []channelContextMemberDTO `json:"members"` + LiveMembersSupported bool `json:"live_members_supported"` + Source string `json:"source"` + } + if err := json.NewDecoder(rec.Body).Decode(&body); err != nil { + t.Fatal(err) + } + if body.LiveMembersSupported || body.Source != "stored_contacts" { + t.Fatalf("expected stored contact fallback, got %+v", body) + } + if len(body.Members) != 1 || body.Members[0].UserID != userID || body.Members[0].PlatformID != "386246614" { + t.Fatalf("members = %+v", body.Members) + } +} diff --git a/internal/http/channel_instances.go b/internal/http/channel_instances.go index ac88145a..f16b29d8 100644 --- a/internal/http/channel_instances.go +++ b/internal/http/channel_instances.go @@ -109,6 +109,11 @@ func (h *ChannelInstancesHandler) RegisterRoutes(mux *http.ServeMux) { mux.HandleFunc("POST /v1/channels/instances/{id}/writers", h.adminAuth(h.handleAddWriter)) mux.HandleFunc("DELETE /v1/channels/instances/{id}/writers/{userId}", h.adminAuth(h.handleRemoveWriter)) } + + if h.contactStore != nil { + mux.HandleFunc("GET /v1/channels/instances/{id}/contexts", h.auth(h.handleListContexts)) + mux.HandleFunc("GET /v1/channels/instances/{id}/contexts/{scopeType}/{scopeKey}/members", h.auth(h.handleListContextMembers)) + } } func (h *ChannelInstancesHandler) auth(next http.HandlerFunc) http.HandlerFunc { @@ -631,6 +636,9 @@ func (h *ChannelInstancesHandler) handleListContacts(w http.ResponseWriter, r *h if v := r.URL.Query().Get("channel_type"); v != "" { opts.ChannelType = v } + if v := r.URL.Query().Get("channel_instance"); v != "" { + opts.ChannelInstance = v + } if v := r.URL.Query().Get("peer_kind"); v != "" { opts.PeerKind = v } From f20d776ebb9693a7a3308ddbb8c37c8c4d85733b Mon Sep 17 00:00:00 2001 From: Goon Date: Fri, 29 May 2026 19:17:18 +0700 Subject: [PATCH 009/105] feat(channels): expose context capability matrix --- cmd/gateway_http_handlers.go | 1 + .../http/channel_instance_capabilities.go | 143 ++++++++++++ .../channel_instance_capabilities_test.go | 210 ++++++++++++++++++ internal/http/channel_instances.go | 10 + 4 files changed, 364 insertions(+) create mode 100644 internal/http/channel_instance_capabilities.go create mode 100644 internal/http/channel_instance_capabilities_test.go diff --git a/cmd/gateway_http_handlers.go b/cmd/gateway_http_handlers.go index 7d7cb532..c33a9f17 100644 --- a/cmd/gateway_http_handlers.go +++ b/cmd/gateway_http_handlers.go @@ -62,6 +62,7 @@ func wireHTTP(stores *store.Stores, defaultWorkspace, dataDir, bundledSkillsDir if stores != nil && stores.ChannelInstances != nil { channelInstancesH = httpapi.NewChannelInstancesHandler(stores.ChannelInstances, stores.Agents, stores.ConfigPermissions, stores.Contacts, stores.Tenants, msgBus) + channelInstancesH.SetCapabilityStores(stores.MCP, stores.SecureCLI) } if stores != nil && stores.Providers != nil { diff --git a/internal/http/channel_instance_capabilities.go b/internal/http/channel_instance_capabilities.go new file mode 100644 index 00000000..9891a1ed --- /dev/null +++ b/internal/http/channel_instance_capabilities.go @@ -0,0 +1,143 @@ +package http + +import ( + "log/slog" + "net/http" + + "github.com/nextlevelbuilder/goclaw/internal/i18n" + "github.com/nextlevelbuilder/goclaw/internal/store" + "github.com/nextlevelbuilder/goclaw/pkg/protocol" +) + +type channelCapabilityDTO struct { + Type string `json:"type"` + ID string `json:"id"` + Name string `json:"name"` + DisplayName string `json:"display_name,omitempty"` + Enabled bool `json:"enabled"` + Source string `json:"source"` + ToolAllow []string `json:"tool_allow,omitempty"` + ToolDeny []string `json:"tool_deny,omitempty"` + CredentialSource string `json:"credential_source"` + HasCredential bool `json:"has_credential"` + ContextGrant bool `json:"context_grant_configured"` + ContextCredentials bool `json:"context_credentials_configured"` +} + +func (h *ChannelInstancesHandler) handleListContextCapabilities(w http.ResponseWriter, r *http.Request) { + inst, ok := h.resolveInstance(w, r) + if !ok { + return + } + scopeType := r.PathValue("scopeType") + scopeKey := r.PathValue("scopeKey") + if scopeType == "" || scopeKey == "" { + locale := store.LocaleFromContext(r.Context()) + writeError(w, http.StatusBadRequest, protocol.ErrInvalidRequest, i18n.T(locale, i18n.MsgRequired, "scopeType and scopeKey")) + return + } + + userID := r.URL.Query().Get("user_id") + if userID == "" { + userID = store.CredentialUserIDFromContext(r.Context()) + } + + mcpRows, err := h.listChannelMCPRows(r, inst, userID) + if err != nil { + slog.Error("channel_instances.capabilities_mcp", "error", err) + locale := store.LocaleFromContext(r.Context()) + writeError(w, http.StatusInternalServerError, protocol.ErrInternal, i18n.T(locale, i18n.MsgFailedToList, "MCP capabilities")) + return + } + cliRows, err := h.listChannelCLIRows(r, inst, userID) + if err != nil { + slog.Error("channel_instances.capabilities_cli", "error", err) + locale := store.LocaleFromContext(r.Context()) + writeError(w, http.StatusInternalServerError, protocol.ErrInternal, i18n.T(locale, i18n.MsgFailedToList, "CLI capabilities")) + return + } + + writeJSON(w, http.StatusOK, map[string]any{ + "scope_type": scopeType, + "scope_key": scopeKey, + "capabilities": append(mcpRows, cliRows...), + "mcp": mcpRows, + "secure_cli": cliRows, + }) +} + +func (h *ChannelInstancesHandler) listChannelMCPRows(r *http.Request, inst *store.ChannelInstanceData, userID string) ([]channelCapabilityDTO, error) { + if h.mcpStore == nil { + return []channelCapabilityDTO{}, nil + } + accessible, err := h.mcpStore.ListAccessible(r.Context(), inst.AgentID, userID) + if err != nil { + return nil, err + } + rows := make([]channelCapabilityDTO, 0, len(accessible)) + for _, info := range accessible { + credentialSource := "global" + hasCredential := info.Server.APIKey != "" || len(info.Server.Headers) > 0 || len(info.Server.Env) > 0 + if userID != "" { + if creds, err := h.mcpStore.GetUserCredentials(r.Context(), info.Server.ID, userID); err == nil && creds != nil { + if creds.APIKey != "" || len(creds.Headers) > 0 || len(creds.Env) > 0 { + credentialSource = "user" + hasCredential = true + } + } + } + rows = append(rows, channelCapabilityDTO{ + Type: "mcp_server", + ID: info.Server.ID.String(), + Name: info.Server.Name, + DisplayName: info.Server.DisplayName, + Enabled: info.Server.Enabled, + Source: "agent", + ToolAllow: info.ToolAllow, + ToolDeny: info.ToolDeny, + CredentialSource: credentialSource, + HasCredential: hasCredential, + ContextGrant: false, + ContextCredentials: false, + }) + } + return rows, nil +} + +func (h *ChannelInstancesHandler) listChannelCLIRows(r *http.Request, inst *store.ChannelInstanceData, userID string) ([]channelCapabilityDTO, error) { + if h.secureCLIStore == nil { + return []channelCapabilityDTO{}, nil + } + binaries, err := h.secureCLIStore.ListForAgent(r.Context(), inst.AgentID) + if err != nil { + return nil, err + } + rows := make([]channelCapabilityDTO, 0, len(binaries)) + for _, b := range binaries { + source := "agent" + if b.IsGlobal { + source = "global" + } + credentialSource := source + hasCredential := len(b.EncryptedEnv) > 0 + if userID != "" { + if creds, err := h.secureCLIStore.GetUserCredentials(r.Context(), b.ID, userID); err == nil && creds != nil && len(creds.EncryptedEnv) > 0 { + credentialSource = "user" + hasCredential = true + } + } + rows = append(rows, channelCapabilityDTO{ + Type: "secure_cli", + ID: b.ID.String(), + Name: b.BinaryName, + DisplayName: b.Description, + Enabled: b.Enabled, + Source: source, + CredentialSource: credentialSource, + HasCredential: hasCredential, + ContextGrant: false, + ContextCredentials: false, + }) + } + return rows, nil +} diff --git a/internal/http/channel_instance_capabilities_test.go b/internal/http/channel_instance_capabilities_test.go new file mode 100644 index 00000000..c7ba31cf --- /dev/null +++ b/internal/http/channel_instance_capabilities_test.go @@ -0,0 +1,210 @@ +package http + +import ( + "context" + "database/sql" + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/google/uuid" + + "github.com/nextlevelbuilder/goclaw/internal/crypto" + "github.com/nextlevelbuilder/goclaw/internal/store" +) + +type fakeChannelCapabilityMCPStore struct { + accessible []store.MCPAccessInfo + userCreds map[uuid.UUID]*store.MCPUserCredentials +} + +func (s *fakeChannelCapabilityMCPStore) CreateServer(context.Context, *store.MCPServerData) error { + return nil +} +func (s *fakeChannelCapabilityMCPStore) GetServer(context.Context, uuid.UUID) (*store.MCPServerData, error) { + return nil, sql.ErrNoRows +} +func (s *fakeChannelCapabilityMCPStore) GetServerByName(context.Context, string) (*store.MCPServerData, error) { + return nil, sql.ErrNoRows +} +func (s *fakeChannelCapabilityMCPStore) ListServers(context.Context) ([]store.MCPServerData, error) { + return nil, nil +} +func (s *fakeChannelCapabilityMCPStore) UpdateServer(context.Context, uuid.UUID, map[string]any) error { + return nil +} +func (s *fakeChannelCapabilityMCPStore) DeleteServer(context.Context, uuid.UUID) error { + return nil +} +func (s *fakeChannelCapabilityMCPStore) GrantToAgent(context.Context, *store.MCPAgentGrant) error { + return nil +} +func (s *fakeChannelCapabilityMCPStore) RevokeFromAgent(context.Context, uuid.UUID, uuid.UUID) error { + return nil +} +func (s *fakeChannelCapabilityMCPStore) ListAgentGrants(context.Context, uuid.UUID) ([]store.MCPAgentGrant, error) { + return nil, nil +} +func (s *fakeChannelCapabilityMCPStore) ListServerGrants(context.Context, uuid.UUID) ([]store.MCPAgentGrant, error) { + return nil, nil +} +func (s *fakeChannelCapabilityMCPStore) GrantToUser(context.Context, *store.MCPUserGrant) error { + return nil +} +func (s *fakeChannelCapabilityMCPStore) RevokeFromUser(context.Context, uuid.UUID, string) error { + return nil +} +func (s *fakeChannelCapabilityMCPStore) CountAgentGrantsByServer(context.Context) (map[uuid.UUID]int, error) { + return map[uuid.UUID]int{}, nil +} +func (s *fakeChannelCapabilityMCPStore) ListAccessible(context.Context, uuid.UUID, string) ([]store.MCPAccessInfo, error) { + return s.accessible, nil +} +func (s *fakeChannelCapabilityMCPStore) CreateRequest(context.Context, *store.MCPAccessRequest) error { + return nil +} +func (s *fakeChannelCapabilityMCPStore) ListPendingRequests(context.Context) ([]store.MCPAccessRequest, error) { + return nil, nil +} +func (s *fakeChannelCapabilityMCPStore) ReviewRequest(context.Context, uuid.UUID, bool, string, string) error { + return nil +} +func (s *fakeChannelCapabilityMCPStore) GetUserCredentials(_ context.Context, serverID uuid.UUID, _ string) (*store.MCPUserCredentials, error) { + if creds := s.userCreds[serverID]; creds != nil { + return creds, nil + } + return nil, sql.ErrNoRows +} +func (s *fakeChannelCapabilityMCPStore) SetUserCredentials(context.Context, uuid.UUID, string, store.MCPUserCredentials) error { + return nil +} +func (s *fakeChannelCapabilityMCPStore) DeleteUserCredentials(context.Context, uuid.UUID, string) error { + return nil +} + +type fakeChannelCapabilityCLIStore struct { + binaries []store.SecureCLIBinary + userCreds map[uuid.UUID]*store.SecureCLIUserCredential +} + +func (s *fakeChannelCapabilityCLIStore) Create(context.Context, *store.SecureCLIBinary) error { + return nil +} +func (s *fakeChannelCapabilityCLIStore) Get(context.Context, uuid.UUID) (*store.SecureCLIBinary, error) { + return nil, sql.ErrNoRows +} +func (s *fakeChannelCapabilityCLIStore) Update(context.Context, uuid.UUID, map[string]any) error { + return nil +} +func (s *fakeChannelCapabilityCLIStore) Delete(context.Context, uuid.UUID) error { + return nil +} +func (s *fakeChannelCapabilityCLIStore) List(context.Context) ([]store.SecureCLIBinary, error) { + return nil, nil +} +func (s *fakeChannelCapabilityCLIStore) LookupByBinary(context.Context, string, *uuid.UUID, string) (*store.SecureCLIBinary, error) { + return nil, sql.ErrNoRows +} +func (s *fakeChannelCapabilityCLIStore) ListEnabled(context.Context) ([]store.SecureCLIBinary, error) { + return nil, nil +} +func (s *fakeChannelCapabilityCLIStore) ListForAgent(context.Context, uuid.UUID) ([]store.SecureCLIBinary, error) { + return s.binaries, nil +} +func (s *fakeChannelCapabilityCLIStore) IsRegisteredBinary(context.Context, string) (bool, error) { + return false, nil +} +func (s *fakeChannelCapabilityCLIStore) GetUserCredentials(_ context.Context, binaryID uuid.UUID, _ string) (*store.SecureCLIUserCredential, error) { + if creds := s.userCreds[binaryID]; creds != nil { + return creds, nil + } + return nil, sql.ErrNoRows +} +func (s *fakeChannelCapabilityCLIStore) SetUserCredentials(context.Context, uuid.UUID, string, []byte) error { + return nil +} +func (s *fakeChannelCapabilityCLIStore) SetUserCredentialsTyped(context.Context, uuid.UUID, string, []byte, *string, *string) error { + return nil +} +func (s *fakeChannelCapabilityCLIStore) DeleteUserCredentials(context.Context, uuid.UUID, string) error { + return nil +} +func (s *fakeChannelCapabilityCLIStore) ListUserCredentials(context.Context, uuid.UUID) ([]store.SecureCLIUserCredential, error) { + return nil, nil +} + +func TestChannelContextCapabilitiesExposeOnlyCredentialMetadata(t *testing.T) { + token := "channel-capabilities-key" + setupTestCache(t, map[string]*store.APIKeyData{ + crypto.HashAPIKey(token): {Scopes: []string{"operator.write"}, OwnerID: "caller"}, + }) + + instID := uuid.New() + agentID := uuid.New() + channelName := "discord-admin" + mcpID := uuid.New() + cliID := uuid.New() + mcpStore := &fakeChannelCapabilityMCPStore{ + accessible: []store.MCPAccessInfo{{ + Server: store.MCPServerData{ + BaseModel: store.BaseModel{ID: mcpID}, + Name: "github", + DisplayName: "GitHub", + APIKey: "global-value", + Enabled: true, + }, + ToolAllow: []string{"issues.create"}, + }}, + userCreds: map[uuid.UUID]*store.MCPUserCredentials{ + mcpID: {APIKey: "user-value"}, + }, + } + cliStore := &fakeChannelCapabilityCLIStore{ + binaries: []store.SecureCLIBinary{{ + BaseModel: store.BaseModel{ID: cliID}, + BinaryName: "gh", + Description: "GitHub CLI", + EncryptedEnv: []byte(`{"VALUE":"global-value"}`), + IsGlobal: true, + Enabled: true, + }}, + userCreds: map[uuid.UUID]*store.SecureCLIUserCredential{ + cliID: {BinaryID: cliID, UserID: "tenant-user-1", EncryptedEnv: []byte(`{"VALUE":"user-value"}`)}, + }, + } + handler := NewChannelInstancesHandler( + &stubChannelInstanceStore{inst: &store.ChannelInstanceData{BaseModel: store.BaseModel{ID: instID}, Name: channelName, ChannelType: "discord", AgentID: agentID}}, + nil, nil, nil, nil, nil, + ) + handler.SetCapabilityStores(mcpStore, cliStore) + mux := http.NewServeMux() + handler.RegisterRoutes(mux) + + req := httptest.NewRequest(http.MethodGet, "/v1/channels/instances/"+instID.String()+"/contexts/channel/"+channelName+"/capabilities?user_id=tenant-user-1", nil) + req.Header.Set("Authorization", "Bearer "+token) + rec := httptest.NewRecorder() + mux.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want 200; body=%s", rec.Code, rec.Body.String()) + } + bodyText := rec.Body.String() + if strings.Contains(bodyText, "global-value") || strings.Contains(bodyText, "user-value") { + t.Fatalf("capability response leaked credential material: %s", bodyText) + } + var body struct { + MCP []channelCapabilityDTO `json:"mcp"` + SecureCLI []channelCapabilityDTO `json:"secure_cli"` + } + if err := json.Unmarshal([]byte(bodyText), &body); err != nil { + t.Fatal(err) + } + if len(body.MCP) != 1 || body.MCP[0].CredentialSource != "user" || !body.MCP[0].HasCredential { + t.Fatalf("mcp capability = %+v", body.MCP) + } + if len(body.SecureCLI) != 1 || body.SecureCLI[0].Source != "global" || body.SecureCLI[0].CredentialSource != "user" || !body.SecureCLI[0].HasCredential { + t.Fatalf("secure cli capability = %+v", body.SecureCLI) + } +} diff --git a/internal/http/channel_instances.go b/internal/http/channel_instances.go index f16b29d8..607c24a1 100644 --- a/internal/http/channel_instances.go +++ b/internal/http/channel_instances.go @@ -39,6 +39,8 @@ type ChannelInstancesHandler struct { msgBus *bus.MessageBus memberResolver channels.MemberResolver // optional — enriches file_writer metadata on addwriter channelMgr *channels.Manager // optional — enables ChannelDestroyer hook on delete + mcpStore store.MCPServerStore + secureCLIStore store.SecureCLIStore // orphanCleaners is keyed by channel_type; called when channelMgr.GetChannel // returns false. Keeps handler agnostic of per-channel packages. orphanCleaners map[string]OrphanChannelCleaner @@ -64,6 +66,13 @@ func (h *ChannelInstancesHandler) SetChannelManager(mgr *channels.Manager) { h.channelMgr = mgr } +// SetCapabilityStores wires MCP and Secure CLI stores for channel-context +// capability visibility. Kept as a setter to preserve startup ordering. +func (h *ChannelInstancesHandler) SetCapabilityStores(mcpStore store.MCPServerStore, secureCLIStore store.SecureCLIStore) { + h.mcpStore = mcpStore + h.secureCLIStore = secureCLIStore +} + // RegisterOrphanCleaner registers a per-channel-type cleanup function that // fires during handleDelete when the channel is no longer loaded in the // Manager (typically because admin disabled it). Without this, deleting a @@ -114,6 +123,7 @@ func (h *ChannelInstancesHandler) RegisterRoutes(mux *http.ServeMux) { mux.HandleFunc("GET /v1/channels/instances/{id}/contexts", h.auth(h.handleListContexts)) mux.HandleFunc("GET /v1/channels/instances/{id}/contexts/{scopeType}/{scopeKey}/members", h.auth(h.handleListContextMembers)) } + mux.HandleFunc("GET /v1/channels/instances/{id}/contexts/{scopeType}/{scopeKey}/capabilities", h.auth(h.handleListContextCapabilities)) } func (h *ChannelInstancesHandler) auth(next http.HandlerFunc) http.HandlerFunc { From 22f33d693069df3b517aff7a7b1215374d1b8a85 Mon Sep 17 00:00:00 2001 From: Goon Date: Fri, 29 May 2026 19:24:24 +0700 Subject: [PATCH 010/105] feat(channels): persist context capability scopes --- .../store/pg/channel_context_capabilities.go | 435 +++++++++++++++++ .../channel-context-capabilities.go | 447 ++++++++++++++++++ internal/store/sqlitestore/schema.go | 82 +++- internal/store/sqlitestore/schema.sql | 85 ++++ internal/upgrade/version.go | 2 +- ...0074_channel_context_capabilities.down.sql | 4 + ...000074_channel_context_capabilities.up.sql | 80 ++++ 7 files changed, 1133 insertions(+), 2 deletions(-) create mode 100644 internal/store/pg/channel_context_capabilities.go create mode 100644 internal/store/sqlitestore/channel-context-capabilities.go create mode 100644 migrations/000074_channel_context_capabilities.down.sql create mode 100644 migrations/000074_channel_context_capabilities.up.sql diff --git a/internal/store/pg/channel_context_capabilities.go b/internal/store/pg/channel_context_capabilities.go new file mode 100644 index 00000000..81f2015f --- /dev/null +++ b/internal/store/pg/channel_context_capabilities.go @@ -0,0 +1,435 @@ +package pg + +import ( + "context" + "database/sql" + "encoding/json" + "errors" + "fmt" + "time" + + "github.com/google/uuid" + + "github.com/nextlevelbuilder/goclaw/internal/crypto" + "github.com/nextlevelbuilder/goclaw/internal/store" +) + +func validateChannelScope(scopeType, scopeKey string) error { + switch scopeType { + case store.ChannelScopeTypeChannel: + return nil + case store.ChannelScopeTypeGroup, store.ChannelScopeTypeUser, store.ChannelScopeTypeRole: + if scopeKey == "" { + return fmt.Errorf("scope_key required for %s scope", scopeType) + } + return nil + default: + return fmt.Errorf("invalid scope_type: %s", scopeType) + } +} + +func rawPtrOrNull(raw *json.RawMessage) any { + if raw == nil || len(*raw) == 0 { + return nil + } + return *raw +} + +func jsonOrEmptyObject(raw json.RawMessage) any { + if len(raw) == 0 { + return json.RawMessage(`{}`) + } + return raw +} + +func (s *PGMCPServerStore) UpsertContextGrant(ctx context.Context, g *store.MCPContextGrant) error { + if err := validateChannelScope(g.ScopeType, g.ScopeKey); err != nil { + return err + } + if err := store.ValidateUserID(g.GrantedBy); err != nil { + return err + } + if g.ID == uuid.Nil { + g.ID = store.GenNewID() + } + now := time.Now() + g.CreatedAt = now + g.UpdatedAt = now + _, err := s.db.ExecContext(ctx, + `INSERT INTO mcp_context_grants + (id, tenant_id, channel_instance_id, scope_type, scope_key, server_id, enabled, + tool_allow, tool_deny, config_overrides, granted_by, created_at, updated_at) + VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13) + ON CONFLICT (tenant_id, channel_instance_id, scope_type, scope_key, server_id) + DO UPDATE SET enabled = EXCLUDED.enabled, + tool_allow = EXCLUDED.tool_allow, + tool_deny = EXCLUDED.tool_deny, + config_overrides = EXCLUDED.config_overrides, + granted_by = EXCLUDED.granted_by, + updated_at = EXCLUDED.updated_at`, + g.ID, tenantIDForInsert(ctx), g.ChannelInstanceID, g.ScopeType, g.ScopeKey, g.ServerID, g.Enabled, + jsonOrNull(g.ToolAllow), jsonOrNull(g.ToolDeny), jsonOrNull(g.ConfigOverrides), + g.GrantedBy, now, now, + ) + return err +} + +func (s *PGMCPServerStore) DeleteContextGrant(ctx context.Context, channelInstanceID uuid.UUID, scopeType, scopeKey string, serverID uuid.UUID) error { + _, err := s.db.ExecContext(ctx, + `DELETE FROM mcp_context_grants + WHERE tenant_id = $1 AND channel_instance_id = $2 AND scope_type = $3 AND scope_key = $4 AND server_id = $5`, + tenantIDForInsert(ctx), channelInstanceID, scopeType, scopeKey, serverID, + ) + return err +} + +func (s *PGMCPServerStore) ListContextGrants(ctx context.Context, channelInstanceID uuid.UUID, scopeType, scopeKey string) ([]store.MCPContextGrant, error) { + rows, err := s.db.QueryContext(ctx, + `SELECT id, channel_instance_id, scope_type, scope_key, server_id, enabled, + COALESCE(tool_allow, 'null'::jsonb), COALESCE(tool_deny, 'null'::jsonb), + COALESCE(config_overrides, 'null'::jsonb), granted_by, created_at, updated_at + FROM mcp_context_grants + WHERE tenant_id = $1 AND channel_instance_id = $2 AND scope_type = $3 AND scope_key = $4 + ORDER BY created_at`, + tenantIDForInsert(ctx), channelInstanceID, scopeType, scopeKey, + ) + if err != nil { + return nil, err + } + defer rows.Close() + result := make([]store.MCPContextGrant, 0) + for rows.Next() { + var g store.MCPContextGrant + if err := rows.Scan(&g.ID, &g.ChannelInstanceID, &g.ScopeType, &g.ScopeKey, &g.ServerID, &g.Enabled, + &g.ToolAllow, &g.ToolDeny, &g.ConfigOverrides, &g.GrantedBy, &g.CreatedAt, &g.UpdatedAt); err != nil { + return nil, err + } + result = append(result, g) + } + return result, rows.Err() +} + +func (s *PGMCPServerStore) SetContextCredentials(ctx context.Context, creds *store.MCPContextCredentials) error { + if err := validateChannelScope(creds.ScopeType, creds.ScopeKey); err != nil { + return err + } + if err := store.ValidateUserID(creds.CreatedBy); err != nil { + return err + } + if creds.ID == uuid.Nil { + creds.ID = store.GenNewID() + } + var apiKey sql.NullString + if creds.APIKey != "" && s.encKey != "" { + enc, err := crypto.Encrypt(creds.APIKey, s.encKey) + if err != nil { + return fmt.Errorf("encrypt mcp context api_key: %w", err) + } + apiKey = sql.NullString{String: enc, Valid: true} + } else if creds.APIKey != "" { + apiKey = sql.NullString{String: creds.APIKey, Valid: true} + } + var headersEnc, envEnc []byte + if len(creds.Headers) > 0 { + raw, _ := json.Marshal(creds.Headers) + headersEnc = s.encryptJSONB(raw) + } + if len(creds.Env) > 0 { + raw, _ := json.Marshal(creds.Env) + envEnc = s.encryptJSONB(raw) + } + now := time.Now() + _, err := s.db.ExecContext(ctx, + `INSERT INTO mcp_context_credentials + (id, tenant_id, channel_instance_id, scope_type, scope_key, server_id, + api_key, headers, env, created_by, created_at, updated_at) + VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12) + ON CONFLICT (tenant_id, channel_instance_id, scope_type, scope_key, server_id) + DO UPDATE SET api_key = EXCLUDED.api_key, + headers = EXCLUDED.headers, + env = EXCLUDED.env, + created_by = EXCLUDED.created_by, + updated_at = EXCLUDED.updated_at`, + creds.ID, tenantIDForInsert(ctx), creds.ChannelInstanceID, creds.ScopeType, creds.ScopeKey, creds.ServerID, + apiKey, headersEnc, envEnc, creds.CreatedBy, now, now, + ) + return err +} + +func (s *PGMCPServerStore) GetContextCredentials(ctx context.Context, channelInstanceID uuid.UUID, scopeType, scopeKey string, serverID uuid.UUID) (*store.MCPContextCredentials, error) { + row := s.db.QueryRowContext(ctx, + `SELECT id, channel_instance_id, scope_type, scope_key, server_id, api_key, headers, env, created_by, created_at, updated_at + FROM mcp_context_credentials + WHERE tenant_id = $1 AND channel_instance_id = $2 AND scope_type = $3 AND scope_key = $4 AND server_id = $5`, + tenantIDForInsert(ctx), channelInstanceID, scopeType, scopeKey, serverID, + ) + creds, err := s.scanContextCredentials(row) + if errors.Is(err, sql.ErrNoRows) { + return nil, nil + } + return creds, err +} + +func (s *PGMCPServerStore) DeleteContextCredentials(ctx context.Context, channelInstanceID uuid.UUID, scopeType, scopeKey string, serverID uuid.UUID) error { + _, err := s.db.ExecContext(ctx, + `DELETE FROM mcp_context_credentials + WHERE tenant_id = $1 AND channel_instance_id = $2 AND scope_type = $3 AND scope_key = $4 AND server_id = $5`, + tenantIDForInsert(ctx), channelInstanceID, scopeType, scopeKey, serverID, + ) + return err +} + +func (s *PGMCPServerStore) ListContextCredentials(ctx context.Context, channelInstanceID uuid.UUID, scopeType, scopeKey string) ([]store.MCPContextCredentials, error) { + rows, err := s.db.QueryContext(ctx, + `SELECT id, channel_instance_id, scope_type, scope_key, server_id, api_key, headers, env, created_by, created_at, updated_at + FROM mcp_context_credentials + WHERE tenant_id = $1 AND channel_instance_id = $2 AND scope_type = $3 AND scope_key = $4 + ORDER BY created_at`, + tenantIDForInsert(ctx), channelInstanceID, scopeType, scopeKey, + ) + if err != nil { + return nil, err + } + defer rows.Close() + result := make([]store.MCPContextCredentials, 0) + for rows.Next() { + creds, err := s.scanContextCredentials(rows) + if err != nil { + return nil, err + } + result = append(result, *creds) + } + return result, rows.Err() +} + +type contextCredentialScanner interface { + Scan(dest ...any) error +} + +func (s *PGMCPServerStore) scanContextCredentials(row contextCredentialScanner) (*store.MCPContextCredentials, error) { + var creds store.MCPContextCredentials + var apiKey sql.NullString + var headersEnc, envEnc []byte + if err := row.Scan(&creds.ID, &creds.ChannelInstanceID, &creds.ScopeType, &creds.ScopeKey, &creds.ServerID, + &apiKey, &headersEnc, &envEnc, &creds.CreatedBy, &creds.CreatedAt, &creds.UpdatedAt); err != nil { + return nil, err + } + if apiKey.Valid && apiKey.String != "" && s.encKey != "" { + if dec, err := crypto.Decrypt(apiKey.String, s.encKey); err == nil { + creds.APIKey = dec + } + } else if apiKey.Valid { + creds.APIKey = apiKey.String + } + if len(headersEnc) > 0 { + _ = json.Unmarshal(s.decryptJSONB(headersEnc), &creds.Headers) + } + if len(envEnc) > 0 { + _ = json.Unmarshal(s.decryptJSONB(envEnc), &creds.Env) + } + return &creds, nil +} + +func (s *PGSecureCLIStore) UpsertContextGrant(ctx context.Context, g *store.SecureCLIContextGrant) error { + if err := validateChannelScope(g.ScopeType, g.ScopeKey); err != nil { + return err + } + if err := store.ValidateUserID(g.GrantedBy); err != nil { + return err + } + if g.ID == uuid.Nil { + g.ID = store.GenNewID() + } + var envBytes []byte + if len(g.EncryptedEnv) > 0 && s.encKey != "" { + encrypted, err := crypto.Encrypt(string(g.EncryptedEnv), s.encKey) + if err != nil { + return fmt.Errorf("encrypt context grant env: %w", err) + } + envBytes = []byte(encrypted) + } else { + envBytes = g.EncryptedEnv + } + now := time.Now() + _, err := s.db.ExecContext(ctx, + `INSERT INTO secure_cli_context_grants + (id, tenant_id, channel_instance_id, scope_type, scope_key, binary_id, + deny_args, deny_verbose, timeout_seconds, tips, encrypted_env, enabled, + granted_by, created_at, updated_at) + VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15) + ON CONFLICT (tenant_id, channel_instance_id, scope_type, scope_key, binary_id) + DO UPDATE SET deny_args = EXCLUDED.deny_args, + deny_verbose = EXCLUDED.deny_verbose, + timeout_seconds = EXCLUDED.timeout_seconds, + tips = EXCLUDED.tips, + encrypted_env = EXCLUDED.encrypted_env, + enabled = EXCLUDED.enabled, + granted_by = EXCLUDED.granted_by, + updated_at = EXCLUDED.updated_at`, + g.ID, tenantIDForInsert(ctx), g.ChannelInstanceID, g.ScopeType, g.ScopeKey, g.BinaryID, + rawPtrOrNull(g.DenyArgs), rawPtrOrNull(g.DenyVerbose), g.TimeoutSeconds, g.Tips, envBytes, g.Enabled, + g.GrantedBy, now, now, + ) + return err +} + +func (s *PGSecureCLIStore) DeleteContextGrant(ctx context.Context, channelInstanceID uuid.UUID, scopeType, scopeKey string, binaryID uuid.UUID) error { + _, err := s.db.ExecContext(ctx, + `DELETE FROM secure_cli_context_grants + WHERE tenant_id = $1 AND channel_instance_id = $2 AND scope_type = $3 AND scope_key = $4 AND binary_id = $5`, + tenantIDForInsert(ctx), channelInstanceID, scopeType, scopeKey, binaryID, + ) + return err +} + +func (s *PGSecureCLIStore) ListContextGrants(ctx context.Context, channelInstanceID uuid.UUID, scopeType, scopeKey string) ([]store.SecureCLIContextGrant, error) { + rows, err := s.db.QueryContext(ctx, + `SELECT id, channel_instance_id, scope_type, scope_key, binary_id, + deny_args, deny_verbose, timeout_seconds, tips, encrypted_env, + enabled, granted_by, created_at, updated_at + FROM secure_cli_context_grants + WHERE tenant_id = $1 AND channel_instance_id = $2 AND scope_type = $3 AND scope_key = $4 + ORDER BY created_at`, + tenantIDForInsert(ctx), channelInstanceID, scopeType, scopeKey, + ) + if err != nil { + return nil, err + } + defer rows.Close() + result := make([]store.SecureCLIContextGrant, 0) + for rows.Next() { + g, err := s.scanSecureCLIContextGrant(rows) + if err != nil { + return nil, err + } + result = append(result, *g) + } + return result, rows.Err() +} + +func (s *PGSecureCLIStore) scanSecureCLIContextGrant(row contextCredentialScanner) (*store.SecureCLIContextGrant, error) { + var g store.SecureCLIContextGrant + var denyArgs, denyVerbose *[]byte + if err := row.Scan(&g.ID, &g.ChannelInstanceID, &g.ScopeType, &g.ScopeKey, &g.BinaryID, + &denyArgs, &denyVerbose, &g.TimeoutSeconds, &g.Tips, &g.EncryptedEnv, &g.Enabled, + &g.GrantedBy, &g.CreatedAt, &g.UpdatedAt); err != nil { + return nil, err + } + if denyArgs != nil { + raw := json.RawMessage(*denyArgs) + g.DenyArgs = &raw + } + if denyVerbose != nil { + raw := json.RawMessage(*denyVerbose) + g.DenyVerbose = &raw + } + if len(g.EncryptedEnv) > 0 && s.encKey != "" { + if decrypted, err := crypto.Decrypt(string(g.EncryptedEnv), s.encKey); err == nil { + g.EncryptedEnv = []byte(decrypted) + } + } + g.EnvSet = len(g.EncryptedEnv) > 0 + return &g, nil +} + +func (s *PGSecureCLIStore) SetContextCredentials(ctx context.Context, creds *store.SecureCLIContextCredentials) error { + if err := validateChannelScope(creds.ScopeType, creds.ScopeKey); err != nil { + return err + } + if err := store.ValidateUserID(creds.CreatedBy); err != nil { + return err + } + if creds.ID == uuid.Nil { + creds.ID = store.GenNewID() + } + var envBytes []byte + if len(creds.EncryptedEnv) > 0 && s.encKey != "" { + encrypted, err := crypto.Encrypt(string(creds.EncryptedEnv), s.encKey) + if err != nil { + return fmt.Errorf("encrypt context credential env: %w", err) + } + envBytes = []byte(encrypted) + } else { + envBytes = creds.EncryptedEnv + } + now := time.Now() + _, err := s.db.ExecContext(ctx, + `INSERT INTO secure_cli_context_credentials + (id, tenant_id, channel_instance_id, scope_type, scope_key, binary_id, + encrypted_env, metadata, credential_type, host_scope, created_by, created_at, updated_at) + VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13) + ON CONFLICT (tenant_id, channel_instance_id, scope_type, scope_key, binary_id) + DO UPDATE SET encrypted_env = EXCLUDED.encrypted_env, + metadata = EXCLUDED.metadata, + credential_type = EXCLUDED.credential_type, + host_scope = EXCLUDED.host_scope, + created_by = EXCLUDED.created_by, + updated_at = EXCLUDED.updated_at`, + creds.ID, tenantIDForInsert(ctx), creds.ChannelInstanceID, creds.ScopeType, creds.ScopeKey, creds.BinaryID, + envBytes, jsonOrEmptyObject(creds.Metadata), creds.CredentialType, creds.HostScope, creds.CreatedBy, now, now, + ) + return err +} + +func (s *PGSecureCLIStore) GetContextCredentials(ctx context.Context, channelInstanceID uuid.UUID, scopeType, scopeKey string, binaryID uuid.UUID) (*store.SecureCLIContextCredentials, error) { + row := s.db.QueryRowContext(ctx, + `SELECT id, channel_instance_id, scope_type, scope_key, binary_id, encrypted_env, + COALESCE(metadata, '{}'::jsonb), credential_type, host_scope, created_by, created_at, updated_at + FROM secure_cli_context_credentials + WHERE tenant_id = $1 AND channel_instance_id = $2 AND scope_type = $3 AND scope_key = $4 AND binary_id = $5`, + tenantIDForInsert(ctx), channelInstanceID, scopeType, scopeKey, binaryID, + ) + creds, err := s.scanSecureCLIContextCredentials(row) + if errors.Is(err, sql.ErrNoRows) { + return nil, nil + } + return creds, err +} + +func (s *PGSecureCLIStore) DeleteContextCredentials(ctx context.Context, channelInstanceID uuid.UUID, scopeType, scopeKey string, binaryID uuid.UUID) error { + _, err := s.db.ExecContext(ctx, + `DELETE FROM secure_cli_context_credentials + WHERE tenant_id = $1 AND channel_instance_id = $2 AND scope_type = $3 AND scope_key = $4 AND binary_id = $5`, + tenantIDForInsert(ctx), channelInstanceID, scopeType, scopeKey, binaryID, + ) + return err +} + +func (s *PGSecureCLIStore) ListContextCredentials(ctx context.Context, channelInstanceID uuid.UUID, scopeType, scopeKey string) ([]store.SecureCLIContextCredentials, error) { + rows, err := s.db.QueryContext(ctx, + `SELECT id, channel_instance_id, scope_type, scope_key, binary_id, encrypted_env, + COALESCE(metadata, '{}'::jsonb), credential_type, host_scope, created_by, created_at, updated_at + FROM secure_cli_context_credentials + WHERE tenant_id = $1 AND channel_instance_id = $2 AND scope_type = $3 AND scope_key = $4 + ORDER BY created_at`, + tenantIDForInsert(ctx), channelInstanceID, scopeType, scopeKey, + ) + if err != nil { + return nil, err + } + defer rows.Close() + result := make([]store.SecureCLIContextCredentials, 0) + for rows.Next() { + creds, err := s.scanSecureCLIContextCredentials(rows) + if err != nil { + return nil, err + } + result = append(result, *creds) + } + return result, rows.Err() +} + +func (s *PGSecureCLIStore) scanSecureCLIContextCredentials(row contextCredentialScanner) (*store.SecureCLIContextCredentials, error) { + var creds store.SecureCLIContextCredentials + if err := row.Scan(&creds.ID, &creds.ChannelInstanceID, &creds.ScopeType, &creds.ScopeKey, &creds.BinaryID, + &creds.EncryptedEnv, &creds.Metadata, &creds.CredentialType, &creds.HostScope, + &creds.CreatedBy, &creds.CreatedAt, &creds.UpdatedAt); err != nil { + return nil, err + } + if len(creds.EncryptedEnv) > 0 && s.encKey != "" { + if decrypted, err := crypto.Decrypt(string(creds.EncryptedEnv), s.encKey); err == nil { + creds.EncryptedEnv = []byte(decrypted) + } + } + return &creds, nil +} diff --git a/internal/store/sqlitestore/channel-context-capabilities.go b/internal/store/sqlitestore/channel-context-capabilities.go new file mode 100644 index 00000000..c7f29acc --- /dev/null +++ b/internal/store/sqlitestore/channel-context-capabilities.go @@ -0,0 +1,447 @@ +//go:build sqlite || sqliteonly + +package sqlitestore + +import ( + "context" + "database/sql" + "encoding/json" + "errors" + "fmt" + "time" + + "github.com/google/uuid" + + "github.com/nextlevelbuilder/goclaw/internal/crypto" + "github.com/nextlevelbuilder/goclaw/internal/store" +) + +func validateChannelScope(scopeType, scopeKey string) error { + switch scopeType { + case store.ChannelScopeTypeChannel: + return nil + case store.ChannelScopeTypeGroup, store.ChannelScopeTypeUser, store.ChannelScopeTypeRole: + if scopeKey == "" { + return fmt.Errorf("scope_key required for %s scope", scopeType) + } + return nil + default: + return fmt.Errorf("invalid scope_type: %s", scopeType) + } +} + +func rawPtrOrNull(raw *json.RawMessage) any { + if raw == nil || len(*raw) == 0 { + return nil + } + return string(*raw) +} + +func jsonOrEmptyObject(raw json.RawMessage) any { + if len(raw) == 0 { + return "{}" + } + return string(raw) +} + +func (s *SQLiteMCPServerStore) UpsertContextGrant(ctx context.Context, g *store.MCPContextGrant) error { + if err := validateChannelScope(g.ScopeType, g.ScopeKey); err != nil { + return err + } + if err := store.ValidateUserID(g.GrantedBy); err != nil { + return err + } + if g.ID == uuid.Nil { + g.ID = store.GenNewID() + } + now := time.Now().UTC().Format(time.RFC3339Nano) + _, err := s.db.ExecContext(ctx, + `INSERT INTO mcp_context_grants + (id, tenant_id, channel_instance_id, scope_type, scope_key, server_id, enabled, + tool_allow, tool_deny, config_overrides, granted_by, created_at, updated_at) + VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?) + ON CONFLICT (tenant_id, channel_instance_id, scope_type, scope_key, server_id) + DO UPDATE SET enabled = excluded.enabled, + tool_allow = excluded.tool_allow, + tool_deny = excluded.tool_deny, + config_overrides = excluded.config_overrides, + granted_by = excluded.granted_by, + updated_at = excluded.updated_at`, + g.ID, tenantIDForInsert(ctx), g.ChannelInstanceID, g.ScopeType, g.ScopeKey, g.ServerID, g.Enabled, + jsonOrNull(g.ToolAllow), jsonOrNull(g.ToolDeny), jsonOrNull(g.ConfigOverrides), + g.GrantedBy, now, now, + ) + return err +} + +func (s *SQLiteMCPServerStore) DeleteContextGrant(ctx context.Context, channelInstanceID uuid.UUID, scopeType, scopeKey string, serverID uuid.UUID) error { + _, err := s.db.ExecContext(ctx, + `DELETE FROM mcp_context_grants + WHERE tenant_id = ? AND channel_instance_id = ? AND scope_type = ? AND scope_key = ? AND server_id = ?`, + tenantIDForInsert(ctx), channelInstanceID, scopeType, scopeKey, serverID, + ) + return err +} + +func (s *SQLiteMCPServerStore) ListContextGrants(ctx context.Context, channelInstanceID uuid.UUID, scopeType, scopeKey string) ([]store.MCPContextGrant, error) { + rows, err := s.db.QueryContext(ctx, + `SELECT id, channel_instance_id, scope_type, scope_key, server_id, enabled, + COALESCE(tool_allow, 'null'), COALESCE(tool_deny, 'null'), + COALESCE(config_overrides, 'null'), granted_by, created_at, updated_at + FROM mcp_context_grants + WHERE tenant_id = ? AND channel_instance_id = ? AND scope_type = ? AND scope_key = ? + ORDER BY created_at`, + tenantIDForInsert(ctx), channelInstanceID, scopeType, scopeKey, + ) + if err != nil { + return nil, err + } + defer rows.Close() + result := make([]store.MCPContextGrant, 0) + for rows.Next() { + var g store.MCPContextGrant + var createdAt, updatedAt sqliteTime + if err := rows.Scan(&g.ID, &g.ChannelInstanceID, &g.ScopeType, &g.ScopeKey, &g.ServerID, &g.Enabled, + &g.ToolAllow, &g.ToolDeny, &g.ConfigOverrides, &g.GrantedBy, &createdAt, &updatedAt); err != nil { + return nil, err + } + g.CreatedAt = createdAt.Time + g.UpdatedAt = updatedAt.Time + result = append(result, g) + } + return result, rows.Err() +} + +func (s *SQLiteMCPServerStore) SetContextCredentials(ctx context.Context, creds *store.MCPContextCredentials) error { + if err := validateChannelScope(creds.ScopeType, creds.ScopeKey); err != nil { + return err + } + if err := store.ValidateUserID(creds.CreatedBy); err != nil { + return err + } + if creds.ID == uuid.Nil { + creds.ID = store.GenNewID() + } + var apiKey sql.NullString + if creds.APIKey != "" && s.encKey != "" { + enc, err := crypto.Encrypt(creds.APIKey, s.encKey) + if err != nil { + return fmt.Errorf("encrypt mcp context api_key: %w", err) + } + apiKey = sql.NullString{String: enc, Valid: true} + } else if creds.APIKey != "" { + apiKey = sql.NullString{String: creds.APIKey, Valid: true} + } + var headersEnc, envEnc []byte + if len(creds.Headers) > 0 { + raw, _ := json.Marshal(creds.Headers) + headersEnc = s.encryptJSON(raw) + } + if len(creds.Env) > 0 { + raw, _ := json.Marshal(creds.Env) + envEnc = s.encryptJSON(raw) + } + now := time.Now().UTC().Format(time.RFC3339Nano) + _, err := s.db.ExecContext(ctx, + `INSERT INTO mcp_context_credentials + (id, tenant_id, channel_instance_id, scope_type, scope_key, server_id, + api_key, headers, env, created_by, created_at, updated_at) + VALUES (?,?,?,?,?,?,?,?,?,?,?,?) + ON CONFLICT (tenant_id, channel_instance_id, scope_type, scope_key, server_id) + DO UPDATE SET api_key = excluded.api_key, + headers = excluded.headers, + env = excluded.env, + created_by = excluded.created_by, + updated_at = excluded.updated_at`, + creds.ID, tenantIDForInsert(ctx), creds.ChannelInstanceID, creds.ScopeType, creds.ScopeKey, creds.ServerID, + apiKey, headersEnc, envEnc, creds.CreatedBy, now, now, + ) + return err +} + +func (s *SQLiteMCPServerStore) GetContextCredentials(ctx context.Context, channelInstanceID uuid.UUID, scopeType, scopeKey string, serverID uuid.UUID) (*store.MCPContextCredentials, error) { + row := s.db.QueryRowContext(ctx, + `SELECT id, channel_instance_id, scope_type, scope_key, server_id, api_key, headers, env, created_by, created_at, updated_at + FROM mcp_context_credentials + WHERE tenant_id = ? AND channel_instance_id = ? AND scope_type = ? AND scope_key = ? AND server_id = ?`, + tenantIDForInsert(ctx), channelInstanceID, scopeType, scopeKey, serverID, + ) + creds, err := s.scanContextCredentials(row) + if errors.Is(err, sql.ErrNoRows) { + return nil, nil + } + return creds, err +} + +func (s *SQLiteMCPServerStore) DeleteContextCredentials(ctx context.Context, channelInstanceID uuid.UUID, scopeType, scopeKey string, serverID uuid.UUID) error { + _, err := s.db.ExecContext(ctx, + `DELETE FROM mcp_context_credentials + WHERE tenant_id = ? AND channel_instance_id = ? AND scope_type = ? AND scope_key = ? AND server_id = ?`, + tenantIDForInsert(ctx), channelInstanceID, scopeType, scopeKey, serverID, + ) + return err +} + +func (s *SQLiteMCPServerStore) ListContextCredentials(ctx context.Context, channelInstanceID uuid.UUID, scopeType, scopeKey string) ([]store.MCPContextCredentials, error) { + rows, err := s.db.QueryContext(ctx, + `SELECT id, channel_instance_id, scope_type, scope_key, server_id, api_key, headers, env, created_by, created_at, updated_at + FROM mcp_context_credentials + WHERE tenant_id = ? AND channel_instance_id = ? AND scope_type = ? AND scope_key = ? + ORDER BY created_at`, + tenantIDForInsert(ctx), channelInstanceID, scopeType, scopeKey, + ) + if err != nil { + return nil, err + } + defer rows.Close() + result := make([]store.MCPContextCredentials, 0) + for rows.Next() { + creds, err := s.scanContextCredentials(rows) + if err != nil { + return nil, err + } + result = append(result, *creds) + } + return result, rows.Err() +} + +type contextCredentialScanner interface { + Scan(dest ...any) error +} + +func (s *SQLiteMCPServerStore) scanContextCredentials(row contextCredentialScanner) (*store.MCPContextCredentials, error) { + var creds store.MCPContextCredentials + var apiKey sql.NullString + var headersEnc, envEnc []byte + var createdAt, updatedAt sqliteTime + if err := row.Scan(&creds.ID, &creds.ChannelInstanceID, &creds.ScopeType, &creds.ScopeKey, &creds.ServerID, + &apiKey, &headersEnc, &envEnc, &creds.CreatedBy, &createdAt, &updatedAt); err != nil { + return nil, err + } + creds.CreatedAt = createdAt.Time + creds.UpdatedAt = updatedAt.Time + if apiKey.Valid && apiKey.String != "" && s.encKey != "" { + if dec, err := crypto.Decrypt(apiKey.String, s.encKey); err == nil { + creds.APIKey = dec + } + } else if apiKey.Valid { + creds.APIKey = apiKey.String + } + if len(headersEnc) > 0 { + _ = json.Unmarshal(s.decryptJSON(headersEnc), &creds.Headers) + } + if len(envEnc) > 0 { + _ = json.Unmarshal(s.decryptJSON(envEnc), &creds.Env) + } + return &creds, nil +} + +func (s *SQLiteSecureCLIStore) UpsertContextGrant(ctx context.Context, g *store.SecureCLIContextGrant) error { + if err := validateChannelScope(g.ScopeType, g.ScopeKey); err != nil { + return err + } + if err := store.ValidateUserID(g.GrantedBy); err != nil { + return err + } + if g.ID == uuid.Nil { + g.ID = store.GenNewID() + } + var envBytes []byte + if len(g.EncryptedEnv) > 0 && s.encKey != "" { + encrypted, err := crypto.Encrypt(string(g.EncryptedEnv), s.encKey) + if err != nil { + return fmt.Errorf("encrypt context grant env: %w", err) + } + envBytes = []byte(encrypted) + } else { + envBytes = g.EncryptedEnv + } + now := time.Now().UTC().Format(time.RFC3339Nano) + _, err := s.db.ExecContext(ctx, + `INSERT INTO secure_cli_context_grants + (id, tenant_id, channel_instance_id, scope_type, scope_key, binary_id, + deny_args, deny_verbose, timeout_seconds, tips, encrypted_env, enabled, + granted_by, created_at, updated_at) + VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) + ON CONFLICT (tenant_id, channel_instance_id, scope_type, scope_key, binary_id) + DO UPDATE SET deny_args = excluded.deny_args, + deny_verbose = excluded.deny_verbose, + timeout_seconds = excluded.timeout_seconds, + tips = excluded.tips, + encrypted_env = excluded.encrypted_env, + enabled = excluded.enabled, + granted_by = excluded.granted_by, + updated_at = excluded.updated_at`, + g.ID, tenantIDForInsert(ctx), g.ChannelInstanceID, g.ScopeType, g.ScopeKey, g.BinaryID, + rawPtrOrNull(g.DenyArgs), rawPtrOrNull(g.DenyVerbose), g.TimeoutSeconds, g.Tips, envBytes, g.Enabled, + g.GrantedBy, now, now, + ) + return err +} + +func (s *SQLiteSecureCLIStore) DeleteContextGrant(ctx context.Context, channelInstanceID uuid.UUID, scopeType, scopeKey string, binaryID uuid.UUID) error { + _, err := s.db.ExecContext(ctx, + `DELETE FROM secure_cli_context_grants + WHERE tenant_id = ? AND channel_instance_id = ? AND scope_type = ? AND scope_key = ? AND binary_id = ?`, + tenantIDForInsert(ctx), channelInstanceID, scopeType, scopeKey, binaryID, + ) + return err +} + +func (s *SQLiteSecureCLIStore) ListContextGrants(ctx context.Context, channelInstanceID uuid.UUID, scopeType, scopeKey string) ([]store.SecureCLIContextGrant, error) { + rows, err := s.db.QueryContext(ctx, + `SELECT id, channel_instance_id, scope_type, scope_key, binary_id, + deny_args, deny_verbose, timeout_seconds, tips, encrypted_env, + enabled, granted_by, created_at, updated_at + FROM secure_cli_context_grants + WHERE tenant_id = ? AND channel_instance_id = ? AND scope_type = ? AND scope_key = ? + ORDER BY created_at`, + tenantIDForInsert(ctx), channelInstanceID, scopeType, scopeKey, + ) + if err != nil { + return nil, err + } + defer rows.Close() + result := make([]store.SecureCLIContextGrant, 0) + for rows.Next() { + g, err := s.scanSecureCLIContextGrant(rows) + if err != nil { + return nil, err + } + result = append(result, *g) + } + return result, rows.Err() +} + +func (s *SQLiteSecureCLIStore) scanSecureCLIContextGrant(row contextCredentialScanner) (*store.SecureCLIContextGrant, error) { + var g store.SecureCLIContextGrant + var denyArgs, denyVerbose []byte + var createdAt, updatedAt sqliteTime + if err := row.Scan(&g.ID, &g.ChannelInstanceID, &g.ScopeType, &g.ScopeKey, &g.BinaryID, + &denyArgs, &denyVerbose, &g.TimeoutSeconds, &g.Tips, &g.EncryptedEnv, &g.Enabled, + &g.GrantedBy, &createdAt, &updatedAt); err != nil { + return nil, err + } + g.CreatedAt = createdAt.Time + g.UpdatedAt = updatedAt.Time + if len(denyArgs) > 0 { + raw := json.RawMessage(denyArgs) + g.DenyArgs = &raw + } + if len(denyVerbose) > 0 { + raw := json.RawMessage(denyVerbose) + g.DenyVerbose = &raw + } + if len(g.EncryptedEnv) > 0 && s.encKey != "" { + if decrypted, err := crypto.Decrypt(string(g.EncryptedEnv), s.encKey); err == nil { + g.EncryptedEnv = []byte(decrypted) + } + } + g.EnvSet = len(g.EncryptedEnv) > 0 + return &g, nil +} + +func (s *SQLiteSecureCLIStore) SetContextCredentials(ctx context.Context, creds *store.SecureCLIContextCredentials) error { + if err := validateChannelScope(creds.ScopeType, creds.ScopeKey); err != nil { + return err + } + if err := store.ValidateUserID(creds.CreatedBy); err != nil { + return err + } + if creds.ID == uuid.Nil { + creds.ID = store.GenNewID() + } + var envBytes []byte + if len(creds.EncryptedEnv) > 0 && s.encKey != "" { + encrypted, err := crypto.Encrypt(string(creds.EncryptedEnv), s.encKey) + if err != nil { + return fmt.Errorf("encrypt context credential env: %w", err) + } + envBytes = []byte(encrypted) + } else { + envBytes = creds.EncryptedEnv + } + now := time.Now().UTC().Format(time.RFC3339Nano) + _, err := s.db.ExecContext(ctx, + `INSERT INTO secure_cli_context_credentials + (id, tenant_id, channel_instance_id, scope_type, scope_key, binary_id, + encrypted_env, metadata, credential_type, host_scope, created_by, created_at, updated_at) + VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?) + ON CONFLICT (tenant_id, channel_instance_id, scope_type, scope_key, binary_id) + DO UPDATE SET encrypted_env = excluded.encrypted_env, + metadata = excluded.metadata, + credential_type = excluded.credential_type, + host_scope = excluded.host_scope, + created_by = excluded.created_by, + updated_at = excluded.updated_at`, + creds.ID, tenantIDForInsert(ctx), creds.ChannelInstanceID, creds.ScopeType, creds.ScopeKey, creds.BinaryID, + envBytes, jsonOrEmptyObject(creds.Metadata), creds.CredentialType, creds.HostScope, creds.CreatedBy, now, now, + ) + return err +} + +func (s *SQLiteSecureCLIStore) GetContextCredentials(ctx context.Context, channelInstanceID uuid.UUID, scopeType, scopeKey string, binaryID uuid.UUID) (*store.SecureCLIContextCredentials, error) { + row := s.db.QueryRowContext(ctx, + `SELECT id, channel_instance_id, scope_type, scope_key, binary_id, encrypted_env, + COALESCE(metadata, '{}'), credential_type, host_scope, created_by, created_at, updated_at + FROM secure_cli_context_credentials + WHERE tenant_id = ? AND channel_instance_id = ? AND scope_type = ? AND scope_key = ? AND binary_id = ?`, + tenantIDForInsert(ctx), channelInstanceID, scopeType, scopeKey, binaryID, + ) + creds, err := s.scanSecureCLIContextCredentials(row) + if errors.Is(err, sql.ErrNoRows) { + return nil, nil + } + return creds, err +} + +func (s *SQLiteSecureCLIStore) DeleteContextCredentials(ctx context.Context, channelInstanceID uuid.UUID, scopeType, scopeKey string, binaryID uuid.UUID) error { + _, err := s.db.ExecContext(ctx, + `DELETE FROM secure_cli_context_credentials + WHERE tenant_id = ? AND channel_instance_id = ? AND scope_type = ? AND scope_key = ? AND binary_id = ?`, + tenantIDForInsert(ctx), channelInstanceID, scopeType, scopeKey, binaryID, + ) + return err +} + +func (s *SQLiteSecureCLIStore) ListContextCredentials(ctx context.Context, channelInstanceID uuid.UUID, scopeType, scopeKey string) ([]store.SecureCLIContextCredentials, error) { + rows, err := s.db.QueryContext(ctx, + `SELECT id, channel_instance_id, scope_type, scope_key, binary_id, encrypted_env, + COALESCE(metadata, '{}'), credential_type, host_scope, created_by, created_at, updated_at + FROM secure_cli_context_credentials + WHERE tenant_id = ? AND channel_instance_id = ? AND scope_type = ? AND scope_key = ? + ORDER BY created_at`, + tenantIDForInsert(ctx), channelInstanceID, scopeType, scopeKey, + ) + if err != nil { + return nil, err + } + defer rows.Close() + result := make([]store.SecureCLIContextCredentials, 0) + for rows.Next() { + creds, err := s.scanSecureCLIContextCredentials(rows) + if err != nil { + return nil, err + } + result = append(result, *creds) + } + return result, rows.Err() +} + +func (s *SQLiteSecureCLIStore) scanSecureCLIContextCredentials(row contextCredentialScanner) (*store.SecureCLIContextCredentials, error) { + var creds store.SecureCLIContextCredentials + var createdAt, updatedAt sqliteTime + if err := row.Scan(&creds.ID, &creds.ChannelInstanceID, &creds.ScopeType, &creds.ScopeKey, &creds.BinaryID, + &creds.EncryptedEnv, &creds.Metadata, &creds.CredentialType, &creds.HostScope, + &creds.CreatedBy, &createdAt, &updatedAt); err != nil { + return nil, err + } + creds.CreatedAt = createdAt.Time + creds.UpdatedAt = updatedAt.Time + if len(creds.EncryptedEnv) > 0 && s.encKey != "" { + if decrypted, err := crypto.Decrypt(string(creds.EncryptedEnv), s.encKey); err == nil { + creds.EncryptedEnv = []byte(decrypted) + } + } + return &creds, nil +} diff --git a/internal/store/sqlitestore/schema.go b/internal/store/sqlitestore/schema.go index a834844b..031f0c18 100644 --- a/internal/store/sqlitestore/schema.go +++ b/internal/store/sqlitestore/schema.go @@ -16,7 +16,7 @@ var schemaSQL string // SchemaVersion is the current SQLite schema version. // Bump this when adding new migration steps below. -const SchemaVersion = 42 +const SchemaVersion = 43 // migrations maps version → SQL to apply when upgrading FROM that version. // schema.sql always represents the LATEST full schema (for fresh DBs). @@ -772,8 +772,88 @@ CREATE INDEX IF NOT EXISTS idx_browser_cookies_expires_at 40: `ALTER TABLE secure_cli_user_credentials ADD COLUMN host_scope TEXT;`, // Version 41 → 42: credential adapter framework — adapter_name on binaries. 41: `ALTER TABLE secure_cli_binaries ADD COLUMN adapter_name TEXT;`, + // Version 42 → 43: channel-context MCP and Secure CLI grants/credentials. + 42: addChannelContextCapabilityTables, } +const addChannelContextCapabilityTables = ` +CREATE TABLE IF NOT EXISTS mcp_context_grants ( + id TEXT NOT NULL PRIMARY KEY, + tenant_id TEXT NOT NULL REFERENCES tenants(id) ON DELETE CASCADE, + channel_instance_id TEXT NOT NULL REFERENCES channel_instances(id) ON DELETE CASCADE, + scope_type VARCHAR(32) NOT NULL, + scope_key VARCHAR(255) NOT NULL DEFAULT '', + server_id TEXT NOT NULL REFERENCES mcp_servers(id) ON DELETE CASCADE, + enabled BOOLEAN NOT NULL DEFAULT 1, + tool_allow TEXT, + tool_deny TEXT, + config_overrides TEXT, + granted_by VARCHAR(255) NOT NULL, + created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')), + updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')), + UNIQUE(tenant_id, channel_instance_id, scope_type, scope_key, server_id) +); +CREATE INDEX IF NOT EXISTS idx_mcp_context_grants_scope ON mcp_context_grants(tenant_id, channel_instance_id, scope_type, scope_key); +CREATE INDEX IF NOT EXISTS idx_mcp_context_grants_server ON mcp_context_grants(server_id); + +CREATE TABLE IF NOT EXISTS mcp_context_credentials ( + id TEXT NOT NULL PRIMARY KEY, + tenant_id TEXT NOT NULL REFERENCES tenants(id) ON DELETE CASCADE, + channel_instance_id TEXT NOT NULL REFERENCES channel_instances(id) ON DELETE CASCADE, + scope_type VARCHAR(32) NOT NULL, + scope_key VARCHAR(255) NOT NULL DEFAULT '', + server_id TEXT NOT NULL REFERENCES mcp_servers(id) ON DELETE CASCADE, + api_key TEXT, + headers BLOB, + env BLOB, + created_by VARCHAR(255) NOT NULL, + created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')), + updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')), + UNIQUE(tenant_id, channel_instance_id, scope_type, scope_key, server_id) +); +CREATE INDEX IF NOT EXISTS idx_mcp_context_credentials_scope ON mcp_context_credentials(tenant_id, channel_instance_id, scope_type, scope_key); +CREATE INDEX IF NOT EXISTS idx_mcp_context_credentials_server ON mcp_context_credentials(server_id); + +CREATE TABLE IF NOT EXISTS secure_cli_context_grants ( + id TEXT NOT NULL PRIMARY KEY, + tenant_id TEXT NOT NULL REFERENCES tenants(id) ON DELETE CASCADE, + channel_instance_id TEXT NOT NULL REFERENCES channel_instances(id) ON DELETE CASCADE, + scope_type VARCHAR(32) NOT NULL, + scope_key VARCHAR(255) NOT NULL DEFAULT '', + binary_id TEXT NOT NULL REFERENCES secure_cli_binaries(id) ON DELETE CASCADE, + deny_args TEXT, + deny_verbose TEXT, + timeout_seconds INTEGER, + tips TEXT, + encrypted_env BLOB, + enabled BOOLEAN NOT NULL DEFAULT 1, + granted_by VARCHAR(255) NOT NULL, + created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')), + updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')), + UNIQUE(tenant_id, channel_instance_id, scope_type, scope_key, binary_id) +); +CREATE INDEX IF NOT EXISTS idx_secure_cli_context_grants_scope ON secure_cli_context_grants(tenant_id, channel_instance_id, scope_type, scope_key); +CREATE INDEX IF NOT EXISTS idx_secure_cli_context_grants_binary ON secure_cli_context_grants(binary_id); + +CREATE TABLE IF NOT EXISTS secure_cli_context_credentials ( + id TEXT NOT NULL PRIMARY KEY, + tenant_id TEXT NOT NULL REFERENCES tenants(id) ON DELETE CASCADE, + channel_instance_id TEXT NOT NULL REFERENCES channel_instances(id) ON DELETE CASCADE, + scope_type VARCHAR(32) NOT NULL, + scope_key VARCHAR(255) NOT NULL DEFAULT '', + binary_id TEXT NOT NULL REFERENCES secure_cli_binaries(id) ON DELETE CASCADE, + encrypted_env BLOB NOT NULL, + metadata TEXT NOT NULL DEFAULT '{}', + credential_type TEXT, + host_scope TEXT, + created_by VARCHAR(255) NOT NULL, + created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')), + updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')), + UNIQUE(tenant_id, channel_instance_id, scope_type, scope_key, binary_id) +); +CREATE INDEX IF NOT EXISTS idx_secure_cli_context_credentials_scope ON secure_cli_context_credentials(tenant_id, channel_instance_id, scope_type, scope_key); +CREATE INDEX IF NOT EXISTS idx_secure_cli_context_credentials_binary ON secure_cli_context_credentials(binary_id);` + // addHooksTables is the SQLite incremental migration for schema v19 → v20. // Mirrors PG migrations 000052–000055 (consolidated — desktop never shipped // with intermediate agent_hooks / agent_hook_agents names). diff --git a/internal/store/sqlitestore/schema.sql b/internal/store/sqlitestore/schema.sql index 7b837760..4423301b 100644 --- a/internal/store/sqlitestore/schema.sql +++ b/internal/store/sqlitestore/schema.sql @@ -775,6 +775,91 @@ CREATE INDEX IF NOT EXISTS idx_channel_instances_type ON channel_instances(chann CREATE INDEX IF NOT EXISTS idx_channel_instances_agent ON channel_instances(agent_id); CREATE INDEX IF NOT EXISTS idx_channel_instances_tenant ON channel_instances(tenant_id); +-- ============================================================ +-- Tables: channel-context MCP and Secure CLI capabilities +-- ============================================================ + +CREATE TABLE IF NOT EXISTS mcp_context_grants ( + id TEXT NOT NULL PRIMARY KEY, + tenant_id TEXT NOT NULL REFERENCES tenants(id) ON DELETE CASCADE, + channel_instance_id TEXT NOT NULL REFERENCES channel_instances(id) ON DELETE CASCADE, + scope_type VARCHAR(32) NOT NULL, + scope_key VARCHAR(255) NOT NULL DEFAULT '', + server_id TEXT NOT NULL REFERENCES mcp_servers(id) ON DELETE CASCADE, + enabled BOOLEAN NOT NULL DEFAULT 1, + tool_allow TEXT, + tool_deny TEXT, + config_overrides TEXT, + granted_by VARCHAR(255) NOT NULL, + created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')), + updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')), + UNIQUE(tenant_id, channel_instance_id, scope_type, scope_key, server_id) +); + +CREATE INDEX IF NOT EXISTS idx_mcp_context_grants_scope ON mcp_context_grants(tenant_id, channel_instance_id, scope_type, scope_key); +CREATE INDEX IF NOT EXISTS idx_mcp_context_grants_server ON mcp_context_grants(server_id); + +CREATE TABLE IF NOT EXISTS mcp_context_credentials ( + id TEXT NOT NULL PRIMARY KEY, + tenant_id TEXT NOT NULL REFERENCES tenants(id) ON DELETE CASCADE, + channel_instance_id TEXT NOT NULL REFERENCES channel_instances(id) ON DELETE CASCADE, + scope_type VARCHAR(32) NOT NULL, + scope_key VARCHAR(255) NOT NULL DEFAULT '', + server_id TEXT NOT NULL REFERENCES mcp_servers(id) ON DELETE CASCADE, + api_key TEXT, + headers BLOB, + env BLOB, + created_by VARCHAR(255) NOT NULL, + created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')), + updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')), + UNIQUE(tenant_id, channel_instance_id, scope_type, scope_key, server_id) +); + +CREATE INDEX IF NOT EXISTS idx_mcp_context_credentials_scope ON mcp_context_credentials(tenant_id, channel_instance_id, scope_type, scope_key); +CREATE INDEX IF NOT EXISTS idx_mcp_context_credentials_server ON mcp_context_credentials(server_id); + +CREATE TABLE IF NOT EXISTS secure_cli_context_grants ( + id TEXT NOT NULL PRIMARY KEY, + tenant_id TEXT NOT NULL REFERENCES tenants(id) ON DELETE CASCADE, + channel_instance_id TEXT NOT NULL REFERENCES channel_instances(id) ON DELETE CASCADE, + scope_type VARCHAR(32) NOT NULL, + scope_key VARCHAR(255) NOT NULL DEFAULT '', + binary_id TEXT NOT NULL REFERENCES secure_cli_binaries(id) ON DELETE CASCADE, + deny_args TEXT, + deny_verbose TEXT, + timeout_seconds INTEGER, + tips TEXT, + encrypted_env BLOB, + enabled BOOLEAN NOT NULL DEFAULT 1, + granted_by VARCHAR(255) NOT NULL, + created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')), + updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')), + UNIQUE(tenant_id, channel_instance_id, scope_type, scope_key, binary_id) +); + +CREATE INDEX IF NOT EXISTS idx_secure_cli_context_grants_scope ON secure_cli_context_grants(tenant_id, channel_instance_id, scope_type, scope_key); +CREATE INDEX IF NOT EXISTS idx_secure_cli_context_grants_binary ON secure_cli_context_grants(binary_id); + +CREATE TABLE IF NOT EXISTS secure_cli_context_credentials ( + id TEXT NOT NULL PRIMARY KEY, + tenant_id TEXT NOT NULL REFERENCES tenants(id) ON DELETE CASCADE, + channel_instance_id TEXT NOT NULL REFERENCES channel_instances(id) ON DELETE CASCADE, + scope_type VARCHAR(32) NOT NULL, + scope_key VARCHAR(255) NOT NULL DEFAULT '', + binary_id TEXT NOT NULL REFERENCES secure_cli_binaries(id) ON DELETE CASCADE, + encrypted_env BLOB NOT NULL, + metadata TEXT NOT NULL DEFAULT '{}', + credential_type TEXT, + host_scope TEXT, + created_by VARCHAR(255) NOT NULL, + created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')), + updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')), + UNIQUE(tenant_id, channel_instance_id, scope_type, scope_key, binary_id) +); + +CREATE INDEX IF NOT EXISTS idx_secure_cli_context_credentials_scope ON secure_cli_context_credentials(tenant_id, channel_instance_id, scope_type, scope_key); +CREATE INDEX IF NOT EXISTS idx_secure_cli_context_credentials_binary ON secure_cli_context_credentials(binary_id); + -- ============================================================ -- Table: config_secrets -- PK changed to (key, tenant_id) in migration 27 Phase I diff --git a/internal/upgrade/version.go b/internal/upgrade/version.go index 0033f111..279d5bfd 100644 --- a/internal/upgrade/version.go +++ b/internal/upgrade/version.go @@ -2,4 +2,4 @@ package upgrade // RequiredSchemaVersion is the schema migration version this binary requires. // Bump this whenever adding a new SQL migration file. -const RequiredSchemaVersion uint = 73 +const RequiredSchemaVersion uint = 74 diff --git a/migrations/000074_channel_context_capabilities.down.sql b/migrations/000074_channel_context_capabilities.down.sql new file mode 100644 index 00000000..49631359 --- /dev/null +++ b/migrations/000074_channel_context_capabilities.down.sql @@ -0,0 +1,4 @@ +DROP TABLE IF EXISTS secure_cli_context_credentials; +DROP TABLE IF EXISTS secure_cli_context_grants; +DROP TABLE IF EXISTS mcp_context_credentials; +DROP TABLE IF EXISTS mcp_context_grants; diff --git a/migrations/000074_channel_context_capabilities.up.sql b/migrations/000074_channel_context_capabilities.up.sql new file mode 100644 index 00000000..670b6b7d --- /dev/null +++ b/migrations/000074_channel_context_capabilities.up.sql @@ -0,0 +1,80 @@ +CREATE TABLE mcp_context_grants ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v7(), + tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE, + channel_instance_id UUID NOT NULL REFERENCES channel_instances(id) ON DELETE CASCADE, + scope_type VARCHAR(32) NOT NULL, + scope_key VARCHAR(255) NOT NULL DEFAULT '', + server_id UUID NOT NULL REFERENCES mcp_servers(id) ON DELETE CASCADE, + enabled BOOLEAN NOT NULL DEFAULT true, + tool_allow JSONB, + tool_deny JSONB, + config_overrides JSONB, + granted_by VARCHAR(255) NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + UNIQUE(tenant_id, channel_instance_id, scope_type, scope_key, server_id) +); + +CREATE INDEX idx_mcp_context_grants_scope ON mcp_context_grants(tenant_id, channel_instance_id, scope_type, scope_key); +CREATE INDEX idx_mcp_context_grants_server ON mcp_context_grants(server_id); + +CREATE TABLE mcp_context_credentials ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v7(), + tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE, + channel_instance_id UUID NOT NULL REFERENCES channel_instances(id) ON DELETE CASCADE, + scope_type VARCHAR(32) NOT NULL, + scope_key VARCHAR(255) NOT NULL DEFAULT '', + server_id UUID NOT NULL REFERENCES mcp_servers(id) ON DELETE CASCADE, + api_key TEXT, + headers BYTEA, + env BYTEA, + created_by VARCHAR(255) NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + UNIQUE(tenant_id, channel_instance_id, scope_type, scope_key, server_id) +); + +CREATE INDEX idx_mcp_context_credentials_scope ON mcp_context_credentials(tenant_id, channel_instance_id, scope_type, scope_key); +CREATE INDEX idx_mcp_context_credentials_server ON mcp_context_credentials(server_id); + +CREATE TABLE secure_cli_context_grants ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v7(), + tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE, + channel_instance_id UUID NOT NULL REFERENCES channel_instances(id) ON DELETE CASCADE, + scope_type VARCHAR(32) NOT NULL, + scope_key VARCHAR(255) NOT NULL DEFAULT '', + binary_id UUID NOT NULL REFERENCES secure_cli_binaries(id) ON DELETE CASCADE, + deny_args JSONB, + deny_verbose JSONB, + timeout_seconds INTEGER, + tips TEXT, + encrypted_env BYTEA, + enabled BOOLEAN NOT NULL DEFAULT true, + granted_by VARCHAR(255) NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + UNIQUE(tenant_id, channel_instance_id, scope_type, scope_key, binary_id) +); + +CREATE INDEX idx_secure_cli_context_grants_scope ON secure_cli_context_grants(tenant_id, channel_instance_id, scope_type, scope_key); +CREATE INDEX idx_secure_cli_context_grants_binary ON secure_cli_context_grants(binary_id); + +CREATE TABLE secure_cli_context_credentials ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v7(), + tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE, + channel_instance_id UUID NOT NULL REFERENCES channel_instances(id) ON DELETE CASCADE, + scope_type VARCHAR(32) NOT NULL, + scope_key VARCHAR(255) NOT NULL DEFAULT '', + binary_id UUID NOT NULL REFERENCES secure_cli_binaries(id) ON DELETE CASCADE, + encrypted_env BYTEA NOT NULL, + metadata JSONB NOT NULL DEFAULT '{}'::jsonb, + credential_type TEXT, + host_scope TEXT, + created_by VARCHAR(255) NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + UNIQUE(tenant_id, channel_instance_id, scope_type, scope_key, binary_id) +); + +CREATE INDEX idx_secure_cli_context_credentials_scope ON secure_cli_context_credentials(tenant_id, channel_instance_id, scope_type, scope_key); +CREATE INDEX idx_secure_cli_context_credentials_binary ON secure_cli_context_credentials(binary_id); From bd34e2917e39ce21f54389dcc1885b3b576ce305 Mon Sep 17 00:00:00 2001 From: Goon Date: Fri, 29 May 2026 19:29:31 +0700 Subject: [PATCH 011/105] feat(channels): manage context scoped capabilities --- .../http/channel_instance_capabilities.go | 162 +++++- ...annel_instance_context_capability_admin.go | 518 ++++++++++++++++++ internal/http/channel_instances.go | 20 + 3 files changed, 688 insertions(+), 12 deletions(-) create mode 100644 internal/http/channel_instance_context_capability_admin.go diff --git a/internal/http/channel_instance_capabilities.go b/internal/http/channel_instance_capabilities.go index 9891a1ed..de772df7 100644 --- a/internal/http/channel_instance_capabilities.go +++ b/internal/http/channel_instance_capabilities.go @@ -1,6 +1,7 @@ package http import ( + "encoding/json" "log/slog" "net/http" @@ -42,14 +43,16 @@ func (h *ChannelInstancesHandler) handleListContextCapabilities(w http.ResponseW userID = store.CredentialUserIDFromContext(r.Context()) } - mcpRows, err := h.listChannelMCPRows(r, inst, userID) + scope := store.ChannelContextScope{ChannelInstanceID: inst.ID, ChannelInstanceName: inst.Name, ScopeType: scopeType, ScopeKey: scopeKey} + + mcpRows, err := h.listChannelMCPRows(r, inst, scope, userID) if err != nil { slog.Error("channel_instances.capabilities_mcp", "error", err) locale := store.LocaleFromContext(r.Context()) writeError(w, http.StatusInternalServerError, protocol.ErrInternal, i18n.T(locale, i18n.MsgFailedToList, "MCP capabilities")) return } - cliRows, err := h.listChannelCLIRows(r, inst, userID) + cliRows, err := h.listChannelCLIRows(r, inst, scope, userID) if err != nil { slog.Error("channel_instances.capabilities_cli", "error", err) locale := store.LocaleFromContext(r.Context()) @@ -66,7 +69,7 @@ func (h *ChannelInstancesHandler) handleListContextCapabilities(w http.ResponseW }) } -func (h *ChannelInstancesHandler) listChannelMCPRows(r *http.Request, inst *store.ChannelInstanceData, userID string) ([]channelCapabilityDTO, error) { +func (h *ChannelInstancesHandler) listChannelMCPRows(r *http.Request, inst *store.ChannelInstanceData, scope store.ChannelContextScope, userID string) ([]channelCapabilityDTO, error) { if h.mcpStore == nil { return []channelCapabilityDTO{}, nil } @@ -74,10 +77,39 @@ func (h *ChannelInstancesHandler) listChannelMCPRows(r *http.Request, inst *stor if err != nil { return nil, err } + contextGrants := map[uuidString]store.MCPContextGrant{} + if h.mcpContextStore != nil { + grants, err := h.mcpContextStore.ListContextGrants(r.Context(), inst.ID, scope.ScopeType, scope.ScopeKey) + if err != nil { + return nil, err + } + for _, grant := range grants { + contextGrants[uuidString(grant.ServerID.String())] = grant + } + } + contextCreds := map[uuidString]store.MCPContextCredentials{} + if h.mcpContextStore != nil { + creds, err := h.mcpContextStore.ListContextCredentials(r.Context(), inst.ID, scope.ScopeType, scope.ScopeKey) + if err != nil { + return nil, err + } + for _, cred := range creds { + contextCreds[uuidString(cred.ServerID.String())] = cred + } + } rows := make([]channelCapabilityDTO, 0, len(accessible)) + seen := map[uuidString]bool{} for _, info := range accessible { + idKey := uuidString(info.Server.ID.String()) + seen[idKey] = true credentialSource := "global" hasCredential := info.Server.APIKey != "" || len(info.Server.Headers) > 0 || len(info.Server.Env) > 0 + contextCredential := false + if cred, ok := contextCreds[idKey]; ok { + credentialSource = scope.ScopeType + hasCredential = cred.APIKey != "" || len(cred.Headers) > 0 || len(cred.Env) > 0 + contextCredential = true + } if userID != "" { if creds, err := h.mcpStore.GetUserCredentials(r.Context(), info.Server.ID, userID); err == nil && creds != nil { if creds.APIKey != "" || len(creds.Headers) > 0 || len(creds.Env) > 0 { @@ -86,25 +118,62 @@ func (h *ChannelInstancesHandler) listChannelMCPRows(r *http.Request, inst *stor } } } + source := "agent" + enabled := info.Server.Enabled + toolAllow := info.ToolAllow + toolDeny := info.ToolDeny + contextGrant := false + if grant, ok := contextGrants[idKey]; ok { + source = scope.ScopeType + enabled = grant.Enabled + toolAllow = decodeStringList(grant.ToolAllow) + toolDeny = decodeStringList(grant.ToolDeny) + contextGrant = true + } rows = append(rows, channelCapabilityDTO{ Type: "mcp_server", ID: info.Server.ID.String(), Name: info.Server.Name, DisplayName: info.Server.DisplayName, - Enabled: info.Server.Enabled, - Source: "agent", - ToolAllow: info.ToolAllow, - ToolDeny: info.ToolDeny, + Enabled: enabled, + Source: source, + ToolAllow: toolAllow, + ToolDeny: toolDeny, CredentialSource: credentialSource, HasCredential: hasCredential, - ContextGrant: false, - ContextCredentials: false, + ContextGrant: contextGrant, + ContextCredentials: contextCredential, + }) + } + for _, grant := range contextGrants { + idKey := uuidString(grant.ServerID.String()) + if seen[idKey] { + continue + } + server, err := h.mcpStore.GetServer(r.Context(), grant.ServerID) + if err != nil || server == nil { + continue + } + cred, hasCredRow := contextCreds[idKey] + rows = append(rows, channelCapabilityDTO{ + Type: "mcp_server", + ID: server.ID.String(), + Name: server.Name, + DisplayName: server.DisplayName, + Enabled: server.Enabled && grant.Enabled, + Source: scope.ScopeType, + ToolAllow: decodeStringList(grant.ToolAllow), + ToolDeny: decodeStringList(grant.ToolDeny), + CredentialSource: scope.ScopeType, + HasCredential: hasCredRow && (cred.APIKey != "" || len(cred.Headers) > 0 || len(cred.Env) > 0), + ContextGrant: true, + ContextCredentials: hasCredRow, }) } return rows, nil } -func (h *ChannelInstancesHandler) listChannelCLIRows(r *http.Request, inst *store.ChannelInstanceData, userID string) ([]channelCapabilityDTO, error) { +func (h *ChannelInstancesHandler) listChannelCLIRows(r *http.Request, inst *store.ChannelInstanceData, scope store.ChannelContextScope, userID string) ([]channelCapabilityDTO, error) { if h.secureCLIStore == nil { return []channelCapabilityDTO{}, nil } @@ -112,14 +181,49 @@ func (h *ChannelInstancesHandler) listChannelCLIRows(r *http.Request, inst *stor if err != nil { return nil, err } + contextGrants := map[uuidString]store.SecureCLIContextGrant{} + if h.cliContextStore != nil { + grants, err := h.cliContextStore.ListContextGrants(r.Context(), inst.ID, scope.ScopeType, scope.ScopeKey) + if err != nil { + return nil, err + } + for _, grant := range grants { + contextGrants[uuidString(grant.BinaryID.String())] = grant + } + } + contextCreds := map[uuidString]store.SecureCLIContextCredentials{} + if h.cliContextStore != nil { + creds, err := h.cliContextStore.ListContextCredentials(r.Context(), inst.ID, scope.ScopeType, scope.ScopeKey) + if err != nil { + return nil, err + } + for _, cred := range creds { + contextCreds[uuidString(cred.BinaryID.String())] = cred + } + } rows := make([]channelCapabilityDTO, 0, len(binaries)) + seen := map[uuidString]bool{} for _, b := range binaries { + idKey := uuidString(b.ID.String()) + seen[idKey] = true source := "agent" if b.IsGlobal { source = "global" } credentialSource := source hasCredential := len(b.EncryptedEnv) > 0 + contextGrant := false + if grant, ok := contextGrants[idKey]; ok { + source = scope.ScopeType + contextGrant = true + b.Enabled = b.Enabled && grant.Enabled + } + contextCredential := false + if cred, ok := contextCreds[idKey]; ok { + credentialSource = scope.ScopeType + hasCredential = len(cred.EncryptedEnv) > 0 + contextCredential = true + } if userID != "" { if creds, err := h.secureCLIStore.GetUserCredentials(r.Context(), b.ID, userID); err == nil && creds != nil && len(creds.EncryptedEnv) > 0 { credentialSource = "user" @@ -135,9 +239,43 @@ func (h *ChannelInstancesHandler) listChannelCLIRows(r *http.Request, inst *stor Source: source, CredentialSource: credentialSource, HasCredential: hasCredential, - ContextGrant: false, - ContextCredentials: false, + ContextGrant: contextGrant, + ContextCredentials: contextCredential, + }) + } + for _, grant := range contextGrants { + idKey := uuidString(grant.BinaryID.String()) + if seen[idKey] { + continue + } + binary, err := h.secureCLIStore.Get(r.Context(), grant.BinaryID) + if err != nil || binary == nil { + continue + } + _, hasCredRow := contextCreds[idKey] + rows = append(rows, channelCapabilityDTO{ + Type: "secure_cli", + ID: binary.ID.String(), + Name: binary.BinaryName, + DisplayName: binary.Description, + Enabled: binary.Enabled && grant.Enabled, + Source: scope.ScopeType, + CredentialSource: scope.ScopeType, + HasCredential: hasCredRow, + ContextGrant: true, + ContextCredentials: hasCredRow, }) } return rows, nil } + +type uuidString string + +func decodeStringList(raw json.RawMessage) []string { + if len(raw) == 0 || string(raw) == "null" { + return nil + } + var list []string + _ = json.Unmarshal(raw, &list) + return list +} diff --git a/internal/http/channel_instance_context_capability_admin.go b/internal/http/channel_instance_context_capability_admin.go new file mode 100644 index 00000000..f6bbee28 --- /dev/null +++ b/internal/http/channel_instance_context_capability_admin.go @@ -0,0 +1,518 @@ +package http + +import ( + "encoding/json" + "log/slog" + "net/http" + + "github.com/google/uuid" + + "github.com/nextlevelbuilder/goclaw/internal/i18n" + "github.com/nextlevelbuilder/goclaw/internal/store" + "github.com/nextlevelbuilder/goclaw/pkg/protocol" +) + +type channelContextPath struct { + Instance *store.ChannelInstanceData + ScopeType string + ScopeKey string +} + +func (h *ChannelInstancesHandler) resolveContextPath(w http.ResponseWriter, r *http.Request) (channelContextPath, bool) { + inst, ok := h.resolveInstance(w, r) + if !ok { + return channelContextPath{}, false + } + scopeType := r.PathValue("scopeType") + scopeKey := r.PathValue("scopeKey") + if !validateHTTPChannelScope(scopeType, scopeKey) { + locale := store.LocaleFromContext(r.Context()) + writeError(w, http.StatusBadRequest, protocol.ErrInvalidRequest, i18n.T(locale, i18n.MsgInvalidRequest, "invalid scope")) + return channelContextPath{}, false + } + return channelContextPath{Instance: inst, ScopeType: scopeType, ScopeKey: scopeKey}, true +} + +func validateHTTPChannelScope(scopeType, scopeKey string) bool { + scope := store.ChannelContextScope{ChannelInstanceName: "placeholder", ScopeType: scopeType, ScopeKey: scopeKey} + return scope.Valid() +} + +func (h *ChannelInstancesHandler) requireContextAdmin(w http.ResponseWriter, r *http.Request) bool { + return requireTenantAdmin(w, r, h.tenantStore) +} + +type contextMCPGrantRequest struct { + Enabled *bool `json:"enabled,omitempty"` + ToolAllow json.RawMessage `json:"tool_allow,omitempty"` + ToolDeny json.RawMessage `json:"tool_deny,omitempty"` + ConfigOverrides json.RawMessage `json:"config_overrides,omitempty"` +} + +func (h *ChannelInstancesHandler) handleListContextMCPGrants(w http.ResponseWriter, r *http.Request) { + if h.mcpContextStore == nil { + writeJSON(w, http.StatusNotImplemented, map[string]string{"error": "MCP context store is not available"}) + return + } + path, ok := h.resolveContextPath(w, r) + if !ok { + return + } + grants, err := h.mcpContextStore.ListContextGrants(r.Context(), path.Instance.ID, path.ScopeType, path.ScopeKey) + if err != nil { + slog.Error("channel_context.mcp_grants.list", "error", err) + locale := store.LocaleFromContext(r.Context()) + writeError(w, http.StatusInternalServerError, protocol.ErrInternal, i18n.T(locale, i18n.MsgFailedToList, "MCP context grants")) + return + } + writeJSON(w, http.StatusOK, map[string]any{"grants": grants}) +} + +func (h *ChannelInstancesHandler) handleUpsertContextMCPGrant(w http.ResponseWriter, r *http.Request) { + if h.mcpContextStore == nil { + writeJSON(w, http.StatusNotImplemented, map[string]string{"error": "MCP context store is not available"}) + return + } + if !h.requireContextAdmin(w, r) { + return + } + path, ok := h.resolveContextPath(w, r) + if !ok { + return + } + locale := store.LocaleFromContext(r.Context()) + serverID, err := uuid.Parse(r.PathValue("serverID")) + if err != nil { + writeError(w, http.StatusBadRequest, protocol.ErrInvalidRequest, i18n.T(locale, i18n.MsgInvalidID, "server")) + return + } + var req contextMCPGrantRequest + if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 1<<20)).Decode(&req); err != nil { + writeError(w, http.StatusBadRequest, protocol.ErrInvalidRequest, i18n.T(locale, i18n.MsgInvalidJSON)) + return + } + enabled := true + if req.Enabled != nil { + enabled = *req.Enabled + } + grant := &store.MCPContextGrant{ + ChannelInstanceID: path.Instance.ID, + ScopeType: path.ScopeType, + ScopeKey: path.ScopeKey, + ServerID: serverID, + Enabled: enabled, + ToolAllow: req.ToolAllow, + ToolDeny: req.ToolDeny, + ConfigOverrides: req.ConfigOverrides, + GrantedBy: store.UserIDFromContext(r.Context()), + } + if err := h.mcpContextStore.UpsertContextGrant(r.Context(), grant); err != nil { + slog.Error("channel_context.mcp_grants.upsert", "error", err) + writeError(w, http.StatusInternalServerError, protocol.ErrInternal, err.Error()) + return + } + emitAudit(h.msgBus, r, "channel_context.mcp_grant_upserted", "mcp_server", serverID.String()) + writeJSON(w, http.StatusOK, map[string]any{"status": "updated"}) +} + +func (h *ChannelInstancesHandler) handleDeleteContextMCPGrant(w http.ResponseWriter, r *http.Request) { + if h.mcpContextStore == nil { + writeJSON(w, http.StatusNotImplemented, map[string]string{"error": "MCP context store is not available"}) + return + } + if !h.requireContextAdmin(w, r) { + return + } + path, ok := h.resolveContextPath(w, r) + if !ok { + return + } + locale := store.LocaleFromContext(r.Context()) + serverID, err := uuid.Parse(r.PathValue("serverID")) + if err != nil { + writeError(w, http.StatusBadRequest, protocol.ErrInvalidRequest, i18n.T(locale, i18n.MsgInvalidID, "server")) + return + } + if err := h.mcpContextStore.DeleteContextGrant(r.Context(), path.Instance.ID, path.ScopeType, path.ScopeKey, serverID); err != nil { + slog.Error("channel_context.mcp_grants.delete", "error", err) + writeError(w, http.StatusInternalServerError, protocol.ErrInternal, err.Error()) + return + } + emitAudit(h.msgBus, r, "channel_context.mcp_grant_deleted", "mcp_server", serverID.String()) + writeJSON(w, http.StatusOK, map[string]any{"status": "deleted"}) +} + +type contextMCPCredentialSummary struct { + ChannelInstanceID uuid.UUID `json:"channel_instance_id"` + ScopeType string `json:"scope_type"` + ScopeKey string `json:"scope_key"` + ServerID uuid.UUID `json:"server_id"` + HasAPIKey bool `json:"has_api_key"` + HasHeaders bool `json:"has_headers"` + HasEnv bool `json:"has_env"` + CreatedBy string `json:"created_by"` +} + +func summarizeMCPCredentials(creds store.MCPContextCredentials) contextMCPCredentialSummary { + return contextMCPCredentialSummary{ + ChannelInstanceID: creds.ChannelInstanceID, + ScopeType: creds.ScopeType, + ScopeKey: creds.ScopeKey, + ServerID: creds.ServerID, + HasAPIKey: creds.APIKey != "", + HasHeaders: len(creds.Headers) > 0, + HasEnv: len(creds.Env) > 0, + CreatedBy: creds.CreatedBy, + } +} + +func (h *ChannelInstancesHandler) handleListContextMCPCredentials(w http.ResponseWriter, r *http.Request) { + if h.mcpContextStore == nil { + writeJSON(w, http.StatusNotImplemented, map[string]string{"error": "MCP context store is not available"}) + return + } + path, ok := h.resolveContextPath(w, r) + if !ok { + return + } + creds, err := h.mcpContextStore.ListContextCredentials(r.Context(), path.Instance.ID, path.ScopeType, path.ScopeKey) + if err != nil { + slog.Error("channel_context.mcp_credentials.list", "error", err) + locale := store.LocaleFromContext(r.Context()) + writeError(w, http.StatusInternalServerError, protocol.ErrInternal, i18n.T(locale, i18n.MsgFailedToList, "MCP context credentials")) + return + } + out := make([]contextMCPCredentialSummary, 0, len(creds)) + for _, c := range creds { + out = append(out, summarizeMCPCredentials(c)) + } + writeJSON(w, http.StatusOK, map[string]any{"credentials": out}) +} + +func (h *ChannelInstancesHandler) handleSetContextMCPCredentials(w http.ResponseWriter, r *http.Request) { + if h.mcpContextStore == nil { + writeJSON(w, http.StatusNotImplemented, map[string]string{"error": "MCP context store is not available"}) + return + } + if !h.requireContextAdmin(w, r) { + return + } + path, ok := h.resolveContextPath(w, r) + if !ok { + return + } + locale := store.LocaleFromContext(r.Context()) + serverID, err := uuid.Parse(r.PathValue("serverID")) + if err != nil { + writeError(w, http.StatusBadRequest, protocol.ErrInvalidRequest, i18n.T(locale, i18n.MsgInvalidID, "server")) + return + } + var creds store.MCPContextCredentials + if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 1<<20)).Decode(&creds); err != nil { + writeError(w, http.StatusBadRequest, protocol.ErrInvalidRequest, i18n.T(locale, i18n.MsgInvalidJSON)) + return + } + creds.ChannelInstanceID = path.Instance.ID + creds.ScopeType = path.ScopeType + creds.ScopeKey = path.ScopeKey + creds.ServerID = serverID + creds.CreatedBy = store.UserIDFromContext(r.Context()) + if err := h.mcpContextStore.SetContextCredentials(r.Context(), &creds); err != nil { + slog.Error("channel_context.mcp_credentials.set", "error", err) + writeError(w, http.StatusInternalServerError, protocol.ErrInternal, err.Error()) + return + } + emitAudit(h.msgBus, r, "channel_context.mcp_credentials_set", "mcp_server", serverID.String()) + writeJSON(w, http.StatusOK, map[string]any{"status": "updated"}) +} + +func (h *ChannelInstancesHandler) handleDeleteContextMCPCredentials(w http.ResponseWriter, r *http.Request) { + if h.mcpContextStore == nil { + writeJSON(w, http.StatusNotImplemented, map[string]string{"error": "MCP context store is not available"}) + return + } + if !h.requireContextAdmin(w, r) { + return + } + path, ok := h.resolveContextPath(w, r) + if !ok { + return + } + locale := store.LocaleFromContext(r.Context()) + serverID, err := uuid.Parse(r.PathValue("serverID")) + if err != nil { + writeError(w, http.StatusBadRequest, protocol.ErrInvalidRequest, i18n.T(locale, i18n.MsgInvalidID, "server")) + return + } + if err := h.mcpContextStore.DeleteContextCredentials(r.Context(), path.Instance.ID, path.ScopeType, path.ScopeKey, serverID); err != nil { + slog.Error("channel_context.mcp_credentials.delete", "error", err) + writeError(w, http.StatusInternalServerError, protocol.ErrInternal, err.Error()) + return + } + emitAudit(h.msgBus, r, "channel_context.mcp_credentials_deleted", "mcp_server", serverID.String()) + writeJSON(w, http.StatusOK, map[string]any{"status": "deleted"}) +} + +type contextCLIGrantRequest struct { + Enabled *bool `json:"enabled,omitempty"` + EnvVars json.RawMessage `json:"env_vars,omitempty"` + DenyArgs *json.RawMessage `json:"deny_args,omitempty"` + DenyVerbose *json.RawMessage `json:"deny_verbose,omitempty"` + TimeoutSeconds *int `json:"timeout_seconds,omitempty"` + Tips *string `json:"tips,omitempty"` +} + +func (h *ChannelInstancesHandler) handleListContextCLIGrants(w http.ResponseWriter, r *http.Request) { + if h.cliContextStore == nil { + writeJSON(w, http.StatusNotImplemented, map[string]string{"error": "Secure CLI context store is not available"}) + return + } + path, ok := h.resolveContextPath(w, r) + if !ok { + return + } + grants, err := h.cliContextStore.ListContextGrants(r.Context(), path.Instance.ID, path.ScopeType, path.ScopeKey) + if err != nil { + slog.Error("channel_context.cli_grants.list", "error", err) + locale := store.LocaleFromContext(r.Context()) + writeError(w, http.StatusInternalServerError, protocol.ErrInternal, i18n.T(locale, i18n.MsgFailedToList, "CLI context grants")) + return + } + for i := range grants { + populateContextGrantEnvFields(&grants[i]) + } + writeJSON(w, http.StatusOK, map[string]any{"grants": grants}) +} + +func populateContextGrantEnvFields(g *store.SecureCLIContextGrant) { + if len(g.EncryptedEnv) == 0 { + g.EnvKeys = []string{} + g.Env = nil + g.EnvSet = false + return + } + g.EnvKeys = store.SecureCLIEnvKeys(g.EncryptedEnv) + g.Env = store.SanitizeSecureCLIEnvJSON(g.EncryptedEnv) + g.EnvSet = len(g.EnvKeys) > 0 +} + +func (h *ChannelInstancesHandler) handleUpsertContextCLIGrant(w http.ResponseWriter, r *http.Request) { + if h.cliContextStore == nil { + writeJSON(w, http.StatusNotImplemented, map[string]string{"error": "Secure CLI context store is not available"}) + return + } + if !h.requireContextAdmin(w, r) { + return + } + path, ok := h.resolveContextPath(w, r) + if !ok { + return + } + locale := store.LocaleFromContext(r.Context()) + binaryID, err := uuid.Parse(r.PathValue("binaryID")) + if err != nil { + writeError(w, http.StatusBadRequest, protocol.ErrInvalidRequest, i18n.T(locale, i18n.MsgInvalidID, "credential")) + return + } + var req contextCLIGrantRequest + if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 1<<20)).Decode(&req); err != nil { + writeError(w, http.StatusBadRequest, protocol.ErrInvalidRequest, i18n.T(locale, i18n.MsgInvalidJSON)) + return + } + enabled := true + if req.Enabled != nil { + enabled = *req.Enabled + } + var envJSON []byte + if len(req.EnvVars) > 0 { + var ok bool + envJSON, ok = validateAndSerializeEnvVars(w, locale, req.EnvVars) + if !ok { + return + } + } + grant := &store.SecureCLIContextGrant{ + ChannelInstanceID: path.Instance.ID, + ScopeType: path.ScopeType, + ScopeKey: path.ScopeKey, + BinaryID: binaryID, + DenyArgs: req.DenyArgs, + DenyVerbose: req.DenyVerbose, + TimeoutSeconds: req.TimeoutSeconds, + Tips: req.Tips, + EncryptedEnv: envJSON, + Enabled: enabled, + GrantedBy: store.UserIDFromContext(r.Context()), + } + if err := h.cliContextStore.UpsertContextGrant(r.Context(), grant); err != nil { + slog.Error("channel_context.cli_grants.upsert", "error", err) + writeError(w, http.StatusInternalServerError, protocol.ErrInternal, err.Error()) + return + } + emitAudit(h.msgBus, r, "channel_context.cli_grant_upserted", "secure_cli", binaryID.String()) + writeJSON(w, http.StatusOK, map[string]any{"status": "updated"}) +} + +func (h *ChannelInstancesHandler) handleDeleteContextCLIGrant(w http.ResponseWriter, r *http.Request) { + if h.cliContextStore == nil { + writeJSON(w, http.StatusNotImplemented, map[string]string{"error": "Secure CLI context store is not available"}) + return + } + if !h.requireContextAdmin(w, r) { + return + } + path, ok := h.resolveContextPath(w, r) + if !ok { + return + } + locale := store.LocaleFromContext(r.Context()) + binaryID, err := uuid.Parse(r.PathValue("binaryID")) + if err != nil { + writeError(w, http.StatusBadRequest, protocol.ErrInvalidRequest, i18n.T(locale, i18n.MsgInvalidID, "credential")) + return + } + if err := h.cliContextStore.DeleteContextGrant(r.Context(), path.Instance.ID, path.ScopeType, path.ScopeKey, binaryID); err != nil { + slog.Error("channel_context.cli_grants.delete", "error", err) + writeError(w, http.StatusInternalServerError, protocol.ErrInternal, err.Error()) + return + } + emitAudit(h.msgBus, r, "channel_context.cli_grant_deleted", "secure_cli", binaryID.String()) + writeJSON(w, http.StatusOK, map[string]any{"status": "deleted"}) +} + +type contextCLICredentialRequest struct { + EnvVars json.RawMessage `json:"env_vars"` + Metadata json.RawMessage `json:"metadata,omitempty"` + CredentialType *string `json:"credential_type,omitempty"` + HostScope *string `json:"host_scope,omitempty"` +} + +type contextCLICredentialSummary struct { + ChannelInstanceID uuid.UUID `json:"channel_instance_id"` + ScopeType string `json:"scope_type"` + ScopeKey string `json:"scope_key"` + BinaryID uuid.UUID `json:"binary_id"` + EnvSet bool `json:"env_set"` + EnvKeys []string `json:"env_keys"` + Metadata json.RawMessage `json:"metadata,omitempty"` + CredentialType *string `json:"credential_type,omitempty"` + HostScope *string `json:"host_scope,omitempty"` + CreatedBy string `json:"created_by"` +} + +func summarizeCLICredentials(creds store.SecureCLIContextCredentials) contextCLICredentialSummary { + keys := store.SecureCLIEnvKeys(creds.EncryptedEnv) + return contextCLICredentialSummary{ + ChannelInstanceID: creds.ChannelInstanceID, + ScopeType: creds.ScopeType, + ScopeKey: creds.ScopeKey, + BinaryID: creds.BinaryID, + EnvSet: len(keys) > 0, + EnvKeys: keys, + Metadata: creds.Metadata, + CredentialType: creds.CredentialType, + HostScope: creds.HostScope, + CreatedBy: creds.CreatedBy, + } +} + +func (h *ChannelInstancesHandler) handleListContextCLICredentials(w http.ResponseWriter, r *http.Request) { + if h.cliContextStore == nil { + writeJSON(w, http.StatusNotImplemented, map[string]string{"error": "Secure CLI context store is not available"}) + return + } + path, ok := h.resolveContextPath(w, r) + if !ok { + return + } + creds, err := h.cliContextStore.ListContextCredentials(r.Context(), path.Instance.ID, path.ScopeType, path.ScopeKey) + if err != nil { + slog.Error("channel_context.cli_credentials.list", "error", err) + locale := store.LocaleFromContext(r.Context()) + writeError(w, http.StatusInternalServerError, protocol.ErrInternal, i18n.T(locale, i18n.MsgFailedToList, "CLI context credentials")) + return + } + out := make([]contextCLICredentialSummary, 0, len(creds)) + for _, c := range creds { + out = append(out, summarizeCLICredentials(c)) + } + writeJSON(w, http.StatusOK, map[string]any{"credentials": out}) +} + +func (h *ChannelInstancesHandler) handleSetContextCLICredentials(w http.ResponseWriter, r *http.Request) { + if h.cliContextStore == nil { + writeJSON(w, http.StatusNotImplemented, map[string]string{"error": "Secure CLI context store is not available"}) + return + } + if !h.requireContextAdmin(w, r) { + return + } + path, ok := h.resolveContextPath(w, r) + if !ok { + return + } + locale := store.LocaleFromContext(r.Context()) + binaryID, err := uuid.Parse(r.PathValue("binaryID")) + if err != nil { + writeError(w, http.StatusBadRequest, protocol.ErrInvalidRequest, i18n.T(locale, i18n.MsgInvalidID, "credential")) + return + } + var req contextCLICredentialRequest + if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 1<<20)).Decode(&req); err != nil { + writeError(w, http.StatusBadRequest, protocol.ErrInvalidRequest, i18n.T(locale, i18n.MsgInvalidJSON)) + return + } + if len(req.EnvVars) == 0 { + writeError(w, http.StatusBadRequest, protocol.ErrInvalidRequest, i18n.T(locale, i18n.MsgRequired, "env_vars")) + return + } + envJSON, ok := validateAndSerializeEnvVars(w, locale, req.EnvVars) + if !ok { + return + } + creds := &store.SecureCLIContextCredentials{ + ChannelInstanceID: path.Instance.ID, + ScopeType: path.ScopeType, + ScopeKey: path.ScopeKey, + BinaryID: binaryID, + EncryptedEnv: envJSON, + Metadata: req.Metadata, + CredentialType: req.CredentialType, + HostScope: req.HostScope, + CreatedBy: store.UserIDFromContext(r.Context()), + } + if err := h.cliContextStore.SetContextCredentials(r.Context(), creds); err != nil { + slog.Error("channel_context.cli_credentials.set", "error", err) + writeError(w, http.StatusInternalServerError, protocol.ErrInternal, err.Error()) + return + } + emitAudit(h.msgBus, r, "channel_context.cli_credentials_set", "secure_cli", binaryID.String()) + writeJSON(w, http.StatusOK, map[string]any{"status": "updated"}) +} + +func (h *ChannelInstancesHandler) handleDeleteContextCLICredentials(w http.ResponseWriter, r *http.Request) { + if h.cliContextStore == nil { + writeJSON(w, http.StatusNotImplemented, map[string]string{"error": "Secure CLI context store is not available"}) + return + } + if !h.requireContextAdmin(w, r) { + return + } + path, ok := h.resolveContextPath(w, r) + if !ok { + return + } + locale := store.LocaleFromContext(r.Context()) + binaryID, err := uuid.Parse(r.PathValue("binaryID")) + if err != nil { + writeError(w, http.StatusBadRequest, protocol.ErrInvalidRequest, i18n.T(locale, i18n.MsgInvalidID, "credential")) + return + } + if err := h.cliContextStore.DeleteContextCredentials(r.Context(), path.Instance.ID, path.ScopeType, path.ScopeKey, binaryID); err != nil { + slog.Error("channel_context.cli_credentials.delete", "error", err) + writeError(w, http.StatusInternalServerError, protocol.ErrInternal, err.Error()) + return + } + emitAudit(h.msgBus, r, "channel_context.cli_credentials_deleted", "secure_cli", binaryID.String()) + writeJSON(w, http.StatusOK, map[string]any{"status": "deleted"}) +} diff --git a/internal/http/channel_instances.go b/internal/http/channel_instances.go index 607c24a1..3fb4e122 100644 --- a/internal/http/channel_instances.go +++ b/internal/http/channel_instances.go @@ -41,6 +41,8 @@ type ChannelInstancesHandler struct { channelMgr *channels.Manager // optional — enables ChannelDestroyer hook on delete mcpStore store.MCPServerStore secureCLIStore store.SecureCLIStore + mcpContextStore store.MCPContextAdminStore + cliContextStore store.SecureCLIContextAdminStore // orphanCleaners is keyed by channel_type; called when channelMgr.GetChannel // returns false. Keeps handler agnostic of per-channel packages. orphanCleaners map[string]OrphanChannelCleaner @@ -71,6 +73,12 @@ func (h *ChannelInstancesHandler) SetChannelManager(mgr *channels.Manager) { func (h *ChannelInstancesHandler) SetCapabilityStores(mcpStore store.MCPServerStore, secureCLIStore store.SecureCLIStore) { h.mcpStore = mcpStore h.secureCLIStore = secureCLIStore + if contextStore, ok := mcpStore.(store.MCPContextAdminStore); ok { + h.mcpContextStore = contextStore + } + if contextStore, ok := secureCLIStore.(store.SecureCLIContextAdminStore); ok { + h.cliContextStore = contextStore + } } // RegisterOrphanCleaner registers a per-channel-type cleanup function that @@ -124,6 +132,18 @@ func (h *ChannelInstancesHandler) RegisterRoutes(mux *http.ServeMux) { mux.HandleFunc("GET /v1/channels/instances/{id}/contexts/{scopeType}/{scopeKey}/members", h.auth(h.handleListContextMembers)) } mux.HandleFunc("GET /v1/channels/instances/{id}/contexts/{scopeType}/{scopeKey}/capabilities", h.auth(h.handleListContextCapabilities)) + mux.HandleFunc("GET /v1/channels/instances/{id}/contexts/{scopeType}/{scopeKey}/mcp-grants", h.auth(h.handleListContextMCPGrants)) + mux.HandleFunc("PUT /v1/channels/instances/{id}/contexts/{scopeType}/{scopeKey}/mcp-grants/{serverID}", h.adminAuth(h.handleUpsertContextMCPGrant)) + mux.HandleFunc("DELETE /v1/channels/instances/{id}/contexts/{scopeType}/{scopeKey}/mcp-grants/{serverID}", h.adminAuth(h.handleDeleteContextMCPGrant)) + mux.HandleFunc("GET /v1/channels/instances/{id}/contexts/{scopeType}/{scopeKey}/mcp-credentials", h.auth(h.handleListContextMCPCredentials)) + mux.HandleFunc("PUT /v1/channels/instances/{id}/contexts/{scopeType}/{scopeKey}/mcp-credentials/{serverID}", h.adminAuth(h.handleSetContextMCPCredentials)) + mux.HandleFunc("DELETE /v1/channels/instances/{id}/contexts/{scopeType}/{scopeKey}/mcp-credentials/{serverID}", h.adminAuth(h.handleDeleteContextMCPCredentials)) + mux.HandleFunc("GET /v1/channels/instances/{id}/contexts/{scopeType}/{scopeKey}/cli-grants", h.auth(h.handleListContextCLIGrants)) + mux.HandleFunc("PUT /v1/channels/instances/{id}/contexts/{scopeType}/{scopeKey}/cli-grants/{binaryID}", h.adminAuth(h.handleUpsertContextCLIGrant)) + mux.HandleFunc("DELETE /v1/channels/instances/{id}/contexts/{scopeType}/{scopeKey}/cli-grants/{binaryID}", h.adminAuth(h.handleDeleteContextCLIGrant)) + mux.HandleFunc("GET /v1/channels/instances/{id}/contexts/{scopeType}/{scopeKey}/cli-credentials", h.auth(h.handleListContextCLICredentials)) + mux.HandleFunc("PUT /v1/channels/instances/{id}/contexts/{scopeType}/{scopeKey}/cli-credentials/{binaryID}", h.adminAuth(h.handleSetContextCLICredentials)) + mux.HandleFunc("DELETE /v1/channels/instances/{id}/contexts/{scopeType}/{scopeKey}/cli-credentials/{binaryID}", h.adminAuth(h.handleDeleteContextCLICredentials)) } func (h *ChannelInstancesHandler) auth(next http.HandlerFunc) http.HandlerFunc { From 993f3e0e337a7cac7297821edbb37b5ba7916eda Mon Sep 17 00:00:00 2001 From: Goon Date: Fri, 29 May 2026 19:35:02 +0700 Subject: [PATCH 012/105] feat(channels): apply context capabilities at runtime --- .../agent/loop_pipeline_tool_callbacks.go | 18 +++++ internal/mcp/grant_checker.go | 12 ++- internal/mcp/manager.go | 55 +++++++++---- internal/store/channel_context_store.go | 6 ++ .../store/pg/channel_context_capabilities.go | 81 +++++++++++++++++++ internal/store/pg/mcp_servers_access.go | 67 ++++++++++++++- internal/store/pg/secure_cli.go | 42 +++++++++- .../channel-context-capabilities.go | 81 +++++++++++++++++++ .../store/sqlitestore/mcp_servers_access.go | 67 ++++++++++++++- internal/store/sqlitestore/secure-cli.go | 42 +++++++++- 10 files changed, 449 insertions(+), 22 deletions(-) diff --git a/internal/agent/loop_pipeline_tool_callbacks.go b/internal/agent/loop_pipeline_tool_callbacks.go index 765903df..5971ffc1 100644 --- a/internal/agent/loop_pipeline_tool_callbacks.go +++ b/internal/agent/loop_pipeline_tool_callbacks.go @@ -44,6 +44,7 @@ func (l *Loop) makeExecuteToolCall(req *RunRequest, bridgeRS *runState) func(ctx OtherConfig: append([]byte(nil), l.agentOtherConfig...), // defensive copy at dispatch }) } + ctx = store.WithChannelContextScope(ctx, channelContextScopeForRun(req)) // C2 fix: route through executeToolForActor so per-user MCP tools // resolve to the calling user's BridgeTool (not the first user's @@ -106,6 +107,7 @@ func (l *Loop) makeExecuteToolRaw(req *RunRequest) func(ctx context.Context, tc OtherConfig: append([]byte(nil), l.agentOtherConfig...), // defensive copy at dispatch }) } + ctx = store.WithChannelContextScope(ctx, channelContextScopeForRun(req)) // C2 fix (parallel path): route through executeToolForActor for per-user // MCP tool isolation. Same rationale as makeExecuteToolCall above. @@ -127,6 +129,22 @@ func (l *Loop) makeExecuteToolRaw(req *RunRequest) func(ctx context.Context, tc } } +func channelContextScopeForRun(req *RunRequest) store.ChannelContextScope { + if req == nil || req.Channel == "" { + return store.ChannelContextScope{} + } + scope := store.ChannelContextScope{ + ChannelInstanceName: req.Channel, + ScopeType: store.ChannelScopeTypeChannel, + ScopeKey: req.Channel, + } + if req.PeerKind == "group" && req.ChatID != "" { + scope.ScopeType = store.ChannelScopeTypeGroup + scope.ScopeKey = req.ChatID + } + return scope +} + // makeProcessToolResult wraps post-execution bookkeeping (sequential, mutates bridgeRS). // rawData is *toolRawResult from ExecuteToolRaw — no re-execution. func (l *Loop) makeProcessToolResult(req *RunRequest, bridgeRS *runState) func(ctx context.Context, state *pipeline.RunState, tc providers.ToolCall, rawMsg providers.Message, rawData any) []providers.Message { diff --git a/internal/mcp/grant_checker.go b/internal/mcp/grant_checker.go index a144f463..a032b818 100644 --- a/internal/mcp/grant_checker.go +++ b/internal/mcp/grant_checker.go @@ -26,8 +26,11 @@ type GrantChecker interface { // cacheKey identifies a unique agent+user combination for caching. type cacheKey struct { - agentID uuid.UUID - userID string + agentID uuid.UUID + userID string + channelInstanceName string + scopeType string + scopeKey string } // cacheEntry holds the resolved access info for an agent+user. @@ -75,6 +78,11 @@ func (gc *storeGrantChecker) IsAllowed(ctx context.Context, agentID uuid.UUID, u } key := cacheKey{agentID: agentID, userID: userID} + if scope, ok := store.ChannelContextScopeFromContext(ctx); ok { + key.channelInstanceName = scope.ChannelInstanceName + key.scopeType = scope.ScopeType + key.scopeKey = scope.ScopeKey + } // Try cache first if cached, ok := gc.cache.Load(key); ok { diff --git a/internal/mcp/manager.go b/internal/mcp/manager.go index 331a8d44..3208d75a 100644 --- a/internal/mcp/manager.go +++ b/internal/mcp/manager.go @@ -62,7 +62,7 @@ type connParams struct { type serverState struct { name string transport string - client *mcpclient.Client // direct ref for health checks (single-goroutine access) + client *mcpclient.Client // direct ref for health checks (single-goroutine access) clientPtr atomic.Pointer[mcpclient.Client] // shared atomic ref for BridgeTools (multi-goroutine safe) connected atomic.Bool toolNames []string // registered tool names in the registry @@ -70,10 +70,10 @@ type serverState struct { cancel context.CancelFunc conn connParams // connection params for reconnect - mu sync.Mutex - reconnAttempts int - healthFailures int // consecutive ping failures (resets on success) - lastErr string + mu sync.Mutex + reconnAttempts int + healthFailures int // consecutive ping failures (resets on success) + lastErr string // reconnPending is set when a BridgeTool detects the server reset its // session lifecycle (FastMCP-style "tools/call invalid during session @@ -111,13 +111,13 @@ type Manager struct { // Shared connection pool (nil = config-only mode) pool *Pool - poolServers map[string]struct{} // server names acquired from pool (for cleanup) - poolToolNames map[string][]string // per-agent tool names for pool-backed servers + poolServers map[string]struct{} // server names acquired from pool (for cleanup) + poolToolNames map[string][]string // per-agent tool names for pool-backed servers poolKeys map[string]string // server name → pool compound key (tenantID/name) for Release // Search mode: deferred tools not registered in registry deferredTools map[string]*BridgeTool // registeredName → BridgeTool - activatedTools map[string]struct{} // tracks activated tool names for group:mcp + activatedTools map[string]struct{} // tracks activated tool names for group:mcp searchMode bool // User-credential servers: servers requiring per-user credentials, stored during @@ -224,13 +224,21 @@ func (m *Manager) resolveServerCredentials(ctx context.Context, info store.MCPAc return nil } - // Skip server if it requires per-user credentials and user has none + var contextCreds *store.MCPContextCredentials + if contextStore, ok := m.store.(store.MCPContextAdminStore); ok { + if scope, hasScope := store.ChannelContextScopeFromContext(ctx); hasScope { + contextCreds, _ = contextStore.GetContextCredentialsForScope(ctx, scope, srv.ID) + } + } + + // Skip server if it requires scoped/user credentials and none are present. if requireUserCreds(srv.Settings) { if userID == "" { return nil } uc, _ := m.store.GetUserCredentials(ctx, srv.ID, userID) - if uc == nil || (uc.APIKey == "" && len(uc.Headers) == 0 && len(uc.Env) == 0) { + hasContextCreds := contextCreds != nil && (contextCreds.APIKey != "" || len(contextCreds.Headers) > 0 || len(contextCreds.Env) > 0) + if !hasContextCreds && (uc == nil || (uc.APIKey == "" && len(uc.Headers) == 0 && len(uc.Env) == 0)) { slog.Debug("mcp.skip_no_user_credentials", "server", srv.Name, "user", userID) return nil } @@ -252,6 +260,27 @@ func (m *Manager) resolveServerCredentials(ctx context.Context, info store.MCPAc headers["Authorization"] = "Bearer " + srv.APIKey } + if contextCreds != nil { + if contextCreds.APIKey != "" { + if headers == nil { + headers = make(map[string]string) + } + headers["Authorization"] = "Bearer " + contextCreds.APIKey + } + for k, v := range contextCreds.Headers { + if headers == nil { + headers = make(map[string]string) + } + headers[k] = v + } + for k, v := range contextCreds.Env { + if env == nil { + env = make(map[string]string) + } + env[k] = v + } + } + // Merge per-user credentials (user overrides server defaults) if userID != "" && m.store != nil { if userCreds, err := m.store.GetUserCredentials(ctx, srv.ID, userID); err == nil && userCreds != nil { @@ -278,12 +307,10 @@ func (m *Manager) resolveServerCredentials(ctx context.Context, info store.MCPAc // Per-user credentials change connection params → can't share pool connection. // Fall back to per-agent mode when user has custom credentials. - hasUserCreds := userID != "" && m.store != nil - if hasUserCreds { + hasUserCreds := contextCreds != nil && (contextCreds.APIKey != "" || len(contextCreds.Headers) > 0 || len(contextCreds.Env) > 0) + if userID != "" && m.store != nil { if uc, _ := m.store.GetUserCredentials(ctx, srv.ID, userID); uc != nil && (uc.APIKey != "" || len(uc.Headers) > 0 || len(uc.Env) > 0) { hasUserCreds = true - } else { - hasUserCreds = false } } diff --git a/internal/store/channel_context_store.go b/internal/store/channel_context_store.go index c6775e9e..acfbb8cc 100644 --- a/internal/store/channel_context_store.go +++ b/internal/store/channel_context_store.go @@ -117,20 +117,26 @@ type MCPContextAdminStore interface { UpsertContextGrant(ctx context.Context, grant *MCPContextGrant) error DeleteContextGrant(ctx context.Context, channelInstanceID uuid.UUID, scopeType, scopeKey string, serverID uuid.UUID) error ListContextGrants(ctx context.Context, channelInstanceID uuid.UUID, scopeType, scopeKey string) ([]MCPContextGrant, error) + ListContextGrantsForScope(ctx context.Context, scope ChannelContextScope) ([]MCPContextGrant, error) SetContextCredentials(ctx context.Context, creds *MCPContextCredentials) error GetContextCredentials(ctx context.Context, channelInstanceID uuid.UUID, scopeType, scopeKey string, serverID uuid.UUID) (*MCPContextCredentials, error) + GetContextCredentialsForScope(ctx context.Context, scope ChannelContextScope, serverID uuid.UUID) (*MCPContextCredentials, error) DeleteContextCredentials(ctx context.Context, channelInstanceID uuid.UUID, scopeType, scopeKey string, serverID uuid.UUID) error ListContextCredentials(ctx context.Context, channelInstanceID uuid.UUID, scopeType, scopeKey string) ([]MCPContextCredentials, error) + ListContextCredentialsForScope(ctx context.Context, scope ChannelContextScope) ([]MCPContextCredentials, error) } type SecureCLIContextAdminStore interface { UpsertContextGrant(ctx context.Context, grant *SecureCLIContextGrant) error DeleteContextGrant(ctx context.Context, channelInstanceID uuid.UUID, scopeType, scopeKey string, binaryID uuid.UUID) error ListContextGrants(ctx context.Context, channelInstanceID uuid.UUID, scopeType, scopeKey string) ([]SecureCLIContextGrant, error) + ListContextGrantsForScope(ctx context.Context, scope ChannelContextScope) ([]SecureCLIContextGrant, error) SetContextCredentials(ctx context.Context, creds *SecureCLIContextCredentials) error GetContextCredentials(ctx context.Context, channelInstanceID uuid.UUID, scopeType, scopeKey string, binaryID uuid.UUID) (*SecureCLIContextCredentials, error) + GetContextCredentialsForScope(ctx context.Context, scope ChannelContextScope, binaryID uuid.UUID) (*SecureCLIContextCredentials, error) DeleteContextCredentials(ctx context.Context, channelInstanceID uuid.UUID, scopeType, scopeKey string, binaryID uuid.UUID) error ListContextCredentials(ctx context.Context, channelInstanceID uuid.UUID, scopeType, scopeKey string) ([]SecureCLIContextCredentials, error) + ListContextCredentialsForScope(ctx context.Context, scope ChannelContextScope) ([]SecureCLIContextCredentials, error) } diff --git a/internal/store/pg/channel_context_capabilities.go b/internal/store/pg/channel_context_capabilities.go index 81f2015f..91fb3be4 100644 --- a/internal/store/pg/channel_context_capabilities.go +++ b/internal/store/pg/channel_context_capabilities.go @@ -14,6 +14,21 @@ import ( "github.com/nextlevelbuilder/goclaw/internal/store" ) +func resolveContextChannelInstanceID(ctx context.Context, db *sql.DB, scope store.ChannelContextScope) (uuid.UUID, error) { + if scope.ChannelInstanceID != uuid.Nil { + return scope.ChannelInstanceID, nil + } + if scope.ChannelInstanceName == "" { + return uuid.Nil, sql.ErrNoRows + } + var id uuid.UUID + err := db.QueryRowContext(ctx, + `SELECT id FROM channel_instances WHERE tenant_id = $1 AND name = $2`, + tenantIDForInsert(ctx), scope.ChannelInstanceName, + ).Scan(&id) + return id, err +} + func validateChannelScope(scopeType, scopeKey string) error { switch scopeType { case store.ChannelScopeTypeChannel: @@ -109,6 +124,17 @@ func (s *PGMCPServerStore) ListContextGrants(ctx context.Context, channelInstanc return result, rows.Err() } +func (s *PGMCPServerStore) ListContextGrantsForScope(ctx context.Context, scope store.ChannelContextScope) ([]store.MCPContextGrant, error) { + id, err := resolveContextChannelInstanceID(ctx, s.db, scope) + if errors.Is(err, sql.ErrNoRows) { + return nil, nil + } + if err != nil { + return nil, err + } + return s.ListContextGrants(ctx, id, scope.ScopeType, scope.ScopeKey) +} + func (s *PGMCPServerStore) SetContextCredentials(ctx context.Context, creds *store.MCPContextCredentials) error { if err := validateChannelScope(creds.ScopeType, creds.ScopeKey); err != nil { return err @@ -170,6 +196,17 @@ func (s *PGMCPServerStore) GetContextCredentials(ctx context.Context, channelIns return creds, err } +func (s *PGMCPServerStore) GetContextCredentialsForScope(ctx context.Context, scope store.ChannelContextScope, serverID uuid.UUID) (*store.MCPContextCredentials, error) { + id, err := resolveContextChannelInstanceID(ctx, s.db, scope) + if errors.Is(err, sql.ErrNoRows) { + return nil, nil + } + if err != nil { + return nil, err + } + return s.GetContextCredentials(ctx, id, scope.ScopeType, scope.ScopeKey, serverID) +} + func (s *PGMCPServerStore) DeleteContextCredentials(ctx context.Context, channelInstanceID uuid.UUID, scopeType, scopeKey string, serverID uuid.UUID) error { _, err := s.db.ExecContext(ctx, `DELETE FROM mcp_context_credentials @@ -202,6 +239,17 @@ func (s *PGMCPServerStore) ListContextCredentials(ctx context.Context, channelIn return result, rows.Err() } +func (s *PGMCPServerStore) ListContextCredentialsForScope(ctx context.Context, scope store.ChannelContextScope) ([]store.MCPContextCredentials, error) { + id, err := resolveContextChannelInstanceID(ctx, s.db, scope) + if errors.Is(err, sql.ErrNoRows) { + return nil, nil + } + if err != nil { + return nil, err + } + return s.ListContextCredentials(ctx, id, scope.ScopeType, scope.ScopeKey) +} + type contextCredentialScanner interface { Scan(dest ...any) error } @@ -307,6 +355,17 @@ func (s *PGSecureCLIStore) ListContextGrants(ctx context.Context, channelInstanc return result, rows.Err() } +func (s *PGSecureCLIStore) ListContextGrantsForScope(ctx context.Context, scope store.ChannelContextScope) ([]store.SecureCLIContextGrant, error) { + id, err := resolveContextChannelInstanceID(ctx, s.db, scope) + if errors.Is(err, sql.ErrNoRows) { + return nil, nil + } + if err != nil { + return nil, err + } + return s.ListContextGrants(ctx, id, scope.ScopeType, scope.ScopeKey) +} + func (s *PGSecureCLIStore) scanSecureCLIContextGrant(row contextCredentialScanner) (*store.SecureCLIContextGrant, error) { var g store.SecureCLIContextGrant var denyArgs, denyVerbose *[]byte @@ -386,6 +445,17 @@ func (s *PGSecureCLIStore) GetContextCredentials(ctx context.Context, channelIns return creds, err } +func (s *PGSecureCLIStore) GetContextCredentialsForScope(ctx context.Context, scope store.ChannelContextScope, binaryID uuid.UUID) (*store.SecureCLIContextCredentials, error) { + id, err := resolveContextChannelInstanceID(ctx, s.db, scope) + if errors.Is(err, sql.ErrNoRows) { + return nil, nil + } + if err != nil { + return nil, err + } + return s.GetContextCredentials(ctx, id, scope.ScopeType, scope.ScopeKey, binaryID) +} + func (s *PGSecureCLIStore) DeleteContextCredentials(ctx context.Context, channelInstanceID uuid.UUID, scopeType, scopeKey string, binaryID uuid.UUID) error { _, err := s.db.ExecContext(ctx, `DELETE FROM secure_cli_context_credentials @@ -419,6 +489,17 @@ func (s *PGSecureCLIStore) ListContextCredentials(ctx context.Context, channelIn return result, rows.Err() } +func (s *PGSecureCLIStore) ListContextCredentialsForScope(ctx context.Context, scope store.ChannelContextScope) ([]store.SecureCLIContextCredentials, error) { + id, err := resolveContextChannelInstanceID(ctx, s.db, scope) + if errors.Is(err, sql.ErrNoRows) { + return nil, nil + } + if err != nil { + return nil, err + } + return s.ListContextCredentials(ctx, id, scope.ScopeType, scope.ScopeKey) +} + func (s *PGSecureCLIStore) scanSecureCLIContextCredentials(row contextCredentialScanner) (*store.SecureCLIContextCredentials, error) { var creds store.SecureCLIContextCredentials if err := row.Scan(&creds.ID, &creds.ChannelInstanceID, &creds.ScopeType, &creds.ScopeKey, &creds.BinaryID, diff --git a/internal/store/pg/mcp_servers_access.go b/internal/store/pg/mcp_servers_access.go index 9fd3f93e..b1b805a2 100644 --- a/internal/store/pg/mcp_servers_access.go +++ b/internal/store/pg/mcp_servers_access.go @@ -173,7 +173,11 @@ func (s *PGMCPServerStore) ListAccessible(ctx context.Context, agentID uuid.UUID if err != nil { return nil, err } - return s.scanAccessibleRows(rows) + result, err := s.scanAccessibleRows(rows) + if err != nil { + return nil, err + } + return s.applyContextMCPAccess(ctx, result) } tClause, tArgs, _, err = scopeClauseAlias(ctx, 3, "ms") if err != nil { @@ -192,7 +196,66 @@ func (s *PGMCPServerStore) ListAccessible(ctx context.Context, agentID uuid.UUID if err != nil { return nil, err } - return s.scanAccessibleRows(rows) + result, err := s.scanAccessibleRows(rows) + if err != nil { + return nil, err + } + return s.applyContextMCPAccess(ctx, result) +} + +func (s *PGMCPServerStore) applyContextMCPAccess(ctx context.Context, result []store.MCPAccessInfo) ([]store.MCPAccessInfo, error) { + scope, ok := store.ChannelContextScopeFromContext(ctx) + if !ok { + return result, nil + } + grants, err := s.ListContextGrantsForScope(ctx, scope) + if err != nil { + return nil, err + } + if len(grants) == 0 { + return result, nil + } + byServer := make(map[uuid.UUID]int, len(result)) + for i := range result { + byServer[result[i].Server.ID] = i + } + for _, grant := range grants { + if idx, exists := byServer[grant.ServerID]; exists { + if !grant.Enabled { + result = append(result[:idx], result[idx+1:]...) + byServer = make(map[uuid.UUID]int, len(result)) + for i := range result { + byServer[result[i].Server.ID] = i + } + continue + } + result[idx].ToolAllow = decodeGrantStringList(grant.ToolAllow) + result[idx].ToolDeny = decodeGrantStringList(grant.ToolDeny) + continue + } + if !grant.Enabled { + continue + } + server, err := s.GetServer(ctx, grant.ServerID) + if err != nil || server == nil || !server.Enabled { + continue + } + result = append(result, store.MCPAccessInfo{ + Server: *server, + ToolAllow: decodeGrantStringList(grant.ToolAllow), + ToolDeny: decodeGrantStringList(grant.ToolDeny), + }) + } + return result, nil +} + +func decodeGrantStringList(raw json.RawMessage) []string { + if len(raw) == 0 || string(raw) == "null" { + return nil + } + var list []string + _ = json.Unmarshal(raw, &list) + return list } // scanAccessibleRows decodes the shared SELECT projection used by both the diff --git a/internal/store/pg/secure_cli.go b/internal/store/pg/secure_cli.go index b0b7eacf..22e79d3b 100644 --- a/internal/store/pg/secure_cli.go +++ b/internal/store/pg/secure_cli.go @@ -410,7 +410,11 @@ func (s *PGSecureCLIStore) LookupByBinary(ctx context.Context, binaryName string query += ` LIMIT 1` row := s.db.QueryRowContext(ctx, query, args...) - return s.scanRowWithGrantAndUserEnv(row) + b, err := s.scanRowWithGrantAndUserEnv(row) + if err != nil || b == nil { + return b, err + } + return s.applyContextSecureCLI(ctx, b) } // scanRowWithGrantAndUserEnv scans a row that includes grant override columns and user_env. @@ -498,6 +502,42 @@ func (s *PGSecureCLIStore) scanRowWithGrantAndUserEnv(row *sql.Row) (*store.Secu return &b, nil } +func (s *PGSecureCLIStore) applyContextSecureCLI(ctx context.Context, b *store.SecureCLIBinary) (*store.SecureCLIBinary, error) { + scope, ok := store.ChannelContextScopeFromContext(ctx) + if !ok || b == nil { + return b, nil + } + grants, err := s.ListContextGrantsForScope(ctx, scope) + if err != nil { + return nil, err + } + for _, contextGrant := range grants { + if contextGrant.BinaryID != b.ID { + continue + } + if !contextGrant.Enabled { + return nil, nil + } + grant := &store.SecureCLIAgentGrant{ + DenyArgs: contextGrant.DenyArgs, + DenyVerbose: contextGrant.DenyVerbose, + TimeoutSeconds: contextGrant.TimeoutSeconds, + Tips: contextGrant.Tips, + EncryptedEnv: contextGrant.EncryptedEnv, + } + b.MergeGrantOverrides(grant) + break + } + creds, err := s.GetContextCredentialsForScope(ctx, scope, b.ID) + if err != nil { + return nil, err + } + if creds != nil && len(creds.EncryptedEnv) > 0 { + b.EncryptedEnv = creds.EncryptedEnv + } + return b, nil +} + func (s *PGSecureCLIStore) ListEnabled(ctx context.Context) ([]store.SecureCLIBinary, error) { query := `SELECT ` + secureCLISelectCols + ` FROM secure_cli_binaries WHERE enabled = true` var qArgs []any diff --git a/internal/store/sqlitestore/channel-context-capabilities.go b/internal/store/sqlitestore/channel-context-capabilities.go index c7f29acc..84efefb8 100644 --- a/internal/store/sqlitestore/channel-context-capabilities.go +++ b/internal/store/sqlitestore/channel-context-capabilities.go @@ -16,6 +16,21 @@ import ( "github.com/nextlevelbuilder/goclaw/internal/store" ) +func resolveContextChannelInstanceID(ctx context.Context, db *sql.DB, scope store.ChannelContextScope) (uuid.UUID, error) { + if scope.ChannelInstanceID != uuid.Nil { + return scope.ChannelInstanceID, nil + } + if scope.ChannelInstanceName == "" { + return uuid.Nil, sql.ErrNoRows + } + var id uuid.UUID + err := db.QueryRowContext(ctx, + `SELECT id FROM channel_instances WHERE tenant_id = ? AND name = ?`, + tenantIDForInsert(ctx), scope.ChannelInstanceName, + ).Scan(&id) + return id, err +} + func validateChannelScope(scopeType, scopeKey string) error { switch scopeType { case store.ChannelScopeTypeChannel: @@ -112,6 +127,17 @@ func (s *SQLiteMCPServerStore) ListContextGrants(ctx context.Context, channelIns return result, rows.Err() } +func (s *SQLiteMCPServerStore) ListContextGrantsForScope(ctx context.Context, scope store.ChannelContextScope) ([]store.MCPContextGrant, error) { + id, err := resolveContextChannelInstanceID(ctx, s.db, scope) + if errors.Is(err, sql.ErrNoRows) { + return nil, nil + } + if err != nil { + return nil, err + } + return s.ListContextGrants(ctx, id, scope.ScopeType, scope.ScopeKey) +} + func (s *SQLiteMCPServerStore) SetContextCredentials(ctx context.Context, creds *store.MCPContextCredentials) error { if err := validateChannelScope(creds.ScopeType, creds.ScopeKey); err != nil { return err @@ -173,6 +199,17 @@ func (s *SQLiteMCPServerStore) GetContextCredentials(ctx context.Context, channe return creds, err } +func (s *SQLiteMCPServerStore) GetContextCredentialsForScope(ctx context.Context, scope store.ChannelContextScope, serverID uuid.UUID) (*store.MCPContextCredentials, error) { + id, err := resolveContextChannelInstanceID(ctx, s.db, scope) + if errors.Is(err, sql.ErrNoRows) { + return nil, nil + } + if err != nil { + return nil, err + } + return s.GetContextCredentials(ctx, id, scope.ScopeType, scope.ScopeKey, serverID) +} + func (s *SQLiteMCPServerStore) DeleteContextCredentials(ctx context.Context, channelInstanceID uuid.UUID, scopeType, scopeKey string, serverID uuid.UUID) error { _, err := s.db.ExecContext(ctx, `DELETE FROM mcp_context_credentials @@ -205,6 +242,17 @@ func (s *SQLiteMCPServerStore) ListContextCredentials(ctx context.Context, chann return result, rows.Err() } +func (s *SQLiteMCPServerStore) ListContextCredentialsForScope(ctx context.Context, scope store.ChannelContextScope) ([]store.MCPContextCredentials, error) { + id, err := resolveContextChannelInstanceID(ctx, s.db, scope) + if errors.Is(err, sql.ErrNoRows) { + return nil, nil + } + if err != nil { + return nil, err + } + return s.ListContextCredentials(ctx, id, scope.ScopeType, scope.ScopeKey) +} + type contextCredentialScanner interface { Scan(dest ...any) error } @@ -313,6 +361,17 @@ func (s *SQLiteSecureCLIStore) ListContextGrants(ctx context.Context, channelIns return result, rows.Err() } +func (s *SQLiteSecureCLIStore) ListContextGrantsForScope(ctx context.Context, scope store.ChannelContextScope) ([]store.SecureCLIContextGrant, error) { + id, err := resolveContextChannelInstanceID(ctx, s.db, scope) + if errors.Is(err, sql.ErrNoRows) { + return nil, nil + } + if err != nil { + return nil, err + } + return s.ListContextGrants(ctx, id, scope.ScopeType, scope.ScopeKey) +} + func (s *SQLiteSecureCLIStore) scanSecureCLIContextGrant(row contextCredentialScanner) (*store.SecureCLIContextGrant, error) { var g store.SecureCLIContextGrant var denyArgs, denyVerbose []byte @@ -395,6 +454,17 @@ func (s *SQLiteSecureCLIStore) GetContextCredentials(ctx context.Context, channe return creds, err } +func (s *SQLiteSecureCLIStore) GetContextCredentialsForScope(ctx context.Context, scope store.ChannelContextScope, binaryID uuid.UUID) (*store.SecureCLIContextCredentials, error) { + id, err := resolveContextChannelInstanceID(ctx, s.db, scope) + if errors.Is(err, sql.ErrNoRows) { + return nil, nil + } + if err != nil { + return nil, err + } + return s.GetContextCredentials(ctx, id, scope.ScopeType, scope.ScopeKey, binaryID) +} + func (s *SQLiteSecureCLIStore) DeleteContextCredentials(ctx context.Context, channelInstanceID uuid.UUID, scopeType, scopeKey string, binaryID uuid.UUID) error { _, err := s.db.ExecContext(ctx, `DELETE FROM secure_cli_context_credentials @@ -428,6 +498,17 @@ func (s *SQLiteSecureCLIStore) ListContextCredentials(ctx context.Context, chann return result, rows.Err() } +func (s *SQLiteSecureCLIStore) ListContextCredentialsForScope(ctx context.Context, scope store.ChannelContextScope) ([]store.SecureCLIContextCredentials, error) { + id, err := resolveContextChannelInstanceID(ctx, s.db, scope) + if errors.Is(err, sql.ErrNoRows) { + return nil, nil + } + if err != nil { + return nil, err + } + return s.ListContextCredentials(ctx, id, scope.ScopeType, scope.ScopeKey) +} + func (s *SQLiteSecureCLIStore) scanSecureCLIContextCredentials(row contextCredentialScanner) (*store.SecureCLIContextCredentials, error) { var creds store.SecureCLIContextCredentials var createdAt, updatedAt sqliteTime diff --git a/internal/store/sqlitestore/mcp_servers_access.go b/internal/store/sqlitestore/mcp_servers_access.go index 01a169d2..01448f33 100644 --- a/internal/store/sqlitestore/mcp_servers_access.go +++ b/internal/store/sqlitestore/mcp_servers_access.go @@ -211,7 +211,11 @@ func (s *SQLiteMCPServerStore) ListAccessible(ctx context.Context, agentID uuid. if err != nil { return nil, err } - return s.scanAccessibleRows(rows) + result, err := s.scanAccessibleRows(rows) + if err != nil { + return nil, err + } + return s.applyContextMCPAccess(ctx, result) } rows, err := s.db.QueryContext(ctx, `SELECT ms.id, ms.name, ms.display_name, ms.transport, ms.command, ms.args, ms.url, ms.headers, ms.env, @@ -226,7 +230,66 @@ func (s *SQLiteMCPServerStore) ListAccessible(ctx context.Context, agentID uuid. if err != nil { return nil, err } - return s.scanAccessibleRows(rows) + result, err := s.scanAccessibleRows(rows) + if err != nil { + return nil, err + } + return s.applyContextMCPAccess(ctx, result) +} + +func (s *SQLiteMCPServerStore) applyContextMCPAccess(ctx context.Context, result []store.MCPAccessInfo) ([]store.MCPAccessInfo, error) { + scope, ok := store.ChannelContextScopeFromContext(ctx) + if !ok { + return result, nil + } + grants, err := s.ListContextGrantsForScope(ctx, scope) + if err != nil { + return nil, err + } + if len(grants) == 0 { + return result, nil + } + byServer := make(map[uuid.UUID]int, len(result)) + for i := range result { + byServer[result[i].Server.ID] = i + } + for _, grant := range grants { + if idx, exists := byServer[grant.ServerID]; exists { + if !grant.Enabled { + result = append(result[:idx], result[idx+1:]...) + byServer = make(map[uuid.UUID]int, len(result)) + for i := range result { + byServer[result[i].Server.ID] = i + } + continue + } + result[idx].ToolAllow = decodeGrantStringList(grant.ToolAllow) + result[idx].ToolDeny = decodeGrantStringList(grant.ToolDeny) + continue + } + if !grant.Enabled { + continue + } + server, err := s.GetServer(ctx, grant.ServerID) + if err != nil || server == nil || !server.Enabled { + continue + } + result = append(result, store.MCPAccessInfo{ + Server: *server, + ToolAllow: decodeGrantStringList(grant.ToolAllow), + ToolDeny: decodeGrantStringList(grant.ToolDeny), + }) + } + return result, nil +} + +func decodeGrantStringList(raw json.RawMessage) []string { + if len(raw) == 0 || string(raw) == "null" { + return nil + } + var list []string + _ = json.Unmarshal(raw, &list) + return list } // scanAccessibleRows decodes the shared SELECT projection used by both the diff --git a/internal/store/sqlitestore/secure-cli.go b/internal/store/sqlitestore/secure-cli.go index 49084010..e669a71d 100644 --- a/internal/store/sqlitestore/secure-cli.go +++ b/internal/store/sqlitestore/secure-cli.go @@ -437,7 +437,11 @@ func (s *SQLiteSecureCLIStore) LookupByBinary(ctx context.Context, binaryName st query += ` LIMIT 1` row := s.db.QueryRowContext(ctx, query, args...) - return s.scanRowWithGrantAndUserEnv(row) + b, err := s.scanRowWithGrantAndUserEnv(row) + if err != nil || b == nil { + return b, err + } + return s.applyContextSecureCLI(ctx, b) } func (s *SQLiteSecureCLIStore) scanRowWithGrantAndUserEnv(row *sql.Row) (*store.SecureCLIBinary, error) { @@ -524,6 +528,42 @@ func (s *SQLiteSecureCLIStore) scanRowWithGrantAndUserEnv(row *sql.Row) (*store. return &b, nil } +func (s *SQLiteSecureCLIStore) applyContextSecureCLI(ctx context.Context, b *store.SecureCLIBinary) (*store.SecureCLIBinary, error) { + scope, ok := store.ChannelContextScopeFromContext(ctx) + if !ok || b == nil { + return b, nil + } + grants, err := s.ListContextGrantsForScope(ctx, scope) + if err != nil { + return nil, err + } + for _, contextGrant := range grants { + if contextGrant.BinaryID != b.ID { + continue + } + if !contextGrant.Enabled { + return nil, nil + } + grant := &store.SecureCLIAgentGrant{ + DenyArgs: contextGrant.DenyArgs, + DenyVerbose: contextGrant.DenyVerbose, + TimeoutSeconds: contextGrant.TimeoutSeconds, + Tips: contextGrant.Tips, + EncryptedEnv: contextGrant.EncryptedEnv, + } + b.MergeGrantOverrides(grant) + break + } + creds, err := s.GetContextCredentialsForScope(ctx, scope, b.ID) + if err != nil { + return nil, err + } + if creds != nil && len(creds.EncryptedEnv) > 0 { + b.EncryptedEnv = creds.EncryptedEnv + } + return b, nil +} + // ListEnabled returns all enabled configs. func (s *SQLiteSecureCLIStore) ListEnabled(ctx context.Context) ([]store.SecureCLIBinary, error) { query := `SELECT ` + secureCLISelectCols + ` FROM secure_cli_binaries WHERE enabled = 1` From 06d9c7aa836284b9bd22350861453a650269f892 Mon Sep 17 00:00:00 2001 From: Goon Date: Fri, 29 May 2026 19:39:37 +0700 Subject: [PATCH 013/105] feat(channels): add context admin surface --- ui/web/src/i18n/locales/en/channels.json | 22 ++ ui/web/src/i18n/locales/vi/channels.json | 22 ++ ui/web/src/i18n/locales/zh/channels.json | 22 ++ ui/web/src/lib/query-keys.ts | 5 + .../channel-detail/channel-contexts-tab.tsx | 218 ++++++++++++++++++ .../channel-detail/channel-detail-page.tsx | 17 +- .../channels/hooks/use-channel-detail.ts | 40 +++- ui/web/src/types/channel.ts | 34 +++ 8 files changed, 378 insertions(+), 2 deletions(-) create mode 100644 ui/web/src/pages/channels/channel-detail/channel-contexts-tab.tsx diff --git a/ui/web/src/i18n/locales/en/channels.json b/ui/web/src/i18n/locales/en/channels.json index d1286ccc..8849a0bd 100644 --- a/ui/web/src/i18n/locales/en/channels.json +++ b/ui/web/src/i18n/locales/en/channels.json @@ -119,6 +119,7 @@ "config": "Config", "credentials": "Credentials", "groups": "Groups", + "contexts": "Contexts", "managers": "Managers" }, "general": { @@ -157,6 +158,27 @@ "saveGroups": "Save Groups", "saving": "Saving..." }, + "contexts": { + "description": "Review channel contexts, stored members, effective MCP tools, Secure CLI tools, and scoped credentials.", + "emptyTitle": "No contexts", + "emptyHint": "Contexts appear after this channel receives messages or group metadata.", + "loading": "Loading context details...", + "capabilities": "Capabilities", + "members": "Members", + "noCapabilities": "No capabilities available in this context.", + "noMembers": "No stored members for this context.", + "none": "None", + "allTools": "All tools", + "scopedCount": "{{count}} scoped override", + "scopedCount_plural": "{{count}} scoped overrides", + "columns": { + "name": "Name", + "type": "Type", + "source": "Source", + "credentials": "Credentials", + "tools": "Tools" + } + }, "timeline": { "title": "Timeline", "firstFailed": "First failed", diff --git a/ui/web/src/i18n/locales/vi/channels.json b/ui/web/src/i18n/locales/vi/channels.json index f79169f9..9f22279a 100644 --- a/ui/web/src/i18n/locales/vi/channels.json +++ b/ui/web/src/i18n/locales/vi/channels.json @@ -118,6 +118,7 @@ "config": "Cấu hình", "credentials": "Thông tin xác thực", "groups": "Nhóm", + "contexts": "Ngữ cảnh", "managers": "Quản lý" }, "general": { @@ -156,6 +157,27 @@ "saveGroups": "Lưu nhóm", "saving": "Đang lưu..." }, + "contexts": { + "description": "Xem ngữ cảnh channel, thành viên đã lưu, MCP tools, Secure CLI tools và thông tin xác thực theo scope.", + "emptyTitle": "Không có ngữ cảnh", + "emptyHint": "Ngữ cảnh xuất hiện sau khi channel nhận tin nhắn hoặc metadata nhóm.", + "loading": "Đang tải chi tiết ngữ cảnh...", + "capabilities": "Quyền năng", + "members": "Thành viên", + "noCapabilities": "Không có quyền năng trong ngữ cảnh này.", + "noMembers": "Không có thành viên đã lưu trong ngữ cảnh này.", + "none": "Không có", + "allTools": "Tất cả tools", + "scopedCount": "{{count}} override theo scope", + "scopedCount_plural": "{{count}} override theo scope", + "columns": { + "name": "Tên", + "type": "Loại", + "source": "Nguồn", + "credentials": "Xác thực", + "tools": "Tools" + } + }, "timeline": { "title": "Dòng thời gian", "firstFailed": "Lỗi đầu tiên", diff --git a/ui/web/src/i18n/locales/zh/channels.json b/ui/web/src/i18n/locales/zh/channels.json index d390f7cf..92bf00b6 100644 --- a/ui/web/src/i18n/locales/zh/channels.json +++ b/ui/web/src/i18n/locales/zh/channels.json @@ -118,6 +118,7 @@ "config": "配置", "credentials": "凭据", "groups": "群组", + "contexts": "上下文", "managers": "管理员" }, "general": { @@ -156,6 +157,27 @@ "saveGroups": "保存群组", "saving": "保存中..." }, + "contexts": { + "description": "查看 Channel 上下文、已存成员、MCP 工具、Secure CLI 工具和作用域凭据。", + "emptyTitle": "暂无上下文", + "emptyHint": "Channel 收到消息或群组元数据后会显示上下文。", + "loading": "正在加载上下文详情...", + "capabilities": "能力", + "members": "成员", + "noCapabilities": "此上下文没有可用能力。", + "noMembers": "此上下文没有已存成员。", + "none": "无", + "allTools": "全部工具", + "scopedCount": "{{count}} 个作用域覆盖", + "scopedCount_plural": "{{count}} 个作用域覆盖", + "columns": { + "name": "名称", + "type": "类型", + "source": "来源", + "credentials": "凭据", + "tools": "工具" + } + }, "timeline": { "title": "时间线", "firstFailed": "首次失败", diff --git a/ui/web/src/lib/query-keys.ts b/ui/web/src/lib/query-keys.ts index 3ce184e1..b834a1e5 100644 --- a/ui/web/src/lib/query-keys.ts +++ b/ui/web/src/lib/query-keys.ts @@ -38,6 +38,11 @@ export const queryKeys = { all: ["channels"] as const, list: (params: Record) => ["channels", params] as const, detail: (id: string) => ["channels", "detail", id] as const, + contexts: (id: string) => ["channels", "detail", id, "contexts"] as const, + contextMembers: (id: string, scopeType: string, scopeKey: string) => + ["channels", "detail", id, "contexts", scopeType, scopeKey, "members"] as const, + contextCapabilities: (id: string, scopeType: string, scopeKey: string) => + ["channels", "detail", id, "contexts", scopeType, scopeKey, "capabilities"] as const, }, contacts: { all: ["contacts"] as const, diff --git a/ui/web/src/pages/channels/channel-detail/channel-contexts-tab.tsx b/ui/web/src/pages/channels/channel-detail/channel-contexts-tab.tsx new file mode 100644 index 00000000..871d8b3f --- /dev/null +++ b/ui/web/src/pages/channels/channel-detail/channel-contexts-tab.tsx @@ -0,0 +1,218 @@ +import { useCallback, useEffect, useMemo, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { ChevronDown, ChevronRight, Loader2, RefreshCw, ShieldCheck, Users } from "lucide-react"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { EmptyState } from "@/components/shared/empty-state"; +import type { ChannelCapability, ChannelContextData, ChannelContextMember } from "@/types/channel"; + +interface ChannelContextsTabProps { + listContexts: () => Promise; + listContextMembers: (scopeType: string, scopeKey: string) => Promise; + listContextCapabilities: (scopeType: string, scopeKey: string) => Promise; +} + +function contextKey(ctx: ChannelContextData) { + return `${ctx.scope_type}:${ctx.scope_key}`; +} + +function capabilityLabel(cap: ChannelCapability) { + return cap.display_name || cap.name; +} + +export function ChannelContextsTab({ + listContexts, + listContextMembers, + listContextCapabilities, +}: ChannelContextsTabProps) { + const { t } = useTranslation("channels"); + const [contexts, setContexts] = useState([]); + const [loading, setLoading] = useState(true); + const [expanded, setExpanded] = useState>({}); + const [members, setMembers] = useState>({}); + const [capabilities, setCapabilities] = useState>({}); + const [loadingRows, setLoadingRows] = useState>({}); + + const refresh = useCallback(async () => { + setLoading(true); + try { + setContexts(await listContexts()); + } finally { + setLoading(false); + } + }, [listContexts]); + + useEffect(() => { + refresh(); + }, [refresh]); + + const loadContextDetail = useCallback( + async (ctx: ChannelContextData) => { + const key = contextKey(ctx); + setLoadingRows((prev) => ({ ...prev, [key]: true })); + try { + const [nextMembers, nextCapabilities] = await Promise.all([ + listContextMembers(ctx.scope_type, ctx.scope_key), + listContextCapabilities(ctx.scope_type, ctx.scope_key), + ]); + setMembers((prev) => ({ ...prev, [key]: nextMembers })); + setCapabilities((prev) => ({ ...prev, [key]: nextCapabilities })); + } finally { + setLoadingRows((prev) => ({ ...prev, [key]: false })); + } + }, + [listContextCapabilities, listContextMembers], + ); + + const rows = useMemo(() => contexts, [contexts]); + + const toggle = (ctx: ChannelContextData) => { + const key = contextKey(ctx); + const open = !expanded[key]; + setExpanded((prev) => ({ ...prev, [key]: open })); + if (open && !capabilities[key]) { + loadContextDetail(ctx); + } + }; + + return ( +
+
+

+ {t("detail.contexts.description")} +

+ +
+ + {!loading && rows.length === 0 ? ( + + ) : ( +
+ {rows.map((ctx) => { + const key = contextKey(ctx); + const isOpen = !!expanded[key]; + const rowMembers = members[key] ?? []; + const rowCapabilities = capabilities[key] ?? []; + const isLoading = !!loadingRows[key]; + const scopedCapabilities = rowCapabilities.filter( + (cap) => cap.context_grant_configured || cap.context_credentials_configured || cap.source === ctx.scope_type, + ); + + return ( +
+ + + {isOpen && ( +
+ {isLoading ? ( +
+ + {t("detail.contexts.loading")} +
+ ) : ( + <> +
+

{t("detail.contexts.capabilities")}

+ {rowCapabilities.length === 0 ? ( +

{t("detail.contexts.noCapabilities")}

+ ) : ( +
+ + + + + + + + + + + + {rowCapabilities.map((cap) => ( + + + + + + + + ))} + +
{t("detail.contexts.columns.name")}{t("detail.contexts.columns.type")}{t("detail.contexts.columns.source")}{t("detail.contexts.columns.credentials")}{t("detail.contexts.columns.tools")}
+
{capabilityLabel(cap)}
+
{cap.name}
+
{cap.type}{cap.source} + + {cap.has_credential ? cap.credential_source : t("detail.contexts.none")} + + + {(cap.tool_allow?.length ?? 0) > 0 ? cap.tool_allow?.join(", ") : t("detail.contexts.allTools")} +
+
+ )} + {scopedCapabilities.length > 0 && ( +

+ {t("detail.contexts.scopedCount", { count: scopedCapabilities.length })} +

+ )} +
+ +
+

{t("detail.contexts.members")}

+ {rowMembers.length === 0 ? ( +

{t("detail.contexts.noMembers")}

+ ) : ( +
+ {rowMembers.map((member) => ( +
+
+ + {member.display_name || member.username || member.platform_id} +
+
{member.user_id || member.platform_id}
+
+ ))} +
+ )} +
+ + )} +
+ )} +
+ ); + })} +
+ )} +
+ ); +} diff --git a/ui/web/src/pages/channels/channel-detail/channel-detail-page.tsx b/ui/web/src/pages/channels/channel-detail/channel-detail-page.tsx index 4dec4073..c7d1adfe 100644 --- a/ui/web/src/pages/channels/channel-detail/channel-detail-page.tsx +++ b/ui/web/src/pages/channels/channel-detail/channel-detail-page.tsx @@ -10,6 +10,7 @@ import { ChannelGeneralTab } from "./channel-general-tab"; import { ChannelCredentialsTab } from "./channel-credentials-tab"; import { ChannelGroupsTab } from "./channel-groups-tab"; import { ChannelManagersTab } from "./channel-managers-tab"; +import { ChannelContextsTab } from "./channel-contexts-tab"; import { ChannelDiagnosticsCard } from "./channel-diagnostics-card"; import { DetailPageSkeleton } from "@/components/shared/loading-skeleton"; import { useChannels } from "../hooks/use-channels"; @@ -30,7 +31,7 @@ interface ChannelDetailPageProps { } const DEFAULT_CHANNEL_DETAIL_TAB = "general"; -const baseChannelDetailTabs = new Set(["general", "credentials", "managers"]); +const baseChannelDetailTabs = new Set(["general", "credentials", "contexts", "managers"]); export function resolveChannelDetailTab( requestedTab: string | null, @@ -60,6 +61,9 @@ export function ChannelDetailPage({ listManagers, addManager, removeManager, + listContexts, + listContextMembers, + listContextCapabilities, } = useChannelDetail(instanceId); const { agents } = useAgents(); const { channels } = useChannels(); @@ -198,6 +202,9 @@ export function ChannelDetailPage({ {t("detail.tabs.groups")} )} + + {t("detail.tabs.contexts")} + {t("detail.tabs.managers")} @@ -228,6 +235,14 @@ export function ChannelDetailPage({ )} + + + + => { + if (!instanceId) return []; + const res = await http.get<{ contexts: ChannelContextData[] }>( + `/v1/channels/instances/${instanceId}/contexts`, + ); + return res.contexts ?? []; + }, [instanceId, http]); + + const listContextMembers = useCallback( + async (scopeType: string, scopeKey: string): Promise => { + if (!instanceId) return []; + const res = await http.get<{ members: ChannelContextMember[] }>( + `/v1/channels/instances/${instanceId}/contexts/${encodeURIComponent(scopeType)}/${encodeURIComponent(scopeKey)}/members`, + ); + return res.members ?? []; + }, + [instanceId, http], + ); + + const listContextCapabilities = useCallback( + async (scopeType: string, scopeKey: string): Promise => { + if (!instanceId) return []; + const res = await http.get<{ capabilities: ChannelCapability[] }>( + `/v1/channels/instances/${instanceId}/contexts/${encodeURIComponent(scopeType)}/${encodeURIComponent(scopeKey)}/capabilities`, + ); + return res.capabilities ?? []; + }, + [instanceId, http], + ); + const listContacts = useCallback( async (search: string, channelType?: string): Promise => { const params: Record = {}; @@ -116,6 +151,9 @@ export function useChannelDetail(instanceId: string | undefined) { listManagers, addManager, removeManager, + listContexts, + listContextMembers, + listContextCapabilities, listContacts, refresh: invalidate, }; diff --git a/ui/web/src/types/channel.ts b/ui/web/src/types/channel.ts index bfedd5b8..469c334c 100644 --- a/ui/web/src/types/channel.ts +++ b/ui/web/src/types/channel.ts @@ -50,3 +50,37 @@ export interface ChannelInstanceInput { config?: Record; enabled?: boolean; } + +export interface ChannelContextData { + scope_type: "channel" | "group" | "user" | "role"; + scope_key: string; + label: string; + source: string; + live_members: boolean; + member_count?: number; + last_seen_at?: string; +} + +export interface ChannelContextMember { + platform_id: string; + user_id?: string; + display_name?: string; + username?: string; + source: string; + last_seen_at?: string; +} + +export interface ChannelCapability { + type: "mcp_server" | "secure_cli"; + id: string; + name: string; + display_name?: string; + enabled: boolean; + source: string; + tool_allow?: string[]; + tool_deny?: string[]; + credential_source: string; + has_credential: boolean; + context_grant_configured: boolean; + context_credentials_configured: boolean; +} From 697aa266e04ca94a1f53b9d8c4e923beb443c196 Mon Sep 17 00:00:00 2001 From: Goon Date: Fri, 29 May 2026 19:43:06 +0700 Subject: [PATCH 014/105] feat(channels): document context admin surface --- docs/23-ai-agent-permission-matrix.md | 15 +++++++++++++++ docs/project-changelog.md | 17 +++++++++++++++++ .../phase-01-research-and-contracts.md | 2 +- .../phase-02-read-only-context-surface.md | 2 +- .../phase-03-effective-capability-matrix.md | 2 +- .../phase-04-context-scoped-grants.md | 2 +- .../phase-05-context-scoped-credentials.md | 2 +- ...se-06-discord-enrichment-and-verification.md | 2 +- .../plan.md | 16 ++++++++-------- 9 files changed, 46 insertions(+), 14 deletions(-) diff --git a/docs/23-ai-agent-permission-matrix.md b/docs/23-ai-agent-permission-matrix.md index c3ef1d92..b64e734b 100644 --- a/docs/23-ai-agent-permission-matrix.md +++ b/docs/23-ai-agent-permission-matrix.md @@ -12,6 +12,7 @@ This matrix documents the effective authorization layers for agent actions acros | Agent config permissions | Agent config mutations from chat | `agent_config_permissions` | Matches by `agent_id`, `scope`, `config_type`, `user_id`, including wildcard rows. | | Workspace file boundary | Filesystem access | tool sandbox/boundary checks | Prevents path escape and unsupported writes. | | Context file boundary | Agent identity/context files | `ContextFileInterceptor` | Routes protected files to store and requires group writer permission in group contexts. | +| Channel context capabilities | MCP + Secure CLI tool execution | `mcp_context_*`, `secure_cli_context_*` | Effective precedence: user credentials > context credentials/grants > agent grants > global defaults. | ## Agent Config Permission Rows @@ -69,3 +70,17 @@ The Permissions tab should expose a full matrix editor: - Permission-store errors fail closed for group mutation boundaries. - Backend validation rejects unknown config types and permissions before writing rules. - Platform send permissions are still separate from GoClaw permissions; a channel adapter may reject delivery even when GoClaw allows the agent action. + +## Channel context capabilities + +Channel instances expose stored contexts in the dashboard and API. The base +context is the channel instance itself; group contexts come from stored channel +contacts. Capability rows combine MCP and Secure CLI visibility for that +context, including source, enabled state, tool allow/deny lists, and credential +presence. + +Context credential rows never return secret material. They only project +metadata such as `has_api_key`, `has_env`, `credential_source`, and key names +where available. Writes are tenant-admin gated, and runtime resolution carries +`ChannelContextScope` so grants and credentials are applied only to matching +channel/group scope. diff --git a/docs/project-changelog.md b/docs/project-changelog.md index 1027b40d..ba945abf 100644 --- a/docs/project-changelog.md +++ b/docs/project-changelog.md @@ -4,6 +4,23 @@ Significant changes, features, and fixes in reverse chronological order. --- +## 2026-05-29 + +### Channel context admin surface (issue #66) + +**New** + +- Added channel context APIs under `/v1/channels/instances/{id}/contexts` for stored channel/group contexts, members, and effective MCP/Secure CLI capability matrix. +- Added tenant-scoped context grants and context credentials for MCP servers and Secure CLI binaries, with PostgreSQL migration `000074` and SQLite schema version `43`. +- Runtime tool execution now carries `ChannelContextScope`; MCP and Secure CLI resolution apply context grants/credentials before user credential overrides. +- Web dashboard channel detail page now has a `Contexts` tab showing contexts, stored members, effective granted tools, and scoped credential presence without exposing secret values. + +**Security** + +- Context capability write APIs use tenant-admin checks. +- Credential APIs return presence/metadata only; raw MCP API keys, headers/env values, and Secure CLI env values are never serialized. +- MCP grant-check cache key includes channel scope so scoped grants do not leak across channel contexts. + ## 2026-05-28 ### CLI credential adapter framework + git adapter (issue #82) diff --git a/plans/260528-issue-66-channel-context-admin-surface/phase-01-research-and-contracts.md b/plans/260528-issue-66-channel-context-admin-surface/phase-01-research-and-contracts.md index 5cb688ef..e4ef343e 100644 --- a/plans/260528-issue-66-channel-context-admin-surface/phase-01-research-and-contracts.md +++ b/plans/260528-issue-66-channel-context-admin-surface/phase-01-research-and-contracts.md @@ -1,7 +1,7 @@ --- phase: 1 title: "Research and Contracts" -status: pending +status: complete effort: "M" --- diff --git a/plans/260528-issue-66-channel-context-admin-surface/phase-02-read-only-context-surface.md b/plans/260528-issue-66-channel-context-admin-surface/phase-02-read-only-context-surface.md index 2ba28a54..045d8099 100644 --- a/plans/260528-issue-66-channel-context-admin-surface/phase-02-read-only-context-surface.md +++ b/plans/260528-issue-66-channel-context-admin-surface/phase-02-read-only-context-surface.md @@ -1,7 +1,7 @@ --- phase: 2 title: "Read-only Context Surface" -status: pending +status: complete effort: "L" --- diff --git a/plans/260528-issue-66-channel-context-admin-surface/phase-03-effective-capability-matrix.md b/plans/260528-issue-66-channel-context-admin-surface/phase-03-effective-capability-matrix.md index 4492ccdb..8bede47d 100644 --- a/plans/260528-issue-66-channel-context-admin-surface/phase-03-effective-capability-matrix.md +++ b/plans/260528-issue-66-channel-context-admin-surface/phase-03-effective-capability-matrix.md @@ -1,7 +1,7 @@ --- phase: 3 title: "Effective Capability Matrix" -status: pending +status: complete effort: "L" --- diff --git a/plans/260528-issue-66-channel-context-admin-surface/phase-04-context-scoped-grants.md b/plans/260528-issue-66-channel-context-admin-surface/phase-04-context-scoped-grants.md index f4d6544f..c93941c2 100644 --- a/plans/260528-issue-66-channel-context-admin-surface/phase-04-context-scoped-grants.md +++ b/plans/260528-issue-66-channel-context-admin-surface/phase-04-context-scoped-grants.md @@ -1,7 +1,7 @@ --- phase: 4 title: "Context-scoped Grants" -status: pending +status: complete effort: "XL" --- diff --git a/plans/260528-issue-66-channel-context-admin-surface/phase-05-context-scoped-credentials.md b/plans/260528-issue-66-channel-context-admin-surface/phase-05-context-scoped-credentials.md index 3fec559a..b80696d9 100644 --- a/plans/260528-issue-66-channel-context-admin-surface/phase-05-context-scoped-credentials.md +++ b/plans/260528-issue-66-channel-context-admin-surface/phase-05-context-scoped-credentials.md @@ -1,7 +1,7 @@ --- phase: 5 title: "Context-scoped Credentials" -status: pending +status: complete effort: "XL" --- diff --git a/plans/260528-issue-66-channel-context-admin-surface/phase-06-discord-enrichment-and-verification.md b/plans/260528-issue-66-channel-context-admin-surface/phase-06-discord-enrichment-and-verification.md index d0d3ff8b..f3b4a910 100644 --- a/plans/260528-issue-66-channel-context-admin-surface/phase-06-discord-enrichment-and-verification.md +++ b/plans/260528-issue-66-channel-context-admin-surface/phase-06-discord-enrichment-and-verification.md @@ -1,7 +1,7 @@ --- phase: 6 title: "Discord Enrichment and Verification" -status: pending +status: complete effort: "M" --- diff --git a/plans/260528-issue-66-channel-context-admin-surface/plan.md b/plans/260528-issue-66-channel-context-admin-surface/plan.md index 35b8395d..2c70616b 100644 --- a/plans/260528-issue-66-channel-context-admin-surface/plan.md +++ b/plans/260528-issue-66-channel-context-admin-surface/plan.md @@ -1,7 +1,7 @@ --- title: "Issue 66 Channel Context Admin Surface" description: "Add channel details admin views for contexts, members, granted MCP/CLI capabilities, and scoped credential overrides." -status: pending +status: complete priority: P2 issue: 66 branch: "codex-issue-66-channel-details-admin-design" @@ -29,12 +29,12 @@ Out of scope for the first implementation pass: raw credential reveal, storing M | Phase | Name | Status | |-------|------|--------| -| 1 | [Research and Contracts](./phase-01-research-and-contracts.md) | Pending | -| 2 | [Read-only Context Surface](./phase-02-read-only-context-surface.md) | Pending | -| 3 | [Effective Capability Matrix](./phase-03-effective-capability-matrix.md) | Pending | -| 4 | [Context-scoped Grants](./phase-04-context-scoped-grants.md) | Pending | -| 5 | [Context-scoped Credentials](./phase-05-context-scoped-credentials.md) | Pending | -| 6 | [Discord Enrichment and Verification](./phase-06-discord-enrichment-and-verification.md) | Pending | +| 1 | [Research and Contracts](./phase-01-research-and-contracts.md) | Complete | +| 2 | [Read-only Context Surface](./phase-02-read-only-context-surface.md) | Complete | +| 3 | [Effective Capability Matrix](./phase-03-effective-capability-matrix.md) | Complete | +| 4 | [Context-scoped Grants](./phase-04-context-scoped-grants.md) | Complete | +| 5 | [Context-scoped Credentials](./phase-05-context-scoped-credentials.md) | Complete | +| 6 | [Discord Enrichment and Verification](./phase-06-discord-enrichment-and-verification.md) | Complete: live role/member enrichment remains gated behind Discord intent verification | ## Dependencies @@ -47,4 +47,4 @@ Out of scope for the first implementation pass: raw credential reveal, storing M ## Unresolved Questions -- Confirm whether Discord live role/member enrichment ships in v1 or stays documented after read-only context visibility. +None. Discord live role/member enrichment is intentionally not enabled until required intents and permissions are verified. From c7aac69ca0f3559eb6972c0ed64a716d72b467ba Mon Sep 17 00:00:00 2001 From: Goon Date: Fri, 29 May 2026 20:04:33 +0700 Subject: [PATCH 015/105] fix(channels): harden context capability admin --- .../http/channel_instance_capabilities.go | 83 ++++---- .../channel_instance_capabilities_test.go | 4 +- ...annel_instance_context_capability_admin.go | 12 ++ internal/http/channel_instance_contexts.go | 2 +- .../http/channel_instance_contexts_test.go | 6 +- internal/http/channel_instances.go | 8 +- internal/mcp/manager.go | 6 +- internal/store/channel_context_store.go | 27 +++ internal/store/pg/mcp_servers_access.go | 72 +++---- internal/store/pg/secure_cli.go | 68 ++++--- .../store/sqlitestore/mcp_servers_access.go | 72 +++---- internal/store/sqlitestore/secure-cli.go | 68 ++++--- ui/web/src/i18n/locales/en/channels.json | 21 +- ui/web/src/i18n/locales/vi/channels.json | 21 +- ui/web/src/i18n/locales/zh/channels.json | 21 +- ...hannel-context-capability-admin-dialog.tsx | 180 ++++++++++++++++++ .../channel-detail/channel-contexts-tab.tsx | 52 ++++- .../channel-detail/channel-detail-page.tsx | 24 +++ .../channels/hooks/use-channel-detail.ts | 61 ++++++ ui/web/src/types/channel.ts | 4 +- 20 files changed, 641 insertions(+), 171 deletions(-) create mode 100644 ui/web/src/pages/channels/channel-detail/channel-context-capability-admin-dialog.tsx diff --git a/internal/http/channel_instance_capabilities.go b/internal/http/channel_instance_capabilities.go index de772df7..493f7579 100644 --- a/internal/http/channel_instance_capabilities.go +++ b/internal/http/channel_instance_capabilities.go @@ -41,9 +41,12 @@ func (h *ChannelInstancesHandler) handleListContextCapabilities(w http.ResponseW userID := r.URL.Query().Get("user_id") if userID == "" { userID = store.CredentialUserIDFromContext(r.Context()) + } else if userID != store.CredentialUserIDFromContext(r.Context()) && !h.requireContextAdmin(w, r) { + return } scope := store.ChannelContextScope{ChannelInstanceID: inst.ID, ChannelInstanceName: inst.Name, ScopeType: scopeType, ScopeKey: scopeKey} + r = r.WithContext(store.WithChannelContextScope(r.Context(), scope)) mcpRows, err := h.listChannelMCPRows(r, inst, scope, userID) if err != nil { @@ -79,22 +82,26 @@ func (h *ChannelInstancesHandler) listChannelMCPRows(r *http.Request, inst *stor } contextGrants := map[uuidString]store.MCPContextGrant{} if h.mcpContextStore != nil { - grants, err := h.mcpContextStore.ListContextGrants(r.Context(), inst.ID, scope.ScopeType, scope.ScopeKey) - if err != nil { - return nil, err - } - for _, grant := range grants { - contextGrants[uuidString(grant.ServerID.String())] = grant + for _, chainedScope := range store.ChannelContextScopeChainFromContext(r.Context()) { + grants, err := h.mcpContextStore.ListContextGrants(r.Context(), inst.ID, chainedScope.ScopeType, chainedScope.ScopeKey) + if err != nil { + return nil, err + } + for _, grant := range grants { + contextGrants[uuidString(grant.ServerID.String())] = grant + } } } contextCreds := map[uuidString]store.MCPContextCredentials{} if h.mcpContextStore != nil { - creds, err := h.mcpContextStore.ListContextCredentials(r.Context(), inst.ID, scope.ScopeType, scope.ScopeKey) - if err != nil { - return nil, err - } - for _, cred := range creds { - contextCreds[uuidString(cred.ServerID.String())] = cred + for _, chainedScope := range store.ChannelContextScopeChainFromContext(r.Context()) { + creds, err := h.mcpContextStore.ListContextCredentials(r.Context(), inst.ID, chainedScope.ScopeType, chainedScope.ScopeKey) + if err != nil { + return nil, err + } + for _, cred := range creds { + contextCreds[uuidString(cred.ServerID.String())] = cred + } } } rows := make([]channelCapabilityDTO, 0, len(accessible)) @@ -106,7 +113,7 @@ func (h *ChannelInstancesHandler) listChannelMCPRows(r *http.Request, inst *stor hasCredential := info.Server.APIKey != "" || len(info.Server.Headers) > 0 || len(info.Server.Env) > 0 contextCredential := false if cred, ok := contextCreds[idKey]; ok { - credentialSource = scope.ScopeType + credentialSource = cred.ScopeType hasCredential = cred.APIKey != "" || len(cred.Headers) > 0 || len(cred.Env) > 0 contextCredential = true } @@ -124,7 +131,7 @@ func (h *ChannelInstancesHandler) listChannelMCPRows(r *http.Request, inst *stor toolDeny := info.ToolDeny contextGrant := false if grant, ok := contextGrants[idKey]; ok { - source = scope.ScopeType + source = grant.ScopeType enabled = grant.Enabled toolAllow = decodeStringList(grant.ToolAllow) toolDeny = decodeStringList(grant.ToolDeny) @@ -155,16 +162,20 @@ func (h *ChannelInstancesHandler) listChannelMCPRows(r *http.Request, inst *stor continue } cred, hasCredRow := contextCreds[idKey] + credentialSource := grant.ScopeType + if hasCredRow { + credentialSource = cred.ScopeType + } rows = append(rows, channelCapabilityDTO{ Type: "mcp_server", ID: server.ID.String(), Name: server.Name, DisplayName: server.DisplayName, Enabled: server.Enabled && grant.Enabled, - Source: scope.ScopeType, + Source: grant.ScopeType, ToolAllow: decodeStringList(grant.ToolAllow), ToolDeny: decodeStringList(grant.ToolDeny), - CredentialSource: scope.ScopeType, + CredentialSource: credentialSource, HasCredential: hasCredRow && (cred.APIKey != "" || len(cred.Headers) > 0 || len(cred.Env) > 0), ContextGrant: true, ContextCredentials: hasCredRow, @@ -183,22 +194,26 @@ func (h *ChannelInstancesHandler) listChannelCLIRows(r *http.Request, inst *stor } contextGrants := map[uuidString]store.SecureCLIContextGrant{} if h.cliContextStore != nil { - grants, err := h.cliContextStore.ListContextGrants(r.Context(), inst.ID, scope.ScopeType, scope.ScopeKey) - if err != nil { - return nil, err - } - for _, grant := range grants { - contextGrants[uuidString(grant.BinaryID.String())] = grant + for _, chainedScope := range store.ChannelContextScopeChainFromContext(r.Context()) { + grants, err := h.cliContextStore.ListContextGrants(r.Context(), inst.ID, chainedScope.ScopeType, chainedScope.ScopeKey) + if err != nil { + return nil, err + } + for _, grant := range grants { + contextGrants[uuidString(grant.BinaryID.String())] = grant + } } } contextCreds := map[uuidString]store.SecureCLIContextCredentials{} if h.cliContextStore != nil { - creds, err := h.cliContextStore.ListContextCredentials(r.Context(), inst.ID, scope.ScopeType, scope.ScopeKey) - if err != nil { - return nil, err - } - for _, cred := range creds { - contextCreds[uuidString(cred.BinaryID.String())] = cred + for _, chainedScope := range store.ChannelContextScopeChainFromContext(r.Context()) { + creds, err := h.cliContextStore.ListContextCredentials(r.Context(), inst.ID, chainedScope.ScopeType, chainedScope.ScopeKey) + if err != nil { + return nil, err + } + for _, cred := range creds { + contextCreds[uuidString(cred.BinaryID.String())] = cred + } } } rows := make([]channelCapabilityDTO, 0, len(binaries)) @@ -214,13 +229,13 @@ func (h *ChannelInstancesHandler) listChannelCLIRows(r *http.Request, inst *stor hasCredential := len(b.EncryptedEnv) > 0 contextGrant := false if grant, ok := contextGrants[idKey]; ok { - source = scope.ScopeType + source = grant.ScopeType contextGrant = true b.Enabled = b.Enabled && grant.Enabled } contextCredential := false if cred, ok := contextCreds[idKey]; ok { - credentialSource = scope.ScopeType + credentialSource = cred.ScopeType hasCredential = len(cred.EncryptedEnv) > 0 contextCredential = true } @@ -253,14 +268,18 @@ func (h *ChannelInstancesHandler) listChannelCLIRows(r *http.Request, inst *stor continue } _, hasCredRow := contextCreds[idKey] + credentialSource := grant.ScopeType + if cred, ok := contextCreds[idKey]; ok { + credentialSource = cred.ScopeType + } rows = append(rows, channelCapabilityDTO{ Type: "secure_cli", ID: binary.ID.String(), Name: binary.BinaryName, DisplayName: binary.Description, Enabled: binary.Enabled && grant.Enabled, - Source: scope.ScopeType, - CredentialSource: scope.ScopeType, + Source: grant.ScopeType, + CredentialSource: credentialSource, HasCredential: hasCredRow, ContextGrant: true, ContextCredentials: hasCredRow, diff --git a/internal/http/channel_instance_capabilities_test.go b/internal/http/channel_instance_capabilities_test.go index c7ba31cf..bf719c87 100644 --- a/internal/http/channel_instance_capabilities_test.go +++ b/internal/http/channel_instance_capabilities_test.go @@ -138,7 +138,7 @@ func (s *fakeChannelCapabilityCLIStore) ListUserCredentials(context.Context, uui func TestChannelContextCapabilitiesExposeOnlyCredentialMetadata(t *testing.T) { token := "channel-capabilities-key" setupTestCache(t, map[string]*store.APIKeyData{ - crypto.HashAPIKey(token): {Scopes: []string{"operator.write"}, OwnerID: "caller"}, + crypto.HashAPIKey(token): {Scopes: []string{"operator.write"}, OwnerID: "tenant-user-1"}, }) instID := uuid.New() @@ -182,7 +182,7 @@ func TestChannelContextCapabilitiesExposeOnlyCredentialMetadata(t *testing.T) { mux := http.NewServeMux() handler.RegisterRoutes(mux) - req := httptest.NewRequest(http.MethodGet, "/v1/channels/instances/"+instID.String()+"/contexts/channel/"+channelName+"/capabilities?user_id=tenant-user-1", nil) + req := httptest.NewRequest(http.MethodGet, "/v1/channels/instances/"+instID.String()+"/contexts/channel/"+channelName+"/capabilities", nil) req.Header.Set("Authorization", "Bearer "+token) rec := httptest.NewRecorder() mux.ServeHTTP(rec, req) diff --git a/internal/http/channel_instance_context_capability_admin.go b/internal/http/channel_instance_context_capability_admin.go index f6bbee28..ac0e7c16 100644 --- a/internal/http/channel_instance_context_capability_admin.go +++ b/internal/http/channel_instance_context_capability_admin.go @@ -54,6 +54,9 @@ func (h *ChannelInstancesHandler) handleListContextMCPGrants(w http.ResponseWrit writeJSON(w, http.StatusNotImplemented, map[string]string{"error": "MCP context store is not available"}) return } + if !h.requireContextAdmin(w, r) { + return + } path, ok := h.resolveContextPath(w, r) if !ok { return @@ -171,6 +174,9 @@ func (h *ChannelInstancesHandler) handleListContextMCPCredentials(w http.Respons writeJSON(w, http.StatusNotImplemented, map[string]string{"error": "MCP context store is not available"}) return } + if !h.requireContextAdmin(w, r) { + return + } path, ok := h.resolveContextPath(w, r) if !ok { return @@ -267,6 +273,9 @@ func (h *ChannelInstancesHandler) handleListContextCLIGrants(w http.ResponseWrit writeJSON(w, http.StatusNotImplemented, map[string]string{"error": "Secure CLI context store is not available"}) return } + if !h.requireContextAdmin(w, r) { + return + } path, ok := h.resolveContextPath(w, r) if !ok { return @@ -421,6 +430,9 @@ func (h *ChannelInstancesHandler) handleListContextCLICredentials(w http.Respons writeJSON(w, http.StatusNotImplemented, map[string]string{"error": "Secure CLI context store is not available"}) return } + if !h.requireContextAdmin(w, r) { + return + } path, ok := h.resolveContextPath(w, r) if !ok { return diff --git a/internal/http/channel_instance_contexts.go b/internal/http/channel_instance_contexts.go index a122bf69..b34130b5 100644 --- a/internal/http/channel_instance_contexts.go +++ b/internal/http/channel_instance_contexts.go @@ -110,7 +110,7 @@ func (h *ChannelInstancesHandler) handleListContextMembers(w http.ResponseWriter } } - if h.contactStore != nil { + if h.contactStore != nil && scopeType != store.ChannelScopeTypeGroup && scopeType != "topic" { contacts, err := h.contactStore.ListContacts(r.Context(), store.ContactListOpts{ ChannelType: inst.ChannelType, ChannelInstance: inst.Name, diff --git a/internal/http/channel_instance_contexts_test.go b/internal/http/channel_instance_contexts_test.go index 6a9ae77b..1d5a91ac 100644 --- a/internal/http/channel_instance_contexts_test.go +++ b/internal/http/channel_instance_contexts_test.go @@ -133,7 +133,7 @@ func TestChannelContextsListUsesStoredGroupsAndMasksToContextShape(t *testing.T) } } -func TestChannelContextMembersUseStoredContactsWhenLiveProviderUnavailable(t *testing.T) { +func TestChannelContextMembersDoNotShowChannelWideContactsAsGroupMembers(t *testing.T) { token := "channel-context-members-key" setupTestCache(t, map[string]*store.APIKeyData{ crypto.HashAPIKey(token): {Scopes: []string{"operator.write"}, OwnerID: "caller"}, @@ -181,9 +181,9 @@ func TestChannelContextMembersUseStoredContactsWhenLiveProviderUnavailable(t *te t.Fatal(err) } if body.LiveMembersSupported || body.Source != "stored_contacts" { - t.Fatalf("expected stored contact fallback, got %+v", body) + t.Fatalf("expected unsupported stored contact response, got %+v", body) } - if len(body.Members) != 1 || body.Members[0].UserID != userID || body.Members[0].PlatformID != "386246614" { + if len(body.Members) != 0 { t.Fatalf("members = %+v", body.Members) } } diff --git a/internal/http/channel_instances.go b/internal/http/channel_instances.go index 3fb4e122..6f540a57 100644 --- a/internal/http/channel_instances.go +++ b/internal/http/channel_instances.go @@ -132,16 +132,16 @@ func (h *ChannelInstancesHandler) RegisterRoutes(mux *http.ServeMux) { mux.HandleFunc("GET /v1/channels/instances/{id}/contexts/{scopeType}/{scopeKey}/members", h.auth(h.handleListContextMembers)) } mux.HandleFunc("GET /v1/channels/instances/{id}/contexts/{scopeType}/{scopeKey}/capabilities", h.auth(h.handleListContextCapabilities)) - mux.HandleFunc("GET /v1/channels/instances/{id}/contexts/{scopeType}/{scopeKey}/mcp-grants", h.auth(h.handleListContextMCPGrants)) + mux.HandleFunc("GET /v1/channels/instances/{id}/contexts/{scopeType}/{scopeKey}/mcp-grants", h.adminAuth(h.handleListContextMCPGrants)) mux.HandleFunc("PUT /v1/channels/instances/{id}/contexts/{scopeType}/{scopeKey}/mcp-grants/{serverID}", h.adminAuth(h.handleUpsertContextMCPGrant)) mux.HandleFunc("DELETE /v1/channels/instances/{id}/contexts/{scopeType}/{scopeKey}/mcp-grants/{serverID}", h.adminAuth(h.handleDeleteContextMCPGrant)) - mux.HandleFunc("GET /v1/channels/instances/{id}/contexts/{scopeType}/{scopeKey}/mcp-credentials", h.auth(h.handleListContextMCPCredentials)) + mux.HandleFunc("GET /v1/channels/instances/{id}/contexts/{scopeType}/{scopeKey}/mcp-credentials", h.adminAuth(h.handleListContextMCPCredentials)) mux.HandleFunc("PUT /v1/channels/instances/{id}/contexts/{scopeType}/{scopeKey}/mcp-credentials/{serverID}", h.adminAuth(h.handleSetContextMCPCredentials)) mux.HandleFunc("DELETE /v1/channels/instances/{id}/contexts/{scopeType}/{scopeKey}/mcp-credentials/{serverID}", h.adminAuth(h.handleDeleteContextMCPCredentials)) - mux.HandleFunc("GET /v1/channels/instances/{id}/contexts/{scopeType}/{scopeKey}/cli-grants", h.auth(h.handleListContextCLIGrants)) + mux.HandleFunc("GET /v1/channels/instances/{id}/contexts/{scopeType}/{scopeKey}/cli-grants", h.adminAuth(h.handleListContextCLIGrants)) mux.HandleFunc("PUT /v1/channels/instances/{id}/contexts/{scopeType}/{scopeKey}/cli-grants/{binaryID}", h.adminAuth(h.handleUpsertContextCLIGrant)) mux.HandleFunc("DELETE /v1/channels/instances/{id}/contexts/{scopeType}/{scopeKey}/cli-grants/{binaryID}", h.adminAuth(h.handleDeleteContextCLIGrant)) - mux.HandleFunc("GET /v1/channels/instances/{id}/contexts/{scopeType}/{scopeKey}/cli-credentials", h.auth(h.handleListContextCLICredentials)) + mux.HandleFunc("GET /v1/channels/instances/{id}/contexts/{scopeType}/{scopeKey}/cli-credentials", h.adminAuth(h.handleListContextCLICredentials)) mux.HandleFunc("PUT /v1/channels/instances/{id}/contexts/{scopeType}/{scopeKey}/cli-credentials/{binaryID}", h.adminAuth(h.handleSetContextCLICredentials)) mux.HandleFunc("DELETE /v1/channels/instances/{id}/contexts/{scopeType}/{scopeKey}/cli-credentials/{binaryID}", h.adminAuth(h.handleDeleteContextCLICredentials)) } diff --git a/internal/mcp/manager.go b/internal/mcp/manager.go index 3208d75a..84abce6d 100644 --- a/internal/mcp/manager.go +++ b/internal/mcp/manager.go @@ -226,8 +226,10 @@ func (m *Manager) resolveServerCredentials(ctx context.Context, info store.MCPAc var contextCreds *store.MCPContextCredentials if contextStore, ok := m.store.(store.MCPContextAdminStore); ok { - if scope, hasScope := store.ChannelContextScopeFromContext(ctx); hasScope { - contextCreds, _ = contextStore.GetContextCredentialsForScope(ctx, scope, srv.ID) + for _, scope := range store.ChannelContextScopeChainFromContext(ctx) { + if creds, _ := contextStore.GetContextCredentialsForScope(ctx, scope, srv.ID); creds != nil { + contextCreds = creds + } } } diff --git a/internal/store/channel_context_store.go b/internal/store/channel_context_store.go index acfbb8cc..7737b2b1 100644 --- a/internal/store/channel_context_store.go +++ b/internal/store/channel_context_store.go @@ -49,6 +49,33 @@ func ChannelContextScopeFromContext(ctx context.Context) (ChannelContextScope, b return ChannelContextScope{}, false } +// ChannelContextScopeChainFromContext returns context scopes ordered from +// broadest to most specific. Applying the chain in order gives channel +// defaults first, then group/user/role overrides. +func ChannelContextScopeChainFromContext(ctx context.Context) []ChannelContextScope { + scope, ok := ChannelContextScopeFromContext(ctx) + if !ok { + return nil + } + if scope.ScopeType == ChannelScopeTypeChannel { + return []ChannelContextScope{scope} + } + channelKey := scope.ChannelInstanceName + if channelKey == "" && scope.ScopeType == ChannelScopeTypeChannel { + channelKey = scope.ScopeKey + } + if channelKey == "" { + return []ChannelContextScope{scope} + } + channelScope := ChannelContextScope{ + ChannelInstanceID: scope.ChannelInstanceID, + ChannelInstanceName: scope.ChannelInstanceName, + ScopeType: ChannelScopeTypeChannel, + ScopeKey: channelKey, + } + return []ChannelContextScope{channelScope, scope} +} + type MCPContextGrant struct { ID uuid.UUID `json:"id" db:"id"` ChannelInstanceID uuid.UUID `json:"channel_instance_id" db:"channel_instance_id"` diff --git a/internal/store/pg/mcp_servers_access.go b/internal/store/pg/mcp_servers_access.go index b1b805a2..bea07579 100644 --- a/internal/store/pg/mcp_servers_access.go +++ b/internal/store/pg/mcp_servers_access.go @@ -204,47 +204,49 @@ func (s *PGMCPServerStore) ListAccessible(ctx context.Context, agentID uuid.UUID } func (s *PGMCPServerStore) applyContextMCPAccess(ctx context.Context, result []store.MCPAccessInfo) ([]store.MCPAccessInfo, error) { - scope, ok := store.ChannelContextScopeFromContext(ctx) - if !ok { + scopes := store.ChannelContextScopeChainFromContext(ctx) + if len(scopes) == 0 { return result, nil } - grants, err := s.ListContextGrantsForScope(ctx, scope) - if err != nil { - return nil, err - } - if len(grants) == 0 { - return result, nil - } - byServer := make(map[uuid.UUID]int, len(result)) - for i := range result { - byServer[result[i].Server.ID] = i - } - for _, grant := range grants { - if idx, exists := byServer[grant.ServerID]; exists { - if !grant.Enabled { - result = append(result[:idx], result[idx+1:]...) - byServer = make(map[uuid.UUID]int, len(result)) - for i := range result { - byServer[result[i].Server.ID] = i + for _, scope := range scopes { + grants, err := s.ListContextGrantsForScope(ctx, scope) + if err != nil { + return nil, err + } + if len(grants) == 0 { + continue + } + byServer := make(map[uuid.UUID]int, len(result)) + for i := range result { + byServer[result[i].Server.ID] = i + } + for _, grant := range grants { + if idx, exists := byServer[grant.ServerID]; exists { + if !grant.Enabled { + result = append(result[:idx], result[idx+1:]...) + byServer = make(map[uuid.UUID]int, len(result)) + for i := range result { + byServer[result[i].Server.ID] = i + } + continue } + result[idx].ToolAllow = decodeGrantStringList(grant.ToolAllow) + result[idx].ToolDeny = decodeGrantStringList(grant.ToolDeny) continue } - result[idx].ToolAllow = decodeGrantStringList(grant.ToolAllow) - result[idx].ToolDeny = decodeGrantStringList(grant.ToolDeny) - continue + if !grant.Enabled { + continue + } + server, err := s.GetServer(ctx, grant.ServerID) + if err != nil || server == nil || !server.Enabled { + continue + } + result = append(result, store.MCPAccessInfo{ + Server: *server, + ToolAllow: decodeGrantStringList(grant.ToolAllow), + ToolDeny: decodeGrantStringList(grant.ToolDeny), + }) } - if !grant.Enabled { - continue - } - server, err := s.GetServer(ctx, grant.ServerID) - if err != nil || server == nil || !server.Enabled { - continue - } - result = append(result, store.MCPAccessInfo{ - Server: *server, - ToolAllow: decodeGrantStringList(grant.ToolAllow), - ToolDeny: decodeGrantStringList(grant.ToolDeny), - }) } return result, nil } diff --git a/internal/store/pg/secure_cli.go b/internal/store/pg/secure_cli.go index 22e79d3b..02cb90fe 100644 --- a/internal/store/pg/secure_cli.go +++ b/internal/store/pg/secure_cli.go @@ -503,37 +503,55 @@ func (s *PGSecureCLIStore) scanRowWithGrantAndUserEnv(row *sql.Row) (*store.Secu } func (s *PGSecureCLIStore) applyContextSecureCLI(ctx context.Context, b *store.SecureCLIBinary) (*store.SecureCLIBinary, error) { - scope, ok := store.ChannelContextScopeFromContext(ctx) - if !ok || b == nil { + scopes := store.ChannelContextScopeChainFromContext(ctx) + if len(scopes) == 0 || b == nil { return b, nil } - grants, err := s.ListContextGrantsForScope(ctx, scope) - if err != nil { - return nil, err + hasUserCredential := len(b.UserEnv) > 0 || b.UserCredentialType != nil || b.UserHostScope != nil + disabledByContext := false + for _, scope := range scopes { + grants, err := s.ListContextGrantsForScope(ctx, scope) + if err != nil { + return nil, err + } + for _, contextGrant := range grants { + if contextGrant.BinaryID != b.ID { + continue + } + if !contextGrant.Enabled { + disabledByContext = true + break + } + grant := &store.SecureCLIAgentGrant{ + DenyArgs: contextGrant.DenyArgs, + DenyVerbose: contextGrant.DenyVerbose, + TimeoutSeconds: contextGrant.TimeoutSeconds, + Tips: contextGrant.Tips, + EncryptedEnv: contextGrant.EncryptedEnv, + } + b.MergeGrantOverrides(grant) + disabledByContext = false + break + } } - for _, contextGrant := range grants { - if contextGrant.BinaryID != b.ID { + if disabledByContext { + return nil, nil + } + for _, scope := range scopes { + creds, err := s.GetContextCredentialsForScope(ctx, scope, b.ID) + if err != nil { + return nil, err + } + if creds == nil || len(creds.EncryptedEnv) == 0 { continue } - if !contextGrant.Enabled { - return nil, nil + if !hasUserCredential { + b.UserEnv = creds.EncryptedEnv + b.UserCredentialType = creds.CredentialType + b.UserHostScope = creds.HostScope + } else { + b.EncryptedEnv = creds.EncryptedEnv } - grant := &store.SecureCLIAgentGrant{ - DenyArgs: contextGrant.DenyArgs, - DenyVerbose: contextGrant.DenyVerbose, - TimeoutSeconds: contextGrant.TimeoutSeconds, - Tips: contextGrant.Tips, - EncryptedEnv: contextGrant.EncryptedEnv, - } - b.MergeGrantOverrides(grant) - break - } - creds, err := s.GetContextCredentialsForScope(ctx, scope, b.ID) - if err != nil { - return nil, err - } - if creds != nil && len(creds.EncryptedEnv) > 0 { - b.EncryptedEnv = creds.EncryptedEnv } return b, nil } diff --git a/internal/store/sqlitestore/mcp_servers_access.go b/internal/store/sqlitestore/mcp_servers_access.go index 01448f33..5fbc52c8 100644 --- a/internal/store/sqlitestore/mcp_servers_access.go +++ b/internal/store/sqlitestore/mcp_servers_access.go @@ -238,47 +238,49 @@ func (s *SQLiteMCPServerStore) ListAccessible(ctx context.Context, agentID uuid. } func (s *SQLiteMCPServerStore) applyContextMCPAccess(ctx context.Context, result []store.MCPAccessInfo) ([]store.MCPAccessInfo, error) { - scope, ok := store.ChannelContextScopeFromContext(ctx) - if !ok { + scopes := store.ChannelContextScopeChainFromContext(ctx) + if len(scopes) == 0 { return result, nil } - grants, err := s.ListContextGrantsForScope(ctx, scope) - if err != nil { - return nil, err - } - if len(grants) == 0 { - return result, nil - } - byServer := make(map[uuid.UUID]int, len(result)) - for i := range result { - byServer[result[i].Server.ID] = i - } - for _, grant := range grants { - if idx, exists := byServer[grant.ServerID]; exists { - if !grant.Enabled { - result = append(result[:idx], result[idx+1:]...) - byServer = make(map[uuid.UUID]int, len(result)) - for i := range result { - byServer[result[i].Server.ID] = i + for _, scope := range scopes { + grants, err := s.ListContextGrantsForScope(ctx, scope) + if err != nil { + return nil, err + } + if len(grants) == 0 { + continue + } + byServer := make(map[uuid.UUID]int, len(result)) + for i := range result { + byServer[result[i].Server.ID] = i + } + for _, grant := range grants { + if idx, exists := byServer[grant.ServerID]; exists { + if !grant.Enabled { + result = append(result[:idx], result[idx+1:]...) + byServer = make(map[uuid.UUID]int, len(result)) + for i := range result { + byServer[result[i].Server.ID] = i + } + continue } + result[idx].ToolAllow = decodeGrantStringList(grant.ToolAllow) + result[idx].ToolDeny = decodeGrantStringList(grant.ToolDeny) continue } - result[idx].ToolAllow = decodeGrantStringList(grant.ToolAllow) - result[idx].ToolDeny = decodeGrantStringList(grant.ToolDeny) - continue + if !grant.Enabled { + continue + } + server, err := s.GetServer(ctx, grant.ServerID) + if err != nil || server == nil || !server.Enabled { + continue + } + result = append(result, store.MCPAccessInfo{ + Server: *server, + ToolAllow: decodeGrantStringList(grant.ToolAllow), + ToolDeny: decodeGrantStringList(grant.ToolDeny), + }) } - if !grant.Enabled { - continue - } - server, err := s.GetServer(ctx, grant.ServerID) - if err != nil || server == nil || !server.Enabled { - continue - } - result = append(result, store.MCPAccessInfo{ - Server: *server, - ToolAllow: decodeGrantStringList(grant.ToolAllow), - ToolDeny: decodeGrantStringList(grant.ToolDeny), - }) } return result, nil } diff --git a/internal/store/sqlitestore/secure-cli.go b/internal/store/sqlitestore/secure-cli.go index e669a71d..a3fe20dc 100644 --- a/internal/store/sqlitestore/secure-cli.go +++ b/internal/store/sqlitestore/secure-cli.go @@ -529,37 +529,55 @@ func (s *SQLiteSecureCLIStore) scanRowWithGrantAndUserEnv(row *sql.Row) (*store. } func (s *SQLiteSecureCLIStore) applyContextSecureCLI(ctx context.Context, b *store.SecureCLIBinary) (*store.SecureCLIBinary, error) { - scope, ok := store.ChannelContextScopeFromContext(ctx) - if !ok || b == nil { + scopes := store.ChannelContextScopeChainFromContext(ctx) + if len(scopes) == 0 || b == nil { return b, nil } - grants, err := s.ListContextGrantsForScope(ctx, scope) - if err != nil { - return nil, err + hasUserCredential := len(b.UserEnv) > 0 || b.UserCredentialType != nil || b.UserHostScope != nil + disabledByContext := false + for _, scope := range scopes { + grants, err := s.ListContextGrantsForScope(ctx, scope) + if err != nil { + return nil, err + } + for _, contextGrant := range grants { + if contextGrant.BinaryID != b.ID { + continue + } + if !contextGrant.Enabled { + disabledByContext = true + break + } + grant := &store.SecureCLIAgentGrant{ + DenyArgs: contextGrant.DenyArgs, + DenyVerbose: contextGrant.DenyVerbose, + TimeoutSeconds: contextGrant.TimeoutSeconds, + Tips: contextGrant.Tips, + EncryptedEnv: contextGrant.EncryptedEnv, + } + b.MergeGrantOverrides(grant) + disabledByContext = false + break + } } - for _, contextGrant := range grants { - if contextGrant.BinaryID != b.ID { + if disabledByContext { + return nil, nil + } + for _, scope := range scopes { + creds, err := s.GetContextCredentialsForScope(ctx, scope, b.ID) + if err != nil { + return nil, err + } + if creds == nil || len(creds.EncryptedEnv) == 0 { continue } - if !contextGrant.Enabled { - return nil, nil + if !hasUserCredential { + b.UserEnv = creds.EncryptedEnv + b.UserCredentialType = creds.CredentialType + b.UserHostScope = creds.HostScope + } else { + b.EncryptedEnv = creds.EncryptedEnv } - grant := &store.SecureCLIAgentGrant{ - DenyArgs: contextGrant.DenyArgs, - DenyVerbose: contextGrant.DenyVerbose, - TimeoutSeconds: contextGrant.TimeoutSeconds, - Tips: contextGrant.Tips, - EncryptedEnv: contextGrant.EncryptedEnv, - } - b.MergeGrantOverrides(grant) - break - } - creds, err := s.GetContextCredentialsForScope(ctx, scope, b.ID) - if err != nil { - return nil, err - } - if creds != nil && len(creds.EncryptedEnv) > 0 { - b.EncryptedEnv = creds.EncryptedEnv } return b, nil } diff --git a/ui/web/src/i18n/locales/en/channels.json b/ui/web/src/i18n/locales/en/channels.json index 8849a0bd..091011ad 100644 --- a/ui/web/src/i18n/locales/en/channels.json +++ b/ui/web/src/i18n/locales/en/channels.json @@ -171,12 +171,31 @@ "allTools": "All tools", "scopedCount": "{{count}} scoped override", "scopedCount_plural": "{{count}} scoped overrides", + "live": "Live", + "grant": "Grant", + "revoke": "Revoke", + "manageCapability": "Manage capability", + "scopedCredential": "Scoped credential", + "apiKeyPlaceholder": "API key (MCP only)", + "envPlaceholder": "Environment JSON, for example {\"TOKEN\":\"...\"}", + "secretHint": "Values are encrypted and never returned after save.", + "saveCredential": "Save credential", + "deleteCredential": "Delete credential", + "grantSaved": "Context grant saved", + "grantDeleted": "Context grant deleted", + "credentialSaved": "Context credential saved", + "credentialDeleted": "Context credential deleted", + "invalidEnv": "Environment must be a JSON object with string values.", + "envRequired": "Secure CLI credentials require environment JSON.", + "credentialRequired": "Enter an API key or environment JSON.", + "actionFailed": "Action failed", "columns": { "name": "Name", "type": "Type", "source": "Source", "credentials": "Credentials", - "tools": "Tools" + "tools": "Tools", + "actions": "Actions" } }, "timeline": { diff --git a/ui/web/src/i18n/locales/vi/channels.json b/ui/web/src/i18n/locales/vi/channels.json index 9f22279a..40feea90 100644 --- a/ui/web/src/i18n/locales/vi/channels.json +++ b/ui/web/src/i18n/locales/vi/channels.json @@ -170,12 +170,31 @@ "allTools": "Tất cả tools", "scopedCount": "{{count}} override theo scope", "scopedCount_plural": "{{count}} override theo scope", + "live": "Live", + "grant": "Grant", + "revoke": "Revoke", + "manageCapability": "Quản lý quyền năng", + "scopedCredential": "Xác thực theo scope", + "apiKeyPlaceholder": "API key (chỉ MCP)", + "envPlaceholder": "JSON môi trường, ví dụ {\"TOKEN\":\"...\"}", + "secretHint": "Giá trị được mã hóa và không bao giờ trả về sau khi lưu.", + "saveCredential": "Lưu xác thực", + "deleteCredential": "Xóa xác thực", + "grantSaved": "Đã lưu grant theo ngữ cảnh", + "grantDeleted": "Đã xóa grant theo ngữ cảnh", + "credentialSaved": "Đã lưu xác thực theo ngữ cảnh", + "credentialDeleted": "Đã xóa xác thực theo ngữ cảnh", + "invalidEnv": "Môi trường phải là JSON object với giá trị dạng chuỗi.", + "envRequired": "Secure CLI credential cần JSON môi trường.", + "credentialRequired": "Nhập API key hoặc JSON môi trường.", + "actionFailed": "Thao tác thất bại", "columns": { "name": "Tên", "type": "Loại", "source": "Nguồn", "credentials": "Xác thực", - "tools": "Tools" + "tools": "Tools", + "actions": "Thao tác" } }, "timeline": { diff --git a/ui/web/src/i18n/locales/zh/channels.json b/ui/web/src/i18n/locales/zh/channels.json index 92bf00b6..357cdd21 100644 --- a/ui/web/src/i18n/locales/zh/channels.json +++ b/ui/web/src/i18n/locales/zh/channels.json @@ -170,12 +170,31 @@ "allTools": "全部工具", "scopedCount": "{{count}} 个作用域覆盖", "scopedCount_plural": "{{count}} 个作用域覆盖", + "live": "实时", + "grant": "授权", + "revoke": "撤销", + "manageCapability": "管理能力", + "scopedCredential": "作用域凭据", + "apiKeyPlaceholder": "API key(仅 MCP)", + "envPlaceholder": "环境变量 JSON,例如 {\"TOKEN\":\"...\"}", + "secretHint": "值会加密保存,保存后不会返回。", + "saveCredential": "保存凭据", + "deleteCredential": "删除凭据", + "grantSaved": "上下文授权已保存", + "grantDeleted": "上下文授权已删除", + "credentialSaved": "上下文凭据已保存", + "credentialDeleted": "上下文凭据已删除", + "invalidEnv": "环境变量必须是字符串值的 JSON 对象。", + "envRequired": "Secure CLI 凭据需要环境变量 JSON。", + "credentialRequired": "请输入 API key 或环境变量 JSON。", + "actionFailed": "操作失败", "columns": { "name": "名称", "type": "类型", "source": "来源", "credentials": "凭据", - "tools": "工具" + "tools": "工具", + "actions": "操作" } }, "timeline": { diff --git a/ui/web/src/pages/channels/channel-detail/channel-context-capability-admin-dialog.tsx b/ui/web/src/pages/channels/channel-detail/channel-context-capability-admin-dialog.tsx new file mode 100644 index 00000000..c5373a4d --- /dev/null +++ b/ui/web/src/pages/channels/channel-detail/channel-context-capability-admin-dialog.tsx @@ -0,0 +1,180 @@ +import { useEffect, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { KeyRound, Loader2, ShieldCheck, ShieldX, Trash2 } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { + Dialog, + DialogContent, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { Input } from "@/components/ui/input"; +import { Textarea } from "@/components/ui/textarea"; +import type { ChannelCapability, ChannelContextData } from "@/types/channel"; +import type { ChannelCredentialPayload } from "../hooks/use-channel-detail"; + +export interface ChannelContextCapabilityTarget { + context: ChannelContextData; + capability: ChannelCapability; +} + +interface ChannelContextCapabilityAdminDialogProps { + target: ChannelContextCapabilityTarget | null; + open: boolean; + onOpenChange: (open: boolean) => void; + onGrant: (target: ChannelContextCapabilityTarget) => Promise; + onRevoke: (target: ChannelContextCapabilityTarget) => Promise; + onSaveCredentials: (target: ChannelContextCapabilityTarget, payload: ChannelCredentialPayload) => Promise; + onDeleteCredentials: (target: ChannelContextCapabilityTarget) => Promise; +} + +function parseEnvJSON(value: string): Record | undefined { + const trimmed = value.trim(); + if (!trimmed) return undefined; + const parsed = JSON.parse(trimmed) as Record; + const env: Record = {}; + for (const [key, raw] of Object.entries(parsed)) { + if (typeof raw === "string") { + env[key] = raw; + } + } + return env; +} + +export function ChannelContextCapabilityAdminDialog({ + target, + open, + onOpenChange, + onGrant, + onRevoke, + onSaveCredentials, + onDeleteCredentials, +}: ChannelContextCapabilityAdminDialogProps) { + const { t } = useTranslation("channels"); + const [apiKey, setApiKey] = useState(""); + const [envText, setEnvText] = useState(""); + const [error, setError] = useState(""); + const [saving, setSaving] = useState<"grant" | "revoke" | "credentials" | "deleteCredentials" | null>(null); + + useEffect(() => { + if (open) { + setApiKey(""); + setEnvText(""); + setError(""); + setSaving(null); + } + }, [open, target]); + + if (!target) return null; + + const { capability } = target; + const isMCP = capability.type === "mcp_server"; + + const run = async ( + action: "grant" | "revoke" | "credentials" | "deleteCredentials", + task: () => Promise, + ) => { + setSaving(action); + setError(""); + try { + await task(); + onOpenChange(false); + } catch (err) { + setError(err instanceof Error ? err.message : t("detail.contexts.actionFailed")); + } finally { + setSaving(null); + } + }; + + const saveCredentials = async () => { + let env: Record | undefined; + try { + env = parseEnvJSON(envText); + } catch { + setError(t("detail.contexts.invalidEnv")); + return; + } + if (!isMCP && !env) { + setError(t("detail.contexts.envRequired")); + return; + } + if (isMCP && !env && !apiKey.trim()) { + setError(t("detail.contexts.credentialRequired")); + return; + } + await run("credentials", () => onSaveCredentials(target, { apiKey: apiKey.trim() || undefined, env })); + }; + + return ( + { if (!saving) onOpenChange(next); }}> + + + {t("detail.contexts.manageCapability")} + + +
+
+
{capability.display_name || capability.name}
+
{capability.name}
+
+ +
+ + +
+ +
+
{t("detail.contexts.scopedCredential")}
+ {isMCP && ( + setApiKey(event.target.value)} + placeholder={t("detail.contexts.apiKeyPlaceholder")} + /> + )} +