From e6242dbbbc33ee121bed288c32fb53c72fda74ec Mon Sep 17 00:00:00 2001 From: Goon Date: Sun, 31 May 2026 15:52:04 +0700 Subject: [PATCH 1/3] feat(cli-credentials): plan agent-scoped git credentials --- .../phase-01-research-and-contract-tests.md | 99 +++++++++++++ .../phase-02-schema-and-store-resolver.md | 135 +++++++++++++++++ ...phase-03-http-api-credential-management.md | 138 ++++++++++++++++++ .../phase-04-web-ui-credential-management.md | 118 +++++++++++++++ ...phase-05-runtime-git-adapter-validation.md | 116 +++++++++++++++ .../phase-06-docs-validation-and-handoff.md | 106 ++++++++++++++ .../plan.md | 64 ++++++++ .../reports/redteam-report.md | 75 ++++++++++ .../reports/validation-report.md | 75 ++++++++++ 9 files changed, 926 insertions(+) create mode 100644 plans/260531-1545-agent-scoped-git-credentials/phase-01-research-and-contract-tests.md create mode 100644 plans/260531-1545-agent-scoped-git-credentials/phase-02-schema-and-store-resolver.md create mode 100644 plans/260531-1545-agent-scoped-git-credentials/phase-03-http-api-credential-management.md create mode 100644 plans/260531-1545-agent-scoped-git-credentials/phase-04-web-ui-credential-management.md create mode 100644 plans/260531-1545-agent-scoped-git-credentials/phase-05-runtime-git-adapter-validation.md create mode 100644 plans/260531-1545-agent-scoped-git-credentials/phase-06-docs-validation-and-handoff.md create mode 100644 plans/260531-1545-agent-scoped-git-credentials/plan.md create mode 100644 plans/260531-1545-agent-scoped-git-credentials/reports/redteam-report.md create mode 100644 plans/260531-1545-agent-scoped-git-credentials/reports/validation-report.md diff --git a/plans/260531-1545-agent-scoped-git-credentials/phase-01-research-and-contract-tests.md b/plans/260531-1545-agent-scoped-git-credentials/phase-01-research-and-contract-tests.md new file mode 100644 index 00000000..a1b76117 --- /dev/null +++ b/plans/260531-1545-agent-scoped-git-credentials/phase-01-research-and-contract-tests.md @@ -0,0 +1,99 @@ +--- +phase: 1 +title: "Research and contract tests" +status: pending +effort: "" +--- + +# Phase 1: Research and contract tests + +## Context Links + +- Issue: https://github.com/digitopvn/goclaw/issues/117 +- Current user ID resolver: `internal/agent/user_identity_resolver.go:40` +- Tool execution context injection: `internal/agent/loop_pipeline_tool_callbacks.go:47` +- Secure CLI store contract: `internal/store/secure_cli_store.go:120` +- Current per-user HTTP API: `internal/http/secure_cli_user_credentials.go:13` +- Current git UI entry point: `ui/web/src/pages/cli-credentials/cli-credentials-table.tsx:80` + +## Overview + +Write characterization and contract tests before schema or UI changes. The goal is to pin the current failure mode: git typed credentials depend on a user credential row, but cross-channel usage often cannot map to the same credential user ID. + +Priority: P1. + +Status: pending. + +## Key Insights + +- Agent identity is stable for the runtime path; external user identity is not stable across channels. +- User credentials should stay supported, but they should not be the primary git credential setup path. +- Existing context credentials already show that credential resolution is not purely per-user; agent credentials should join that explicit precedence chain. + +## Requirements + +- Preserve existing per-user credential behavior. +- Add tests that fail under the current implementation when no matching `userID` exists but an agent credential exists. +- Make API and UI requirements explicit before implementation. + +## Architecture + +Effective credential source should become a small explicit enum in tests and later code: + +1. `user` for explicit per-user override. +2. `context` for group/member/channel scoped credential. +3. `agent` for the new agent-scoped credential. +4. `binary` for legacy/global binary env. +5. `none` when no typed credential is available. + +## Related Code Files + +- Modify tests under `internal/store/pg/`, `internal/store/sqlitestore/`, `internal/tools/`, and `internal/http/`. +- Add or extend UI tests under `ui/web/src/pages/cli-credentials/__tests__/`. +- No production code changes in this phase except test fixtures if needed. + +## Implementation Steps + +1. Add store contract tests proving the intended precedence: user > context > agent > binary. +2. Add a runtime test where the same agent executes `git clone` from two different credential user IDs and resolves the same agent credential. +3. Add an HTTP route contract test for planned agent credential endpoints: + - `GET /v1/cli-credentials/{id}/agent-credentials` + - `GET /v1/cli-credentials/{id}/agent-credentials/{agentId}` + - `PUT /v1/cli-credentials/{id}/agent-credentials/{agentId}` + - `DELETE /v1/cli-credentials/{id}/agent-credentials/{agentId}` +4. Add negative API tests: + - invalid `binaryID` + - invalid `agentID` + - missing `host_scope` for `pat` and `ssh_key` + - unsupported `credential_type` + - response never includes raw token/key/blob +5. Add Web UI tests that the git credential action defaults to Agent Credentials, with User Credentials shown as advanced/personal override. +6. Document which tests fail before implementation. + +## Todo List + +- [ ] Store precedence tests written. +- [ ] Runtime cross-channel agent credential test written. +- [ ] HTTP endpoint contract tests written. +- [ ] UI default-flow test written. +- [ ] Initial failing test set documented in the phase notes. + +## Success Criteria + +- [ ] Tests prove the current user-id keyed design cannot satisfy the target behavior. +- [ ] Tests define the exact endpoint contract and response masking. +- [ ] No implementation-only code is added before the contract tests exist. + +## Risk Assessment + +- Risk: tests could encode a wrong precedence order. Mitigation: keep user override highest for compatibility, but make agent credential the default UI path. +- Risk: agent credential could accidentally grant binary execution access. Mitigation: test that credential rows do not bypass non-global agent grants. + +## Security Considerations + +- Contract tests must assert no plaintext credential values appear in list/detail responses, audit labels, logs, or errors. +- Tests must assert tenant isolation for every new endpoint. + +## Next Steps + +- Phase 2 adds schema and store implementation until Phase 1 tests pass. diff --git a/plans/260531-1545-agent-scoped-git-credentials/phase-02-schema-and-store-resolver.md b/plans/260531-1545-agent-scoped-git-credentials/phase-02-schema-and-store-resolver.md new file mode 100644 index 00000000..088bc698 --- /dev/null +++ b/plans/260531-1545-agent-scoped-git-credentials/phase-02-schema-and-store-resolver.md @@ -0,0 +1,135 @@ +--- +phase: 2 +title: "Schema and store resolver" +status: pending +effort: "" +--- + +# Phase 2: Schema and store resolver + +## Context Links + +- User credential struct: `internal/store/secure_cli_store.go:79` +- Agent grant struct: `internal/store/secure_cli_store.go:97` +- PostgreSQL lookup path: `internal/store/pg/secure_cli.go:337` +- Context credential overlay: `internal/store/pg/secure_cli.go:505` +- SQLite schema version map: `internal/store/sqlitestore/schema.go` +- PostgreSQL latest migration at planning time: `migrations/000076_channel_memory_extraction.up.sql` + +## Overview + +Create durable agent-scoped typed credential storage and make `LookupByBinary` return an effective credential source without depending on channel-specific user IDs. + +Priority: P1. + +Status: pending. + +## Key Insights + +- `secure_cli_agent_grants.encrypted_env` is a policy override payload, not a typed credential identity. It lacks `credential_type` and `host_scope`. +- A dedicated table keeps grant authorization separate from credential material. +- SQLite must be updated in both fresh schema and incremental migrations. + +## Requirements + +- Add `secure_cli_agent_credentials` for both PostgreSQL and SQLite. +- Encrypt secret blob with the same AES-256-GCM pattern as existing SecureCLI credentials. +- Preserve per-user and context credentials. +- Return source metadata for audit and UI. +- Do not let an agent credential row grant access to a non-global CLI binary by itself. + +## Architecture + +New table shape: + +```sql +secure_cli_agent_credentials ( + id uuid/text primary key, + tenant_id uuid/text not null, + binary_id uuid/text not null, + agent_id uuid/text not null, + encrypted_env bytea/blob not null, + metadata jsonb/text not null default '{}', + credential_type text null, + host_scope text null, + created_by text not null default '', + created_at timestamptz/text not null, + updated_at timestamptz/text not null, + unique (tenant_id, binary_id, agent_id) +) +``` + +Effective precedence: + +1. Per-user credential when `userID` maps to a row. +2. Context scoped credential from the channel scope chain. +3. Agent credential for `(tenant_id, binary_id, agent_id)`. +4. Binary/global env. + +Authorization rule: + +- If `secure_cli_binaries.is_global = false`, `secure_cli_agent_grants` must still allow the agent before runtime uses the binary or its credential. +- If `is_global = true`, an agent credential can specialize the otherwise global binary for that agent. + +## Related Code Files + +- Add migration: `migrations/000077_secure_cli_agent_credentials.up.sql` and `.down.sql`, if `000077` is still the next number. +- Update `internal/upgrade/version.go`. +- Update `internal/store/secure_cli_store.go`. +- Add `internal/store/pg/secure_cli_agent_credentials.go`. +- Add `internal/store/sqlitestore/secure-cli-agent-credentials.go`. +- Update `internal/store/pg/secure_cli.go`. +- Update `internal/store/sqlitestore/secure-cli.go`. +- Update `internal/store/sqlitestore/schema.sql` and `internal/store/sqlitestore/schema.go`. + +## Implementation Steps + +1. Re-run `find migrations -name '*.up.sql' | sort | tail` and choose the next migration number. +2. Add PG migration with foreign keys to `secure_cli_binaries`, `agents`, and tenant scope. Add indexes on `(tenant_id, binary_id)`, `(tenant_id, agent_id)`, and unique `(tenant_id, binary_id, agent_id)`. +3. Add SQLite fresh schema and incremental migration. Bump `SchemaVersion`. +4. Add `SecureCLIAgentCredential` struct and store methods: + - `GetAgentCredentials(ctx, binaryID, agentID)` + - `SetAgentCredentialsTyped(ctx, binaryID, agentID, encryptedEnv, credentialType, hostScope)` + - `SetAgentCredentials(ctx, binaryID, agentID, encryptedEnv)` for legacy env + - `DeleteAgentCredentials(ctx, binaryID, agentID)` + - `ListAgentCredentials(ctx, binaryID)` +5. Extend lookup result with effective credential source fields. Prefer a neutral name such as `CredentialEnv`, `CredentialType`, `CredentialHostScope`, `CredentialSource`, and `CredentialSubjectID` instead of reusing `User*` fields for non-user sources. +6. Update PG and SQLite lookup: + - join user credential only when `userID` exists + - join agent credential when `agentID` exists + - apply context credentials before agent credential if context credential exists + - preserve grant authorization check before returning a non-global binary +7. Update fake stores in tests to implement the new interface. +8. Run targeted store tests for PG and SQLite. + +## Todo List + +- [ ] PG migration added and down migration removes table/indexes. +- [ ] SQLite schema and version migration added. +- [ ] Store interface and concrete PG/SQLite methods added. +- [ ] Effective source metadata added without breaking JSON responses. +- [ ] Phase 1 store tests pass. + +## Success Criteria + +- [ ] `LookupByBinary` can resolve typed git credentials for an agent even when `userID == ""`. +- [ ] Per-user credential still wins when present. +- [ ] Context credential still wins over agent credential. +- [ ] Non-global binaries still require enabled grants. +- [ ] PG and SQLite tests cover fresh and migrated schemas. + +## Risk Assessment + +- Risk: reusing `UserEnv` for agent credentials hides source semantics. Mitigation: introduce source-neutral fields and keep `User*` only for backward compatibility during refactor. +- Risk: migration number collision. Mitigation: verify immediately before implementation. +- Risk: SQLite desktop startup breaks. Mitigation: update both schema.sql and incremental migration map. + +## Security Considerations + +- Secret values remain encrypted at rest. +- Store methods must scope by tenant in every query. +- Delete binary or agent should cascade or fail predictably; use foreign keys consistent with existing store behavior. + +## Next Steps + +- Phase 3 exposes API CRUD over the new store methods. diff --git a/plans/260531-1545-agent-scoped-git-credentials/phase-03-http-api-credential-management.md b/plans/260531-1545-agent-scoped-git-credentials/phase-03-http-api-credential-management.md new file mode 100644 index 00000000..da29401b --- /dev/null +++ b/plans/260531-1545-agent-scoped-git-credentials/phase-03-http-api-credential-management.md @@ -0,0 +1,138 @@ +--- +phase: 3 +title: "HTTP API credential management" +status: pending +effort: "" +--- + +# Phase 3: HTTP API credential management + +## Context Links + +- Route registration: `internal/http/secure_cli.go:38` +- Current user credential handlers: `internal/http/secure_cli_user_credentials.go:13` +- Typed credential validator: `internal/http/secure_cli_typed_credentials.go:54` +- API docs table: `docs/18-http-api.md:1176` + +## Overview + +Add HTTP API endpoints for agent-scoped CLI credential management. This is required by the user request and must not be left as a UI-only feature. + +Priority: P1. + +Status: pending. + +## Key Insights + +- The validator for `{credential_type, host_scope, blob}` already exists for user credentials and should be reused for agent credentials. +- API must make clear that credentials are not grants. A credential row stores secret material; `agent-grants` still controls non-global binary access. +- Responses should mirror the user credential API, but use `agent_id` and optional agent display metadata. + +## Requirements + +- Add routes: + - `GET /v1/cli-credentials/{id}/agent-credentials` + - `GET /v1/cli-credentials/{id}/agent-credentials/{agentId}` + - `PUT /v1/cli-credentials/{id}/agent-credentials/{agentId}` + - `DELETE /v1/cli-credentials/{id}/agent-credentials/{agentId}` +- Reuse typed payload body: + - `credential_type: "pat" | "ssh_key" | "env"` + - `host_scope` + - `blob: {"token": "..."} | {"key": "..."}` + - legacy `env` for env-only CLIs +- Return masked metadata only. +- Emit audit events with credential type and IDs, never secret values. +- Require admin auth and tenant scope. + +## Architecture + +Response list shape: + +```json +{ + "agent_credentials": [ + { + "id": "...", + "binary_id": "...", + "agent_id": "...", + "agent_key": "builder", + "has_secret": true, + "credential_type": "pat", + "host_scope": "github.com", + "created_at": "...", + "updated_at": "..." + } + ] +} +``` + +Detail response: + +```json +{ + "agent_id": "...", + "credential_type": "pat", + "host_scope": "github.com", + "has_secret": true +} +``` + +Legacy env credentials may include sanitized `env` entries. Typed credentials must not return `blob`, `token`, or `key`. + +## Related Code Files + +- Add `internal/http/secure_cli_agent_credentials.go`. +- Extend `internal/http/secure_cli_typed_credentials.go` if shared helpers need neutral names. +- Update `internal/http/secure_cli.go` route registration. +- Add tests near `internal/http/secure_cli_typed_credentials_test.go`. +- Update API docs in `docs/18-http-api.md` and `docs/20-api-keys-auth.md`. + +## Implementation Steps + +1. Refactor `typedCredentialBody`, `prepareTypedCredentialEnv`, and `writeTypedCredentialError` only if needed to support both user and agent handlers. +2. Add handler methods for list/get/put/delete agent credentials. +3. Validate `binaryID` and `agentID` path params with `uuid.Parse`. +4. Verify binary and agent exist through store methods before writing. +5. For PUT: + - typed branch: validate blob and call `SetAgentCredentialsTyped` + - env branch: merge env object and call `SetAgentCredentials` +6. For GET/list: + - return metadata and `has_secret` + - suppress `env` for typed credentials +7. Emit audit events: + - `secure_cli.agent_credentials.updated` + - `secure_cli.agent_credentials.deleted` +8. Invalidate SecureCLI cache after update/delete. +9. Run HTTP tests. + +## Todo List + +- [ ] Agent credential routes registered. +- [ ] Handler tests cover list/get/put/delete. +- [ ] Typed validation shared with user credential API. +- [ ] Responses mask typed secrets. +- [ ] API docs updated with endpoint table and body examples. + +## Success Criteria + +- [ ] API can fully manage agent-scoped PAT and SSH credentials. +- [ ] API can edit legacy env credentials for non-git binaries. +- [ ] Invalid agent/binary returns a clear 404 or 400. +- [ ] Non-admin cannot manage credentials. +- [ ] No API response leaks raw credential material. + +## Risk Assessment + +- Risk: new credential endpoint may be mistaken for grant endpoint. Mitigation: docs and UI state credential does not grant access. +- Risk: duplicate validation forks. Mitigation: reuse the existing typed credential validator. + +## Security Considerations + +- Use `http.MaxBytesReader` or existing JSON binding limits. +- Do not log raw request body. +- Audit should include `credential_type` and resource IDs only. +- All writes must be tenant-scoped. + +## Next Steps + +- Phase 4 consumes these endpoints from the Web UI. diff --git a/plans/260531-1545-agent-scoped-git-credentials/phase-04-web-ui-credential-management.md b/plans/260531-1545-agent-scoped-git-credentials/phase-04-web-ui-credential-management.md new file mode 100644 index 00000000..3d6368c4 --- /dev/null +++ b/plans/260531-1545-agent-scoped-git-credentials/phase-04-web-ui-credential-management.md @@ -0,0 +1,118 @@ +--- +phase: 4 +title: "Web UI credential management" +status: pending +effort: "" +--- + +# Phase 4: Web UI credential management + +## Context Links + +- CLI credentials table action: `ui/web/src/pages/cli-credentials/cli-credentials-table.tsx:70` +- Current user credential dialog: `ui/web/src/pages/cli-credentials/cli-user-credentials-dialog.tsx:70` +- Current git typed fields: `ui/web/src/pages/cli-credentials/cli-credential-git-fields.tsx` +- Current hooks: `ui/web/src/pages/cli-credentials/hooks/use-cli-credentials.ts` +- Current i18n namespace: `ui/web/src/i18n/locales/en/cli-credentials.json` + +## Overview + +Make agent credentials the primary UI path for git PAT and SSH setup. Keep user credentials available but visually demote them to advanced personal overrides. + +Priority: P1. + +Status: pending. + +## Key Insights + +- Current UI exposes git typed fields inside `User Credentials`, which requires operators to know a stable `user_id`. +- Issue #117 asks for obvious `GH_PAT` or SSH fields. The primary action should therefore be per-agent credential setup from the git template row. +- Agent access becomes the practical permission boundary. UI must say this plainly without leaking secrets. + +## Requirements + +- Add Agent Credentials action/button in the CLI credentials table. +- For git adapter rows, default the credential form to PAT with `host_scope = github.com` placeholder. +- Support SSH key as second option. +- Show effective credential source where useful: user override, context, agent, binary. +- Move User Credentials into advanced/personal override wording. +- Keep mobile-safe dialog behavior. +- Add en/vi/zh i18n keys. + +## Architecture + +Preferred UI structure: + +- Main table actions: + - Grants + - Agent Credentials + - Advanced: User Credentials + - Edit + - Delete +- New dialog: + - agent picker + - credential type selector + - host scope input + - PAT token field or SSH private key textarea + - masked secret state on edit + - help text explaining agent access implies credential use +- Use existing `CliCredentialGitFields` by extracting labels/state shape into reusable props if needed. + +## Related Code Files + +- Add `ui/web/src/pages/cli-credentials/cli-agent-credentials-dialog.tsx`. +- Reuse or refactor `cli-credential-git-fields.tsx`. +- Extend `ui/web/src/pages/cli-credentials/hooks/use-cli-credentials.ts`. +- Update `cli-credentials-table.tsx` and panel state. +- Update all locale files under `ui/web/src/i18n/locales/{en,vi,zh}/cli-credentials.json`. +- Add/update tests in `ui/web/src/pages/cli-credentials/__tests__/`. + +## Implementation Steps + +1. Add API hook methods: + - `listAgentCredentials(binaryId)` + - `getAgentCredential(binaryId, agentId)` + - `setAgentCredential(binaryId, agentId, payload)` + - `deleteAgentCredential(binaryId, agentId)` +2. Add `CliAgentCredentialsDialog`. +3. Reuse typed git fields and env vars section. Avoid copy-pasting validation logic unless component boundaries require it. +4. Update table/panel to open Agent Credentials as the main credential action. +5. Rename current User Credentials copy to "Advanced user overrides" or equivalent in all locale files. +6. Add a warning/info line: users with access to this agent can cause it to use this credential. +7. Add UI tests: + - git row shows Agent Credentials action + - PAT form posts to `/agent-credentials/{agentId}` + - edit state shows masked secret and does not submit empty replacement + - User Credentials remains reachable as advanced override +8. Run `pnpm` tests/build for `ui/web`. + +## Todo List + +- [ ] Agent credential dialog implemented. +- [ ] Hooks added for all new endpoints. +- [ ] Table/panel actions updated. +- [ ] i18n updated in en/vi/zh. +- [ ] UI tests pass. + +## Success Criteria + +- [ ] Operator can configure `GH_PAT` for `github.com` without typing a channel user ID. +- [ ] Operator can configure SSH private key for a host-scoped git remote. +- [ ] User Credentials path remains available but no longer looks like the default git setup. +- [ ] UI makes the agent-access security boundary visible. + +## Risk Assessment + +- Risk: table action area becomes crowded. Mitigation: use icon buttons/tooltips or a compact menu if needed. +- Risk: duplicated form state between user and agent dialogs. Mitigation: extract only the shared typed git fields, not the whole dialog. +- Risk: mobile overflow in credential dialog. Mitigation: keep `max-h` scroll region and mobile-safe input sizes. + +## Security Considerations + +- Never render raw stored token/key after save. +- Clear plaintext form state on close/unmount. +- Do not store plaintext in Zustand or route state. + +## Next Steps + +- Phase 5 validates runtime behavior against the new source model. diff --git a/plans/260531-1545-agent-scoped-git-credentials/phase-05-runtime-git-adapter-validation.md b/plans/260531-1545-agent-scoped-git-credentials/phase-05-runtime-git-adapter-validation.md new file mode 100644 index 00000000..28b209bb --- /dev/null +++ b/plans/260531-1545-agent-scoped-git-credentials/phase-05-runtime-git-adapter-validation.md @@ -0,0 +1,116 @@ +--- +phase: 5 +title: "Runtime git adapter validation" +status: pending +effort: "" +--- + +# Phase 5: Runtime git adapter validation + +## Context Links + +- Adapter prepare call: `internal/tools/credentialed_exec.go:466` +- Synthetic user credential helper: `internal/tools/credentialed_exec.go:511` +- Git adapter: `internal/tools/credential_adapter_git.go` +- Git adapter tests: `internal/tools/credential_adapter_git_test.go` +- SSH adapter tests: `internal/tools/credential_adapter_git_ssh_test.go` +- Current docs mention User Credentials: `docs/git-credential-adapter.md:29` + +## Overview + +Wire the effective credential source into runtime execution and validate the git adapter still injects PAT/SSH credentials only for remote git operations. + +Priority: P1. + +Status: pending. + +## Key Insights + +- `credentialed_exec.go` currently synthesizes a `SecureCLIUserCredential` from `UserEnv`, `UserCredentialType`, and `UserHostScope`. +- After Phase 2, the adapter should receive a source-neutral credential object, or the helper should be renamed so non-user sources are not misrepresented. +- Git operations must be tested with same-agent, different-channel contexts. + +## Requirements + +- Keep `git status`, `git log`, and other local-only commands uncredentialed. +- Continue denying sandbox mode for non-passthrough adapters unless sandbox support is explicitly added later. +- Add audit source metadata so operators can tell whether `user`, `context`, or `agent` credential was used. +- Validate PAT header behavior against a GitHub-like HTTP endpoint or fixture. +- Validate SSH key injection still scrubs temp paths and key bytes. + +## Architecture + +Runtime should deal with a neutral credential payload: + +```go +type SecureCLIEffectiveCredential struct { + BinaryID uuid.UUID + SubjectID string + Source string // user, context, agent + EncryptedEnv []byte + CredentialType *string + HostScope *string +} +``` + +If implementation keeps `SecureCLIUserCredential` as the adapter input for minimal change, add comments/tests that prove `UserID` is metadata-only and do not expose it as the credential source. + +## Related Code Files + +- Update `internal/tools/credential_adapter.go` if a neutral type is introduced. +- Update `internal/tools/credentialed_exec.go`. +- Update `internal/tools/credential_audit_log_test.go`. +- Update `internal/tools/shell_credentialed_gate_test.go` fake store. +- Update git adapter tests. + +## Implementation Steps + +1. Decide minimal runtime shape: + - preferred: introduce neutral `SecureCLIEffectiveCredential` + - fallback: keep `SecureCLIUserCredential` but add source metadata elsewhere +2. Update `userCredFromBinary` or replace it with `effectiveCredentialFromBinary`. +3. Ensure adapters receive credential data from user/context/agent source. +4. Add audit source to `emitSystemEnvInjectionAudit`. +5. Add tests: + - no `userID`, agent credential present, git clone injects PAT + - two different `CredentialUserID` values use same agent credential + - user credential overrides agent credential + - context credential overrides agent credential + - agent credential does not bypass grant for non-global binary + - PAT and SSH paths scrub secrets and temp paths +6. Validate PAT transport: + - create a local HTTP test server that captures git extra header behavior, or unit-test the generated Git config/env args + - reconcile docs and code on Basic vs Bearer if mismatch is found +7. Run targeted Go tests for tools/store/http. + +## Todo List + +- [ ] Runtime uses effective credential from agent source. +- [ ] Audit includes credential source without raw host or secret value. +- [ ] Cross-channel runtime tests pass. +- [ ] Git PAT and SSH adapter tests pass. +- [ ] Grant boundary tests pass. + +## Success Criteria + +- [ ] Git clone/fetch/pull/push can use an agent credential without a matching user credential. +- [ ] Same agent uses the same credential from Discord, Telegram, HTTP, and cron contexts. +- [ ] Per-user overrides remain backward compatible. +- [ ] Local git operations remain uncredentialed. +- [ ] Secrets remain scrubbed from output, logs, and errors. + +## Risk Assessment + +- Risk: adapter API churn touches many tests. Mitigation: start with a small neutral adapter type and update fake stores in one pass. +- Risk: PAT auth behavior is wrong for GitHub. Mitigation: add characterization test and update docs/code together. +- Risk: audit source reveals too much host info. Mitigation: keep host hashed or omit host value, matching current audit style. + +## Security Considerations + +- Host scope validation remains exact host or host:port, no wildcards. +- Deny patterns from binary/grant/context still apply after credential resolution. +- Temporary SSH files must be removed and scrubbed from errors. + +## Next Steps + +- Phase 6 updates docs and performs final plan/implementation validation. diff --git a/plans/260531-1545-agent-scoped-git-credentials/phase-06-docs-validation-and-handoff.md b/plans/260531-1545-agent-scoped-git-credentials/phase-06-docs-validation-and-handoff.md new file mode 100644 index 00000000..3cb5e7e2 --- /dev/null +++ b/plans/260531-1545-agent-scoped-git-credentials/phase-06-docs-validation-and-handoff.md @@ -0,0 +1,106 @@ +--- +phase: 6 +title: "Docs validation and handoff" +status: pending +effort: "" +--- + +# Phase 6: Docs validation and handoff + +## Context Links + +- Git guide: `docs/git-credential-adapter.md` +- HTTP API docs: `docs/18-http-api.md` +- API auth docs: `docs/20-api-keys-auth.md` +- Security docs: `docs/09-security.md` +- Store model docs: `docs/06-store-data-model.md` +- Project changelog: `docs/project-changelog.md` + +## Overview + +Update user-facing and developer docs, run validation, and leave a clean handoff for implementation review. + +Priority: P1. + +Status: pending. + +## Key Insights + +- The current git guide says to open User Credentials. That will become the advanced path. +- API docs must include the new endpoints because user specifically asked for endpoint support. +- Docs must state the trust model: agent access implies ability to cause that agent to use its configured git credential. + +## Requirements + +- Update docs for agent credential default. +- Keep user credential override documented. +- Add HTTP API endpoint table and examples. +- Update security/data-model docs. +- Update changelog. +- Run code, test, and build validation appropriate to touched files. + +## Architecture + +Documentation model: + +- Quick start: create git CLI credential, grant/use agent, add agent credential. +- PAT path: fine-grained PAT preferred where possible, `host_scope = github.com`. +- SSH path: unencrypted private key only, public key added to git host by operator. +- Advanced path: per-user credential overrides for personal credentials. +- Security model: any principal with access to run the agent can trigger the credential. + +## Related Code Files + +- `docs/git-credential-adapter.md` +- `docs/18-http-api.md` +- `docs/20-api-keys-auth.md` +- `docs/09-security.md` +- `docs/06-store-data-model.md` +- `docs/project-changelog.md` + +## Implementation Steps + +1. Rewrite git guide "Adding a credential" around Agent Credentials first. +2. Add "Advanced user overrides" section. +3. Add endpoint documentation for all agent credential routes. +4. Add request/response examples for PAT and SSH. +5. Update security docs with trust boundary and secret masking. +6. Update data model docs with `secure_cli_agent_credentials`. +7. Update changelog with issue #117 entry. +8. Run validation: + - `go test ./internal/store/... ./internal/http/... ./internal/tools/...` + - `go build ./...` + - `go build -tags sqliteonly ./...` + - `cd ui/web && pnpm test -- --run` + - `cd ui/web && pnpm build` +9. If full integration tests are skipped due local database requirements, state that explicitly in final handoff. + +## Todo List + +- [ ] Git guide updated. +- [ ] HTTP/API auth docs updated. +- [ ] Security and data-model docs updated. +- [ ] Changelog updated. +- [ ] Validation commands recorded with pass/fail status. + +## Success Criteria + +- [ ] A new operator can find where to enter `GH_PAT` or SSH key without knowing channel user IDs. +- [ ] API consumers can manage agent credentials without reading code. +- [ ] Security docs describe agent access as the permission boundary. +- [ ] Build/test output supports merge readiness. + +## Risk Assessment + +- Risk: docs overpromise support for GitHub App or passphrase SSH. Mitigation: keep out-of-scope section explicit. +- Risk: endpoint examples drift from implementation. Mitigation: generate examples from handler tests where practical. + +## Security Considerations + +- Do not include real tokens, keys, or screenshots with secrets. +- Use placeholder values only. +- State least-privilege recommendation: fine-grained PAT or deploy key per host/repo where possible. + +## Next Steps + +- After implementation and validation, open PR referencing issue #117 and this plan. diff --git a/plans/260531-1545-agent-scoped-git-credentials/plan.md b/plans/260531-1545-agent-scoped-git-credentials/plan.md new file mode 100644 index 00000000..aacfd4b6 --- /dev/null +++ b/plans/260531-1545-agent-scoped-git-credentials/plan.md @@ -0,0 +1,64 @@ +--- +title: "Agent-scoped Git credentials" +description: "TDD plan for moving git typed credentials from channel/user-id keyed defaults to agent-scoped credentials, with HTTP API and Web UI management." +status: pending +priority: P1 +issue: 117 +branch: "codex/issue-117-agent-scoped-git-credentials-plan" +tags: [] +blockedBy: [] +blocks: [] +created: "2026-05-31T08:45:33.071Z" +createdBy: "ck:plan" +source: skill +--- + +# Agent-scoped Git credentials + +## Overview + +Issue #117 started as a UI gap: the git template does not make it obvious where to enter `GH_PAT` or SSH key material. The deeper design problem is that current `User Credentials` are keyed by credential user ID, while the same human can appear as different external IDs across Discord, Telegram, HTTP, or group contexts. + +Decision: make agent-scoped git credentials the primary model. Granting access to an agent becomes the security boundary for whether a user can cause that agent to use a git PAT or SSH key. Keep per-user credentials as an advanced override for backward compatibility and truly personal credentials. + +TDD target: + +- Add contract tests first for effective credential precedence and API behavior. +- Add a dedicated agent credential storage surface instead of mixing typed secrets into agent grant policy rows. +- Add HTTP endpoints for create, edit, list, detail, and delete of agent-scoped CLI credentials. +- Update Web UI so git PAT and SSH setup is managed from Agent Credentials by default. +- Validate runtime injection across Discord/Telegram/userless contexts uses the same agent credential. + +## Phases + +| Phase | Name | Status | +|-------|------|--------| +| 1 | [Research and contract tests](./phase-01-research-and-contract-tests.md) | Pending | +| 2 | [Schema and store resolver](./phase-02-schema-and-store-resolver.md) | Pending | +| 3 | [HTTP API credential management](./phase-03-http-api-credential-management.md) | Pending | +| 4 | [Web UI credential management](./phase-04-web-ui-credential-management.md) | Pending | +| 5 | [Runtime git adapter validation](./phase-05-runtime-git-adapter-validation.md) | Pending | +| 6 | [Docs validation and handoff](./phase-06-docs-validation-and-handoff.md) | Pending | + +## Dependencies + +- Current typed git adapter and validation: `internal/tools/credential_adapter_git.go`, `internal/http/secure_cli_typed_credentials.go`. +- Current lookup joins per-user credentials in `internal/store/pg/secure_cli.go` and SQLite equivalent. +- Current UI git form lives under `ui/web/src/pages/cli-credentials/cli-user-credentials-dialog.tsx`. +- Migration number must be re-verified at implementation time. As of plan creation, latest PostgreSQL migration is `000076_channel_memory_extraction`. + +## Verified Facts + +- `LookupByBinary` takes `(binaryName, agentID, userID)` and only joins `secure_cli_user_credentials` when `userID` is non-empty. +- Context credentials can currently override/fill credential fields via `applyContextSecureCLI`, but there is no agent credential typed secret row. +- `SecureCLIAgentGrant` already has `encrypted_env`, but lacks `credential_type` and `host_scope`; using it for typed git secrets would mix policy and secret identity. +- HTTP routes currently expose `/v1/cli-credentials/{id}/user-credentials...` but not agent credential endpoints. +- The Web UI currently opens User Credentials from the CLI credentials table action. + +## Out of Scope + +- GitHub App installation tokens. +- OAuth/device-code token minting. +- Wildcard host scopes. +- Passphrase-protected SSH keys. +- Sandbox-mode git credential injection. diff --git a/plans/260531-1545-agent-scoped-git-credentials/reports/redteam-report.md b/plans/260531-1545-agent-scoped-git-credentials/reports/redteam-report.md new file mode 100644 index 00000000..6bbf093c --- /dev/null +++ b/plans/260531-1545-agent-scoped-git-credentials/reports/redteam-report.md @@ -0,0 +1,75 @@ +# Redteam Report: Agent-scoped Git Credentials + +Date: 2026-05-31 + +Scope: plan review for issue #117 before implementation. + +## Findings + +### R1 - Credential rows could accidentally bypass grants + +Risk: If runtime joins `secure_cli_agent_credentials` without preserving the existing non-global grant gate, creating a credential row becomes an implicit grant. + +Fix in plan: Phase 2 and Phase 5 require tests that non-global binaries still need `secure_cli_agent_grants`. Credential rows store secrets only. + +Status: fixed in plan. + +### R2 - Agent grants are the wrong place for typed secrets + +Risk: `secure_cli_agent_grants.encrypted_env` already exists and is tempting to reuse, but it is policy override state and lacks `credential_type` and `host_scope`. + +Fix in plan: Phase 2 uses dedicated `secure_cli_agent_credentials`. + +Status: fixed in plan. + +### R3 - User credential precedence could preserve the confusing default + +Risk: Keeping user credentials as the visible default would not solve cross-channel identity confusion. + +Fix in plan: Phase 4 makes Agent Credentials the primary git path and moves User Credentials to advanced overrides. + +Status: fixed in plan. + +### R4 - API support could lag behind UI + +Risk: A UI-only feature would block automation and contradict the user requirement. + +Fix in plan: Phase 3 defines full CRUD endpoints, request bodies, response masking, audit events, docs, and tests. + +Status: fixed in plan. + +### R5 - Typed validation might fork and drift + +Risk: Copying PAT/SSH validation into a new handler can create inconsistent behavior between user and agent credentials. + +Fix in plan: Phase 3 requires reusing `prepareTypedCredentialEnv` or a shared equivalent. + +Status: fixed in plan. + +### R6 - SQLite migration can be missed + +Risk: Desktop edition uses SQLite and can break if only PostgreSQL migrations are added. + +Fix in plan: Phase 2 requires PG migration, SQLite fresh schema, SQLite incremental migration, and version bump. + +Status: fixed in plan. + +### R7 - Runtime audit source can become misleading + +Risk: Existing audit uses credential user ID. Agent credentials would make that label inaccurate. + +Fix in plan: Phase 5 requires source-neutral credential metadata and audit source coverage. + +Status: fixed in plan. + +### R8 - PAT transport behavior needs proof + +Risk: Docs and adapter assumptions around GitHub HTTPS auth can silently diverge. + +Fix in plan: Phase 5 requires a GitHub-like PAT transport characterization test and code/docs reconciliation. + +Status: fixed in plan. + +## Unresolved Questions + +None. diff --git a/plans/260531-1545-agent-scoped-git-credentials/reports/validation-report.md b/plans/260531-1545-agent-scoped-git-credentials/reports/validation-report.md new file mode 100644 index 00000000..039c30c5 --- /dev/null +++ b/plans/260531-1545-agent-scoped-git-credentials/reports/validation-report.md @@ -0,0 +1,75 @@ +# Validation Report: Agent-scoped Git Credentials Plan + +Date: 2026-05-31 + +Scope: validate plan completeness and consistency against current code. + +## Checks + +### V1 - Current failure mode is represented + +Evidence: current resolver can return channel-specific user IDs, and `LookupByBinary` only joins user credentials when `userID` is non-empty. + +Plan coverage: Phase 1 and Phase 5 include cross-channel/no-user tests. + +Status: pass. + +### V2 - API endpoints are included + +Evidence: current routes include user credential endpoints only. + +Plan coverage: Phase 3 defines list/get/put/delete agent credential endpoints and docs updates. + +Status: pass. + +### V3 - Store model separates policy from secret identity + +Evidence: `SecureCLIAgentGrant` has policy fields plus encrypted env override but no typed credential metadata. + +Plan coverage: Phase 2 adds dedicated `secure_cli_agent_credentials`. + +Status: pass. + +### V4 - Dual database migration is covered + +Evidence: repo has separate PostgreSQL migrations and SQLite schema/version migrations. + +Plan coverage: Phase 2 explicitly updates both systems. + +Status: pass. + +### V5 - UI default path matches product decision + +Evidence: current table opens User Credentials, and current git guide documents User Credentials. + +Plan coverage: Phase 4 and Phase 6 make Agent Credentials the default and keep User Credentials as advanced override. + +Status: pass. + +### V6 - TDD gates are explicit + +Evidence: implementation phases depend on failing tests from Phase 1. + +Plan coverage: every phase lists tests or validation commands. + +Status: pass. + +### V7 - Security boundary is explicit + +Evidence: agent access is the proposed operational permission boundary. + +Plan coverage: Phase 3, Phase 4, and Phase 6 all require warnings/docs/tests that credential does not grant binary access but agent users can trigger credential use. + +Status: pass. + +## Fixes Applied During Validation + +- Added explicit non-global grant boundary tests. +- Added exact HTTP endpoint list and response shapes. +- Added SQLite migration requirement. +- Added runtime audit source requirement. +- Added PAT transport characterization requirement. + +## Unresolved Questions + +None. From 994acfe3a4111faaf37618369740bfe6bb515f95 Mon Sep 17 00:00:00 2001 From: Goon Date: Sun, 31 May 2026 16:49:23 +0700 Subject: [PATCH 2/3] feat(cli-credentials): add agent-scoped git credentials --- cmd/gateway_http_handlers.go | 2 +- docs/06-store-data-model.md | 12 +- docs/09-security.md | 8 +- docs/18-http-api.md | 37 +++ docs/20-api-keys-auth.md | 4 + docs/git-credential-adapter.md | 48 +++- docs/project-changelog.md | 23 ++ internal/http/secure_cli.go | 24 +- internal/http/secure_cli_agent_credentials.go | 217 ++++++++++++++++++ .../http/secure_cli_typed_credentials_test.go | 181 ++++++++++++++- internal/store/pg/secure_cli.go | 59 ++++- .../store/pg/secure_cli_agent_credentials.go | 179 +++++++++++++++ internal/store/secure_cli_store.go | 57 +++++ internal/store/sqlitestore/schema.go | 26 ++- internal/store/sqlitestore/schema.sql | 30 +++ .../secure-cli-agent-credentials.go | 193 ++++++++++++++++ internal/store/sqlitestore/secure-cli.go | 64 ++++-- .../sqlitestore/secure_cli_schema_test.go | 187 ++++++++++++++- internal/tools/credential_adapter.go | 8 +- internal/tools/credential_audit_log_test.go | 11 +- internal/tools/credentialed_exec.go | 54 ++++- internal/tools/credentialed_exec_env_test.go | 38 +++ internal/upgrade/version.go | 2 +- ...0077_secure_cli_agent_credentials.down.sql | 3 + ...000077_secure_cli_agent_credentials.up.sql | 29 +++ .../phase-01-research-and-contract-tests.md | 6 +- .../phase-02-schema-and-store-resolver.md | 6 +- ...phase-03-http-api-credential-management.md | 6 +- .../phase-04-web-ui-credential-management.md | 6 +- ...phase-05-runtime-git-adapter-validation.md | 6 +- .../phase-06-docs-validation-and-handoff.md | 6 +- .../plan.md | 26 ++- .../src/i18n/locales/en/cli-credentials.json | 24 +- .../src/i18n/locales/vi/cli-credentials.json | 24 +- .../src/i18n/locales/zh/cli-credentials.json | 24 +- ...i-agent-credentials-dialog-helpers.test.ts | 56 +++++ .../cli-agent-credentials-wiring.test.ts | 25 ++ .../__tests__/use-cli-credentials.test.ts | 13 ++ .../cli-agent-credential-form.tsx | 84 +++++++ .../cli-agent-credential-list.tsx | 62 +++++ .../cli-agent-credentials-dialog-helpers.ts | 53 +++++ .../cli-agent-credentials-dialog.tsx | 199 ++++++++++++++++ .../cli-credentials/cli-credentials-panel.tsx | 15 ++ .../cli-credentials/cli-credentials-table.tsx | 8 +- .../hooks/use-cli-agent-credentials.ts | 80 +++++++ .../hooks/use-cli-credentials.ts | 16 +- ui/web/src/types/cli-credential.ts | 24 ++ 47 files changed, 2152 insertions(+), 113 deletions(-) create mode 100644 internal/http/secure_cli_agent_credentials.go create mode 100644 internal/store/pg/secure_cli_agent_credentials.go create mode 100644 internal/store/sqlitestore/secure-cli-agent-credentials.go create mode 100644 migrations/000077_secure_cli_agent_credentials.down.sql create mode 100644 migrations/000077_secure_cli_agent_credentials.up.sql create mode 100644 ui/web/src/pages/cli-credentials/__tests__/cli-agent-credentials-dialog-helpers.test.ts create mode 100644 ui/web/src/pages/cli-credentials/__tests__/cli-agent-credentials-wiring.test.ts create mode 100644 ui/web/src/pages/cli-credentials/cli-agent-credential-form.tsx create mode 100644 ui/web/src/pages/cli-credentials/cli-agent-credential-list.tsx create mode 100644 ui/web/src/pages/cli-credentials/cli-agent-credentials-dialog-helpers.ts create mode 100644 ui/web/src/pages/cli-credentials/cli-agent-credentials-dialog.tsx create mode 100644 ui/web/src/pages/cli-credentials/hooks/use-cli-agent-credentials.ts diff --git a/cmd/gateway_http_handlers.go b/cmd/gateway_http_handlers.go index e47def21..c270af1d 100644 --- a/cmd/gateway_http_handlers.go +++ b/cmd/gateway_http_handlers.go @@ -112,7 +112,7 @@ func wireHTTP(stores *store.Stores, defaultWorkspace, dataDir, bundledSkillsDir } if stores != nil && stores.SecureCLI != nil { - secureCLIH = httpapi.NewSecureCLIHandler(stores.SecureCLI, msgBus) + secureCLIH = httpapi.NewSecureCLIHandler(stores.SecureCLI, msgBus, stores.Tenants) } if stores != nil && stores.SecureCLIGrants != nil { secureCLIGrantH = httpapi.NewSecureCLIGrantHandler(stores.SecureCLIGrants, stores.Tenants, msgBus) diff --git a/docs/06-store-data-model.md b/docs/06-store-data-model.md index e71bc3a1..e2fcbae7 100644 --- a/docs/06-store-data-model.md +++ b/docs/06-store-data-model.md @@ -480,7 +480,14 @@ Pre-computed usage snapshots (hourly aggregations) for analytics dashboards. Tra ### SecureCLIStore -CLI binary credential configuration with encrypted environment variable injection. Credentials are auto-injected into child processes without exposing them to command output. +CLI binary credential configuration with encrypted environment variable +injection. Credentials are auto-injected into child processes without exposing +them to command output. + +Credential rows can live at binary, agent, channel/context, or user scope. +Runtime resolution prefers user overrides, then context credentials, then agent +credentials, then binary defaults. The `secure_cli_agent_credentials` table +stores one encrypted PAT/SSH/env payload per `(binary_id, agent_id, tenant_id)`. | Method | Purpose | |--------|---------| @@ -492,6 +499,9 @@ CLI binary credential configuration with encrypted environment variable injectio | `ListByAgent(agentID)` | Return configs for a specific agent | | `LookupByBinary(binaryName, agentID)` | Find best-matching config (agent-specific > global) | | `ListEnabled()` | Return enabled configs for TOOLS.md generation | +| `ListAgentCredentials(binaryID)` | Return masked agent credential metadata | +| `SetAgentCredentialsTyped(binaryID, agentID, env, type, hostScope)` | Store agent-scoped PAT/SSH/env payload | +| `DeleteAgentCredentials(binaryID, agentID)` | Remove an agent-scoped credential | ### APIKeyStore diff --git a/docs/09-security.md b/docs/09-security.md index eb3c22be..6931c196 100644 --- a/docs/09-security.md +++ b/docs/09-security.md @@ -231,7 +231,7 @@ AES-256-GCM encryption for secrets stored in PostgreSQL. Key provided via `GOCLA | LLM provider API keys | `llm_providers` | `api_key` | | MCP server API keys | `mcp_servers` | `api_key` | | Custom tool env vars | `custom_tools` | `env` | -| Credentialed CLI env vars | `secure_cli_binaries`, `secure_cli_agent_grants`, `secure_cli_user_credentials` | `encrypted_env` | +| Credentialed CLI env vars | `secure_cli_binaries`, `secure_cli_agent_grants`, `secure_cli_user_credentials`, `secure_cli_agent_credentials` | `encrypted_env` | **Format**: `"aes-gcm:" + base64(12-byte nonce + ciphertext + GCM tag)` @@ -534,6 +534,11 @@ Implementer guide: [credential-adapter-playbook.md](./credential-adapter-playboo Both paths coexist. A typo in `adapter_name` falls back to passthrough, which restores the legacy denylist-only behavior — no silent bypass. +Runtime credential precedence is explicit: user override, then channel/context +credential, then agent credential, then binary-level env defaults. Agent +credentials are the default git trust boundary; granting access to an agent +also grants the ability to make that agent use its stored git credential. + ### Audit log: `security.system_env_injection` Every adapter injection emits **exactly one** structured slog line. Field @@ -547,6 +552,7 @@ the test and operator-facing log-search recipes. | `adapter` | string | e.g. `git`, `psql`, `passthrough` | | `binary` | string | binary name (`git`, `kubectl`, …) | | `user_id` | string | tenant user UUID (empty for global-only contexts) | +| `credential_source` | string | `user`, `context`, `agent`, or empty when no scoped credential row was selected | | `env_keys` | []string | sorted env-var NAMES (never values) | | `argv_prefix_len` | int | number of argv elements prepended (NOT their content) | | `host_scope_hash` | string | SHA-256 first 8 hex chars of normalized host_scope, or `"none"` | diff --git a/docs/18-http-api.md b/docs/18-http-api.md index ae7fc6cd..93dbf079 100644 --- a/docs/18-http-api.md +++ b/docs/18-http-api.md @@ -1173,8 +1173,45 @@ CLI authentication credentials for secure command execution. Requires **admin ro | `DELETE` | `/v1/cli-credentials/{id}` | Delete credential | | `POST` | `/v1/cli-credentials/{id}/test` | Test credential connection (dry-run) | +### Agent Credentials + +Agent credentials store PAT/SSH/env material for one CLI credential and one +agent. They are the default git setup path; agent access controls who can cause +the credential to be used. Responses return metadata only and never include raw +typed token/key blobs. + +| Method | Path | Description | +|--------|------|-------------| +| `GET` | `/v1/cli-credentials/{id}/agent-credentials` | List agent credentials for CLI cred | +| `GET` | `/v1/cli-credentials/{id}/agent-credentials/{agentId}` | Get agent credential metadata | +| `PUT` | `/v1/cli-credentials/{id}/agent-credentials/{agentId}` | Set agent credential | +| `DELETE` | `/v1/cli-credentials/{id}/agent-credentials/{agentId}` | Delete agent credential | + +Typed git request body: + +```json +{ + "credential_type": "pat", + "host_scope": "github.com", + "blob": { "token": "ghp_..." } +} +``` + +Env request body: + +```json +{ + "env": { + "GH_TOKEN": { "kind": "sensitive", "value": "..." } + } +} +``` + ### Per-User Credentials +Advanced personal overrides. These remain for backward compatibility and have +higher runtime precedence than channel/context and agent credentials. + | Method | Path | Description | |--------|------|-------------| | `GET` | `/v1/cli-credentials/{id}/user-credentials` | List user credentials for CLI cred | diff --git a/docs/20-api-keys-auth.md b/docs/20-api-keys-auth.md index 483c829f..ae5e6187 100644 --- a/docs/20-api-keys-auth.md +++ b/docs/20-api-keys-auth.md @@ -335,6 +335,10 @@ CREATE TABLE secure_cli_binaries ( | `DELETE` | `/v1/cli-credentials/{id}` | Delete a SecureCLI config | | `POST` | `/v1/cli-credentials/{id}/test` | Dry-run test (requires admin) | | `GET` | `/v1/cli-credentials/presets` | List preset templates for common CLIs | +| `GET` | `/v1/cli-credentials/{id}/agent-credentials` | List agent-scoped credentials | +| `GET` | `/v1/cli-credentials/{id}/agent-credentials/{agentId}` | Get agent-scoped credential metadata | +| `PUT` | `/v1/cli-credentials/{id}/agent-credentials/{agentId}` | Create or replace agent-scoped credential | +| `DELETE` | `/v1/cli-credentials/{id}/agent-credentials/{agentId}` | Delete agent-scoped credential | ### Google Workspace CLI preset diff --git a/docs/git-credential-adapter.md b/docs/git-credential-adapter.md index 552008fc..25715c1f 100644 --- a/docs/git-credential-adapter.md +++ b/docs/git-credential-adapter.md @@ -24,9 +24,31 @@ The typed `git` adapter accepts either a **Personal Access Token (PAT)** or an | **SSH** | Self-hosted git over SSH. You manage `~/.ssh/known_hosts` or accept TOFU risk. | Passphrase-protected keys are NOT supported (see below). | | **Env** | Legacy path — you have a custom env-var-driven workflow. | Loses host-scoped routing; same trust profile as other CLIs. | -## Adding a credential (UI) +## Adding an agent credential (UI) -1. Open **Settings → CLI Credentials → User Credentials → Add**. +Agent credentials are the default path for git auth. They avoid channel-user +ID ambiguity: the selected agent owns the credential, and anyone allowed to use +that agent can cause it to run git with the stored credential. + +1. Open **Packages → CLI Credentials**. +2. Pick the `git` row and open **Agent Credentials**. +3. Select the agent. +4. Choose **Credential Type**: `Personal Access Token` or `SSH Private Key`. +5. Enter **Host Scope** (required for PAT/SSH): the hostname the credential + authenticates to. + - Examples: `github.com`, `gitlab.example.com`, `gitea.internal:8443`. + - Case-insensitive. Punycode normalized via `idna.ToASCII`. + - Port included only when non-default for the scheme. +6. Paste the token (PAT) or the unencrypted PEM body (SSH). +7. Save. + +## Advanced user overrides + +Per-user credentials remain available for personal overrides and backward +compatibility. Use them only when a stable tenant user ID is the intended +credential boundary. + +1. Open **Packages → CLI Credentials → Advanced User Overrides → Add**. 2. Select user. 3. Choose **Credential Type**: `Personal Access Token` or `SSH Private Key`. 4. Enter **Host Scope** (required for PAT/SSH): the hostname the credential @@ -41,6 +63,13 @@ The stored secret is encrypted (AES-256-GCM) and can never be read back through the API or UI. Editing the row shows a `••••••••` placeholder; leaving the secret field blank preserves the stored value, typing a new value replaces it. +Effective credential precedence is: + +1. User override. +2. Channel/context credential. +3. Agent credential. +4. Binary-level env defaults. + ## What gets auto-injected The adapter runs ONLY for these subcommands: @@ -140,7 +169,7 @@ Every successful credential injection emits exactly one structured log line: ``` level=WARN msg=security.system_env_injection - adapter=git binary=git user_id= + adapter=git binary=git user_id= credential_source=agent env_keys=[GIT_CONFIG_COUNT,GIT_CONFIG_KEY_0,GIT_CONFIG_VALUE_0] argv_prefix_len=0 host_scope_hash=3aeb0024 @@ -159,12 +188,12 @@ See `docs/09-security.md` → "CLI credential adapters" for the full schema. ## Migration from legacy env-paste Existing rows in `secure_cli_user_credentials` with `credential_type IS NULL` -or `= 'env'` continue to work via the passthrough adapter — they keep -emitting their env vars exactly as before. There is no forced migration. +or `= 'env'` continue to work via the passthrough adapter. Existing user +overrides remain higher precedence than agent credentials. There is no forced +migration. -To upgrade an existing git credential, open the user-credentials dialog, pick -PAT or SSH, paste the secret, and save. The legacy env-paste row is replaced -atomically. +To move to the agent-scoped model, create a matching Agent Credential for the +agent and remove the user override when the override is no longer needed. ## Operator notes @@ -186,7 +215,8 @@ atomically. ## Known limitations (v1) -- One credential per (user, binary, host_scope) row. +- One credential per (agent, binary) row, plus legacy one credential per + (user, binary) override. - No multi-host wildcard (`*.github.com`). - No passphrase-protected SSH keys. - No persistent `known_hosts` per credential (TOFU only). diff --git a/docs/project-changelog.md b/docs/project-changelog.md index d8a84842..3eef23b6 100644 --- a/docs/project-changelog.md +++ b/docs/project-changelog.md @@ -4,6 +4,29 @@ Significant changes, features, and fixes in reverse chronological order. --- +## 2026-05-31 + +### Agent-scoped git credentials (issue #117) + +**New** + +- Added agent-scoped Secure CLI credentials with PostgreSQL migration `000077` + and SQLite schema version `46`. +- Added HTTP APIs under + `/v1/cli-credentials/{id}/agent-credentials/{agentId}` for listing, + reading metadata, saving, and deleting agent credentials. +- Web CLI Credentials now exposes Agent Credentials as the primary git PAT/SSH + setup path, with User Credentials renamed to advanced personal overrides. + +**Security** + +- Runtime credential precedence is now user override, context credential, agent + credential, then binary env defaults. +- Git adapter audit logs include `credential_source` without logging raw + secrets or plaintext host scopes. + +--- + ## 2026-05-29 ### Passive channel memory extraction (issue #64) diff --git a/internal/http/secure_cli.go b/internal/http/secure_cli.go index 1e3d51ba..98de702c 100644 --- a/internal/http/secure_cli.go +++ b/internal/http/secure_cli.go @@ -26,13 +26,22 @@ var safeBinaryNameRe = regexp.MustCompile(`^[a-zA-Z0-9][a-zA-Z0-9._-]{0,63}$`) // SecureCLIHandler handles secure CLI binary credential CRUD endpoints. type SecureCLIHandler struct { - store store.SecureCLIStore - msgBus *bus.MessageBus + store store.SecureCLIStore + agentCreds store.SecureCLIAgentCredentialStore + tenants store.TenantStore + msgBus *bus.MessageBus } // NewSecureCLIHandler creates a handler for secure CLI credential management. -func NewSecureCLIHandler(s store.SecureCLIStore, msgBus *bus.MessageBus) *SecureCLIHandler { - return &SecureCLIHandler{store: s, msgBus: msgBus} +func NewSecureCLIHandler(s store.SecureCLIStore, msgBus *bus.MessageBus, tenants ...store.TenantStore) *SecureCLIHandler { + h := &SecureCLIHandler{store: s, msgBus: msgBus} + if len(tenants) > 0 { + h.tenants = tenants[0] + } + if agentCreds, ok := s.(store.SecureCLIAgentCredentialStore); ok { + h.agentCreds = agentCreds + } + return h } // RegisterRoutes registers all secure CLI routes on the given mux. @@ -51,6 +60,13 @@ func (h *SecureCLIHandler) RegisterRoutes(mux *http.ServeMux) { mux.HandleFunc("GET /v1/cli-credentials/{id}/user-credentials/{userId}", h.auth(h.handleGetUserCredentials)) mux.HandleFunc("PUT /v1/cli-credentials/{id}/user-credentials/{userId}", h.auth(h.handleSetUserCredentials)) mux.HandleFunc("DELETE /v1/cli-credentials/{id}/user-credentials/{userId}", h.auth(h.handleDeleteUserCredentials)) + + // Per-agent credential management. Credentials do not grant binary access; + // non-global binaries still require agent-grants. + mux.HandleFunc("GET /v1/cli-credentials/{id}/agent-credentials", h.auth(h.handleListAgentCredentials)) + mux.HandleFunc("GET /v1/cli-credentials/{id}/agent-credentials/{agentId}", h.auth(h.handleGetAgentCredentials)) + mux.HandleFunc("PUT /v1/cli-credentials/{id}/agent-credentials/{agentId}", h.auth(h.handleSetAgentCredentials)) + mux.HandleFunc("DELETE /v1/cli-credentials/{id}/agent-credentials/{agentId}", h.auth(h.handleDeleteAgentCredentials)) } func (h *SecureCLIHandler) auth(next http.HandlerFunc) http.HandlerFunc { diff --git a/internal/http/secure_cli_agent_credentials.go b/internal/http/secure_cli_agent_credentials.go new file mode 100644 index 00000000..ebe4115a --- /dev/null +++ b/internal/http/secure_cli_agent_credentials.go @@ -0,0 +1,217 @@ +package http + +import ( + "encoding/json" + "net/http" + + "github.com/google/uuid" + + "github.com/nextlevelbuilder/goclaw/internal/i18n" + "github.com/nextlevelbuilder/goclaw/internal/store" +) + +type agentCredentialEntry struct { + ID uuid.UUID `json:"id"` + BinaryID uuid.UUID `json:"binary_id"` + AgentID uuid.UUID `json:"agent_id"` + AgentKey string `json:"agent_key,omitempty"` + Name string `json:"name,omitempty"` + HasSecret bool `json:"has_secret"` + EnvKeys []string `json:"env_keys,omitempty"` + Env map[string]store.SecureCLIEnvResponseEntry `json:"env,omitempty"` + CredentialType *string `json:"credential_type,omitempty"` + HostScope *string `json:"host_scope,omitempty"` + CreatedBy string `json:"created_by,omitempty"` + CreatedAt string `json:"created_at"` + UpdatedAt string `json:"updated_at"` +} + +func (h *SecureCLIHandler) requireAgentCredentialStore(w http.ResponseWriter, r *http.Request) (store.SecureCLIAgentCredentialStore, bool) { + if h.agentCreds == nil { + locale := store.LocaleFromContext(r.Context()) + writeJSON(w, http.StatusNotImplemented, map[string]string{"error": i18n.T(locale, i18n.MsgInternalError, "agent credentials store unavailable")}) + return nil, false + } + return h.agentCreds, true +} + +func (h *SecureCLIHandler) requireAgentCredentialAdmin(w http.ResponseWriter, r *http.Request) bool { + return requireTenantAdmin(w, r, h.tenants) +} + +func agentCredentialResponse(c store.SecureCLIAgentCredential) agentCredentialEntry { + isTyped := c.CredentialType != nil && *c.CredentialType != "" && *c.CredentialType != "env" + e := agentCredentialEntry{ + ID: c.ID, + BinaryID: c.BinaryID, + AgentID: c.AgentID, + AgentKey: c.AgentKey, + Name: c.Name, + HasSecret: len(c.EncryptedEnv) > 0, + CredentialType: c.CredentialType, + HostScope: c.HostScope, + CreatedBy: c.CreatedBy, + CreatedAt: c.CreatedAt, + UpdatedAt: c.UpdatedAt, + } + if !isTyped { + e.EnvKeys = envKeysFromDecryptedJSON(c.EncryptedEnv) + e.Env = store.SanitizeSecureCLIEnvJSON(c.EncryptedEnv) + } + return e +} + +func parseAgentCredentialPath(w http.ResponseWriter, r *http.Request, locale string) (uuid.UUID, uuid.UUID, bool) { + binaryID, err := uuid.Parse(r.PathValue("id")) + if err != nil { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgInvalidID, "credential")}) + return uuid.Nil, uuid.Nil, false + } + agentID, err := uuid.Parse(r.PathValue("agentId")) + if err != nil { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgInvalidID, "agent")}) + return uuid.Nil, uuid.Nil, false + } + return binaryID, agentID, true +} + +func (h *SecureCLIHandler) handleListAgentCredentials(w http.ResponseWriter, r *http.Request) { + if !h.requireAgentCredentialAdmin(w, r) { + return + } + agentCreds, ok := h.requireAgentCredentialStore(w, r) + if !ok { + return + } + locale := store.LocaleFromContext(r.Context()) + binaryID, err := uuid.Parse(r.PathValue("id")) + if err != nil { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgInvalidID, "credential")}) + return + } + creds, err := agentCreds.ListAgentCredentials(r.Context(), binaryID) + if err != nil { + writeJSON(w, http.StatusInternalServerError, map[string]string{"error": i18n.T(locale, i18n.MsgInternalError, err.Error())}) + return + } + entries := make([]agentCredentialEntry, 0, len(creds)) + for _, c := range creds { + entries = append(entries, agentCredentialResponse(c)) + } + writeJSON(w, http.StatusOK, map[string]any{"agent_credentials": entries}) +} + +func (h *SecureCLIHandler) handleGetAgentCredentials(w http.ResponseWriter, r *http.Request) { + if !h.requireAgentCredentialAdmin(w, r) { + return + } + agentCreds, ok := h.requireAgentCredentialStore(w, r) + if !ok { + return + } + locale := store.LocaleFromContext(r.Context()) + binaryID, agentID, ok := parseAgentCredentialPath(w, r, locale) + if !ok { + return + } + cred, err := agentCreds.GetAgentCredentials(r.Context(), binaryID, agentID) + if err != nil { + writeJSON(w, http.StatusInternalServerError, map[string]string{"error": i18n.T(locale, i18n.MsgInternalError, err.Error())}) + return + } + if cred == nil { + writeJSON(w, http.StatusNotFound, map[string]string{"error": i18n.T(locale, i18n.MsgNotFound, "agent credential", agentID.String())}) + return + } + writeJSON(w, http.StatusOK, agentCredentialResponse(*cred)) +} + +func (h *SecureCLIHandler) handleSetAgentCredentials(w http.ResponseWriter, r *http.Request) { + if !h.requireAgentCredentialAdmin(w, r) { + return + } + agentCreds, ok := h.requireAgentCredentialStore(w, r) + if !ok { + return + } + locale := store.LocaleFromContext(r.Context()) + binaryID, agentID, ok := parseAgentCredentialPath(w, r, locale) + if !ok { + return + } + if exists, err := agentCreds.BinaryExists(r.Context(), binaryID); err != nil { + writeJSON(w, http.StatusInternalServerError, map[string]string{"error": i18n.T(locale, i18n.MsgInternalError, "validate credential")}) + return + } else if !exists { + writeJSON(w, http.StatusNotFound, map[string]string{"error": i18n.T(locale, i18n.MsgNotFound, "credential", binaryID.String())}) + return + } + if exists, err := agentCreds.AgentExists(r.Context(), agentID); err != nil { + writeJSON(w, http.StatusInternalServerError, map[string]string{"error": i18n.T(locale, i18n.MsgInternalError, "validate agent")}) + return + } else if !exists { + writeJSON(w, http.StatusNotFound, map[string]string{"error": i18n.T(locale, i18n.MsgNotFound, "agent", agentID.String())}) + return + } + + var body struct { + Env json.RawMessage `json:"env"` + typedCredentialBody + } + if !bindJSON(w, r, locale, &body) { + return + } + envBytes, credType, hostScope, terr := prepareTypedCredentialEnv(locale, body.typedCredentialBody) + if terr != nil { + writeTypedCredentialError(w, terr) + return + } + createdBy := store.UserIDFromContext(r.Context()) + if envBytes != nil { + if err := agentCreds.SetAgentCredentialsTyped(r.Context(), binaryID, agentID, envBytes, credType, hostScope, createdBy); err != nil { + writeJSON(w, http.StatusInternalServerError, map[string]string{"error": i18n.T(locale, i18n.MsgInternalError, err.Error())}) + return + } + emitAudit(h.msgBus, r, "secure_cli.agent_credentials.updated", "secure_cli_agent_credentials", binaryID.String()+"/"+agentID.String()+"#"+*credType) + h.emitCacheInvalidate("") + writeJSON(w, http.StatusOK, map[string]string{"status": "ok"}) + return + } + if len(body.Env) == 0 { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgRequired, "env")}) + return + } + envJSON, ok := validateAndSerializeEnvVars(w, locale, body.Env) + if !ok { + return + } + if err := agentCreds.SetAgentCredentials(r.Context(), binaryID, agentID, envJSON, createdBy); err != nil { + writeJSON(w, http.StatusInternalServerError, map[string]string{"error": i18n.T(locale, i18n.MsgInternalError, err.Error())}) + return + } + emitAudit(h.msgBus, r, "secure_cli.agent_credentials.updated", "secure_cli_agent_credentials", binaryID.String()+"/"+agentID.String()) + h.emitCacheInvalidate("") + writeJSON(w, http.StatusOK, map[string]string{"status": "ok"}) +} + +func (h *SecureCLIHandler) handleDeleteAgentCredentials(w http.ResponseWriter, r *http.Request) { + if !h.requireAgentCredentialAdmin(w, r) { + return + } + agentCreds, ok := h.requireAgentCredentialStore(w, r) + if !ok { + return + } + locale := store.LocaleFromContext(r.Context()) + binaryID, agentID, ok := parseAgentCredentialPath(w, r, locale) + if !ok { + return + } + if err := agentCreds.DeleteAgentCredentials(r.Context(), binaryID, agentID); err != nil { + writeJSON(w, http.StatusInternalServerError, map[string]string{"error": i18n.T(locale, i18n.MsgInternalError, err.Error())}) + return + } + emitAudit(h.msgBus, r, "secure_cli.agent_credentials.deleted", "secure_cli_agent_credentials", binaryID.String()+"/"+agentID.String()) + h.emitCacheInvalidate("") + writeJSON(w, http.StatusOK, map[string]string{"status": "ok"}) +} diff --git a/internal/http/secure_cli_typed_credentials_test.go b/internal/http/secure_cli_typed_credentials_test.go index bc0f822c..bcc99641 100644 --- a/internal/http/secure_cli_typed_credentials_test.go +++ b/internal/http/secure_cli_typed_credentials_test.go @@ -31,9 +31,16 @@ type recordingSecureCLIStore struct { lastTypedType *string lastTypedScope *string lastLegacyEnv []byte + lastAgentTypedEnv []byte + lastAgentType *string + lastAgentScope *string + lastAgentLegacy []byte typedCalls int legacyCalls int + agentTypedCalls int + agentLegacyCalls int existingForGet *store.SecureCLIUserCredential + existingAgent *store.SecureCLIAgentCredential } func (s *recordingSecureCLIStore) SetUserCredentialsTyped(_ context.Context, _ uuid.UUID, _ string, encryptedEnv []byte, credentialType, hostScope *string) error { @@ -58,6 +65,61 @@ func (s *recordingSecureCLIStore) GetUserCredentials(context.Context, uuid.UUID, return &cp, nil } +func (s *recordingSecureCLIStore) BinaryExists(context.Context, uuid.UUID) (bool, error) { + return true, nil +} + +func (s *recordingSecureCLIStore) AgentExists(context.Context, uuid.UUID) (bool, error) { + return true, nil +} + +func (s *recordingSecureCLIStore) SetAgentCredentialsTyped(_ context.Context, _ uuid.UUID, _ uuid.UUID, encryptedEnv []byte, credentialType, hostScope *string, _ string) error { + s.lastAgentTypedEnv = append([]byte(nil), encryptedEnv...) + s.lastAgentType = credentialType + s.lastAgentScope = hostScope + s.agentTypedCalls++ + return nil +} + +func (s *recordingSecureCLIStore) SetAgentCredentials(_ context.Context, _ uuid.UUID, _ uuid.UUID, encryptedEnv []byte, _ string) error { + s.lastAgentLegacy = append([]byte(nil), encryptedEnv...) + s.agentLegacyCalls++ + return nil +} + +func (s *recordingSecureCLIStore) GetAgentCredentials(context.Context, uuid.UUID, uuid.UUID) (*store.SecureCLIAgentCredential, error) { + if s.existingAgent == nil { + return nil, nil + } + cp := *s.existingAgent + return &cp, nil +} + +func (s *recordingSecureCLIStore) DeleteAgentCredentials(context.Context, uuid.UUID, uuid.UUID) error { + return nil +} + +func (s *recordingSecureCLIStore) ListAgentCredentials(context.Context, uuid.UUID) ([]store.SecureCLIAgentCredential, error) { + if s.existingAgent == nil { + return nil, nil + } + cp := *s.existingAgent + return []store.SecureCLIAgentCredential{cp}, nil +} + +func newAgentCredentialTestHandler(st *recordingSecureCLIStore) *SecureCLIHandler { + ts := newMockTenantStore() + ts.addTenant(store.MasterTenantID, "master") + ts.setUserRole(store.MasterTenantID, "system", store.TenantRoleAdmin) + return NewSecureCLIHandler(st, nil, ts) +} + +func withTenantAdminContext(req *http.Request) *http.Request { + ctx := store.WithTenantID(req.Context(), store.MasterTenantID) + ctx = store.WithUserID(ctx, "system") + return req.WithContext(ctx) +} + func putUserCred(t *testing.T, h *SecureCLIHandler, binaryID uuid.UUID, body any) *httptest.ResponseRecorder { t.Helper() buf, _ := json.Marshal(body) @@ -69,6 +131,17 @@ func putUserCred(t *testing.T, h *SecureCLIHandler, binaryID uuid.UUID, body any return rec } +func putAgentCred(t *testing.T, h *SecureCLIHandler, binaryID, agentID uuid.UUID, body any) *httptest.ResponseRecorder { + t.Helper() + buf, _ := json.Marshal(body) + req := httptest.NewRequest(http.MethodPut, "/v1/cli-credentials/"+binaryID.String()+"/agent-credentials/"+agentID.String(), bytes.NewReader(buf)) + req.SetPathValue("id", binaryID.String()) + req.SetPathValue("agentId", agentID.String()) + rec := httptest.NewRecorder() + h.handleSetAgentCredentials(rec, withTenantAdminContext(req)) + return rec +} + // 9. Handler accepts new PAT payload and routes through SetUserCredentialsTyped. func TestPutUserCredential_PATPayload(t *testing.T) { st := &recordingSecureCLIStore{} @@ -106,6 +179,109 @@ func TestPutUserCredential_PATPayload(t *testing.T) { } } +func TestPutAgentCredential_PATPayload(t *testing.T) { + st := &recordingSecureCLIStore{} + h := newAgentCredentialTestHandler(st) + binaryID := uuid.New() + agentID := uuid.New() + + rec := putAgentCred(t, h, binaryID, agentID, map[string]any{ + "credential_type": "pat", + "host_scope": "github.com", + "blob": map[string]string{"token": "ghp_agentABC123456"}, + }) + if rec.Code != http.StatusOK { + t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String()) + } + if st.agentTypedCalls != 1 || st.agentLegacyCalls != 0 { + t.Fatalf("expected agent typed=1 legacy=0, got typed=%d legacy=%d", st.agentTypedCalls, st.agentLegacyCalls) + } + if st.lastAgentType == nil || *st.lastAgentType != "pat" { + t.Fatalf("type mismatch: %#v", st.lastAgentType) + } + if st.lastAgentScope == nil || *st.lastAgentScope != "github.com" { + t.Fatalf("scope mismatch: %#v", st.lastAgentScope) + } + var got map[string]string + if err := json.Unmarshal(st.lastAgentTypedEnv, &got); err != nil { + t.Fatal(err) + } + if got["token"] != "ghp_agentABC123456" { + t.Fatalf("stored token mismatch: %#v", got) + } + if strings.Contains(rec.Body.String(), "ghp_agent") { + t.Fatalf("response leaked token: %s", rec.Body.String()) + } +} + +func TestGetAgentCredential_TypedDoesNotReturnSecretBlob(t *testing.T) { + credType := "pat" + hostScope := "github.com" + binaryID := uuid.New() + agentID := uuid.New() + st := &recordingSecureCLIStore{existingAgent: &store.SecureCLIAgentCredential{ + ID: uuid.New(), + BinaryID: binaryID, + AgentID: agentID, + EncryptedEnv: []byte(`{"token":"ghp_secret_agent_token"}`), + CredentialType: &credType, + HostScope: &hostScope, + }} + h := newAgentCredentialTestHandler(st) + req := httptest.NewRequest(http.MethodGet, "/v1/cli-credentials/"+binaryID.String()+"/agent-credentials/"+agentID.String(), nil) + req.SetPathValue("id", binaryID.String()) + req.SetPathValue("agentId", agentID.String()) + rec := httptest.NewRecorder() + + h.handleGetAgentCredentials(rec, withTenantAdminContext(req)) + + if rec.Code != http.StatusOK { + t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String()) + } + if strings.Contains(rec.Body.String(), "ghp_secret") || strings.Contains(rec.Body.String(), "token") { + t.Fatalf("typed agent credential leaked blob material: %s", rec.Body.String()) + } +} + +func TestAgentCredentialsRouteRequiresTenantAdmin(t *testing.T) { + oldToken := pkgGatewayToken + oldFallback := pkgNoAuthFallbackAllowed + pkgGatewayToken = "" + pkgNoAuthFallbackAllowed = true + defer func() { + pkgGatewayToken = oldToken + pkgNoAuthFallbackAllowed = oldFallback + }() + + st := &recordingSecureCLIStore{} + ts := newMockTenantStore() + ts.addTenant(store.MasterTenantID, "master") + ts.setUserRole(store.MasterTenantID, "system", store.TenantRoleViewer) + h := NewSecureCLIHandler(st, nil, ts) + mux := http.NewServeMux() + h.RegisterRoutes(mux) + + binaryID := uuid.New() + agentID := uuid.New() + body, _ := json.Marshal(map[string]any{ + "credential_type": "pat", + "host_scope": "github.com", + "blob": map[string]string{"token": "ghp_routeABC123456"}, + }) + req := httptest.NewRequest(http.MethodPut, "/v1/cli-credentials/"+binaryID.String()+"/agent-credentials/"+agentID.String(), bytes.NewReader(body)) + req.Header.Set("X-GoClaw-User-Id", "browser-user") + rec := httptest.NewRecorder() + + mux.ServeHTTP(rec, req) + + if rec.Code != http.StatusForbidden { + t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String()) + } + if st.agentTypedCalls != 0 || st.agentLegacyCalls != 0 { + t.Fatalf("tenant viewer must not write credentials, typed=%d legacy=%d", st.agentTypedCalls, st.agentLegacyCalls) + } +} + // 10. Handler rejects passphrase-protected SSH key with error_key. func TestPutUserCredential_RejectsPassphraseKey(t *testing.T) { st := &recordingSecureCLIStore{} @@ -236,10 +412,7 @@ func base64Wrap(b []byte) string { s := base64.StdEncoding.EncodeToString(b) var out strings.Builder for i := 0; i < len(s); i += 70 { - end := i + 70 - if end > len(s) { - end = len(s) - } + end := min(i+70, len(s)) if i > 0 { out.WriteByte('\n') } diff --git a/internal/store/pg/secure_cli.go b/internal/store/pg/secure_cli.go index 02cb90fe..1b0191ac 100644 --- a/internal/store/pg/secure_cli.go +++ b/internal/store/pg/secure_cli.go @@ -355,6 +355,11 @@ func (s *PGSecureCLIStore) LookupByBinary(ctx context.Context, binaryName string } else { selectCols += ", NULL AS user_env, NULL AS user_cred_type, NULL AS user_host_scope" } + if agentID != nil { + selectCols += ", ac.encrypted_env AS agent_env, ac.credential_type AS agent_cred_type, ac.host_scope AS agent_host_scope" + } else { + selectCols += ", NULL AS agent_env, NULL AS agent_cred_type, NULL AS agent_host_scope" + } var args []any argIdx := 1 @@ -370,6 +375,16 @@ func (s *PGSecureCLIStore) LookupByBinary(ctx context.Context, binaryName string } else { query += ` LEFT JOIN secure_cli_agent_grants g ON FALSE` // never match } + if agentID != nil { + query += fmt.Sprintf(` LEFT JOIN secure_cli_agent_credentials ac ON ac.binary_id = b.id AND ac.agent_id = $%d`, argIdx) + args = append(args, *agentID) + argIdx++ + if !isCross { + query += fmt.Sprintf(` AND ac.tenant_id = $%d`, argIdx) + args = append(args, tid) + argIdx++ + } + } // LEFT JOIN user credentials if userID != "" { @@ -432,6 +447,8 @@ func (s *PGSecureCLIStore) scanRowWithGrantAndUserEnv(row *sql.Row) (*store.Secu var grantEncEnv []byte var userEnv []byte var userCredType, userHostScope *string + var agentEnv []byte + var agentCredType, agentHostScope *string err := row.Scan( &b.ID, &b.BinaryName, &binaryPath, &b.Description, &env, @@ -442,6 +459,8 @@ func (s *PGSecureCLIStore) scanRowWithGrantAndUserEnv(row *sql.Row) (*store.Secu &grantDenyArgs, &grantDenyVerbose, &grantTimeout, &grantTips, &grantEnabled, &grantID, &grantEncEnv, // User credential columns &userEnv, &userCredType, &userHostScope, + // Agent credential columns + &agentEnv, &agentCredType, &agentHostScope, ) if err != nil { if errors.Is(err, sql.ErrNoRows) { @@ -481,23 +500,45 @@ func (s *PGSecureCLIStore) scanRowWithGrantAndUserEnv(row *sql.Row) (*store.Secu grant.TimeoutSeconds = grantTimeout grant.Tips = grantTips // Decrypt grant env override (fail-closed: skip if decrypt fails). - if len(grantEncEnv) > 0 && s.encKey != "" { - if decrypted, err := crypto.Decrypt(string(grantEncEnv), s.encKey); err == nil { - grant.EncryptedEnv = []byte(decrypted) + if len(grantEncEnv) > 0 { + if s.encKey != "" { + if decrypted, err := crypto.Decrypt(string(grantEncEnv), s.encKey); err == nil { + grant.EncryptedEnv = []byte(decrypted) + } + } else { + grant.EncryptedEnv = grantEncEnv } } b.MergeGrantOverrides(grant) } // Decrypt per-user env - if len(userEnv) > 0 && s.encKey != "" { - if decrypted, err := crypto.Decrypt(string(userEnv), s.encKey); err == nil { - b.UserEnv = []byte(decrypted) + if len(userEnv) > 0 { + if s.encKey != "" { + if decrypted, err := crypto.Decrypt(string(userEnv), s.encKey); err == nil { + b.UserEnv = []byte(decrypted) + } + } else { + b.UserEnv = userEnv } } // Project per-user credential metadata so adapters can branch on it. b.UserCredentialType = userCredType b.UserHostScope = userHostScope + if len(b.UserEnv) > 0 || userCredType != nil || userHostScope != nil { + b.SetEffectiveCredential(b.UserEnv, userCredType, userHostScope, "user", "") + } + if len(agentEnv) > 0 || agentCredType != nil || agentHostScope != nil { + decrypted := agentEnv + if len(agentEnv) > 0 && s.encKey != "" { + if d, err := crypto.Decrypt(string(agentEnv), s.encKey); err == nil { + decrypted = []byte(d) + } + } + if b.CredentialSource == "" { + b.SetEffectiveCredential(decrypted, agentCredType, agentHostScope, "agent", "") + } + } return &b, nil } @@ -507,7 +548,7 @@ func (s *PGSecureCLIStore) applyContextSecureCLI(ctx context.Context, b *store.S if len(scopes) == 0 || b == nil { return b, nil } - hasUserCredential := len(b.UserEnv) > 0 || b.UserCredentialType != nil || b.UserHostScope != nil + hasUserCredential := b.CredentialSource == "user" || len(b.UserEnv) > 0 || b.UserCredentialType != nil || b.UserHostScope != nil disabledByContext := false for _, scope := range scopes { grants, err := s.ListContextGrantsForScope(ctx, scope) @@ -546,9 +587,7 @@ func (s *PGSecureCLIStore) applyContextSecureCLI(ctx context.Context, b *store.S continue } if !hasUserCredential { - b.UserEnv = creds.EncryptedEnv - b.UserCredentialType = creds.CredentialType - b.UserHostScope = creds.HostScope + b.SetEffectiveCredential(creds.EncryptedEnv, creds.CredentialType, creds.HostScope, "context", scope.ScopeType+":"+scope.ScopeKey) } else { b.EncryptedEnv = creds.EncryptedEnv } diff --git a/internal/store/pg/secure_cli_agent_credentials.go b/internal/store/pg/secure_cli_agent_credentials.go new file mode 100644 index 00000000..6d27d5a6 --- /dev/null +++ b/internal/store/pg/secure_cli_agent_credentials.go @@ -0,0 +1,179 @@ +package pg + +import ( + "context" + "database/sql" + "errors" + "fmt" + "time" + + "github.com/google/uuid" + + "github.com/nextlevelbuilder/goclaw/internal/crypto" + "github.com/nextlevelbuilder/goclaw/internal/store" +) + +const agentCredSelectCols = `c.id, c.binary_id, c.agent_id, c.encrypted_env, c.metadata, + c.credential_type, c.host_scope, c.created_by, c.created_at, c.updated_at` + +func agentCredentialTenantID(ctx context.Context) (uuid.UUID, error) { + tid := store.TenantIDFromContext(ctx) + if tid == uuid.Nil { + return uuid.Nil, fmt.Errorf("tenant_id required for agent credentials") + } + return tid, nil +} + +func (s *PGSecureCLIStore) BinaryExists(ctx context.Context, binaryID uuid.UUID) (bool, error) { + tid, err := agentCredentialTenantID(ctx) + if err != nil { + return false, nil + } + var exists bool + err = s.db.QueryRowContext(ctx, + `SELECT EXISTS(SELECT 1 FROM secure_cli_binaries WHERE id = $1 AND tenant_id = $2)`, + binaryID, tid, + ).Scan(&exists) + return exists, err +} + +func (s *PGSecureCLIStore) AgentExists(ctx context.Context, agentID uuid.UUID) (bool, error) { + tid, err := agentCredentialTenantID(ctx) + if err != nil { + return false, nil + } + var exists bool + err = s.db.QueryRowContext(ctx, + `SELECT EXISTS(SELECT 1 FROM agents WHERE id = $1 AND tenant_id = $2 AND deleted_at IS NULL)`, + agentID, tid, + ).Scan(&exists) + return exists, err +} + +func (s *PGSecureCLIStore) GetAgentCredentials(ctx context.Context, binaryID uuid.UUID, agentID uuid.UUID) (*store.SecureCLIAgentCredential, error) { + tid, err := agentCredentialTenantID(ctx) + if err != nil { + return nil, err + } + var c store.SecureCLIAgentCredential + var env []byte + err = s.db.QueryRowContext(ctx, + `SELECT `+agentCredSelectCols+`, COALESCE(a.agent_key, ''), COALESCE(a.display_name, '') + FROM secure_cli_agent_credentials c + LEFT JOIN agents a ON a.id = c.agent_id AND a.tenant_id = c.tenant_id + WHERE c.binary_id = $1 AND c.agent_id = $2 AND c.tenant_id = $3`, + binaryID, agentID, tid, + ).Scan(&c.ID, &c.BinaryID, &c.AgentID, &env, &c.Metadata, + &c.CredentialType, &c.HostScope, &c.CreatedBy, &c.CreatedAt, &c.UpdatedAt, + &c.AgentKey, &c.Name) + if errors.Is(err, sql.ErrNoRows) { + return nil, nil + } + if err != nil { + return nil, err + } + c.EncryptedEnv = s.decryptAgentCredentialEnv(env) + return &c, nil +} + +func (s *PGSecureCLIStore) SetAgentCredentials(ctx context.Context, binaryID uuid.UUID, agentID uuid.UUID, encryptedEnv []byte, createdBy string) error { + return s.SetAgentCredentialsTyped(ctx, binaryID, agentID, encryptedEnv, nil, nil, createdBy) +} + +func (s *PGSecureCLIStore) SetAgentCredentialsTyped(ctx context.Context, binaryID uuid.UUID, agentID uuid.UUID, encryptedEnv []byte, credentialType, hostScope *string, createdBy string) error { + tid, err := agentCredentialTenantID(ctx) + if err != nil { + return err + } + envBytes, err := s.encryptAgentCredentialEnv(encryptedEnv) + if err != nil { + return err + } + now := time.Now() + res, err := s.db.ExecContext(ctx, + `INSERT INTO secure_cli_agent_credentials + (binary_id, agent_id, encrypted_env, metadata, tenant_id, + credential_type, host_scope, created_by, created_at, updated_at) + SELECT b.id, a.id, $3, '{}', $4, $5, $6, $7, $8, $8 + FROM secure_cli_binaries b + JOIN agents a ON a.id = $2 AND a.tenant_id = $4 AND a.deleted_at IS NULL + WHERE b.id = $1 AND b.tenant_id = $4 + ON CONFLICT (binary_id, agent_id, tenant_id) DO UPDATE SET + encrypted_env = EXCLUDED.encrypted_env, + credential_type = EXCLUDED.credential_type, + host_scope = EXCLUDED.host_scope, + created_by = EXCLUDED.created_by, + updated_at = EXCLUDED.updated_at`, + binaryID, agentID, envBytes, tid, credentialType, hostScope, createdBy, now, + ) + if err != nil { + return err + } + if n, err := res.RowsAffected(); err == nil && n == 0 { + return sql.ErrNoRows + } + return nil +} + +func (s *PGSecureCLIStore) DeleteAgentCredentials(ctx context.Context, binaryID uuid.UUID, agentID uuid.UUID) error { + tid, err := agentCredentialTenantID(ctx) + if err != nil { + return err + } + _, err = s.db.ExecContext(ctx, + `DELETE FROM secure_cli_agent_credentials WHERE binary_id = $1 AND agent_id = $2 AND tenant_id = $3`, + binaryID, agentID, tid, + ) + return err +} + +func (s *PGSecureCLIStore) ListAgentCredentials(ctx context.Context, binaryID uuid.UUID) ([]store.SecureCLIAgentCredential, error) { + tid, err := agentCredentialTenantID(ctx) + if err != nil { + return nil, err + } + rows, err := s.db.QueryContext(ctx, + `SELECT `+agentCredSelectCols+`, COALESCE(a.agent_key, ''), COALESCE(a.display_name, '') + FROM secure_cli_agent_credentials c + LEFT JOIN agents a ON a.id = c.agent_id AND a.tenant_id = c.tenant_id + WHERE c.binary_id = $1 AND c.tenant_id = $2 + ORDER BY c.created_at`, binaryID, tid) + if err != nil { + return nil, err + } + defer rows.Close() + + var result []store.SecureCLIAgentCredential + for rows.Next() { + var c store.SecureCLIAgentCredential + var env []byte + if err := rows.Scan(&c.ID, &c.BinaryID, &c.AgentID, &env, &c.Metadata, + &c.CredentialType, &c.HostScope, &c.CreatedBy, &c.CreatedAt, &c.UpdatedAt, + &c.AgentKey, &c.Name); err != nil { + return nil, err + } + c.EncryptedEnv = s.decryptAgentCredentialEnv(env) + result = append(result, c) + } + return result, rows.Err() +} + +func (s *PGSecureCLIStore) encryptAgentCredentialEnv(env []byte) ([]byte, error) { + if len(env) == 0 || s.encKey == "" { + return env, nil + } + encrypted, err := crypto.Encrypt(string(env), s.encKey) + if err != nil { + return nil, fmt.Errorf("encrypt agent credential env: %w", err) + } + return []byte(encrypted), nil +} + +func (s *PGSecureCLIStore) decryptAgentCredentialEnv(env []byte) []byte { + if len(env) > 0 && s.encKey != "" { + if decrypted, err := crypto.Decrypt(string(env), s.encKey); err == nil { + return []byte(decrypted) + } + } + return env +} diff --git a/internal/store/secure_cli_store.go b/internal/store/secure_cli_store.go index b3c9955f..f7b5555d 100644 --- a/internal/store/secure_cli_store.go +++ b/internal/store/secure_cli_store.go @@ -44,6 +44,13 @@ type SecureCLIBinary struct { // credential is legacy env-only. UserCredentialType *string `json:"-" db:"-"` UserHostScope *string `json:"-" db:"-"` + // CredentialEnv + metadata carry the effective typed credential selected + // for runtime injection. Source can be "user", "context", "agent", or "". + CredentialEnv []byte `json:"-" db:"-"` + CredentialType *string `json:"-" db:"-"` + CredentialHostScope *string `json:"-" db:"-"` + CredentialSource string `json:"credential_source,omitempty" db:"-"` + CredentialSubjectID string `json:"credential_subject_id,omitempty" db:"-"` // EnvKeys is set by HTTP handlers only (names from decrypted env, no values); not a DB column. EnvKeys []string `json:"env_keys,omitempty" db:"-"` // Env is set by HTTP handlers only. Sensitive values are masked; value entries are visible. @@ -52,6 +59,25 @@ type SecureCLIBinary struct { AgentGrantsSummary []AgentGrantSummary `json:"agent_grants_summary" db:"-"` } +// SetEffectiveCredential records the credential material selected for runtime +// injection. The legacy User* fields remain populated for older call sites that +// still synthesize adapter credentials from the binary row. +func (b *SecureCLIBinary) SetEffectiveCredential(env []byte, credentialType, hostScope *string, source, subjectID string) { + if b == nil { + return + } + b.CredentialEnv = env + b.CredentialType = credentialType + b.CredentialHostScope = hostScope + b.CredentialSource = source + b.CredentialSubjectID = subjectID + if source == "user" || source == "context" { + b.UserEnv = env + b.UserCredentialType = credentialType + b.UserHostScope = hostScope + } +} + // MergeGrantOverrides applies agent grant overrides onto a binary config. // Non-nil grant fields replace binary defaults; nil fields keep binary values. func (b *SecureCLIBinary) MergeGrantOverrides(g *SecureCLIAgentGrant) { @@ -94,6 +120,25 @@ type SecureCLIUserCredential struct { HostScope *string `json:"host_scope,omitempty" db:"host_scope"` } +// SecureCLIAgentCredential holds per-agent encrypted env overrides for a binary. +type SecureCLIAgentCredential struct { + ID uuid.UUID `json:"id" db:"id"` + BinaryID uuid.UUID `json:"binary_id" db:"binary_id"` + AgentID uuid.UUID `json:"agent_id" db:"agent_id"` + AgentKey string `json:"agent_key,omitempty" db:"-"` + Name string `json:"name,omitempty" db:"-"` + Metadata json.RawMessage `json:"metadata,omitempty" db:"metadata"` + CreatedBy string `json:"created_by" db:"created_by"` + CreatedAt string `json:"created_at" db:"created_at"` + UpdatedAt string `json:"updated_at" db:"updated_at"` + // EncryptedEnv is decrypted JSON — never serialized to API. + EncryptedEnv []byte `json:"-" db:"encrypted_env"` + // CredentialType selects the wire shape carried in EncryptedEnv. + CredentialType *string `json:"credential_type,omitempty" db:"credential_type"` + // HostScope binds the credential to a specific hostname. + HostScope *string `json:"host_scope,omitempty" db:"host_scope"` +} + // SecureCLIAgentGrant represents a per-agent grant with optional setting overrides. type SecureCLIAgentGrant struct { BaseModel @@ -158,6 +203,18 @@ type SecureCLIStore interface { ListUserCredentials(ctx context.Context, binaryID uuid.UUID) ([]SecureCLIUserCredential, error) } +// SecureCLIAgentCredentialStore manages per-agent credential material for +// secure CLI binaries. It intentionally does not grant binary access. +type SecureCLIAgentCredentialStore interface { + BinaryExists(ctx context.Context, binaryID uuid.UUID) (bool, error) + AgentExists(ctx context.Context, agentID uuid.UUID) (bool, error) + GetAgentCredentials(ctx context.Context, binaryID uuid.UUID, agentID uuid.UUID) (*SecureCLIAgentCredential, error) + SetAgentCredentials(ctx context.Context, binaryID uuid.UUID, agentID uuid.UUID, encryptedEnv []byte, createdBy string) error + SetAgentCredentialsTyped(ctx context.Context, binaryID uuid.UUID, agentID uuid.UUID, encryptedEnv []byte, credentialType, hostScope *string, createdBy string) error + DeleteAgentCredentials(ctx context.Context, binaryID uuid.UUID, agentID uuid.UUID) error + ListAgentCredentials(ctx context.Context, binaryID uuid.UUID) ([]SecureCLIAgentCredential, error) +} + // SecureCLIAgentGrantStore manages per-agent grants for secure CLI binaries. type SecureCLIAgentGrantStore interface { BinaryExists(ctx context.Context, binaryID uuid.UUID) (bool, error) diff --git a/internal/store/sqlitestore/schema.go b/internal/store/sqlitestore/schema.go index ac4e0193..e7791b79 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 = 45 +const SchemaVersion = 46 // migrations maps version → SQL to apply when upgrading FROM that version. // schema.sql always represents the LATEST full schema (for fresh DBs). @@ -807,6 +807,30 @@ CREATE INDEX IF NOT EXISTS idx_run_timeline_trace 43: addChannelContextCapabilityTables, // Version 44 → 45: passive channel memory extraction run and review queue. 44: addChannelMemoryExtractionTables, + // Version 45 → 46: per-agent typed Secure CLI credentials. + 45: `CREATE UNIQUE INDEX IF NOT EXISTS idx_secure_cli_binaries_id_tenant + ON secure_cli_binaries(id, tenant_id); +CREATE UNIQUE INDEX IF NOT EXISTS idx_agents_id_tenant + ON agents(id, tenant_id); +CREATE TABLE IF NOT EXISTS secure_cli_agent_credentials ( + id TEXT NOT NULL PRIMARY KEY, + binary_id TEXT NOT NULL, + agent_id TEXT NOT NULL, + encrypted_env BLOB NOT NULL, + metadata TEXT NOT NULL DEFAULT '{}', + tenant_id TEXT NOT NULL REFERENCES tenants(id), + credential_type TEXT, + host_scope TEXT, + created_by VARCHAR(255) NOT NULL DEFAULT '', + 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(binary_id, agent_id, tenant_id), + FOREIGN KEY (binary_id, tenant_id) REFERENCES secure_cli_binaries(id, tenant_id) ON DELETE CASCADE, + FOREIGN KEY (agent_id, tenant_id) REFERENCES agents(id, tenant_id) ON DELETE CASCADE +); +CREATE INDEX IF NOT EXISTS idx_scac_tenant ON secure_cli_agent_credentials(tenant_id); +CREATE INDEX IF NOT EXISTS idx_scac_binary ON secure_cli_agent_credentials(binary_id); +CREATE INDEX IF NOT EXISTS idx_scac_agent ON secure_cli_agent_credentials(agent_id);`, } const addChannelMemoryExtractionTables = ` diff --git a/internal/store/sqlitestore/schema.sql b/internal/store/sqlitestore/schema.sql index c46fc0a3..28989c07 100644 --- a/internal/store/sqlitestore/schema.sql +++ b/internal/store/sqlitestore/schema.sql @@ -1710,6 +1710,36 @@ CREATE TABLE IF NOT EXISTS secure_cli_user_credentials ( CREATE INDEX IF NOT EXISTS idx_scuc_tenant ON secure_cli_user_credentials(tenant_id); CREATE INDEX IF NOT EXISTS idx_scuc_binary ON secure_cli_user_credentials(binary_id); +-- ============================================================ +-- Table: secure_cli_agent_credentials (per-agent encrypted env) +-- ============================================================ + +CREATE UNIQUE INDEX IF NOT EXISTS idx_secure_cli_binaries_id_tenant + ON secure_cli_binaries(id, tenant_id); +CREATE UNIQUE INDEX IF NOT EXISTS idx_agents_id_tenant + ON agents(id, tenant_id); + +CREATE TABLE IF NOT EXISTS secure_cli_agent_credentials ( + id TEXT NOT NULL PRIMARY KEY, + binary_id TEXT NOT NULL, + agent_id TEXT NOT NULL, + encrypted_env BLOB NOT NULL, + metadata TEXT NOT NULL DEFAULT '{}', + tenant_id TEXT NOT NULL REFERENCES tenants(id), + credential_type TEXT, + host_scope TEXT, + created_by VARCHAR(255) NOT NULL DEFAULT '', + 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(binary_id, agent_id, tenant_id), + FOREIGN KEY (binary_id, tenant_id) REFERENCES secure_cli_binaries(id, tenant_id) ON DELETE CASCADE, + FOREIGN KEY (agent_id, tenant_id) REFERENCES agents(id, tenant_id) ON DELETE CASCADE +); + +CREATE INDEX IF NOT EXISTS idx_scac_tenant ON secure_cli_agent_credentials(tenant_id); +CREATE INDEX IF NOT EXISTS idx_scac_binary ON secure_cli_agent_credentials(binary_id); +CREATE INDEX IF NOT EXISTS idx_scac_agent ON secure_cli_agent_credentials(agent_id); + -- ============================================================ -- Table: vault_documents (V3 Knowledge Vault registry) -- ============================================================ diff --git a/internal/store/sqlitestore/secure-cli-agent-credentials.go b/internal/store/sqlitestore/secure-cli-agent-credentials.go new file mode 100644 index 00000000..9cd80425 --- /dev/null +++ b/internal/store/sqlitestore/secure-cli-agent-credentials.go @@ -0,0 +1,193 @@ +//go:build sqlite || sqliteonly + +package sqlitestore + +import ( + "context" + "database/sql" + "errors" + "fmt" + "time" + + "github.com/google/uuid" + + "github.com/nextlevelbuilder/goclaw/internal/crypto" + "github.com/nextlevelbuilder/goclaw/internal/store" +) + +const agentCredSelectCols = `c.id, c.binary_id, c.agent_id, c.encrypted_env, COALESCE(c.metadata, '{}'), + c.credential_type, c.host_scope, c.created_by, c.created_at, c.updated_at` + +func agentCredentialTenantID(ctx context.Context) (uuid.UUID, error) { + tid := store.TenantIDFromContext(ctx) + if tid == uuid.Nil { + return uuid.Nil, fmt.Errorf("tenant_id required for agent credentials") + } + return tid, nil +} + +func (s *SQLiteSecureCLIStore) BinaryExists(ctx context.Context, binaryID uuid.UUID) (bool, error) { + tid, err := agentCredentialTenantID(ctx) + if err != nil { + return false, nil + } + var exists bool + err = s.db.QueryRowContext(ctx, + `SELECT EXISTS(SELECT 1 FROM secure_cli_binaries WHERE id = ? AND tenant_id = ?)`, + binaryID, tid, + ).Scan(&exists) + return exists, err +} + +func (s *SQLiteSecureCLIStore) AgentExists(ctx context.Context, agentID uuid.UUID) (bool, error) { + tid, err := agentCredentialTenantID(ctx) + if err != nil { + return false, nil + } + var exists bool + err = s.db.QueryRowContext(ctx, + `SELECT EXISTS(SELECT 1 FROM agents WHERE id = ? AND tenant_id = ? AND deleted_at IS NULL)`, + agentID, tid, + ).Scan(&exists) + return exists, err +} + +func (s *SQLiteSecureCLIStore) GetAgentCredentials(ctx context.Context, binaryID uuid.UUID, agentID uuid.UUID) (*store.SecureCLIAgentCredential, error) { + tid, err := agentCredentialTenantID(ctx) + if err != nil { + return nil, err + } + var c store.SecureCLIAgentCredential + var env []byte + var metaBytes []byte + var createdAt, updatedAt string + err = s.db.QueryRowContext(ctx, + `SELECT `+agentCredSelectCols+`, COALESCE(a.agent_key, ''), COALESCE(a.display_name, '') + FROM secure_cli_agent_credentials c + LEFT JOIN agents a ON a.id = c.agent_id AND a.tenant_id = c.tenant_id + WHERE c.binary_id = ? AND c.agent_id = ? AND c.tenant_id = ?`, + binaryID, agentID, tid, + ).Scan(&c.ID, &c.BinaryID, &c.AgentID, &env, &metaBytes, + &c.CredentialType, &c.HostScope, &c.CreatedBy, &createdAt, &updatedAt, + &c.AgentKey, &c.Name) + if errors.Is(err, sql.ErrNoRows) { + return nil, nil + } + if err != nil { + return nil, err + } + c.CreatedAt = createdAt + c.UpdatedAt = updatedAt + c.Metadata = metaBytes + c.EncryptedEnv = s.decryptAgentCredentialEnv(env) + return &c, nil +} + +func (s *SQLiteSecureCLIStore) SetAgentCredentials(ctx context.Context, binaryID uuid.UUID, agentID uuid.UUID, encryptedEnv []byte, createdBy string) error { + return s.SetAgentCredentialsTyped(ctx, binaryID, agentID, encryptedEnv, nil, nil, createdBy) +} + +func (s *SQLiteSecureCLIStore) SetAgentCredentialsTyped(ctx context.Context, binaryID uuid.UUID, agentID uuid.UUID, encryptedEnv []byte, credentialType, hostScope *string, createdBy string) error { + tid, err := agentCredentialTenantID(ctx) + if err != nil { + return err + } + envBytes, err := s.encryptAgentCredentialEnv(encryptedEnv) + if err != nil { + return err + } + now := time.Now().UTC().Format(time.RFC3339Nano) + id := store.GenNewID() + res, err := s.db.ExecContext(ctx, + `INSERT INTO secure_cli_agent_credentials + (id, binary_id, agent_id, encrypted_env, metadata, tenant_id, + credential_type, host_scope, created_by, created_at, updated_at) + SELECT ?, b.id, a.id, ?, '{}', ?, ?, ?, ?, ?, ? + FROM secure_cli_binaries b + JOIN agents a ON a.id = ? AND a.tenant_id = ? AND a.deleted_at IS NULL + WHERE b.id = ? AND b.tenant_id = ? + ON CONFLICT (binary_id, agent_id, tenant_id) DO UPDATE SET + encrypted_env = excluded.encrypted_env, + credential_type = excluded.credential_type, + host_scope = excluded.host_scope, + created_by = excluded.created_by, + updated_at = excluded.updated_at`, + id, envBytes, tid, credentialType, hostScope, createdBy, now, now, + agentID, tid, binaryID, tid, + ) + if err != nil { + return err + } + if n, err := res.RowsAffected(); err == nil && n == 0 { + return sql.ErrNoRows + } + return nil +} + +func (s *SQLiteSecureCLIStore) DeleteAgentCredentials(ctx context.Context, binaryID uuid.UUID, agentID uuid.UUID) error { + tid, err := agentCredentialTenantID(ctx) + if err != nil { + return err + } + _, err = s.db.ExecContext(ctx, + `DELETE FROM secure_cli_agent_credentials WHERE binary_id = ? AND agent_id = ? AND tenant_id = ?`, + binaryID, agentID, tid, + ) + return err +} + +func (s *SQLiteSecureCLIStore) ListAgentCredentials(ctx context.Context, binaryID uuid.UUID) ([]store.SecureCLIAgentCredential, error) { + tid, err := agentCredentialTenantID(ctx) + if err != nil { + return nil, err + } + rows, err := s.db.QueryContext(ctx, + `SELECT `+agentCredSelectCols+`, COALESCE(a.agent_key, ''), COALESCE(a.display_name, '') + FROM secure_cli_agent_credentials c + LEFT JOIN agents a ON a.id = c.agent_id AND a.tenant_id = c.tenant_id + WHERE c.binary_id = ? AND c.tenant_id = ? + ORDER BY c.created_at`, binaryID, tid) + if err != nil { + return nil, err + } + defer rows.Close() + + var result []store.SecureCLIAgentCredential + for rows.Next() { + var c store.SecureCLIAgentCredential + var env []byte + var metaBytes []byte + var createdAt, updatedAt string + if err := rows.Scan(&c.ID, &c.BinaryID, &c.AgentID, &env, &metaBytes, + &c.CredentialType, &c.HostScope, &c.CreatedBy, &createdAt, &updatedAt, + &c.AgentKey, &c.Name); err != nil { + return nil, err + } + c.CreatedAt = createdAt + c.UpdatedAt = updatedAt + c.Metadata = metaBytes + c.EncryptedEnv = s.decryptAgentCredentialEnv(env) + result = append(result, c) + } + return result, rows.Err() +} + +func (s *SQLiteSecureCLIStore) encryptAgentCredentialEnv(env []byte) ([]byte, error) { + if len(env) == 0 || s.encKey == "" { + return env, nil + } + encrypted, err := crypto.Encrypt(string(env), s.encKey) + if err != nil { + return nil, fmt.Errorf("encrypt agent credential env: %w", err) + } + return []byte(encrypted), nil +} + +func (s *SQLiteSecureCLIStore) decryptAgentCredentialEnv(env []byte) []byte { + if len(env) > 0 && s.encKey != "" { + if decrypted, err := crypto.Decrypt(string(env), s.encKey); err == nil { + return []byte(decrypted) + } + } + return env +} diff --git a/internal/store/sqlitestore/secure-cli.go b/internal/store/sqlitestore/secure-cli.go index a3fe20dc..85a69521 100644 --- a/internal/store/sqlitestore/secure-cli.go +++ b/internal/store/sqlitestore/secure-cli.go @@ -381,8 +381,6 @@ func (s *SQLiteSecureCLIStore) LookupByBinary(ctx context.Context, binaryName st var args []any - query := `SELECT ` + selectCols - // Project user-credential columns (encrypted_env + credential_type + host_scope). // When userID is empty we cannot reference uc_user.* — emit NULL placeholders // so the scan column count stays stable. @@ -390,10 +388,17 @@ func (s *SQLiteSecureCLIStore) LookupByBinary(ctx context.Context, binaryName st hasUserJoinNoAgent := userID != "" && agentID == nil if hasUserJoin || hasUserJoinNoAgent { - query += `, uc_user.encrypted_env AS user_env, uc_user.credential_type AS user_cred_type, uc_user.host_scope AS user_host_scope FROM secure_cli_binaries b` + selectCols += `, uc_user.encrypted_env AS user_env, uc_user.credential_type AS user_cred_type, uc_user.host_scope AS user_host_scope` } else { - query += `, NULL AS user_env, NULL AS user_cred_type, NULL AS user_host_scope FROM secure_cli_binaries b` + selectCols += `, NULL AS user_env, NULL AS user_cred_type, NULL AS user_host_scope` } + if agentID != nil { + selectCols += `, ac.encrypted_env AS agent_env, ac.credential_type AS agent_cred_type, ac.host_scope AS agent_host_scope` + } else { + selectCols += `, NULL AS agent_env, NULL AS agent_cred_type, NULL AS agent_host_scope` + } + + query := `SELECT ` + selectCols + ` FROM secure_cli_binaries b` // LEFT JOIN agent grant if agentID != nil { @@ -402,6 +407,14 @@ func (s *SQLiteSecureCLIStore) LookupByBinary(ctx context.Context, binaryName st } else { query += ` LEFT JOIN secure_cli_agent_grants g ON 0` } + if agentID != nil { + query += ` LEFT JOIN secure_cli_agent_credentials ac ON ac.binary_id = b.id AND ac.agent_id = ?` + args = append(args, *agentID) + if !isCross { + query += ` AND ac.tenant_id = ?` + args = append(args, tid) + } + } // LEFT JOIN user credentials (only when we project uc_user.*) if hasUserJoin || hasUserJoinNoAgent { @@ -457,6 +470,8 @@ func (s *SQLiteSecureCLIStore) scanRowWithGrantAndUserEnv(row *sql.Row) (*store. var grantEncEnv []byte var userEnv []byte var userCredType, userHostScope *string + var agentEnv []byte + var agentCredType, agentHostScope *string var createdAt, updatedAt sqliteTime err := row.Scan( @@ -466,6 +481,7 @@ func (s *SQLiteSecureCLIStore) scanRowWithGrantAndUserEnv(row *sql.Row) (*store. &b.Enabled, &b.CreatedBy, &b.AdapterName, &createdAt, &updatedAt, &grantDenyArgs, &grantDenyVerbose, &grantTimeout, &grantTips, &grantEnabled, &grantID, &grantEncEnv, &userEnv, &userCredType, &userHostScope, + &agentEnv, &agentCredType, &agentHostScope, ) if err != nil { if errors.Is(err, sql.ErrNoRows) { @@ -506,24 +522,46 @@ func (s *SQLiteSecureCLIStore) scanRowWithGrantAndUserEnv(row *sql.Row) (*store. } grant.TimeoutSeconds = grantTimeout grant.Tips = grantTips - if len(grantEncEnv) > 0 && s.encKey != "" { - if decrypted, err := crypto.Decrypt(string(grantEncEnv), s.encKey); err == nil { - grant.EncryptedEnv = []byte(decrypted) + if len(grantEncEnv) > 0 { + if s.encKey != "" { + if decrypted, err := crypto.Decrypt(string(grantEncEnv), s.encKey); err == nil { + grant.EncryptedEnv = []byte(decrypted) + } + } else { + grant.EncryptedEnv = grantEncEnv } } b.MergeGrantOverrides(grant) } // Decrypt per-user env - if len(userEnv) > 0 && s.encKey != "" { - if decrypted, err := crypto.Decrypt(string(userEnv), s.encKey); err == nil { - b.UserEnv = []byte(decrypted) + if len(userEnv) > 0 { + if s.encKey != "" { + if decrypted, err := crypto.Decrypt(string(userEnv), s.encKey); err == nil { + b.UserEnv = []byte(decrypted) + } + } else { + b.UserEnv = userEnv } } // Typed-credential metadata from the joined user-credential row. b.UserCredentialType = userCredType b.UserHostScope = userHostScope + if len(b.UserEnv) > 0 || userCredType != nil || userHostScope != nil { + b.SetEffectiveCredential(b.UserEnv, userCredType, userHostScope, "user", "") + } + if len(agentEnv) > 0 || agentCredType != nil || agentHostScope != nil { + decrypted := agentEnv + if len(agentEnv) > 0 && s.encKey != "" { + if d, err := crypto.Decrypt(string(agentEnv), s.encKey); err == nil { + decrypted = []byte(d) + } + } + if b.CredentialSource == "" { + b.SetEffectiveCredential(decrypted, agentCredType, agentHostScope, "agent", "") + } + } return &b, nil } @@ -533,7 +571,7 @@ func (s *SQLiteSecureCLIStore) applyContextSecureCLI(ctx context.Context, b *sto if len(scopes) == 0 || b == nil { return b, nil } - hasUserCredential := len(b.UserEnv) > 0 || b.UserCredentialType != nil || b.UserHostScope != nil + hasUserCredential := b.CredentialSource == "user" || len(b.UserEnv) > 0 || b.UserCredentialType != nil || b.UserHostScope != nil disabledByContext := false for _, scope := range scopes { grants, err := s.ListContextGrantsForScope(ctx, scope) @@ -572,9 +610,7 @@ func (s *SQLiteSecureCLIStore) applyContextSecureCLI(ctx context.Context, b *sto continue } if !hasUserCredential { - b.UserEnv = creds.EncryptedEnv - b.UserCredentialType = creds.CredentialType - b.UserHostScope = creds.HostScope + b.SetEffectiveCredential(creds.EncryptedEnv, creds.CredentialType, creds.HostScope, "context", scope.ScopeType+":"+scope.ScopeKey) } else { b.EncryptedEnv = creds.EncryptedEnv } diff --git a/internal/store/sqlitestore/secure_cli_schema_test.go b/internal/store/sqlitestore/secure_cli_schema_test.go index d989d0ec..9a821157 100644 --- a/internal/store/sqlitestore/secure_cli_schema_test.go +++ b/internal/store/sqlitestore/secure_cli_schema_test.go @@ -5,6 +5,7 @@ package sqlitestore import ( "context" "database/sql" + "errors" "strings" "testing" @@ -75,11 +76,11 @@ func TestSQLite_CreateBinary_AdapterNameRoundTrip(t *testing.T) { adapter := "git" bin := &store.SecureCLIBinary{ - BinaryName: "git", - Description: "git with PAT adapter", - IsGlobal: true, - Enabled: true, - CreatedBy: "u-tester", + BinaryName: "git", + Description: "git with PAT adapter", + IsGlobal: true, + Enabled: true, + CreatedBy: "u-tester", AdapterName: &adapter, EncryptedEnv: []byte(`{}`), } @@ -206,11 +207,11 @@ func TestSQLite_LookupByBinary_ProjectsNewColumns(t *testing.T) { adapter := "git" bin := &store.SecureCLIBinary{ - BinaryName: "git", - Description: "git adapter binary", - IsGlobal: true, - Enabled: true, - CreatedBy: "u-tester", + BinaryName: "git", + Description: "git adapter binary", + IsGlobal: true, + Enabled: true, + CreatedBy: "u-tester", AdapterName: &adapter, EncryptedEnv: []byte(`{}`), } @@ -268,3 +269,169 @@ func TestSQLite_LookupByBinary_BackwardCompatibleNulls(t *testing.T) { t.Fatalf("expected UserHostScope NULL, got %q", *got.UserHostScope) } } + +func seedAgent(t *testing.T, db *sql.DB, tenantID uuid.UUID, key string) uuid.UUID { + t.Helper() + id := uuid.New() + _, err := db.Exec( + `INSERT INTO agents (id, tenant_id, agent_key, display_name, owner_id, provider, model, agent_type, status) + VALUES (?, ?, ?, ?, 'owner', 'openai', 'gpt-5', 'predefined', 'active')`, + id, tenantID, key, key, + ) + if err != nil { + t.Fatalf("seed agent %s: %v", key, err) + } + return id +} + +func TestSQLite_SetAgentCredentialsTyped_RoundTrip(t *testing.T) { + s, db := newPhase1Store(t) + tid := seedTenant(t, db, "t-agent-typed") + ctx := store.WithTenantID(context.Background(), tid) + agentID := seedAgent(t, db, tid, "builder") + + seedBinary(t, db, tid, "git", true, true) + var binID uuid.UUID + if err := db.QueryRow(`SELECT id FROM secure_cli_binaries WHERE binary_name = ? AND tenant_id = ?`, "git", tid).Scan(&binID); err != nil { + t.Fatalf("lookup seeded binary: %v", err) + } + + credType := "pat" + hostScope := "github.com" + plaintext := []byte(`{"token":"ghp_agent"}`) + if err := s.SetAgentCredentialsTyped(ctx, binID, agentID, plaintext, &credType, &hostScope, "admin"); err != nil { + t.Fatalf("SetAgentCredentialsTyped: %v", err) + } + + got, err := s.GetAgentCredentials(ctx, binID, agentID) + if err != nil { + t.Fatalf("GetAgentCredentials: %v", err) + } + if got == nil { + t.Fatalf("expected credential, got nil") + } + if got.CredentialType == nil || *got.CredentialType != "pat" { + t.Fatalf("expected credential_type=pat, got %v", got.CredentialType) + } + if got.HostScope == nil || *got.HostScope != "github.com" { + t.Fatalf("expected host_scope=github.com, got %v", got.HostScope) + } + if string(got.EncryptedEnv) != string(plaintext) { + t.Fatalf("decrypted env mismatch: got %q want %q", got.EncryptedEnv, plaintext) + } +} + +func TestSQLite_LookupByBinary_UsesAgentCredentialWithoutUserID(t *testing.T) { + s, db := newPhase1Store(t) + tid := seedTenant(t, db, "t-agent-lookup") + ctx := store.WithTenantID(context.Background(), tid) + agentID := seedAgent(t, db, tid, "builder") + + adapter := "git" + bin := &store.SecureCLIBinary{ + BinaryName: "git", + Description: "git adapter binary", + IsGlobal: true, + Enabled: true, + CreatedBy: "u-tester", + AdapterName: &adapter, + EncryptedEnv: []byte(`{}`), + } + if err := s.Create(ctx, bin); err != nil { + t.Fatalf("Create: %v", err) + } + + credType := "pat" + hostScope := "github.com" + if err := s.SetAgentCredentialsTyped(ctx, bin.ID, agentID, []byte(`{"token":"agent"}`), &credType, &hostScope, "admin"); err != nil { + t.Fatalf("SetAgentCredentialsTyped: %v", err) + } + + got, err := s.LookupByBinary(ctx, "git", &agentID, "") + if err != nil { + t.Fatalf("LookupByBinary: %v", err) + } + if got == nil { + t.Fatalf("expected binary, got nil") + } + if got.CredentialSource != "agent" { + t.Fatalf("expected agent source, got %q", got.CredentialSource) + } + if got.CredentialType == nil || *got.CredentialType != "pat" { + t.Fatalf("expected CredentialType=pat, got %v", got.CredentialType) + } + if string(got.CredentialEnv) != `{"token":"agent"}` { + t.Fatalf("expected agent credential env, got %q", got.CredentialEnv) + } +} + +func TestSQLite_LookupByBinary_UserCredentialOverridesAgentCredential(t *testing.T) { + s, db := newPhase1Store(t) + tid := seedTenant(t, db, "t-user-over-agent") + ctx := store.WithTenantID(context.Background(), tid) + agentID := seedAgent(t, db, tid, "builder") + + adapter := "git" + bin := &store.SecureCLIBinary{ + BinaryName: "git", + Description: "git adapter binary", + IsGlobal: true, + Enabled: true, + CreatedBy: "u-tester", + AdapterName: &adapter, + EncryptedEnv: []byte(`{}`), + } + if err := s.Create(ctx, bin); err != nil { + t.Fatalf("Create: %v", err) + } + + credType := "pat" + hostScope := "github.com" + if err := s.SetAgentCredentialsTyped(ctx, bin.ID, agentID, []byte(`{"token":"agent"}`), &credType, &hostScope, "admin"); err != nil { + t.Fatalf("SetAgentCredentialsTyped: %v", err) + } + if err := s.SetUserCredentialsTyped(ctx, bin.ID, "u-1", []byte(`{"token":"user"}`), &credType, &hostScope); err != nil { + t.Fatalf("SetUserCredentialsTyped: %v", err) + } + + got, err := s.LookupByBinary(ctx, "git", &agentID, "u-1") + if err != nil { + t.Fatalf("LookupByBinary: %v", err) + } + if got == nil { + t.Fatalf("expected binary, got nil") + } + if got.CredentialSource != "user" { + t.Fatalf("expected user source, got %q", got.CredentialSource) + } + if string(got.CredentialEnv) != `{"token":"user"}` { + t.Fatalf("expected user credential env, got %q", got.CredentialEnv) + } +} + +func TestSQLite_SetAgentCredentialsRejectsCrossTenantAgent(t *testing.T) { + s, db := newPhase1Store(t) + tenantA := seedTenant(t, db, "t-agent-cred-a") + tenantB := seedTenant(t, db, "t-agent-cred-b") + ctxA := store.WithTenantID(context.Background(), tenantA) + agentB := seedAgent(t, db, tenantB, "other-tenant-agent") + + bin := &store.SecureCLIBinary{ + BinaryName: "git", + Description: "git adapter binary", + IsGlobal: true, + Enabled: true, + CreatedBy: "u-tester", + EncryptedEnv: []byte(`{}`), + } + if err := s.Create(ctxA, bin); err != nil { + t.Fatalf("Create: %v", err) + } + + credType := "pat" + hostScope := "github.com" + err := s.SetAgentCredentialsTyped(ctxA, bin.ID, agentB, []byte(`{"token":"agent"}`), &credType, &hostScope, "admin") + if !errors.Is(err, sql.ErrNoRows) { + t.Fatalf("expected sql.ErrNoRows for cross-tenant agent, got %v", err) + } +} diff --git a/internal/tools/credential_adapter.go b/internal/tools/credential_adapter.go index a5d9112e..fb95d49e 100644 --- a/internal/tools/credential_adapter.go +++ b/internal/tools/credential_adapter.go @@ -80,8 +80,8 @@ func AdapterFor(name string) CredentialAdapter { // env-injection-only path so every existing preset behaves identically. type passthroughAdapter struct{} -func (passthroughAdapter) Name() string { return "passthrough" } -func (passthroughAdapter) ShouldInject([]string) bool { return false } +func (passthroughAdapter) Name() string { return "passthrough" } +func (passthroughAdapter) ShouldInject([]string) bool { return false } func (passthroughAdapter) Prepare(context.Context, *store.SecureCLIBinary, *store.SecureCLIUserCredential, []string) (*Injection, error) { return &Injection{}, nil } @@ -120,16 +120,18 @@ func sortedKeys(m map[string]string) []string { // - adapter: adapter name (e.g. "git", "passthrough") // - binary: binary name (e.g. "git", "gh") // - user_id: tenant user UUID (or empty for global-only contexts) +// - credential_source: source selected by runtime ("user", "context", "agent", or empty) // - env_keys: sorted env-var NAMES (never values) // - argv_prefix_len: number of argv elements prepended (NOT their content) // - host_scope_hash: SHA-256 first 8 hex chars of host_scope (or "none") -func emitSystemEnvInjectionAudit(adapter, binary, userID string, inj *Injection, hostScope *string) { +func emitSystemEnvInjectionAudit(adapter, binary, userID, credentialSource string, inj *Injection, hostScope *string) { if inj == nil { return } slog.Warn("security.system_env_injection", "adapter", adapter, "user_id", userID, + "credential_source", credentialSource, "binary", binary, "env_keys", sortedKeys(inj.Env), "argv_prefix_len", len(inj.ArgvPrefix), diff --git a/internal/tools/credential_audit_log_test.go b/internal/tools/credential_audit_log_test.go index b878b2d0..b466a63c 100644 --- a/internal/tools/credential_audit_log_test.go +++ b/internal/tools/credential_audit_log_test.go @@ -62,7 +62,7 @@ func TestEmitSystemEnvInjectionAudit_PAT(t *testing.T) { } records, raw := capturedAudit(t, func() { - emitSystemEnvInjectionAudit("git", "git", "user-42", inj, &scope) + emitSystemEnvInjectionAudit("git", "git", "user-42", "agent", inj, &scope) }) if len(records) != 1 { @@ -81,6 +81,9 @@ func TestEmitSystemEnvInjectionAudit_PAT(t *testing.T) { if rec["user_id"] != "user-42" { t.Errorf("user_id=%v, want user-42", rec["user_id"]) } + if rec["credential_source"] != "agent" { + t.Errorf("credential_source=%v, want agent", rec["credential_source"]) + } if rec["argv_prefix_len"] != float64(0) { t.Errorf("argv_prefix_len=%v, want 0", rec["argv_prefix_len"]) } @@ -132,7 +135,7 @@ func TestEmitSystemEnvInjectionAudit_SSH(t *testing.T) { } records, raw := capturedAudit(t, func() { - emitSystemEnvInjectionAudit("git", "git", "u1", inj, &scope) + emitSystemEnvInjectionAudit("git", "git", "u1", "user", inj, &scope) }) if len(records) != 1 { @@ -161,7 +164,7 @@ func TestEmitSystemEnvInjectionAudit_SSH(t *testing.T) { // 3. Nil injection: emitter is a no-op (audit only fires on actual injection). func TestEmitSystemEnvInjectionAudit_NilInjection(t *testing.T) { records, _ := capturedAudit(t, func() { - emitSystemEnvInjectionAudit("git", "git", "u1", nil, nil) + emitSystemEnvInjectionAudit("git", "git", "u1", "user", nil, nil) }) if len(records) != 0 { t.Fatalf("expected 0 records for nil injection, got %d", len(records)) @@ -172,7 +175,7 @@ func TestEmitSystemEnvInjectionAudit_NilInjection(t *testing.T) { func TestEmitSystemEnvInjectionAudit_NilHostScope(t *testing.T) { inj := &Injection{Env: map[string]string{"FOO": "bar"}} records, _ := capturedAudit(t, func() { - emitSystemEnvInjectionAudit("passthrough", "gh", "u1", inj, nil) + emitSystemEnvInjectionAudit("passthrough", "gh", "u1", "", inj, nil) }) if len(records) != 1 { t.Fatalf("expected 1 record, got %d", len(records)) diff --git a/internal/tools/credentialed_exec.go b/internal/tools/credentialed_exec.go index 200732e3..916e0a36 100644 --- a/internal/tools/credentialed_exec.go +++ b/internal/tools/credentialed_exec.go @@ -490,14 +490,12 @@ func (t *ExecTool) executeCredentialed(ctx context.Context, cred *store.SecureCL if len(inj.ArgvPrefix) > 0 { args = append(append([]string{}, inj.ArgvPrefix...), args...) } - for k, v := range inj.Env { - envMap[k] = v - } + maps.Copy(envMap, inj.Env) if len(inj.ScrubValues) > 0 { AddScrubValuesCtx(ctx, inj.ScrubValues...) } emitSystemEnvInjectionAudit(adapter.Name(), binary, - store.CredentialUserIDFromContext(ctx), inj, cred.UserHostScope) + store.CredentialUserIDFromContext(ctx), cred.CredentialSource, inj, effectiveHostScope(cred)) } } @@ -508,13 +506,23 @@ func (t *ExecTool) executeCredentialed(ctx context.Context, cred *store.SecureCL return t.executeCredentialedHost(ctx, absPath, args, cwd, envMap, timeout) } -// userCredFromBinary synthesizes a *SecureCLIUserCredential from the fields -// LookupByBinary's LEFT JOIN populated on the binary row. Returns nil when -// no user credential exists (UserEnv empty + no typed metadata). +// userCredFromBinary synthesizes adapter credential input from LookupByBinary's +// effective credential fields. The adapter contract still uses +// SecureCLIUserCredential, but the material can come from user, context, or +// agent scoped rows. func userCredFromBinary(ctx context.Context, bin *store.SecureCLIBinary) *store.SecureCLIUserCredential { if bin == nil { return nil } + if len(bin.CredentialEnv) > 0 || bin.CredentialType != nil || bin.CredentialHostScope != nil { + return &store.SecureCLIUserCredential{ + BinaryID: bin.ID, + UserID: credentialSubjectForAdapter(ctx, bin), + EncryptedEnv: bin.CredentialEnv, + CredentialType: bin.CredentialType, + HostScope: bin.CredentialHostScope, + } + } if len(bin.UserEnv) == 0 && bin.UserCredentialType == nil && bin.UserHostScope == nil { return nil } @@ -527,6 +535,23 @@ func userCredFromBinary(ctx context.Context, bin *store.SecureCLIBinary) *store. } } +func credentialSubjectForAdapter(ctx context.Context, bin *store.SecureCLIBinary) string { + if bin != nil && bin.CredentialSubjectID != "" { + return bin.CredentialSubjectID + } + return store.CredentialUserIDFromContext(ctx) +} + +func effectiveHostScope(bin *store.SecureCLIBinary) *string { + if bin == nil { + return nil + } + if bin.CredentialHostScope != nil { + return bin.CredentialHostScope + } + return bin.UserHostScope +} + func mergeCredentialedEnv(cred *store.SecureCLIBinary) (map[string]string, error) { envMap := make(map[string]string) if cred == nil { @@ -539,7 +564,18 @@ func mergeCredentialedEnv(cred *store.SecureCLIBinary) (map[string]string, error } maps.Copy(envMap, baseEnv) } + if len(cred.CredentialEnv) > 0 && isEnvCredentialType(cred.CredentialType) { + scopedEnv, err := store.FlattenSecureCLIEnv(cred.CredentialEnv) + if err != nil { + return nil, err + } + maps.Copy(envMap, scopedEnv) + return envMap, nil + } if len(cred.UserEnv) > 0 { + if !isEnvCredentialType(cred.UserCredentialType) { + return envMap, nil + } userEnvMap, err := store.FlattenSecureCLIEnv(cred.UserEnv) if err != nil { return nil, err @@ -549,6 +585,10 @@ func mergeCredentialedEnv(cred *store.SecureCLIBinary) (map[string]string, error return envMap, nil } +func isEnvCredentialType(typ *string) bool { + return typ == nil || *typ == "" || *typ == "env" +} + func missingRequiredCredentialEnv(binary string, envMap map[string]string) []string { required := requiredCredentialEnvVars(binary) if len(required) == 0 { diff --git a/internal/tools/credentialed_exec_env_test.go b/internal/tools/credentialed_exec_env_test.go index 7186d15e..daaca6d7 100644 --- a/internal/tools/credentialed_exec_env_test.go +++ b/internal/tools/credentialed_exec_env_test.go @@ -73,6 +73,44 @@ func TestMergeCredentialedEnvFlattensSensitiveValueEntries(t *testing.T) { } } +func TestMergeCredentialedEnvUsesAgentEnvCredential(t *testing.T) { + typ := "env" + binary := &store.SecureCLIBinary{ + EncryptedEnv: []byte(`{"SHARED_KEY":"binary","BINARY_ONLY":"base"}`), + } + binary.SetEffectiveCredential([]byte(`{"SHARED_KEY":"agent","AGENT_ONLY":"scoped"}`), &typ, nil, "agent", "") + + env, err := mergeCredentialedEnv(binary) + if err != nil { + t.Fatalf("mergeCredentialedEnv() error = %v", err) + } + if env["SHARED_KEY"] != "agent" { + t.Fatalf("SHARED_KEY = %q, want agent override", env["SHARED_KEY"]) + } + if env["AGENT_ONLY"] != "scoped" { + t.Fatalf("AGENT_ONLY = %q", env["AGENT_ONLY"]) + } +} + +func TestMergeCredentialedEnvDoesNotFlattenTypedCredentialBlob(t *testing.T) { + typ := "pat" + binary := &store.SecureCLIBinary{ + EncryptedEnv: []byte(`{"PUBLIC_BASE_URL":"https://goclaw.sh"}`), + } + binary.SetEffectiveCredential([]byte(`{"token":"ghp_not_real_token"}`), &typ, nil, "agent", "") + + env, err := mergeCredentialedEnv(binary) + if err != nil { + t.Fatalf("mergeCredentialedEnv() error = %v", err) + } + if _, ok := env["token"]; ok { + t.Fatalf("typed credential blob was flattened into child env: %#v", env) + } + if env["PUBLIC_BASE_URL"] != "https://goclaw.sh" { + t.Fatalf("PUBLIC_BASE_URL = %q", env["PUBLIC_BASE_URL"]) + } +} + func TestExec_RapidAPIMissingRequiredEnvFailsBeforeBinaryResolution(t *testing.T) { stub := newStubSecureCLIStore() stub.byName["rapidapi"] = &store.SecureCLIBinary{ diff --git a/internal/upgrade/version.go b/internal/upgrade/version.go index af197a28..36f249f9 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 = 76 +const RequiredSchemaVersion uint = 77 diff --git a/migrations/000077_secure_cli_agent_credentials.down.sql b/migrations/000077_secure_cli_agent_credentials.down.sql new file mode 100644 index 00000000..a57de9cf --- /dev/null +++ b/migrations/000077_secure_cli_agent_credentials.down.sql @@ -0,0 +1,3 @@ +DROP TABLE IF EXISTS secure_cli_agent_credentials; +DROP INDEX IF EXISTS idx_agents_id_tenant; +DROP INDEX IF EXISTS idx_secure_cli_binaries_id_tenant; diff --git a/migrations/000077_secure_cli_agent_credentials.up.sql b/migrations/000077_secure_cli_agent_credentials.up.sql new file mode 100644 index 00000000..fab74867 --- /dev/null +++ b/migrations/000077_secure_cli_agent_credentials.up.sql @@ -0,0 +1,29 @@ +-- Per-agent credentials for secure CLI binaries. +-- Stores typed secret material separately from secure_cli_agent_grants policy. +CREATE UNIQUE INDEX IF NOT EXISTS idx_secure_cli_binaries_id_tenant + ON secure_cli_binaries(id, tenant_id); +CREATE UNIQUE INDEX IF NOT EXISTS idx_agents_id_tenant + ON agents(id, tenant_id); + +CREATE TABLE secure_cli_agent_credentials ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + binary_id UUID NOT NULL, + agent_id UUID NOT NULL, + encrypted_env BYTEA NOT NULL, + metadata JSONB NOT NULL DEFAULT '{}', + tenant_id UUID NOT NULL REFERENCES tenants(id), + credential_type TEXT NULL, + host_scope TEXT NULL, + created_by VARCHAR(255) NOT NULL DEFAULT '', + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + UNIQUE(binary_id, agent_id, tenant_id), + CONSTRAINT fk_scac_binary_tenant + FOREIGN KEY (binary_id, tenant_id) REFERENCES secure_cli_binaries(id, tenant_id) ON DELETE CASCADE, + CONSTRAINT fk_scac_agent_tenant + FOREIGN KEY (agent_id, tenant_id) REFERENCES agents(id, tenant_id) ON DELETE CASCADE +); + +CREATE INDEX idx_scac_tenant ON secure_cli_agent_credentials(tenant_id); +CREATE INDEX idx_scac_binary ON secure_cli_agent_credentials(binary_id); +CREATE INDEX idx_scac_agent ON secure_cli_agent_credentials(agent_id); diff --git a/plans/260531-1545-agent-scoped-git-credentials/phase-01-research-and-contract-tests.md b/plans/260531-1545-agent-scoped-git-credentials/phase-01-research-and-contract-tests.md index a1b76117..0a139101 100644 --- a/plans/260531-1545-agent-scoped-git-credentials/phase-01-research-and-contract-tests.md +++ b/plans/260531-1545-agent-scoped-git-credentials/phase-01-research-and-contract-tests.md @@ -1,8 +1,8 @@ --- phase: 1 -title: "Research and contract tests" -status: pending -effort: "" +title: Research and contract tests +status: completed +effort: '' --- # Phase 1: Research and contract tests diff --git a/plans/260531-1545-agent-scoped-git-credentials/phase-02-schema-and-store-resolver.md b/plans/260531-1545-agent-scoped-git-credentials/phase-02-schema-and-store-resolver.md index 088bc698..f67aff06 100644 --- a/plans/260531-1545-agent-scoped-git-credentials/phase-02-schema-and-store-resolver.md +++ b/plans/260531-1545-agent-scoped-git-credentials/phase-02-schema-and-store-resolver.md @@ -1,8 +1,8 @@ --- phase: 2 -title: "Schema and store resolver" -status: pending -effort: "" +title: Schema and store resolver +status: completed +effort: '' --- # Phase 2: Schema and store resolver diff --git a/plans/260531-1545-agent-scoped-git-credentials/phase-03-http-api-credential-management.md b/plans/260531-1545-agent-scoped-git-credentials/phase-03-http-api-credential-management.md index da29401b..898a7953 100644 --- a/plans/260531-1545-agent-scoped-git-credentials/phase-03-http-api-credential-management.md +++ b/plans/260531-1545-agent-scoped-git-credentials/phase-03-http-api-credential-management.md @@ -1,8 +1,8 @@ --- phase: 3 -title: "HTTP API credential management" -status: pending -effort: "" +title: HTTP API credential management +status: completed +effort: '' --- # Phase 3: HTTP API credential management diff --git a/plans/260531-1545-agent-scoped-git-credentials/phase-04-web-ui-credential-management.md b/plans/260531-1545-agent-scoped-git-credentials/phase-04-web-ui-credential-management.md index 3d6368c4..985b8531 100644 --- a/plans/260531-1545-agent-scoped-git-credentials/phase-04-web-ui-credential-management.md +++ b/plans/260531-1545-agent-scoped-git-credentials/phase-04-web-ui-credential-management.md @@ -1,8 +1,8 @@ --- phase: 4 -title: "Web UI credential management" -status: pending -effort: "" +title: Web UI credential management +status: completed +effort: '' --- # Phase 4: Web UI credential management diff --git a/plans/260531-1545-agent-scoped-git-credentials/phase-05-runtime-git-adapter-validation.md b/plans/260531-1545-agent-scoped-git-credentials/phase-05-runtime-git-adapter-validation.md index 28b209bb..fce45f32 100644 --- a/plans/260531-1545-agent-scoped-git-credentials/phase-05-runtime-git-adapter-validation.md +++ b/plans/260531-1545-agent-scoped-git-credentials/phase-05-runtime-git-adapter-validation.md @@ -1,8 +1,8 @@ --- phase: 5 -title: "Runtime git adapter validation" -status: pending -effort: "" +title: Runtime git adapter validation +status: completed +effort: '' --- # Phase 5: Runtime git adapter validation diff --git a/plans/260531-1545-agent-scoped-git-credentials/phase-06-docs-validation-and-handoff.md b/plans/260531-1545-agent-scoped-git-credentials/phase-06-docs-validation-and-handoff.md index 3cb5e7e2..b634b4ac 100644 --- a/plans/260531-1545-agent-scoped-git-credentials/phase-06-docs-validation-and-handoff.md +++ b/plans/260531-1545-agent-scoped-git-credentials/phase-06-docs-validation-and-handoff.md @@ -1,8 +1,8 @@ --- phase: 6 -title: "Docs validation and handoff" -status: pending -effort: "" +title: Docs validation and handoff +status: completed +effort: '' --- # Phase 6: Docs validation and handoff diff --git a/plans/260531-1545-agent-scoped-git-credentials/plan.md b/plans/260531-1545-agent-scoped-git-credentials/plan.md index aacfd4b6..7ea87305 100644 --- a/plans/260531-1545-agent-scoped-git-credentials/plan.md +++ b/plans/260531-1545-agent-scoped-git-credentials/plan.md @@ -1,15 +1,17 @@ --- -title: "Agent-scoped Git credentials" -description: "TDD plan for moving git typed credentials from channel/user-id keyed defaults to agent-scoped credentials, with HTTP API and Web UI management." -status: pending +title: Agent-scoped Git credentials +description: >- + TDD plan for moving git typed credentials from channel/user-id keyed defaults + to agent-scoped credentials, with HTTP API and Web UI management. +status: completed priority: P1 issue: 117 -branch: "codex/issue-117-agent-scoped-git-credentials-plan" +branch: codex/issue-117-agent-scoped-git-credentials-plan tags: [] blockedBy: [] blocks: [] -created: "2026-05-31T08:45:33.071Z" -createdBy: "ck:plan" +created: '2026-05-31T08:45:33.071Z' +createdBy: 'ck:plan' source: skill --- @@ -33,12 +35,12 @@ TDD target: | Phase | Name | Status | |-------|------|--------| -| 1 | [Research and contract tests](./phase-01-research-and-contract-tests.md) | Pending | -| 2 | [Schema and store resolver](./phase-02-schema-and-store-resolver.md) | Pending | -| 3 | [HTTP API credential management](./phase-03-http-api-credential-management.md) | Pending | -| 4 | [Web UI credential management](./phase-04-web-ui-credential-management.md) | Pending | -| 5 | [Runtime git adapter validation](./phase-05-runtime-git-adapter-validation.md) | Pending | -| 6 | [Docs validation and handoff](./phase-06-docs-validation-and-handoff.md) | Pending | +| 1 | [Research and contract tests](./phase-01-research-and-contract-tests.md) | Completed | +| 2 | [Schema and store resolver](./phase-02-schema-and-store-resolver.md) | Completed | +| 3 | [HTTP API credential management](./phase-03-http-api-credential-management.md) | Completed | +| 4 | [Web UI credential management](./phase-04-web-ui-credential-management.md) | Completed | +| 5 | [Runtime git adapter validation](./phase-05-runtime-git-adapter-validation.md) | Completed | +| 6 | [Docs validation and handoff](./phase-06-docs-validation-and-handoff.md) | Completed | ## Dependencies diff --git a/ui/web/src/i18n/locales/en/cli-credentials.json b/ui/web/src/i18n/locales/en/cli-credentials.json index 9f73cd5e..77f53bcb 100644 --- a/ui/web/src/i18n/locales/en/cli-credentials.json +++ b/ui/web/src/i18n/locales/en/cli-credentials.json @@ -70,9 +70,27 @@ "deleted": "CLI credential deleted", "deleteFailed": "Failed to delete credential" }, + "agentCredentials": { + "title": "Agent Credentials", + "description": "Configure PAT, SSH key, or env credentials that {{name}} can use when a selected agent runs it.", + "securityHint": "Anyone with access to the selected agent can cause that agent to use this credential.", + "agent": "Agent", + "env": "Environment Variables", + "add": "Add Agent Credential", + "save": "Save", + "edit": "Edit", + "delete": "Delete", + "back": "Back", + "empty": "No agent credentials configured", + "saved": "Agent credentials saved", + "saveFailed": "Failed to save agent credentials", + "deleted": "Agent credentials deleted", + "deleteFailed": "Failed to delete agent credentials", + "envRequired": "At least one environment variable is required" + }, "userCredentials": { - "title": "User Credentials", - "description": "Per-user environment variable overrides for {{name}}", + "title": "Advanced User Overrides", + "description": "Optional per-user overrides for {{name}}. Use Agent Credentials for normal git PAT or SSH setup.", "userId": "User ID", "userIdPlaceholder": "user-id or email", "env": "Environment Variables", @@ -89,7 +107,7 @@ "deleted": "User credentials deleted", "deleteFailed": "Failed to delete user credentials", "envRequired": "At least one environment variable is required", - "mergeHint": "Chat users (Telegram, Discord, etc.) must be merged into a tenant user first via Contacts page before they can have per-user credentials.", + "mergeHint": "Use only for personal overrides. Chat users (Telegram, Discord, etc.) must be merged into a tenant user first via Contacts page.", "credentialType": "Credential Type", "credentialTypeEnv": "Environment Variables (legacy)", "credentialTypePAT": "Personal Access Token (PAT)", diff --git a/ui/web/src/i18n/locales/vi/cli-credentials.json b/ui/web/src/i18n/locales/vi/cli-credentials.json index 8538ccd2..76f65515 100644 --- a/ui/web/src/i18n/locales/vi/cli-credentials.json +++ b/ui/web/src/i18n/locales/vi/cli-credentials.json @@ -70,9 +70,27 @@ "deleted": "Đã xóa thông tin CLI", "deleteFailed": "Không thể xóa thông tin" }, + "agentCredentials": { + "title": "Credential theo agent", + "description": "Cấu hình PAT, khóa SSH hoặc biến môi trường để {{name}} dùng khi agent được chọn chạy lệnh.", + "securityHint": "Bất kỳ ai có quyền dùng agent được chọn đều có thể khiến agent đó dùng credential này.", + "agent": "Agent", + "env": "Biến môi trường", + "add": "Thêm credential agent", + "save": "Lưu", + "edit": "Sửa", + "delete": "Xóa", + "back": "Quay lại", + "empty": "Chưa có credential theo agent", + "saved": "Đã lưu credential agent", + "saveFailed": "Không thể lưu credential agent", + "deleted": "Đã xóa credential agent", + "deleteFailed": "Không thể xóa credential agent", + "envRequired": "Cần ít nhất một biến môi trường" + }, "userCredentials": { - "title": "Thông tin người dùng", - "description": "Ghi đè biến môi trường cho từng người dùng của {{name}}", + "title": "Ghi đè người dùng nâng cao", + "description": "Ghi đè tùy chọn theo người dùng cho {{name}}. Dùng Credential theo agent cho thiết lập git PAT hoặc SSH thông thường.", "userId": "ID người dùng", "userIdPlaceholder": "user-id hoặc email", "env": "Biến môi trường", @@ -89,7 +107,7 @@ "deleted": "Đã xóa thông tin người dùng", "deleteFailed": "Không thể xóa thông tin người dùng", "envRequired": "Cần ít nhất một biến môi trường", - "mergeHint": "Người dùng chat (Telegram, Discord...) cần được gộp vào tenant user qua trang Contacts trước khi có thể thiết lập credentials riêng.", + "mergeHint": "Chỉ dùng cho ghi đè cá nhân. Người dùng chat (Telegram, Discord...) cần được gộp vào tenant user qua trang Contacts trước.", "credentialType": "Loại credential", "credentialTypeEnv": "Biến môi trường (cũ)", "credentialTypePAT": "Personal Access Token (PAT)", diff --git a/ui/web/src/i18n/locales/zh/cli-credentials.json b/ui/web/src/i18n/locales/zh/cli-credentials.json index 2709e969..fee0f689 100644 --- a/ui/web/src/i18n/locales/zh/cli-credentials.json +++ b/ui/web/src/i18n/locales/zh/cli-credentials.json @@ -70,9 +70,27 @@ "deleted": "CLI 凭证已删除", "deleteFailed": "删除凭证失败" }, + "agentCredentials": { + "title": "代理凭证", + "description": "配置所选代理运行 {{name}} 时使用的 PAT、SSH 密钥或环境变量凭证。", + "securityHint": "任何可访问所选代理的用户都可以让该代理使用此凭证。", + "agent": "代理", + "env": "环境变量", + "add": "添加代理凭证", + "save": "保存", + "edit": "编辑", + "delete": "删除", + "back": "返回", + "empty": "未配置代理凭证", + "saved": "代理凭证已保存", + "saveFailed": "保存代理凭证失败", + "deleted": "代理凭证已删除", + "deleteFailed": "删除代理凭证失败", + "envRequired": "至少需要一个环境变量" + }, "userCredentials": { - "title": "用户凭证", - "description": "{{name}} 的每用户环境变量覆盖", + "title": "高级用户覆盖", + "description": "{{name}} 的可选每用户覆盖。常规 git PAT 或 SSH 设置请使用代理凭证。", "userId": "用户 ID", "userIdPlaceholder": "用户 ID 或邮箱", "env": "环境变量", @@ -89,7 +107,7 @@ "deleted": "用户凭证已删除", "deleteFailed": "删除用户凭证失败", "envRequired": "至少需要一个环境变量", - "mergeHint": "聊天用户(Telegram、Discord 等)需要先通过联系人页面合并为租户用户,才能设置独立凭证。", + "mergeHint": "仅用于个人覆盖。聊天用户(Telegram、Discord 等)需要先通过联系人页面合并为租户用户。", "credentialType": "凭证类型", "credentialTypeEnv": "环境变量(旧)", "credentialTypePAT": "个人访问令牌 (PAT)", diff --git a/ui/web/src/pages/cli-credentials/__tests__/cli-agent-credentials-dialog-helpers.test.ts b/ui/web/src/pages/cli-credentials/__tests__/cli-agent-credentials-dialog-helpers.test.ts new file mode 100644 index 00000000..22a4b0b2 --- /dev/null +++ b/ui/web/src/pages/cli-credentials/__tests__/cli-agent-credentials-dialog-helpers.test.ts @@ -0,0 +1,56 @@ +import { describe, expect, it } from "vitest"; +import { buildAgentCredentialPayload } from "../cli-agent-credentials-dialog-helpers"; + +describe("agent credential payload builder", () => { + it("builds a PAT payload for git agent credentials", () => { + const result = buildAgentCredentialPayload({ + isGit: true, + type: "pat", + hostScope: " github.com ", + token: "ghp_test", + privateKey: "", + hasExistingSecret: false, + envEntries: [], + isNewEntry: true, + }); + + expect(result).toEqual({ + kind: "typed", + payload: { + credential_type: "pat", + host_scope: "github.com", + blob: { token: "ghp_test" }, + }, + }); + }); + + it("keeps an existing typed secret when edit form has no replacement secret", () => { + const result = buildAgentCredentialPayload({ + isGit: true, + type: "ssh_key", + hostScope: "git.example.com:2222", + token: "", + privateKey: "", + hasExistingSecret: true, + envEntries: [], + isNewEntry: false, + }); + + expect(result).toEqual({ kind: "no_change" }); + }); + + it("requires env vars for a new env credential", () => { + const result = buildAgentCredentialPayload({ + isGit: false, + type: "env", + hostScope: "", + token: "", + privateKey: "", + hasExistingSecret: false, + envEntries: [], + isNewEntry: true, + }); + + expect(result).toEqual({ kind: "error", errorKey: "env_required" }); + }); +}); diff --git a/ui/web/src/pages/cli-credentials/__tests__/cli-agent-credentials-wiring.test.ts b/ui/web/src/pages/cli-credentials/__tests__/cli-agent-credentials-wiring.test.ts new file mode 100644 index 00000000..00301b9d --- /dev/null +++ b/ui/web/src/pages/cli-credentials/__tests__/cli-agent-credentials-wiring.test.ts @@ -0,0 +1,25 @@ +import { describe, expect, it } from "vitest"; +import { readFileSync } from "node:fs"; +import { resolve } from "node:path"; + +function source(path: string): string { + return readFileSync(resolve(process.cwd(), path), "utf8"); +} + +describe("CLI agent credential UI wiring", () => { + it("exposes Agent Credentials as a distinct table action before advanced user overrides", () => { + const table = source("src/pages/cli-credentials/cli-credentials-table.tsx"); + + expect(table).toContain("onAgentCreds"); + expect(table).toContain("agentCredentials.title"); + expect(table.indexOf("onAgentCreds(item)")).toBeLessThan(table.indexOf("onUserCreds(item)")); + }); + + it("mounts the Agent Credentials dialog from the panel", () => { + const panel = source("src/pages/cli-credentials/cli-credentials-panel.tsx"); + + expect(panel).toContain("cli-agent-credentials-dialog"); + expect(panel).toContain("CLIAgentCredentialsDialog"); + expect(panel).toContain("agentCredsTarget"); + }); +}); diff --git a/ui/web/src/pages/cli-credentials/__tests__/use-cli-credentials.test.ts b/ui/web/src/pages/cli-credentials/__tests__/use-cli-credentials.test.ts index ea3349cd..ab63c93b 100644 --- a/ui/web/src/pages/cli-credentials/__tests__/use-cli-credentials.test.ts +++ b/ui/web/src/pages/cli-credentials/__tests__/use-cli-credentials.test.ts @@ -1,4 +1,6 @@ import { describe, expect, it } from "vitest"; +import { readFileSync } from "node:fs"; +import { resolve } from "node:path"; import { normalizeCliPreset, normalizeCliPresets } from "../hooks/use-cli-credentials"; describe("cli credential preset normalization", () => { @@ -30,4 +32,15 @@ describe("cli credential preset normalization", () => { expect(normalizeCliPresets(null)).toEqual({}); expect(normalizeCliPresets(undefined)).toEqual({}); }); + + it("keeps agent credential API calls on the agent-credentials endpoint family", () => { + const source = readFileSync( + resolve(process.cwd(), "src/pages/cli-credentials/hooks/use-cli-agent-credentials.ts"), + "utf8", + ); + + expect(source).toContain("/v1/cli-credentials/${binaryId}/agent-credentials"); + expect(source).toContain("/v1/cli-credentials/${binaryId}/agent-credentials/${agentId}"); + expect(source).toContain("useCliAgentCredentials"); + }); }); diff --git a/ui/web/src/pages/cli-credentials/cli-agent-credential-form.tsx b/ui/web/src/pages/cli-credentials/cli-agent-credential-form.tsx new file mode 100644 index 00000000..9d9d957c --- /dev/null +++ b/ui/web/src/pages/cli-credentials/cli-agent-credential-form.tsx @@ -0,0 +1,84 @@ +import { useTranslation } from "react-i18next"; +import type { Dispatch, SetStateAction } from "react"; +import { Label } from "@/components/ui/label"; +import { + Select, SelectContent, SelectItem, SelectTrigger, SelectValue, +} from "@/components/ui/select"; +import { CliCredentialEnvVarsSection, type ManualEnvEntry } from "./cli-credential-env-vars-section"; +import { CliCredentialGitFields, type GitCredentialType } from "./cli-credential-git-fields"; +import type { AgentData } from "@/types/agent"; +import type { SecureCLIBinary } from "./hooks/use-cli-credentials"; + +interface Props { + binary: SecureCLIBinary; + agents: AgentData[]; + agentId: string; + setAgentId: (v: string) => void; + editing: boolean; + envEntries: ManualEnvEntry[]; + setEnvEntries: Dispatch>; + gitType: GitCredentialType; + setGitType: (v: GitCredentialType) => void; + gitHostScope: string; + setGitHostScope: (v: string) => void; + gitToken: string; + setGitToken: (v: string) => void; + gitPrivateKey: string; + setGitPrivateKey: (v: string) => void; + gitErrorKey?: string; + gitHasExistingSecret: boolean; +} + +export function CliAgentCredentialForm(props: Props) { + const { t } = useTranslation("cli-credentials"); + const isGit = props.binary.adapter_name === "git"; + + return ( +
+
+ + +
+ + {isGit ? ( + + ) : null} + + {(!isGit || props.gitType === "env") ? ( +
+ + undefined} + manualEnvEntries={props.envEntries} + setManualEnvEntries={props.setEnvEntries} + /> +
+ ) : null} +
+ ); +} diff --git a/ui/web/src/pages/cli-credentials/cli-agent-credential-list.tsx b/ui/web/src/pages/cli-credentials/cli-agent-credential-list.tsx new file mode 100644 index 00000000..564f10d9 --- /dev/null +++ b/ui/web/src/pages/cli-credentials/cli-agent-credential-list.tsx @@ -0,0 +1,62 @@ +import { useTranslation } from "react-i18next"; +import { Loader2, Pencil, Trash2 } from "lucide-react"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import type { CLIAgentCredential } from "./hooks/use-cli-agent-credentials"; + +interface Props { + entries: CLIAgentCredential[]; + agentNameMap: Map; + deleting: string | null; + onEdit: (entry: CLIAgentCredential) => void; + onDelete: (entry: CLIAgentCredential) => void; +} + +export function CliAgentCredentialList({ entries, agentNameMap, deleting, onEdit, onDelete }: Props) { + const { t } = useTranslation("cli-credentials"); + + const credentialLabel = (entry: CLIAgentCredential) => { + if (entry.credential_type === "pat") return t("userCredentials.credentialTypePAT"); + if (entry.credential_type === "ssh_key") return t("userCredentials.credentialTypeSSH"); + return entry.has_secret ? "env" : t("userCredentials.noSecret"); + }; + + return ( +
+ {entries.map((entry) => ( +
+
+
+ + {entry.name || agentNameMap.get(entry.agent_id) || entry.agent_key || entry.agent_id} + + {credentialLabel(entry)} +
+ {entry.host_scope ? ( +

{entry.host_scope}

+ ) : null} +
+
+ + +
+
+ ))} +
+ ); +} diff --git a/ui/web/src/pages/cli-credentials/cli-agent-credentials-dialog-helpers.ts b/ui/web/src/pages/cli-credentials/cli-agent-credentials-dialog-helpers.ts new file mode 100644 index 00000000..07fa9400 --- /dev/null +++ b/ui/web/src/pages/cli-credentials/cli-agent-credentials-dialog-helpers.ts @@ -0,0 +1,53 @@ +import type { CLIEnvEntryResponse, CLIEnvPayload } from "@/types/cli-credential"; +import type { ManualEnvEntry } from "./cli-credential-env-vars-section"; +import type { GitCredentialType } from "./cli-credential-git-fields"; + +export function entriesFromEnv(env: Record | null | undefined): ManualEnvEntry[] { + if (!env || Object.keys(env).length === 0) return []; + return Object.entries(env).map(([key, entry]) => ({ + key, + value: entry.value ?? "", + kind: entry.kind ?? "sensitive", + })); +} + +export function envPayloadFromEntries(entries: ManualEnvEntry[]): CLIEnvPayload { + const env: CLIEnvPayload = {}; + for (const entry of entries) { + const key = entry.key.trim(); + if (key) env[key] = { kind: entry.kind, value: entry.value }; + } + return env; +} + +export type AgentCredentialPayloadResult = + | { kind: "typed"; payload: { credential_type: "pat" | "ssh_key"; host_scope: string; blob: Record } } + | { kind: "env"; payload: { env: CLIEnvPayload } } + | { kind: "no_change" } + | { kind: "error"; errorKey: string }; + +export function buildAgentCredentialPayload(input: { + isGit: boolean; + type: GitCredentialType; + hostScope: string; + token: string; + privateKey: string; + hasExistingSecret: boolean; + envEntries: ManualEnvEntry[]; + isNewEntry: boolean; +}): AgentCredentialPayloadResult { + const { isGit, type, hostScope, token, privateKey, hasExistingSecret, envEntries, isNewEntry } = input; + if (isGit && type !== "env") { + const scope = hostScope.trim(); + if (!scope) return { kind: "error", errorKey: "git.cred_host_scope_required" }; + if (type === "pat") { + if (!token) return hasExistingSecret ? { kind: "no_change" } : { kind: "error", errorKey: "git.cred_blob_missing_token" }; + return { kind: "typed", payload: { credential_type: "pat", host_scope: scope, blob: { token } } }; + } + if (!privateKey.trim()) return hasExistingSecret ? { kind: "no_change" } : { kind: "error", errorKey: "git.cred_blob_missing_key" }; + return { kind: "typed", payload: { credential_type: "ssh_key", host_scope: scope, blob: { key: privateKey } } }; + } + const env = envPayloadFromEntries(envEntries); + if (isNewEntry && Object.keys(env).length === 0) return { kind: "error", errorKey: "env_required" }; + return { kind: "env", payload: { env } }; +} diff --git a/ui/web/src/pages/cli-credentials/cli-agent-credentials-dialog.tsx b/ui/web/src/pages/cli-credentials/cli-agent-credentials-dialog.tsx new file mode 100644 index 00000000..ddcc29ea --- /dev/null +++ b/ui/web/src/pages/cli-credentials/cli-agent-credentials-dialog.tsx @@ -0,0 +1,199 @@ +import { useEffect, useMemo, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { Bot, Loader2, Plus } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog"; +import { useAgents } from "@/pages/agents/hooks/use-agents"; +import { toast } from "@/stores/use-toast-store"; +import { type ManualEnvEntry } from "./cli-credential-env-vars-section"; +import { CliAgentCredentialForm } from "./cli-agent-credential-form"; +import { CliAgentCredentialList } from "./cli-agent-credential-list"; +import { + buildAgentCredentialPayload, + entriesFromEnv, +} from "./cli-agent-credentials-dialog-helpers"; +import { type GitCredentialType } from "./cli-credential-git-fields"; +import { useCliAgentCredentials, type CLIAgentCredential } from "./hooks/use-cli-agent-credentials"; +import type { SecureCLIBinary } from "./hooks/use-cli-credentials"; + +interface Props { + open: boolean; + onOpenChange: (open: boolean) => void; + binary: SecureCLIBinary; +} + +type ViewState = "list" | "form"; + +export function CLIAgentCredentialsDialog({ open, onOpenChange, binary }: Props) { + const { t } = useTranslation("cli-credentials"); + const { t: tc } = useTranslation("common"); + const { agents } = useAgents(); + const { agentCredentials, loading, getCredential, setCredential, deleteCredential } = useCliAgentCredentials(binary.id); + const isGit = binary.adapter_name === "git"; + + const [view, setView] = useState("list"); + const [editEntry, setEditEntry] = useState(null); + const [agentId, setAgentId] = useState(""); + const [envEntries, setEnvEntries] = useState([]); + const [saving, setSaving] = useState(false); + const [deleting, setDeleting] = useState(null); + const [gitType, setGitType] = useState("pat"); + const [gitHostScope, setGitHostScope] = useState("github.com"); + const [gitToken, setGitToken] = useState(""); + const [gitPrivateKey, setGitPrivateKey] = useState(""); + const [gitErrorKey, setGitErrorKey] = useState(); + const [gitHasExistingSecret, setGitHasExistingSecret] = useState(false); + + const agentNameMap = useMemo(() => { + const map = new Map(); + for (const a of agents) map.set(a.id, a.display_name || a.agent_key); + return map; + }, [agents]); + + const clearForm = () => { + setEditEntry(null); + setAgentId(""); + setEnvEntries([]); + setGitType(isGit ? "pat" : "env"); + setGitHostScope(isGit ? "github.com" : ""); + setGitToken(""); + setGitPrivateKey(""); + setGitErrorKey(undefined); + setGitHasExistingSecret(false); + }; + + useEffect(() => { + if (!open) { + clearForm(); + return; + } + setView("list"); + clearForm(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [open, binary.id]); + + const openAdd = () => { + clearForm(); + setView("form"); + }; + + const openEdit = async (entry: CLIAgentCredential) => { + setEditEntry(entry); + setAgentId(entry.agent_id); + setGitErrorKey(undefined); + setView("form"); + try { + const res = await getCredential(entry.agent_id); + setEnvEntries(entriesFromEnv(res.env)); + if (isGit) { + const nextType = (res.credential_type ?? "env") as GitCredentialType; + setGitType(nextType === "pat" || nextType === "ssh_key" ? nextType : "env"); + setGitHostScope(res.host_scope ?? "github.com"); + setGitHasExistingSecret(!!res.has_secret); + setGitToken(""); + setGitPrivateKey(""); + } + } catch { + setEnvEntries([]); + } + }; + + const handleSave = async () => { + if (!agentId) return; + setGitErrorKey(undefined); + const result = buildAgentCredentialPayload({ + isGit, + type: gitType, + hostScope: gitHostScope, + token: gitToken, + privateKey: gitPrivateKey, + hasExistingSecret: gitHasExistingSecret, + envEntries, + isNewEntry: editEntry === null, + }); + if (result.kind === "error") { + if (result.errorKey === "env_required") toast.error(t("agentCredentials.envRequired")); + else setGitErrorKey(result.errorKey); + return; + } + if (result.kind === "no_change") { + toast.success(t("agentCredentials.saved")); + setView("list"); + return; + } + setSaving(true); + try { + await setCredential(agentId, result.payload); + clearForm(); + setView("list"); + } catch (err) { + const code = (err as { code?: string })?.code; + if (code?.startsWith("git.cred_")) setGitErrorKey(code); + } finally { + setSaving(false); + } + }; + + const handleDelete = async (entry: CLIAgentCredential) => { + setDeleting(entry.agent_id); + try { + await deleteCredential(entry.agent_id); + } finally { + setDeleting(null); + } + }; + + return ( + + + + {t("agentCredentials.title")} + {t("agentCredentials.description", { name: binary.binary_name })} + +
+ {view === "list" ? ( + <> +

{t("agentCredentials.securityHint")}

+ {loading ?

{tc("loading")}

: null} + {agentCredentials.length === 0 ?

{t("agentCredentials.empty")}

: null} + + + ) : ( + + )} +
+ + {view === "list" ? ( + <> + ) : ( + <> + )} + +
+
+ ); +} diff --git a/ui/web/src/pages/cli-credentials/cli-credentials-panel.tsx b/ui/web/src/pages/cli-credentials/cli-credentials-panel.tsx index a6e745bb..eca1ce07 100644 --- a/ui/web/src/pages/cli-credentials/cli-credentials-panel.tsx +++ b/ui/web/src/pages/cli-credentials/cli-credentials-panel.tsx @@ -24,6 +24,9 @@ const CliCredentialFormDialog = lazy(() => const CLIUserCredentialsDialog = lazy(() => import("./cli-user-credentials-dialog").then((m) => ({ default: m.CLIUserCredentialsDialog })) ); +const CLIAgentCredentialsDialog = lazy(() => + import("./cli-agent-credentials-dialog").then((m) => ({ default: m.CLIAgentCredentialsDialog })) +); export function CliCredentialsPanel() { const { t } = useTranslation("cli-credentials"); @@ -34,6 +37,7 @@ export function CliCredentialsPanel() { const [deleteTarget, setDeleteTarget] = useState(null); const [deleteLoading, setDeleteLoading] = useState(false); const [userCredsTarget, setUserCredsTarget] = useState(null); + const [agentCredsTarget, setAgentCredsTarget] = useState(null); const [grantsTarget, setGrantsTarget] = useState(null); const { items, loading, refresh, createCredential, updateCredential, deleteCredential } = @@ -88,6 +92,7 @@ export function CliCredentialsPanel() { onEdit={openEdit} onDelete={setDeleteTarget} onUserCreds={setUserCredsTarget} + onAgentCreds={setAgentCredsTarget} onGrants={setGrantsTarget} /> {/* Finding #12: surface LIMIT 20 truncation so admins know there are more entries. */} @@ -130,6 +135,16 @@ export function CliCredentialsPanel() { )} + {agentCredsTarget && ( + + !open && setAgentCredsTarget(null)} + binary={agentCredsTarget} + /> + + )} + {grantsTarget && ( void; onDelete: (item: SecureCLIBinary) => void; onUserCreds: (item: SecureCLIBinary) => void; + onAgentCreds: (item: SecureCLIBinary) => void; onGrants: (item: SecureCLIBinary) => void; } -export function CliCredentialsTable({ items, onEdit, onDelete, onUserCreds, onGrants }: Props) { +export function CliCredentialsTable({ items, onEdit, onDelete, onUserCreds, onAgentCreds, onGrants }: Props) { const { t } = useTranslation("cli-credentials"); const { t: tc } = useTranslation("common"); @@ -77,6 +78,9 @@ export function CliCredentialsTable({ items, onEdit, onDelete, onUserCreds, onGr {t("grants.addGrant")} + diff --git a/ui/web/src/pages/cli-credentials/hooks/use-cli-agent-credentials.ts b/ui/web/src/pages/cli-credentials/hooks/use-cli-agent-credentials.ts new file mode 100644 index 00000000..a1c941ee --- /dev/null +++ b/ui/web/src/pages/cli-credentials/hooks/use-cli-agent-credentials.ts @@ -0,0 +1,80 @@ +import { useCallback } from "react"; +import { useQuery, useQueryClient } from "@tanstack/react-query"; +import { useHttp } from "@/hooks/use-ws"; +import { toast } from "@/stores/use-toast-store"; +import i18n from "@/i18n"; +import type { CLIAgentCredential, CLIAgentCredentialInput } from "@/types/cli-credential"; + +export type { CLIAgentCredential, CLIAgentCredentialInput }; + +/** Hook for managing agent-scoped credentials on a specific CLI binary. */ +export function useCliAgentCredentials(binaryId: string) { + const http = useHttp(); + const queryClient = useQueryClient(); + const queryKey = ["cliCredentials", binaryId, "agentCredentials"] as const; + + const { data, isLoading } = useQuery({ + queryKey, + queryFn: async () => { + const res = await http.get<{ agent_credentials: CLIAgentCredential[] }>( + `/v1/cli-credentials/${binaryId}/agent-credentials`, + ); + return res.agent_credentials ?? []; + }, + enabled: !!binaryId, + }); + + const invalidate = useCallback( + () => queryClient.invalidateQueries({ queryKey }), + [queryClient, queryKey], + ); + + const getCredential = useCallback( + (agentId: string) => http.get( + `/v1/cli-credentials/${binaryId}/agent-credentials/${agentId}`, + ), + [http, binaryId], + ); + + const setCredential = useCallback( + async (agentId: string, input: CLIAgentCredentialInput) => { + try { + await http.put(`/v1/cli-credentials/${binaryId}/agent-credentials/${agentId}`, input); + await invalidate(); + toast.success(i18n.t("cli-credentials:agentCredentials.saved")); + } catch (err) { + toast.error( + i18n.t("cli-credentials:agentCredentials.saveFailed"), + err instanceof Error ? err.message : "", + ); + throw err; + } + }, + [http, binaryId, invalidate], + ); + + const deleteCredential = useCallback( + async (agentId: string) => { + try { + await http.delete(`/v1/cli-credentials/${binaryId}/agent-credentials/${agentId}`); + await invalidate(); + toast.success(i18n.t("cli-credentials:agentCredentials.deleted")); + } catch (err) { + toast.error( + i18n.t("cli-credentials:agentCredentials.deleteFailed"), + err instanceof Error ? err.message : "", + ); + throw err; + } + }, + [http, binaryId, invalidate], + ); + + return { + agentCredentials: data ?? [], + loading: isLoading, + getCredential, + setCredential, + deleteCredential, + }; +} diff --git a/ui/web/src/pages/cli-credentials/hooks/use-cli-credentials.ts b/ui/web/src/pages/cli-credentials/hooks/use-cli-credentials.ts index e31e7041..43211c97 100644 --- a/ui/web/src/pages/cli-credentials/hooks/use-cli-credentials.ts +++ b/ui/web/src/pages/cli-credentials/hooks/use-cli-credentials.ts @@ -3,9 +3,21 @@ import { useQuery, useQueryClient } from "@tanstack/react-query"; import { useHttp } from "@/hooks/use-ws"; import { toast } from "@/stores/use-toast-store"; import i18n from "@/i18n"; -import type { SecureCLIBinary, CLICredentialInput, CLIPreset, CLIAgentGrant, CLIAgentGrantInput } from "@/types/cli-credential"; +import type { + SecureCLIBinary, + CLICredentialInput, + CLIPreset, + CLIAgentGrant, + CLIAgentGrantInput, +} from "@/types/cli-credential"; -export type { SecureCLIBinary, CLICredentialInput, CLIPreset, CLIAgentGrant, CLIAgentGrantInput }; +export type { + SecureCLIBinary, + CLICredentialInput, + CLIPreset, + CLIAgentGrant, + CLIAgentGrantInput, +}; const QUERY_KEY = ["cliCredentials"] as const; const PRESETS_KEY = ["cliCredentials", "presets"] as const; diff --git a/ui/web/src/types/cli-credential.ts b/ui/web/src/types/cli-credential.ts index b93f8d77..ce430fec 100644 --- a/ui/web/src/types/cli-credential.ts +++ b/ui/web/src/types/cli-credential.ts @@ -12,6 +12,7 @@ export interface CLIEnvEntryResponse { } export type CLIEnvPayload = Record; +export type CLIGitCredentialType = "env" | "pat" | "ssh_key"; export interface SecureCLIBinary { id: string; @@ -116,6 +117,29 @@ export interface CLIAgentGrantInput { env_vars?: CLIEnvPayload | null; } +export interface CLIAgentCredential { + id: string; + binary_id: string; + agent_id: string; + agent_key?: string; + name?: string; + has_secret: boolean; + env_keys?: string[]; + env?: Record; + credential_type?: CLIGitCredentialType | string | null; + host_scope?: string | null; + created_by?: string; + created_at: string; + updated_at: string; +} + +export interface CLIAgentCredentialInput { + env?: CLIEnvPayload; + credential_type?: Exclude; + host_scope?: string; + blob?: Record; +} + /** Summary of a single grant shown in the table row chips (Phase 4 API field). */ export interface AgentGrantSummary { grant_id: string; From 6f676d1bd412ca89355cfa7b4df741cd88fb4453 Mon Sep 17 00:00:00 2001 From: Goon Date: Sun, 31 May 2026 17:01:35 +0700 Subject: [PATCH 3/3] fix(cron): wait for job goroutines on stop --- internal/cron/service.go | 12 ++++++++++-- internal/cron/service_execution.go | 2 ++ 2 files changed, 12 insertions(+), 2 deletions(-) diff --git a/internal/cron/service.go b/internal/cron/service.go index 0e8d7a03..eee8765f 100644 --- a/internal/cron/service.go +++ b/internal/cron/service.go @@ -13,6 +13,7 @@ type Service struct { onJob JobHandler running bool stopChan chan struct{} + wg sync.WaitGroup mu sync.Mutex runLog []RunLogEntry // in-memory run history (last 200 entries) retryCfg RetryConfig // retry config for failed jobs @@ -89,7 +90,11 @@ func (cs *Service) Start() error { // after a previous Stop() returned but the runLoop goroutine hasn't yet // executed its ticker construction. tick := runLoopTickInterval - go cs.runLoop(cs.stopChan, tick) + cs.wg.Add(1) + go func() { + defer cs.wg.Done() + cs.runLoop(cs.stopChan, tick) + }() slog.Info("cron service started", "jobs", len(cs.store.Jobs)) return nil @@ -98,14 +103,17 @@ func (cs *Service) Start() error { // Stop halts the scheduling loop. func (cs *Service) Stop() { cs.mu.Lock() - defer cs.mu.Unlock() if !cs.running { + cs.mu.Unlock() return } close(cs.stopChan) cs.running = false + cs.mu.Unlock() + + cs.wg.Wait() slog.Info("cron service stopped") } diff --git a/internal/cron/service_execution.go b/internal/cron/service_execution.go index fd8a7a5d..fdd13485 100644 --- a/internal/cron/service_execution.go +++ b/internal/cron/service_execution.go @@ -216,7 +216,9 @@ func (cs *Service) checkJobs() { // agent loop stuck), the entire cron scheduler would stop checking for new // due jobs. Now each job runs independently with panic recovery. for _, dj := range dueJobs { + cs.wg.Add(1) go func(id string, scheduledAtMS int64) { + defer cs.wg.Done() defer safego.Recover(nil, "job_id", id) cs.executeJobByID(id, scheduledAtMS) }(dj.id, dj.scheduledAtMS)