Compare commits
224 Commits
jon/gvisor
...
cleanup/qu
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0666db75a3 | ||
|
|
6f244cddde | ||
|
|
89eaf6ad74 | ||
|
|
ac51610332 | ||
|
|
a1e912a763 | ||
|
|
f1d333f0e6 | ||
|
|
23542cb6af | ||
|
|
03a1653324 | ||
|
|
4c9041b270 | ||
|
|
3014376c36 | ||
|
|
2a3be30a88 | ||
|
|
186424b4e2 | ||
|
|
41e15ae440 | ||
|
|
c8e58575e0 | ||
|
|
d8cad81ada | ||
|
|
e08c3c1699 | ||
|
|
139594a4f4 | ||
|
|
06c50d13ad | ||
|
|
484f637c6c | ||
|
|
25445714b3 | ||
|
|
6edcbdba7f | ||
|
|
abd7b7aeba | ||
|
|
be5f9b1ffd | ||
|
|
135c4d0f42 | ||
|
|
de4e568994 | ||
|
|
c2bc2c5738 | ||
|
|
0c9771a38b | ||
|
|
6afc1bac0b | ||
|
|
2f50e89afd | ||
|
|
7c3c7bb5e6 | ||
|
|
cf0c4d0dcf | ||
|
|
4da273ba3c | ||
|
|
999bbf9685 | ||
|
|
cc6766e64a | ||
|
|
7db77bbefa | ||
|
|
a908d51097 | ||
|
|
3ef13f54ab | ||
|
|
b4b562dc9b | ||
|
|
176f57bb13 | ||
|
|
748022f2ba | ||
|
|
0a0c976a1a | ||
|
|
436a17fcf2 | ||
|
|
0176a5dd6b | ||
|
|
bb7c5f93f3 | ||
|
|
84f032d97c | ||
|
|
91d7516dc1 | ||
|
|
bb6e826d91 | ||
|
|
742694eb20 | ||
|
|
31fe58819e | ||
|
|
62cf884e81 | ||
|
|
86cb313765 | ||
|
|
ca57a0bcab | ||
|
|
00d292d764 | ||
|
|
c107d2bf5d | ||
|
|
6d214644f6 | ||
|
|
83809bb380 | ||
|
|
c424c31ab8 | ||
|
|
635ce1f064 | ||
|
|
d8ff67fb68 | ||
|
|
8f78c5145f | ||
|
|
1840d6f942 | ||
|
|
f31a8277a9 | ||
|
|
fdc2366227 | ||
|
|
d0f93f0818 | ||
|
|
7a98b4a876 | ||
|
|
5b9a9e5bdf | ||
|
|
2ee90dfd84 | ||
|
|
cda460f5df | ||
|
|
7877b26088 | ||
|
|
9ba822628a | ||
|
|
0339c083ab | ||
|
|
be1c06dec9 | ||
|
|
a6856320f9 | ||
|
|
2245612ece | ||
|
|
d285a3e74e | ||
|
|
eba7d943a0 | ||
|
|
147d627505 | ||
|
|
14ed3e3644 | ||
|
|
fb61c48227 | ||
|
|
1f0d896fc9 | ||
|
|
f395e2e9c2 | ||
|
|
cbe29e4e25 | ||
|
|
dbb7aee65b | ||
|
|
90cf4f0a91 | ||
|
|
0b13ba978a | ||
|
|
4ce9fbeaf0 | ||
|
|
4b8a5e2b10 | ||
|
|
d4a072b61e | ||
|
|
c46136ff73 | ||
|
|
65b7658568 | ||
|
|
2577d16af2 | ||
|
|
119030d795 | ||
|
|
483adc59fe | ||
|
|
1f5f6c9ccb | ||
|
|
a130a7dc97 | ||
|
|
d6fef96d72 | ||
|
|
4dd8531f37 | ||
|
|
3bcb7de7c0 | ||
|
|
1e07ec49a6 | ||
|
|
84de391f26 | ||
|
|
b83b93ea5c | ||
|
|
014e5b4f57 | ||
|
|
fc3508dc60 | ||
|
|
8b4d35798a | ||
|
|
d69dcf18de | ||
|
|
fe82d0aeb9 | ||
|
|
81dba9da14 | ||
|
|
20ac96e68d | ||
|
|
677f90b78a | ||
|
|
d697213373 | ||
|
|
62144d230f | ||
|
|
0d0c6c956d | ||
|
|
488ceb6e58 | ||
|
|
481c132135 | ||
|
|
d42008e93d | ||
|
|
aa3cee6410 | ||
|
|
4f566f92b5 | ||
|
|
bd5b62c976 | ||
|
|
66f809388e | ||
|
|
563c00fb2c | ||
|
|
817fb4e67a | ||
|
|
2cf47ec384 | ||
|
|
11481d7bed | ||
|
|
f3bf5baba0 | ||
|
|
9df7fda5f6 | ||
|
|
665db7bdeb | ||
|
|
903cfb183f | ||
|
|
49e5547c22 | ||
|
|
f9c265ca6e | ||
|
|
a65a31a5a3 | ||
|
|
22a4a33886 | ||
|
|
d3c9469e13 | ||
|
|
91ec0f1484 | ||
|
|
6b76e30321 | ||
|
|
6fc9f195f1 | ||
|
|
c2243addce | ||
|
|
cd163d404b | ||
|
|
41d12b8aa3 | ||
|
|
497e1e6589 | ||
|
|
b779c9ee33 | ||
|
|
144b32a4b6 | ||
|
|
a40716b6fe | ||
|
|
635c5d52a8 | ||
|
|
075dfecd12 | ||
|
|
fdb1205bdf | ||
|
|
33a47fced3 | ||
|
|
ca5158f94a | ||
|
|
b7e0f42591 | ||
|
|
41bd7acf66 | ||
|
|
87d4a29371 | ||
|
|
a797a494ef | ||
|
|
a33605df58 | ||
|
|
3c430a67fa | ||
|
|
abee77ac2f | ||
|
|
7946dc6645 | ||
|
|
eb828a6a86 | ||
|
|
4e2d7ffaa7 | ||
|
|
524bca4c87 | ||
|
|
365de3e367 | ||
|
|
5d0eb772da | ||
|
|
04fca84872 | ||
|
|
7cca2b6176 | ||
|
|
1031da9738 | ||
|
|
b69631cb35 | ||
|
|
7b0aa31b55 | ||
|
|
93b9d70a9b | ||
|
|
6972d073a2 | ||
|
|
89bb5bb945 | ||
|
|
b7eab35734 | ||
|
|
3f76f312e4 | ||
|
|
abf59ee7a6 | ||
|
|
cabb611fd9 | ||
|
|
b2d8b67ff7 | ||
|
|
c1884148f0 | ||
|
|
741af057dc | ||
|
|
32a894d4a7 | ||
|
|
4fdd48b3f5 | ||
|
|
e94de0bdab | ||
|
|
fa8693605f | ||
|
|
af1be592cf | ||
|
|
6f97539122 | ||
|
|
530872873e | ||
|
|
115011bd70 | ||
|
|
3c6445606d | ||
|
|
f8dff3f758 | ||
|
|
27cbf5474b | ||
|
|
3704e930a1 | ||
|
|
3a3537a642 | ||
|
|
c4db03f11a | ||
|
|
08107b35d7 | ||
|
|
fbc8930fc3 | ||
|
|
59553b8df8 | ||
|
|
68fd82e0ba | ||
|
|
2927fea959 | ||
|
|
d6306461bb | ||
|
|
cb05419872 | ||
|
|
29225252f6 | ||
|
|
93ea5f5d22 | ||
|
|
9a6356513b | ||
|
|
069d3e2beb | ||
|
|
aa6f301305 | ||
|
|
ae8bed4d8e | ||
|
|
703b974757 | ||
|
|
9c2f217ca2 | ||
|
|
3d9628c27e | ||
|
|
a2b8564c48 | ||
|
|
1adc22fffd | ||
|
|
266c611716 | ||
|
|
83e4f9f93e | ||
|
|
ff9d061ae9 | ||
|
|
0d3e39a24e | ||
|
|
3f7f25b3ee | ||
|
|
ddd1e86a90 | ||
|
|
969066b55e | ||
|
|
f6976fd6c1 | ||
|
|
cbb3841e81 | ||
|
|
36665e17b2 | ||
|
|
b492c42624 | ||
|
|
c5b8611c5a | ||
|
|
f714f589c5 | ||
|
|
72689c2552 | ||
|
|
85509733f3 | ||
|
|
eacabd8390 | ||
|
|
84527390c6 |
72
.agents/skills/pull-requests/SKILL.md
Normal file
72
.agents/skills/pull-requests/SKILL.md
Normal file
@@ -0,0 +1,72 @@
|
||||
---
|
||||
name: pull-requests
|
||||
description: "Guide for creating, updating, and following up on pull requests in the Coder repository. Use when asked to open a PR, update a PR, rewrite a PR description, or follow up on CI/check failures."
|
||||
---
|
||||
|
||||
# Pull Request Skill
|
||||
|
||||
## When to Use This Skill
|
||||
|
||||
Use this skill when asked to:
|
||||
|
||||
- Create a pull request for the current branch.
|
||||
- Update an existing PR branch or description.
|
||||
- Rewrite a PR body.
|
||||
- Follow up on CI or check failures for an existing PR.
|
||||
|
||||
## References
|
||||
|
||||
Use the canonical docs for shared conventions and validation guidance:
|
||||
|
||||
- PR title and description conventions:
|
||||
`.claude/docs/PR_STYLE_GUIDE.md`
|
||||
- Local validation commands and git hooks: `AGENTS.md` (Essential Commands and
|
||||
Git Hooks sections)
|
||||
|
||||
## Lifecycle Rules
|
||||
|
||||
1. **Check for an existing PR** before creating a new one:
|
||||
|
||||
```bash
|
||||
gh pr list --head "$(git branch --show-current)" --author @me --json number --jq '.[0].number // empty'
|
||||
```
|
||||
|
||||
If that returns a number, update that PR. If it returns empty output,
|
||||
create a new one.
|
||||
2. **Check you are not on main.** If the current branch is `main` or `master`,
|
||||
create a feature branch before doing PR work.
|
||||
3. **Default to draft.** Use `gh pr create --draft` unless the user explicitly
|
||||
asks for ready-for-review.
|
||||
4. **Keep description aligned with the full diff.** Re-read the diff against
|
||||
the base branch before writing or updating the title and body. Describe the
|
||||
entire PR diff, not just the last commit.
|
||||
5. **Never auto-merge.** Do not merge or mark ready for review unless the user
|
||||
explicitly asks.
|
||||
6. **Never push to main or master.**
|
||||
|
||||
## CI / Checks Follow-up
|
||||
|
||||
**Always watch CI checks after pushing.** Do not push and walk away.
|
||||
|
||||
After pushing:
|
||||
|
||||
- Monitor CI with `gh pr checks <PR_NUMBER> --watch`.
|
||||
- Use `gh pr view <PR_NUMBER> --json statusCheckRollup` for programmatic check
|
||||
status.
|
||||
|
||||
If checks fail:
|
||||
|
||||
1. Find the failed run ID from the `gh pr checks` output.
|
||||
2. Read the logs with `gh run view <run-id> --log-failed`.
|
||||
3. Fix the problem locally.
|
||||
4. Run `make pre-commit`.
|
||||
5. Push the fix.
|
||||
|
||||
## What Not to Do
|
||||
|
||||
- Do not reference or call helper scripts that do not exist in this
|
||||
repository.
|
||||
- Do not auto-merge or mark ready for review without explicit user request.
|
||||
- Do not push to `origin/main` or `origin/master`.
|
||||
- Do not skip local validation before pushing.
|
||||
- Do not fabricate or embellish PR descriptions.
|
||||
@@ -113,7 +113,7 @@ Coder emphasizes clear error handling, with specific patterns required:
|
||||
|
||||
All tests should run in parallel using `t.Parallel()` to ensure efficient testing and expose potential race conditions. The codebase is rigorously linted with golangci-lint to maintain consistent code quality.
|
||||
|
||||
Git contributions follow a standard format with commit messages structured as `type: <message>`, where type is one of `feat`, `fix`, or `chore`.
|
||||
Git contributions follow [Conventional Commits](https://www.conventionalcommits.org/en/v1.0.0/). See [CONTRIBUTING.md](docs/about/contributing/CONTRIBUTING.md#commit-messages) for full rules. PR titles are linted in CI.
|
||||
|
||||
## Development Workflow
|
||||
|
||||
|
||||
@@ -4,22 +4,13 @@ This guide documents the PR description style used in the Coder repository, base
|
||||
|
||||
## PR Title Format
|
||||
|
||||
Follow [Conventional Commits 1.0.0](https://www.conventionalcommits.org/en/v1.0.0/) format:
|
||||
Format: `type(scope): description`. See [CONTRIBUTING.md](docs/about/contributing/CONTRIBUTING.md#commit-messages) for full rules. PR titles are linted in CI.
|
||||
|
||||
```text
|
||||
type(scope): brief description
|
||||
```
|
||||
- Types: `feat`, `fix`, `docs`, `style`, `refactor`, `perf`, `test`, `build`, `ci`, `chore`, `revert`
|
||||
- Scopes must be a real path (directory or file stem) containing all changed files
|
||||
- Omit scope if changes span multiple top-level directories
|
||||
|
||||
**Common types:**
|
||||
|
||||
- `feat`: New features
|
||||
- `fix`: Bug fixes
|
||||
- `refactor`: Code refactoring without behavior change
|
||||
- `perf`: Performance improvements
|
||||
- `docs`: Documentation changes
|
||||
- `chore`: Dependency updates, tooling changes
|
||||
|
||||
**Examples:**
|
||||
Examples:
|
||||
|
||||
- `feat: add tracing to aibridge`
|
||||
- `fix: move contexts to appropriate locations`
|
||||
|
||||
@@ -136,9 +136,11 @@ Then make your changes and push normally. Don't use `git push --force` unless th
|
||||
|
||||
## Commit Style
|
||||
|
||||
- Follow [Conventional Commits 1.0.0](https://www.conventionalcommits.org/en/v1.0.0/)
|
||||
- Format: `type(scope): message`
|
||||
- Types: `feat`, `fix`, `docs`, `style`, `refactor`, `test`, `chore`
|
||||
Format: `type(scope): message`. See [CONTRIBUTING.md](docs/about/contributing/CONTRIBUTING.md#commit-messages) for full rules. PR titles are linted in CI.
|
||||
|
||||
- Types: `feat`, `fix`, `docs`, `style`, `refactor`, `perf`, `test`, `build`, `ci`, `chore`, `revert`
|
||||
- Scopes must be a real path (directory or file stem) containing all changed files
|
||||
- Omit scope if changes span multiple top-level directories
|
||||
- Keep message titles concise (~70 characters)
|
||||
- Use imperative, present tense in commit titles
|
||||
|
||||
|
||||
9
.github/actionlint.yaml
vendored
Normal file
9
.github/actionlint.yaml
vendored
Normal file
@@ -0,0 +1,9 @@
|
||||
paths:
|
||||
# The triage workflow uses a quoted heredoc (<<'EOF') with ${VAR}
|
||||
# placeholders that envsubst expands later. Shellcheck's SC2016
|
||||
# warns about unexpanded variables in single-quoted strings, but
|
||||
# the non-expansion is intentional here. Actionlint doesn't honor
|
||||
# inline shellcheck disable directives inside heredocs.
|
||||
.github/workflows/triage-via-chat-api.yaml:
|
||||
ignore:
|
||||
- 'SC2016'
|
||||
130
.github/workflows/ci.yaml
vendored
130
.github/workflows/ci.yaml
vendored
@@ -35,7 +35,7 @@ jobs:
|
||||
tailnet-integration: ${{ steps.filter.outputs.tailnet-integration }}
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@5ef0c079ce82195b2a36a210272d6b661572d83e # v2.14.2
|
||||
uses: step-security/harden-runner@fa2e9d605c4eeb9fcad4c99c224cee0c6c7f3594 # v2.16.0
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
@@ -157,7 +157,7 @@ jobs:
|
||||
runs-on: ${{ github.repository_owner == 'coder' && 'depot-ubuntu-22.04-8' || 'ubuntu-latest' }}
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@5ef0c079ce82195b2a36a210272d6b661572d83e # v2.14.2
|
||||
uses: step-security/harden-runner@fa2e9d605c4eeb9fcad4c99c224cee0c6c7f3594 # v2.16.0
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
@@ -191,7 +191,7 @@ jobs:
|
||||
|
||||
# Check for any typos
|
||||
- name: Check for typos
|
||||
uses: crate-ci/typos@2d0ce569feab1f8752f1dde43cc2f2aa53236e06 # v1.40.0
|
||||
uses: crate-ci/typos@631208b7aac2daa8b707f55e7331f9112b0e062d # v1.44.0
|
||||
with:
|
||||
config: .github/workflows/typos.toml
|
||||
|
||||
@@ -247,7 +247,7 @@ jobs:
|
||||
runs-on: ${{ github.repository_owner == 'coder' && 'depot-ubuntu-22.04-8' || 'ubuntu-latest' }}
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@5ef0c079ce82195b2a36a210272d6b661572d83e # v2.14.2
|
||||
uses: step-security/harden-runner@fa2e9d605c4eeb9fcad4c99c224cee0c6c7f3594 # v2.16.0
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
@@ -272,7 +272,7 @@ jobs:
|
||||
if: ${{ !cancelled() }}
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@5ef0c079ce82195b2a36a210272d6b661572d83e # v2.14.2
|
||||
uses: step-security/harden-runner@fa2e9d605c4eeb9fcad4c99c224cee0c6c7f3594 # v2.16.0
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
@@ -327,7 +327,7 @@ jobs:
|
||||
timeout-minutes: 20
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@5ef0c079ce82195b2a36a210272d6b661572d83e # v2.14.2
|
||||
uses: step-security/harden-runner@fa2e9d605c4eeb9fcad4c99c224cee0c6c7f3594 # v2.16.0
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
@@ -379,7 +379,7 @@ jobs:
|
||||
- windows-2022
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@5ef0c079ce82195b2a36a210272d6b661572d83e # v2.14.2
|
||||
uses: step-security/harden-runner@fa2e9d605c4eeb9fcad4c99c224cee0c6c7f3594 # v2.16.0
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
@@ -537,7 +537,7 @@ jobs:
|
||||
embedded-pg-cache: ${{ steps.embedded-pg-cache.outputs.embedded-pg-cache }}
|
||||
|
||||
- name: Upload failed test db dumps
|
||||
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
|
||||
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
|
||||
with:
|
||||
name: failed-test-db-dump-${{matrix.os}}
|
||||
path: "**/*.test.sql"
|
||||
@@ -575,7 +575,7 @@ jobs:
|
||||
timeout-minutes: 25
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@5ef0c079ce82195b2a36a210272d6b661572d83e # v2.14.2
|
||||
uses: step-security/harden-runner@fa2e9d605c4eeb9fcad4c99c224cee0c6c7f3594 # v2.16.0
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
@@ -637,7 +637,7 @@ jobs:
|
||||
timeout-minutes: 25
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@5ef0c079ce82195b2a36a210272d6b661572d83e # v2.14.2
|
||||
uses: step-security/harden-runner@fa2e9d605c4eeb9fcad4c99c224cee0c6c7f3594 # v2.16.0
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
@@ -709,7 +709,7 @@ jobs:
|
||||
timeout-minutes: 20
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@5ef0c079ce82195b2a36a210272d6b661572d83e # v2.14.2
|
||||
uses: step-security/harden-runner@fa2e9d605c4eeb9fcad4c99c224cee0c6c7f3594 # v2.16.0
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
@@ -736,7 +736,7 @@ jobs:
|
||||
timeout-minutes: 20
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@5ef0c079ce82195b2a36a210272d6b661572d83e # v2.14.2
|
||||
uses: step-security/harden-runner@fa2e9d605c4eeb9fcad4c99c224cee0c6c7f3594 # v2.16.0
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
@@ -769,7 +769,7 @@ jobs:
|
||||
name: ${{ matrix.variant.name }}
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@5ef0c079ce82195b2a36a210272d6b661572d83e # v2.14.2
|
||||
uses: step-security/harden-runner@fa2e9d605c4eeb9fcad4c99c224cee0c6c7f3594 # v2.16.0
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
@@ -818,7 +818,7 @@ jobs:
|
||||
|
||||
- name: Upload Playwright Failed Tests
|
||||
if: always() && github.actor != 'dependabot[bot]' && runner.os == 'Linux' && !github.event.pull_request.head.repo.fork
|
||||
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
|
||||
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
|
||||
with:
|
||||
name: failed-test-videos${{ matrix.variant.premium && '-premium' || '' }}
|
||||
path: ./site/test-results/**/*.webm
|
||||
@@ -826,7 +826,7 @@ jobs:
|
||||
|
||||
- name: Upload debug log
|
||||
if: always() && github.actor != 'dependabot[bot]' && runner.os == 'Linux' && !github.event.pull_request.head.repo.fork
|
||||
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
|
||||
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
|
||||
with:
|
||||
name: coderd-debug-logs${{ matrix.variant.premium && '-premium' || '' }}
|
||||
path: ./site/e2e/test-results/debug.log
|
||||
@@ -834,7 +834,7 @@ jobs:
|
||||
|
||||
- name: Upload pprof dumps
|
||||
if: always() && github.actor != 'dependabot[bot]' && runner.os == 'Linux' && !github.event.pull_request.head.repo.fork
|
||||
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
|
||||
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
|
||||
with:
|
||||
name: debug-pprof-dumps${{ matrix.variant.premium && '-premium' || '' }}
|
||||
path: ./site/test-results/**/debug-pprof-*.txt
|
||||
@@ -849,7 +849,7 @@ jobs:
|
||||
if: needs.changes.outputs.site == 'true' || needs.changes.outputs.ci == 'true'
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@5ef0c079ce82195b2a36a210272d6b661572d83e # v2.14.2
|
||||
uses: step-security/harden-runner@fa2e9d605c4eeb9fcad4c99c224cee0c6c7f3594 # v2.16.0
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
@@ -930,7 +930,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@5ef0c079ce82195b2a36a210272d6b661572d83e # v2.14.2
|
||||
uses: step-security/harden-runner@fa2e9d605c4eeb9fcad4c99c224cee0c6c7f3594 # v2.16.0
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
@@ -1005,7 +1005,7 @@ jobs:
|
||||
if: always()
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@5ef0c079ce82195b2a36a210272d6b661572d83e # v2.14.2
|
||||
uses: step-security/harden-runner@fa2e9d605c4eeb9fcad4c99c224cee0c6c7f3594 # v2.16.0
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
@@ -1043,7 +1043,7 @@ jobs:
|
||||
runs-on: ${{ github.repository_owner == 'coder' && 'depot-ubuntu-22.04-8' || 'ubuntu-latest' }}
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@5ef0c079ce82195b2a36a210272d6b661572d83e # v2.14.2
|
||||
uses: step-security/harden-runner@fa2e9d605c4eeb9fcad4c99c224cee0c6c7f3594 # v2.16.0
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
@@ -1097,7 +1097,7 @@ jobs:
|
||||
IMAGE: ghcr.io/coder/coder-preview:${{ steps.build-docker.outputs.tag }}
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@5ef0c079ce82195b2a36a210272d6b661572d83e # v2.14.2
|
||||
uses: step-security/harden-runner@fa2e9d605c4eeb9fcad4c99c224cee0c6c7f3594 # v2.16.0
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
@@ -1108,7 +1108,7 @@ jobs:
|
||||
persist-credentials: false
|
||||
|
||||
- name: GHCR Login
|
||||
uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0
|
||||
uses: docker/login-action@b45d80f862d83dbcd57f89517bcf500b2ab88fb2 # v4.0.0
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
@@ -1198,7 +1198,7 @@ jobs:
|
||||
make -j \
|
||||
build/coder_linux_{amd64,arm64,armv7} \
|
||||
build/coder_"$version"_windows_amd64.zip \
|
||||
build/coder_"$version"_linux_amd64.{tar.gz,deb}
|
||||
build/coder_"$version"_linux_{amd64,arm64,armv7}.{tar.gz,deb}
|
||||
env:
|
||||
# The Windows and Darwin slim binaries must be signed for Coder
|
||||
# Desktop to accept them.
|
||||
@@ -1216,11 +1216,28 @@ jobs:
|
||||
GCLOUD_ACCESS_TOKEN: ${{ steps.gcloud_auth.outputs.access_token }}
|
||||
JSIGN_PATH: /tmp/jsign-6.0.jar
|
||||
|
||||
# Free up disk space before building Docker images. The preceding
|
||||
# Build step produces ~2 GB of binaries and packages, the Go build
|
||||
# cache is ~1.3 GB, and node_modules is ~500 MB. Docker image
|
||||
# builds, pushes, and SBOM generation need headroom that isn't
|
||||
# available without reclaiming some of that space.
|
||||
- name: Clean up build cache
|
||||
run: |
|
||||
set -euxo pipefail
|
||||
# Go caches are no longer needed — binaries are already compiled.
|
||||
go clean -cache -modcache
|
||||
# Remove .apk and .rpm packages that are not uploaded as
|
||||
# artifacts and were only built as make prerequisites.
|
||||
rm -f ./build/*.apk ./build/*.rpm
|
||||
|
||||
- name: Build Linux Docker images
|
||||
id: build-docker
|
||||
env:
|
||||
CODER_IMAGE_BASE: ghcr.io/coder/coder-preview
|
||||
DOCKER_CLI_EXPERIMENTAL: "enabled"
|
||||
# Skip building .deb/.rpm/.apk/.tar.gz as prerequisites for
|
||||
# the Docker image targets — they were already built above.
|
||||
DOCKER_IMAGE_NO_PREREQUISITES: "true"
|
||||
run: |
|
||||
set -euxo pipefail
|
||||
|
||||
@@ -1302,7 +1319,7 @@ jobs:
|
||||
id: attest_main
|
||||
if: github.ref == 'refs/heads/main'
|
||||
continue-on-error: true
|
||||
uses: actions/attest@e59cbc1ad1ac2d59339667419eb8cdde6eb61e3d # v3.2.0
|
||||
uses: actions/attest@59d89421af93a897026c735860bf21b6eb4f7b26 # v4.1.0
|
||||
with:
|
||||
subject-name: "ghcr.io/coder/coder-preview:main"
|
||||
predicate-type: "https://slsa.dev/provenance/v1"
|
||||
@@ -1339,7 +1356,7 @@ jobs:
|
||||
id: attest_latest
|
||||
if: github.ref == 'refs/heads/main'
|
||||
continue-on-error: true
|
||||
uses: actions/attest@e59cbc1ad1ac2d59339667419eb8cdde6eb61e3d # v3.2.0
|
||||
uses: actions/attest@59d89421af93a897026c735860bf21b6eb4f7b26 # v4.1.0
|
||||
with:
|
||||
subject-name: "ghcr.io/coder/coder-preview:latest"
|
||||
predicate-type: "https://slsa.dev/provenance/v1"
|
||||
@@ -1376,7 +1393,7 @@ jobs:
|
||||
id: attest_version
|
||||
if: github.ref == 'refs/heads/main'
|
||||
continue-on-error: true
|
||||
uses: actions/attest@e59cbc1ad1ac2d59339667419eb8cdde6eb61e3d # v3.2.0
|
||||
uses: actions/attest@59d89421af93a897026c735860bf21b6eb4f7b26 # v4.1.0
|
||||
with:
|
||||
subject-name: "ghcr.io/coder/coder-preview:${{ steps.build-docker.outputs.tag }}"
|
||||
predicate-type: "https://slsa.dev/provenance/v1"
|
||||
@@ -1438,15 +1455,60 @@ jobs:
|
||||
^v
|
||||
prune-untagged: true
|
||||
|
||||
- name: Upload build artifacts
|
||||
- name: Upload build artifact (coder-linux-amd64.tar.gz)
|
||||
if: github.ref == 'refs/heads/main'
|
||||
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
|
||||
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
|
||||
with:
|
||||
name: coder
|
||||
path: |
|
||||
./build/*.zip
|
||||
./build/*.tar.gz
|
||||
./build/*.deb
|
||||
name: coder-linux-amd64.tar.gz
|
||||
path: ./build/*_linux_amd64.tar.gz
|
||||
retention-days: 7
|
||||
|
||||
- name: Upload build artifact (coder-linux-amd64.deb)
|
||||
if: github.ref == 'refs/heads/main'
|
||||
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
|
||||
with:
|
||||
name: coder-linux-amd64.deb
|
||||
path: ./build/*_linux_amd64.deb
|
||||
retention-days: 7
|
||||
|
||||
- name: Upload build artifact (coder-linux-arm64.tar.gz)
|
||||
if: github.ref == 'refs/heads/main'
|
||||
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
|
||||
with:
|
||||
name: coder-linux-arm64.tar.gz
|
||||
path: ./build/*_linux_arm64.tar.gz
|
||||
retention-days: 7
|
||||
|
||||
- name: Upload build artifact (coder-linux-arm64.deb)
|
||||
if: github.ref == 'refs/heads/main'
|
||||
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
|
||||
with:
|
||||
name: coder-linux-arm64.deb
|
||||
path: ./build/*_linux_arm64.deb
|
||||
retention-days: 7
|
||||
|
||||
- name: Upload build artifact (coder-linux-armv7.tar.gz)
|
||||
if: github.ref == 'refs/heads/main'
|
||||
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
|
||||
with:
|
||||
name: coder-linux-armv7.tar.gz
|
||||
path: ./build/*_linux_armv7.tar.gz
|
||||
retention-days: 7
|
||||
|
||||
- name: Upload build artifact (coder-linux-armv7.deb)
|
||||
if: github.ref == 'refs/heads/main'
|
||||
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
|
||||
with:
|
||||
name: coder-linux-armv7.deb
|
||||
path: ./build/*_linux_armv7.deb
|
||||
retention-days: 7
|
||||
|
||||
- name: Upload build artifact (coder-windows-amd64.zip)
|
||||
if: github.ref == 'refs/heads/main'
|
||||
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
|
||||
with:
|
||||
name: coder-windows-amd64.zip
|
||||
path: ./build/*_windows_amd64.zip
|
||||
retention-days: 7
|
||||
|
||||
# Deploy is handled in deploy.yaml so we can apply concurrency limits.
|
||||
@@ -1481,7 +1543,7 @@ jobs:
|
||||
if: needs.changes.outputs.db == 'true' || needs.changes.outputs.ci == 'true' || github.ref == 'refs/heads/main'
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@5ef0c079ce82195b2a36a210272d6b661572d83e # v2.14.2
|
||||
uses: step-security/harden-runner@fa2e9d605c4eeb9fcad4c99c224cee0c6c7f3594 # v2.16.0
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
|
||||
141
.github/workflows/contrib.yaml
vendored
141
.github/workflows/contrib.yaml
vendored
@@ -23,6 +23,44 @@ permissions:
|
||||
concurrency: pr-${{ github.ref }}
|
||||
|
||||
jobs:
|
||||
community-label:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
pull-requests: write
|
||||
if: >-
|
||||
${{
|
||||
github.event_name == 'pull_request_target' &&
|
||||
github.event.action == 'opened' &&
|
||||
github.event.pull_request.author_association != 'MEMBER' &&
|
||||
github.event.pull_request.author_association != 'COLLABORATOR' &&
|
||||
github.event.pull_request.author_association != 'OWNER'
|
||||
}}
|
||||
steps:
|
||||
- name: Add community label
|
||||
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
|
||||
with:
|
||||
script: |
|
||||
const params = {
|
||||
issue_number: context.issue.number,
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
}
|
||||
|
||||
const labels = context.payload.pull_request.labels.map((label) => label.name)
|
||||
if (labels.includes("community")) {
|
||||
console.log('PR already has "community" label.')
|
||||
return
|
||||
}
|
||||
|
||||
console.log(
|
||||
'Adding "community" label for author association "%s".',
|
||||
context.payload.pull_request.author_association,
|
||||
)
|
||||
await github.rest.issues.addLabels({
|
||||
...params,
|
||||
labels: ["community"],
|
||||
})
|
||||
|
||||
cla:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
@@ -45,6 +83,109 @@ jobs:
|
||||
# Some users have signed a corporate CLA with Coder so are exempt from signing our community one.
|
||||
allowlist: "coryb,aaronlehmann,dependabot*,blink-so*,blinkagent*"
|
||||
|
||||
title:
|
||||
runs-on: ubuntu-latest
|
||||
if: ${{ github.event_name == 'pull_request_target' }}
|
||||
steps:
|
||||
- name: Validate PR title
|
||||
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
|
||||
with:
|
||||
script: |
|
||||
const { pull_request } = context.payload;
|
||||
const title = pull_request.title;
|
||||
const repo = { owner: context.repo.owner, repo: context.repo.repo };
|
||||
|
||||
const allowedTypes = [
|
||||
"feat", "fix", "docs", "style", "refactor",
|
||||
"perf", "test", "build", "ci", "chore", "revert",
|
||||
];
|
||||
const expectedFormat = `"type(scope): description" or "type: description"`;
|
||||
const guidelinesLink = `See: https://github.com/coder/coder/blob/main/docs/about/contributing/CONTRIBUTING.md#commit-messages`;
|
||||
const scopeHint = (type) =>
|
||||
`Use a broader scope or no scope (e.g., "${type}: ...") for cross-cutting changes.\n` +
|
||||
guidelinesLink;
|
||||
|
||||
console.log("Title: %s", title);
|
||||
|
||||
// Parse conventional commit format: type(scope)!: description
|
||||
const match = title.match(/^(\w+)(\(([^)]*)\))?(!)?\s*:\s*.+/);
|
||||
if (!match) {
|
||||
core.setFailed(
|
||||
`PR title does not match conventional commit format.\n` +
|
||||
`Expected: ${expectedFormat}\n` +
|
||||
`Allowed types: ${allowedTypes.join(", ")}\n` +
|
||||
guidelinesLink
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const type = match[1];
|
||||
const scope = match[3]; // undefined if no parentheses
|
||||
|
||||
// Validate type.
|
||||
if (!allowedTypes.includes(type)) {
|
||||
core.setFailed(
|
||||
`PR title has invalid type "${type}".\n` +
|
||||
`Expected: ${expectedFormat}\n` +
|
||||
`Allowed types: ${allowedTypes.join(", ")}\n` +
|
||||
guidelinesLink
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// If no scope, we're done.
|
||||
if (!scope) {
|
||||
console.log("No scope provided, title is valid.");
|
||||
return;
|
||||
}
|
||||
|
||||
console.log("Scope: %s", scope);
|
||||
|
||||
// Fetch changed files.
|
||||
const files = await github.paginate(github.rest.pulls.listFiles, {
|
||||
...repo,
|
||||
pull_number: pull_request.number,
|
||||
per_page: 100,
|
||||
});
|
||||
const changedPaths = files.map(f => f.filename);
|
||||
console.log("Changed files: %d", changedPaths.length);
|
||||
|
||||
// Derive scope type from the changed files. The diff is the
|
||||
// source of truth: if files exist under the scope, the path
|
||||
// exists on the PR branch. No need for Contents API calls.
|
||||
const isDir = changedPaths.some(f => f.startsWith(scope + "/"));
|
||||
const isFile = changedPaths.some(f => f === scope);
|
||||
const isStem = changedPaths.some(f => f.startsWith(scope + "."));
|
||||
|
||||
if (!isDir && !isFile && !isStem) {
|
||||
core.setFailed(
|
||||
`PR title scope "${scope}" does not match any files changed in this PR.\n` +
|
||||
`Scopes must reference a path (directory or file stem) that contains changed files.\n` +
|
||||
scopeHint(type)
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Verify all changed files fall under the scope.
|
||||
const outsideFiles = changedPaths.filter(f => {
|
||||
if (isDir && f.startsWith(scope + "/")) return false;
|
||||
if (f === scope) return false;
|
||||
if (isStem && f.startsWith(scope + ".")) return false;
|
||||
return true;
|
||||
});
|
||||
|
||||
if (outsideFiles.length > 0) {
|
||||
const listed = outsideFiles.map(f => " - " + f).join("\n");
|
||||
core.setFailed(
|
||||
`PR title scope "${scope}" does not contain all changed files.\n` +
|
||||
`Files outside scope:\n${listed}\n\n` +
|
||||
scopeHint(type)
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
console.log("PR title is valid.");
|
||||
|
||||
release-labels:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
|
||||
34
.github/workflows/deploy.yaml
vendored
34
.github/workflows/deploy.yaml
vendored
@@ -36,7 +36,7 @@ jobs:
|
||||
verdict: ${{ steps.check.outputs.verdict }} # DEPLOY or NOOP
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@5ef0c079ce82195b2a36a210272d6b661572d83e # v2.14.2
|
||||
uses: step-security/harden-runner@fa2e9d605c4eeb9fcad4c99c224cee0c6c7f3594 # v2.16.0
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
@@ -61,11 +61,11 @@ jobs:
|
||||
if: needs.should-deploy.outputs.verdict == 'DEPLOY'
|
||||
permissions:
|
||||
contents: read
|
||||
id-token: write
|
||||
id-token: write # to authenticate to EKS cluster
|
||||
packages: write # to retag image as dogfood
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@5ef0c079ce82195b2a36a210272d6b661572d83e # v2.14.2
|
||||
uses: step-security/harden-runner@fa2e9d605c4eeb9fcad4c99c224cee0c6c7f3594 # v2.16.0
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
@@ -76,33 +76,29 @@ jobs:
|
||||
persist-credentials: false
|
||||
|
||||
- name: GHCR Login
|
||||
uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0
|
||||
uses: docker/login-action@b45d80f862d83dbcd57f89517bcf500b2ab88fb2 # v4.0.0
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Authenticate to Google Cloud
|
||||
uses: google-github-actions/auth@7c6bc770dae815cd3e89ee6cdf493a5fab2cc093 # v3.0.0
|
||||
- name: Configure AWS Credentials
|
||||
uses: aws-actions/configure-aws-credentials@8df5847569e6427dd6c4fb1cf565c83acfa8afa7 # v6.0.0
|
||||
with:
|
||||
workload_identity_provider: ${{ vars.GCP_WORKLOAD_ID_PROVIDER }}
|
||||
service_account: ${{ vars.GCP_SERVICE_ACCOUNT }}
|
||||
role-to-assume: ${{ vars.AWS_DOGFOOD_DEPLOY_ROLE }}
|
||||
aws-region: ${{ vars.AWS_DOGFOOD_DEPLOY_REGION }}
|
||||
|
||||
- name: Set up Google Cloud SDK
|
||||
uses: google-github-actions/setup-gcloud@aa5489c8933f4cc7a4f7d45035b3b1440c9c10db # v3.0.1
|
||||
- name: Get Cluster Credentials
|
||||
run: aws eks update-kubeconfig --name "$AWS_DOGFOOD_CLUSTER_NAME" --region "$AWS_DOGFOOD_DEPLOY_REGION"
|
||||
env:
|
||||
AWS_DOGFOOD_CLUSTER_NAME: ${{ vars.AWS_DOGFOOD_CLUSTER_NAME }}
|
||||
AWS_DOGFOOD_DEPLOY_REGION: ${{ vars.AWS_DOGFOOD_DEPLOY_REGION }}
|
||||
|
||||
- name: Set up Flux CLI
|
||||
uses: fluxcd/flux2/action@8454b02a32e48d775b9f563cb51fdcb1787b5b93 # v2.7.5
|
||||
with:
|
||||
# Keep this and the github action up to date with the version of flux installed in dogfood cluster
|
||||
version: "2.7.0"
|
||||
|
||||
- name: Get Cluster Credentials
|
||||
uses: google-github-actions/get-gke-credentials@3da1e46a907576cefaa90c484278bb5b259dd395 # v3.0.0
|
||||
with:
|
||||
cluster_name: dogfood-v2
|
||||
location: us-central1-a
|
||||
project_id: coder-dogfood-v2
|
||||
version: "2.8.2"
|
||||
|
||||
# Retag image as dogfood while maintaining the multi-arch manifest
|
||||
- name: Tag image as dogfood
|
||||
@@ -146,7 +142,7 @@ jobs:
|
||||
needs: deploy
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@5ef0c079ce82195b2a36a210272d6b661572d83e # v2.14.2
|
||||
uses: step-security/harden-runner@fa2e9d605c4eeb9fcad4c99c224cee0c6c7f3594 # v2.16.0
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
|
||||
4
.github/workflows/docker-base.yaml
vendored
4
.github/workflows/docker-base.yaml
vendored
@@ -38,7 +38,7 @@ jobs:
|
||||
if: github.repository_owner == 'coder'
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@5ef0c079ce82195b2a36a210272d6b661572d83e # v2.14.2
|
||||
uses: step-security/harden-runner@fa2e9d605c4eeb9fcad4c99c224cee0c6c7f3594 # v2.16.0
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
@@ -48,7 +48,7 @@ jobs:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Docker login
|
||||
uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0
|
||||
uses: docker/login-action@b45d80f862d83dbcd57f89517bcf500b2ab88fb2 # v4.0.0
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
|
||||
2
.github/workflows/docs-ci.yaml
vendored
2
.github/workflows/docs-ci.yaml
vendored
@@ -30,7 +30,7 @@ jobs:
|
||||
- name: Setup Node
|
||||
uses: ./.github/actions/setup-node
|
||||
|
||||
- uses: tj-actions/changed-files@e0021407031f5be11a464abee9a0776171c79891 # v45.0.7
|
||||
- uses: tj-actions/changed-files@22103cc46bda19c2b464ffe86db46df6922fd323 # v45.0.7
|
||||
id: changed-files
|
||||
with:
|
||||
files: |
|
||||
|
||||
8
.github/workflows/dogfood.yaml
vendored
8
.github/workflows/dogfood.yaml
vendored
@@ -26,7 +26,7 @@ jobs:
|
||||
runs-on: ${{ github.repository_owner == 'coder' && 'depot-ubuntu-22.04-4' || 'ubuntu-latest' }}
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@5ef0c079ce82195b2a36a210272d6b661572d83e # v2.14.2
|
||||
uses: step-security/harden-runner@fa2e9d605c4eeb9fcad4c99c224cee0c6c7f3594 # v2.16.0
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
@@ -78,11 +78,11 @@ jobs:
|
||||
uses: depot/setup-action@15c09a5f77a0840ad4bce955686522a257853461 # v1.7.1
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.12.0
|
||||
uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0
|
||||
|
||||
- name: Login to DockerHub
|
||||
if: github.ref == 'refs/heads/main'
|
||||
uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0
|
||||
uses: docker/login-action@b45d80f862d83dbcd57f89517bcf500b2ab88fb2 # v4.0.0
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_PASSWORD }}
|
||||
@@ -125,7 +125,7 @@ jobs:
|
||||
id-token: write
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@5ef0c079ce82195b2a36a210272d6b661572d83e # v2.14.2
|
||||
uses: step-security/harden-runner@fa2e9d605c4eeb9fcad4c99c224cee0c6c7f3594 # v2.16.0
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
|
||||
4
.github/workflows/linear-release.yaml
vendored
4
.github/workflows/linear-release.yaml
vendored
@@ -30,7 +30,7 @@ jobs:
|
||||
|
||||
- name: Sync issues
|
||||
id: sync
|
||||
uses: linear/linear-release-action@f64cdc603e6eb7a7ef934bc5492ae929f88c8d1a # v0
|
||||
uses: linear/linear-release-action@5cbaabc187ceb63eee9d446e62e68e5c29a03ae8 # v0.5.0
|
||||
with:
|
||||
access_key: ${{ secrets.LINEAR_ACCESS_KEY }}
|
||||
command: sync
|
||||
@@ -52,7 +52,7 @@ jobs:
|
||||
|
||||
- name: Complete release
|
||||
id: complete
|
||||
uses: linear/linear-release-action@f64cdc603e6eb7a7ef934bc5492ae929f88c8d1a # v0
|
||||
uses: linear/linear-release-action@5cbaabc187ceb63eee9d446e62e68e5c29a03ae8 # v0
|
||||
with:
|
||||
access_key: ${{ secrets.LINEAR_ACCESS_KEY }}
|
||||
command: complete
|
||||
|
||||
2
.github/workflows/nightly-gauntlet.yaml
vendored
2
.github/workflows/nightly-gauntlet.yaml
vendored
@@ -28,7 +28,7 @@ jobs:
|
||||
- windows-2022
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@5ef0c079ce82195b2a36a210272d6b661572d83e # v2.14.2
|
||||
uses: step-security/harden-runner@fa2e9d605c4eeb9fcad4c99c224cee0c6c7f3594 # v2.16.0
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
|
||||
2
.github/workflows/pr-auto-assign.yaml
vendored
2
.github/workflows/pr-auto-assign.yaml
vendored
@@ -15,7 +15,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@5ef0c079ce82195b2a36a210272d6b661572d83e # v2.14.2
|
||||
uses: step-security/harden-runner@fa2e9d605c4eeb9fcad4c99c224cee0c6c7f3594 # v2.16.0
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
|
||||
2
.github/workflows/pr-cleanup.yaml
vendored
2
.github/workflows/pr-cleanup.yaml
vendored
@@ -19,7 +19,7 @@ jobs:
|
||||
packages: write
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@5ef0c079ce82195b2a36a210272d6b661572d83e # v2.14.2
|
||||
uses: step-security/harden-runner@fa2e9d605c4eeb9fcad4c99c224cee0c6c7f3594 # v2.16.0
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
|
||||
12
.github/workflows/pr-deploy.yaml
vendored
12
.github/workflows/pr-deploy.yaml
vendored
@@ -39,7 +39,7 @@ jobs:
|
||||
PR_OPEN: ${{ steps.check_pr.outputs.pr_open }}
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@5ef0c079ce82195b2a36a210272d6b661572d83e # v2.14.2
|
||||
uses: step-security/harden-runner@fa2e9d605c4eeb9fcad4c99c224cee0c6c7f3594 # v2.16.0
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
@@ -76,7 +76,7 @@ jobs:
|
||||
runs-on: "ubuntu-latest"
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@5ef0c079ce82195b2a36a210272d6b661572d83e # v2.14.2
|
||||
uses: step-security/harden-runner@fa2e9d605c4eeb9fcad4c99c224cee0c6c7f3594 # v2.16.0
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
@@ -184,7 +184,7 @@ jobs:
|
||||
pull-requests: write # needed for commenting on PRs
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@5ef0c079ce82195b2a36a210272d6b661572d83e # v2.14.2
|
||||
uses: step-security/harden-runner@fa2e9d605c4eeb9fcad4c99c224cee0c6c7f3594 # v2.16.0
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
@@ -228,7 +228,7 @@ jobs:
|
||||
CODER_IMAGE_TAG: ${{ needs.get_info.outputs.CODER_IMAGE_TAG }}
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@5ef0c079ce82195b2a36a210272d6b661572d83e # v2.14.2
|
||||
uses: step-security/harden-runner@fa2e9d605c4eeb9fcad4c99c224cee0c6c7f3594 # v2.16.0
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
@@ -248,7 +248,7 @@ jobs:
|
||||
uses: ./.github/actions/setup-sqlc
|
||||
|
||||
- name: GHCR Login
|
||||
uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0
|
||||
uses: docker/login-action@b45d80f862d83dbcd57f89517bcf500b2ab88fb2 # v4.0.0
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
@@ -288,7 +288,7 @@ jobs:
|
||||
PR_HOSTNAME: "pr${{ needs.get_info.outputs.PR_NUMBER }}.${{ secrets.PR_DEPLOYMENTS_DOMAIN }}"
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@5ef0c079ce82195b2a36a210272d6b661572d83e # v2.14.2
|
||||
uses: step-security/harden-runner@fa2e9d605c4eeb9fcad4c99c224cee0c6c7f3594 # v2.16.0
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
|
||||
4
.github/workflows/release-validation.yaml
vendored
4
.github/workflows/release-validation.yaml
vendored
@@ -14,12 +14,12 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@5ef0c079ce82195b2a36a210272d6b661572d83e # v2.14.2
|
||||
uses: step-security/harden-runner@fa2e9d605c4eeb9fcad4c99c224cee0c6c7f3594 # v2.16.0
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
- name: Run Schmoder CI
|
||||
uses: benc-uk/workflow-dispatch@e2e5e9a103e331dad343f381a29e654aea3cf8fc # v1.2.4
|
||||
uses: benc-uk/workflow-dispatch@7a027648b88c2413826b6ddd6c76114894dc5ec4 # v1.3.1
|
||||
with:
|
||||
workflow: ci.yaml
|
||||
repo: coder/schmoder
|
||||
|
||||
22
.github/workflows/release.yaml
vendored
22
.github/workflows/release.yaml
vendored
@@ -80,7 +80,7 @@ jobs:
|
||||
version: ${{ steps.version.outputs.version }}
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@5ef0c079ce82195b2a36a210272d6b661572d83e # v2.14.2
|
||||
uses: step-security/harden-runner@fa2e9d605c4eeb9fcad4c99c224cee0c6c7f3594 # v2.16.0
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
@@ -155,7 +155,7 @@ jobs:
|
||||
cat "$CODER_RELEASE_NOTES_FILE"
|
||||
|
||||
- name: Docker Login
|
||||
uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0
|
||||
uses: docker/login-action@b45d80f862d83dbcd57f89517bcf500b2ab88fb2 # v4.0.0
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
@@ -358,7 +358,7 @@ jobs:
|
||||
id: attest_base
|
||||
if: ${{ !inputs.dry_run && steps.image-base-tag.outputs.tag != '' }}
|
||||
continue-on-error: true
|
||||
uses: actions/attest@e59cbc1ad1ac2d59339667419eb8cdde6eb61e3d # v3.2.0
|
||||
uses: actions/attest@59d89421af93a897026c735860bf21b6eb4f7b26 # v4.1.0
|
||||
with:
|
||||
subject-name: ${{ steps.image-base-tag.outputs.tag }}
|
||||
predicate-type: "https://slsa.dev/provenance/v1"
|
||||
@@ -474,7 +474,7 @@ jobs:
|
||||
id: attest_main
|
||||
if: ${{ !inputs.dry_run }}
|
||||
continue-on-error: true
|
||||
uses: actions/attest@e59cbc1ad1ac2d59339667419eb8cdde6eb61e3d # v3.2.0
|
||||
uses: actions/attest@59d89421af93a897026c735860bf21b6eb4f7b26 # v4.1.0
|
||||
with:
|
||||
subject-name: ${{ steps.build_docker.outputs.multiarch_image }}
|
||||
predicate-type: "https://slsa.dev/provenance/v1"
|
||||
@@ -518,7 +518,7 @@ jobs:
|
||||
id: attest_latest
|
||||
if: ${{ !inputs.dry_run && steps.build_docker.outputs.created_latest_tag == 'true' }}
|
||||
continue-on-error: true
|
||||
uses: actions/attest@e59cbc1ad1ac2d59339667419eb8cdde6eb61e3d # v3.2.0
|
||||
uses: actions/attest@59d89421af93a897026c735860bf21b6eb4f7b26 # v4.1.0
|
||||
with:
|
||||
subject-name: ${{ steps.latest_tag.outputs.tag }}
|
||||
predicate-type: "https://slsa.dev/provenance/v1"
|
||||
@@ -665,7 +665,7 @@ jobs:
|
||||
|
||||
- name: Upload artifacts to actions (if dry-run)
|
||||
if: ${{ inputs.dry_run }}
|
||||
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
|
||||
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
|
||||
with:
|
||||
name: release-artifacts
|
||||
path: |
|
||||
@@ -681,7 +681,7 @@ jobs:
|
||||
|
||||
- name: Upload latest sbom artifact to actions (if dry-run)
|
||||
if: inputs.dry_run && steps.build_docker.outputs.created_latest_tag == 'true'
|
||||
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
|
||||
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
|
||||
with:
|
||||
name: latest-sbom-artifact
|
||||
path: ./coder_latest_sbom.spdx.json
|
||||
@@ -700,13 +700,11 @@ jobs:
|
||||
name: Publish to Homebrew tap
|
||||
runs-on: ubuntu-latest
|
||||
needs: release
|
||||
if: ${{ !inputs.dry_run }}
|
||||
if: ${{ !inputs.dry_run && inputs.release_channel == 'mainline' }}
|
||||
|
||||
steps:
|
||||
# TODO: skip this if it's not a new release (i.e. a backport). This is
|
||||
# fine right now because it just makes a PR that we can close.
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@5ef0c079ce82195b2a36a210272d6b661572d83e # v2.14.2
|
||||
uses: step-security/harden-runner@fa2e9d605c4eeb9fcad4c99c224cee0c6c7f3594 # v2.16.0
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
@@ -782,7 +780,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@5ef0c079ce82195b2a36a210272d6b661572d83e # v2.14.2
|
||||
uses: step-security/harden-runner@fa2e9d605c4eeb9fcad4c99c224cee0c6c7f3594 # v2.16.0
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
|
||||
4
.github/workflows/scorecard.yml
vendored
4
.github/workflows/scorecard.yml
vendored
@@ -20,7 +20,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@5ef0c079ce82195b2a36a210272d6b661572d83e # v2.14.2
|
||||
uses: step-security/harden-runner@fa2e9d605c4eeb9fcad4c99c224cee0c6c7f3594 # v2.16.0
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
@@ -39,7 +39,7 @@ jobs:
|
||||
|
||||
# Upload the results as artifacts.
|
||||
- name: "Upload artifact"
|
||||
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
|
||||
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
|
||||
with:
|
||||
name: SARIF file
|
||||
path: results.sarif
|
||||
|
||||
8
.github/workflows/security.yaml
vendored
8
.github/workflows/security.yaml
vendored
@@ -27,7 +27,7 @@ jobs:
|
||||
runs-on: ${{ github.repository_owner == 'coder' && 'depot-ubuntu-22.04-8' || 'ubuntu-latest' }}
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@5ef0c079ce82195b2a36a210272d6b661572d83e # v2.14.2
|
||||
uses: step-security/harden-runner@fa2e9d605c4eeb9fcad4c99c224cee0c6c7f3594 # v2.16.0
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
@@ -69,7 +69,7 @@ jobs:
|
||||
runs-on: ${{ github.repository_owner == 'coder' && 'depot-ubuntu-22.04-8' || 'ubuntu-latest' }}
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@5ef0c079ce82195b2a36a210272d6b661572d83e # v2.14.2
|
||||
uses: step-security/harden-runner@fa2e9d605c4eeb9fcad4c99c224cee0c6c7f3594 # v2.16.0
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
@@ -146,7 +146,7 @@ jobs:
|
||||
echo "image=$(cat "$image_job")" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Run Trivy vulnerability scanner
|
||||
uses: aquasecurity/trivy-action@c1824fd6edce30d7ab345a9989de00bbd46ef284 # v0.34.0
|
||||
uses: aquasecurity/trivy-action@57a97c7e7821a5776cebc9bb87c984fa69cba8f1 # v0.34.0
|
||||
with:
|
||||
image-ref: ${{ steps.build.outputs.image }}
|
||||
format: sarif
|
||||
@@ -160,7 +160,7 @@ jobs:
|
||||
category: "Trivy"
|
||||
|
||||
- name: Upload Trivy scan results as an artifact
|
||||
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
|
||||
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
|
||||
with:
|
||||
name: trivy
|
||||
path: trivy-results.sarif
|
||||
|
||||
6
.github/workflows/stale.yaml
vendored
6
.github/workflows/stale.yaml
vendored
@@ -18,7 +18,7 @@ jobs:
|
||||
pull-requests: write
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@5ef0c079ce82195b2a36a210272d6b661572d83e # v2.14.2
|
||||
uses: step-security/harden-runner@fa2e9d605c4eeb9fcad4c99c224cee0c6c7f3594 # v2.16.0
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
@@ -96,7 +96,7 @@ jobs:
|
||||
contents: write
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@5ef0c079ce82195b2a36a210272d6b661572d83e # v2.14.2
|
||||
uses: step-security/harden-runner@fa2e9d605c4eeb9fcad4c99c224cee0c6c7f3594 # v2.16.0
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
@@ -120,7 +120,7 @@ jobs:
|
||||
actions: write
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@5ef0c079ce82195b2a36a210272d6b661572d83e # v2.14.2
|
||||
uses: step-security/harden-runner@fa2e9d605c4eeb9fcad4c99c224cee0c6c7f3594 # v2.16.0
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
|
||||
295
.github/workflows/triage-via-chat-api.yaml
vendored
Normal file
295
.github/workflows/triage-via-chat-api.yaml
vendored
Normal file
@@ -0,0 +1,295 @@
|
||||
# This workflow reimplements the AI Triage Automation using the Coder Chat API
|
||||
# instead of the Tasks API. The Chat API (/api/experimental/chats) is a simpler
|
||||
# interface that does not require a dedicated GitHub Action or workspace
|
||||
# provisioning — we just create a chat, poll for completion, and link the
|
||||
# result on the issue. All API calls use curl + jq directly.
|
||||
#
|
||||
# Key differences from the Tasks API workflow (traiage.yaml):
|
||||
# - No checkout of coder/create-task-action; everything is inline curl/jq.
|
||||
# - No template_name / template_preset / prefix inputs — the Chat API handles
|
||||
# resource allocation internally.
|
||||
# - Uses POST /api/experimental/chats to create a chat session.
|
||||
# - Polls GET /api/experimental/chats/<id> until the agent finishes.
|
||||
# - Chat URL format: ${CODER_URL}/agents?chat=${CHAT_ID}
|
||||
|
||||
name: AI Triage via Chat API
|
||||
|
||||
on:
|
||||
issues:
|
||||
types:
|
||||
- labeled
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
issue_url:
|
||||
description: "GitHub Issue URL to process"
|
||||
required: true
|
||||
type: string
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
triage-chat:
|
||||
name: Triage GitHub Issue via Chat API
|
||||
runs-on: ubuntu-latest
|
||||
if: github.event.label.name == 'chat-triage' || github.event_name == 'workflow_dispatch'
|
||||
timeout-minutes: 30
|
||||
env:
|
||||
CODER_URL: ${{ secrets.TRAIAGE_CODER_URL }}
|
||||
CODER_SESSION_TOKEN: ${{ secrets.TRAIAGE_CODER_SESSION_TOKEN }}
|
||||
permissions:
|
||||
contents: read
|
||||
issues: write
|
||||
|
||||
steps:
|
||||
# ------------------------------------------------------------------
|
||||
# Step 1: Determine the GitHub user and issue URL.
|
||||
# Identical to the Tasks API workflow — resolve the actor for
|
||||
# workflow_dispatch or the issue sender for label events.
|
||||
# ------------------------------------------------------------------
|
||||
- name: Determine Inputs
|
||||
id: determine-inputs
|
||||
if: always()
|
||||
env:
|
||||
GITHUB_ACTOR: ${{ github.actor }}
|
||||
GITHUB_EVENT_ISSUE_HTML_URL: ${{ github.event.issue.html_url }}
|
||||
GITHUB_EVENT_NAME: ${{ github.event_name }}
|
||||
GITHUB_EVENT_USER_ID: ${{ github.event.sender.id }}
|
||||
GITHUB_EVENT_USER_LOGIN: ${{ github.event.sender.login }}
|
||||
INPUTS_ISSUE_URL: ${{ inputs.issue_url }}
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
# For workflow_dispatch, use the actor who triggered it.
|
||||
# For issues events, use the issue sender.
|
||||
if [[ "${GITHUB_EVENT_NAME}" == "workflow_dispatch" ]]; then
|
||||
if ! GITHUB_USER_ID=$(gh api "users/${GITHUB_ACTOR}" --jq '.id'); then
|
||||
echo "::error::Failed to get GitHub user ID for actor ${GITHUB_ACTOR}"
|
||||
exit 1
|
||||
fi
|
||||
echo "Using workflow_dispatch actor: ${GITHUB_ACTOR} (ID: ${GITHUB_USER_ID})"
|
||||
echo "github_user_id=${GITHUB_USER_ID}" >> "${GITHUB_OUTPUT}"
|
||||
echo "github_username=${GITHUB_ACTOR}" >> "${GITHUB_OUTPUT}"
|
||||
|
||||
echo "Using issue URL: ${INPUTS_ISSUE_URL}"
|
||||
echo "issue_url=${INPUTS_ISSUE_URL}" >> "${GITHUB_OUTPUT}"
|
||||
|
||||
exit 0
|
||||
elif [[ "${GITHUB_EVENT_NAME}" == "issues" ]]; then
|
||||
GITHUB_USER_ID=${GITHUB_EVENT_USER_ID}
|
||||
echo "Using issue author: ${GITHUB_EVENT_USER_LOGIN} (ID: ${GITHUB_USER_ID})"
|
||||
echo "github_user_id=${GITHUB_USER_ID}" >> "${GITHUB_OUTPUT}"
|
||||
echo "github_username=${GITHUB_EVENT_USER_LOGIN}" >> "${GITHUB_OUTPUT}"
|
||||
|
||||
echo "Using issue URL: ${GITHUB_EVENT_ISSUE_HTML_URL}"
|
||||
echo "issue_url=${GITHUB_EVENT_ISSUE_HTML_URL}" >> "${GITHUB_OUTPUT}"
|
||||
|
||||
exit 0
|
||||
else
|
||||
echo "::error::Unsupported event type: ${GITHUB_EVENT_NAME}"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Step 2: Verify the triggering user has push access.
|
||||
# Unchanged from the Tasks API workflow.
|
||||
# ------------------------------------------------------------------
|
||||
- name: Verify push access
|
||||
env:
|
||||
GITHUB_REPOSITORY: ${{ github.repository }}
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
GITHUB_USERNAME: ${{ steps.determine-inputs.outputs.github_username }}
|
||||
GITHUB_USER_ID: ${{ steps.determine-inputs.outputs.github_user_id }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
can_push="$(gh api "/repos/${GITHUB_REPOSITORY}/collaborators/${GITHUB_USERNAME}/permission" --jq '.user.permissions.push')"
|
||||
if [[ "${can_push}" != "true" ]]; then
|
||||
echo "::error title=Access Denied::${GITHUB_USERNAME} does not have push access to ${GITHUB_REPOSITORY}"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Step 3: Create a chat via the Coder Chat API.
|
||||
# Unlike the Tasks API which provisions a full workspace, the Chat
|
||||
# API creates a lightweight chat session. We POST to
|
||||
# /api/experimental/chats with the triage prompt as the initial
|
||||
# message and receive a chat ID back.
|
||||
# ------------------------------------------------------------------
|
||||
- name: Create chat via Coder Chat API
|
||||
id: create-chat
|
||||
env:
|
||||
ISSUE_URL: ${{ steps.determine-inputs.outputs.issue_url }}
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
# Build the same triage prompt used by the Tasks API workflow.
|
||||
TASK_PROMPT=$(cat <<'EOF'
|
||||
Fix ${ISSUE_URL}
|
||||
|
||||
1. Use the gh CLI to read the issue description and comments.
|
||||
2. Think carefully and try to understand the root cause. If the issue is unclear or not well defined, ask me to clarify and provide more information.
|
||||
3. Write a proposed implementation plan to PLAN.md for me to review before starting implementation. Your plan should use TDD and only make the minimal changes necessary to fix the root cause.
|
||||
4. When I approve your plan, start working on it. If you encounter issues with the plan, ask me for clarification and update the plan as required.
|
||||
5. When you have finished implementation according to the plan, commit and push your changes, and create a PR using the gh CLI for me to review.
|
||||
EOF
|
||||
)
|
||||
# Perform variable substitution on the prompt — scoped to $ISSUE_URL only.
|
||||
# Using envsubst without arguments would expand every env var in scope
|
||||
# (including CODER_SESSION_TOKEN), so we name the variable explicitly.
|
||||
TASK_PROMPT=$(echo "${TASK_PROMPT}" | envsubst '$ISSUE_URL')
|
||||
|
||||
echo "Creating chat with prompt:"
|
||||
echo "${TASK_PROMPT}"
|
||||
|
||||
# POST to the Chat API to create a new chat session.
|
||||
RESPONSE=$(curl --silent --fail-with-body \
|
||||
-X POST \
|
||||
-H "Coder-Session-Token: ${CODER_SESSION_TOKEN}" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "$(jq -n --arg prompt "${TASK_PROMPT}" \
|
||||
'{content: [{type: "text", text: $prompt}]}')" \
|
||||
"${CODER_URL}/api/experimental/chats")
|
||||
|
||||
echo "Chat API response:"
|
||||
echo "${RESPONSE}" | jq .
|
||||
|
||||
CHAT_ID=$(echo "${RESPONSE}" | jq -r '.id')
|
||||
CHAT_STATUS=$(echo "${RESPONSE}" | jq -r '.status')
|
||||
|
||||
if [[ -z "${CHAT_ID}" || "${CHAT_ID}" == "null" ]]; then
|
||||
echo "::error::Failed to create chat — no ID returned"
|
||||
echo "Response: ${RESPONSE}"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Validate that CHAT_ID is a UUID before using it in URL paths.
|
||||
# This guards against unexpected API responses being interpolated
|
||||
# into subsequent curl calls.
|
||||
if [[ ! "${CHAT_ID}" =~ ^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$ ]]; then
|
||||
echo "::error::CHAT_ID is not a valid UUID: ${CHAT_ID}"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
CHAT_URL="${CODER_URL}/agents?chat=${CHAT_ID}"
|
||||
|
||||
echo "Chat created: ${CHAT_ID} (status: ${CHAT_STATUS})"
|
||||
echo "Chat URL: ${CHAT_URL}"
|
||||
|
||||
echo "chat_id=${CHAT_ID}" >> "${GITHUB_OUTPUT}"
|
||||
echo "chat_url=${CHAT_URL}" >> "${GITHUB_OUTPUT}"
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Step 4: Poll the chat status until the agent finishes.
|
||||
# The Chat API is asynchronous — after creation the agent begins
|
||||
# working in the background. We poll GET /api/experimental/chats/<id>
|
||||
# every 5 seconds until the status is "waiting" (agent needs input),
|
||||
# "completed" (agent finished), or "error". Timeout after 10 minutes.
|
||||
# ------------------------------------------------------------------
|
||||
- name: Poll chat status
|
||||
id: poll-status
|
||||
env:
|
||||
CHAT_ID: ${{ steps.create-chat.outputs.chat_id }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
POLL_INTERVAL=5
|
||||
# 10 minutes = 600 seconds.
|
||||
TIMEOUT=600
|
||||
ELAPSED=0
|
||||
|
||||
echo "Polling chat ${CHAT_ID} every ${POLL_INTERVAL}s (timeout: ${TIMEOUT}s)..."
|
||||
|
||||
while true; do
|
||||
RESPONSE=$(curl --silent --fail-with-body \
|
||||
-H "Coder-Session-Token: ${CODER_SESSION_TOKEN}" \
|
||||
"${CODER_URL}/api/experimental/chats/${CHAT_ID}")
|
||||
|
||||
STATUS=$(echo "${RESPONSE}" | jq -r '.status')
|
||||
|
||||
echo "[${ELAPSED}s] Chat status: ${STATUS}"
|
||||
|
||||
case "${STATUS}" in
|
||||
waiting|completed)
|
||||
echo "Chat reached terminal status: ${STATUS}"
|
||||
echo "final_status=${STATUS}" >> "${GITHUB_OUTPUT}"
|
||||
exit 0
|
||||
;;
|
||||
error)
|
||||
echo "::error::Chat entered error state"
|
||||
echo "${RESPONSE}" | jq .
|
||||
echo "final_status=error" >> "${GITHUB_OUTPUT}"
|
||||
exit 1
|
||||
;;
|
||||
pending|running)
|
||||
# Still working — keep polling.
|
||||
;;
|
||||
*)
|
||||
echo "::warning::Unknown chat status: ${STATUS}"
|
||||
;;
|
||||
esac
|
||||
|
||||
if [[ ${ELAPSED} -ge ${TIMEOUT} ]]; then
|
||||
echo "::error::Timed out after ${TIMEOUT}s waiting for chat to finish"
|
||||
echo "final_status=timeout" >> "${GITHUB_OUTPUT}"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
sleep "${POLL_INTERVAL}"
|
||||
ELAPSED=$((ELAPSED + POLL_INTERVAL))
|
||||
done
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Step 5: Comment on the GitHub issue with a link to the chat.
|
||||
# Only comment if the issue belongs to this repository (same guard
|
||||
# as the Tasks API workflow).
|
||||
# ------------------------------------------------------------------
|
||||
- name: Comment on issue
|
||||
if: startsWith(steps.determine-inputs.outputs.issue_url, format('{0}/{1}', github.server_url, github.repository))
|
||||
env:
|
||||
ISSUE_URL: ${{ steps.determine-inputs.outputs.issue_url }}
|
||||
CHAT_URL: ${{ steps.create-chat.outputs.chat_url }}
|
||||
CHAT_ID: ${{ steps.create-chat.outputs.chat_id }}
|
||||
FINAL_STATUS: ${{ steps.poll-status.outputs.final_status }}
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
COMMENT_BODY=$(cat <<EOF
|
||||
🤖 **AI Triage Chat Created**
|
||||
|
||||
A Coder chat session has been created to investigate this issue.
|
||||
|
||||
**Chat URL:** ${CHAT_URL}
|
||||
**Chat ID:** \`${CHAT_ID}\`
|
||||
**Status:** ${FINAL_STATUS}
|
||||
|
||||
The agent is working on a triage plan. Visit the chat to follow progress or provide guidance.
|
||||
EOF
|
||||
)
|
||||
|
||||
gh issue comment "${ISSUE_URL}" --body "${COMMENT_BODY}"
|
||||
echo "Comment posted on ${ISSUE_URL}"
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Step 6: Write a summary to the GitHub Actions step summary.
|
||||
# ------------------------------------------------------------------
|
||||
- name: Write summary
|
||||
env:
|
||||
CHAT_ID: ${{ steps.create-chat.outputs.chat_id }}
|
||||
CHAT_URL: ${{ steps.create-chat.outputs.chat_url }}
|
||||
FINAL_STATUS: ${{ steps.poll-status.outputs.final_status }}
|
||||
ISSUE_URL: ${{ steps.determine-inputs.outputs.issue_url }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
{
|
||||
echo "## AI Triage via Chat API"
|
||||
echo ""
|
||||
echo "**Issue:** ${ISSUE_URL}"
|
||||
echo "**Chat ID:** \`${CHAT_ID}\`"
|
||||
echo "**Chat URL:** ${CHAT_URL}"
|
||||
echo "**Status:** ${FINAL_STATUS}"
|
||||
} >> "${GITHUB_STEP_SUMMARY}"
|
||||
2
.github/workflows/typos.toml
vendored
2
.github/workflows/typos.toml
vendored
@@ -29,6 +29,8 @@ EDE = "EDE"
|
||||
HELO = "HELO"
|
||||
LKE = "LKE"
|
||||
byt = "byt"
|
||||
cpy = "cpy"
|
||||
Cpy = "Cpy"
|
||||
typ = "typ"
|
||||
# file extensions used in seti icon theme
|
||||
styl = "styl"
|
||||
|
||||
2
.github/workflows/weekly-docs.yaml
vendored
2
.github/workflows/weekly-docs.yaml
vendored
@@ -21,7 +21,7 @@ jobs:
|
||||
pull-requests: write # required to post PR review comments by the action
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@5ef0c079ce82195b2a36a210272d6b661572d83e # v2.14.2
|
||||
uses: step-security/harden-runner@fa2e9d605c4eeb9fcad4c99c224cee0c6c7f3594 # v2.16.0
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
|
||||
39
AGENTS.md
39
AGENTS.md
@@ -146,11 +146,20 @@ git config core.hooksPath scripts/githooks
|
||||
|
||||
Two hooks run automatically:
|
||||
|
||||
- **pre-commit**: `make pre-commit` (gen, fmt, lint, typos, build).
|
||||
Fast checks that catch most CI failures. Allow at least 5 minutes.
|
||||
- **pre-push**: `make pre-push` (heavier checks including tests).
|
||||
Allowlisted in `scripts/githooks/pre-push`. Runs only for developers
|
||||
who opt in. Allow at least 15 minutes.
|
||||
- **pre-commit**: Classifies staged files by type and runs either
|
||||
the full `make pre-commit` or the lightweight `make pre-commit-light`
|
||||
depending on whether Go, TypeScript, SQL, proto, or Makefile
|
||||
changes are present. Falls back to the full target when
|
||||
`CODER_HOOK_RUN_ALL=1` is set. A markdown-only commit takes
|
||||
seconds; a Go change takes several minutes.
|
||||
- **pre-push**: Classifies changed files (vs remote branch or
|
||||
merge-base) and runs `make pre-push` when Go, TypeScript, SQL,
|
||||
proto, or Makefile changes are detected. Skips tests entirely
|
||||
for lightweight changes. Allowlisted in
|
||||
`scripts/githooks/pre-push`. Runs only for developers who opt
|
||||
in. Falls back to `make pre-push` when the diff range can't
|
||||
be determined or `CODER_HOOK_RUN_ALL=1` is set. Allow at least
|
||||
15 minutes for a full run.
|
||||
|
||||
`git commit` and `git push` will appear to hang while hooks run.
|
||||
This is normal. Do not interrupt, retry, or reduce the timeout.
|
||||
@@ -208,6 +217,26 @@ seems like it should use `time.Sleep`, read through https://github.com/coder/qua
|
||||
|
||||
- Follow [Uber Go Style Guide](https://github.com/uber-go/guide/blob/master/style.md)
|
||||
- Commit format: `type(scope): message`
|
||||
- PR titles follow the same `type(scope): message` format.
|
||||
- When you use a scope, it must be a real filesystem path containing every
|
||||
changed file.
|
||||
- Use a broader path scope, or omit the scope, for cross-cutting changes.
|
||||
- Example: `fix(coderd/chatd): ...` for changes only in `coderd/chatd/`.
|
||||
|
||||
### Frontend Patterns
|
||||
|
||||
- Prefer existing shared UI components and utilities over custom
|
||||
implementations. Reuse common primitives such as loading, table, and error
|
||||
handling components when they fit the use case.
|
||||
- Use Storybook stories for all component and page testing, including
|
||||
visual presentation, user interactions, keyboard navigation, focus
|
||||
management, and accessibility behavior. Do not create standalone
|
||||
vitest/RTL test files for components or pages. Stories double as living
|
||||
documentation, visual regression coverage, and interaction test suites
|
||||
via `play` functions. Reserve plain vitest files for pure logic only:
|
||||
utility functions, data transformations, hooks tested via
|
||||
`renderHook()` that do not require DOM assertions, and query/cache
|
||||
operations with no rendered output.
|
||||
|
||||
### Writing Comments
|
||||
|
||||
|
||||
51
Makefile
51
Makefile
@@ -136,18 +136,10 @@ endif
|
||||
# the search path so that these exclusions match.
|
||||
FIND_EXCLUSIONS= \
|
||||
-not \( \( -path '*/.git/*' -o -path './build/*' -o -path './vendor/*' -o -path './.coderv2/*' -o -path '*/node_modules/*' -o -path '*/out/*' -o -path './coderd/apidoc/*' -o -path '*/.next/*' -o -path '*/.terraform/*' -o -path './_gen/*' \) -prune \)
|
||||
|
||||
# Source files used for make targets, evaluated on use.
|
||||
GO_SRC_FILES := $(shell find . $(FIND_EXCLUSIONS) -type f -name '*.go' -not -name '*_test.go')
|
||||
# Same as GO_SRC_FILES but excluding certain files that have problematic
|
||||
# Makefile dependencies (e.g. pnpm).
|
||||
MOST_GO_SRC_FILES := $(shell \
|
||||
find . \
|
||||
$(FIND_EXCLUSIONS) \
|
||||
-type f \
|
||||
-name '*.go' \
|
||||
-not -name '*_test.go' \
|
||||
-not -wholename './agent/agentcontainers/dcspec/dcspec_gen.go' \
|
||||
)
|
||||
|
||||
# All the shell files in the repo, excluding ignored files.
|
||||
SHELL_SRC_FILES := $(shell find . $(FIND_EXCLUSIONS) -type f -name '*.sh')
|
||||
|
||||
@@ -514,6 +506,12 @@ install: build/coder_$(VERSION)_$(GOOS)_$(GOARCH)$(GOOS_BIN_EXT)
|
||||
cp "$<" "$$output_file"
|
||||
.PHONY: install
|
||||
|
||||
# Only wildcard the go files in the develop directory to avoid rebuilds
|
||||
# when project files are changd. Technically changes to some imports may
|
||||
# not be detected, but it's unlikely to cause any issues.
|
||||
build/.bin/develop: go.mod go.sum $(wildcard scripts/develop/*.go)
|
||||
CGO_ENABLED=0 go build -o $@ ./scripts/develop
|
||||
|
||||
BOLD := $(shell tput bold 2>/dev/null)
|
||||
GREEN := $(shell tput setaf 2 2>/dev/null)
|
||||
RED := $(shell tput setaf 1 2>/dev/null)
|
||||
@@ -524,6 +522,10 @@ RESET := $(shell tput sgr0 2>/dev/null)
|
||||
fmt: fmt/ts fmt/go fmt/terraform fmt/shfmt fmt/biome fmt/markdown
|
||||
.PHONY: fmt
|
||||
|
||||
# Subset of fmt that does not require Go or Node toolchains.
|
||||
fmt-light: fmt/shfmt fmt/terraform fmt/markdown
|
||||
.PHONY: fmt-light
|
||||
|
||||
fmt/go:
|
||||
ifdef FILE
|
||||
# Format single file
|
||||
@@ -631,6 +633,10 @@ LINT_ACTIONS_TARGETS := $(if $(CI),,lint/actions/actionlint)
|
||||
lint: lint/shellcheck lint/go lint/ts lint/examples lint/helm lint/site-icons lint/markdown lint/check-scopes lint/migrations lint/bootstrap $(LINT_ACTIONS_TARGETS)
|
||||
.PHONY: lint
|
||||
|
||||
# Subset of lint that does not require Go or Node toolchains.
|
||||
lint-light: lint/shellcheck lint/markdown lint/helm lint/bootstrap lint/migrations lint/actions/actionlint lint/typos
|
||||
.PHONY: lint-light
|
||||
|
||||
lint/site-icons:
|
||||
./scripts/check_site_icons.sh
|
||||
.PHONY: lint/site-icons
|
||||
@@ -773,6 +779,25 @@ pre-commit:
|
||||
echo "$(GREEN)✓ pre-commit passed$(RESET) ($$(( $$(date +%s) - $$start ))s)"
|
||||
.PHONY: pre-commit
|
||||
|
||||
# Lightweight pre-commit for changes that don't touch Go or
|
||||
# TypeScript. Skips gen, lint/go, lint/ts, fmt/go, fmt/ts, and
|
||||
# the binary build. Used by the pre-commit hook when only docs,
|
||||
# shell, terraform, helm, or other fast-to-check files changed.
|
||||
pre-commit-light:
|
||||
start=$$(date +%s)
|
||||
logdir=$$(mktemp -d "$${TMPDIR:-/tmp}/coder-pre-commit-light.XXXXXX")
|
||||
echo "$(BOLD)pre-commit-light$(RESET) ($$logdir)"
|
||||
echo "fmt:"
|
||||
$(MAKE) --no-print-directory -j$(PARALLEL_JOBS) MAKE_TIMED=1 MAKE_LOGDIR=$$logdir fmt-light
|
||||
$(check-unstaged)
|
||||
echo "lint:"
|
||||
$(MAKE) --no-print-directory -j$(PARALLEL_JOBS) MAKE_TIMED=1 MAKE_LOGDIR=$$logdir lint-light
|
||||
$(check-unstaged)
|
||||
$(check-untracked)
|
||||
rm -rf $$logdir
|
||||
echo "$(GREEN)✓ pre-commit-light passed$(RESET) ($$(( $$(date +%s) - $$start ))s)"
|
||||
.PHONY: pre-commit-light
|
||||
|
||||
pre-push:
|
||||
start=$$(date +%s)
|
||||
logdir=$$(mktemp -d "$${TMPDIR:-/tmp}/coder-pre-push.XXXXXX")
|
||||
@@ -781,6 +806,7 @@ pre-push:
|
||||
$(MAKE) --no-print-directory -j$(PARALLEL_JOBS) MAKE_TIMED=1 MAKE_LOGDIR=$$logdir \
|
||||
test \
|
||||
test-js \
|
||||
test-storybook \
|
||||
site/out/index.html
|
||||
rm -rf $$logdir
|
||||
echo "$(GREEN)✓ pre-push passed$(RESET) ($$(( $$(date +%s) - $$start ))s)"
|
||||
@@ -1315,6 +1341,11 @@ test-js: site/node_modules/.installed
|
||||
pnpm test:ci
|
||||
.PHONY: test-js
|
||||
|
||||
test-storybook: site/node_modules/.installed
|
||||
cd site/
|
||||
pnpm exec vitest run --project=storybook
|
||||
.PHONY: test-storybook
|
||||
|
||||
# sqlc-cloud-is-setup will fail if no SQLc auth token is set. Use this as a
|
||||
# dependency for any sqlc-cloud related targets.
|
||||
sqlc-cloud-is-setup:
|
||||
|
||||
@@ -39,6 +39,7 @@ import (
|
||||
"cdr.dev/slog/v3"
|
||||
"github.com/coder/clistat"
|
||||
"github.com/coder/coder/v2/agent/agentcontainers"
|
||||
"github.com/coder/coder/v2/agent/agentdesktop"
|
||||
"github.com/coder/coder/v2/agent/agentexec"
|
||||
"github.com/coder/coder/v2/agent/agentfiles"
|
||||
"github.com/coder/coder/v2/agent/agentgit"
|
||||
@@ -310,6 +311,7 @@ type agent struct {
|
||||
filesAPI *agentfiles.API
|
||||
gitAPI *agentgit.API
|
||||
processAPI *agentproc.API
|
||||
desktopAPI *agentdesktop.API
|
||||
|
||||
socketServerEnabled bool
|
||||
socketPath string
|
||||
@@ -383,10 +385,18 @@ func (a *agent) init() {
|
||||
|
||||
pathStore := agentgit.NewPathStore()
|
||||
a.filesAPI = agentfiles.NewAPI(a.logger.Named("files"), a.filesystem, pathStore)
|
||||
a.processAPI = agentproc.NewAPI(a.logger.Named("processes"), a.execer, a.updateCommandEnv, pathStore)
|
||||
a.processAPI = agentproc.NewAPI(a.logger.Named("processes"), a.execer, a.updateCommandEnv, pathStore, func() string {
|
||||
if m := a.manifest.Load(); m != nil {
|
||||
return m.Directory
|
||||
}
|
||||
return ""
|
||||
})
|
||||
gitOpts := append([]agentgit.Option{agentgit.WithClock(a.clock)}, a.gitAPIOptions...)
|
||||
a.gitAPI = agentgit.NewAPI(a.logger.Named("git"), pathStore, gitOpts...)
|
||||
|
||||
desktop := agentdesktop.NewPortableDesktop(
|
||||
a.logger.Named("desktop"), a.execer, a.scriptRunner.ScriptBinDir(),
|
||||
)
|
||||
a.desktopAPI = agentdesktop.NewAPI(a.logger.Named("desktop"), desktop, a.clock)
|
||||
a.reconnectingPTYServer = reconnectingpty.NewServer(
|
||||
a.logger.Named("reconnecting-pty"),
|
||||
a.sshServer,
|
||||
@@ -2057,6 +2067,10 @@ func (a *agent) Close() error {
|
||||
a.logger.Error(a.hardCtx, "process API close", slog.Error(err))
|
||||
}
|
||||
|
||||
if err := a.desktopAPI.Close(); err != nil {
|
||||
a.logger.Error(a.hardCtx, "desktop API close", slog.Error(err))
|
||||
}
|
||||
|
||||
if a.boundaryLogProxy != nil {
|
||||
err = a.boundaryLogProxy.Close()
|
||||
if err != nil {
|
||||
|
||||
@@ -57,18 +57,26 @@ type fakeContainerCLI struct {
|
||||
}
|
||||
|
||||
func (f *fakeContainerCLI) List(_ context.Context) (codersdk.WorkspaceAgentListContainersResponse, error) {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
return f.containers, f.listErr
|
||||
}
|
||||
|
||||
func (f *fakeContainerCLI) DetectArchitecture(_ context.Context, _ string) (string, error) {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
return f.arch, f.archErr
|
||||
}
|
||||
|
||||
func (f *fakeContainerCLI) Copy(ctx context.Context, name, src, dst string) error {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
return f.copyErr
|
||||
}
|
||||
|
||||
func (f *fakeContainerCLI) ExecAs(ctx context.Context, name, user string, args ...string) ([]byte, error) {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
return nil, f.execErr
|
||||
}
|
||||
|
||||
@@ -2689,7 +2697,9 @@ func TestAPI(t *testing.T) {
|
||||
|
||||
// When: The container is recreated (new container ID) with config changes.
|
||||
terraformContainer.ID = "new-container-id"
|
||||
fCCLI.mu.Lock()
|
||||
fCCLI.containers.Containers = []codersdk.WorkspaceAgentContainer{terraformContainer}
|
||||
fCCLI.mu.Unlock()
|
||||
fDCCLI.upID = terraformContainer.ID
|
||||
fDCCLI.readConfig.MergedConfiguration.Customizations.Coder = []agentcontainers.CoderCustomization{{
|
||||
Apps: []agentcontainers.SubAgentApp{{Slug: "app2"}}, // Changed app triggers recreation logic.
|
||||
@@ -2821,7 +2831,9 @@ func TestAPI(t *testing.T) {
|
||||
// Simulate container rebuild: new container ID, changed display apps.
|
||||
newContainerID := "new-container-id"
|
||||
terraformContainer.ID = newContainerID
|
||||
fCCLI.mu.Lock()
|
||||
fCCLI.containers.Containers = []codersdk.WorkspaceAgentContainer{terraformContainer}
|
||||
fCCLI.mu.Unlock()
|
||||
fDCCLI.upID = newContainerID
|
||||
fDCCLI.readConfig.MergedConfiguration.Customizations.Coder = []agentcontainers.CoderCustomization{{
|
||||
DisplayApps: map[codersdk.DisplayApp]bool{
|
||||
@@ -4926,9 +4938,11 @@ func TestDevcontainerPrebuildSupport(t *testing.T) {
|
||||
)
|
||||
api.Start()
|
||||
|
||||
fCCLI.mu.Lock()
|
||||
fCCLI.containers = codersdk.WorkspaceAgentListContainersResponse{
|
||||
Containers: []codersdk.WorkspaceAgentContainer{testContainer},
|
||||
}
|
||||
fCCLI.mu.Unlock()
|
||||
|
||||
// Given: We allow the dev container to be created.
|
||||
fDCCLI.upID = testContainer.ID
|
||||
|
||||
536
agent/agentdesktop/api.go
Normal file
536
agent/agentdesktop/api.go
Normal file
@@ -0,0 +1,536 @@
|
||||
package agentdesktop
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"math"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
|
||||
"cdr.dev/slog/v3"
|
||||
"github.com/coder/coder/v2/agent/agentssh"
|
||||
"github.com/coder/coder/v2/coderd/httpapi"
|
||||
"github.com/coder/coder/v2/codersdk"
|
||||
"github.com/coder/quartz"
|
||||
"github.com/coder/websocket"
|
||||
)
|
||||
|
||||
// DesktopAction is the request body for the desktop action endpoint.
|
||||
type DesktopAction struct {
|
||||
Action string `json:"action"`
|
||||
Coordinate *[2]int `json:"coordinate,omitempty"`
|
||||
StartCoordinate *[2]int `json:"start_coordinate,omitempty"`
|
||||
Text *string `json:"text,omitempty"`
|
||||
Duration *int `json:"duration,omitempty"`
|
||||
ScrollAmount *int `json:"scroll_amount,omitempty"`
|
||||
ScrollDirection *string `json:"scroll_direction,omitempty"`
|
||||
// ScaledWidth and ScaledHeight are the coordinate space the
|
||||
// model is using. When provided, coordinates are linearly
|
||||
// mapped from scaled → native before dispatching.
|
||||
ScaledWidth *int `json:"scaled_width,omitempty"`
|
||||
ScaledHeight *int `json:"scaled_height,omitempty"`
|
||||
}
|
||||
|
||||
// DesktopActionResponse is the response from the desktop action
|
||||
// endpoint.
|
||||
type DesktopActionResponse struct {
|
||||
Output string `json:"output,omitempty"`
|
||||
ScreenshotData string `json:"screenshot_data,omitempty"`
|
||||
ScreenshotWidth int `json:"screenshot_width,omitempty"`
|
||||
ScreenshotHeight int `json:"screenshot_height,omitempty"`
|
||||
}
|
||||
|
||||
// API exposes the desktop streaming HTTP routes for the agent.
|
||||
type API struct {
|
||||
logger slog.Logger
|
||||
desktop Desktop
|
||||
clock quartz.Clock
|
||||
}
|
||||
|
||||
// NewAPI creates a new desktop streaming API.
|
||||
func NewAPI(logger slog.Logger, desktop Desktop, clock quartz.Clock) *API {
|
||||
if clock == nil {
|
||||
clock = quartz.NewReal()
|
||||
}
|
||||
return &API{
|
||||
logger: logger,
|
||||
desktop: desktop,
|
||||
clock: clock,
|
||||
}
|
||||
}
|
||||
|
||||
// Routes returns the chi router for mounting at /api/v0/desktop.
|
||||
func (a *API) Routes() http.Handler {
|
||||
r := chi.NewRouter()
|
||||
r.Get("/vnc", a.handleDesktopVNC)
|
||||
r.Post("/action", a.handleAction)
|
||||
return r
|
||||
}
|
||||
|
||||
func (a *API) handleDesktopVNC(rw http.ResponseWriter, r *http.Request) {
|
||||
ctx := r.Context()
|
||||
|
||||
// Start the desktop session (idempotent).
|
||||
_, err := a.desktop.Start(ctx)
|
||||
if err != nil {
|
||||
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
|
||||
Message: "Failed to start desktop session.",
|
||||
Detail: err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Get a VNC connection.
|
||||
vncConn, err := a.desktop.VNCConn(ctx)
|
||||
if err != nil {
|
||||
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
|
||||
Message: "Failed to connect to VNC server.",
|
||||
Detail: err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
defer vncConn.Close()
|
||||
|
||||
// Accept WebSocket from coderd.
|
||||
conn, err := websocket.Accept(rw, r, &websocket.AcceptOptions{
|
||||
CompressionMode: websocket.CompressionDisabled,
|
||||
})
|
||||
if err != nil {
|
||||
a.logger.Error(ctx, "failed to accept websocket", slog.Error(err))
|
||||
return
|
||||
}
|
||||
|
||||
// No read limit — RFB framebuffer updates can be large.
|
||||
conn.SetReadLimit(-1)
|
||||
|
||||
wsCtx, wsNetConn := codersdk.WebsocketNetConn(ctx, conn, websocket.MessageBinary)
|
||||
defer wsNetConn.Close()
|
||||
|
||||
// Bicopy raw bytes between WebSocket and VNC TCP.
|
||||
agentssh.Bicopy(wsCtx, wsNetConn, vncConn)
|
||||
}
|
||||
|
||||
func (a *API) handleAction(rw http.ResponseWriter, r *http.Request) {
|
||||
ctx := r.Context()
|
||||
handlerStart := a.clock.Now()
|
||||
|
||||
// Ensure the desktop is running and grab native dimensions.
|
||||
cfg, err := a.desktop.Start(ctx)
|
||||
if err != nil {
|
||||
a.logger.Warn(ctx, "handleAction: desktop.Start failed",
|
||||
slog.Error(err),
|
||||
slog.F("elapsed_ms", a.clock.Since(handlerStart).Milliseconds()),
|
||||
)
|
||||
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
|
||||
Message: "Failed to start desktop session.",
|
||||
Detail: err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
var action DesktopAction
|
||||
if err := json.NewDecoder(r.Body).Decode(&action); err != nil {
|
||||
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
|
||||
Message: "Failed to decode request body.",
|
||||
Detail: err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
a.logger.Info(ctx, "handleAction: started",
|
||||
slog.F("action", action.Action),
|
||||
slog.F("elapsed_ms", a.clock.Since(handlerStart).Milliseconds()),
|
||||
)
|
||||
|
||||
// Helper to scale a coordinate pair from the model's space to
|
||||
// native display pixels.
|
||||
scaleXY := func(x, y int) (int, int) {
|
||||
if action.ScaledWidth != nil && *action.ScaledWidth > 0 {
|
||||
x = scaleCoordinate(x, *action.ScaledWidth, cfg.Width)
|
||||
}
|
||||
if action.ScaledHeight != nil && *action.ScaledHeight > 0 {
|
||||
y = scaleCoordinate(y, *action.ScaledHeight, cfg.Height)
|
||||
}
|
||||
return x, y
|
||||
}
|
||||
|
||||
var resp DesktopActionResponse
|
||||
|
||||
switch action.Action {
|
||||
case "key":
|
||||
if action.Text == nil {
|
||||
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
|
||||
Message: "Missing \"text\" for key action.",
|
||||
})
|
||||
return
|
||||
}
|
||||
if err := a.desktop.KeyPress(ctx, *action.Text); err != nil {
|
||||
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
|
||||
Message: "Key press failed.",
|
||||
Detail: err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
resp.Output = "key action performed"
|
||||
|
||||
case "type":
|
||||
if action.Text == nil {
|
||||
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
|
||||
Message: "Missing \"text\" for type action.",
|
||||
})
|
||||
return
|
||||
}
|
||||
if err := a.desktop.Type(ctx, *action.Text); err != nil {
|
||||
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
|
||||
Message: "Type action failed.",
|
||||
Detail: err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
resp.Output = "type action performed"
|
||||
|
||||
case "cursor_position":
|
||||
x, y, err := a.desktop.CursorPosition(ctx)
|
||||
if err != nil {
|
||||
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
|
||||
Message: "Cursor position failed.",
|
||||
Detail: err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
resp.Output = "x=" + strconv.Itoa(x) + ",y=" + strconv.Itoa(y)
|
||||
|
||||
case "mouse_move":
|
||||
x, y, err := coordFromAction(action)
|
||||
if err != nil {
|
||||
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
|
||||
Message: err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
x, y = scaleXY(x, y)
|
||||
if err := a.desktop.Move(ctx, x, y); err != nil {
|
||||
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
|
||||
Message: "Mouse move failed.",
|
||||
Detail: err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
resp.Output = "mouse_move action performed"
|
||||
|
||||
case "left_click":
|
||||
x, y, err := coordFromAction(action)
|
||||
if err != nil {
|
||||
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
|
||||
Message: err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
x, y = scaleXY(x, y)
|
||||
stepStart := a.clock.Now()
|
||||
if err := a.desktop.Click(ctx, x, y, MouseButtonLeft); err != nil {
|
||||
a.logger.Warn(ctx, "handleAction: Click failed",
|
||||
slog.F("action", "left_click"),
|
||||
slog.F("step", "click"),
|
||||
slog.F("step_ms", time.Since(stepStart).Milliseconds()),
|
||||
slog.F("elapsed_ms", a.clock.Since(handlerStart).Milliseconds()),
|
||||
slog.Error(err),
|
||||
)
|
||||
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
|
||||
Message: "Left click failed.",
|
||||
Detail: err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
a.logger.Debug(ctx, "handleAction: Click completed",
|
||||
slog.F("action", "left_click"),
|
||||
slog.F("step_ms", time.Since(stepStart).Milliseconds()),
|
||||
slog.F("elapsed_ms", a.clock.Since(handlerStart).Milliseconds()),
|
||||
)
|
||||
resp.Output = "left_click action performed"
|
||||
|
||||
case "left_click_drag":
|
||||
if action.Coordinate == nil || action.StartCoordinate == nil {
|
||||
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
|
||||
Message: "Missing \"coordinate\" or \"start_coordinate\" for left_click_drag.",
|
||||
})
|
||||
return
|
||||
}
|
||||
sx, sy := scaleXY(action.StartCoordinate[0], action.StartCoordinate[1])
|
||||
ex, ey := scaleXY(action.Coordinate[0], action.Coordinate[1])
|
||||
if err := a.desktop.Drag(ctx, sx, sy, ex, ey); err != nil {
|
||||
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
|
||||
Message: "Left click drag failed.",
|
||||
Detail: err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
resp.Output = "left_click_drag action performed"
|
||||
|
||||
case "left_mouse_down":
|
||||
if err := a.desktop.ButtonDown(ctx, MouseButtonLeft); err != nil {
|
||||
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
|
||||
Message: "Left mouse down failed.",
|
||||
Detail: err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
resp.Output = "left_mouse_down action performed"
|
||||
|
||||
case "left_mouse_up":
|
||||
if err := a.desktop.ButtonUp(ctx, MouseButtonLeft); err != nil {
|
||||
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
|
||||
Message: "Left mouse up failed.",
|
||||
Detail: err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
resp.Output = "left_mouse_up action performed"
|
||||
|
||||
case "right_click":
|
||||
x, y, err := coordFromAction(action)
|
||||
if err != nil {
|
||||
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
|
||||
Message: err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
x, y = scaleXY(x, y)
|
||||
if err := a.desktop.Click(ctx, x, y, MouseButtonRight); err != nil {
|
||||
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
|
||||
Message: "Right click failed.",
|
||||
Detail: err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
resp.Output = "right_click action performed"
|
||||
|
||||
case "middle_click":
|
||||
x, y, err := coordFromAction(action)
|
||||
if err != nil {
|
||||
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
|
||||
Message: err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
x, y = scaleXY(x, y)
|
||||
if err := a.desktop.Click(ctx, x, y, MouseButtonMiddle); err != nil {
|
||||
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
|
||||
Message: "Middle click failed.",
|
||||
Detail: err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
resp.Output = "middle_click action performed"
|
||||
|
||||
case "double_click":
|
||||
x, y, err := coordFromAction(action)
|
||||
if err != nil {
|
||||
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
|
||||
Message: err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
x, y = scaleXY(x, y)
|
||||
if err := a.desktop.DoubleClick(ctx, x, y, MouseButtonLeft); err != nil {
|
||||
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
|
||||
Message: "Double click failed.",
|
||||
Detail: err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
resp.Output = "double_click action performed"
|
||||
|
||||
case "triple_click":
|
||||
x, y, err := coordFromAction(action)
|
||||
if err != nil {
|
||||
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
|
||||
Message: err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
x, y = scaleXY(x, y)
|
||||
for range 3 {
|
||||
if err := a.desktop.Click(ctx, x, y, MouseButtonLeft); err != nil {
|
||||
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
|
||||
Message: "Triple click failed.",
|
||||
Detail: err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
}
|
||||
resp.Output = "triple_click action performed"
|
||||
|
||||
case "scroll":
|
||||
x, y, err := coordFromAction(action)
|
||||
if err != nil {
|
||||
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
|
||||
Message: err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
x, y = scaleXY(x, y)
|
||||
|
||||
amount := 3
|
||||
if action.ScrollAmount != nil {
|
||||
amount = *action.ScrollAmount
|
||||
}
|
||||
direction := "down"
|
||||
if action.ScrollDirection != nil {
|
||||
direction = *action.ScrollDirection
|
||||
}
|
||||
|
||||
var dx, dy int
|
||||
switch direction {
|
||||
case "up":
|
||||
dy = -amount
|
||||
case "down":
|
||||
dy = amount
|
||||
case "left":
|
||||
dx = -amount
|
||||
case "right":
|
||||
dx = amount
|
||||
default:
|
||||
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
|
||||
Message: "Invalid scroll direction: " + direction,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if err := a.desktop.Scroll(ctx, x, y, dx, dy); err != nil {
|
||||
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
|
||||
Message: "Scroll failed.",
|
||||
Detail: err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
resp.Output = "scroll action performed"
|
||||
|
||||
case "hold_key":
|
||||
if action.Text == nil {
|
||||
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
|
||||
Message: "Missing \"text\" for hold_key action.",
|
||||
})
|
||||
return
|
||||
}
|
||||
dur := 1000
|
||||
if action.Duration != nil {
|
||||
dur = *action.Duration
|
||||
}
|
||||
if err := a.desktop.KeyDown(ctx, *action.Text); err != nil {
|
||||
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
|
||||
Message: "Key down failed.",
|
||||
Detail: err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
timer := a.clock.NewTimer(time.Duration(dur)*time.Millisecond, "agentdesktop", "hold_key")
|
||||
defer timer.Stop()
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
// Context canceled; release the key immediately.
|
||||
if err := a.desktop.KeyUp(ctx, *action.Text); err != nil {
|
||||
a.logger.Warn(ctx, "handleAction: KeyUp after context cancel", slog.Error(err))
|
||||
}
|
||||
return
|
||||
case <-timer.C:
|
||||
}
|
||||
if err := a.desktop.KeyUp(ctx, *action.Text); err != nil {
|
||||
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
|
||||
Message: "Key up failed.",
|
||||
Detail: err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
resp.Output = "hold_key action performed"
|
||||
|
||||
case "screenshot":
|
||||
var opts ScreenshotOptions
|
||||
if action.ScaledWidth != nil && *action.ScaledWidth > 0 {
|
||||
opts.TargetWidth = *action.ScaledWidth
|
||||
}
|
||||
if action.ScaledHeight != nil && *action.ScaledHeight > 0 {
|
||||
opts.TargetHeight = *action.ScaledHeight
|
||||
}
|
||||
result, err := a.desktop.Screenshot(ctx, opts)
|
||||
if err != nil {
|
||||
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
|
||||
Message: "Screenshot failed.",
|
||||
Detail: err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
resp.Output = "screenshot"
|
||||
resp.ScreenshotData = result.Data
|
||||
if action.ScaledWidth != nil && *action.ScaledWidth > 0 && *action.ScaledWidth != cfg.Width {
|
||||
resp.ScreenshotWidth = *action.ScaledWidth
|
||||
} else {
|
||||
resp.ScreenshotWidth = cfg.Width
|
||||
}
|
||||
if action.ScaledHeight != nil && *action.ScaledHeight > 0 && *action.ScaledHeight != cfg.Height {
|
||||
resp.ScreenshotHeight = *action.ScaledHeight
|
||||
} else {
|
||||
resp.ScreenshotHeight = cfg.Height
|
||||
}
|
||||
|
||||
default:
|
||||
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
|
||||
Message: "Unknown action: " + action.Action,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
elapsedMs := a.clock.Since(handlerStart).Milliseconds()
|
||||
if ctx.Err() != nil {
|
||||
a.logger.Error(ctx, "handleAction: context canceled before writing response",
|
||||
slog.F("action", action.Action),
|
||||
slog.F("elapsed_ms", elapsedMs),
|
||||
slog.Error(ctx.Err()),
|
||||
)
|
||||
return
|
||||
}
|
||||
a.logger.Info(ctx, "handleAction: writing response",
|
||||
slog.F("action", action.Action),
|
||||
slog.F("elapsed_ms", elapsedMs),
|
||||
)
|
||||
httpapi.Write(ctx, rw, http.StatusOK, resp)
|
||||
}
|
||||
|
||||
// Close shuts down the desktop session if one is running.
|
||||
func (a *API) Close() error {
|
||||
return a.desktop.Close()
|
||||
}
|
||||
|
||||
// coordFromAction extracts the coordinate pair from a DesktopAction,
|
||||
// returning an error if the coordinate field is missing.
|
||||
func coordFromAction(action DesktopAction) (x, y int, err error) {
|
||||
if action.Coordinate == nil {
|
||||
return 0, 0, &missingFieldError{field: "coordinate", action: action.Action}
|
||||
}
|
||||
return action.Coordinate[0], action.Coordinate[1], nil
|
||||
}
|
||||
|
||||
// missingFieldError is returned when a required field is absent from
|
||||
// a DesktopAction.
|
||||
type missingFieldError struct {
|
||||
field string
|
||||
action string
|
||||
}
|
||||
|
||||
func (e *missingFieldError) Error() string {
|
||||
return "Missing \"" + e.field + "\" for " + e.action + " action."
|
||||
}
|
||||
|
||||
// scaleCoordinate maps a coordinate from scaled → native space.
|
||||
func scaleCoordinate(scaled, scaledDim, nativeDim int) int {
|
||||
if scaledDim == 0 || scaledDim == nativeDim {
|
||||
return scaled
|
||||
}
|
||||
native := (float64(scaled)+0.5)*float64(nativeDim)/float64(scaledDim) - 0.5
|
||||
// Clamp to valid range.
|
||||
native = math.Max(native, 0)
|
||||
native = math.Min(native, float64(nativeDim-1))
|
||||
return int(native)
|
||||
}
|
||||
467
agent/agentdesktop/api_test.go
Normal file
467
agent/agentdesktop/api_test.go
Normal file
@@ -0,0 +1,467 @@
|
||||
package agentdesktop_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"golang.org/x/xerrors"
|
||||
|
||||
"cdr.dev/slog/v3/sloggers/slogtest"
|
||||
"github.com/coder/coder/v2/agent/agentdesktop"
|
||||
"github.com/coder/coder/v2/codersdk"
|
||||
"github.com/coder/coder/v2/codersdk/workspacesdk"
|
||||
"github.com/coder/quartz"
|
||||
)
|
||||
|
||||
// Ensure fakeDesktop satisfies the Desktop interface at compile time.
|
||||
var _ agentdesktop.Desktop = (*fakeDesktop)(nil)
|
||||
|
||||
// fakeDesktop is a minimal Desktop implementation for unit tests.
|
||||
type fakeDesktop struct {
|
||||
startErr error
|
||||
startCfg agentdesktop.DisplayConfig
|
||||
vncConnErr error
|
||||
screenshotErr error
|
||||
screenshotRes agentdesktop.ScreenshotResult
|
||||
closed bool
|
||||
|
||||
// Track calls for assertions.
|
||||
lastMove [2]int
|
||||
lastClick [3]int // x, y, button
|
||||
lastScroll [4]int // x, y, dx, dy
|
||||
lastKey string
|
||||
lastTyped string
|
||||
lastKeyDown string
|
||||
lastKeyUp string
|
||||
}
|
||||
|
||||
func (f *fakeDesktop) Start(context.Context) (agentdesktop.DisplayConfig, error) {
|
||||
return f.startCfg, f.startErr
|
||||
}
|
||||
|
||||
func (f *fakeDesktop) VNCConn(context.Context) (net.Conn, error) {
|
||||
return nil, f.vncConnErr
|
||||
}
|
||||
|
||||
func (f *fakeDesktop) Screenshot(_ context.Context, _ agentdesktop.ScreenshotOptions) (agentdesktop.ScreenshotResult, error) {
|
||||
return f.screenshotRes, f.screenshotErr
|
||||
}
|
||||
|
||||
func (f *fakeDesktop) Move(_ context.Context, x, y int) error {
|
||||
f.lastMove = [2]int{x, y}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (f *fakeDesktop) Click(_ context.Context, x, y int, _ agentdesktop.MouseButton) error {
|
||||
f.lastClick = [3]int{x, y, 1}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (f *fakeDesktop) DoubleClick(_ context.Context, x, y int, _ agentdesktop.MouseButton) error {
|
||||
f.lastClick = [3]int{x, y, 2}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (*fakeDesktop) ButtonDown(context.Context, agentdesktop.MouseButton) error { return nil }
|
||||
func (*fakeDesktop) ButtonUp(context.Context, agentdesktop.MouseButton) error { return nil }
|
||||
|
||||
func (f *fakeDesktop) Scroll(_ context.Context, x, y, dx, dy int) error {
|
||||
f.lastScroll = [4]int{x, y, dx, dy}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (*fakeDesktop) Drag(context.Context, int, int, int, int) error { return nil }
|
||||
|
||||
func (f *fakeDesktop) KeyPress(_ context.Context, key string) error {
|
||||
f.lastKey = key
|
||||
return nil
|
||||
}
|
||||
|
||||
func (f *fakeDesktop) KeyDown(_ context.Context, key string) error {
|
||||
f.lastKeyDown = key
|
||||
return nil
|
||||
}
|
||||
|
||||
func (f *fakeDesktop) KeyUp(_ context.Context, key string) error {
|
||||
f.lastKeyUp = key
|
||||
return nil
|
||||
}
|
||||
|
||||
func (f *fakeDesktop) Type(_ context.Context, text string) error {
|
||||
f.lastTyped = text
|
||||
return nil
|
||||
}
|
||||
|
||||
func (*fakeDesktop) CursorPosition(context.Context) (x int, y int, err error) {
|
||||
return 10, 20, nil
|
||||
}
|
||||
|
||||
func (f *fakeDesktop) Close() error {
|
||||
f.closed = true
|
||||
return nil
|
||||
}
|
||||
|
||||
func TestHandleDesktopVNC_StartError(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
logger := slogtest.Make(t, nil)
|
||||
fake := &fakeDesktop{startErr: xerrors.New("no desktop")}
|
||||
api := agentdesktop.NewAPI(logger, fake, nil)
|
||||
defer api.Close()
|
||||
|
||||
rr := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodGet, "/vnc", nil)
|
||||
|
||||
handler := api.Routes()
|
||||
handler.ServeHTTP(rr, req)
|
||||
|
||||
assert.Equal(t, http.StatusInternalServerError, rr.Code)
|
||||
|
||||
var resp codersdk.Response
|
||||
err := json.NewDecoder(rr.Body).Decode(&resp)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "Failed to start desktop session.", resp.Message)
|
||||
}
|
||||
|
||||
func TestHandleAction_Screenshot(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
logger := slogtest.Make(t, nil)
|
||||
fake := &fakeDesktop{
|
||||
startCfg: agentdesktop.DisplayConfig{Width: workspacesdk.DesktopDisplayWidth, Height: workspacesdk.DesktopDisplayHeight},
|
||||
screenshotRes: agentdesktop.ScreenshotResult{Data: "base64data"},
|
||||
}
|
||||
api := agentdesktop.NewAPI(logger, fake, nil)
|
||||
defer api.Close()
|
||||
|
||||
body := agentdesktop.DesktopAction{Action: "screenshot"}
|
||||
b, err := json.Marshal(body)
|
||||
require.NoError(t, err)
|
||||
|
||||
rr := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodPost, "/action", bytes.NewReader(b))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
handler := api.Routes()
|
||||
handler.ServeHTTP(rr, req)
|
||||
|
||||
assert.Equal(t, http.StatusOK, rr.Code)
|
||||
|
||||
var result agentdesktop.DesktopActionResponse
|
||||
err = json.NewDecoder(rr.Body).Decode(&result)
|
||||
require.NoError(t, err)
|
||||
// Dimensions come from DisplayConfig, not the screenshot CLI.
|
||||
assert.Equal(t, "screenshot", result.Output)
|
||||
assert.Equal(t, "base64data", result.ScreenshotData)
|
||||
assert.Equal(t, workspacesdk.DesktopDisplayWidth, result.ScreenshotWidth)
|
||||
assert.Equal(t, workspacesdk.DesktopDisplayHeight, result.ScreenshotHeight)
|
||||
}
|
||||
|
||||
func TestHandleAction_LeftClick(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
logger := slogtest.Make(t, nil)
|
||||
fake := &fakeDesktop{
|
||||
startCfg: agentdesktop.DisplayConfig{Width: 1920, Height: 1080},
|
||||
}
|
||||
api := agentdesktop.NewAPI(logger, fake, nil)
|
||||
defer api.Close()
|
||||
|
||||
body := agentdesktop.DesktopAction{
|
||||
Action: "left_click",
|
||||
Coordinate: &[2]int{100, 200},
|
||||
}
|
||||
b, err := json.Marshal(body)
|
||||
require.NoError(t, err)
|
||||
|
||||
rr := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodPost, "/action", bytes.NewReader(b))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
handler := api.Routes()
|
||||
handler.ServeHTTP(rr, req)
|
||||
|
||||
assert.Equal(t, http.StatusOK, rr.Code)
|
||||
|
||||
var resp agentdesktop.DesktopActionResponse
|
||||
err = json.NewDecoder(rr.Body).Decode(&resp)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "left_click action performed", resp.Output)
|
||||
assert.Equal(t, [3]int{100, 200, 1}, fake.lastClick)
|
||||
}
|
||||
|
||||
func TestHandleAction_UnknownAction(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
logger := slogtest.Make(t, nil)
|
||||
fake := &fakeDesktop{
|
||||
startCfg: agentdesktop.DisplayConfig{Width: 1920, Height: 1080},
|
||||
}
|
||||
api := agentdesktop.NewAPI(logger, fake, nil)
|
||||
defer api.Close()
|
||||
|
||||
body := agentdesktop.DesktopAction{Action: "explode"}
|
||||
b, err := json.Marshal(body)
|
||||
require.NoError(t, err)
|
||||
|
||||
rr := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodPost, "/action", bytes.NewReader(b))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
handler := api.Routes()
|
||||
handler.ServeHTTP(rr, req)
|
||||
|
||||
assert.Equal(t, http.StatusBadRequest, rr.Code)
|
||||
}
|
||||
|
||||
func TestHandleAction_KeyAction(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
logger := slogtest.Make(t, nil)
|
||||
fake := &fakeDesktop{
|
||||
startCfg: agentdesktop.DisplayConfig{Width: 1920, Height: 1080},
|
||||
}
|
||||
api := agentdesktop.NewAPI(logger, fake, nil)
|
||||
defer api.Close()
|
||||
|
||||
text := "Return"
|
||||
body := agentdesktop.DesktopAction{
|
||||
Action: "key",
|
||||
Text: &text,
|
||||
}
|
||||
b, err := json.Marshal(body)
|
||||
require.NoError(t, err)
|
||||
|
||||
rr := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodPost, "/action", bytes.NewReader(b))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
handler := api.Routes()
|
||||
handler.ServeHTTP(rr, req)
|
||||
|
||||
assert.Equal(t, http.StatusOK, rr.Code)
|
||||
assert.Equal(t, "Return", fake.lastKey)
|
||||
}
|
||||
|
||||
func TestHandleAction_TypeAction(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
logger := slogtest.Make(t, nil)
|
||||
fake := &fakeDesktop{
|
||||
startCfg: agentdesktop.DisplayConfig{Width: 1920, Height: 1080},
|
||||
}
|
||||
api := agentdesktop.NewAPI(logger, fake, nil)
|
||||
defer api.Close()
|
||||
|
||||
text := "hello world"
|
||||
body := agentdesktop.DesktopAction{
|
||||
Action: "type",
|
||||
Text: &text,
|
||||
}
|
||||
b, err := json.Marshal(body)
|
||||
require.NoError(t, err)
|
||||
|
||||
rr := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodPost, "/action", bytes.NewReader(b))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
handler := api.Routes()
|
||||
handler.ServeHTTP(rr, req)
|
||||
|
||||
assert.Equal(t, http.StatusOK, rr.Code)
|
||||
assert.Equal(t, "hello world", fake.lastTyped)
|
||||
}
|
||||
|
||||
func TestHandleAction_HoldKey(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
logger := slogtest.Make(t, nil)
|
||||
fake := &fakeDesktop{
|
||||
startCfg: agentdesktop.DisplayConfig{Width: 1920, Height: 1080},
|
||||
}
|
||||
mClk := quartz.NewMock(t)
|
||||
trap := mClk.Trap().NewTimer("agentdesktop", "hold_key")
|
||||
defer trap.Close()
|
||||
api := agentdesktop.NewAPI(logger, fake, mClk)
|
||||
defer api.Close()
|
||||
|
||||
text := "Shift_L"
|
||||
dur := 100
|
||||
body := agentdesktop.DesktopAction{
|
||||
Action: "hold_key",
|
||||
Text: &text,
|
||||
Duration: &dur,
|
||||
}
|
||||
b, err := json.Marshal(body)
|
||||
require.NoError(t, err)
|
||||
|
||||
rr := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodPost, "/action", bytes.NewReader(b))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
handler := api.Routes()
|
||||
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
defer close(done)
|
||||
handler.ServeHTTP(rr, req)
|
||||
}()
|
||||
|
||||
// Wait for the timer to be created, then advance past it.
|
||||
trap.MustWait(req.Context()).MustRelease(req.Context())
|
||||
mClk.Advance(time.Duration(dur) * time.Millisecond).MustWait(req.Context())
|
||||
|
||||
<-done
|
||||
|
||||
assert.Equal(t, http.StatusOK, rr.Code)
|
||||
|
||||
var resp agentdesktop.DesktopActionResponse
|
||||
err = json.NewDecoder(rr.Body).Decode(&resp)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "hold_key action performed", resp.Output)
|
||||
assert.Equal(t, "Shift_L", fake.lastKeyDown)
|
||||
assert.Equal(t, "Shift_L", fake.lastKeyUp)
|
||||
}
|
||||
|
||||
func TestHandleAction_HoldKeyMissingText(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
logger := slogtest.Make(t, nil)
|
||||
fake := &fakeDesktop{
|
||||
startCfg: agentdesktop.DisplayConfig{Width: 1920, Height: 1080},
|
||||
}
|
||||
api := agentdesktop.NewAPI(logger, fake, nil)
|
||||
defer api.Close()
|
||||
|
||||
body := agentdesktop.DesktopAction{Action: "hold_key"}
|
||||
b, err := json.Marshal(body)
|
||||
require.NoError(t, err)
|
||||
|
||||
rr := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodPost, "/action", bytes.NewReader(b))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
handler := api.Routes()
|
||||
handler.ServeHTTP(rr, req)
|
||||
|
||||
assert.Equal(t, http.StatusBadRequest, rr.Code)
|
||||
|
||||
var resp codersdk.Response
|
||||
err = json.NewDecoder(rr.Body).Decode(&resp)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "Missing \"text\" for hold_key action.", resp.Message)
|
||||
}
|
||||
|
||||
func TestHandleAction_ScrollDown(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
logger := slogtest.Make(t, nil)
|
||||
fake := &fakeDesktop{
|
||||
startCfg: agentdesktop.DisplayConfig{Width: 1920, Height: 1080},
|
||||
}
|
||||
api := agentdesktop.NewAPI(logger, fake, nil)
|
||||
defer api.Close()
|
||||
|
||||
dir := "down"
|
||||
amount := 5
|
||||
body := agentdesktop.DesktopAction{
|
||||
Action: "scroll",
|
||||
Coordinate: &[2]int{500, 400},
|
||||
ScrollDirection: &dir,
|
||||
ScrollAmount: &amount,
|
||||
}
|
||||
b, err := json.Marshal(body)
|
||||
require.NoError(t, err)
|
||||
|
||||
rr := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodPost, "/action", bytes.NewReader(b))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
handler := api.Routes()
|
||||
handler.ServeHTTP(rr, req)
|
||||
|
||||
assert.Equal(t, http.StatusOK, rr.Code)
|
||||
// dy should be positive 5 for "down".
|
||||
assert.Equal(t, [4]int{500, 400, 0, 5}, fake.lastScroll)
|
||||
}
|
||||
|
||||
func TestHandleAction_CoordinateScaling(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
logger := slogtest.Make(t, nil)
|
||||
fake := &fakeDesktop{
|
||||
// Native display is 1920x1080.
|
||||
startCfg: agentdesktop.DisplayConfig{Width: 1920, Height: 1080},
|
||||
}
|
||||
api := agentdesktop.NewAPI(logger, fake, nil)
|
||||
defer api.Close()
|
||||
|
||||
// Model is working in a 1280x720 coordinate space.
|
||||
sw := 1280
|
||||
sh := 720
|
||||
body := agentdesktop.DesktopAction{
|
||||
Action: "mouse_move",
|
||||
Coordinate: &[2]int{640, 360},
|
||||
ScaledWidth: &sw,
|
||||
ScaledHeight: &sh,
|
||||
}
|
||||
b, err := json.Marshal(body)
|
||||
require.NoError(t, err)
|
||||
|
||||
rr := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodPost, "/action", bytes.NewReader(b))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
handler := api.Routes()
|
||||
handler.ServeHTTP(rr, req)
|
||||
|
||||
assert.Equal(t, http.StatusOK, rr.Code)
|
||||
// 640 in 1280-space → 960 in 1920-space (midpoint maps to
|
||||
// midpoint).
|
||||
assert.Equal(t, 960, fake.lastMove[0])
|
||||
assert.Equal(t, 540, fake.lastMove[1])
|
||||
}
|
||||
|
||||
func TestClose_DelegatesToDesktop(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
logger := slogtest.Make(t, nil)
|
||||
fake := &fakeDesktop{}
|
||||
api := agentdesktop.NewAPI(logger, fake, nil)
|
||||
|
||||
err := api.Close()
|
||||
require.NoError(t, err)
|
||||
assert.True(t, fake.closed)
|
||||
}
|
||||
|
||||
func TestClose_PreventsNewSessions(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
logger := slogtest.Make(t, nil)
|
||||
// After Close(), Start() will return an error because the
|
||||
// underlying Desktop is closed.
|
||||
fake := &fakeDesktop{}
|
||||
api := agentdesktop.NewAPI(logger, fake, nil)
|
||||
|
||||
err := api.Close()
|
||||
require.NoError(t, err)
|
||||
|
||||
// Simulate the closed desktop returning an error on Start().
|
||||
fake.startErr = xerrors.New("desktop is closed")
|
||||
|
||||
rr := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodGet, "/vnc", nil)
|
||||
|
||||
handler := api.Routes()
|
||||
handler.ServeHTTP(rr, req)
|
||||
|
||||
assert.Equal(t, http.StatusInternalServerError, rr.Code)
|
||||
}
|
||||
91
agent/agentdesktop/desktop.go
Normal file
91
agent/agentdesktop/desktop.go
Normal file
@@ -0,0 +1,91 @@
|
||||
package agentdesktop
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net"
|
||||
)
|
||||
|
||||
// Desktop abstracts a virtual desktop session running inside a workspace.
|
||||
type Desktop interface {
|
||||
// Start launches the desktop session. It is idempotent — calling
|
||||
// Start on an already-running session returns the existing
|
||||
// config. The returned DisplayConfig describes the running
|
||||
// session.
|
||||
Start(ctx context.Context) (DisplayConfig, error)
|
||||
|
||||
// VNCConn dials the desktop's VNC server and returns a raw
|
||||
// net.Conn carrying RFB binary frames. Each call returns a new
|
||||
// connection; multiple clients can connect simultaneously.
|
||||
// Start must be called before VNCConn.
|
||||
VNCConn(ctx context.Context) (net.Conn, error)
|
||||
|
||||
// Screenshot captures the current framebuffer as a PNG and
|
||||
// returns it base64-encoded. TargetWidth/TargetHeight in opts
|
||||
// are the desired output dimensions (the implementation
|
||||
// rescales); pass 0 to use native resolution.
|
||||
Screenshot(ctx context.Context, opts ScreenshotOptions) (ScreenshotResult, error)
|
||||
|
||||
// Mouse operations.
|
||||
|
||||
// Move moves the mouse cursor to absolute coordinates.
|
||||
Move(ctx context.Context, x, y int) error
|
||||
// Click performs a mouse button click at the given coordinates.
|
||||
Click(ctx context.Context, x, y int, button MouseButton) error
|
||||
// DoubleClick performs a double-click at the given coordinates.
|
||||
DoubleClick(ctx context.Context, x, y int, button MouseButton) error
|
||||
// ButtonDown presses and holds a mouse button.
|
||||
ButtonDown(ctx context.Context, button MouseButton) error
|
||||
// ButtonUp releases a mouse button.
|
||||
ButtonUp(ctx context.Context, button MouseButton) error
|
||||
// Scroll scrolls by (dx, dy) clicks at the given coordinates.
|
||||
Scroll(ctx context.Context, x, y, dx, dy int) error
|
||||
// Drag moves from (startX,startY) to (endX,endY) while holding
|
||||
// the left mouse button.
|
||||
Drag(ctx context.Context, startX, startY, endX, endY int) error
|
||||
|
||||
// Keyboard operations.
|
||||
|
||||
// KeyPress sends a key-down then key-up for a key combo string
|
||||
// (e.g. "Return", "ctrl+c").
|
||||
KeyPress(ctx context.Context, keys string) error
|
||||
// KeyDown presses and holds a key.
|
||||
KeyDown(ctx context.Context, key string) error
|
||||
// KeyUp releases a key.
|
||||
KeyUp(ctx context.Context, key string) error
|
||||
// Type types a string of text character-by-character.
|
||||
Type(ctx context.Context, text string) error
|
||||
|
||||
// CursorPosition returns the current cursor coordinates.
|
||||
CursorPosition(ctx context.Context) (x, y int, err error)
|
||||
|
||||
// Close shuts down the desktop session and cleans up resources.
|
||||
Close() error
|
||||
}
|
||||
|
||||
// DisplayConfig describes a running desktop session.
|
||||
type DisplayConfig struct {
|
||||
Width int // native width in pixels
|
||||
Height int // native height in pixels
|
||||
VNCPort int // local TCP port for the VNC server
|
||||
Display int // X11 display number (e.g. 1 for :1), -1 if N/A
|
||||
}
|
||||
|
||||
// MouseButton identifies a mouse button.
|
||||
type MouseButton string
|
||||
|
||||
const (
|
||||
MouseButtonLeft MouseButton = "left"
|
||||
MouseButtonRight MouseButton = "right"
|
||||
MouseButtonMiddle MouseButton = "middle"
|
||||
)
|
||||
|
||||
// ScreenshotOptions configures a screenshot capture.
|
||||
type ScreenshotOptions struct {
|
||||
TargetWidth int // 0 = native
|
||||
TargetHeight int // 0 = native
|
||||
}
|
||||
|
||||
// ScreenshotResult is a captured screenshot.
|
||||
type ScreenshotResult struct {
|
||||
Data string // base64-encoded PNG
|
||||
}
|
||||
399
agent/agentdesktop/portabledesktop.go
Normal file
399
agent/agentdesktop/portabledesktop.go
Normal file
@@ -0,0 +1,399 @@
|
||||
package agentdesktop
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strconv"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"golang.org/x/xerrors"
|
||||
|
||||
"cdr.dev/slog/v3"
|
||||
"github.com/coder/coder/v2/agent/agentexec"
|
||||
"github.com/coder/coder/v2/codersdk/workspacesdk"
|
||||
)
|
||||
|
||||
// portableDesktopOutput is the JSON output from
|
||||
// `portabledesktop up --json`.
|
||||
type portableDesktopOutput struct {
|
||||
VNCPort int `json:"vncPort"`
|
||||
Geometry string `json:"geometry"` // e.g. "1920x1080"
|
||||
}
|
||||
|
||||
// desktopSession tracks a running portabledesktop process.
|
||||
type desktopSession struct {
|
||||
cmd *exec.Cmd
|
||||
vncPort int
|
||||
width int // native width, parsed from geometry
|
||||
height int // native height, parsed from geometry
|
||||
display int // X11 display number, -1 if not available
|
||||
cancel context.CancelFunc
|
||||
}
|
||||
|
||||
// cursorOutput is the JSON output from `portabledesktop cursor --json`.
|
||||
type cursorOutput struct {
|
||||
X int `json:"x"`
|
||||
Y int `json:"y"`
|
||||
}
|
||||
|
||||
// screenshotOutput is the JSON output from
|
||||
// `portabledesktop screenshot --json`.
|
||||
type screenshotOutput struct {
|
||||
Data string `json:"data"`
|
||||
}
|
||||
|
||||
// portableDesktop implements Desktop by shelling out to the
|
||||
// portabledesktop CLI via agentexec.Execer.
|
||||
type portableDesktop struct {
|
||||
logger slog.Logger
|
||||
execer agentexec.Execer
|
||||
scriptBinDir string // coder script bin directory
|
||||
|
||||
mu sync.Mutex
|
||||
session *desktopSession // nil until started
|
||||
binPath string // resolved path to binary, cached
|
||||
closed bool
|
||||
}
|
||||
|
||||
// NewPortableDesktop creates a Desktop backed by the portabledesktop
|
||||
// CLI binary, using execer to spawn child processes. scriptBinDir is
|
||||
// the coder script bin directory checked for the binary.
|
||||
func NewPortableDesktop(
|
||||
logger slog.Logger,
|
||||
execer agentexec.Execer,
|
||||
scriptBinDir string,
|
||||
) Desktop {
|
||||
return &portableDesktop{
|
||||
logger: logger,
|
||||
execer: execer,
|
||||
scriptBinDir: scriptBinDir,
|
||||
}
|
||||
}
|
||||
|
||||
// Start launches the desktop session (idempotent).
|
||||
func (p *portableDesktop) Start(ctx context.Context) (DisplayConfig, error) {
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
|
||||
if p.closed {
|
||||
return DisplayConfig{}, xerrors.New("desktop is closed")
|
||||
}
|
||||
|
||||
if err := p.ensureBinary(ctx); err != nil {
|
||||
return DisplayConfig{}, xerrors.Errorf("ensure portabledesktop binary: %w", err)
|
||||
}
|
||||
|
||||
// If we have an existing session, check if it's still alive.
|
||||
if p.session != nil {
|
||||
if !(p.session.cmd.ProcessState != nil && p.session.cmd.ProcessState.Exited()) {
|
||||
return DisplayConfig{
|
||||
Width: p.session.width,
|
||||
Height: p.session.height,
|
||||
VNCPort: p.session.vncPort,
|
||||
Display: p.session.display,
|
||||
}, nil
|
||||
}
|
||||
// Process died — clean up and recreate.
|
||||
p.logger.Warn(ctx, "portabledesktop process died, recreating session")
|
||||
p.session.cancel()
|
||||
p.session = nil
|
||||
}
|
||||
|
||||
// Spawn portabledesktop up --json.
|
||||
sessionCtx, sessionCancel := context.WithCancel(context.Background())
|
||||
|
||||
//nolint:gosec // portabledesktop is a trusted binary resolved via ensureBinary.
|
||||
cmd := p.execer.CommandContext(sessionCtx, p.binPath, "up", "--json",
|
||||
"--geometry", fmt.Sprintf("%dx%d", workspacesdk.DesktopDisplayWidth, workspacesdk.DesktopDisplayHeight))
|
||||
stdout, err := cmd.StdoutPipe()
|
||||
if err != nil {
|
||||
sessionCancel()
|
||||
return DisplayConfig{}, xerrors.Errorf("create stdout pipe: %w", err)
|
||||
}
|
||||
|
||||
if err := cmd.Start(); err != nil {
|
||||
sessionCancel()
|
||||
return DisplayConfig{}, xerrors.Errorf("start portabledesktop: %w", err)
|
||||
}
|
||||
|
||||
// Parse the JSON output to get VNC port and geometry.
|
||||
var output portableDesktopOutput
|
||||
if err := json.NewDecoder(stdout).Decode(&output); err != nil {
|
||||
sessionCancel()
|
||||
_ = cmd.Process.Kill()
|
||||
_ = cmd.Wait()
|
||||
return DisplayConfig{}, xerrors.Errorf("parse portabledesktop output: %w", err)
|
||||
}
|
||||
|
||||
if output.VNCPort == 0 {
|
||||
sessionCancel()
|
||||
_ = cmd.Process.Kill()
|
||||
_ = cmd.Wait()
|
||||
return DisplayConfig{}, xerrors.New("portabledesktop returned port 0")
|
||||
}
|
||||
|
||||
var w, h int
|
||||
if output.Geometry != "" {
|
||||
if _, err := fmt.Sscanf(output.Geometry, "%dx%d", &w, &h); err != nil {
|
||||
p.logger.Warn(ctx, "failed to parse geometry, using defaults",
|
||||
slog.F("geometry", output.Geometry),
|
||||
slog.Error(err),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
p.logger.Info(ctx, "started portabledesktop session",
|
||||
slog.F("vnc_port", output.VNCPort),
|
||||
slog.F("width", w),
|
||||
slog.F("height", h),
|
||||
slog.F("pid", cmd.Process.Pid),
|
||||
)
|
||||
|
||||
p.session = &desktopSession{
|
||||
cmd: cmd,
|
||||
vncPort: output.VNCPort,
|
||||
width: w,
|
||||
height: h,
|
||||
display: -1,
|
||||
cancel: sessionCancel,
|
||||
}
|
||||
|
||||
return DisplayConfig{
|
||||
Width: w,
|
||||
Height: h,
|
||||
VNCPort: output.VNCPort,
|
||||
Display: -1,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// VNCConn dials the desktop's VNC server and returns a raw
|
||||
// net.Conn carrying RFB binary frames.
|
||||
func (p *portableDesktop) VNCConn(_ context.Context) (net.Conn, error) {
|
||||
p.mu.Lock()
|
||||
session := p.session
|
||||
p.mu.Unlock()
|
||||
|
||||
if session == nil {
|
||||
return nil, xerrors.New("desktop session not started")
|
||||
}
|
||||
|
||||
return net.Dial("tcp", fmt.Sprintf("127.0.0.1:%d", session.vncPort))
|
||||
}
|
||||
|
||||
// Screenshot captures the current framebuffer as a base64-encoded PNG.
|
||||
func (p *portableDesktop) Screenshot(ctx context.Context, opts ScreenshotOptions) (ScreenshotResult, error) {
|
||||
args := []string{"screenshot", "--json"}
|
||||
if opts.TargetWidth > 0 {
|
||||
args = append(args, "--target-width", strconv.Itoa(opts.TargetWidth))
|
||||
}
|
||||
if opts.TargetHeight > 0 {
|
||||
args = append(args, "--target-height", strconv.Itoa(opts.TargetHeight))
|
||||
}
|
||||
|
||||
out, err := p.runCmd(ctx, args...)
|
||||
if err != nil {
|
||||
return ScreenshotResult{}, err
|
||||
}
|
||||
|
||||
var result screenshotOutput
|
||||
if err := json.Unmarshal([]byte(out), &result); err != nil {
|
||||
return ScreenshotResult{}, xerrors.Errorf("parse screenshot output: %w", err)
|
||||
}
|
||||
|
||||
return ScreenshotResult(result), nil
|
||||
}
|
||||
|
||||
// Move moves the mouse cursor to absolute coordinates.
|
||||
func (p *portableDesktop) Move(ctx context.Context, x, y int) error {
|
||||
_, err := p.runCmd(ctx, "mouse", "move", strconv.Itoa(x), strconv.Itoa(y))
|
||||
return err
|
||||
}
|
||||
|
||||
// Click performs a mouse button click at the given coordinates.
|
||||
func (p *portableDesktop) Click(ctx context.Context, x, y int, button MouseButton) error {
|
||||
if _, err := p.runCmd(ctx, "mouse", "move", strconv.Itoa(x), strconv.Itoa(y)); err != nil {
|
||||
return err
|
||||
}
|
||||
_, err := p.runCmd(ctx, "mouse", "click", string(button))
|
||||
return err
|
||||
}
|
||||
|
||||
// DoubleClick performs a double-click at the given coordinates.
|
||||
func (p *portableDesktop) DoubleClick(ctx context.Context, x, y int, button MouseButton) error {
|
||||
if _, err := p.runCmd(ctx, "mouse", "move", strconv.Itoa(x), strconv.Itoa(y)); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := p.runCmd(ctx, "mouse", "click", string(button)); err != nil {
|
||||
return err
|
||||
}
|
||||
_, err := p.runCmd(ctx, "mouse", "click", string(button))
|
||||
return err
|
||||
}
|
||||
|
||||
// ButtonDown presses and holds a mouse button.
|
||||
func (p *portableDesktop) ButtonDown(ctx context.Context, button MouseButton) error {
|
||||
_, err := p.runCmd(ctx, "mouse", "down", string(button))
|
||||
return err
|
||||
}
|
||||
|
||||
// ButtonUp releases a mouse button.
|
||||
func (p *portableDesktop) ButtonUp(ctx context.Context, button MouseButton) error {
|
||||
_, err := p.runCmd(ctx, "mouse", "up", string(button))
|
||||
return err
|
||||
}
|
||||
|
||||
// Scroll scrolls by (dx, dy) clicks at the given coordinates.
|
||||
func (p *portableDesktop) Scroll(ctx context.Context, x, y, dx, dy int) error {
|
||||
if _, err := p.runCmd(ctx, "mouse", "move", strconv.Itoa(x), strconv.Itoa(y)); err != nil {
|
||||
return err
|
||||
}
|
||||
_, err := p.runCmd(ctx, "mouse", "scroll", strconv.Itoa(dx), strconv.Itoa(dy))
|
||||
return err
|
||||
}
|
||||
|
||||
// Drag moves from (startX,startY) to (endX,endY) while holding the
|
||||
// left mouse button.
|
||||
func (p *portableDesktop) Drag(ctx context.Context, startX, startY, endX, endY int) error {
|
||||
if _, err := p.runCmd(ctx, "mouse", "move", strconv.Itoa(startX), strconv.Itoa(startY)); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := p.runCmd(ctx, "mouse", "down", string(MouseButtonLeft)); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := p.runCmd(ctx, "mouse", "move", strconv.Itoa(endX), strconv.Itoa(endY)); err != nil {
|
||||
return err
|
||||
}
|
||||
_, err := p.runCmd(ctx, "mouse", "up", string(MouseButtonLeft))
|
||||
return err
|
||||
}
|
||||
|
||||
// KeyPress sends a key-down then key-up for a key combo string.
|
||||
func (p *portableDesktop) KeyPress(ctx context.Context, keys string) error {
|
||||
_, err := p.runCmd(ctx, "keyboard", "key", keys)
|
||||
return err
|
||||
}
|
||||
|
||||
// KeyDown presses and holds a key.
|
||||
func (p *portableDesktop) KeyDown(ctx context.Context, key string) error {
|
||||
_, err := p.runCmd(ctx, "keyboard", "down", key)
|
||||
return err
|
||||
}
|
||||
|
||||
// KeyUp releases a key.
|
||||
func (p *portableDesktop) KeyUp(ctx context.Context, key string) error {
|
||||
_, err := p.runCmd(ctx, "keyboard", "up", key)
|
||||
return err
|
||||
}
|
||||
|
||||
// Type types a string of text character-by-character.
|
||||
func (p *portableDesktop) Type(ctx context.Context, text string) error {
|
||||
_, err := p.runCmd(ctx, "keyboard", "type", text)
|
||||
return err
|
||||
}
|
||||
|
||||
// CursorPosition returns the current cursor coordinates.
|
||||
func (p *portableDesktop) CursorPosition(ctx context.Context) (x int, y int, err error) {
|
||||
out, err := p.runCmd(ctx, "cursor", "--json")
|
||||
if err != nil {
|
||||
return 0, 0, err
|
||||
}
|
||||
|
||||
var result cursorOutput
|
||||
if err := json.Unmarshal([]byte(out), &result); err != nil {
|
||||
return 0, 0, xerrors.Errorf("parse cursor output: %w", err)
|
||||
}
|
||||
|
||||
return result.X, result.Y, nil
|
||||
}
|
||||
|
||||
// Close shuts down the desktop session and cleans up resources.
|
||||
func (p *portableDesktop) Close() error {
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
|
||||
p.closed = true
|
||||
if p.session != nil {
|
||||
p.session.cancel()
|
||||
// Xvnc is a child process — killing it cleans up the X
|
||||
// session.
|
||||
_ = p.session.cmd.Process.Kill()
|
||||
_ = p.session.cmd.Wait()
|
||||
p.session = nil
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// runCmd executes a portabledesktop subcommand and returns combined
|
||||
// output. The caller must have previously called ensureBinary.
|
||||
func (p *portableDesktop) runCmd(ctx context.Context, args ...string) (string, error) {
|
||||
start := time.Now()
|
||||
//nolint:gosec // args are constructed by the caller, not user input.
|
||||
cmd := p.execer.CommandContext(ctx, p.binPath, args...)
|
||||
out, err := cmd.CombinedOutput()
|
||||
elapsed := time.Since(start)
|
||||
if err != nil {
|
||||
p.logger.Warn(ctx, "portabledesktop command failed",
|
||||
slog.F("args", args),
|
||||
slog.F("elapsed_ms", elapsed.Milliseconds()),
|
||||
slog.Error(err),
|
||||
slog.F("output", string(out)),
|
||||
)
|
||||
return "", xerrors.Errorf("portabledesktop %s: %w: %s", args[0], err, string(out))
|
||||
}
|
||||
if elapsed > 5*time.Second {
|
||||
p.logger.Warn(ctx, "portabledesktop command slow",
|
||||
slog.F("args", args),
|
||||
slog.F("elapsed_ms", elapsed.Milliseconds()),
|
||||
)
|
||||
} else {
|
||||
p.logger.Debug(ctx, "portabledesktop command completed",
|
||||
slog.F("args", args),
|
||||
slog.F("elapsed_ms", elapsed.Milliseconds()),
|
||||
)
|
||||
}
|
||||
return string(out), nil
|
||||
}
|
||||
|
||||
// ensureBinary resolves the portabledesktop binary from PATH or the
|
||||
// coder script bin directory. It must be called while p.mu is held.
|
||||
func (p *portableDesktop) ensureBinary(ctx context.Context) error {
|
||||
if p.binPath != "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
// 1. Check PATH.
|
||||
if path, err := exec.LookPath("portabledesktop"); err == nil {
|
||||
p.logger.Info(ctx, "found portabledesktop in PATH",
|
||||
slog.F("path", path),
|
||||
)
|
||||
p.binPath = path
|
||||
return nil
|
||||
}
|
||||
|
||||
// 2. Check the coder script bin directory.
|
||||
scriptBinPath := filepath.Join(p.scriptBinDir, "portabledesktop")
|
||||
if info, err := os.Stat(scriptBinPath); err == nil && !info.IsDir() {
|
||||
// On Windows, permission bits don't indicate executability,
|
||||
// so accept any regular file.
|
||||
if runtime.GOOS == "windows" || info.Mode()&0o111 != 0 {
|
||||
p.logger.Info(ctx, "found portabledesktop in script bin directory",
|
||||
slog.F("path", scriptBinPath),
|
||||
)
|
||||
p.binPath = scriptBinPath
|
||||
return nil
|
||||
}
|
||||
p.logger.Warn(ctx, "portabledesktop found in script bin directory but not executable",
|
||||
slog.F("path", scriptBinPath),
|
||||
slog.F("mode", info.Mode().String()),
|
||||
)
|
||||
}
|
||||
|
||||
return xerrors.New("portabledesktop binary not found in PATH or script bin directory")
|
||||
}
|
||||
545
agent/agentdesktop/portabledesktop_internal_test.go
Normal file
545
agent/agentdesktop/portabledesktop_internal_test.go
Normal file
@@ -0,0 +1,545 @@
|
||||
package agentdesktop
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"cdr.dev/slog/v3/sloggers/slogtest"
|
||||
"github.com/coder/coder/v2/agent/agentexec"
|
||||
"github.com/coder/coder/v2/pty"
|
||||
)
|
||||
|
||||
// recordedExecer implements agentexec.Execer by recording every
|
||||
// invocation and delegating to a real shell command built from a
|
||||
// caller-supplied mapping of subcommand → shell script body.
|
||||
type recordedExecer struct {
|
||||
mu sync.Mutex
|
||||
commands [][]string
|
||||
// scripts maps a subcommand keyword (e.g. "up", "screenshot")
|
||||
// to a shell snippet whose stdout will be the command output.
|
||||
scripts map[string]string
|
||||
}
|
||||
|
||||
func (r *recordedExecer) record(cmd string, args ...string) {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
r.commands = append(r.commands, append([]string{cmd}, args...))
|
||||
}
|
||||
|
||||
func (r *recordedExecer) allCommands() [][]string {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
out := make([][]string, len(r.commands))
|
||||
copy(out, r.commands)
|
||||
return out
|
||||
}
|
||||
|
||||
// scriptFor finds the first matching script key present in args.
|
||||
func (r *recordedExecer) scriptFor(args []string) string {
|
||||
for _, a := range args {
|
||||
if s, ok := r.scripts[a]; ok {
|
||||
return s
|
||||
}
|
||||
}
|
||||
// Fallback: succeed silently.
|
||||
return "true"
|
||||
}
|
||||
|
||||
func (r *recordedExecer) CommandContext(ctx context.Context, cmd string, args ...string) *exec.Cmd {
|
||||
r.record(cmd, args...)
|
||||
script := r.scriptFor(args)
|
||||
//nolint:gosec // Test helper — script content is controlled by the test.
|
||||
return exec.CommandContext(ctx, "sh", "-c", script)
|
||||
}
|
||||
|
||||
func (r *recordedExecer) PTYCommandContext(ctx context.Context, cmd string, args ...string) *pty.Cmd {
|
||||
r.record(cmd, args...)
|
||||
return pty.CommandContext(ctx, "sh", "-c", r.scriptFor(args))
|
||||
}
|
||||
|
||||
// --- portableDesktop tests ---
|
||||
|
||||
func TestPortableDesktop_Start_ParsesOutput(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
logger := slogtest.Make(t, nil)
|
||||
|
||||
// The "up" script prints the JSON line then sleeps until
|
||||
// the context is canceled (simulating a long-running process).
|
||||
rec := &recordedExecer{
|
||||
scripts: map[string]string{
|
||||
"up": `printf '{"vncPort":5901,"geometry":"1920x1080"}\n' && sleep 120`,
|
||||
},
|
||||
}
|
||||
|
||||
pd := &portableDesktop{
|
||||
logger: logger,
|
||||
execer: rec,
|
||||
scriptBinDir: t.TempDir(),
|
||||
binPath: "portabledesktop", // pre-set so ensureBinary is a no-op
|
||||
}
|
||||
|
||||
ctx := t.Context()
|
||||
cfg, err := pd.Start(ctx)
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.Equal(t, 1920, cfg.Width)
|
||||
assert.Equal(t, 1080, cfg.Height)
|
||||
assert.Equal(t, 5901, cfg.VNCPort)
|
||||
assert.Equal(t, -1, cfg.Display)
|
||||
|
||||
// Clean up the long-running process.
|
||||
require.NoError(t, pd.Close())
|
||||
}
|
||||
|
||||
func TestPortableDesktop_Start_Idempotent(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
logger := slogtest.Make(t, nil)
|
||||
|
||||
rec := &recordedExecer{
|
||||
scripts: map[string]string{
|
||||
"up": `printf '{"vncPort":5901,"geometry":"1920x1080"}\n' && sleep 120`,
|
||||
},
|
||||
}
|
||||
|
||||
pd := &portableDesktop{
|
||||
logger: logger,
|
||||
execer: rec,
|
||||
scriptBinDir: t.TempDir(),
|
||||
binPath: "portabledesktop",
|
||||
}
|
||||
|
||||
ctx := t.Context()
|
||||
cfg1, err := pd.Start(ctx)
|
||||
require.NoError(t, err)
|
||||
|
||||
cfg2, err := pd.Start(ctx)
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.Equal(t, cfg1, cfg2, "second Start should return the same config")
|
||||
|
||||
// The execer should have been called exactly once for "up".
|
||||
cmds := rec.allCommands()
|
||||
upCalls := 0
|
||||
for _, c := range cmds {
|
||||
for _, a := range c {
|
||||
if a == "up" {
|
||||
upCalls++
|
||||
}
|
||||
}
|
||||
}
|
||||
assert.Equal(t, 1, upCalls, "expected exactly one 'up' invocation")
|
||||
|
||||
require.NoError(t, pd.Close())
|
||||
}
|
||||
|
||||
func TestPortableDesktop_Screenshot(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
logger := slogtest.Make(t, nil)
|
||||
|
||||
rec := &recordedExecer{
|
||||
scripts: map[string]string{
|
||||
"screenshot": `echo '{"data":"abc123"}'`,
|
||||
},
|
||||
}
|
||||
|
||||
pd := &portableDesktop{
|
||||
logger: logger,
|
||||
execer: rec,
|
||||
scriptBinDir: t.TempDir(),
|
||||
binPath: "portabledesktop",
|
||||
}
|
||||
|
||||
ctx := t.Context()
|
||||
result, err := pd.Screenshot(ctx, ScreenshotOptions{})
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.Equal(t, "abc123", result.Data)
|
||||
}
|
||||
|
||||
func TestPortableDesktop_Screenshot_WithTargetDimensions(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
logger := slogtest.Make(t, nil)
|
||||
|
||||
rec := &recordedExecer{
|
||||
scripts: map[string]string{
|
||||
"screenshot": `echo '{"data":"x"}'`,
|
||||
},
|
||||
}
|
||||
|
||||
pd := &portableDesktop{
|
||||
logger: logger,
|
||||
execer: rec,
|
||||
scriptBinDir: t.TempDir(),
|
||||
binPath: "portabledesktop",
|
||||
}
|
||||
|
||||
ctx := t.Context()
|
||||
_, err := pd.Screenshot(ctx, ScreenshotOptions{
|
||||
TargetWidth: 800,
|
||||
TargetHeight: 600,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
cmds := rec.allCommands()
|
||||
require.NotEmpty(t, cmds)
|
||||
|
||||
// The last command should contain the target dimension flags.
|
||||
last := cmds[len(cmds)-1]
|
||||
joined := strings.Join(last, " ")
|
||||
assert.Contains(t, joined, "--target-width 800")
|
||||
assert.Contains(t, joined, "--target-height 600")
|
||||
}
|
||||
|
||||
func TestPortableDesktop_MouseMethods(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// Each sub-test verifies a single mouse method dispatches the
|
||||
// correct CLI arguments.
|
||||
tests := []struct {
|
||||
name string
|
||||
invoke func(context.Context, *portableDesktop) error
|
||||
wantArgs []string // substrings expected in a recorded command
|
||||
}{
|
||||
{
|
||||
name: "Move",
|
||||
invoke: func(ctx context.Context, pd *portableDesktop) error {
|
||||
return pd.Move(ctx, 42, 99)
|
||||
},
|
||||
wantArgs: []string{"mouse", "move", "42", "99"},
|
||||
},
|
||||
{
|
||||
name: "Click",
|
||||
invoke: func(ctx context.Context, pd *portableDesktop) error {
|
||||
return pd.Click(ctx, 10, 20, MouseButtonLeft)
|
||||
},
|
||||
// Click does move then click.
|
||||
wantArgs: []string{"mouse", "click", "left"},
|
||||
},
|
||||
{
|
||||
name: "DoubleClick",
|
||||
invoke: func(ctx context.Context, pd *portableDesktop) error {
|
||||
return pd.DoubleClick(ctx, 5, 6, MouseButtonRight)
|
||||
},
|
||||
wantArgs: []string{"mouse", "click", "right"},
|
||||
},
|
||||
{
|
||||
name: "ButtonDown",
|
||||
invoke: func(ctx context.Context, pd *portableDesktop) error {
|
||||
return pd.ButtonDown(ctx, MouseButtonMiddle)
|
||||
},
|
||||
wantArgs: []string{"mouse", "down", "middle"},
|
||||
},
|
||||
{
|
||||
name: "ButtonUp",
|
||||
invoke: func(ctx context.Context, pd *portableDesktop) error {
|
||||
return pd.ButtonUp(ctx, MouseButtonLeft)
|
||||
},
|
||||
wantArgs: []string{"mouse", "up", "left"},
|
||||
},
|
||||
{
|
||||
name: "Scroll",
|
||||
invoke: func(ctx context.Context, pd *portableDesktop) error {
|
||||
return pd.Scroll(ctx, 50, 60, 3, 4)
|
||||
},
|
||||
wantArgs: []string{"mouse", "scroll", "3", "4"},
|
||||
},
|
||||
{
|
||||
name: "Drag",
|
||||
invoke: func(ctx context.Context, pd *portableDesktop) error {
|
||||
return pd.Drag(ctx, 10, 20, 30, 40)
|
||||
},
|
||||
// Drag ends with mouse up left.
|
||||
wantArgs: []string{"mouse", "up", "left"},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
logger := slogtest.Make(t, nil)
|
||||
rec := &recordedExecer{
|
||||
scripts: map[string]string{
|
||||
"mouse": `echo ok`,
|
||||
},
|
||||
}
|
||||
|
||||
pd := &portableDesktop{
|
||||
logger: logger,
|
||||
execer: rec,
|
||||
scriptBinDir: t.TempDir(),
|
||||
binPath: "portabledesktop",
|
||||
}
|
||||
|
||||
err := tt.invoke(t.Context(), pd)
|
||||
require.NoError(t, err)
|
||||
|
||||
cmds := rec.allCommands()
|
||||
require.NotEmpty(t, cmds, "expected at least one command")
|
||||
|
||||
// Find at least one recorded command that contains
|
||||
// all expected argument substrings.
|
||||
found := false
|
||||
for _, cmd := range cmds {
|
||||
joined := strings.Join(cmd, " ")
|
||||
match := true
|
||||
for _, want := range tt.wantArgs {
|
||||
if !strings.Contains(joined, want) {
|
||||
match = false
|
||||
break
|
||||
}
|
||||
}
|
||||
if match {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
assert.True(t, found,
|
||||
"no recorded command matched %v; got %v", tt.wantArgs, cmds)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestPortableDesktop_KeyboardMethods(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
invoke func(context.Context, *portableDesktop) error
|
||||
wantArgs []string
|
||||
}{
|
||||
{
|
||||
name: "KeyPress",
|
||||
invoke: func(ctx context.Context, pd *portableDesktop) error {
|
||||
return pd.KeyPress(ctx, "Return")
|
||||
},
|
||||
wantArgs: []string{"keyboard", "key", "Return"},
|
||||
},
|
||||
{
|
||||
name: "KeyDown",
|
||||
invoke: func(ctx context.Context, pd *portableDesktop) error {
|
||||
return pd.KeyDown(ctx, "shift")
|
||||
},
|
||||
wantArgs: []string{"keyboard", "down", "shift"},
|
||||
},
|
||||
{
|
||||
name: "KeyUp",
|
||||
invoke: func(ctx context.Context, pd *portableDesktop) error {
|
||||
return pd.KeyUp(ctx, "shift")
|
||||
},
|
||||
wantArgs: []string{"keyboard", "up", "shift"},
|
||||
},
|
||||
{
|
||||
name: "Type",
|
||||
invoke: func(ctx context.Context, pd *portableDesktop) error {
|
||||
return pd.Type(ctx, "hello world")
|
||||
},
|
||||
wantArgs: []string{"keyboard", "type", "hello world"},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
logger := slogtest.Make(t, nil)
|
||||
rec := &recordedExecer{
|
||||
scripts: map[string]string{
|
||||
"keyboard": `echo ok`,
|
||||
},
|
||||
}
|
||||
|
||||
pd := &portableDesktop{
|
||||
logger: logger,
|
||||
execer: rec,
|
||||
scriptBinDir: t.TempDir(),
|
||||
binPath: "portabledesktop",
|
||||
}
|
||||
|
||||
err := tt.invoke(t.Context(), pd)
|
||||
require.NoError(t, err)
|
||||
|
||||
cmds := rec.allCommands()
|
||||
require.NotEmpty(t, cmds)
|
||||
|
||||
last := cmds[len(cmds)-1]
|
||||
joined := strings.Join(last, " ")
|
||||
for _, want := range tt.wantArgs {
|
||||
assert.Contains(t, joined, want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestPortableDesktop_CursorPosition(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
logger := slogtest.Make(t, nil)
|
||||
rec := &recordedExecer{
|
||||
scripts: map[string]string{
|
||||
"cursor": `echo '{"x":100,"y":200}'`,
|
||||
},
|
||||
}
|
||||
|
||||
pd := &portableDesktop{
|
||||
logger: logger,
|
||||
execer: rec,
|
||||
scriptBinDir: t.TempDir(),
|
||||
binPath: "portabledesktop",
|
||||
}
|
||||
|
||||
x, y, err := pd.CursorPosition(t.Context())
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, 100, x)
|
||||
assert.Equal(t, 200, y)
|
||||
}
|
||||
|
||||
func TestPortableDesktop_Close(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
logger := slogtest.Make(t, nil)
|
||||
|
||||
rec := &recordedExecer{
|
||||
scripts: map[string]string{
|
||||
"up": `printf '{"vncPort":5901,"geometry":"1024x768"}\n' && sleep 120`,
|
||||
},
|
||||
}
|
||||
|
||||
pd := &portableDesktop{
|
||||
logger: logger,
|
||||
execer: rec,
|
||||
scriptBinDir: t.TempDir(),
|
||||
binPath: "portabledesktop",
|
||||
}
|
||||
|
||||
ctx := t.Context()
|
||||
_, err := pd.Start(ctx)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Session should exist.
|
||||
pd.mu.Lock()
|
||||
require.NotNil(t, pd.session)
|
||||
pd.mu.Unlock()
|
||||
|
||||
require.NoError(t, pd.Close())
|
||||
|
||||
// Session should be cleaned up.
|
||||
pd.mu.Lock()
|
||||
assert.Nil(t, pd.session)
|
||||
assert.True(t, pd.closed)
|
||||
pd.mu.Unlock()
|
||||
|
||||
// Subsequent Start must fail.
|
||||
_, err = pd.Start(ctx)
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "desktop is closed")
|
||||
}
|
||||
|
||||
// --- ensureBinary tests ---
|
||||
|
||||
func TestEnsureBinary_UsesCachedBinPath(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// When binPath is already set, ensureBinary should return
|
||||
// immediately without doing any work.
|
||||
logger := slogtest.Make(t, nil)
|
||||
pd := &portableDesktop{
|
||||
logger: logger,
|
||||
execer: agentexec.DefaultExecer,
|
||||
scriptBinDir: t.TempDir(),
|
||||
binPath: "/already/set",
|
||||
}
|
||||
|
||||
err := pd.ensureBinary(t.Context())
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "/already/set", pd.binPath)
|
||||
}
|
||||
|
||||
func TestEnsureBinary_UsesScriptBinDir(t *testing.T) {
|
||||
// Cannot use t.Parallel because t.Setenv modifies the process
|
||||
// environment.
|
||||
|
||||
scriptBinDir := t.TempDir()
|
||||
binPath := filepath.Join(scriptBinDir, "portabledesktop")
|
||||
require.NoError(t, os.WriteFile(binPath, []byte("#!/bin/sh\n"), 0o600))
|
||||
require.NoError(t, os.Chmod(binPath, 0o755))
|
||||
|
||||
logger := slogtest.Make(t, nil)
|
||||
pd := &portableDesktop{
|
||||
logger: logger,
|
||||
execer: agentexec.DefaultExecer,
|
||||
scriptBinDir: scriptBinDir,
|
||||
}
|
||||
|
||||
// Clear PATH so LookPath won't find a real binary.
|
||||
t.Setenv("PATH", "")
|
||||
|
||||
err := pd.ensureBinary(t.Context())
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, binPath, pd.binPath)
|
||||
}
|
||||
|
||||
func TestEnsureBinary_ScriptBinDirNotExecutable(t *testing.T) {
|
||||
if runtime.GOOS == "windows" {
|
||||
t.Skip("Windows does not support Unix permission bits")
|
||||
}
|
||||
// Cannot use t.Parallel because t.Setenv modifies the process
|
||||
// environment.
|
||||
|
||||
scriptBinDir := t.TempDir()
|
||||
binPath := filepath.Join(scriptBinDir, "portabledesktop")
|
||||
// Write without execute permission.
|
||||
require.NoError(t, os.WriteFile(binPath, []byte("#!/bin/sh\n"), 0o600))
|
||||
_ = binPath
|
||||
|
||||
logger := slogtest.Make(t, nil)
|
||||
pd := &portableDesktop{
|
||||
logger: logger,
|
||||
execer: agentexec.DefaultExecer,
|
||||
scriptBinDir: scriptBinDir,
|
||||
}
|
||||
|
||||
// Clear PATH so LookPath won't find a real binary.
|
||||
t.Setenv("PATH", "")
|
||||
|
||||
err := pd.ensureBinary(t.Context())
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "not found")
|
||||
}
|
||||
|
||||
func TestEnsureBinary_NotFound(t *testing.T) {
|
||||
// Cannot use t.Parallel because t.Setenv modifies the process
|
||||
// environment.
|
||||
|
||||
logger := slogtest.Make(t, nil)
|
||||
pd := &portableDesktop{
|
||||
logger: logger,
|
||||
execer: agentexec.DefaultExecer,
|
||||
scriptBinDir: t.TempDir(), // empty directory
|
||||
}
|
||||
|
||||
// Clear PATH so LookPath won't find a real binary.
|
||||
t.Setenv("PATH", "")
|
||||
|
||||
err := pd.ensureBinary(t.Context())
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "not found")
|
||||
}
|
||||
|
||||
// Ensure that portableDesktop satisfies the Desktop interface at
|
||||
// compile time. This uses the unexported type so it lives in the
|
||||
// internal test package.
|
||||
var _ Desktop = (*portableDesktop)(nil)
|
||||
@@ -333,22 +333,68 @@ func (api *API) writeFile(ctx context.Context, r *http.Request, path string) (HT
|
||||
return status, err
|
||||
}
|
||||
|
||||
f, err := api.filesystem.Create(path)
|
||||
// Check if the target already exists so we can preserve its
|
||||
// permissions on the temp file before rename.
|
||||
var origMode os.FileMode
|
||||
var haveOrigMode bool
|
||||
if stat, serr := api.filesystem.Stat(path); serr == nil {
|
||||
if stat.IsDir() {
|
||||
return http.StatusBadRequest, xerrors.Errorf("open %s: is a directory", path)
|
||||
}
|
||||
origMode = stat.Mode()
|
||||
haveOrigMode = true
|
||||
}
|
||||
|
||||
// Write to a temp file in the same directory so the rename is
|
||||
// always on the same device (atomic).
|
||||
tmpfile, err := afero.TempFile(api.filesystem, dir, filepath.Base(path))
|
||||
if err != nil {
|
||||
status := http.StatusInternalServerError
|
||||
switch {
|
||||
case errors.Is(err, os.ErrPermission):
|
||||
if errors.Is(err, os.ErrPermission) {
|
||||
status = http.StatusForbidden
|
||||
case errors.Is(err, syscall.EISDIR):
|
||||
status = http.StatusBadRequest
|
||||
}
|
||||
return status, err
|
||||
}
|
||||
defer f.Close()
|
||||
tmpName := tmpfile.Name()
|
||||
|
||||
_, err = io.Copy(f, r.Body)
|
||||
if err != nil && !errors.Is(err, io.EOF) && ctx.Err() == nil {
|
||||
api.logger.Error(ctx, "workspace agent write file", slog.Error(err))
|
||||
_, err = io.Copy(tmpfile, r.Body)
|
||||
if err != nil && !errors.Is(err, io.EOF) {
|
||||
_ = tmpfile.Close()
|
||||
if rerr := api.filesystem.Remove(tmpName); rerr != nil {
|
||||
api.logger.Warn(ctx, "unable to clean up temp file", slog.Error(rerr))
|
||||
}
|
||||
return http.StatusInternalServerError, xerrors.Errorf("write %s: %w", path, err)
|
||||
}
|
||||
|
||||
// Close before rename to flush buffered data and catch write
|
||||
// errors (e.g. delayed allocation failures).
|
||||
if err := tmpfile.Close(); err != nil {
|
||||
if rerr := api.filesystem.Remove(tmpName); rerr != nil {
|
||||
api.logger.Warn(ctx, "unable to clean up temp file", slog.Error(rerr))
|
||||
}
|
||||
return http.StatusInternalServerError, xerrors.Errorf("write %s: %w", path, err)
|
||||
}
|
||||
|
||||
// Set permissions on the temp file before rename so there is
|
||||
// no window where the target has wrong permissions.
|
||||
if haveOrigMode {
|
||||
if err := api.filesystem.Chmod(tmpName, origMode); err != nil {
|
||||
api.logger.Warn(ctx, "unable to set file permissions",
|
||||
slog.F("path", path),
|
||||
slog.Error(err),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if err := api.filesystem.Rename(tmpName, path); err != nil {
|
||||
if rerr := api.filesystem.Remove(tmpName); rerr != nil {
|
||||
api.logger.Warn(ctx, "unable to clean up temp file", slog.Error(rerr))
|
||||
}
|
||||
status := http.StatusInternalServerError
|
||||
if errors.Is(err, os.ErrPermission) {
|
||||
status = http.StatusForbidden
|
||||
}
|
||||
return status, err
|
||||
}
|
||||
|
||||
return 0, nil
|
||||
@@ -447,13 +493,10 @@ func (api *API) editFile(ctx context.Context, path string, edits []workspacesdk.
|
||||
content := string(data)
|
||||
|
||||
for _, edit := range edits {
|
||||
var ok bool
|
||||
content, ok = fuzzyReplace(content, edit.Search, edit.Replace)
|
||||
if !ok {
|
||||
api.logger.Warn(ctx, "edit search string not found, skipping",
|
||||
slog.F("path", path),
|
||||
slog.F("search_preview", truncate(edit.Search, 64)),
|
||||
)
|
||||
var err error
|
||||
content, err = fuzzyReplace(content, edit)
|
||||
if err != nil {
|
||||
return http.StatusBadRequest, xerrors.Errorf("edit %s: %w", path, err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -463,68 +506,135 @@ func (api *API) editFile(ctx context.Context, path string, edits []workspacesdk.
|
||||
if err != nil {
|
||||
return http.StatusInternalServerError, err
|
||||
}
|
||||
defer tmpfile.Close()
|
||||
tmpName := tmpfile.Name()
|
||||
|
||||
if _, err := tmpfile.Write([]byte(content)); err != nil {
|
||||
if rerr := api.filesystem.Remove(tmpfile.Name()); rerr != nil {
|
||||
_ = tmpfile.Close()
|
||||
if rerr := api.filesystem.Remove(tmpName); rerr != nil {
|
||||
api.logger.Warn(ctx, "unable to clean up temp file", slog.Error(rerr))
|
||||
}
|
||||
return http.StatusInternalServerError, xerrors.Errorf("edit %s: %w", path, err)
|
||||
}
|
||||
|
||||
err = api.filesystem.Rename(tmpfile.Name(), path)
|
||||
// Close before rename to flush buffered data and catch write
|
||||
// errors (e.g. delayed allocation failures).
|
||||
if err := tmpfile.Close(); err != nil {
|
||||
if rerr := api.filesystem.Remove(tmpName); rerr != nil {
|
||||
api.logger.Warn(ctx, "unable to clean up temp file", slog.Error(rerr))
|
||||
}
|
||||
return http.StatusInternalServerError, xerrors.Errorf("edit %s: %w", path, err)
|
||||
}
|
||||
|
||||
// Set permissions on the temp file before rename so there is
|
||||
// no window where the target has wrong permissions.
|
||||
if err := api.filesystem.Chmod(tmpName, stat.Mode()); err != nil {
|
||||
api.logger.Warn(ctx, "unable to set file permissions",
|
||||
slog.F("path", path),
|
||||
slog.Error(err),
|
||||
)
|
||||
}
|
||||
|
||||
err = api.filesystem.Rename(tmpName, path)
|
||||
if err != nil {
|
||||
return http.StatusInternalServerError, err
|
||||
if rerr := api.filesystem.Remove(tmpName); rerr != nil {
|
||||
api.logger.Warn(ctx, "unable to clean up temp file", slog.Error(rerr))
|
||||
}
|
||||
status := http.StatusInternalServerError
|
||||
if errors.Is(err, os.ErrPermission) {
|
||||
status = http.StatusForbidden
|
||||
}
|
||||
return status, err
|
||||
}
|
||||
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
// fuzzyReplace attempts to find `search` inside `content` and replace its first
|
||||
// occurrence with `replace`. It uses a cascading match strategy inspired by
|
||||
// fuzzyReplace attempts to find `search` inside `content` and replace it
|
||||
// with `replace`. It uses a cascading match strategy inspired by
|
||||
// openai/codex's apply_patch:
|
||||
//
|
||||
// 1. Exact substring match (byte-for-byte).
|
||||
// 2. Line-by-line match ignoring trailing whitespace on each line.
|
||||
// 3. Line-by-line match ignoring all leading/trailing whitespace (indentation-tolerant).
|
||||
// 3. Line-by-line match ignoring all leading/trailing whitespace
|
||||
// (indentation-tolerant).
|
||||
//
|
||||
// When a fuzzy match is found (passes 2 or 3), the replacement is still applied
|
||||
// at the byte offsets of the original content so that surrounding text (including
|
||||
// indentation of untouched lines) is preserved.
|
||||
// When edit.ReplaceAll is false (the default), the search string must
|
||||
// match exactly one location. If multiple matches are found, an error
|
||||
// is returned asking the caller to include more context or set
|
||||
// replace_all.
|
||||
//
|
||||
// Returns the (possibly modified) content and a bool indicating whether a match
|
||||
// was found.
|
||||
func fuzzyReplace(content, search, replace string) (string, bool) {
|
||||
// Pass 1 – exact substring (replace all occurrences).
|
||||
// When a fuzzy match is found (passes 2 or 3), the replacement is still
|
||||
// applied at the byte offsets of the original content so that surrounding
|
||||
// text (including indentation of untouched lines) is preserved.
|
||||
func fuzzyReplace(content string, edit workspacesdk.FileEdit) (string, error) {
|
||||
search := edit.Search
|
||||
replace := edit.Replace
|
||||
|
||||
// Pass 1 – exact substring match.
|
||||
if strings.Contains(content, search) {
|
||||
return strings.ReplaceAll(content, search, replace), true
|
||||
if edit.ReplaceAll {
|
||||
return strings.ReplaceAll(content, search, replace), nil
|
||||
}
|
||||
count := strings.Count(content, search)
|
||||
if count > 1 {
|
||||
return "", xerrors.Errorf("search string matches %d occurrences "+
|
||||
"(expected exactly 1). Include more surrounding "+
|
||||
"context to make the match unique, or set "+
|
||||
"replace_all to true", count)
|
||||
}
|
||||
// Exactly one match.
|
||||
return strings.Replace(content, search, replace, 1), nil
|
||||
}
|
||||
|
||||
// For line-level fuzzy matching we split both content and search into lines.
|
||||
// For line-level fuzzy matching we split both content and search
|
||||
// into lines.
|
||||
contentLines := strings.SplitAfter(content, "\n")
|
||||
searchLines := strings.SplitAfter(search, "\n")
|
||||
|
||||
// A trailing newline in the search produces an empty final element from
|
||||
// SplitAfter. Drop it so it doesn't interfere with line matching.
|
||||
// A trailing newline in the search produces an empty final element
|
||||
// from SplitAfter. Drop it so it doesn't interfere with line
|
||||
// matching.
|
||||
if len(searchLines) > 0 && searchLines[len(searchLines)-1] == "" {
|
||||
searchLines = searchLines[:len(searchLines)-1]
|
||||
}
|
||||
|
||||
// Pass 2 – trim trailing whitespace on each line.
|
||||
if start, end, ok := seekLines(contentLines, searchLines, func(a, b string) bool {
|
||||
trimRight := func(a, b string) bool {
|
||||
return strings.TrimRight(a, " \t\r\n") == strings.TrimRight(b, " \t\r\n")
|
||||
}); ok {
|
||||
return spliceLines(contentLines, start, end, replace), true
|
||||
}
|
||||
|
||||
// Pass 3 – trim all leading and trailing whitespace (indentation-tolerant).
|
||||
if start, end, ok := seekLines(contentLines, searchLines, func(a, b string) bool {
|
||||
trimAll := func(a, b string) bool {
|
||||
return strings.TrimSpace(a) == strings.TrimSpace(b)
|
||||
}); ok {
|
||||
return spliceLines(contentLines, start, end, replace), true
|
||||
}
|
||||
|
||||
return content, false
|
||||
// Pass 2 – trim trailing whitespace on each line.
|
||||
if start, end, ok := seekLines(contentLines, searchLines, trimRight); ok {
|
||||
if !edit.ReplaceAll {
|
||||
if count := countLineMatches(contentLines, searchLines, trimRight); count > 1 {
|
||||
return "", xerrors.Errorf("search string matches %d occurrences "+
|
||||
"(expected exactly 1). Include more surrounding "+
|
||||
"context to make the match unique, or set "+
|
||||
"replace_all to true", count)
|
||||
}
|
||||
}
|
||||
return spliceLines(contentLines, start, end, replace), nil
|
||||
}
|
||||
|
||||
// Pass 3 – trim all leading and trailing whitespace
|
||||
// (indentation-tolerant).
|
||||
if start, end, ok := seekLines(contentLines, searchLines, trimAll); ok {
|
||||
if !edit.ReplaceAll {
|
||||
if count := countLineMatches(contentLines, searchLines, trimAll); count > 1 {
|
||||
return "", xerrors.Errorf("search string matches %d occurrences "+
|
||||
"(expected exactly 1). Include more surrounding "+
|
||||
"context to make the match unique, or set "+
|
||||
"replace_all to true", count)
|
||||
}
|
||||
}
|
||||
return spliceLines(contentLines, start, end, replace), nil
|
||||
}
|
||||
|
||||
return "", xerrors.New("search string not found in file. Verify the search " +
|
||||
"string matches the file content exactly, including whitespace " +
|
||||
"and indentation")
|
||||
}
|
||||
|
||||
// seekLines scans contentLines looking for a contiguous subsequence that matches
|
||||
@@ -549,6 +659,26 @@ outer:
|
||||
return 0, 0, false
|
||||
}
|
||||
|
||||
// countLineMatches counts how many non-overlapping contiguous
|
||||
// subsequences of contentLines match searchLines according to eq.
|
||||
func countLineMatches(contentLines, searchLines []string, eq func(a, b string) bool) int {
|
||||
count := 0
|
||||
if len(searchLines) == 0 || len(searchLines) > len(contentLines) {
|
||||
return count
|
||||
}
|
||||
outer:
|
||||
for i := 0; i <= len(contentLines)-len(searchLines); i++ {
|
||||
for j, sLine := range searchLines {
|
||||
if !eq(contentLines[i+j], sLine) {
|
||||
continue outer
|
||||
}
|
||||
}
|
||||
count++
|
||||
i += len(searchLines) - 1 // skip past this match
|
||||
}
|
||||
return count
|
||||
}
|
||||
|
||||
// spliceLines replaces contentLines[start:end] with replacement text, returning
|
||||
// the full content as a single string.
|
||||
func spliceLines(contentLines []string, start, end int, replacement string) string {
|
||||
@@ -562,10 +692,3 @@ func spliceLines(contentLines []string, start, end int, replacement string) stri
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
func truncate(s string, n int) string {
|
||||
if len(s) <= n {
|
||||
return s
|
||||
}
|
||||
return s[:n] + "..."
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@ import (
|
||||
"strings"
|
||||
"syscall"
|
||||
"testing"
|
||||
"testing/iotest"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/google/uuid"
|
||||
@@ -399,6 +400,83 @@ func TestWriteFile(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriteFile_ReportsIOError(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
logger := slogtest.Make(t, &slogtest.Options{IgnoreErrors: true}).Leveled(slog.LevelDebug)
|
||||
fs := afero.NewMemMapFs()
|
||||
api := agentfiles.NewAPI(logger, fs, nil)
|
||||
|
||||
tmpdir := os.TempDir()
|
||||
path := filepath.Join(tmpdir, "write-io-error")
|
||||
err := afero.WriteFile(fs, path, []byte("original"), 0o644)
|
||||
require.NoError(t, err)
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitShort)
|
||||
defer cancel()
|
||||
|
||||
// A reader that always errors simulates a failed body read
|
||||
// (e.g. network interruption). The atomic write should leave
|
||||
// the original file intact.
|
||||
body := iotest.ErrReader(xerrors.New("simulated I/O error"))
|
||||
w := httptest.NewRecorder()
|
||||
r := httptest.NewRequestWithContext(ctx, http.MethodPost,
|
||||
fmt.Sprintf("/write-file?path=%s", path), body)
|
||||
api.Routes().ServeHTTP(w, r)
|
||||
|
||||
require.Equal(t, http.StatusInternalServerError, w.Code)
|
||||
got := &codersdk.Error{}
|
||||
err = json.NewDecoder(w.Body).Decode(got)
|
||||
require.NoError(t, err)
|
||||
require.ErrorContains(t, got, "simulated I/O error")
|
||||
|
||||
// The original file must survive the failed write.
|
||||
data, err := afero.ReadFile(fs, path)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "original", string(data))
|
||||
}
|
||||
|
||||
func TestWriteFile_PreservesPermissions(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
if runtime.GOOS == "windows" {
|
||||
t.Skip("file permissions are not reliably supported on Windows")
|
||||
}
|
||||
|
||||
dir := t.TempDir()
|
||||
logger := slogtest.Make(t, nil).Leveled(slog.LevelDebug)
|
||||
osFs := afero.NewOsFs()
|
||||
api := agentfiles.NewAPI(logger, osFs, nil)
|
||||
|
||||
path := filepath.Join(dir, "script.sh")
|
||||
err := afero.WriteFile(osFs, path, []byte("#!/bin/sh\necho hello\n"), 0o755)
|
||||
require.NoError(t, err)
|
||||
|
||||
info, err := osFs.Stat(path)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, os.FileMode(0o755), info.Mode().Perm())
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitShort)
|
||||
defer cancel()
|
||||
|
||||
// Overwrite the file with new content.
|
||||
w := httptest.NewRecorder()
|
||||
r := httptest.NewRequestWithContext(ctx, http.MethodPost,
|
||||
fmt.Sprintf("/write-file?path=%s", path),
|
||||
bytes.NewReader([]byte("#!/bin/sh\necho world\n")))
|
||||
api.Routes().ServeHTTP(w, r)
|
||||
require.Equal(t, http.StatusOK, w.Code)
|
||||
|
||||
data, err := afero.ReadFile(osFs, path)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "#!/bin/sh\necho world\n", string(data))
|
||||
|
||||
info, err = osFs.Stat(path)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, os.FileMode(0o755), info.Mode().Perm(),
|
||||
"write_file should preserve the original file's permissions")
|
||||
}
|
||||
|
||||
func TestEditFiles(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
@@ -576,7 +654,9 @@ func TestEditFiles(t *testing.T) {
|
||||
expected: map[string]string{filepath.Join(tmpdir, "edit1"): "bar bar"},
|
||||
},
|
||||
{
|
||||
name: "EditEdit", // Edits affect previous edits.
|
||||
// When the second edit creates ambiguity (two "bar"
|
||||
// occurrences), it should fail.
|
||||
name: "EditEditAmbiguous",
|
||||
contents: map[string]string{filepath.Join(tmpdir, "edit-edit"): "foo bar"},
|
||||
edits: []workspacesdk.FileEdits{
|
||||
{
|
||||
@@ -593,7 +673,33 @@ func TestEditFiles(t *testing.T) {
|
||||
},
|
||||
},
|
||||
},
|
||||
expected: map[string]string{filepath.Join(tmpdir, "edit-edit"): "qux qux"},
|
||||
errCode: http.StatusBadRequest,
|
||||
errors: []string{"matches 2 occurrences"},
|
||||
// File should not be modified on error.
|
||||
expected: map[string]string{filepath.Join(tmpdir, "edit-edit"): "foo bar"},
|
||||
},
|
||||
{
|
||||
// With replace_all the cascading edit replaces
|
||||
// both occurrences.
|
||||
name: "EditEditReplaceAll",
|
||||
contents: map[string]string{filepath.Join(tmpdir, "edit-edit-ra"): "foo bar"},
|
||||
edits: []workspacesdk.FileEdits{
|
||||
{
|
||||
Path: filepath.Join(tmpdir, "edit-edit-ra"),
|
||||
Edits: []workspacesdk.FileEdit{
|
||||
{
|
||||
Search: "foo",
|
||||
Replace: "bar",
|
||||
},
|
||||
{
|
||||
Search: "bar",
|
||||
Replace: "qux",
|
||||
ReplaceAll: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
expected: map[string]string{filepath.Join(tmpdir, "edit-edit-ra"): "qux qux"},
|
||||
},
|
||||
{
|
||||
name: "Multiline",
|
||||
@@ -720,7 +826,7 @@ func TestEditFiles(t *testing.T) {
|
||||
expected: map[string]string{filepath.Join(tmpdir, "exact-preferred"): "goodbye world"},
|
||||
},
|
||||
{
|
||||
name: "NoMatchStillSucceeds",
|
||||
name: "NoMatchErrors",
|
||||
contents: map[string]string{filepath.Join(tmpdir, "no-match"): "original content"},
|
||||
edits: []workspacesdk.FileEdits{
|
||||
{
|
||||
@@ -733,9 +839,46 @@ func TestEditFiles(t *testing.T) {
|
||||
},
|
||||
},
|
||||
},
|
||||
errCode: http.StatusBadRequest,
|
||||
errors: []string{"search string not found in file"},
|
||||
// File should remain unchanged.
|
||||
expected: map[string]string{filepath.Join(tmpdir, "no-match"): "original content"},
|
||||
},
|
||||
{
|
||||
name: "AmbiguousExactMatch",
|
||||
contents: map[string]string{filepath.Join(tmpdir, "ambig-exact"): "foo bar foo baz foo"},
|
||||
edits: []workspacesdk.FileEdits{
|
||||
{
|
||||
Path: filepath.Join(tmpdir, "ambig-exact"),
|
||||
Edits: []workspacesdk.FileEdit{
|
||||
{
|
||||
Search: "foo",
|
||||
Replace: "qux",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
errCode: http.StatusBadRequest,
|
||||
errors: []string{"matches 3 occurrences"},
|
||||
expected: map[string]string{filepath.Join(tmpdir, "ambig-exact"): "foo bar foo baz foo"},
|
||||
},
|
||||
{
|
||||
name: "ReplaceAllExact",
|
||||
contents: map[string]string{filepath.Join(tmpdir, "ra-exact"): "foo bar foo baz foo"},
|
||||
edits: []workspacesdk.FileEdits{
|
||||
{
|
||||
Path: filepath.Join(tmpdir, "ra-exact"),
|
||||
Edits: []workspacesdk.FileEdit{
|
||||
{
|
||||
Search: "foo",
|
||||
Replace: "qux",
|
||||
ReplaceAll: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
expected: map[string]string{filepath.Join(tmpdir, "ra-exact"): "qux bar qux baz qux"},
|
||||
},
|
||||
{
|
||||
name: "MixedWhitespaceMultiline",
|
||||
contents: map[string]string{filepath.Join(tmpdir, "mixed-ws"): "func main() {\n\tresult := compute()\n\tfmt.Println(result)\n}"},
|
||||
@@ -842,6 +985,67 @@ func TestEditFiles(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestEditFiles_PreservesPermissions(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
if runtime.GOOS == "windows" {
|
||||
t.Skip("file permissions are not reliably supported on Windows")
|
||||
}
|
||||
|
||||
dir := t.TempDir()
|
||||
logger := slogtest.Make(t, nil).Leveled(slog.LevelDebug)
|
||||
osFs := afero.NewOsFs()
|
||||
api := agentfiles.NewAPI(logger, osFs, nil)
|
||||
|
||||
path := filepath.Join(dir, "script.sh")
|
||||
err := afero.WriteFile(osFs, path, []byte("#!/bin/sh\necho hello\n"), 0o755)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Sanity-check the initial mode.
|
||||
info, err := osFs.Stat(path)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, os.FileMode(0o755), info.Mode().Perm())
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitShort)
|
||||
defer cancel()
|
||||
|
||||
body := workspacesdk.FileEditRequest{
|
||||
Files: []workspacesdk.FileEdits{
|
||||
{
|
||||
Path: path,
|
||||
Edits: []workspacesdk.FileEdit{
|
||||
{
|
||||
Search: "hello",
|
||||
Replace: "world",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
buf := bytes.NewBuffer(nil)
|
||||
enc := json.NewEncoder(buf)
|
||||
enc.SetEscapeHTML(false)
|
||||
err = enc.Encode(body)
|
||||
require.NoError(t, err)
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
r := httptest.NewRequestWithContext(ctx, http.MethodPost, "/edit-files", buf)
|
||||
api.Routes().ServeHTTP(w, r)
|
||||
require.Equal(t, http.StatusOK, w.Code)
|
||||
|
||||
// Verify content was updated.
|
||||
data, err := afero.ReadFile(osFs, path)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "#!/bin/sh\necho world\n", string(data))
|
||||
|
||||
// Verify permissions are preserved after the
|
||||
// temp-file-and-rename cycle.
|
||||
info, err = osFs.Stat(path)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, os.FileMode(0o755), info.Mode().Perm(),
|
||||
"edit_files should preserve the original file's permissions")
|
||||
}
|
||||
|
||||
func TestHandleWriteFile_ChatHeaders_UpdatesPathStore(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
package agentproc
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"sort"
|
||||
"time"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/google/uuid"
|
||||
@@ -18,6 +20,13 @@ import (
|
||||
"github.com/coder/coder/v2/codersdk/workspacesdk"
|
||||
)
|
||||
|
||||
const (
|
||||
// maxWaitDuration is the maximum time a blocking
|
||||
// process output request can wait, regardless of
|
||||
// what the client requests.
|
||||
maxWaitDuration = 5 * time.Minute
|
||||
)
|
||||
|
||||
// API exposes process-related operations through the agent.
|
||||
type API struct {
|
||||
logger slog.Logger
|
||||
@@ -26,10 +35,10 @@ type API struct {
|
||||
}
|
||||
|
||||
// NewAPI creates a new process API handler.
|
||||
func NewAPI(logger slog.Logger, execer agentexec.Execer, updateEnv func(current []string) (updated []string, err error), pathStore *agentgit.PathStore) *API {
|
||||
func NewAPI(logger slog.Logger, execer agentexec.Execer, updateEnv func(current []string) (updated []string, err error), pathStore *agentgit.PathStore, workingDir func() string) *API {
|
||||
return &API{
|
||||
logger: logger,
|
||||
manager: newManager(logger, execer, updateEnv),
|
||||
manager: newManager(logger, execer, updateEnv, workingDir),
|
||||
pathStore: pathStore,
|
||||
}
|
||||
}
|
||||
@@ -151,6 +160,42 @@ func (api *API) handleProcessOutput(rw http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
// Enforce chat ID isolation. If the request carries
|
||||
// a chat context, only allow access to processes
|
||||
// belonging to that chat.
|
||||
if chatID, _, ok := agentgit.ExtractChatContext(r); ok {
|
||||
if proc.chatID != "" && proc.chatID != chatID.String() {
|
||||
httpapi.Write(ctx, rw, http.StatusNotFound, codersdk.Response{
|
||||
Message: fmt.Sprintf("Process %q not found.", id),
|
||||
})
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// Check for blocking mode via query params.
|
||||
waitStr := r.URL.Query().Get("wait")
|
||||
wantWait := waitStr == "true"
|
||||
|
||||
if wantWait {
|
||||
// Extend the write deadline so the HTTP server's
|
||||
// WriteTimeout does not kill the connection while
|
||||
// we block.
|
||||
rc := http.NewResponseController(rw)
|
||||
if err := rc.SetWriteDeadline(time.Now().Add(maxWaitDuration)); err != nil {
|
||||
api.logger.Error(ctx, "extend write deadline for blocking process output",
|
||||
slog.Error(err),
|
||||
)
|
||||
}
|
||||
|
||||
// Cap the wait at maxWaitDuration regardless of
|
||||
// client-supplied timeout.
|
||||
waitCtx, waitCancel := context.WithTimeout(ctx, maxWaitDuration)
|
||||
defer waitCancel()
|
||||
|
||||
_ = proc.waitForOutput(waitCtx)
|
||||
// Fall through to read snapshot below.
|
||||
}
|
||||
|
||||
output, truncated := proc.output()
|
||||
info := proc.info()
|
||||
|
||||
@@ -168,6 +213,17 @@ func (api *API) handleSignalProcess(rw http.ResponseWriter, r *http.Request) {
|
||||
|
||||
id := chi.URLParam(r, "id")
|
||||
|
||||
// Enforce chat ID isolation.
|
||||
if chatID, _, ok := agentgit.ExtractChatContext(r); ok {
|
||||
proc, procOK := api.manager.get(id)
|
||||
if procOK && proc.chatID != "" && proc.chatID != chatID.String() {
|
||||
httpapi.Write(ctx, rw, http.StatusNotFound, codersdk.Response{
|
||||
Message: fmt.Sprintf("Process %q not found.", id),
|
||||
})
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
var req workspacesdk.SignalProcessRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
|
||||
|
||||
@@ -7,8 +7,10 @@ import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"runtime"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
@@ -76,6 +78,22 @@ func getOutput(t *testing.T, handler http.Handler, id string) *httptest.Response
|
||||
return w
|
||||
}
|
||||
|
||||
// getOutputWithHeaders sends a GET /{id}/output request with
|
||||
// custom headers and returns the recorder.
|
||||
func getOutputWithHeaders(t *testing.T, handler http.Handler, id string, headers http.Header) *httptest.ResponseRecorder {
|
||||
t.Helper()
|
||||
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
|
||||
defer cancel()
|
||||
path := fmt.Sprintf("/%s/output", id)
|
||||
req := httptest.NewRequestWithContext(ctx, http.MethodGet, path, nil)
|
||||
for k, v := range headers {
|
||||
req.Header[k] = v
|
||||
}
|
||||
w := httptest.NewRecorder()
|
||||
handler.ServeHTTP(w, req)
|
||||
return w
|
||||
}
|
||||
|
||||
// postSignal sends a POST /{id}/signal request and returns
|
||||
// the recorder.
|
||||
func postSignal(t *testing.T, handler http.Handler, id string, req workspacesdk.SignalProcessRequest) *httptest.ResponseRecorder {
|
||||
@@ -97,18 +115,25 @@ func postSignal(t *testing.T, handler http.Handler, id string, req workspacesdk.
|
||||
// execer, returning the handler and API.
|
||||
func newTestAPI(t *testing.T) http.Handler {
|
||||
t.Helper()
|
||||
return newTestAPIWithUpdateEnv(t, nil)
|
||||
return newTestAPIWithOptions(t, nil, nil)
|
||||
}
|
||||
|
||||
// newTestAPIWithUpdateEnv creates a new API with an optional
|
||||
// updateEnv hook for testing environment injection.
|
||||
func newTestAPIWithUpdateEnv(t *testing.T, updateEnv func([]string) ([]string, error)) http.Handler {
|
||||
t.Helper()
|
||||
return newTestAPIWithOptions(t, updateEnv, nil)
|
||||
}
|
||||
|
||||
// newTestAPIWithOptions creates a new API with optional
|
||||
// updateEnv and workingDir hooks.
|
||||
func newTestAPIWithOptions(t *testing.T, updateEnv func([]string) ([]string, error), workingDir func() string) http.Handler {
|
||||
t.Helper()
|
||||
|
||||
logger := slogtest.Make(t, &slogtest.Options{
|
||||
IgnoreErrors: true,
|
||||
}).Leveled(slog.LevelDebug)
|
||||
api := agentproc.NewAPI(logger, agentexec.DefaultExecer, updateEnv, nil)
|
||||
api := agentproc.NewAPI(logger, agentexec.DefaultExecer, updateEnv, nil, workingDir)
|
||||
t.Cleanup(func() {
|
||||
_ = api.Close()
|
||||
})
|
||||
@@ -253,6 +278,100 @@ func TestStartProcess(t *testing.T) {
|
||||
require.Contains(t, resp.Output, "marker.txt")
|
||||
})
|
||||
|
||||
t.Run("DefaultWorkDirIsHome", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// No working directory closure, so the process
|
||||
// should fall back to $HOME. We verify through
|
||||
// the process list API which reports the resolved
|
||||
// working directory using native OS paths,
|
||||
// avoiding shell path format mismatches on
|
||||
// Windows (Git Bash returns POSIX paths).
|
||||
handler := newTestAPI(t)
|
||||
|
||||
homeDir, err := os.UserHomeDir()
|
||||
require.NoError(t, err)
|
||||
|
||||
id := startAndGetID(t, handler, workspacesdk.StartProcessRequest{
|
||||
Command: "echo ok",
|
||||
})
|
||||
|
||||
resp := waitForExit(t, handler, id)
|
||||
require.NotNil(t, resp.ExitCode)
|
||||
require.Equal(t, 0, *resp.ExitCode)
|
||||
|
||||
w := getList(t, handler)
|
||||
require.Equal(t, http.StatusOK, w.Code)
|
||||
var listResp workspacesdk.ListProcessesResponse
|
||||
require.NoError(t, json.NewDecoder(w.Body).Decode(&listResp))
|
||||
var proc *workspacesdk.ProcessInfo
|
||||
for i := range listResp.Processes {
|
||||
if listResp.Processes[i].ID == id {
|
||||
proc = &listResp.Processes[i]
|
||||
break
|
||||
}
|
||||
}
|
||||
require.NotNil(t, proc, "process not found in list")
|
||||
require.Equal(t, homeDir, proc.WorkDir)
|
||||
})
|
||||
|
||||
t.Run("DefaultWorkDirFromClosure", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// The closure provides a valid directory, so the
|
||||
// process should start there. Use the marker file
|
||||
// pattern to avoid path format mismatches on
|
||||
// Windows.
|
||||
tmpDir := t.TempDir()
|
||||
handler := newTestAPIWithOptions(t, nil, func() string {
|
||||
return tmpDir
|
||||
})
|
||||
|
||||
id := startAndGetID(t, handler, workspacesdk.StartProcessRequest{
|
||||
Command: "touch marker.txt && ls marker.txt",
|
||||
})
|
||||
|
||||
resp := waitForExit(t, handler, id)
|
||||
require.NotNil(t, resp.ExitCode)
|
||||
require.Equal(t, 0, *resp.ExitCode)
|
||||
require.Contains(t, resp.Output, "marker.txt")
|
||||
})
|
||||
|
||||
t.Run("DefaultWorkDirClosureNonExistentFallsBackToHome", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// The closure returns a path that doesn't exist,
|
||||
// so the process should fall back to $HOME.
|
||||
handler := newTestAPIWithOptions(t, nil, func() string {
|
||||
return "/tmp/nonexistent-dir-" + fmt.Sprintf("%d", time.Now().UnixNano())
|
||||
})
|
||||
|
||||
homeDir, err := os.UserHomeDir()
|
||||
require.NoError(t, err)
|
||||
|
||||
id := startAndGetID(t, handler, workspacesdk.StartProcessRequest{
|
||||
Command: "echo ok",
|
||||
})
|
||||
|
||||
resp := waitForExit(t, handler, id)
|
||||
require.NotNil(t, resp.ExitCode)
|
||||
require.Equal(t, 0, *resp.ExitCode)
|
||||
|
||||
w := getList(t, handler)
|
||||
require.Equal(t, http.StatusOK, w.Code)
|
||||
var listResp workspacesdk.ListProcessesResponse
|
||||
require.NoError(t, json.NewDecoder(w.Body).Decode(&listResp))
|
||||
var proc *workspacesdk.ProcessInfo
|
||||
for i := range listResp.Processes {
|
||||
if listResp.Processes[i].ID == id {
|
||||
proc = &listResp.Processes[i]
|
||||
break
|
||||
}
|
||||
}
|
||||
require.NotNil(t, proc, "process not found in list")
|
||||
require.Equal(t, homeDir, proc.WorkDir)
|
||||
})
|
||||
|
||||
t.Run("CustomEnv", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
@@ -637,6 +756,161 @@ func TestProcessOutput(t *testing.T) {
|
||||
require.NoError(t, err)
|
||||
require.Contains(t, resp.Message, "not found")
|
||||
})
|
||||
|
||||
t.Run("ChatIDEnforcement", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
handler := newTestAPI(t)
|
||||
|
||||
// Start a process with chat-a.
|
||||
chatA := uuid.New()
|
||||
id := startAndGetID(t, handler, workspacesdk.StartProcessRequest{
|
||||
Command: "echo secret",
|
||||
Background: true,
|
||||
}, http.Header{
|
||||
workspacesdk.CoderChatIDHeader: {chatA.String()},
|
||||
})
|
||||
waitForExit(t, handler, id)
|
||||
|
||||
// Chat-b should NOT see this process.
|
||||
chatB := uuid.New()
|
||||
w1 := getOutputWithHeaders(t, handler, id, http.Header{
|
||||
workspacesdk.CoderChatIDHeader: {chatB.String()},
|
||||
})
|
||||
require.Equal(t, http.StatusNotFound, w1.Code)
|
||||
|
||||
// Without any chat ID header, should return 200
|
||||
// (backwards compatible).
|
||||
w2 := getOutput(t, handler, id)
|
||||
require.Equal(t, http.StatusOK, w2.Code)
|
||||
})
|
||||
|
||||
t.Run("WaitForExit", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
handler := newTestAPI(t)
|
||||
|
||||
id := startAndGetID(t, handler, workspacesdk.StartProcessRequest{
|
||||
Command: "echo hello-wait && sleep 0.1",
|
||||
})
|
||||
|
||||
w := getOutputWithWait(t, handler, id)
|
||||
require.Equal(t, http.StatusOK, w.Code)
|
||||
|
||||
var resp workspacesdk.ProcessOutputResponse
|
||||
err := json.NewDecoder(w.Body).Decode(&resp)
|
||||
require.NoError(t, err)
|
||||
require.False(t, resp.Running)
|
||||
require.NotNil(t, resp.ExitCode)
|
||||
require.Equal(t, 0, *resp.ExitCode)
|
||||
require.Contains(t, resp.Output, "hello-wait")
|
||||
})
|
||||
|
||||
t.Run("WaitAlreadyExited", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
handler := newTestAPI(t)
|
||||
|
||||
id := startAndGetID(t, handler, workspacesdk.StartProcessRequest{
|
||||
Command: "echo done",
|
||||
})
|
||||
|
||||
waitForExit(t, handler, id)
|
||||
|
||||
w := getOutputWithWait(t, handler, id)
|
||||
require.Equal(t, http.StatusOK, w.Code)
|
||||
|
||||
var resp workspacesdk.ProcessOutputResponse
|
||||
err := json.NewDecoder(w.Body).Decode(&resp)
|
||||
require.NoError(t, err)
|
||||
require.False(t, resp.Running)
|
||||
require.Contains(t, resp.Output, "done")
|
||||
})
|
||||
|
||||
t.Run("WaitTimeout", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
handler := newTestAPI(t)
|
||||
|
||||
id := startAndGetID(t, handler, workspacesdk.StartProcessRequest{
|
||||
Command: "sleep 300",
|
||||
Background: true,
|
||||
})
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), testutil.IntervalMedium)
|
||||
defer cancel()
|
||||
|
||||
w := getOutputWithWaitCtx(ctx, t, handler, id)
|
||||
require.Equal(t, http.StatusOK, w.Code)
|
||||
|
||||
var resp workspacesdk.ProcessOutputResponse
|
||||
err := json.NewDecoder(w.Body).Decode(&resp)
|
||||
require.NoError(t, err)
|
||||
require.True(t, resp.Running)
|
||||
|
||||
// Kill and wait for the process so cleanup does
|
||||
// not hang.
|
||||
postSignal(
|
||||
t, handler, id,
|
||||
workspacesdk.SignalProcessRequest{Signal: "kill"},
|
||||
)
|
||||
waitForExit(t, handler, id)
|
||||
})
|
||||
|
||||
t.Run("ConcurrentWaiters", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
handler := newTestAPI(t)
|
||||
|
||||
id := startAndGetID(t, handler, workspacesdk.StartProcessRequest{
|
||||
Command: "sleep 300",
|
||||
Background: true,
|
||||
})
|
||||
|
||||
var (
|
||||
wg sync.WaitGroup
|
||||
resps [2]workspacesdk.ProcessOutputResponse
|
||||
codes [2]int
|
||||
)
|
||||
for i := range 2 {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
w := getOutputWithWait(t, handler, id)
|
||||
codes[i] = w.Code
|
||||
_ = json.NewDecoder(w.Body).Decode(&resps[i])
|
||||
}()
|
||||
}
|
||||
|
||||
// Signal the process to exit so both waiters unblock.
|
||||
postSignal(
|
||||
t, handler, id,
|
||||
workspacesdk.SignalProcessRequest{Signal: "kill"},
|
||||
)
|
||||
|
||||
wg.Wait()
|
||||
|
||||
for i := range 2 {
|
||||
require.Equal(t, http.StatusOK, codes[i], "waiter %d", i)
|
||||
require.False(t, resps[i].Running, "waiter %d", i)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func getOutputWithWait(t *testing.T, handler http.Handler, id string) *httptest.ResponseRecorder {
|
||||
t.Helper()
|
||||
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
|
||||
defer cancel()
|
||||
return getOutputWithWaitCtx(ctx, t, handler, id)
|
||||
}
|
||||
|
||||
func getOutputWithWaitCtx(ctx context.Context, t *testing.T, handler http.Handler, id string) *httptest.ResponseRecorder {
|
||||
t.Helper()
|
||||
path := fmt.Sprintf("/%s/output?wait=true", id)
|
||||
req := httptest.NewRequestWithContext(ctx, http.MethodGet, path, nil)
|
||||
w := httptest.NewRecorder()
|
||||
handler.ServeHTTP(w, req)
|
||||
return w
|
||||
}
|
||||
|
||||
func TestSignalProcess(t *testing.T) {
|
||||
@@ -781,7 +1055,7 @@ func TestHandleStartProcess_ChatHeaders_EmptyWorkDir_StillNotifies(t *testing.T)
|
||||
logger := slogtest.Make(t, nil).Leveled(slog.LevelDebug)
|
||||
api := agentproc.NewAPI(logger, agentexec.DefaultExecer, func(current []string) ([]string, error) {
|
||||
return current, nil
|
||||
}, pathStore)
|
||||
}, pathStore, nil)
|
||||
defer api.Close()
|
||||
|
||||
routes := api.Routes()
|
||||
|
||||
@@ -39,11 +39,13 @@ const (
|
||||
// how much output is written.
|
||||
type HeadTailBuffer struct {
|
||||
mu sync.Mutex
|
||||
cond *sync.Cond
|
||||
head []byte
|
||||
tail []byte
|
||||
tailPos int
|
||||
tailFull bool
|
||||
headFull bool
|
||||
closed bool
|
||||
totalBytes int
|
||||
maxHead int
|
||||
maxTail int
|
||||
@@ -52,20 +54,24 @@ type HeadTailBuffer struct {
|
||||
// NewHeadTailBuffer creates a new HeadTailBuffer with the
|
||||
// default head and tail sizes.
|
||||
func NewHeadTailBuffer() *HeadTailBuffer {
|
||||
return &HeadTailBuffer{
|
||||
b := &HeadTailBuffer{
|
||||
maxHead: MaxHeadBytes,
|
||||
maxTail: MaxTailBytes,
|
||||
}
|
||||
b.cond = sync.NewCond(&b.mu)
|
||||
return b
|
||||
}
|
||||
|
||||
// NewHeadTailBufferSized creates a HeadTailBuffer with custom
|
||||
// head and tail sizes. This is useful for testing truncation
|
||||
// logic with smaller buffers.
|
||||
func NewHeadTailBufferSized(maxHead, maxTail int) *HeadTailBuffer {
|
||||
return &HeadTailBuffer{
|
||||
b := &HeadTailBuffer{
|
||||
maxHead: maxHead,
|
||||
maxTail: maxTail,
|
||||
}
|
||||
b.cond = sync.NewCond(&b.mu)
|
||||
return b
|
||||
}
|
||||
|
||||
// Write implements io.Writer. It is safe for concurrent use.
|
||||
@@ -296,6 +302,15 @@ func truncateLines(s string) string {
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// Close marks the buffer as closed and wakes any waiters.
|
||||
// This is called when the process exits.
|
||||
func (b *HeadTailBuffer) Close() {
|
||||
b.mu.Lock()
|
||||
defer b.mu.Unlock()
|
||||
b.closed = true
|
||||
b.cond.Broadcast()
|
||||
}
|
||||
|
||||
// Reset clears the buffer, discarding all data.
|
||||
func (b *HeadTailBuffer) Reset() {
|
||||
b.mu.Lock()
|
||||
@@ -305,5 +320,7 @@ func (b *HeadTailBuffer) Reset() {
|
||||
b.tailPos = 0
|
||||
b.tailFull = false
|
||||
b.headFull = false
|
||||
b.closed = false
|
||||
b.totalBytes = 0
|
||||
b.cond.Broadcast()
|
||||
}
|
||||
|
||||
26
agent/agentproc/proc_other.go
Normal file
26
agent/agentproc/proc_other.go
Normal file
@@ -0,0 +1,26 @@
|
||||
//go:build !windows
|
||||
|
||||
package agentproc
|
||||
|
||||
import (
|
||||
"os"
|
||||
"syscall"
|
||||
)
|
||||
|
||||
// procSysProcAttr returns the SysProcAttr to use when spawning
|
||||
// processes. On Unix, Setpgid creates a new process group so
|
||||
// that signals can be delivered to the entire group (the shell
|
||||
// and all its children).
|
||||
func procSysProcAttr() *syscall.SysProcAttr {
|
||||
return &syscall.SysProcAttr{
|
||||
Setpgid: true,
|
||||
}
|
||||
}
|
||||
|
||||
// signalProcess sends a signal to the process group rooted at p.
|
||||
// Using the negative PID sends the signal to every process in the
|
||||
// group, ensuring child processes (e.g. from shell pipelines) are
|
||||
// also signaled.
|
||||
func signalProcess(p *os.Process, sig syscall.Signal) error {
|
||||
return syscall.Kill(-p.Pid, sig)
|
||||
}
|
||||
20
agent/agentproc/proc_windows.go
Normal file
20
agent/agentproc/proc_windows.go
Normal file
@@ -0,0 +1,20 @@
|
||||
package agentproc
|
||||
|
||||
import (
|
||||
"os"
|
||||
"syscall"
|
||||
)
|
||||
|
||||
// procSysProcAttr returns the SysProcAttr to use when spawning
|
||||
// processes. On Windows, process groups are not supported in the
|
||||
// same way as Unix, so this returns an empty struct.
|
||||
func procSysProcAttr() *syscall.SysProcAttr {
|
||||
return &syscall.SysProcAttr{}
|
||||
}
|
||||
|
||||
// signalProcess sends a signal directly to the process. Windows
|
||||
// does not support process group signaling, so we fall back to
|
||||
// sending the signal to the process itself.
|
||||
func signalProcess(p *os.Process, _ syscall.Signal) error {
|
||||
return p.Kill()
|
||||
}
|
||||
@@ -70,23 +70,25 @@ func (p *process) output() (string, *workspacesdk.ProcessTruncation) {
|
||||
|
||||
// manager tracks processes spawned by the agent.
|
||||
type manager struct {
|
||||
mu sync.Mutex
|
||||
logger slog.Logger
|
||||
execer agentexec.Execer
|
||||
clock quartz.Clock
|
||||
procs map[string]*process
|
||||
closed bool
|
||||
updateEnv func(current []string) (updated []string, err error)
|
||||
mu sync.Mutex
|
||||
logger slog.Logger
|
||||
execer agentexec.Execer
|
||||
clock quartz.Clock
|
||||
procs map[string]*process
|
||||
closed bool
|
||||
updateEnv func(current []string) (updated []string, err error)
|
||||
workingDir func() string
|
||||
}
|
||||
|
||||
// newManager creates a new process manager.
|
||||
func newManager(logger slog.Logger, execer agentexec.Execer, updateEnv func(current []string) (updated []string, err error)) *manager {
|
||||
func newManager(logger slog.Logger, execer agentexec.Execer, updateEnv func(current []string) (updated []string, err error), workingDir func() string) *manager {
|
||||
return &manager{
|
||||
logger: logger,
|
||||
execer: execer,
|
||||
clock: quartz.NewReal(),
|
||||
procs: make(map[string]*process),
|
||||
updateEnv: updateEnv,
|
||||
logger: logger,
|
||||
execer: execer,
|
||||
clock: quartz.NewReal(),
|
||||
procs: make(map[string]*process),
|
||||
updateEnv: updateEnv,
|
||||
workingDir: workingDir,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -109,10 +111,9 @@ func (m *manager) start(req workspacesdk.StartProcessRequest, chatID string) (*p
|
||||
// the process is not tied to any HTTP request.
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
cmd := m.execer.CommandContext(ctx, "sh", "-c", req.Command)
|
||||
if req.WorkDir != "" {
|
||||
cmd.Dir = req.WorkDir
|
||||
}
|
||||
cmd.Dir = m.resolveWorkDir(req.WorkDir)
|
||||
cmd.Stdin = nil
|
||||
cmd.SysProcAttr = procSysProcAttr()
|
||||
|
||||
// WaitDelay ensures cmd.Wait returns promptly after
|
||||
// the process is killed, even if child processes are
|
||||
@@ -157,7 +158,7 @@ func (m *manager) start(req workspacesdk.StartProcessRequest, chatID string) (*p
|
||||
proc := &process{
|
||||
id: id,
|
||||
command: req.Command,
|
||||
workDir: req.WorkDir,
|
||||
workDir: cmd.Dir,
|
||||
background: req.Background,
|
||||
chatID: chatID,
|
||||
cmd: cmd,
|
||||
@@ -207,6 +208,9 @@ func (m *manager) start(req workspacesdk.StartProcessRequest, chatID string) (*p
|
||||
proc.exitCode = &code
|
||||
proc.mu.Unlock()
|
||||
|
||||
// Wake any waiters blocked on new output or
|
||||
// process exit before closing the done channel.
|
||||
proc.buf.Close()
|
||||
close(proc.done)
|
||||
}()
|
||||
|
||||
@@ -272,13 +276,15 @@ func (m *manager) signal(id string, sig string) error {
|
||||
|
||||
switch sig {
|
||||
case "kill":
|
||||
if err := proc.cmd.Process.Kill(); err != nil {
|
||||
// Use process group kill to ensure child processes
|
||||
// (e.g. from shell pipelines) are also killed.
|
||||
if err := signalProcess(proc.cmd.Process, syscall.SIGKILL); err != nil {
|
||||
return xerrors.Errorf("kill process: %w", err)
|
||||
}
|
||||
case "terminate":
|
||||
//nolint:revive // syscall.SIGTERM is portable enough
|
||||
// for our supported platforms.
|
||||
if err := proc.cmd.Process.Signal(syscall.SIGTERM); err != nil {
|
||||
// Use process group signal to ensure child processes
|
||||
// are also terminated.
|
||||
if err := signalProcess(proc.cmd.Process, syscall.SIGTERM); err != nil {
|
||||
return xerrors.Errorf("terminate process: %w", err)
|
||||
}
|
||||
default:
|
||||
@@ -316,3 +322,54 @@ func (m *manager) Close() error {
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// waitForOutput blocks until the buffer is closed (process
|
||||
// exited) or the context is canceled. Returns nil when the
|
||||
// buffer closed, ctx.Err() when the context expired.
|
||||
func (p *process) waitForOutput(ctx context.Context) error {
|
||||
p.buf.cond.L.Lock()
|
||||
defer p.buf.cond.L.Unlock()
|
||||
|
||||
nevermind := make(chan struct{})
|
||||
defer close(nevermind)
|
||||
go func() {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
// Acquire the lock before broadcasting to
|
||||
// guarantee the waiter has entered cond.Wait()
|
||||
// (which atomically releases the lock).
|
||||
// Without this, a Broadcast between the loop
|
||||
// predicate check and cond.Wait() is lost.
|
||||
p.buf.cond.L.Lock()
|
||||
defer p.buf.cond.L.Unlock()
|
||||
p.buf.cond.Broadcast()
|
||||
case <-nevermind:
|
||||
}
|
||||
}()
|
||||
|
||||
for ctx.Err() == nil && !p.buf.closed {
|
||||
p.buf.cond.Wait()
|
||||
}
|
||||
return ctx.Err()
|
||||
}
|
||||
|
||||
// resolveWorkDir returns the directory a process should start in.
|
||||
// Priority: explicit request dir > agent configured dir > $HOME.
|
||||
// Falls through when a candidate is empty or does not exist on
|
||||
// disk, matching the behavior of SSH sessions.
|
||||
func (m *manager) resolveWorkDir(requested string) string {
|
||||
if requested != "" {
|
||||
return requested
|
||||
}
|
||||
if m.workingDir != nil {
|
||||
if dir := m.workingDir(); dir != "" {
|
||||
if info, err := os.Stat(dir); err == nil && info.IsDir() {
|
||||
return dir
|
||||
}
|
||||
}
|
||||
}
|
||||
if home, err := os.UserHomeDir(); err == nil {
|
||||
return home
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
@@ -398,11 +398,11 @@ func (r *Runner) run(ctx context.Context, script codersdk.WorkspaceAgentScript,
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
logger.Error(ctx, fmt.Sprintf("reporting script completed: %s", err.Error()))
|
||||
logger.Warn(ctx, "reporting script completed", slog.Error(err))
|
||||
}
|
||||
})
|
||||
if err != nil {
|
||||
logger.Error(ctx, fmt.Sprintf("reporting script completed: track command goroutine: %s", err.Error()))
|
||||
logger.Warn(ctx, "reporting script completed: track command goroutine", slog.Error(err))
|
||||
}
|
||||
}()
|
||||
|
||||
|
||||
@@ -30,6 +30,7 @@ func (a *agent) apiHandler() http.Handler {
|
||||
r.Mount("/api/v0", a.filesAPI.Routes())
|
||||
r.Mount("/api/v0/git", a.gitAPI.Routes())
|
||||
r.Mount("/api/v0/processes", a.processAPI.Routes())
|
||||
r.Mount("/api/v0/desktop", a.desktopAPI.Routes())
|
||||
|
||||
if a.devcontainers {
|
||||
r.Mount("/api/v0/containers", a.containerAPI.Routes())
|
||||
|
||||
@@ -6,7 +6,6 @@ import (
|
||||
"context"
|
||||
"net"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
"github.com/google/uuid"
|
||||
@@ -23,26 +22,6 @@ import (
|
||||
"github.com/coder/coder/v2/testutil"
|
||||
)
|
||||
|
||||
// logSink captures structured log entries for testing.
|
||||
type logSink struct {
|
||||
mu sync.Mutex
|
||||
entries []slog.SinkEntry
|
||||
}
|
||||
|
||||
func (s *logSink) LogEntry(_ context.Context, e slog.SinkEntry) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
s.entries = append(s.entries, e)
|
||||
}
|
||||
|
||||
func (*logSink) Sync() {}
|
||||
|
||||
func (s *logSink) getEntries() []slog.SinkEntry {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
return append([]slog.SinkEntry{}, s.entries...)
|
||||
}
|
||||
|
||||
// getField returns the value of a field by name from a slog.Map.
|
||||
func getField(fields slog.Map, name string) interface{} {
|
||||
for _, f := range fields {
|
||||
@@ -76,8 +55,8 @@ func TestBoundaryLogs_EndToEnd(t *testing.T) {
|
||||
require.NoError(t, err)
|
||||
t.Cleanup(func() { require.NoError(t, srv.Close()) })
|
||||
|
||||
sink := &logSink{}
|
||||
logger := slog.Make(sink)
|
||||
sink := testutil.NewFakeSink(t)
|
||||
logger := sink.Logger(slog.LevelInfo)
|
||||
workspaceID := uuid.New()
|
||||
templateID := uuid.New()
|
||||
templateVersionID := uuid.New()
|
||||
@@ -118,10 +97,10 @@ func TestBoundaryLogs_EndToEnd(t *testing.T) {
|
||||
sendBoundaryLogsRequest(t, conn, req)
|
||||
|
||||
require.Eventually(t, func() bool {
|
||||
return len(sink.getEntries()) >= 1
|
||||
return len(sink.Entries()) >= 1
|
||||
}, testutil.WaitShort, testutil.IntervalFast)
|
||||
|
||||
entries := sink.getEntries()
|
||||
entries := sink.Entries()
|
||||
require.Len(t, entries, 1)
|
||||
entry := entries[0]
|
||||
require.Equal(t, slog.LevelInfo, entry.Level)
|
||||
@@ -152,10 +131,10 @@ func TestBoundaryLogs_EndToEnd(t *testing.T) {
|
||||
sendBoundaryLogsRequest(t, conn, req2)
|
||||
|
||||
require.Eventually(t, func() bool {
|
||||
return len(sink.getEntries()) >= 2
|
||||
return len(sink.Entries()) >= 2
|
||||
}, testutil.WaitShort, testutil.IntervalFast)
|
||||
|
||||
entries = sink.getEntries()
|
||||
entries = sink.Entries()
|
||||
entry = entries[1]
|
||||
require.Len(t, entries, 2)
|
||||
require.Equal(t, slog.LevelInfo, entry.Level)
|
||||
|
||||
@@ -78,6 +78,9 @@ func withDone(t *testing.T) []reaper.Option {
|
||||
// processes and passes their PIDs through the shared channel.
|
||||
func TestReap(t *testing.T) {
|
||||
t.Parallel()
|
||||
if testutil.InCI() {
|
||||
t.Skip("Detected CI, skipping reaper tests")
|
||||
}
|
||||
if !runSubprocess(t) {
|
||||
return
|
||||
}
|
||||
@@ -124,6 +127,9 @@ func TestReap(t *testing.T) {
|
||||
//nolint:tparallel // Subtests must be sequential, each starts its own reaper.
|
||||
func TestForkReapExitCodes(t *testing.T) {
|
||||
t.Parallel()
|
||||
if testutil.InCI() {
|
||||
t.Skip("Detected CI, skipping reaper tests")
|
||||
}
|
||||
if !runSubprocess(t) {
|
||||
return
|
||||
}
|
||||
@@ -164,6 +170,9 @@ func TestForkReapExitCodes(t *testing.T) {
|
||||
// ensures SIGINT cannot kill the parent test binary.
|
||||
func TestReapInterrupt(t *testing.T) {
|
||||
t.Parallel()
|
||||
if testutil.InCI() {
|
||||
t.Skip("Detected CI, skipping reaper tests")
|
||||
}
|
||||
if !runSubprocess(t) {
|
||||
return
|
||||
}
|
||||
|
||||
@@ -46,6 +46,7 @@ func (r *RootCmd) Create(opts CreateOptions) *serpent.Command {
|
||||
autoUpdates string
|
||||
copyParametersFrom string
|
||||
useParameterDefaults bool
|
||||
noWait bool
|
||||
// Organization context is only required if more than 1 template
|
||||
// shares the same name across multiple organizations.
|
||||
orgContext = NewOrganizationContext()
|
||||
@@ -372,6 +373,14 @@ func (r *RootCmd) Create(opts CreateOptions) *serpent.Command {
|
||||
|
||||
cliutil.WarnMatchedProvisioners(inv.Stderr, workspace.LatestBuild.MatchedProvisioners, workspace.LatestBuild.Job)
|
||||
|
||||
if noWait {
|
||||
_, _ = fmt.Fprintf(inv.Stdout,
|
||||
"\nThe %s workspace has been created and is building in the background.\n",
|
||||
cliui.Keyword(workspace.Name),
|
||||
)
|
||||
return nil
|
||||
}
|
||||
|
||||
err = cliui.WorkspaceBuild(inv.Context(), inv.Stdout, client, workspace.LatestBuild.ID)
|
||||
if err != nil {
|
||||
return xerrors.Errorf("watch build: %w", err)
|
||||
@@ -445,6 +454,12 @@ func (r *RootCmd) Create(opts CreateOptions) *serpent.Command {
|
||||
Description: "Automatically accept parameter defaults when no value is provided.",
|
||||
Value: serpent.BoolOf(&useParameterDefaults),
|
||||
},
|
||||
serpent.Option{
|
||||
Flag: "no-wait",
|
||||
Env: "CODER_CREATE_NO_WAIT",
|
||||
Description: "Return immediately after creating the workspace. The build will run in the background.",
|
||||
Value: serpent.BoolOf(&noWait),
|
||||
},
|
||||
cliui.SkipPromptOption(),
|
||||
)
|
||||
cmd.Options = append(cmd.Options, parameterFlags.cliParameters()...)
|
||||
|
||||
@@ -603,6 +603,81 @@ func TestCreate(t *testing.T) {
|
||||
assert.Nil(t, ws.AutostartSchedule, "expected workspace autostart schedule to be nil")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("NoWait", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerDaemon: true})
|
||||
owner := coderdtest.CreateFirstUser(t, client)
|
||||
member, _ := coderdtest.CreateAnotherUser(t, client, owner.OrganizationID)
|
||||
version := coderdtest.CreateTemplateVersion(t, client, owner.OrganizationID, nil)
|
||||
coderdtest.AwaitTemplateVersionJobCompleted(t, client, version.ID)
|
||||
template := coderdtest.CreateTemplate(t, client, owner.OrganizationID, version.ID)
|
||||
|
||||
ctx := testutil.Context(t, testutil.WaitLong)
|
||||
inv, root := clitest.New(t, "create", "my-workspace",
|
||||
"--template", template.Name,
|
||||
"-y",
|
||||
"--no-wait",
|
||||
)
|
||||
clitest.SetupConfig(t, member, root)
|
||||
doneChan := make(chan struct{})
|
||||
pty := ptytest.New(t).Attach(inv)
|
||||
go func() {
|
||||
defer close(doneChan)
|
||||
err := inv.Run()
|
||||
assert.NoError(t, err)
|
||||
}()
|
||||
|
||||
pty.ExpectMatchContext(ctx, "building in the background")
|
||||
_ = testutil.TryReceive(ctx, t, doneChan)
|
||||
|
||||
// Verify workspace was actually created.
|
||||
ws, err := member.WorkspaceByOwnerAndName(ctx, codersdk.Me, "my-workspace", codersdk.WorkspaceOptions{})
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, ws.TemplateName, template.Name)
|
||||
})
|
||||
|
||||
t.Run("NoWaitWithParameterDefaults", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerDaemon: true})
|
||||
owner := coderdtest.CreateFirstUser(t, client)
|
||||
member, _ := coderdtest.CreateAnotherUser(t, client, owner.OrganizationID)
|
||||
version := coderdtest.CreateTemplateVersion(t, client, owner.OrganizationID, prepareEchoResponses([]*proto.RichParameter{
|
||||
{Name: "region", Type: "string", DefaultValue: "us-east-1"},
|
||||
{Name: "instance_type", Type: "string", DefaultValue: "t3.micro"},
|
||||
}))
|
||||
coderdtest.AwaitTemplateVersionJobCompleted(t, client, version.ID)
|
||||
template := coderdtest.CreateTemplate(t, client, owner.OrganizationID, version.ID)
|
||||
|
||||
ctx := testutil.Context(t, testutil.WaitLong)
|
||||
inv, root := clitest.New(t, "create", "my-workspace",
|
||||
"--template", template.Name,
|
||||
"-y",
|
||||
"--use-parameter-defaults",
|
||||
"--no-wait",
|
||||
)
|
||||
clitest.SetupConfig(t, member, root)
|
||||
doneChan := make(chan struct{})
|
||||
pty := ptytest.New(t).Attach(inv)
|
||||
go func() {
|
||||
defer close(doneChan)
|
||||
err := inv.Run()
|
||||
assert.NoError(t, err)
|
||||
}()
|
||||
|
||||
pty.ExpectMatchContext(ctx, "building in the background")
|
||||
_ = testutil.TryReceive(ctx, t, doneChan)
|
||||
|
||||
// Verify workspace was created and parameters were applied.
|
||||
ws, err := member.WorkspaceByOwnerAndName(ctx, codersdk.Me, "my-workspace", codersdk.WorkspaceOptions{})
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, ws.TemplateName, template.Name)
|
||||
|
||||
buildParams, err := member.WorkspaceBuildParameters(ctx, ws.LatestBuild.ID)
|
||||
require.NoError(t, err)
|
||||
assert.Contains(t, buildParams, codersdk.WorkspaceBuildParameter{Name: "region", Value: "us-east-1"})
|
||||
assert.Contains(t, buildParams, codersdk.WorkspaceBuildParameter{Name: "instance_type", Value: "t3.micro"})
|
||||
})
|
||||
}
|
||||
|
||||
func prepareEchoResponses(parameters []*proto.RichParameter, presets ...*proto.Preset) *echo.Responses {
|
||||
|
||||
@@ -1000,6 +1000,12 @@ func mcpFromSDK(sdkTool toolsdk.GenericTool, tb toolsdk.Deps) server.ServerTool
|
||||
Properties: sdkTool.Schema.Properties,
|
||||
Required: sdkTool.Schema.Required,
|
||||
},
|
||||
Annotations: mcp.ToolAnnotation{
|
||||
ReadOnlyHint: mcp.ToBoolPtr(sdkTool.MCPAnnotations.ReadOnlyHint),
|
||||
DestructiveHint: mcp.ToBoolPtr(sdkTool.MCPAnnotations.DestructiveHint),
|
||||
IdempotentHint: mcp.ToBoolPtr(sdkTool.MCPAnnotations.IdempotentHint),
|
||||
OpenWorldHint: mcp.ToBoolPtr(sdkTool.MCPAnnotations.OpenWorldHint),
|
||||
},
|
||||
},
|
||||
Handler: func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
||||
var buf bytes.Buffer
|
||||
|
||||
@@ -81,7 +81,13 @@ func TestExpMcpServer(t *testing.T) {
|
||||
var toolsResponse struct {
|
||||
Result struct {
|
||||
Tools []struct {
|
||||
Name string `json:"name"`
|
||||
Name string `json:"name"`
|
||||
Annotations struct {
|
||||
ReadOnlyHint *bool `json:"readOnlyHint"`
|
||||
DestructiveHint *bool `json:"destructiveHint"`
|
||||
IdempotentHint *bool `json:"idempotentHint"`
|
||||
OpenWorldHint *bool `json:"openWorldHint"`
|
||||
} `json:"annotations"`
|
||||
} `json:"tools"`
|
||||
} `json:"result"`
|
||||
}
|
||||
@@ -94,6 +100,15 @@ func TestExpMcpServer(t *testing.T) {
|
||||
}
|
||||
slices.Sort(foundTools)
|
||||
require.Equal(t, []string{"coder_get_authenticated_user"}, foundTools)
|
||||
annotations := toolsResponse.Result.Tools[0].Annotations
|
||||
require.NotNil(t, annotations.ReadOnlyHint)
|
||||
require.NotNil(t, annotations.DestructiveHint)
|
||||
require.NotNil(t, annotations.IdempotentHint)
|
||||
require.NotNil(t, annotations.OpenWorldHint)
|
||||
assert.True(t, *annotations.ReadOnlyHint)
|
||||
assert.False(t, *annotations.DestructiveHint)
|
||||
assert.True(t, *annotations.IdempotentHint)
|
||||
assert.False(t, *annotations.OpenWorldHint)
|
||||
|
||||
// Call the tool and ensure it works.
|
||||
toolPayload := `{"jsonrpc":"2.0","id":3,"method":"tools/call", "params": {"name": "coder_get_authenticated_user", "arguments": {}}}`
|
||||
|
||||
@@ -1732,19 +1732,18 @@ const (
|
||||
|
||||
func (r *RootCmd) scaletestAutostart() *serpent.Command {
|
||||
var (
|
||||
workspaceCount int64
|
||||
workspaceJobTimeout time.Duration
|
||||
autostartDelay time.Duration
|
||||
autostartTimeout time.Duration
|
||||
template string
|
||||
noCleanup bool
|
||||
workspaceCount int64
|
||||
workspaceJobTimeout time.Duration
|
||||
autostartBuildTimeout time.Duration
|
||||
autostartDelay time.Duration
|
||||
template string
|
||||
noCleanup bool
|
||||
|
||||
parameterFlags workspaceParameterFlags
|
||||
tracingFlags = &scaletestTracingFlags{}
|
||||
timeoutStrategy = &timeoutFlags{}
|
||||
cleanupStrategy = newScaletestCleanupStrategy()
|
||||
output = &scaletestOutputFlags{}
|
||||
prometheusFlags = &scaletestPrometheusFlags{}
|
||||
)
|
||||
|
||||
cmd := &serpent.Command{
|
||||
@@ -1772,7 +1771,7 @@ func (r *RootCmd) scaletestAutostart() *serpent.Command {
|
||||
|
||||
outputs, err := output.parse()
|
||||
if err != nil {
|
||||
return xerrors.Errorf("could not parse --output flags")
|
||||
return xerrors.Errorf("parse output flags: %w", err)
|
||||
}
|
||||
|
||||
tpl, err := parseTemplate(ctx, client, me.OrganizationIDs, template)
|
||||
@@ -1803,15 +1802,41 @@ func (r *RootCmd) scaletestAutostart() *serpent.Command {
|
||||
}
|
||||
tracer := tracerProvider.Tracer(scaletestTracerName)
|
||||
|
||||
reg := prometheus.NewRegistry()
|
||||
metrics := autostart.NewMetrics(reg)
|
||||
|
||||
setupBarrier := new(sync.WaitGroup)
|
||||
setupBarrier.Add(int(workspaceCount))
|
||||
|
||||
th := harness.NewTestHarness(timeoutStrategy.wrapStrategy(harness.ConcurrentExecutionStrategy{}), cleanupStrategy.toStrategy())
|
||||
// The workspace-build-updates experiment must be enabled to use
|
||||
// the centralized pubsub channel for coordinating workspace builds.
|
||||
experiments, err := client.Experiments(ctx)
|
||||
if err != nil {
|
||||
return xerrors.Errorf("get experiments: %w", err)
|
||||
}
|
||||
if !experiments.Enabled(codersdk.ExperimentWorkspaceBuildUpdates) {
|
||||
return xerrors.New("the workspace-build-updates experiment must be enabled to run the autostart scaletest")
|
||||
}
|
||||
|
||||
workspaceNames := make([]string, 0, workspaceCount)
|
||||
resultSink := make(chan autostart.RunResult, workspaceCount)
|
||||
for i := range workspaceCount {
|
||||
id := strconv.Itoa(int(i))
|
||||
workspaceNames = append(workspaceNames, loadtestutil.GenerateDeterministicWorkspaceName(id))
|
||||
}
|
||||
dispatcher := autostart.NewWorkspaceDispatcher(workspaceNames)
|
||||
|
||||
decoder, err := client.WatchAllWorkspaceBuilds(ctx)
|
||||
if err != nil {
|
||||
return xerrors.Errorf("watch all workspace builds: %w", err)
|
||||
}
|
||||
defer decoder.Close()
|
||||
|
||||
// Start the dispatcher. It will run in a goroutine and automatically
|
||||
// close all workspace channels when the build updates channel closes.
|
||||
dispatcher.Start(ctx, decoder.Chan())
|
||||
|
||||
th := harness.NewTestHarness(timeoutStrategy.wrapStrategy(harness.ConcurrentExecutionStrategy{}), cleanupStrategy.toStrategy())
|
||||
for workspaceName, buildUpdatesChannel := range dispatcher.Channels {
|
||||
id := strings.TrimPrefix(workspaceName, loadtestutil.ScaleTestPrefix+"-")
|
||||
|
||||
config := autostart.Config{
|
||||
User: createusers.Config{
|
||||
OrganizationID: me.OrganizationIDs[0],
|
||||
@@ -1821,13 +1846,16 @@ func (r *RootCmd) scaletestAutostart() *serpent.Command {
|
||||
Request: codersdk.CreateWorkspaceRequest{
|
||||
TemplateID: tpl.ID,
|
||||
RichParameterValues: richParameters,
|
||||
// Use deterministic workspace name so we can pre-create the channel.
|
||||
Name: workspaceName,
|
||||
},
|
||||
},
|
||||
WorkspaceJobTimeout: workspaceJobTimeout,
|
||||
AutostartDelay: autostartDelay,
|
||||
AutostartTimeout: autostartTimeout,
|
||||
Metrics: metrics,
|
||||
SetupBarrier: setupBarrier,
|
||||
WorkspaceJobTimeout: workspaceJobTimeout,
|
||||
AutostartBuildTimeout: autostartBuildTimeout,
|
||||
AutostartDelay: autostartDelay,
|
||||
SetupBarrier: setupBarrier,
|
||||
BuildUpdates: buildUpdatesChannel,
|
||||
ResultSink: resultSink,
|
||||
}
|
||||
if err := config.Validate(); err != nil {
|
||||
return xerrors.Errorf("validate config: %w", err)
|
||||
@@ -1849,18 +1877,11 @@ func (r *RootCmd) scaletestAutostart() *serpent.Command {
|
||||
th.AddRun(autostartTestName, id, runner)
|
||||
}
|
||||
|
||||
logger := inv.Logger
|
||||
prometheusSrvClose := ServeHandler(ctx, logger, promhttp.HandlerFor(reg, promhttp.HandlerOpts{}), prometheusFlags.Address, "prometheus")
|
||||
defer prometheusSrvClose()
|
||||
|
||||
defer func() {
|
||||
_, _ = fmt.Fprintln(inv.Stderr, "\nUploading traces...")
|
||||
if err := closeTracing(ctx); err != nil {
|
||||
_, _ = fmt.Fprintf(inv.Stderr, "\nError uploading traces: %+v\n", err)
|
||||
}
|
||||
// Wait for prometheus metrics to be scraped
|
||||
_, _ = fmt.Fprintf(inv.Stderr, "Waiting %s for prometheus metrics to be scraped\n", prometheusFlags.Wait)
|
||||
<-time.After(prometheusFlags.Wait)
|
||||
}()
|
||||
|
||||
_, _ = fmt.Fprintln(inv.Stderr, "Running autostart load test...")
|
||||
@@ -1871,31 +1892,40 @@ func (r *RootCmd) scaletestAutostart() *serpent.Command {
|
||||
return xerrors.Errorf("run test harness (harness failure, not a test failure): %w", err)
|
||||
}
|
||||
|
||||
// If the command was interrupted, skip stats.
|
||||
if notifyCtx.Err() != nil {
|
||||
return notifyCtx.Err()
|
||||
// Collect all metrics from the channel.
|
||||
close(resultSink)
|
||||
var runResults []autostart.RunResult
|
||||
for r := range resultSink {
|
||||
runResults = append(runResults, r)
|
||||
}
|
||||
|
||||
res := th.Results()
|
||||
for _, o := range outputs {
|
||||
err = o.write(res, inv.Stdout)
|
||||
if err != nil {
|
||||
return xerrors.Errorf("write output %q to %q: %w", o.format, o.path, err)
|
||||
if res.TotalFail > 0 {
|
||||
return xerrors.New("load test failed, see above for more details")
|
||||
}
|
||||
|
||||
_, _ = fmt.Fprintf(inv.Stderr, "\nAll %d autostart builds completed successfully (elapsed: %s)\n", res.TotalRuns, time.Duration(res.Elapsed).Round(time.Millisecond))
|
||||
|
||||
if len(runResults) > 0 {
|
||||
results := autostart.NewRunResults(runResults)
|
||||
for _, out := range outputs {
|
||||
if err := out.write(results.ToHarnessResults(), inv.Stdout); err != nil {
|
||||
return xerrors.Errorf("write output: %w", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if !noCleanup {
|
||||
_, _ = fmt.Fprintln(inv.Stderr, "\nCleaning up...")
|
||||
cleanupCtx, cleanupCancel := cleanupStrategy.toContext(ctx)
|
||||
cleanupCtx, cleanupCancel := cleanupStrategy.toContext(context.Background())
|
||||
defer cleanupCancel()
|
||||
err = th.Cleanup(cleanupCtx)
|
||||
if err != nil {
|
||||
return xerrors.Errorf("cleanup tests: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
if res.TotalFail > 0 {
|
||||
return xerrors.New("load test failed, see above for more details")
|
||||
_, _ = fmt.Fprintln(inv.Stderr, "Cleanup complete")
|
||||
} else {
|
||||
_, _ = fmt.Fprintln(inv.Stderr, "\nSkipping cleanup (--no-cleanup specified). Resources left running.")
|
||||
}
|
||||
|
||||
return nil
|
||||
@@ -1918,6 +1948,13 @@ func (r *RootCmd) scaletestAutostart() *serpent.Command {
|
||||
Description: "Timeout for workspace jobs (e.g. build, start).",
|
||||
Value: serpent.DurationOf(&workspaceJobTimeout),
|
||||
},
|
||||
{
|
||||
Flag: "autostart-build-timeout",
|
||||
Env: "CODER_SCALETEST_AUTOSTART_BUILD_TIMEOUT",
|
||||
Default: "15m",
|
||||
Description: "Timeout for the autostart build to complete. Must be longer than workspace-job-timeout to account for queueing time in high-load scenarios.",
|
||||
Value: serpent.DurationOf(&autostartBuildTimeout),
|
||||
},
|
||||
{
|
||||
Flag: "autostart-delay",
|
||||
Env: "CODER_SCALETEST_AUTOSTART_DELAY",
|
||||
@@ -1925,13 +1962,6 @@ func (r *RootCmd) scaletestAutostart() *serpent.Command {
|
||||
Description: "How long after all the workspaces have been stopped to schedule them to be started again.",
|
||||
Value: serpent.DurationOf(&autostartDelay),
|
||||
},
|
||||
{
|
||||
Flag: "autostart-timeout",
|
||||
Env: "CODER_SCALETEST_AUTOSTART_TIMEOUT",
|
||||
Default: "5m",
|
||||
Description: "Timeout for the autostart build to be initiated after the scheduled start time.",
|
||||
Value: serpent.DurationOf(&autostartTimeout),
|
||||
},
|
||||
{
|
||||
Flag: "template",
|
||||
FlagShorthand: "t",
|
||||
@@ -1950,10 +1980,9 @@ func (r *RootCmd) scaletestAutostart() *serpent.Command {
|
||||
|
||||
cmd.Options = append(cmd.Options, parameterFlags.cliParameters()...)
|
||||
tracingFlags.attach(&cmd.Options)
|
||||
output.attach(&cmd.Options)
|
||||
timeoutStrategy.attach(&cmd.Options)
|
||||
cleanupStrategy.attach(&cmd.Options)
|
||||
output.attach(&cmd.Options)
|
||||
prometheusFlags.attach(&cmd.Options)
|
||||
return cmd
|
||||
}
|
||||
|
||||
|
||||
@@ -214,7 +214,7 @@ func (r *RootCmd) createOrganizationRole(orgContext *OrganizationContext) *serpe
|
||||
} else {
|
||||
updated, err = client.CreateOrganizationRole(ctx, customRole)
|
||||
if err != nil {
|
||||
return xerrors.Errorf("patch role: %w", err)
|
||||
return xerrors.Errorf("create role: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
23
cli/start.go
23
cli/start.go
@@ -79,6 +79,29 @@ func (r *RootCmd) start() *serpent.Command {
|
||||
)
|
||||
build = workspace.LatestBuild
|
||||
default:
|
||||
// If the last build was a failed start, run a stop
|
||||
// first to clean up any partially-provisioned
|
||||
// resources.
|
||||
if workspace.LatestBuild.Status == codersdk.WorkspaceStatusFailed &&
|
||||
workspace.LatestBuild.Transition == codersdk.WorkspaceTransitionStart {
|
||||
_, _ = fmt.Fprintf(inv.Stdout, "The last start build failed. Cleaning up before retrying...\n")
|
||||
stopBuild, stopErr := client.CreateWorkspaceBuild(inv.Context(), workspace.ID, codersdk.CreateWorkspaceBuildRequest{
|
||||
Transition: codersdk.WorkspaceTransitionStop,
|
||||
})
|
||||
if stopErr != nil {
|
||||
return xerrors.Errorf("cleanup stop after failed start: %w", stopErr)
|
||||
}
|
||||
stopErr = cliui.WorkspaceBuild(inv.Context(), inv.Stdout, client, stopBuild.ID)
|
||||
if stopErr != nil {
|
||||
return xerrors.Errorf("wait for cleanup stop: %w", stopErr)
|
||||
}
|
||||
// Re-fetch workspace after stop completes so
|
||||
// startWorkspace sees the latest state.
|
||||
workspace, err = namedWorkspace(inv.Context(), client, inv.Args[0])
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
build, err = startWorkspace(inv, client, workspace, parameterFlags, bflags, WorkspaceStart)
|
||||
// It's possible for a workspace build to fail due to the template requiring starting
|
||||
// workspaces with the active version.
|
||||
|
||||
@@ -534,3 +534,55 @@ func TestStart_WithReason(t *testing.T) {
|
||||
workspace = coderdtest.MustWorkspace(t, member, workspace.ID)
|
||||
require.Equal(t, codersdk.BuildReasonCLI, workspace.LatestBuild.Reason)
|
||||
}
|
||||
|
||||
func TestStart_FailedStartCleansUp(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctx := testutil.Context(t, testutil.WaitLong)
|
||||
|
||||
store, ps := dbtestutil.NewDB(t)
|
||||
client := coderdtest.New(t, &coderdtest.Options{
|
||||
Database: store,
|
||||
Pubsub: ps,
|
||||
IncludeProvisionerDaemon: true,
|
||||
})
|
||||
owner := coderdtest.CreateFirstUser(t, client)
|
||||
memberClient, member := coderdtest.CreateAnotherUser(t, client, owner.OrganizationID)
|
||||
|
||||
version := coderdtest.CreateTemplateVersion(t, client, owner.OrganizationID, nil)
|
||||
coderdtest.AwaitTemplateVersionJobCompleted(t, client, version.ID)
|
||||
template := coderdtest.CreateTemplate(t, client, owner.OrganizationID, version.ID)
|
||||
workspace := coderdtest.CreateWorkspace(t, memberClient, template.ID)
|
||||
coderdtest.AwaitWorkspaceBuildJobCompleted(t, client, workspace.LatestBuild.ID)
|
||||
|
||||
// Insert a failed start build directly into the database so that
|
||||
// the workspace's latest build is a failed "start" transition.
|
||||
dbfake.WorkspaceBuild(t, store, database.WorkspaceTable{
|
||||
ID: workspace.ID,
|
||||
OwnerID: member.ID,
|
||||
OrganizationID: owner.OrganizationID,
|
||||
TemplateID: template.ID,
|
||||
}).
|
||||
Seed(database.WorkspaceBuild{
|
||||
TemplateVersionID: version.ID,
|
||||
Transition: database.WorkspaceTransitionStart,
|
||||
BuildNumber: workspace.LatestBuild.BuildNumber + 1,
|
||||
}).
|
||||
Failed().
|
||||
Do()
|
||||
|
||||
inv, root := clitest.New(t, "start", workspace.Name)
|
||||
clitest.SetupConfig(t, memberClient, root)
|
||||
pty := ptytest.New(t).Attach(inv)
|
||||
doneChan := make(chan struct{})
|
||||
go func() {
|
||||
defer close(doneChan)
|
||||
err := inv.Run()
|
||||
assert.NoError(t, err)
|
||||
}()
|
||||
|
||||
// The CLI should detect the failed start and clean up first.
|
||||
pty.ExpectMatch("Cleaning up before retrying")
|
||||
pty.ExpectMatch("workspace has been started")
|
||||
|
||||
_ = testutil.TryReceive(ctx, t, doneChan)
|
||||
}
|
||||
|
||||
@@ -113,6 +113,20 @@ func (r *RootCmd) supportBundle() *serpent.Command {
|
||||
)
|
||||
cliLog.Debug(inv.Context(), "invocation", slog.F("args", strings.Join(os.Args, " ")))
|
||||
|
||||
// Bypass rate limiting for support bundle collection since it makes many API calls.
|
||||
// Note: this can only be done by the owner user.
|
||||
if ok, err := support.CanGenerateFull(inv.Context(), client); err == nil && ok {
|
||||
cliLog.Debug(inv.Context(), "running as owner")
|
||||
client.HTTPClient.Transport = &codersdk.HeaderTransport{
|
||||
Transport: client.HTTPClient.Transport,
|
||||
Header: http.Header{codersdk.BypassRatelimitHeader: {"true"}},
|
||||
}
|
||||
} else if !ok {
|
||||
cliLog.Warn(inv.Context(), "not running as owner, not all information available")
|
||||
} else {
|
||||
cliLog.Error(inv.Context(), "failed to look up current user", slog.Error(err))
|
||||
}
|
||||
|
||||
// Check if we're running inside a workspace
|
||||
if val, found := os.LookupEnv("CODER"); found && val == "true" {
|
||||
cliui.Warn(inv.Stderr, "Running inside Coder workspace; this can affect results!")
|
||||
@@ -200,12 +214,6 @@ func (r *RootCmd) supportBundle() *serpent.Command {
|
||||
_, _ = fmt.Fprintln(inv.Stderr, "pprof data collection will take approximately 30 seconds...")
|
||||
}
|
||||
|
||||
// Bypass rate limiting for support bundle collection since it makes many API calls.
|
||||
client.HTTPClient.Transport = &codersdk.HeaderTransport{
|
||||
Transport: client.HTTPClient.Transport,
|
||||
Header: http.Header{codersdk.BypassRatelimitHeader: {"true"}},
|
||||
}
|
||||
|
||||
deps := support.Deps{
|
||||
Client: client,
|
||||
// Support adds a sink so we don't need to supply one ourselves.
|
||||
@@ -354,19 +362,20 @@ func summarizeBundle(inv *serpent.Invocation, bun *support.Bundle) {
|
||||
return
|
||||
}
|
||||
|
||||
if bun.Deployment.Config == nil {
|
||||
cliui.Error(inv.Stdout, "No deployment configuration available!")
|
||||
return
|
||||
var docsURL string
|
||||
if bun.Deployment.Config != nil {
|
||||
docsURL = bun.Deployment.Config.Values.DocsURL.String()
|
||||
} else {
|
||||
cliui.Warn(inv.Stdout, "No deployment configuration available. This may require the Owner role.")
|
||||
}
|
||||
|
||||
docsURL := bun.Deployment.Config.Values.DocsURL.String()
|
||||
if bun.Deployment.HealthReport == nil {
|
||||
cliui.Error(inv.Stdout, "No deployment health report available!")
|
||||
return
|
||||
}
|
||||
deployHealthSummary := bun.Deployment.HealthReport.Summarize(docsURL)
|
||||
if len(deployHealthSummary) > 0 {
|
||||
cliui.Warn(inv.Stdout, "Deployment health issues detected:", deployHealthSummary...)
|
||||
if bun.Deployment.HealthReport != nil {
|
||||
deployHealthSummary := bun.Deployment.HealthReport.Summarize(docsURL)
|
||||
if len(deployHealthSummary) > 0 {
|
||||
cliui.Warn(inv.Stdout, "Deployment health issues detected:", deployHealthSummary...)
|
||||
}
|
||||
} else {
|
||||
cliui.Warn(inv.Stdout, "No deployment health report available.")
|
||||
}
|
||||
|
||||
if bun.Network.Netcheck == nil {
|
||||
|
||||
@@ -28,7 +28,9 @@ import (
|
||||
"github.com/coder/coder/v2/coderd/database/dbauthz"
|
||||
"github.com/coder/coder/v2/coderd/database/dbfake"
|
||||
"github.com/coder/coder/v2/coderd/database/dbtime"
|
||||
"github.com/coder/coder/v2/coderd/healthcheck"
|
||||
"github.com/coder/coder/v2/coderd/healthcheck/derphealth"
|
||||
"github.com/coder/coder/v2/coderd/healthcheck/health"
|
||||
"github.com/coder/coder/v2/codersdk"
|
||||
"github.com/coder/coder/v2/codersdk/agentsdk"
|
||||
"github.com/coder/coder/v2/codersdk/healthsdk"
|
||||
@@ -50,9 +52,21 @@ func TestSupportBundle(t *testing.T) {
|
||||
dc.Values.Prometheus.Enable = true
|
||||
secretValue := uuid.NewString()
|
||||
seedSecretDeploymentOptions(t, &dc, secretValue)
|
||||
// Use a mock healthcheck function to avoid flaky DERP health
|
||||
// checks in CI. The DERP checker performs real network operations
|
||||
// (portmapper gateway probing, STUN) that can hang for 60s+ on
|
||||
// macOS CI runners. Since this test validates support bundle
|
||||
// generation, not healthcheck correctness, a canned report is
|
||||
// sufficient.
|
||||
client, closer, api := coderdtest.NewWithAPI(t, &coderdtest.Options{
|
||||
DeploymentValues: dc.Values,
|
||||
HealthcheckTimeout: testutil.WaitSuperLong,
|
||||
DeploymentValues: dc.Values,
|
||||
HealthcheckFunc: func(_ context.Context, _ string, _ *healthcheck.Progress) *healthsdk.HealthcheckReport {
|
||||
return &healthsdk.HealthcheckReport{
|
||||
Time: time.Now(),
|
||||
Healthy: true,
|
||||
Severity: health.SeverityOK,
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
t.Cleanup(func() { closer.Close() })
|
||||
@@ -60,7 +74,7 @@ func TestSupportBundle(t *testing.T) {
|
||||
memberClient, member := coderdtest.CreateAnotherUser(t, client, owner.OrganizationID)
|
||||
|
||||
// Set up test fixtures
|
||||
setupCtx := testutil.Context(t, testutil.WaitSuperLong)
|
||||
setupCtx := testutil.Context(t, testutil.WaitLong)
|
||||
workspaceWithAgent := setupSupportBundleTestFixture(setupCtx, t, api.Database, owner.OrganizationID, owner.UserID, func(agents []*proto.Agent) []*proto.Agent {
|
||||
// This should not show up in the bundle output
|
||||
agents[0].Env["SECRET_VALUE"] = secretValue
|
||||
@@ -69,22 +83,6 @@ func TestSupportBundle(t *testing.T) {
|
||||
workspaceWithoutAgent := setupSupportBundleTestFixture(setupCtx, t, api.Database, owner.OrganizationID, owner.UserID, nil)
|
||||
memberWorkspace := setupSupportBundleTestFixture(setupCtx, t, api.Database, owner.OrganizationID, member.ID, nil)
|
||||
|
||||
// Wait for healthcheck to complete successfully before continuing with sub-tests.
|
||||
// The result is cached so subsequent requests will be fast.
|
||||
healthcheckDone := make(chan *healthsdk.HealthcheckReport)
|
||||
go func() {
|
||||
defer close(healthcheckDone)
|
||||
hc, err := healthsdk.New(client).DebugHealth(setupCtx)
|
||||
if err != nil {
|
||||
assert.NoError(t, err, "seed healthcheck cache")
|
||||
return
|
||||
}
|
||||
healthcheckDone <- &hc
|
||||
}()
|
||||
if _, ok := testutil.AssertReceive(setupCtx, t, healthcheckDone); !ok {
|
||||
t.Fatal("healthcheck did not complete in time -- this may be a transient issue")
|
||||
}
|
||||
|
||||
t.Run("WorkspaceWithAgent", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
@@ -132,12 +130,35 @@ func TestSupportBundle(t *testing.T) {
|
||||
assertBundleContents(t, path, true, false, []string{secretValue})
|
||||
})
|
||||
|
||||
t.Run("NoPrivilege", func(t *testing.T) {
|
||||
t.Run("MemberCanGenerateBundle", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
inv, root := clitest.New(t, "support", "bundle", memberWorkspace.Workspace.Name, "--yes")
|
||||
|
||||
d := t.TempDir()
|
||||
path := filepath.Join(d, "bundle.zip")
|
||||
inv, root := clitest.New(t, "support", "bundle", memberWorkspace.Workspace.Name, "--output-file", path, "--yes")
|
||||
clitest.SetupConfig(t, memberClient, root)
|
||||
err := inv.Run()
|
||||
require.ErrorContains(t, err, "failed authorization check")
|
||||
require.NoError(t, err)
|
||||
r, err := zip.OpenReader(path)
|
||||
require.NoError(t, err, "open zip file")
|
||||
defer r.Close()
|
||||
fileNames := make(map[string]struct{}, len(r.File))
|
||||
for _, f := range r.File {
|
||||
fileNames[f.Name] = struct{}{}
|
||||
}
|
||||
// These should always be present in the zip structure, even if
|
||||
// the content is null/empty for non-admin users.
|
||||
for _, name := range []string{
|
||||
"deployment/buildinfo.json",
|
||||
"deployment/config.json",
|
||||
"workspace/workspace.json",
|
||||
"logs.txt",
|
||||
"cli_logs.txt",
|
||||
"network/netcheck.json",
|
||||
"network/interfaces.json",
|
||||
} {
|
||||
require.Contains(t, fileNames, name)
|
||||
}
|
||||
})
|
||||
|
||||
// This ensures that the CLI does not panic when trying to generate a support bundle
|
||||
@@ -159,6 +180,10 @@ func TestSupportBundle(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
t.Logf("received request: %s %s", r.Method, r.URL)
|
||||
switch r.URL.Path {
|
||||
case "/api/v2/users/me":
|
||||
resp := codersdk.User{}
|
||||
w.WriteHeader(http.StatusOK)
|
||||
assert.NoError(t, json.NewEncoder(w).Encode(resp))
|
||||
case "/api/v2/authcheck":
|
||||
// Fake auth check
|
||||
resp := codersdk.AuthorizationResponse{
|
||||
|
||||
4
cli/testdata/coder_create_--help.golden
vendored
4
cli/testdata/coder_create_--help.golden
vendored
@@ -20,6 +20,10 @@ OPTIONS:
|
||||
--copy-parameters-from string, $CODER_WORKSPACE_COPY_PARAMETERS_FROM
|
||||
Specify the source workspace name to copy parameters from.
|
||||
|
||||
--no-wait bool, $CODER_CREATE_NO_WAIT
|
||||
Return immediately after creating the workspace. The build will run in
|
||||
the background.
|
||||
|
||||
--parameter string-array, $CODER_RICH_PARAMETER
|
||||
Rich parameter value in the format "name=value".
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
"last_seen_at": "====[timestamp]=====",
|
||||
"name": "test-daemon",
|
||||
"version": "v0.0.0-devel",
|
||||
"api_version": "1.15",
|
||||
"api_version": "1.16",
|
||||
"provisioners": [
|
||||
"echo"
|
||||
],
|
||||
|
||||
6
cli/testdata/coder_server_--help.golden
vendored
6
cli/testdata/coder_server_--help.golden
vendored
@@ -170,6 +170,12 @@ AI BRIDGE OPTIONS:
|
||||
exporting these records to external SIEM or observability systems.
|
||||
|
||||
AI BRIDGE PROXY OPTIONS:
|
||||
--aibridge-proxy-allowed-private-cidrs string-array, $CODER_AIBRIDGE_PROXY_ALLOWED_PRIVATE_CIDRS
|
||||
Comma-separated list of CIDR ranges that are permitted even though
|
||||
they fall within blocked private/reserved IP ranges. By default all
|
||||
private ranges are blocked to prevent SSRF attacks. Use this to allow
|
||||
access to specific internal networks.
|
||||
|
||||
--aibridge-proxy-enabled bool, $CODER_AIBRIDGE_PROXY_ENABLED (default: false)
|
||||
Enable the AI Bridge MITM Proxy for intercepting and decrypting AI
|
||||
provider requests.
|
||||
|
||||
21
cli/testdata/coder_users_--help.golden
vendored
21
cli/testdata/coder_users_--help.golden
vendored
@@ -8,16 +8,17 @@ USAGE:
|
||||
Aliases: user
|
||||
|
||||
SUBCOMMANDS:
|
||||
activate Update a user's status to 'active'. Active users can fully
|
||||
interact with the platform
|
||||
create Create a new user.
|
||||
delete Delete a user by username or user_id.
|
||||
edit-roles Edit a user's roles by username or id
|
||||
list Prints the list of users.
|
||||
show Show a single user. Use 'me' to indicate the currently
|
||||
authenticated user.
|
||||
suspend Update a user's status to 'suspended'. A suspended user cannot
|
||||
log into the platform
|
||||
activate Update a user's status to 'active'. Active users can fully
|
||||
interact with the platform
|
||||
create Create a new user.
|
||||
delete Delete a user by username or user_id.
|
||||
edit-roles Edit a user's roles by username or id
|
||||
list Prints the list of users.
|
||||
oidc-claims Display the OIDC claims for the authenticated user.
|
||||
show Show a single user. Use 'me' to indicate the currently
|
||||
authenticated user.
|
||||
suspend Update a user's status to 'suspended'. A suspended user
|
||||
cannot log into the platform
|
||||
|
||||
———
|
||||
Run `coder --help` for a list of global options.
|
||||
|
||||
@@ -24,6 +24,10 @@ OPTIONS:
|
||||
-p, --password string
|
||||
Specifies a password for the new user.
|
||||
|
||||
--service-account bool
|
||||
Create a user account intended to be used by a service or as an
|
||||
intermediary rather than by a human.
|
||||
|
||||
-u, --username string
|
||||
Specifies a username for the new user.
|
||||
|
||||
|
||||
24
cli/testdata/coder_users_oidc-claims_--help.golden
vendored
Normal file
24
cli/testdata/coder_users_oidc-claims_--help.golden
vendored
Normal file
@@ -0,0 +1,24 @@
|
||||
coder v0.0.0-devel
|
||||
|
||||
USAGE:
|
||||
coder users oidc-claims [flags]
|
||||
|
||||
Display the OIDC claims for the authenticated user.
|
||||
|
||||
- Display your OIDC claims:
|
||||
|
||||
$ coder users oidc-claims
|
||||
|
||||
- Display your OIDC claims as JSON:
|
||||
|
||||
$ coder users oidc-claims -o json
|
||||
|
||||
OPTIONS:
|
||||
-c, --column [key|value] (default: key,value)
|
||||
Columns to display in table output.
|
||||
|
||||
-o, --output table|json (default: table)
|
||||
Output format.
|
||||
|
||||
———
|
||||
Run `coder --help` for a list of global options.
|
||||
11
cli/testdata/server-config.yaml.golden
vendored
11
cli/testdata/server-config.yaml.golden
vendored
@@ -752,6 +752,11 @@ workspace_prebuilds:
|
||||
# limit; disabled when set to zero.
|
||||
# (default: 3, type: int)
|
||||
failure_hard_limit: 3
|
||||
# Configure the background chat processing daemon.
|
||||
chat:
|
||||
# How many pending chats a worker should acquire per polling cycle.
|
||||
# (default: 10, type: int)
|
||||
acquireBatchSize: 10
|
||||
aibridge:
|
||||
# Whether to start an in-memory aibridged instance.
|
||||
# (default: false, type: bool)
|
||||
@@ -868,6 +873,12 @@ aibridgeproxy:
|
||||
# by the system. If not provided, the system certificate pool is used.
|
||||
# (default: <unset>, type: string)
|
||||
upstream_proxy_ca: ""
|
||||
# Comma-separated list of CIDR ranges that are permitted even though they fall
|
||||
# within blocked private/reserved IP ranges. By default all private ranges are
|
||||
# blocked to prevent SSRF attacks. Use this to allow access to specific internal
|
||||
# networks.
|
||||
# (default: <unset>, type: string-array)
|
||||
allowed_private_cidrs: []
|
||||
# Configure data retention policies for various database tables. Retention
|
||||
# policies automatically purge old data to reduce database size and improve
|
||||
# performance. Setting a retention duration to 0 disables automatic purging for
|
||||
|
||||
@@ -17,13 +17,14 @@ import (
|
||||
|
||||
func (r *RootCmd) userCreate() *serpent.Command {
|
||||
var (
|
||||
email string
|
||||
username string
|
||||
name string
|
||||
password string
|
||||
disableLogin bool
|
||||
loginType string
|
||||
orgContext = NewOrganizationContext()
|
||||
email string
|
||||
username string
|
||||
name string
|
||||
password string
|
||||
disableLogin bool
|
||||
loginType string
|
||||
serviceAccount bool
|
||||
orgContext = NewOrganizationContext()
|
||||
)
|
||||
cmd := &serpent.Command{
|
||||
Use: "create",
|
||||
@@ -32,6 +33,23 @@ func (r *RootCmd) userCreate() *serpent.Command {
|
||||
serpent.RequireNArgs(0),
|
||||
),
|
||||
Handler: func(inv *serpent.Invocation) error {
|
||||
if serviceAccount {
|
||||
switch {
|
||||
case loginType != "":
|
||||
return xerrors.New("You cannot use --login-type with --service-account")
|
||||
case password != "":
|
||||
return xerrors.New("You cannot use --password with --service-account")
|
||||
case email != "":
|
||||
return xerrors.New("You cannot use --email with --service-account")
|
||||
case disableLogin:
|
||||
return xerrors.New("You cannot use --disable-login with --service-account")
|
||||
}
|
||||
}
|
||||
|
||||
if disableLogin && loginType != "" {
|
||||
return xerrors.New("You cannot specify both --disable-login and --login-type")
|
||||
}
|
||||
|
||||
client, err := r.InitClient(inv)
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -59,7 +77,7 @@ func (r *RootCmd) userCreate() *serpent.Command {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if email == "" {
|
||||
if email == "" && !serviceAccount {
|
||||
email, err = cliui.Prompt(inv, cliui.PromptOptions{
|
||||
Text: "Email:",
|
||||
Validate: func(s string) error {
|
||||
@@ -87,10 +105,7 @@ func (r *RootCmd) userCreate() *serpent.Command {
|
||||
}
|
||||
}
|
||||
userLoginType := codersdk.LoginTypePassword
|
||||
if disableLogin && loginType != "" {
|
||||
return xerrors.New("You cannot specify both --disable-login and --login-type")
|
||||
}
|
||||
if disableLogin {
|
||||
if disableLogin || serviceAccount {
|
||||
userLoginType = codersdk.LoginTypeNone
|
||||
} else if loginType != "" {
|
||||
userLoginType = codersdk.LoginType(loginType)
|
||||
@@ -111,6 +126,7 @@ func (r *RootCmd) userCreate() *serpent.Command {
|
||||
Password: password,
|
||||
OrganizationIDs: []uuid.UUID{organization.ID},
|
||||
UserLoginType: userLoginType,
|
||||
ServiceAccount: serviceAccount,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -127,6 +143,10 @@ func (r *RootCmd) userCreate() *serpent.Command {
|
||||
case codersdk.LoginTypeOIDC:
|
||||
authenticationMethod = `Login is authenticated through the configured OIDC provider.`
|
||||
}
|
||||
if serviceAccount {
|
||||
email = "n/a"
|
||||
authenticationMethod = "Service accounts must authenticate with a token and cannot log in."
|
||||
}
|
||||
|
||||
_, _ = fmt.Fprintln(inv.Stderr, `A new user has been created!
|
||||
Share the instructions below to get them started.
|
||||
@@ -194,6 +214,11 @@ Create a workspace `+pretty.Sprint(cliui.DefaultStyles.Code, "coder create")+`!
|
||||
)),
|
||||
Value: serpent.StringOf(&loginType),
|
||||
},
|
||||
{
|
||||
Flag: "service-account",
|
||||
Description: "Create a user account intended to be used by a service or as an intermediary rather than by a human.",
|
||||
Value: serpent.BoolOf(&serviceAccount),
|
||||
},
|
||||
}
|
||||
|
||||
orgContext.AttachOptions(cmd)
|
||||
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
|
||||
"github.com/coder/coder/v2/cli/clitest"
|
||||
"github.com/coder/coder/v2/coderd/coderdtest"
|
||||
"github.com/coder/coder/v2/codersdk"
|
||||
"github.com/coder/coder/v2/pty/ptytest"
|
||||
"github.com/coder/coder/v2/testutil"
|
||||
)
|
||||
@@ -124,4 +125,56 @@ func TestUserCreate(t *testing.T) {
|
||||
assert.Equal(t, args[5], created.Username)
|
||||
assert.Empty(t, created.Name)
|
||||
})
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
args []string
|
||||
err string
|
||||
}{
|
||||
{
|
||||
name: "ServiceAccount",
|
||||
args: []string{"--service-account", "-u", "dean"},
|
||||
},
|
||||
{
|
||||
name: "ServiceAccountLoginType",
|
||||
args: []string{"--service-account", "-u", "dean", "--login-type", "none"},
|
||||
err: "You cannot use --login-type with --service-account",
|
||||
},
|
||||
{
|
||||
name: "ServiceAccountDisableLogin",
|
||||
args: []string{"--service-account", "-u", "dean", "--disable-login"},
|
||||
err: "You cannot use --disable-login with --service-account",
|
||||
},
|
||||
{
|
||||
name: "ServiceAccountEmail",
|
||||
args: []string{"--service-account", "-u", "dean", "--email", "dean@coder.com"},
|
||||
err: "You cannot use --email with --service-account",
|
||||
},
|
||||
{
|
||||
name: "ServiceAccountPassword",
|
||||
args: []string{"--service-account", "-u", "dean", "--password", "1n5ecureP4ssw0rd!"},
|
||||
err: "You cannot use --password with --service-account",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
client := coderdtest.New(t, nil)
|
||||
coderdtest.CreateFirstUser(t, client)
|
||||
inv, root := clitest.New(t, append([]string{"users", "create"}, tt.args...)...)
|
||||
clitest.SetupConfig(t, client, root)
|
||||
err := inv.Run()
|
||||
if tt.err == "" {
|
||||
require.NoError(t, err)
|
||||
ctx := testutil.Context(t, testutil.WaitShort)
|
||||
created, err := client.User(ctx, "dean")
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, codersdk.LoginTypeNone, created.LoginType)
|
||||
} else {
|
||||
require.Error(t, err)
|
||||
require.ErrorContains(t, err, tt.err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
79
cli/useroidcclaims.go
Normal file
79
cli/useroidcclaims.go
Normal file
@@ -0,0 +1,79 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"golang.org/x/xerrors"
|
||||
|
||||
"github.com/coder/coder/v2/cli/cliui"
|
||||
"github.com/coder/coder/v2/codersdk"
|
||||
"github.com/coder/serpent"
|
||||
)
|
||||
|
||||
func (r *RootCmd) userOIDCClaims() *serpent.Command {
|
||||
formatter := cliui.NewOutputFormatter(
|
||||
cliui.ChangeFormatterData(
|
||||
cliui.TableFormat([]claimRow{}, []string{"key", "value"}),
|
||||
func(data any) (any, error) {
|
||||
resp, ok := data.(codersdk.OIDCClaimsResponse)
|
||||
if !ok {
|
||||
return nil, xerrors.Errorf("expected type %T, got %T", resp, data)
|
||||
}
|
||||
rows := make([]claimRow, 0, len(resp.Claims))
|
||||
for k, v := range resp.Claims {
|
||||
rows = append(rows, claimRow{
|
||||
Key: k,
|
||||
Value: fmt.Sprintf("%v", v),
|
||||
})
|
||||
}
|
||||
return rows, nil
|
||||
},
|
||||
),
|
||||
cliui.JSONFormat(),
|
||||
)
|
||||
|
||||
cmd := &serpent.Command{
|
||||
Use: "oidc-claims",
|
||||
Short: "Display the OIDC claims for the authenticated user.",
|
||||
Long: FormatExamples(
|
||||
Example{
|
||||
Description: "Display your OIDC claims",
|
||||
Command: "coder users oidc-claims",
|
||||
},
|
||||
Example{
|
||||
Description: "Display your OIDC claims as JSON",
|
||||
Command: "coder users oidc-claims -o json",
|
||||
},
|
||||
),
|
||||
Middleware: serpent.Chain(
|
||||
serpent.RequireNArgs(0),
|
||||
),
|
||||
Handler: func(inv *serpent.Invocation) error {
|
||||
client, err := r.InitClient(inv)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
resp, err := client.UserOIDCClaims(inv.Context())
|
||||
if err != nil {
|
||||
return xerrors.Errorf("get oidc claims: %w", err)
|
||||
}
|
||||
|
||||
out, err := formatter.Format(inv.Context(), resp)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
_, err = fmt.Fprintln(inv.Stdout, out)
|
||||
return err
|
||||
},
|
||||
}
|
||||
|
||||
formatter.AttachOptions(&cmd.Options)
|
||||
return cmd
|
||||
}
|
||||
|
||||
type claimRow struct {
|
||||
Key string `json:"-" table:"key,default_sort"`
|
||||
Value string `json:"-" table:"value"`
|
||||
}
|
||||
161
cli/useroidcclaims_test.go
Normal file
161
cli/useroidcclaims_test.go
Normal file
@@ -0,0 +1,161 @@
|
||||
package cli_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"testing"
|
||||
|
||||
"github.com/golang-jwt/jwt/v4"
|
||||
"github.com/google/uuid"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/coder/coder/v2/cli/clitest"
|
||||
"github.com/coder/coder/v2/coderd"
|
||||
"github.com/coder/coder/v2/coderd/coderdtest"
|
||||
"github.com/coder/coder/v2/coderd/coderdtest/oidctest"
|
||||
"github.com/coder/coder/v2/codersdk"
|
||||
"github.com/coder/coder/v2/testutil"
|
||||
)
|
||||
|
||||
func TestUserOIDCClaims(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
newOIDCTest := func(t *testing.T) (*oidctest.FakeIDP, *codersdk.Client) {
|
||||
t.Helper()
|
||||
|
||||
fake := oidctest.NewFakeIDP(t,
|
||||
oidctest.WithServing(),
|
||||
)
|
||||
cfg := fake.OIDCConfig(t, nil, func(cfg *coderd.OIDCConfig) {
|
||||
cfg.AllowSignups = true
|
||||
})
|
||||
ownerClient := coderdtest.New(t, &coderdtest.Options{
|
||||
OIDCConfig: cfg,
|
||||
})
|
||||
return fake, ownerClient
|
||||
}
|
||||
|
||||
t.Run("OwnClaims", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
fake, ownerClient := newOIDCTest(t)
|
||||
claims := jwt.MapClaims{
|
||||
"email": "alice@coder.com",
|
||||
"email_verified": true,
|
||||
"sub": uuid.NewString(),
|
||||
"groups": []string{"admin", "eng"},
|
||||
}
|
||||
userClient, loginResp := fake.Login(t, ownerClient, claims)
|
||||
defer loginResp.Body.Close()
|
||||
|
||||
inv, root := clitest.New(t, "users", "oidc-claims", "-o", "json")
|
||||
clitest.SetupConfig(t, userClient, root)
|
||||
|
||||
buf := bytes.NewBuffer(nil)
|
||||
inv.Stdout = buf
|
||||
err := inv.WithContext(testutil.Context(t, testutil.WaitMedium)).Run()
|
||||
require.NoError(t, err)
|
||||
|
||||
var resp codersdk.OIDCClaimsResponse
|
||||
err = json.Unmarshal(buf.Bytes(), &resp)
|
||||
require.NoError(t, err, "unmarshal JSON output")
|
||||
require.NotEmpty(t, resp.Claims, "claims should not be empty")
|
||||
assert.Equal(t, "alice@coder.com", resp.Claims["email"])
|
||||
})
|
||||
|
||||
t.Run("Table", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
fake, ownerClient := newOIDCTest(t)
|
||||
claims := jwt.MapClaims{
|
||||
"email": "bob@coder.com",
|
||||
"email_verified": true,
|
||||
"sub": uuid.NewString(),
|
||||
}
|
||||
userClient, loginResp := fake.Login(t, ownerClient, claims)
|
||||
defer loginResp.Body.Close()
|
||||
|
||||
inv, root := clitest.New(t, "users", "oidc-claims")
|
||||
clitest.SetupConfig(t, userClient, root)
|
||||
|
||||
buf := bytes.NewBuffer(nil)
|
||||
inv.Stdout = buf
|
||||
err := inv.WithContext(testutil.Context(t, testutil.WaitMedium)).Run()
|
||||
require.NoError(t, err)
|
||||
|
||||
output := buf.String()
|
||||
require.Contains(t, output, "email")
|
||||
require.Contains(t, output, "bob@coder.com")
|
||||
})
|
||||
|
||||
t.Run("NotOIDCUser", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
client := coderdtest.New(t, nil)
|
||||
_ = coderdtest.CreateFirstUser(t, client)
|
||||
|
||||
inv, root := clitest.New(t, "users", "oidc-claims")
|
||||
clitest.SetupConfig(t, client, root)
|
||||
|
||||
err := inv.WithContext(testutil.Context(t, testutil.WaitMedium)).Run()
|
||||
require.Error(t, err)
|
||||
require.Contains(t, err.Error(), "not an OIDC user")
|
||||
})
|
||||
|
||||
// Verify that two different OIDC users each only see their own
|
||||
// claims. The endpoint has no user parameter, so there is no way
|
||||
// to request another user's claims by design.
|
||||
t.Run("OnlyOwnClaims", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
aliceFake, aliceOwnerClient := newOIDCTest(t)
|
||||
aliceClaims := jwt.MapClaims{
|
||||
"email": "alice-isolation@coder.com",
|
||||
"email_verified": true,
|
||||
"sub": uuid.NewString(),
|
||||
}
|
||||
aliceClient, aliceLoginResp := aliceFake.Login(t, aliceOwnerClient, aliceClaims)
|
||||
defer aliceLoginResp.Body.Close()
|
||||
|
||||
bobFake, bobOwnerClient := newOIDCTest(t)
|
||||
bobClaims := jwt.MapClaims{
|
||||
"email": "bob-isolation@coder.com",
|
||||
"email_verified": true,
|
||||
"sub": uuid.NewString(),
|
||||
}
|
||||
bobClient, bobLoginResp := bobFake.Login(t, bobOwnerClient, bobClaims)
|
||||
defer bobLoginResp.Body.Close()
|
||||
|
||||
ctx := testutil.Context(t, testutil.WaitMedium)
|
||||
|
||||
// Alice sees her own claims.
|
||||
aliceResp, err := aliceClient.UserOIDCClaims(ctx)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "alice-isolation@coder.com", aliceResp.Claims["email"])
|
||||
|
||||
// Bob sees his own claims.
|
||||
bobResp, err := bobClient.UserOIDCClaims(ctx)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "bob-isolation@coder.com", bobResp.Claims["email"])
|
||||
})
|
||||
|
||||
t.Run("ClaimsNeverNull", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
fake, ownerClient := newOIDCTest(t)
|
||||
// Use minimal claims — just enough for OIDC login.
|
||||
claims := jwt.MapClaims{
|
||||
"email": "minimal@coder.com",
|
||||
"email_verified": true,
|
||||
"sub": uuid.NewString(),
|
||||
}
|
||||
userClient, loginResp := fake.Login(t, ownerClient, claims)
|
||||
defer loginResp.Body.Close()
|
||||
|
||||
ctx := testutil.Context(t, testutil.WaitMedium)
|
||||
resp, err := userClient.UserOIDCClaims(ctx)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, resp.Claims, "claims should never be nil, expected empty map")
|
||||
})
|
||||
}
|
||||
@@ -19,6 +19,7 @@ func (r *RootCmd) users() *serpent.Command {
|
||||
r.userSingle(),
|
||||
r.userDelete(),
|
||||
r.userEditRoles(),
|
||||
r.userOIDCClaims(),
|
||||
r.createUserStatusCommand(codersdk.UserStatusActive),
|
||||
r.createUserStatusCommand(codersdk.UserStatusSuspended),
|
||||
},
|
||||
|
||||
38
coderd/aiseats/aiseats.go
Normal file
38
coderd/aiseats/aiseats.go
Normal file
@@ -0,0 +1,38 @@
|
||||
// Package aiseats is the AGPL version the package.
|
||||
// The actual implementation is in `enterprise/aiseats`.
|
||||
package aiseats
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/google/uuid"
|
||||
|
||||
"github.com/coder/coder/v2/coderd/database"
|
||||
)
|
||||
|
||||
type Reason struct {
|
||||
EventType database.AiSeatUsageReason
|
||||
Description string
|
||||
}
|
||||
|
||||
// ReasonAIBridge constructs a reason for usage originating from AI Bridge.
|
||||
func ReasonAIBridge(description string) Reason {
|
||||
return Reason{EventType: database.AiSeatUsageReasonAibridge, Description: description}
|
||||
}
|
||||
|
||||
// ReasonTask constructs a reason for usage originating from tasks.
|
||||
func ReasonTask(description string) Reason {
|
||||
return Reason{EventType: database.AiSeatUsageReasonTask, Description: description}
|
||||
}
|
||||
|
||||
// SeatTracker records AI seat consumption state.
|
||||
type SeatTracker interface {
|
||||
// RecordUsage does not return an error to prevent blocking the user from using
|
||||
// AI features. This method is used to record usage, not enforce it.
|
||||
RecordUsage(ctx context.Context, userID uuid.UUID, reason Reason)
|
||||
}
|
||||
|
||||
// Noop is an AGPL seat tracker that does nothing.
|
||||
type Noop struct{}
|
||||
|
||||
func (Noop) RecordUsage(context.Context, uuid.UUID, Reason) {}
|
||||
3747
coderd/apidoc/docs.go
generated
3747
coderd/apidoc/docs.go
generated
File diff suppressed because it is too large
Load Diff
3723
coderd/apidoc/swagger.json
generated
3723
coderd/apidoc/swagger.json
generated
File diff suppressed because it is too large
Load Diff
@@ -32,7 +32,8 @@ type Auditable interface {
|
||||
idpsync.OrganizationSyncSettings |
|
||||
idpsync.GroupSyncSettings |
|
||||
idpsync.RoleSyncSettings |
|
||||
database.TaskTable
|
||||
database.TaskTable |
|
||||
database.AiSeatState
|
||||
}
|
||||
|
||||
// Map is a map of changed fields in an audited resource. It maps field names to
|
||||
|
||||
@@ -132,6 +132,8 @@ func ResourceTarget[T Auditable](tgt T) string {
|
||||
return "Organization Role Sync"
|
||||
case database.TaskTable:
|
||||
return typed.Name
|
||||
case database.AiSeatState:
|
||||
return "AI Seat"
|
||||
default:
|
||||
panic(fmt.Sprintf("unknown resource %T for ResourceTarget", tgt))
|
||||
}
|
||||
@@ -196,6 +198,8 @@ func ResourceID[T Auditable](tgt T) uuid.UUID {
|
||||
return noID // Org field on audit log has org id
|
||||
case database.TaskTable:
|
||||
return typed.ID
|
||||
case database.AiSeatState:
|
||||
return typed.UserID
|
||||
default:
|
||||
panic(fmt.Sprintf("unknown resource %T for ResourceID", tgt))
|
||||
}
|
||||
@@ -251,6 +255,8 @@ func ResourceType[T Auditable](tgt T) database.ResourceType {
|
||||
return database.ResourceTypeIdpSyncSettingsGroup
|
||||
case database.TaskTable:
|
||||
return database.ResourceTypeTask
|
||||
case database.AiSeatState:
|
||||
return database.ResourceTypeAiSeat
|
||||
default:
|
||||
panic(fmt.Sprintf("unknown resource %T for ResourceType", typed))
|
||||
}
|
||||
@@ -309,6 +315,8 @@ func ResourceRequiresOrgID[T Auditable]() bool {
|
||||
return true
|
||||
case database.TaskTable:
|
||||
return true
|
||||
case database.AiSeatState:
|
||||
return false
|
||||
default:
|
||||
panic(fmt.Sprintf("unknown resource %T for ResourceRequiresOrgID", tgt))
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -2,13 +2,26 @@ package chatd
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/stretchr/testify/require"
|
||||
"go.uber.org/mock/gomock"
|
||||
"golang.org/x/xerrors"
|
||||
|
||||
"cdr.dev/slog/v3"
|
||||
"cdr.dev/slog/v3/sloggers/slogtest"
|
||||
"github.com/coder/coder/v2/coderd/database"
|
||||
"github.com/coder/coder/v2/coderd/database/dbmock"
|
||||
dbpubsub "github.com/coder/coder/v2/coderd/database/pubsub"
|
||||
coderdpubsub "github.com/coder/coder/v2/coderd/pubsub"
|
||||
"github.com/coder/coder/v2/codersdk"
|
||||
"github.com/coder/coder/v2/codersdk/workspacesdk"
|
||||
"github.com/coder/coder/v2/codersdk/workspacesdk/agentconnmock"
|
||||
"github.com/coder/coder/v2/testutil"
|
||||
"github.com/coder/quartz"
|
||||
)
|
||||
|
||||
func TestRefreshChatWorkspaceSnapshot_NoReloadWhenWorkspacePresent(t *testing.T) {
|
||||
@@ -84,3 +97,524 @@ func TestRefreshChatWorkspaceSnapshot_ReturnsReloadError(t *testing.T) {
|
||||
require.ErrorContains(t, err, loadErr.Error())
|
||||
require.Equal(t, chat, refreshed)
|
||||
}
|
||||
|
||||
func TestResolveInstructionsReusesTurnLocalWorkspaceAgent(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
ctx := context.Background()
|
||||
ctrl := gomock.NewController(t)
|
||||
db := dbmock.NewMockStore(ctrl)
|
||||
|
||||
workspaceID := uuid.New()
|
||||
chat := database.Chat{
|
||||
ID: uuid.New(),
|
||||
WorkspaceID: uuid.NullUUID{
|
||||
UUID: workspaceID,
|
||||
Valid: true,
|
||||
},
|
||||
}
|
||||
workspaceAgent := database.WorkspaceAgent{
|
||||
ID: uuid.New(),
|
||||
OperatingSystem: "linux",
|
||||
Directory: "/home/coder/project",
|
||||
ExpandedDirectory: "/home/coder/project",
|
||||
}
|
||||
|
||||
db.EXPECT().GetWorkspaceAgentsInLatestBuildByWorkspaceID(
|
||||
gomock.Any(),
|
||||
workspaceID,
|
||||
).Return([]database.WorkspaceAgent{workspaceAgent}, nil).Times(1)
|
||||
|
||||
conn := agentconnmock.NewMockAgentConn(ctrl)
|
||||
conn.EXPECT().SetExtraHeaders(gomock.Any()).Times(1)
|
||||
conn.EXPECT().LS(gomock.Any(), "", gomock.Any()).Return(
|
||||
workspacesdk.LSResponse{},
|
||||
codersdk.NewTestError(404, "POST", "/api/v0/list-directory"),
|
||||
).Times(1)
|
||||
conn.EXPECT().ReadFile(
|
||||
gomock.Any(),
|
||||
"/home/coder/project/AGENTS.md",
|
||||
int64(0),
|
||||
int64(maxInstructionFileBytes+1),
|
||||
).Return(
|
||||
nil,
|
||||
"",
|
||||
codersdk.NewTestError(404, "GET", "/api/v0/read-file"),
|
||||
).Times(1)
|
||||
|
||||
logger := slogtest.Make(t, &slogtest.Options{IgnoreErrors: true})
|
||||
server := &Server{
|
||||
db: db,
|
||||
logger: logger,
|
||||
instructionCache: make(map[uuid.UUID]cachedInstruction),
|
||||
agentConnFn: func(context.Context, uuid.UUID) (workspacesdk.AgentConn, func(), error) {
|
||||
return conn, func() {}, nil
|
||||
},
|
||||
}
|
||||
|
||||
chatStateMu := &sync.Mutex{}
|
||||
currentChat := chat
|
||||
workspaceCtx := turnWorkspaceContext{
|
||||
server: server,
|
||||
chatStateMu: chatStateMu,
|
||||
currentChat: ¤tChat,
|
||||
loadChatSnapshot: func(context.Context, uuid.UUID) (database.Chat, error) { return database.Chat{}, nil },
|
||||
}
|
||||
t.Cleanup(workspaceCtx.close)
|
||||
|
||||
instruction := server.resolveInstructions(
|
||||
ctx,
|
||||
chat,
|
||||
workspaceCtx.getWorkspaceAgent,
|
||||
workspaceCtx.getWorkspaceConn,
|
||||
)
|
||||
require.Contains(t, instruction, "Operating System: linux")
|
||||
require.Contains(t, instruction, "Working Directory: /home/coder/project")
|
||||
}
|
||||
|
||||
func TestTurnWorkspaceContextGetWorkspaceConnRefreshesWorkspaceAgent(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
ctx := context.Background()
|
||||
ctrl := gomock.NewController(t)
|
||||
db := dbmock.NewMockStore(ctrl)
|
||||
|
||||
workspaceID := uuid.New()
|
||||
chat := database.Chat{
|
||||
ID: uuid.New(),
|
||||
WorkspaceID: uuid.NullUUID{
|
||||
UUID: workspaceID,
|
||||
Valid: true,
|
||||
},
|
||||
}
|
||||
initialAgent := database.WorkspaceAgent{ID: uuid.New()}
|
||||
refreshedAgent := database.WorkspaceAgent{ID: uuid.New()}
|
||||
|
||||
gomock.InOrder(
|
||||
db.EXPECT().GetWorkspaceAgentsInLatestBuildByWorkspaceID(
|
||||
gomock.Any(),
|
||||
workspaceID,
|
||||
).Return([]database.WorkspaceAgent{initialAgent}, nil),
|
||||
db.EXPECT().GetWorkspaceAgentsInLatestBuildByWorkspaceID(
|
||||
gomock.Any(),
|
||||
workspaceID,
|
||||
).Return([]database.WorkspaceAgent{refreshedAgent}, nil),
|
||||
)
|
||||
|
||||
conn := agentconnmock.NewMockAgentConn(ctrl)
|
||||
conn.EXPECT().SetExtraHeaders(gomock.Any()).Times(1)
|
||||
|
||||
var dialed []uuid.UUID
|
||||
server := &Server{db: db}
|
||||
server.agentConnFn = func(_ context.Context, agentID uuid.UUID) (workspacesdk.AgentConn, func(), error) {
|
||||
dialed = append(dialed, agentID)
|
||||
if agentID == initialAgent.ID {
|
||||
return nil, nil, xerrors.New("dial failed")
|
||||
}
|
||||
return conn, func() {}, nil
|
||||
}
|
||||
|
||||
chatStateMu := &sync.Mutex{}
|
||||
currentChat := chat
|
||||
workspaceCtx := turnWorkspaceContext{
|
||||
server: server,
|
||||
chatStateMu: chatStateMu,
|
||||
currentChat: ¤tChat,
|
||||
loadChatSnapshot: func(context.Context, uuid.UUID) (database.Chat, error) { return database.Chat{}, nil },
|
||||
}
|
||||
t.Cleanup(workspaceCtx.close)
|
||||
|
||||
gotConn, err := workspaceCtx.getWorkspaceConn(ctx)
|
||||
require.NoError(t, err)
|
||||
require.Same(t, conn, gotConn)
|
||||
require.Equal(t, []uuid.UUID{initialAgent.ID, refreshedAgent.ID}, dialed)
|
||||
}
|
||||
|
||||
func TestSubscribeSkipsDatabaseCatchupForLocallyDeliveredMessage(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
ctx, cancelCtx := context.WithCancel(context.Background())
|
||||
defer cancelCtx()
|
||||
|
||||
ctrl := gomock.NewController(t)
|
||||
db := dbmock.NewMockStore(ctrl)
|
||||
|
||||
chatID := uuid.New()
|
||||
chat := database.Chat{ID: chatID, Status: database.ChatStatusPending}
|
||||
initialMessage := database.ChatMessage{
|
||||
ID: 1,
|
||||
ChatID: chatID,
|
||||
Role: database.ChatMessageRoleUser,
|
||||
}
|
||||
localMessage := database.ChatMessage{
|
||||
ID: 2,
|
||||
ChatID: chatID,
|
||||
Role: database.ChatMessageRoleAssistant,
|
||||
}
|
||||
|
||||
gomock.InOrder(
|
||||
db.EXPECT().GetChatMessagesByChatID(gomock.Any(), database.GetChatMessagesByChatIDParams{
|
||||
ChatID: chatID,
|
||||
AfterID: 0,
|
||||
}).Return([]database.ChatMessage{initialMessage}, nil),
|
||||
db.EXPECT().GetChatQueuedMessages(gomock.Any(), chatID).Return(nil, nil),
|
||||
db.EXPECT().GetChatByID(gomock.Any(), chatID).Return(chat, nil),
|
||||
)
|
||||
|
||||
server := newSubscribeTestServer(t, db)
|
||||
_, events, cancel, ok := server.Subscribe(ctx, chatID, nil, 0)
|
||||
require.True(t, ok)
|
||||
defer cancel()
|
||||
|
||||
server.publishMessage(chatID, localMessage)
|
||||
|
||||
event := requireStreamMessageEvent(t, events)
|
||||
require.Equal(t, int64(2), event.Message.ID)
|
||||
requireNoStreamEvent(t, events, 200*time.Millisecond)
|
||||
}
|
||||
|
||||
func TestSubscribeUsesDurableCacheWhenLocalMessageWasNotDelivered(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
ctx, cancelCtx := context.WithCancel(context.Background())
|
||||
defer cancelCtx()
|
||||
|
||||
ctrl := gomock.NewController(t)
|
||||
db := dbmock.NewMockStore(ctrl)
|
||||
|
||||
chatID := uuid.New()
|
||||
chat := database.Chat{ID: chatID, Status: database.ChatStatusPending}
|
||||
initialMessage := database.ChatMessage{
|
||||
ID: 1,
|
||||
ChatID: chatID,
|
||||
Role: database.ChatMessageRoleUser,
|
||||
}
|
||||
cachedMessage := codersdk.ChatMessage{
|
||||
ID: 2,
|
||||
ChatID: chatID,
|
||||
Role: codersdk.ChatMessageRoleAssistant,
|
||||
}
|
||||
|
||||
gomock.InOrder(
|
||||
db.EXPECT().GetChatMessagesByChatID(gomock.Any(), database.GetChatMessagesByChatIDParams{
|
||||
ChatID: chatID,
|
||||
AfterID: 0,
|
||||
}).Return([]database.ChatMessage{initialMessage}, nil),
|
||||
db.EXPECT().GetChatQueuedMessages(gomock.Any(), chatID).Return(nil, nil),
|
||||
db.EXPECT().GetChatByID(gomock.Any(), chatID).Return(chat, nil),
|
||||
)
|
||||
|
||||
server := newSubscribeTestServer(t, db)
|
||||
server.cacheDurableMessage(chatID, codersdk.ChatStreamEvent{
|
||||
Type: codersdk.ChatStreamEventTypeMessage,
|
||||
ChatID: chatID,
|
||||
Message: &cachedMessage,
|
||||
})
|
||||
|
||||
_, events, cancel, ok := server.Subscribe(ctx, chatID, nil, 0)
|
||||
require.True(t, ok)
|
||||
defer cancel()
|
||||
|
||||
server.publishChatStreamNotify(chatID, coderdpubsub.ChatStreamNotifyMessage{
|
||||
AfterMessageID: 1,
|
||||
})
|
||||
|
||||
event := requireStreamMessageEvent(t, events)
|
||||
require.Equal(t, int64(2), event.Message.ID)
|
||||
requireNoStreamEvent(t, events, 200*time.Millisecond)
|
||||
}
|
||||
|
||||
func TestSubscribeQueriesDatabaseWhenDurableCacheMisses(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
ctx, cancelCtx := context.WithCancel(context.Background())
|
||||
defer cancelCtx()
|
||||
|
||||
ctrl := gomock.NewController(t)
|
||||
db := dbmock.NewMockStore(ctrl)
|
||||
|
||||
chatID := uuid.New()
|
||||
chat := database.Chat{ID: chatID, Status: database.ChatStatusPending}
|
||||
initialMessage := database.ChatMessage{
|
||||
ID: 1,
|
||||
ChatID: chatID,
|
||||
Role: database.ChatMessageRoleUser,
|
||||
}
|
||||
catchupMessage := database.ChatMessage{
|
||||
ID: 2,
|
||||
ChatID: chatID,
|
||||
Role: database.ChatMessageRoleAssistant,
|
||||
}
|
||||
|
||||
gomock.InOrder(
|
||||
db.EXPECT().GetChatMessagesByChatID(gomock.Any(), database.GetChatMessagesByChatIDParams{
|
||||
ChatID: chatID,
|
||||
AfterID: 0,
|
||||
}).Return([]database.ChatMessage{initialMessage}, nil),
|
||||
db.EXPECT().GetChatQueuedMessages(gomock.Any(), chatID).Return(nil, nil),
|
||||
db.EXPECT().GetChatByID(gomock.Any(), chatID).Return(chat, nil),
|
||||
db.EXPECT().GetChatMessagesByChatID(gomock.Any(), database.GetChatMessagesByChatIDParams{
|
||||
ChatID: chatID,
|
||||
AfterID: 1,
|
||||
}).Return([]database.ChatMessage{catchupMessage}, nil),
|
||||
)
|
||||
|
||||
server := newSubscribeTestServer(t, db)
|
||||
_, events, cancel, ok := server.Subscribe(ctx, chatID, nil, 0)
|
||||
require.True(t, ok)
|
||||
defer cancel()
|
||||
|
||||
server.publishChatStreamNotify(chatID, coderdpubsub.ChatStreamNotifyMessage{
|
||||
AfterMessageID: 1,
|
||||
})
|
||||
|
||||
event := requireStreamMessageEvent(t, events)
|
||||
require.Equal(t, int64(2), event.Message.ID)
|
||||
requireNoStreamEvent(t, events, 200*time.Millisecond)
|
||||
}
|
||||
|
||||
func TestSubscribeFullRefreshStillUsesDatabaseCatchup(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
ctx, cancelCtx := context.WithCancel(context.Background())
|
||||
defer cancelCtx()
|
||||
|
||||
ctrl := gomock.NewController(t)
|
||||
db := dbmock.NewMockStore(ctrl)
|
||||
|
||||
chatID := uuid.New()
|
||||
chat := database.Chat{ID: chatID, Status: database.ChatStatusPending}
|
||||
initialMessage := database.ChatMessage{
|
||||
ID: 1,
|
||||
ChatID: chatID,
|
||||
Role: database.ChatMessageRoleUser,
|
||||
}
|
||||
editedMessage := database.ChatMessage{
|
||||
ID: 1,
|
||||
ChatID: chatID,
|
||||
Role: database.ChatMessageRoleUser,
|
||||
}
|
||||
|
||||
gomock.InOrder(
|
||||
db.EXPECT().GetChatMessagesByChatID(gomock.Any(), database.GetChatMessagesByChatIDParams{
|
||||
ChatID: chatID,
|
||||
AfterID: 0,
|
||||
}).Return([]database.ChatMessage{initialMessage}, nil),
|
||||
db.EXPECT().GetChatQueuedMessages(gomock.Any(), chatID).Return(nil, nil),
|
||||
db.EXPECT().GetChatByID(gomock.Any(), chatID).Return(chat, nil),
|
||||
db.EXPECT().GetChatMessagesByChatID(gomock.Any(), database.GetChatMessagesByChatIDParams{
|
||||
ChatID: chatID,
|
||||
AfterID: 0,
|
||||
}).Return([]database.ChatMessage{editedMessage}, nil),
|
||||
)
|
||||
|
||||
server := newSubscribeTestServer(t, db)
|
||||
_, events, cancel, ok := server.Subscribe(ctx, chatID, nil, 0)
|
||||
require.True(t, ok)
|
||||
defer cancel()
|
||||
|
||||
server.publishEditedMessage(chatID, editedMessage)
|
||||
|
||||
event := requireStreamMessageEvent(t, events)
|
||||
require.Equal(t, int64(1), event.Message.ID)
|
||||
requireNoStreamEvent(t, events, 200*time.Millisecond)
|
||||
}
|
||||
|
||||
func TestSubscribeDeliversRetryEventViaPubsubOnce(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
ctx, cancelCtx := context.WithCancel(context.Background())
|
||||
defer cancelCtx()
|
||||
|
||||
ctrl := gomock.NewController(t)
|
||||
db := dbmock.NewMockStore(ctrl)
|
||||
|
||||
chatID := uuid.New()
|
||||
chat := database.Chat{ID: chatID, Status: database.ChatStatusPending}
|
||||
|
||||
gomock.InOrder(
|
||||
db.EXPECT().GetChatMessagesByChatID(gomock.Any(), database.GetChatMessagesByChatIDParams{
|
||||
ChatID: chatID,
|
||||
AfterID: 0,
|
||||
}).Return(nil, nil),
|
||||
db.EXPECT().GetChatQueuedMessages(gomock.Any(), chatID).Return(nil, nil),
|
||||
db.EXPECT().GetChatByID(gomock.Any(), chatID).Return(chat, nil),
|
||||
)
|
||||
|
||||
server := newSubscribeTestServer(t, db)
|
||||
_, events, cancel, ok := server.Subscribe(ctx, chatID, nil, 0)
|
||||
require.True(t, ok)
|
||||
defer cancel()
|
||||
|
||||
retryingAt := time.Unix(1_700_000_000, 0).UTC()
|
||||
expected := &codersdk.ChatStreamRetry{
|
||||
Attempt: 1,
|
||||
DelayMs: (1500 * time.Millisecond).Milliseconds(),
|
||||
Error: "rate limit exceeded",
|
||||
RetryingAt: retryingAt,
|
||||
}
|
||||
|
||||
server.publishRetry(chatID, expected)
|
||||
|
||||
event := requireStreamRetryEvent(t, events)
|
||||
require.Equal(t, expected, event.Retry)
|
||||
requireNoStreamEvent(t, events, 200*time.Millisecond)
|
||||
}
|
||||
|
||||
func newSubscribeTestServer(t *testing.T, db database.Store) *Server {
|
||||
t.Helper()
|
||||
|
||||
return &Server{
|
||||
db: db,
|
||||
logger: slogtest.Make(t, &slogtest.Options{IgnoreErrors: true}),
|
||||
pubsub: dbpubsub.NewInMemory(),
|
||||
}
|
||||
}
|
||||
|
||||
func requireStreamMessageEvent(t *testing.T, events <-chan codersdk.ChatStreamEvent) codersdk.ChatStreamEvent {
|
||||
t.Helper()
|
||||
|
||||
select {
|
||||
case event, ok := <-events:
|
||||
require.True(t, ok, "chat stream closed before delivering an event")
|
||||
require.Equal(t, codersdk.ChatStreamEventTypeMessage, event.Type)
|
||||
require.NotNil(t, event.Message)
|
||||
return event
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("timed out waiting for chat stream message event")
|
||||
return codersdk.ChatStreamEvent{}
|
||||
}
|
||||
}
|
||||
|
||||
func requireStreamRetryEvent(t *testing.T, events <-chan codersdk.ChatStreamEvent) codersdk.ChatStreamEvent {
|
||||
t.Helper()
|
||||
|
||||
select {
|
||||
case event, ok := <-events:
|
||||
require.True(t, ok, "chat stream closed before delivering an event")
|
||||
require.Equal(t, codersdk.ChatStreamEventTypeRetry, event.Type)
|
||||
require.NotNil(t, event.Retry)
|
||||
return event
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("timed out waiting for chat stream retry event")
|
||||
return codersdk.ChatStreamEvent{}
|
||||
}
|
||||
}
|
||||
|
||||
func requireNoStreamEvent(t *testing.T, events <-chan codersdk.ChatStreamEvent, wait time.Duration) {
|
||||
t.Helper()
|
||||
|
||||
select {
|
||||
case event, ok := <-events:
|
||||
if !ok {
|
||||
t.Fatal("chat stream closed unexpectedly")
|
||||
}
|
||||
t.Fatalf("unexpected chat stream event: %+v", event)
|
||||
case <-time.After(wait):
|
||||
}
|
||||
}
|
||||
|
||||
// TestPublishToStream_DropWarnRateLimiting walks through a
|
||||
// realistic lifecycle: buffer fills up, subscriber channel fills
|
||||
// up, counters get reset between steps. It verifies that WARN
|
||||
// logs are rate-limited to at most once per streamDropWarnInterval
|
||||
// and that counter resets re-enable an immediate WARN.
|
||||
func TestPublishToStream_DropWarnRateLimiting(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
sink := testutil.NewFakeSink(t)
|
||||
mClock := quartz.NewMock(t)
|
||||
|
||||
server := &Server{
|
||||
logger: sink.Logger(),
|
||||
clock: mClock,
|
||||
}
|
||||
|
||||
chatID := uuid.New()
|
||||
subCh := make(chan codersdk.ChatStreamEvent, 1)
|
||||
subCh <- codersdk.ChatStreamEvent{} // pre-fill so sends always drop
|
||||
|
||||
// Set up state that mirrors a running chat: buffer at capacity,
|
||||
// buffering enabled, one saturated subscriber.
|
||||
state := &chatStreamState{
|
||||
buffering: true,
|
||||
buffer: make([]codersdk.ChatStreamEvent, maxStreamBufferSize),
|
||||
subscribers: map[uuid.UUID]chan codersdk.ChatStreamEvent{
|
||||
uuid.New(): subCh,
|
||||
},
|
||||
}
|
||||
server.chatStreams.Store(chatID, state)
|
||||
|
||||
bufferMsg := "chat stream buffer full, dropping oldest event"
|
||||
subMsg := "dropping chat stream event"
|
||||
|
||||
filter := func(level slog.Level, msg string) func(slog.SinkEntry) bool {
|
||||
return func(e slog.SinkEntry) bool {
|
||||
return e.Level == level && e.Message == msg
|
||||
}
|
||||
}
|
||||
|
||||
// --- Phase 1: buffer-full rate limiting ---
|
||||
// message_part events hit both the buffer-full and subscriber-full
|
||||
// paths. The first publish triggers a WARN for each; the rest
|
||||
// within the window are DEBUG.
|
||||
partEvent := codersdk.ChatStreamEvent{
|
||||
Type: codersdk.ChatStreamEventTypeMessagePart,
|
||||
MessagePart: &codersdk.ChatStreamMessagePart{},
|
||||
}
|
||||
for i := 0; i < 50; i++ {
|
||||
server.publishToStream(chatID, partEvent)
|
||||
}
|
||||
|
||||
require.Len(t, sink.Entries(filter(slog.LevelWarn, bufferMsg)), 1)
|
||||
require.Empty(t, sink.Entries(filter(slog.LevelDebug, bufferMsg)))
|
||||
requireFieldValue(t, sink.Entries(filter(slog.LevelWarn, bufferMsg))[0], "dropped_count", int64(1))
|
||||
|
||||
// Subscriber also saw 50 drops (one per publish).
|
||||
require.Len(t, sink.Entries(filter(slog.LevelWarn, subMsg)), 1)
|
||||
require.Empty(t, sink.Entries(filter(slog.LevelDebug, subMsg)))
|
||||
requireFieldValue(t, sink.Entries(filter(slog.LevelWarn, subMsg))[0], "dropped_count", int64(1))
|
||||
|
||||
// --- Phase 2: clock advance triggers second WARN with count ---
|
||||
mClock.Advance(streamDropWarnInterval + time.Second)
|
||||
server.publishToStream(chatID, partEvent)
|
||||
|
||||
bufWarn := sink.Entries(filter(slog.LevelWarn, bufferMsg))
|
||||
require.Len(t, bufWarn, 2)
|
||||
requireFieldValue(t, bufWarn[1], "dropped_count", int64(50))
|
||||
|
||||
subWarn := sink.Entries(filter(slog.LevelWarn, subMsg))
|
||||
require.Len(t, subWarn, 2)
|
||||
requireFieldValue(t, subWarn[1], "dropped_count", int64(50))
|
||||
|
||||
// --- Phase 3: counter reset (simulates step persist) ---
|
||||
state.mu.Lock()
|
||||
state.buffer = make([]codersdk.ChatStreamEvent, maxStreamBufferSize)
|
||||
state.resetDropCounters()
|
||||
state.mu.Unlock()
|
||||
|
||||
// The very next drop should WARN immediately — the reset zeroed
|
||||
// lastWarnAt so the interval check passes.
|
||||
server.publishToStream(chatID, partEvent)
|
||||
|
||||
bufWarn = sink.Entries(filter(slog.LevelWarn, bufferMsg))
|
||||
require.Len(t, bufWarn, 3, "expected WARN immediately after counter reset")
|
||||
requireFieldValue(t, bufWarn[2], "dropped_count", int64(1))
|
||||
|
||||
subWarn = sink.Entries(filter(slog.LevelWarn, subMsg))
|
||||
require.Len(t, subWarn, 3, "expected subscriber WARN immediately after counter reset")
|
||||
requireFieldValue(t, subWarn[2], "dropped_count", int64(1))
|
||||
}
|
||||
|
||||
// requireFieldValue asserts that a SinkEntry contains a field with
|
||||
// the given name and value.
|
||||
func requireFieldValue(t *testing.T, entry slog.SinkEntry, name string, expected interface{}) {
|
||||
t.Helper()
|
||||
for _, f := range entry.Fields {
|
||||
if f.Name == name {
|
||||
require.Equal(t, expected, f.Value, "field %q value mismatch", name)
|
||||
return
|
||||
}
|
||||
}
|
||||
t.Fatalf("field %q not found in log entry", name)
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -42,6 +42,11 @@ type PersistedStep struct {
|
||||
Content []fantasy.Content
|
||||
Usage fantasy.Usage
|
||||
ContextLimit sql.NullInt64
|
||||
// Runtime is the wall-clock duration of this step,
|
||||
// covering LLM streaming, tool execution, and retries.
|
||||
// Zero indicates the duration was not measured (e.g.
|
||||
// interrupted steps).
|
||||
Runtime time.Duration
|
||||
}
|
||||
|
||||
// RunOptions configures a single streaming chat loop run.
|
||||
@@ -63,11 +68,12 @@ type RunOptions struct {
|
||||
// of the provider, which lives in chatd, not chatloop.
|
||||
ProviderOptions fantasy.ProviderOptions
|
||||
|
||||
// ProviderTools are provider-native tools (like web search)
|
||||
// that are passed directly to the provider API alongside
|
||||
// function tool definitions. These are not necessarily
|
||||
// executed server-side; handling is provider-specific.
|
||||
ProviderTools []fantasy.Tool
|
||||
// ProviderTools are provider-native tools (like web search
|
||||
// and computer use) whose definitions are passed directly
|
||||
// to the provider API. When a ProviderTool has a non-nil
|
||||
// Runner, tool calls are executed locally; otherwise the
|
||||
// provider handles execution (e.g. web search).
|
||||
ProviderTools []ProviderTool
|
||||
|
||||
PersistStep func(context.Context, PersistedStep) error
|
||||
PublishMessagePart func(
|
||||
@@ -88,6 +94,16 @@ type RunOptions struct {
|
||||
OnInterruptedPersistError func(error)
|
||||
}
|
||||
|
||||
// ProviderTool pairs a provider-native tool definition with an
|
||||
// optional local executor. When Runner is nil the tool is fully
|
||||
// provider-executed (e.g. web search). When Runner is non-nil
|
||||
// the definition is sent to the API but execution is handled
|
||||
// locally (e.g. computer use).
|
||||
type ProviderTool struct {
|
||||
Definition fantasy.Tool
|
||||
Runner fantasy.AgentTool
|
||||
}
|
||||
|
||||
// stepResult holds the accumulated output of a single streaming
|
||||
// step. Since we own the stream consumer, all content is tracked
|
||||
// directly here — no shadow draft state needed.
|
||||
@@ -111,7 +127,7 @@ func (r stepResult) toResponseMessages() []fantasy.Message {
|
||||
switch c.GetType() {
|
||||
case fantasy.ContentTypeText:
|
||||
text, ok := fantasy.AsContentType[fantasy.TextContent](c)
|
||||
if !ok {
|
||||
if !ok || strings.TrimSpace(text.Text) == "" {
|
||||
continue
|
||||
}
|
||||
assistantParts = append(assistantParts, fantasy.TextPart{
|
||||
@@ -120,7 +136,7 @@ func (r stepResult) toResponseMessages() []fantasy.Message {
|
||||
})
|
||||
case fantasy.ContentTypeReasoning:
|
||||
reasoning, ok := fantasy.AsContentType[fantasy.ReasoningContent](c)
|
||||
if !ok {
|
||||
if !ok || strings.TrimSpace(reasoning.Text) == "" {
|
||||
continue
|
||||
}
|
||||
assistantParts = append(assistantParts, fantasy.ReasoningPart{
|
||||
@@ -249,6 +265,7 @@ func Run(ctx context.Context, opts RunOptions) error {
|
||||
|
||||
for step := 0; totalSteps < opts.MaxSteps; step++ {
|
||||
totalSteps++
|
||||
stepStart := time.Now()
|
||||
// Copy messages so that provider-specific caching
|
||||
// mutations don't leak back to the caller's slice.
|
||||
// copy copies Message structs by value, so field
|
||||
@@ -315,7 +332,7 @@ func Run(ctx context.Context, opts RunOptions) error {
|
||||
return ctx.Err()
|
||||
}
|
||||
|
||||
toolResults = executeTools(ctx, opts.Tools, result.toolCalls, func(tr fantasy.ToolResultContent) {
|
||||
toolResults = executeTools(ctx, opts.Tools, opts.ProviderTools, result.toolCalls, func(tr fantasy.ToolResultContent) {
|
||||
publishMessagePart(
|
||||
codersdk.ChatMessageRoleTool,
|
||||
chatprompt.PartFromContent(tr),
|
||||
@@ -354,6 +371,7 @@ func Run(ctx context.Context, opts RunOptions) error {
|
||||
Content: result.content,
|
||||
Usage: result.usage,
|
||||
ContextLimit: contextLimit,
|
||||
Runtime: time.Since(stepStart),
|
||||
}); err != nil {
|
||||
if errors.Is(err, ErrInterrupted) {
|
||||
persistInterruptedStep(ctx, opts, &result)
|
||||
@@ -599,10 +617,12 @@ func processStepStream(
|
||||
result.providerMetadata = part.ProviderMetadata
|
||||
|
||||
case fantasy.StreamPartTypeError:
|
||||
// Detect interruption: context canceled with
|
||||
// ErrInterrupted as the cause.
|
||||
if errors.Is(part.Error, context.Canceled) &&
|
||||
errors.Is(context.Cause(ctx), ErrInterrupted) {
|
||||
// Detect interruption: the stream may surface the
|
||||
// cancel as context.Canceled or propagate the
|
||||
// ErrInterrupted cause directly, depending on
|
||||
// the provider implementation.
|
||||
if errors.Is(context.Cause(ctx), ErrInterrupted) &&
|
||||
(errors.Is(part.Error, context.Canceled) || errors.Is(part.Error, ErrInterrupted)) {
|
||||
// Flush in-progress content so that
|
||||
// persistInterruptedStep has access to partial
|
||||
// text, reasoning, and tool calls that were
|
||||
@@ -620,6 +640,23 @@ func processStepStream(
|
||||
}
|
||||
}
|
||||
|
||||
// The stream iterator may stop yielding parts without
|
||||
// producing a StreamPartTypeError when the context is
|
||||
// canceled (e.g. some providers close the response body
|
||||
// silently). Detect this case and flush partial content
|
||||
// so that persistInterruptedStep can save it.
|
||||
if ctx.Err() != nil &&
|
||||
errors.Is(context.Cause(ctx), ErrInterrupted) {
|
||||
flushActiveState(
|
||||
&result,
|
||||
activeTextContent,
|
||||
activeReasoningContent,
|
||||
activeToolCalls,
|
||||
toolNames,
|
||||
)
|
||||
return result, ErrInterrupted
|
||||
}
|
||||
|
||||
hasLocalToolCalls := false
|
||||
for _, tc := range result.toolCalls {
|
||||
if !tc.ProviderExecuted {
|
||||
@@ -639,6 +676,7 @@ func processStepStream(
|
||||
func executeTools(
|
||||
ctx context.Context,
|
||||
allTools []fantasy.AgentTool,
|
||||
providerTools []ProviderTool,
|
||||
toolCalls []fantasy.ToolCallContent,
|
||||
onResult func(fantasy.ToolResultContent),
|
||||
) []fantasy.ToolResultContent {
|
||||
@@ -664,6 +702,13 @@ func executeTools(
|
||||
for _, t := range allTools {
|
||||
toolMap[t.Info().Name] = t
|
||||
}
|
||||
// Include runners from provider tools so locally-executed
|
||||
// provider tools (e.g. computer use) can be dispatched.
|
||||
for _, pt := range providerTools {
|
||||
if pt.Runner != nil {
|
||||
toolMap[pt.Runner.Info().Name] = pt.Runner
|
||||
}
|
||||
}
|
||||
|
||||
results := make([]fantasy.ToolResultContent, len(localToolCalls))
|
||||
var wg sync.WaitGroup
|
||||
@@ -863,15 +908,16 @@ func persistInterruptedStep(
|
||||
// buildToolDefinitions converts AgentTool definitions into the
|
||||
// fantasy.Tool slice expected by fantasy.Call. When activeTools
|
||||
// is non-empty, only function tools whose name appears in the
|
||||
// list are included. Provider tools bypass this filter and are
|
||||
// always appended unconditionally.
|
||||
func buildToolDefinitions(tools []fantasy.AgentTool, activeTools []string, providerTools []fantasy.Tool) []fantasy.Tool {
|
||||
prepared := make([]fantasy.Tool, 0, len(tools))
|
||||
// list are included. Provider tool definitions are always
|
||||
// appended unconditionally.
|
||||
func buildToolDefinitions(tools []fantasy.AgentTool, activeTools []string, providerTools []ProviderTool) []fantasy.Tool {
|
||||
prepared := make([]fantasy.Tool, 0, len(tools)+len(providerTools))
|
||||
for _, tool := range tools {
|
||||
info := tool.Info()
|
||||
if len(activeTools) > 0 && !slices.Contains(activeTools, info.Name) {
|
||||
continue
|
||||
}
|
||||
|
||||
inputSchema := map[string]any{
|
||||
"type": "object",
|
||||
"properties": info.Parameters,
|
||||
@@ -885,7 +931,9 @@ func buildToolDefinitions(tools []fantasy.AgentTool, activeTools []string, provi
|
||||
ProviderOptions: tool.ProviderOptions(),
|
||||
})
|
||||
}
|
||||
prepared = append(prepared, providerTools...)
|
||||
for _, pt := range providerTools {
|
||||
prepared = append(prepared, pt.Definition)
|
||||
}
|
||||
return prepared
|
||||
}
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"charm.land/fantasy"
|
||||
fantasyanthropic "charm.land/fantasy/providers/anthropic"
|
||||
@@ -64,6 +65,8 @@ func TestRun_ActiveToolsPrepareBehavior(t *testing.T) {
|
||||
require.Equal(t, 1, persistStepCalls)
|
||||
require.True(t, persistedStep.ContextLimit.Valid)
|
||||
require.Equal(t, int64(4096), persistedStep.ContextLimit.Int64)
|
||||
require.Greater(t, persistedStep.Runtime, time.Duration(0),
|
||||
"step runtime should be positive")
|
||||
|
||||
require.NotEmpty(t, capturedCall.Prompt)
|
||||
require.False(t, containsPromptSentinel(capturedCall.Prompt))
|
||||
@@ -575,6 +578,84 @@ func TestToResponseMessages_ProviderExecutedToolResultInAssistantMessage(t *test
|
||||
assert.False(t, localTR.ProviderExecuted)
|
||||
}
|
||||
|
||||
func TestToResponseMessages_FiltersEmptyTextAndReasoningParts(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
sr := stepResult{
|
||||
content: []fantasy.Content{
|
||||
// Empty text — should be filtered.
|
||||
fantasy.TextContent{Text: ""},
|
||||
// Whitespace-only text — should be filtered.
|
||||
fantasy.TextContent{Text: " \t\n"},
|
||||
// Empty reasoning — should be filtered.
|
||||
fantasy.ReasoningContent{Text: ""},
|
||||
// Whitespace-only reasoning — should be filtered.
|
||||
fantasy.ReasoningContent{Text: " \n"},
|
||||
// Non-empty text — should pass through.
|
||||
fantasy.TextContent{Text: "hello world"},
|
||||
// Leading/trailing whitespace with content — kept
|
||||
// with the original value (not trimmed).
|
||||
fantasy.TextContent{Text: " hello "},
|
||||
// Non-empty reasoning — should pass through.
|
||||
fantasy.ReasoningContent{Text: "let me think"},
|
||||
// Tool call — should be unaffected by filtering.
|
||||
fantasy.ToolCallContent{
|
||||
ToolCallID: "tc-1",
|
||||
ToolName: "read_file",
|
||||
Input: `{"path":"main.go"}`,
|
||||
},
|
||||
// Local tool result — should be unaffected by filtering.
|
||||
fantasy.ToolResultContent{
|
||||
ToolCallID: "tc-1",
|
||||
ToolName: "read_file",
|
||||
Result: fantasy.ToolResultOutputContentText{Text: "file contents"},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
msgs := sr.toResponseMessages()
|
||||
require.Len(t, msgs, 2, "expected assistant + tool messages")
|
||||
|
||||
// First message: assistant role with non-empty text, reasoning,
|
||||
// and the tool call. The four empty/whitespace-only parts must
|
||||
// have been dropped.
|
||||
assistantMsg := msgs[0]
|
||||
assert.Equal(t, fantasy.MessageRoleAssistant, assistantMsg.Role)
|
||||
require.Len(t, assistantMsg.Content, 4,
|
||||
"assistant message should have 2x TextPart, ReasoningPart, and ToolCallPart")
|
||||
|
||||
// Part 0: non-empty text.
|
||||
textPart, ok := fantasy.AsMessagePart[fantasy.TextPart](assistantMsg.Content[0])
|
||||
require.True(t, ok, "part 0 should be TextPart")
|
||||
assert.Equal(t, "hello world", textPart.Text)
|
||||
|
||||
// Part 1: padded text — original whitespace preserved.
|
||||
paddedPart, ok := fantasy.AsMessagePart[fantasy.TextPart](assistantMsg.Content[1])
|
||||
require.True(t, ok, "part 1 should be TextPart")
|
||||
assert.Equal(t, " hello ", paddedPart.Text)
|
||||
|
||||
// Part 2: non-empty reasoning.
|
||||
reasoningPart, ok := fantasy.AsMessagePart[fantasy.ReasoningPart](assistantMsg.Content[2])
|
||||
require.True(t, ok, "part 2 should be ReasoningPart")
|
||||
assert.Equal(t, "let me think", reasoningPart.Text)
|
||||
|
||||
// Part 3: tool call (unaffected by text/reasoning filtering).
|
||||
toolCallPart, ok := fantasy.AsMessagePart[fantasy.ToolCallPart](assistantMsg.Content[3])
|
||||
require.True(t, ok, "part 3 should be ToolCallPart")
|
||||
assert.Equal(t, "tc-1", toolCallPart.ToolCallID)
|
||||
assert.Equal(t, "read_file", toolCallPart.ToolName)
|
||||
|
||||
// Second message: tool role with the local tool result.
|
||||
toolMsg := msgs[1]
|
||||
assert.Equal(t, fantasy.MessageRoleTool, toolMsg.Role)
|
||||
require.Len(t, toolMsg.Content, 1,
|
||||
"tool message should have only the local ToolResultPart")
|
||||
|
||||
toolResultPart, ok := fantasy.AsMessagePart[fantasy.ToolResultPart](toolMsg.Content[0])
|
||||
require.True(t, ok, "tool part should be ToolResultPart")
|
||||
assert.Equal(t, "tc-1", toolResultPart.ToolCallID)
|
||||
}
|
||||
|
||||
func hasAnthropicEphemeralCacheControl(message fantasy.Message) bool {
|
||||
if len(message.ProviderOptions) == 0 {
|
||||
return false
|
||||
|
||||
399
coderd/chatd/chatloop/contextlimit_internal_test.go
Normal file
399
coderd/chatd/chatloop/contextlimit_internal_test.go
Normal file
@@ -0,0 +1,399 @@
|
||||
package chatloop
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
|
||||
"charm.land/fantasy"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// testProviderData implements fantasy.ProviderOptionsData so we can
|
||||
// construct arbitrary ProviderMetadata for extractContextLimit tests.
|
||||
type testProviderData struct {
|
||||
data map[string]any
|
||||
}
|
||||
|
||||
func (*testProviderData) Options() {}
|
||||
|
||||
func (d *testProviderData) MarshalJSON() ([]byte, error) {
|
||||
return json.Marshal(d.data)
|
||||
}
|
||||
|
||||
// Required by the ProviderOptionsData interface; unused in tests.
|
||||
func (d *testProviderData) UnmarshalJSON(b []byte) error {
|
||||
return json.Unmarshal(b, &d.data)
|
||||
}
|
||||
|
||||
func TestNormalizeMetadataKey(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
key string
|
||||
want string
|
||||
}{
|
||||
{name: "lowercase", key: "camelCase", want: "camelcase"},
|
||||
{name: "hyphens stripped", key: "kebab-case", want: "kebabcase"},
|
||||
{name: "underscores stripped", key: "snake_case", want: "snakecase"},
|
||||
{name: "uppercase", key: "UPPER", want: "upper"},
|
||||
{name: "spaces stripped", key: "with spaces", want: "withspaces"},
|
||||
{name: "empty", key: "", want: ""},
|
||||
{name: "digits preserved", key: "123", want: "123"},
|
||||
{name: "mixed separators", key: "Max_Context-Tokens", want: "maxcontexttokens"},
|
||||
{name: "dots stripped", key: "context.limit", want: "contextlimit"},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
got := normalizeMetadataKey(tt.key)
|
||||
require.Equal(t, tt.want, got)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsContextLimitKey(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
key string
|
||||
want bool
|
||||
skip bool
|
||||
}{ // Exact matches after normalization.
|
||||
{name: "context_limit", key: "context_limit", want: true},
|
||||
{name: "context_window", key: "context_window", want: true},
|
||||
{name: "context_length", key: "context_length", want: true},
|
||||
{name: "max_context", key: "max_context", want: true},
|
||||
{name: "max_context_tokens", key: "max_context_tokens", want: true},
|
||||
{name: "max_input_tokens", key: "max_input_tokens", want: true},
|
||||
{name: "max_input_token", key: "max_input_token", want: true},
|
||||
{name: "input_token_limit", key: "input_token_limit", want: true},
|
||||
|
||||
// Case and separator variations.
|
||||
{name: "Context-Window mixed case", key: "Context-Window", want: true},
|
||||
{name: "MAX_CONTEXT_TOKENS screaming", key: "MAX_CONTEXT_TOKENS", want: true},
|
||||
{name: "contextLimit camelCase", key: "contextLimit", want: true},
|
||||
|
||||
// Fallback heuristic: contains "context" + limit/window/length.
|
||||
{name: "model_context_limit", key: "model_context_limit", want: true},
|
||||
{name: "context_window_size", key: "context_window_size", want: true},
|
||||
{name: "context_length_max", key: "context_length_max", want: true},
|
||||
|
||||
// Fallback heuristic: starts with "max" + contains "context".
|
||||
// BUG(isContextLimitKey): "max_context_version" matches
|
||||
// because it contains "context" and starts with "max",
|
||||
// but a version field is not a context limit.
|
||||
// TODO: Fix the heuristic and remove this skip.
|
||||
{name: "max_context_version false positive", key: "max_context_version", want: false, skip: true}, // Non-matching keys.
|
||||
{name: "context_id no limit keyword", key: "context_id", want: false},
|
||||
{name: "empty string", key: "", want: false},
|
||||
{name: "unrelated key", key: "model_name", want: false},
|
||||
{name: "limit without context", key: "rate_limit", want: false},
|
||||
{name: "max without context", key: "max_tokens", want: false},
|
||||
{name: "context alone", key: "context", want: false},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
if tt.skip {
|
||||
t.Skip("known bug: isContextLimitKey false positive")
|
||||
}
|
||||
got := isContextLimitKey(tt.key)
|
||||
require.Equal(t, tt.want, got)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestNumericContextLimitValue(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
value any
|
||||
want int64
|
||||
wantOK bool
|
||||
}{
|
||||
// float64: the default numeric type from json.Unmarshal.
|
||||
{name: "float64 integer", value: float64(128000), want: 128000, wantOK: true},
|
||||
{name: "float64 fractional rejected", value: float64(128000.5), want: 0, wantOK: false},
|
||||
{name: "float64 zero rejected", value: float64(0), want: 0, wantOK: false},
|
||||
{name: "float64 negative rejected", value: float64(-1), want: 0, wantOK: false},
|
||||
|
||||
// int64
|
||||
{name: "int64 positive", value: int64(200000), want: 200000, wantOK: true},
|
||||
{name: "int64 zero rejected", value: int64(0), want: 0, wantOK: false},
|
||||
{name: "int64 negative rejected", value: int64(-1), want: 0, wantOK: false},
|
||||
|
||||
// int32
|
||||
{name: "int32 positive", value: int32(50000), want: 50000, wantOK: true},
|
||||
{name: "int32 zero rejected", value: int32(0), want: 0, wantOK: false},
|
||||
|
||||
// int
|
||||
{name: "int positive", value: int(50000), want: 50000, wantOK: true},
|
||||
{name: "int zero rejected", value: int(0), want: 0, wantOK: false},
|
||||
|
||||
// string
|
||||
{name: "string numeric", value: "128000", want: 128000, wantOK: true},
|
||||
{name: "string trimmed", value: " 128000 ", want: 128000, wantOK: true},
|
||||
{name: "string non-numeric rejected", value: "not a number", want: 0, wantOK: false},
|
||||
{name: "string empty rejected", value: "", want: 0, wantOK: false},
|
||||
{name: "string zero rejected", value: "0", want: 0, wantOK: false},
|
||||
{name: "string negative rejected", value: "-1", want: 0, wantOK: false},
|
||||
|
||||
// json.Number
|
||||
{name: "json.Number valid", value: json.Number("200000"), want: 200000, wantOK: true},
|
||||
{name: "json.Number invalid rejected", value: json.Number("invalid"), want: 0, wantOK: false},
|
||||
{name: "json.Number zero rejected", value: json.Number("0"), want: 0, wantOK: false},
|
||||
|
||||
// Unhandled types.
|
||||
{name: "bool rejected", value: true, want: 0, wantOK: false},
|
||||
{name: "nil rejected", value: nil, want: 0, wantOK: false},
|
||||
{name: "slice rejected", value: []int{1}, want: 0, wantOK: false},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
got, ok := numericContextLimitValue(tt.value)
|
||||
require.Equal(t, tt.wantOK, ok)
|
||||
require.Equal(t, tt.want, got)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestPositiveInt64(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
got, ok := positiveInt64(42)
|
||||
require.True(t, ok)
|
||||
require.Equal(t, int64(42), got)
|
||||
|
||||
got, ok = positiveInt64(0)
|
||||
require.False(t, ok)
|
||||
require.Equal(t, int64(0), got)
|
||||
|
||||
got, ok = positiveInt64(-1)
|
||||
require.False(t, ok)
|
||||
require.Equal(t, int64(0), got)
|
||||
}
|
||||
|
||||
func TestCollectContextLimitValues(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
t.Run("FlatMap", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
input := map[string]any{
|
||||
"context_limit": float64(200000),
|
||||
"other_key": float64(999),
|
||||
}
|
||||
var collected []int64
|
||||
collectContextLimitValues(input, func(v int64) {
|
||||
collected = append(collected, v)
|
||||
})
|
||||
require.Equal(t, []int64{200000}, collected)
|
||||
})
|
||||
|
||||
t.Run("NestedMaps", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
input := map[string]any{
|
||||
"provider": map[string]any{
|
||||
"info": map[string]any{
|
||||
"context_window": float64(100000),
|
||||
},
|
||||
},
|
||||
}
|
||||
var collected []int64
|
||||
collectContextLimitValues(input, func(v int64) {
|
||||
collected = append(collected, v)
|
||||
})
|
||||
require.Equal(t, []int64{100000}, collected)
|
||||
})
|
||||
|
||||
t.Run("ArrayTraversal", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
input := []any{
|
||||
map[string]any{"context_limit": float64(50000)},
|
||||
map[string]any{"context_limit": float64(80000)},
|
||||
}
|
||||
var collected []int64
|
||||
collectContextLimitValues(input, func(v int64) {
|
||||
collected = append(collected, v)
|
||||
})
|
||||
require.Len(t, collected, 2)
|
||||
require.Contains(t, collected, int64(50000))
|
||||
require.Contains(t, collected, int64(80000))
|
||||
})
|
||||
|
||||
t.Run("MixedNesting", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
input := map[string]any{
|
||||
"models": []any{
|
||||
map[string]any{
|
||||
"context_limit": float64(128000),
|
||||
},
|
||||
},
|
||||
}
|
||||
var collected []int64
|
||||
collectContextLimitValues(input, func(v int64) {
|
||||
collected = append(collected, v)
|
||||
})
|
||||
require.Equal(t, []int64{128000}, collected)
|
||||
})
|
||||
|
||||
t.Run("NonMatchingKey", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
input := map[string]any{
|
||||
"model_name": "gpt-4",
|
||||
"tokens": float64(1000),
|
||||
}
|
||||
var collected []int64
|
||||
collectContextLimitValues(input, func(v int64) {
|
||||
collected = append(collected, v)
|
||||
})
|
||||
require.Empty(t, collected)
|
||||
})
|
||||
|
||||
t.Run("ScalarIgnored", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
var collected []int64
|
||||
collectContextLimitValues("just a string", func(v int64) {
|
||||
collected = append(collected, v)
|
||||
})
|
||||
require.Empty(t, collected)
|
||||
})
|
||||
}
|
||||
|
||||
func TestFindContextLimitValue(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
t.Run("SingleCandidate", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
input := map[string]any{
|
||||
"context_limit": float64(200000),
|
||||
}
|
||||
limit, ok := findContextLimitValue(input)
|
||||
require.True(t, ok)
|
||||
require.Equal(t, int64(200000), limit)
|
||||
})
|
||||
|
||||
t.Run("MultipleCandidatesTakesMax", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
input := map[string]any{
|
||||
"a": map[string]any{"context_limit": float64(50000)},
|
||||
"b": map[string]any{"context_limit": float64(200000)},
|
||||
}
|
||||
limit, ok := findContextLimitValue(input)
|
||||
require.True(t, ok)
|
||||
require.Equal(t, int64(200000), limit)
|
||||
})
|
||||
|
||||
t.Run("NoCandidates", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
input := map[string]any{
|
||||
"model": "gpt-4",
|
||||
}
|
||||
_, ok := findContextLimitValue(input)
|
||||
require.False(t, ok)
|
||||
})
|
||||
|
||||
t.Run("NilInput", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
_, ok := findContextLimitValue(nil)
|
||||
require.False(t, ok)
|
||||
})
|
||||
}
|
||||
|
||||
func TestExtractContextLimit(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
t.Run("AnthropicStyle", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
metadata := fantasy.ProviderMetadata{
|
||||
"anthropic": &testProviderData{
|
||||
data: map[string]any{
|
||||
"cache_read_input_tokens": float64(100),
|
||||
"context_limit": float64(200000),
|
||||
},
|
||||
},
|
||||
}
|
||||
result := extractContextLimit(metadata)
|
||||
require.True(t, result.Valid)
|
||||
require.Equal(t, int64(200000), result.Int64)
|
||||
})
|
||||
|
||||
t.Run("OpenAIStyle", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
metadata := fantasy.ProviderMetadata{
|
||||
"openai": &testProviderData{
|
||||
data: map[string]any{
|
||||
"max_context_tokens": float64(128000),
|
||||
},
|
||||
},
|
||||
}
|
||||
result := extractContextLimit(metadata)
|
||||
require.True(t, result.Valid)
|
||||
require.Equal(t, int64(128000), result.Int64)
|
||||
})
|
||||
|
||||
t.Run("NestedDeeply", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
metadata := fantasy.ProviderMetadata{
|
||||
"provider": &testProviderData{
|
||||
data: map[string]any{
|
||||
"info": map[string]any{
|
||||
"context_window": float64(100000),
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
result := extractContextLimit(metadata)
|
||||
require.True(t, result.Valid)
|
||||
require.Equal(t, int64(100000), result.Int64)
|
||||
})
|
||||
|
||||
t.Run("MultipleCandidatesTakesMax", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
metadata := fantasy.ProviderMetadata{
|
||||
"a": &testProviderData{
|
||||
data: map[string]any{
|
||||
"context_limit": float64(50000),
|
||||
},
|
||||
},
|
||||
"b": &testProviderData{
|
||||
data: map[string]any{
|
||||
"context_limit": float64(200000),
|
||||
},
|
||||
},
|
||||
}
|
||||
result := extractContextLimit(metadata)
|
||||
require.True(t, result.Valid)
|
||||
require.Equal(t, int64(200000), result.Int64)
|
||||
})
|
||||
|
||||
t.Run("NoMatchingKeys", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
metadata := fantasy.ProviderMetadata{
|
||||
"openai": &testProviderData{
|
||||
data: map[string]any{
|
||||
"model": "gpt-4",
|
||||
"tokens": float64(1000),
|
||||
},
|
||||
},
|
||||
}
|
||||
result := extractContextLimit(metadata)
|
||||
assert.False(t, result.Valid)
|
||||
})
|
||||
|
||||
t.Run("NilMetadata", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
result := extractContextLimit(nil)
|
||||
assert.False(t, result.Valid)
|
||||
})
|
||||
|
||||
t.Run("EmptyMetadata", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
result := extractContextLimit(fantasy.ProviderMetadata{})
|
||||
assert.False(t, result.Valid)
|
||||
})
|
||||
}
|
||||
@@ -139,9 +139,13 @@ func ConvertMessagesWithFiles(
|
||||
},
|
||||
})
|
||||
case codersdk.ChatMessageRoleUser:
|
||||
userParts := partsToMessageParts(logger, pm.parts, resolved)
|
||||
if len(userParts) == 0 {
|
||||
continue
|
||||
}
|
||||
prompt = append(prompt, fantasy.Message{
|
||||
Role: fantasy.MessageRoleUser,
|
||||
Content: partsToMessageParts(logger, pm.parts, resolved),
|
||||
Content: userParts,
|
||||
})
|
||||
case codersdk.ChatMessageRoleAssistant:
|
||||
fantasyParts := normalizeAssistantToolCallInputs(
|
||||
@@ -153,6 +157,9 @@ func ConvertMessagesWithFiles(
|
||||
}
|
||||
toolNameByCallID[sanitizeToolCallID(toolCall.ToolCallID)] = toolCall.ToolName
|
||||
}
|
||||
if len(fantasyParts) == 0 {
|
||||
continue
|
||||
}
|
||||
prompt = append(prompt, fantasy.Message{
|
||||
Role: fantasy.MessageRoleAssistant,
|
||||
Content: fantasyParts,
|
||||
@@ -166,9 +173,13 @@ func ConvertMessagesWithFiles(
|
||||
}
|
||||
}
|
||||
}
|
||||
toolParts := partsToMessageParts(logger, pm.parts, resolved)
|
||||
if len(toolParts) == 0 {
|
||||
continue
|
||||
}
|
||||
prompt = append(prompt, fantasy.Message{
|
||||
Role: fantasy.MessageRoleTool,
|
||||
Content: partsToMessageParts(logger, pm.parts, resolved),
|
||||
Content: toolParts,
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -321,6 +332,7 @@ func parseContentV1(role codersdk.ChatMessageRole, raw pqtype.NullRawMessage) ([
|
||||
if err := json.Unmarshal(raw.RawMessage, &parts); err != nil {
|
||||
return nil, xerrors.Errorf("parse %s content: %w", role, err)
|
||||
}
|
||||
decodeNulInParts(parts)
|
||||
return parts, nil
|
||||
}
|
||||
|
||||
@@ -1018,11 +1030,16 @@ func sanitizeToolCallID(id string) string {
|
||||
}
|
||||
|
||||
// MarshalParts encodes SDK chat message parts for persistence.
|
||||
// NUL characters in string fields are encoded as PUA sentinel
|
||||
// pairs (U+E000 U+E001) before marshaling so the resulting JSON
|
||||
// never contains \u0000 (rejected by PostgreSQL jsonb). The
|
||||
// encoding operates on Go string values, not JSON bytes, so it
|
||||
// survives jsonb text normalization.
|
||||
func MarshalParts(parts []codersdk.ChatMessagePart) (pqtype.NullRawMessage, error) {
|
||||
if len(parts) == 0 {
|
||||
return pqtype.NullRawMessage{}, nil
|
||||
}
|
||||
data, err := json.Marshal(parts)
|
||||
data, err := json.Marshal(encodeNulInParts(parts))
|
||||
if err != nil {
|
||||
return pqtype.NullRawMessage{}, xerrors.Errorf("encode chat message parts: %w", err)
|
||||
}
|
||||
@@ -1169,11 +1186,23 @@ func partsToMessageParts(
|
||||
for _, part := range parts {
|
||||
switch part.Type {
|
||||
case codersdk.ChatMessagePartTypeText:
|
||||
// Anthropic rejects empty text content blocks with
|
||||
// "text content blocks must be non-empty". Empty parts
|
||||
// can arise when a stream sends TextStart/TextEnd with
|
||||
// no delta in between. We filter them here rather than
|
||||
// at persistence time to preserve the raw record.
|
||||
if strings.TrimSpace(part.Text) == "" {
|
||||
continue
|
||||
}
|
||||
result = append(result, fantasy.TextPart{
|
||||
Text: part.Text,
|
||||
ProviderOptions: providerMetadataToOptions(logger, part.ProviderMetadata),
|
||||
})
|
||||
case codersdk.ChatMessagePartTypeReasoning:
|
||||
// Same guard as text parts above.
|
||||
if strings.TrimSpace(part.Text) == "" {
|
||||
continue
|
||||
}
|
||||
result = append(result, fantasy.ReasoningPart{
|
||||
Text: part.Text,
|
||||
ProviderOptions: providerMetadataToOptions(logger, part.ProviderMetadata),
|
||||
@@ -1216,3 +1245,186 @@ func partsToMessageParts(
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// encodeNulInString replaces NUL (U+0000) characters in s with
|
||||
// the sentinel pair U+E000 U+E001, and doubles any pre-existing
|
||||
// U+E000 to U+E000 U+E000 so the encoding is reversible.
|
||||
// Operates on Unicode code points, not JSON escape sequences,
|
||||
// making it safe through jsonb round-trips (jsonb stores parsed
|
||||
// characters, not original escape text).
|
||||
func encodeNulInString(s string) string {
|
||||
if !strings.ContainsRune(s, 0) && !strings.ContainsRune(s, '\uE000') {
|
||||
return s
|
||||
}
|
||||
var b strings.Builder
|
||||
b.Grow(len(s))
|
||||
for _, r := range s {
|
||||
switch r {
|
||||
case '\uE000':
|
||||
_, _ = b.WriteRune('\uE000')
|
||||
_, _ = b.WriteRune('\uE000')
|
||||
case 0:
|
||||
_, _ = b.WriteRune('\uE000')
|
||||
_, _ = b.WriteRune('\uE001')
|
||||
default:
|
||||
_, _ = b.WriteRune(r)
|
||||
}
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// decodeNulInString reverses encodeNulInString: U+E000 U+E000
|
||||
// becomes U+E000, and U+E000 U+E001 becomes NUL.
|
||||
func decodeNulInString(s string) string {
|
||||
if !strings.ContainsRune(s, '\uE000') {
|
||||
return s
|
||||
}
|
||||
var b strings.Builder
|
||||
b.Grow(len(s))
|
||||
runes := []rune(s)
|
||||
for i := 0; i < len(runes); i++ {
|
||||
if runes[i] == '\uE000' && i+1 < len(runes) {
|
||||
switch runes[i+1] {
|
||||
case '\uE000':
|
||||
_, _ = b.WriteRune('\uE000')
|
||||
i++
|
||||
case '\uE001':
|
||||
_, _ = b.WriteRune(0)
|
||||
i++
|
||||
default:
|
||||
// Unpaired sentinel — preserve as-is.
|
||||
_, _ = b.WriteRune(runes[i])
|
||||
}
|
||||
} else {
|
||||
_, _ = b.WriteRune(runes[i])
|
||||
}
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// encodeNulInValue recursively walks a JSON value (as produced
|
||||
// by json.Unmarshal with UseNumber) and applies
|
||||
// encodeNulInString to every string, including map keys.
|
||||
func encodeNulInValue(v any) any {
|
||||
switch val := v.(type) {
|
||||
case string:
|
||||
return encodeNulInString(val)
|
||||
case map[string]any:
|
||||
out := make(map[string]any, len(val))
|
||||
for k, elem := range val {
|
||||
out[encodeNulInString(k)] = encodeNulInValue(elem)
|
||||
}
|
||||
return out
|
||||
case []any:
|
||||
out := make([]any, len(val))
|
||||
for i, elem := range val {
|
||||
out[i] = encodeNulInValue(elem)
|
||||
}
|
||||
return out
|
||||
default:
|
||||
return v // numbers, bools, nil
|
||||
}
|
||||
}
|
||||
|
||||
// decodeNulInValue recursively walks a JSON value and applies
|
||||
// decodeNulInString to every string, including map keys.
|
||||
func decodeNulInValue(v any) any {
|
||||
switch val := v.(type) {
|
||||
case string:
|
||||
return decodeNulInString(val)
|
||||
case map[string]any:
|
||||
out := make(map[string]any, len(val))
|
||||
for k, elem := range val {
|
||||
out[decodeNulInString(k)] = decodeNulInValue(elem)
|
||||
}
|
||||
return out
|
||||
case []any:
|
||||
out := make([]any, len(val))
|
||||
for i, elem := range val {
|
||||
out[i] = decodeNulInValue(elem)
|
||||
}
|
||||
return out
|
||||
default:
|
||||
return v
|
||||
}
|
||||
}
|
||||
|
||||
// encodeNulInJSON walks all string values (and keys) inside a
|
||||
// json.RawMessage and applies encodeNulInString. Returns the
|
||||
// original unchanged when the raw message does not contain NUL
|
||||
// escapes or U+E000 bytes, or when parsing fails.
|
||||
func encodeNulInJSON(raw json.RawMessage) json.RawMessage {
|
||||
if len(raw) == 0 {
|
||||
return raw
|
||||
}
|
||||
// Quick exit: no \u0000 escape and no U+E000 UTF-8 bytes.
|
||||
if !bytes.Contains(raw, []byte(`\u0000`)) &&
|
||||
!bytes.Contains(raw, []byte{0xEE, 0x80, 0x80}) {
|
||||
return raw
|
||||
}
|
||||
dec := json.NewDecoder(bytes.NewReader(raw))
|
||||
dec.UseNumber()
|
||||
var v any
|
||||
if err := dec.Decode(&v); err != nil {
|
||||
return raw
|
||||
}
|
||||
result, err := json.Marshal(encodeNulInValue(v))
|
||||
if err != nil {
|
||||
return raw
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// decodeNulInJSON walks all string values (and keys) inside a
|
||||
// json.RawMessage and applies decodeNulInString.
|
||||
func decodeNulInJSON(raw json.RawMessage) json.RawMessage {
|
||||
if len(raw) == 0 {
|
||||
return raw
|
||||
}
|
||||
// U+E000 encoded as UTF-8 is 0xEE 0x80 0x80.
|
||||
if !bytes.Contains(raw, []byte{0xEE, 0x80, 0x80}) {
|
||||
return raw
|
||||
}
|
||||
dec := json.NewDecoder(bytes.NewReader(raw))
|
||||
dec.UseNumber()
|
||||
var v any
|
||||
if err := dec.Decode(&v); err != nil {
|
||||
return raw
|
||||
}
|
||||
result, err := json.Marshal(decodeNulInValue(v))
|
||||
if err != nil {
|
||||
return raw
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// encodeNulInParts returns a shallow copy of parts with all
|
||||
// string and json.RawMessage fields NUL-encoded. The caller's
|
||||
// slice is not modified.
|
||||
func encodeNulInParts(parts []codersdk.ChatMessagePart) []codersdk.ChatMessagePart {
|
||||
encoded := make([]codersdk.ChatMessagePart, len(parts))
|
||||
copy(encoded, parts)
|
||||
for i := range encoded {
|
||||
p := &encoded[i]
|
||||
p.Text = encodeNulInString(p.Text)
|
||||
p.Content = encodeNulInString(p.Content)
|
||||
p.Args = encodeNulInJSON(p.Args)
|
||||
p.ArgsDelta = encodeNulInString(p.ArgsDelta)
|
||||
p.Result = encodeNulInJSON(p.Result)
|
||||
p.ResultDelta = encodeNulInString(p.ResultDelta)
|
||||
}
|
||||
return encoded
|
||||
}
|
||||
|
||||
// decodeNulInParts reverses encodeNulInParts in place.
|
||||
func decodeNulInParts(parts []codersdk.ChatMessagePart) {
|
||||
for i := range parts {
|
||||
p := &parts[i]
|
||||
p.Text = decodeNulInString(p.Text)
|
||||
p.Content = decodeNulInString(p.Content)
|
||||
p.Args = decodeNulInJSON(p.Args)
|
||||
p.ArgsDelta = decodeNulInString(p.ArgsDelta)
|
||||
p.Result = decodeNulInJSON(p.Result)
|
||||
p.ResultDelta = decodeNulInString(p.ResultDelta)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,7 +17,10 @@ import (
|
||||
"github.com/coder/coder/v2/coderd/chatd/chatprompt"
|
||||
"github.com/coder/coder/v2/coderd/database"
|
||||
"github.com/coder/coder/v2/coderd/database/db2sdk"
|
||||
"github.com/coder/coder/v2/coderd/database/dbgen"
|
||||
"github.com/coder/coder/v2/coderd/database/dbtestutil"
|
||||
"github.com/coder/coder/v2/codersdk"
|
||||
"github.com/coder/coder/v2/testutil"
|
||||
)
|
||||
|
||||
// testMsg builds a database.ChatMessage for ParseContent tests.
|
||||
@@ -1441,3 +1444,327 @@ func extractToolResultIDs(t *testing.T, msgs ...fantasy.Message) []string {
|
||||
}
|
||||
return ids
|
||||
}
|
||||
|
||||
func TestNulEscapeRoundTrip(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
db, _ := dbtestutil.NewDB(t)
|
||||
ctx := testutil.Context(t, testutil.WaitShort)
|
||||
|
||||
// Seed minimal dependencies for the DB round-trip path:
|
||||
// user, provider, model config, chat.
|
||||
user := dbgen.User(t, db, database.User{})
|
||||
|
||||
_, err := db.InsertChatProvider(ctx, database.InsertChatProviderParams{
|
||||
Provider: "openai",
|
||||
DisplayName: "openai",
|
||||
APIKey: "test-key",
|
||||
CreatedBy: uuid.NullUUID{UUID: user.ID, Valid: true},
|
||||
Enabled: true,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
model, err := db.InsertChatModelConfig(ctx, database.InsertChatModelConfigParams{
|
||||
Provider: "openai",
|
||||
Model: "gpt-4o-mini",
|
||||
DisplayName: "Test Model",
|
||||
CreatedBy: uuid.NullUUID{UUID: user.ID, Valid: true},
|
||||
UpdatedBy: uuid.NullUUID{UUID: user.ID, Valid: true},
|
||||
Enabled: true,
|
||||
IsDefault: true,
|
||||
ContextLimit: 128000,
|
||||
CompressionThreshold: 70,
|
||||
Options: json.RawMessage(`{}`),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
chat, err := db.InsertChat(ctx, database.InsertChatParams{
|
||||
OwnerID: user.ID,
|
||||
LastModelConfigID: model.ID,
|
||||
Title: "nul-roundtrip-test",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
textTests := []struct {
|
||||
name string
|
||||
input string
|
||||
hasNul bool // Whether the input contains actual NUL bytes.
|
||||
}{
|
||||
// --- basic ---
|
||||
{"NoNul", "hello world", false},
|
||||
{"SingleNul", "a\x00b", true},
|
||||
{"MultipleNuls", "a\x00b\x00c", true},
|
||||
{"ConsecutiveNuls", "\x00\x00\x00", true},
|
||||
|
||||
// --- boundaries ---
|
||||
{"EmptyString", "", false},
|
||||
{"NulOnly", "\x00", true},
|
||||
{"NulAtStart", "\x00hello", true},
|
||||
{"NulAtEnd", "hello\x00", true},
|
||||
|
||||
// --- sentinel / marker in original data ---
|
||||
// U+E000 is the sentinel character. The encoder must
|
||||
// double it so it round-trips without being mistaken
|
||||
// for an encoded NUL.
|
||||
{"SentinelInOriginal", "a\uE000b", false},
|
||||
{"ConsecutiveSentinels", "\uE000\uE000\uE000", false},
|
||||
// U+E001 is the marker character used in the NUL pair.
|
||||
{"MarkerCharInOriginal", "a\uE001b", false},
|
||||
// U+E000 followed by U+E001 looks exactly like an
|
||||
// encoded NUL in the encoded form, so the encoder must
|
||||
// double the U+E000 to avoid confusion.
|
||||
{"SentinelThenMarkerChar", "\uE000\uE001", false},
|
||||
{"NulAndSentinel", "a\x00b\uE000c", true},
|
||||
// Both orders: sentinel adjacent to NUL.
|
||||
{"SentinelThenNul", "\uE000\x00", true},
|
||||
{"NulThenSentinel", "\x00\uE000", true},
|
||||
{"AlternatingSentinelNul", "\x00\uE000\x00\uE000", true},
|
||||
|
||||
// --- strings containing backslashes ---
|
||||
// Backslashes are normal characters at the Go string
|
||||
// level; no special handling needed (unlike the old
|
||||
// JSON-byte approach).
|
||||
{"BackslashU0000Text", "\\u0000", false},
|
||||
{"BackslashThenNul", "\\\x00", true},
|
||||
|
||||
// --- literal text that looks like escape patterns ---
|
||||
{"LiteralTextU0000", "the value is u0000 here", false},
|
||||
{"LiteralTextUE000", "sentinel uE000 text", false},
|
||||
|
||||
// --- other control characters mixed with NUL ---
|
||||
{"ControlCharsMixedWithNul", "\x01\x00\x02\x00\x1f", true},
|
||||
|
||||
// --- long / stress ---
|
||||
{"LongNulRun", "\x00\x00\x00\x00\x00\x00\x00\x00", true},
|
||||
// Simulated find -print0 output.
|
||||
{"FindPrint0", "/usr/bin/ls\x00/usr/bin/cat\x00/usr/bin/grep\x00", true},
|
||||
}
|
||||
|
||||
for _, tc := range textTests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
parts := []codersdk.ChatMessagePart{
|
||||
codersdk.ChatMessageText(tc.input),
|
||||
}
|
||||
|
||||
encoded, err := chatprompt.MarshalParts(parts)
|
||||
require.NoError(t, err)
|
||||
|
||||
// When the input has real NUL bytes, the stored JSON
|
||||
// must not contain the \u0000 escape sequence.
|
||||
if tc.hasNul {
|
||||
require.NotContains(t, string(encoded.RawMessage), `\u0000`,
|
||||
"encoded JSON must not contain \\u0000")
|
||||
}
|
||||
|
||||
// In-memory round-trip through ParseContent.
|
||||
msg := testMsgV1(codersdk.ChatMessageRoleAssistant, encoded)
|
||||
decoded, err := chatprompt.ParseContent(msg)
|
||||
require.NoError(t, err)
|
||||
|
||||
require.Len(t, decoded, 1)
|
||||
require.Equal(t, tc.input, decoded[0].Text)
|
||||
|
||||
// Full DB round-trip: write to PostgreSQL jsonb, read
|
||||
// back, and verify the value survives storage.
|
||||
ctx := testutil.Context(t, testutil.WaitShort)
|
||||
dbMsgs, err := db.InsertChatMessages(ctx, database.InsertChatMessagesParams{
|
||||
ChatID: chat.ID,
|
||||
CreatedBy: []uuid.UUID{user.ID},
|
||||
ModelConfigID: []uuid.UUID{model.ID},
|
||||
Role: []database.ChatMessageRole{database.ChatMessageRoleAssistant},
|
||||
Content: []string{string(encoded.RawMessage)},
|
||||
ContentVersion: []int16{chatprompt.CurrentContentVersion},
|
||||
Visibility: []database.ChatMessageVisibility{database.ChatMessageVisibilityBoth},
|
||||
InputTokens: []int64{0},
|
||||
OutputTokens: []int64{0},
|
||||
TotalTokens: []int64{0},
|
||||
ReasoningTokens: []int64{0},
|
||||
CacheCreationTokens: []int64{0},
|
||||
CacheReadTokens: []int64{0},
|
||||
ContextLimit: []int64{0},
|
||||
Compressed: []bool{false},
|
||||
TotalCostMicros: []int64{0},
|
||||
RuntimeMs: []int64{0},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.Len(t, dbMsgs, 1)
|
||||
|
||||
readBack, err := db.GetChatMessageByID(ctx, dbMsgs[0].ID)
|
||||
require.NoError(t, err)
|
||||
|
||||
dbDecoded, err := chatprompt.ParseContent(readBack)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, dbDecoded, 1)
|
||||
require.Equal(t, tc.input, dbDecoded[0].Text)
|
||||
})
|
||||
}
|
||||
|
||||
// Tool result with NUL in the result JSON value.
|
||||
t.Run("ToolResultWithNul", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
resultJSON := json.RawMessage(`"output:\u0000done"`)
|
||||
parts := []codersdk.ChatMessagePart{
|
||||
codersdk.ChatMessageToolResult("call-1", "my_tool", resultJSON, false),
|
||||
}
|
||||
|
||||
encoded, err := chatprompt.MarshalParts(parts)
|
||||
require.NoError(t, err)
|
||||
require.NotContains(t, string(encoded.RawMessage), `\u0000`,
|
||||
"encoded JSON must not contain \\u0000")
|
||||
|
||||
msg := testMsgV1(codersdk.ChatMessageRoleTool, encoded)
|
||||
decoded, err := chatprompt.ParseContent(msg)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, decoded, 1)
|
||||
// JSON re-serialization may reformat, so compare
|
||||
// semantically.
|
||||
assert.JSONEq(t, string(resultJSON), string(decoded[0].Result))
|
||||
})
|
||||
|
||||
// Multiple parts in one message: one with NUL, one without.
|
||||
t.Run("MultiPartMixed", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
parts := []codersdk.ChatMessagePart{
|
||||
codersdk.ChatMessageText("clean text"),
|
||||
codersdk.ChatMessageText("has\x00nul"),
|
||||
}
|
||||
|
||||
encoded, err := chatprompt.MarshalParts(parts)
|
||||
require.NoError(t, err)
|
||||
require.NotContains(t, string(encoded.RawMessage), `\u0000`,
|
||||
"encoded JSON must not contain \\u0000")
|
||||
|
||||
msg := testMsgV1(codersdk.ChatMessageRoleAssistant, encoded)
|
||||
decoded, err := chatprompt.ParseContent(msg)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, decoded, 2)
|
||||
require.Equal(t, "clean text", decoded[0].Text)
|
||||
require.Equal(t, "has\x00nul", decoded[1].Text)
|
||||
})
|
||||
}
|
||||
|
||||
func TestConvertMessagesWithFiles_FiltersEmptyTextAndReasoningParts(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// Helper to build a DB message from SDK parts.
|
||||
makeMsg := func(t *testing.T, role database.ChatMessageRole, parts []codersdk.ChatMessagePart) database.ChatMessage {
|
||||
t.Helper()
|
||||
encoded, err := chatprompt.MarshalParts(parts)
|
||||
require.NoError(t, err)
|
||||
return database.ChatMessage{
|
||||
Role: role,
|
||||
Visibility: database.ChatMessageVisibilityBoth,
|
||||
Content: encoded,
|
||||
ContentVersion: chatprompt.CurrentContentVersion,
|
||||
}
|
||||
}
|
||||
|
||||
t.Run("UserRole", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
parts := []codersdk.ChatMessagePart{
|
||||
codersdk.ChatMessageText(""), // empty — filtered
|
||||
codersdk.ChatMessageText(" \t\n "), // whitespace — filtered
|
||||
codersdk.ChatMessageReasoning(""), // empty — filtered
|
||||
codersdk.ChatMessageReasoning(" \n"), // whitespace — filtered
|
||||
codersdk.ChatMessageText("hello"), // kept
|
||||
codersdk.ChatMessageText(" hello "), // kept with original whitespace
|
||||
codersdk.ChatMessageReasoning("thinking deeply"), // kept
|
||||
codersdk.ChatMessageToolCall("call-1", "my_tool", json.RawMessage(`{"x":1}`)),
|
||||
codersdk.ChatMessageToolResult("call-1", "my_tool", json.RawMessage(`{"ok":true}`), false),
|
||||
}
|
||||
|
||||
prompt, err := chatprompt.ConvertMessagesWithFiles(
|
||||
context.Background(),
|
||||
[]database.ChatMessage{makeMsg(t, database.ChatMessageRoleUser, parts)},
|
||||
nil,
|
||||
slogtest.Make(t, nil),
|
||||
)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, prompt, 1)
|
||||
|
||||
resultParts := prompt[0].Content
|
||||
require.Len(t, resultParts, 5, "expected 5 parts after filtering empty text/reasoning")
|
||||
|
||||
textPart, ok := fantasy.AsMessagePart[fantasy.TextPart](resultParts[0])
|
||||
require.True(t, ok, "expected TextPart at index 0")
|
||||
require.Equal(t, "hello", textPart.Text)
|
||||
|
||||
// Leading/trailing whitespace is preserved — only
|
||||
// all-whitespace parts are dropped.
|
||||
paddedPart, ok := fantasy.AsMessagePart[fantasy.TextPart](resultParts[1])
|
||||
require.True(t, ok, "expected TextPart at index 1")
|
||||
require.Equal(t, " hello ", paddedPart.Text)
|
||||
|
||||
reasoningPart, ok := fantasy.AsMessagePart[fantasy.ReasoningPart](resultParts[2])
|
||||
require.True(t, ok, "expected ReasoningPart at index 2")
|
||||
require.Equal(t, "thinking deeply", reasoningPart.Text)
|
||||
|
||||
toolCallPart, ok := fantasy.AsMessagePart[fantasy.ToolCallPart](resultParts[3])
|
||||
require.True(t, ok, "expected ToolCallPart at index 3")
|
||||
require.Equal(t, "call-1", toolCallPart.ToolCallID)
|
||||
|
||||
toolResultPart, ok := fantasy.AsMessagePart[fantasy.ToolResultPart](resultParts[4])
|
||||
require.True(t, ok, "expected ToolResultPart at index 4")
|
||||
require.Equal(t, "call-1", toolResultPart.ToolCallID)
|
||||
})
|
||||
|
||||
t.Run("AssistantRole", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
parts := []codersdk.ChatMessagePart{
|
||||
codersdk.ChatMessageText(""), // empty — filtered
|
||||
codersdk.ChatMessageText(" "), // whitespace — filtered
|
||||
codersdk.ChatMessageReasoning(""), // empty — filtered
|
||||
codersdk.ChatMessageText(" reply "), // kept with whitespace
|
||||
codersdk.ChatMessageToolCall("tc-1", "read_file", json.RawMessage(`{"path":"x"}`)),
|
||||
}
|
||||
|
||||
prompt, err := chatprompt.ConvertMessagesWithFiles(
|
||||
context.Background(),
|
||||
[]database.ChatMessage{makeMsg(t, database.ChatMessageRoleAssistant, parts)},
|
||||
nil,
|
||||
slogtest.Make(t, nil),
|
||||
)
|
||||
require.NoError(t, err)
|
||||
// 2 messages: assistant + synthetic tool result injected
|
||||
// by injectMissingToolResults for the unmatched tool call.
|
||||
require.Len(t, prompt, 2)
|
||||
|
||||
resultParts := prompt[0].Content
|
||||
require.Len(t, resultParts, 2, "expected text + tool-call after filtering")
|
||||
|
||||
textPart, ok := fantasy.AsMessagePart[fantasy.TextPart](resultParts[0])
|
||||
require.True(t, ok, "expected TextPart")
|
||||
require.Equal(t, " reply ", textPart.Text)
|
||||
|
||||
tcPart, ok := fantasy.AsMessagePart[fantasy.ToolCallPart](resultParts[1])
|
||||
require.True(t, ok, "expected ToolCallPart")
|
||||
require.Equal(t, "tc-1", tcPart.ToolCallID)
|
||||
})
|
||||
|
||||
t.Run("AllEmptyDropsMessage", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// When every part is filtered, the message itself should
|
||||
// be dropped rather than appending an empty-content message.
|
||||
parts := []codersdk.ChatMessagePart{
|
||||
codersdk.ChatMessageText(""),
|
||||
codersdk.ChatMessageText(" "),
|
||||
codersdk.ChatMessageReasoning(""),
|
||||
}
|
||||
|
||||
prompt, err := chatprompt.ConvertMessagesWithFiles(
|
||||
context.Background(),
|
||||
[]database.ChatMessage{makeMsg(t, database.ChatMessageRoleAssistant, parts)},
|
||||
nil,
|
||||
slogtest.Make(t, nil),
|
||||
)
|
||||
require.NoError(t, err)
|
||||
require.Empty(t, prompt, "all-empty message should be dropped entirely")
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1083,6 +1083,7 @@ func openAIProviderOptionsFromChatConfig(
|
||||
SafetyIdentifier: normalizedStringPointer(options.SafetyIdentifier),
|
||||
ServiceTier: openAIServiceTierFromChat(options.ServiceTier),
|
||||
StrictJSONSchema: options.StrictJSONSchema,
|
||||
Store: boolPtrOrDefault(options.Store, true),
|
||||
TextVerbosity: OpenAITextVerbosityFromChat(options.TextVerbosity),
|
||||
User: normalizedStringPointer(options.User),
|
||||
}
|
||||
@@ -1099,7 +1100,7 @@ func openAIProviderOptionsFromChatConfig(
|
||||
MaxCompletionTokens: options.MaxCompletionTokens,
|
||||
TextVerbosity: normalizedStringPointer(options.TextVerbosity),
|
||||
Prediction: options.Prediction,
|
||||
Store: options.Store,
|
||||
Store: boolPtrOrDefault(options.Store, true),
|
||||
Metadata: options.Metadata,
|
||||
PromptCacheKey: normalizedStringPointer(options.PromptCacheKey),
|
||||
SafetyIdentifier: normalizedStringPointer(options.SafetyIdentifier),
|
||||
@@ -1280,6 +1281,13 @@ func useOpenAIResponsesOptions(model fantasy.LanguageModel) bool {
|
||||
}
|
||||
}
|
||||
|
||||
func boolPtrOrDefault(value *bool, def bool) *bool {
|
||||
if value != nil {
|
||||
return value
|
||||
}
|
||||
return &def
|
||||
}
|
||||
|
||||
func normalizedStringPointer(value *string) *string {
|
||||
if value == nil {
|
||||
return nil
|
||||
|
||||
@@ -10,6 +10,7 @@ import (
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/coder/coder/v2/coderd/chatd/chatprovider"
|
||||
"github.com/coder/coder/v2/coderd/util/ptr"
|
||||
"github.com/coder/coder/v2/codersdk"
|
||||
)
|
||||
|
||||
@@ -25,37 +26,37 @@ func TestReasoningEffortFromChat(t *testing.T) {
|
||||
{
|
||||
name: "OpenAICaseInsensitive",
|
||||
provider: "openai",
|
||||
input: stringPtr(" HIGH "),
|
||||
want: stringPtr(string(fantasyopenai.ReasoningEffortHigh)),
|
||||
input: ptr.Ref(" HIGH "),
|
||||
want: ptr.Ref(string(fantasyopenai.ReasoningEffortHigh)),
|
||||
},
|
||||
{
|
||||
name: "AnthropicEffort",
|
||||
provider: "anthropic",
|
||||
input: stringPtr("max"),
|
||||
want: stringPtr(string(fantasyanthropic.EffortMax)),
|
||||
input: ptr.Ref("max"),
|
||||
want: ptr.Ref(string(fantasyanthropic.EffortMax)),
|
||||
},
|
||||
{
|
||||
name: "OpenRouterEffort",
|
||||
provider: "openrouter",
|
||||
input: stringPtr("medium"),
|
||||
want: stringPtr(string(fantasyopenrouter.ReasoningEffortMedium)),
|
||||
input: ptr.Ref("medium"),
|
||||
want: ptr.Ref(string(fantasyopenrouter.ReasoningEffortMedium)),
|
||||
},
|
||||
{
|
||||
name: "VercelEffort",
|
||||
provider: "vercel",
|
||||
input: stringPtr("xhigh"),
|
||||
want: stringPtr(string(fantasyvercel.ReasoningEffortXHigh)),
|
||||
input: ptr.Ref("xhigh"),
|
||||
want: ptr.Ref(string(fantasyvercel.ReasoningEffortXHigh)),
|
||||
},
|
||||
{
|
||||
name: "InvalidEffortReturnsNil",
|
||||
provider: "openai",
|
||||
input: stringPtr("unknown"),
|
||||
input: ptr.Ref("unknown"),
|
||||
want: nil,
|
||||
},
|
||||
{
|
||||
name: "UnsupportedProviderReturnsNil",
|
||||
provider: "bedrock",
|
||||
input: stringPtr("high"),
|
||||
input: ptr.Ref("high"),
|
||||
want: nil,
|
||||
},
|
||||
{
|
||||
@@ -82,8 +83,8 @@ func TestMergeMissingProviderOptions_OpenRouterNested(t *testing.T) {
|
||||
|
||||
options := &codersdk.ChatModelProviderOptions{
|
||||
OpenRouter: &codersdk.ChatModelOpenRouterProviderOptions{
|
||||
Reasoning: &codersdk.ChatModelOpenRouterReasoningOptions{
|
||||
Enabled: boolPtr(true),
|
||||
Reasoning: &codersdk.ChatModelReasoningOptions{
|
||||
Enabled: ptr.Ref(true),
|
||||
},
|
||||
Provider: &codersdk.ChatModelOpenRouterProvider{
|
||||
Order: []string{"openai"},
|
||||
@@ -92,22 +93,22 @@ func TestMergeMissingProviderOptions_OpenRouterNested(t *testing.T) {
|
||||
}
|
||||
defaults := &codersdk.ChatModelProviderOptions{
|
||||
OpenRouter: &codersdk.ChatModelOpenRouterProviderOptions{
|
||||
Reasoning: &codersdk.ChatModelOpenRouterReasoningOptions{
|
||||
Enabled: boolPtr(false),
|
||||
Exclude: boolPtr(true),
|
||||
MaxTokens: int64Ptr(123),
|
||||
Effort: stringPtr("high"),
|
||||
Reasoning: &codersdk.ChatModelReasoningOptions{
|
||||
Enabled: ptr.Ref(false),
|
||||
Exclude: ptr.Ref(true),
|
||||
MaxTokens: ptr.Ref[int64](123),
|
||||
Effort: ptr.Ref("high"),
|
||||
},
|
||||
IncludeUsage: boolPtr(true),
|
||||
IncludeUsage: ptr.Ref(true),
|
||||
Provider: &codersdk.ChatModelOpenRouterProvider{
|
||||
Order: []string{"anthropic"},
|
||||
AllowFallbacks: boolPtr(true),
|
||||
RequireParameters: boolPtr(false),
|
||||
DataCollection: stringPtr("allow"),
|
||||
AllowFallbacks: ptr.Ref(true),
|
||||
RequireParameters: ptr.Ref(false),
|
||||
DataCollection: ptr.Ref("allow"),
|
||||
Only: []string{"openai"},
|
||||
Ignore: []string{"foo"},
|
||||
Quantizations: []string{"int8"},
|
||||
Sort: stringPtr("latency"),
|
||||
Sort: ptr.Ref("latency"),
|
||||
},
|
||||
},
|
||||
}
|
||||
@@ -136,15 +137,3 @@ func TestMergeMissingProviderOptions_OpenRouterNested(t *testing.T) {
|
||||
require.Equal(t, []string{"int8"}, options.OpenRouter.Provider.Quantizations)
|
||||
require.Equal(t, "latency", *options.OpenRouter.Provider.Sort)
|
||||
}
|
||||
|
||||
func stringPtr(value string) *string {
|
||||
return &value
|
||||
}
|
||||
|
||||
func boolPtr(value bool) *bool {
|
||||
return &value
|
||||
}
|
||||
|
||||
func int64Ptr(value int64) *int64 {
|
||||
return &value
|
||||
}
|
||||
|
||||
@@ -243,7 +243,7 @@ func (s *anthropicServer) writeNonStreamingResponse(w http.ResponseWriter, resp
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.Header().Set("anthropic-version", "2023-06-01")
|
||||
if err := json.NewEncoder(w).Encode(response); err != nil {
|
||||
s.t.Logf("writeNonStreamingResponse: failed to encode response: %v", err)
|
||||
s.t.Errorf("writeNonStreamingResponse: failed to encode response: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -26,7 +26,7 @@ func writeErrorResponse(t testing.TB, w http.ResponseWriter, errResp *ErrorRespo
|
||||
},
|
||||
}
|
||||
if err := json.NewEncoder(w).Encode(body); err != nil {
|
||||
t.Logf("writeErrorResponse: failed to encode error response: %v", err)
|
||||
t.Errorf("writeErrorResponse: failed to encode error response: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -228,7 +228,7 @@ func (s *openAIServer) writeResponsesAPIResponse(w http.ResponseWriter, req *Ope
|
||||
http.Error(w, "handler returned streaming response for non-streaming request", http.StatusInternalServerError)
|
||||
return
|
||||
case hasStreaming:
|
||||
writeResponsesAPIStreaming(w, req.Request, resp.StreamingChunks)
|
||||
writeResponsesAPIStreaming(s.t, w, req.Request, resp.StreamingChunks)
|
||||
default:
|
||||
s.writeResponsesAPINonStreaming(w, resp.Response)
|
||||
}
|
||||
@@ -320,7 +320,7 @@ func writeSSEEvent(w http.ResponseWriter, v interface{}) error {
|
||||
return err
|
||||
}
|
||||
|
||||
func writeResponsesAPIStreaming(w http.ResponseWriter, r *http.Request, chunks <-chan OpenAIChunk) {
|
||||
func writeResponsesAPIStreaming(t testing.TB, w http.ResponseWriter, r *http.Request, chunks <-chan OpenAIChunk) {
|
||||
w.Header().Set("Content-Type", "text/event-stream")
|
||||
w.Header().Set("Cache-Control", "no-cache")
|
||||
w.Header().Set("Connection", "keep-alive")
|
||||
@@ -351,6 +351,7 @@ func writeResponsesAPIStreaming(w http.ResponseWriter, r *http.Request, chunks <
|
||||
ItemID: itemID,
|
||||
OutputIndex: int64(outputIndex),
|
||||
}); err != nil {
|
||||
t.Logf("writeResponsesAPIStreaming: failed to write ResponseTextDoneEvent: %v", err)
|
||||
return
|
||||
}
|
||||
if err := writeSSEEvent(w, responses.ResponseOutputItemDoneEvent{
|
||||
@@ -360,10 +361,12 @@ func writeResponsesAPIStreaming(w http.ResponseWriter, r *http.Request, chunks <
|
||||
Type: "message",
|
||||
},
|
||||
}); err != nil {
|
||||
t.Logf("writeResponsesAPIStreaming: failed to write ResponseOutputItemDoneEvent: %v", err)
|
||||
return
|
||||
}
|
||||
}
|
||||
if err := writeSSEEvent(w, responses.ResponseCompletedEvent{}); err != nil {
|
||||
t.Logf("writeResponsesAPIStreaming: failed to write ResponseCompletedEvent: %v", err)
|
||||
return
|
||||
}
|
||||
flusher.Flush()
|
||||
@@ -390,6 +393,7 @@ func writeResponsesAPIStreaming(w http.ResponseWriter, r *http.Request, chunks <
|
||||
Type: "message",
|
||||
},
|
||||
}); err != nil {
|
||||
t.Logf("writeResponsesAPIStreaming: failed to write ResponseOutputItemAddedEvent: %v", err)
|
||||
return
|
||||
}
|
||||
flusher.Flush()
|
||||
@@ -407,10 +411,12 @@ func writeResponsesAPIStreaming(w http.ResponseWriter, r *http.Request, chunks <
|
||||
|
||||
chunkBytes, err := json.Marshal(chunkData)
|
||||
if err != nil {
|
||||
t.Logf("writeResponsesAPIStreaming: failed to marshal chunk data: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
if _, err := fmt.Fprintf(w, "data: %s\n\n", chunkBytes); err != nil {
|
||||
t.Logf("writeResponsesAPIStreaming: failed to write chunk data: %v", err)
|
||||
return
|
||||
}
|
||||
flusher.Flush()
|
||||
@@ -421,7 +427,7 @@ func writeResponsesAPIStreaming(w http.ResponseWriter, r *http.Request, chunks <
|
||||
func (s *openAIServer) writeChatCompletionsNonStreaming(w http.ResponseWriter, resp *OpenAICompletion) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
if err := json.NewEncoder(w).Encode(resp); err != nil {
|
||||
s.t.Logf("writeChatCompletionsNonStreaming: failed to encode response: %v", err)
|
||||
s.t.Errorf("writeChatCompletionsNonStreaming: failed to encode response: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -452,7 +458,7 @@ func (s *openAIServer) writeResponsesAPINonStreaming(w http.ResponseWriter, resp
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
if err := json.NewEncoder(w).Encode(response); err != nil {
|
||||
s.t.Logf("writeResponsesAPINonStreaming: failed to encode response: %v", err)
|
||||
s.t.Errorf("writeResponsesAPINonStreaming: failed to encode response: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
220
coderd/chatd/chattool/computeruse.go
Normal file
220
coderd/chatd/chattool/computeruse.go
Normal file
@@ -0,0 +1,220 @@
|
||||
package chattool
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"math"
|
||||
"time"
|
||||
|
||||
"charm.land/fantasy"
|
||||
fantasyanthropic "charm.land/fantasy/providers/anthropic"
|
||||
|
||||
"github.com/coder/coder/v2/codersdk/workspacesdk"
|
||||
"github.com/coder/quartz"
|
||||
)
|
||||
|
||||
const (
|
||||
// ComputerUseModelProvider is the provider for the computer
|
||||
// use model.
|
||||
ComputerUseModelProvider = "anthropic"
|
||||
// ComputerUseModelName is the model used for computer use
|
||||
// subagents.
|
||||
ComputerUseModelName = "claude-opus-4-6"
|
||||
)
|
||||
|
||||
// computerUseTool implements fantasy.AgentTool and
|
||||
// chatloop.ToolDefiner for Anthropic computer use.
|
||||
type computerUseTool struct {
|
||||
displayWidth int
|
||||
displayHeight int
|
||||
getWorkspaceConn func(ctx context.Context) (workspacesdk.AgentConn, error)
|
||||
providerOptions fantasy.ProviderOptions
|
||||
clock quartz.Clock
|
||||
}
|
||||
|
||||
// NewComputerUseTool creates a computer use AgentTool that
|
||||
// delegates to the agent's desktop endpoints.
|
||||
func NewComputerUseTool(
|
||||
displayWidth, displayHeight int,
|
||||
getWorkspaceConn func(ctx context.Context) (workspacesdk.AgentConn, error),
|
||||
clock quartz.Clock,
|
||||
) fantasy.AgentTool {
|
||||
return &computerUseTool{
|
||||
displayWidth: displayWidth,
|
||||
displayHeight: displayHeight,
|
||||
getWorkspaceConn: getWorkspaceConn,
|
||||
clock: clock,
|
||||
}
|
||||
}
|
||||
|
||||
func (*computerUseTool) Info() fantasy.ToolInfo {
|
||||
return fantasy.ToolInfo{
|
||||
Name: "computer",
|
||||
Description: "Control the desktop: take screenshots, move the mouse, click, type, and scroll.",
|
||||
Parameters: map[string]any{},
|
||||
Required: []string{},
|
||||
}
|
||||
}
|
||||
|
||||
// ComputerUseProviderTool creates the provider-defined tool
|
||||
// definition for Anthropic computer use. This is passed via
|
||||
// ProviderTools so the API receives the correct wire format.
|
||||
func ComputerUseProviderTool(displayWidth, displayHeight int) fantasy.Tool {
|
||||
return fantasyanthropic.NewComputerUseTool(
|
||||
fantasyanthropic.ComputerUseToolOptions{
|
||||
DisplayWidthPx: int64(displayWidth),
|
||||
DisplayHeightPx: int64(displayHeight),
|
||||
ToolVersion: fantasyanthropic.ComputerUse20251124,
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
func (t *computerUseTool) ProviderOptions() fantasy.ProviderOptions {
|
||||
return t.providerOptions
|
||||
}
|
||||
|
||||
func (t *computerUseTool) SetProviderOptions(opts fantasy.ProviderOptions) {
|
||||
t.providerOptions = opts
|
||||
}
|
||||
|
||||
func (t *computerUseTool) Run(ctx context.Context, call fantasy.ToolCall) (fantasy.ToolResponse, error) {
|
||||
input, err := fantasyanthropic.ParseComputerUseInput(call.Input)
|
||||
if err != nil {
|
||||
return fantasy.NewTextErrorResponse(
|
||||
fmt.Sprintf("invalid computer use input: %v", err),
|
||||
), nil
|
||||
}
|
||||
|
||||
conn, err := t.getWorkspaceConn(ctx)
|
||||
if err != nil {
|
||||
return fantasy.NewTextErrorResponse(
|
||||
fmt.Sprintf("failed to connect to workspace: %v", err),
|
||||
), nil
|
||||
}
|
||||
|
||||
// Compute scaled screenshot size for Anthropic constraints.
|
||||
scaledW, scaledH := computeScaledScreenshotSize(
|
||||
t.displayWidth, t.displayHeight,
|
||||
)
|
||||
|
||||
// For wait actions, sleep then return a screenshot.
|
||||
if input.Action == fantasyanthropic.ActionWait {
|
||||
d := input.Duration
|
||||
if d <= 0 {
|
||||
d = 1000
|
||||
}
|
||||
timer := t.clock.NewTimer(time.Duration(d)*time.Millisecond, "computeruse", "wait")
|
||||
defer timer.Stop()
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
case <-timer.C:
|
||||
}
|
||||
screenshotAction := workspacesdk.DesktopAction{
|
||||
Action: "screenshot",
|
||||
ScaledWidth: &scaledW,
|
||||
ScaledHeight: &scaledH,
|
||||
}
|
||||
screenResp, sErr := conn.ExecuteDesktopAction(ctx, screenshotAction)
|
||||
if sErr != nil {
|
||||
return fantasy.NewTextErrorResponse(
|
||||
fmt.Sprintf("screenshot failed: %v", sErr),
|
||||
), nil
|
||||
}
|
||||
return fantasy.NewImageResponse(
|
||||
[]byte(screenResp.ScreenshotData), "image/png",
|
||||
), nil
|
||||
}
|
||||
|
||||
// For screenshot action, use ExecuteDesktopAction.
|
||||
if input.Action == fantasyanthropic.ActionScreenshot {
|
||||
screenshotAction := workspacesdk.DesktopAction{
|
||||
Action: "screenshot",
|
||||
ScaledWidth: &scaledW,
|
||||
ScaledHeight: &scaledH,
|
||||
}
|
||||
screenResp, sErr := conn.ExecuteDesktopAction(ctx, screenshotAction)
|
||||
if sErr != nil {
|
||||
return fantasy.NewTextErrorResponse(
|
||||
fmt.Sprintf("screenshot failed: %v", sErr),
|
||||
), nil
|
||||
}
|
||||
return fantasy.NewImageResponse(
|
||||
[]byte(screenResp.ScreenshotData), "image/png",
|
||||
), nil
|
||||
}
|
||||
|
||||
// Build the action request.
|
||||
action := workspacesdk.DesktopAction{
|
||||
Action: string(input.Action),
|
||||
ScaledWidth: &scaledW,
|
||||
ScaledHeight: &scaledH,
|
||||
}
|
||||
if input.Coordinate != ([2]int64{}) {
|
||||
coord := [2]int{int(input.Coordinate[0]), int(input.Coordinate[1])}
|
||||
action.Coordinate = &coord
|
||||
}
|
||||
if input.StartCoordinate != ([2]int64{}) {
|
||||
coord := [2]int{int(input.StartCoordinate[0]), int(input.StartCoordinate[1])}
|
||||
action.StartCoordinate = &coord
|
||||
}
|
||||
if input.Text != "" {
|
||||
action.Text = &input.Text
|
||||
}
|
||||
if input.Duration > 0 {
|
||||
d := int(input.Duration)
|
||||
action.Duration = &d
|
||||
}
|
||||
if input.ScrollAmount > 0 {
|
||||
s := int(input.ScrollAmount)
|
||||
action.ScrollAmount = &s
|
||||
}
|
||||
if input.ScrollDirection != "" {
|
||||
action.ScrollDirection = &input.ScrollDirection
|
||||
}
|
||||
|
||||
// Execute the action.
|
||||
_, err = conn.ExecuteDesktopAction(ctx, action)
|
||||
if err != nil {
|
||||
return fantasy.NewTextErrorResponse(
|
||||
fmt.Sprintf("action %q failed: %v", input.Action, err),
|
||||
), nil
|
||||
}
|
||||
|
||||
// Take a screenshot after every action (Anthropic pattern).
|
||||
screenshotAction := workspacesdk.DesktopAction{
|
||||
Action: "screenshot",
|
||||
ScaledWidth: &scaledW,
|
||||
ScaledHeight: &scaledH,
|
||||
}
|
||||
screenResp, sErr := conn.ExecuteDesktopAction(ctx, screenshotAction)
|
||||
if sErr != nil {
|
||||
return fantasy.NewTextErrorResponse(
|
||||
fmt.Sprintf("screenshot failed: %v", sErr),
|
||||
), nil
|
||||
}
|
||||
|
||||
return fantasy.NewImageResponse(
|
||||
[]byte(screenResp.ScreenshotData), "image/png",
|
||||
), nil
|
||||
}
|
||||
|
||||
// computeScaledScreenshotSize computes the target screenshot
|
||||
// dimensions to fit within Anthropic's constraints.
|
||||
func computeScaledScreenshotSize(width, height int) (scaledWidth int, scaledHeight int) {
|
||||
const maxLongEdge = 1568
|
||||
const maxTotalPixels = 1_150_000
|
||||
|
||||
longEdge := max(width, height)
|
||||
totalPixels := width * height
|
||||
longEdgeScale := float64(maxLongEdge) / float64(longEdge)
|
||||
totalPixelsScale := math.Sqrt(
|
||||
float64(maxTotalPixels) / float64(totalPixels),
|
||||
)
|
||||
scale := min(1.0, longEdgeScale, totalPixelsScale)
|
||||
|
||||
if scale >= 1.0 {
|
||||
return width, height
|
||||
}
|
||||
return max(1, int(float64(width)*scale)),
|
||||
max(1, int(float64(height)*scale))
|
||||
}
|
||||
81
coderd/chatd/chattool/computeruse_internal_test.go
Normal file
81
coderd/chatd/chattool/computeruse_internal_test.go
Normal file
@@ -0,0 +1,81 @@
|
||||
package chattool
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestComputeScaledScreenshotSize(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
width, height int
|
||||
wantW, wantH int
|
||||
}{
|
||||
{
|
||||
name: "1920x1080_scales_down",
|
||||
width: 1920,
|
||||
height: 1080,
|
||||
wantW: 1429,
|
||||
wantH: 804,
|
||||
},
|
||||
{
|
||||
name: "1280x800_no_scaling",
|
||||
width: 1280,
|
||||
height: 800,
|
||||
wantW: 1280,
|
||||
wantH: 800,
|
||||
},
|
||||
{
|
||||
name: "3840x2160_large_display",
|
||||
width: 3840,
|
||||
height: 2160,
|
||||
wantW: 1429,
|
||||
wantH: 804,
|
||||
},
|
||||
{
|
||||
name: "1568x1000_pixel_cap_applies",
|
||||
width: 1568,
|
||||
height: 1000,
|
||||
wantW: 1342,
|
||||
wantH: 856,
|
||||
},
|
||||
{
|
||||
name: "100x100_small_display",
|
||||
width: 100,
|
||||
height: 100,
|
||||
wantW: 100,
|
||||
wantH: 100,
|
||||
},
|
||||
{
|
||||
name: "4000x3000_stays_within_limits",
|
||||
width: 4000,
|
||||
// Both constraints apply. The function should keep
|
||||
// the result within maxLongEdge=1568 and
|
||||
// totalPixels<=1,150,000.
|
||||
height: 3000,
|
||||
wantW: 1238,
|
||||
wantH: 928,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
gotW, gotH := computeScaledScreenshotSize(tt.width, tt.height)
|
||||
assert.Equal(t, tt.wantW, gotW)
|
||||
assert.Equal(t, tt.wantH, gotH)
|
||||
|
||||
// Invariant: results must respect Anthropic constraints.
|
||||
const maxLongEdge = 1568
|
||||
const maxTotalPixels = 1_150_000
|
||||
longEdge := max(gotW, gotH)
|
||||
assert.LessOrEqual(t, longEdge, maxLongEdge,
|
||||
"long edge %d exceeds max %d", longEdge, maxLongEdge)
|
||||
assert.LessOrEqual(t, gotW*gotH, maxTotalPixels,
|
||||
"total pixels %d exceeds max %d", gotW*gotH, maxTotalPixels)
|
||||
})
|
||||
}
|
||||
}
|
||||
186
coderd/chatd/chattool/computeruse_test.go
Normal file
186
coderd/chatd/chattool/computeruse_test.go
Normal file
@@ -0,0 +1,186 @@
|
||||
package chattool_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"charm.land/fantasy"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"go.uber.org/mock/gomock"
|
||||
"golang.org/x/xerrors"
|
||||
|
||||
"github.com/coder/coder/v2/coderd/chatd/chattool"
|
||||
"github.com/coder/coder/v2/codersdk/workspacesdk"
|
||||
"github.com/coder/coder/v2/codersdk/workspacesdk/agentconnmock"
|
||||
"github.com/coder/quartz"
|
||||
)
|
||||
|
||||
func TestComputerUseTool_Info(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tool := chattool.NewComputerUseTool(workspacesdk.DesktopDisplayWidth, workspacesdk.DesktopDisplayHeight, nil, quartz.NewReal())
|
||||
info := tool.Info()
|
||||
assert.Equal(t, "computer", info.Name)
|
||||
assert.NotEmpty(t, info.Description)
|
||||
}
|
||||
|
||||
func TestComputerUseProviderTool(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
def := chattool.ComputerUseProviderTool(workspacesdk.DesktopDisplayWidth, workspacesdk.DesktopDisplayHeight)
|
||||
pdt, ok := def.(fantasy.ProviderDefinedTool)
|
||||
require.True(t, ok, "ComputerUseProviderTool should return a ProviderDefinedTool")
|
||||
assert.Contains(t, pdt.ID, "computer")
|
||||
assert.Equal(t, "computer", pdt.Name)
|
||||
// Verify display dimensions are passed through.
|
||||
assert.Equal(t, int64(workspacesdk.DesktopDisplayWidth), pdt.Args["display_width_px"])
|
||||
assert.Equal(t, int64(workspacesdk.DesktopDisplayHeight), pdt.Args["display_height_px"])
|
||||
}
|
||||
|
||||
func TestComputerUseTool_Run_Screenshot(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
ctrl := gomock.NewController(t)
|
||||
mockConn := agentconnmock.NewMockAgentConn(ctrl)
|
||||
|
||||
mockConn.EXPECT().ExecuteDesktopAction(
|
||||
gomock.Any(),
|
||||
gomock.Any(),
|
||||
).Return(workspacesdk.DesktopActionResponse{
|
||||
Output: "screenshot",
|
||||
ScreenshotData: "base64png",
|
||||
ScreenshotWidth: 1024,
|
||||
ScreenshotHeight: 768,
|
||||
}, nil)
|
||||
|
||||
tool := chattool.NewComputerUseTool(workspacesdk.DesktopDisplayWidth, workspacesdk.DesktopDisplayHeight, func(_ context.Context) (workspacesdk.AgentConn, error) {
|
||||
return mockConn, nil
|
||||
}, quartz.NewReal())
|
||||
|
||||
call := fantasy.ToolCall{
|
||||
ID: "test-1",
|
||||
Name: "computer",
|
||||
Input: `{"action":"screenshot"}`,
|
||||
}
|
||||
|
||||
resp, err := tool.Run(context.Background(), call)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "image", resp.Type)
|
||||
assert.Equal(t, "image/png", resp.MediaType)
|
||||
assert.Equal(t, []byte("base64png"), resp.Data)
|
||||
assert.False(t, resp.IsError)
|
||||
}
|
||||
|
||||
func TestComputerUseTool_Run_LeftClick(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
ctrl := gomock.NewController(t)
|
||||
mockConn := agentconnmock.NewMockAgentConn(ctrl)
|
||||
|
||||
// Expect the action call first.
|
||||
mockConn.EXPECT().ExecuteDesktopAction(
|
||||
gomock.Any(),
|
||||
gomock.Any(),
|
||||
).Return(workspacesdk.DesktopActionResponse{
|
||||
Output: "left_click performed",
|
||||
}, nil)
|
||||
|
||||
// Then expect a screenshot (auto-screenshot after action).
|
||||
mockConn.EXPECT().ExecuteDesktopAction(
|
||||
gomock.Any(),
|
||||
gomock.Any(),
|
||||
).Return(workspacesdk.DesktopActionResponse{
|
||||
Output: "screenshot",
|
||||
ScreenshotData: "after-click",
|
||||
ScreenshotWidth: 1024,
|
||||
ScreenshotHeight: 768,
|
||||
}, nil)
|
||||
|
||||
tool := chattool.NewComputerUseTool(workspacesdk.DesktopDisplayWidth, workspacesdk.DesktopDisplayHeight, func(_ context.Context) (workspacesdk.AgentConn, error) {
|
||||
return mockConn, nil
|
||||
}, quartz.NewReal())
|
||||
|
||||
call := fantasy.ToolCall{
|
||||
ID: "test-2",
|
||||
Name: "computer",
|
||||
Input: `{"action":"left_click","coordinate":[100,200]}`,
|
||||
}
|
||||
|
||||
resp, err := tool.Run(context.Background(), call)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "image", resp.Type)
|
||||
assert.Equal(t, []byte("after-click"), resp.Data)
|
||||
}
|
||||
|
||||
func TestComputerUseTool_Run_Wait(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
ctrl := gomock.NewController(t)
|
||||
mockConn := agentconnmock.NewMockAgentConn(ctrl)
|
||||
// Expect a screenshot after the wait completes.
|
||||
mockConn.EXPECT().ExecuteDesktopAction(
|
||||
gomock.Any(),
|
||||
gomock.Any(),
|
||||
).Return(workspacesdk.DesktopActionResponse{
|
||||
Output: "screenshot",
|
||||
ScreenshotData: "after-wait",
|
||||
ScreenshotWidth: 1024,
|
||||
ScreenshotHeight: 768,
|
||||
}, nil)
|
||||
|
||||
tool := chattool.NewComputerUseTool(workspacesdk.DesktopDisplayWidth, workspacesdk.DesktopDisplayHeight, func(_ context.Context) (workspacesdk.AgentConn, error) {
|
||||
return mockConn, nil
|
||||
}, quartz.NewReal())
|
||||
|
||||
call := fantasy.ToolCall{
|
||||
ID: "test-3",
|
||||
Name: "computer",
|
||||
Input: `{"action":"wait","duration":10}`,
|
||||
}
|
||||
|
||||
resp, err := tool.Run(context.Background(), call)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "image", resp.Type)
|
||||
assert.Equal(t, "image/png", resp.MediaType)
|
||||
assert.Equal(t, []byte("after-wait"), resp.Data)
|
||||
assert.False(t, resp.IsError)
|
||||
}
|
||||
|
||||
func TestComputerUseTool_Run_ConnError(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tool := chattool.NewComputerUseTool(workspacesdk.DesktopDisplayWidth, workspacesdk.DesktopDisplayHeight, func(_ context.Context) (workspacesdk.AgentConn, error) {
|
||||
return nil, xerrors.New("workspace not available")
|
||||
}, quartz.NewReal())
|
||||
|
||||
call := fantasy.ToolCall{
|
||||
ID: "test-4",
|
||||
Name: "computer",
|
||||
Input: `{"action":"screenshot"}`,
|
||||
}
|
||||
|
||||
resp, err := tool.Run(context.Background(), call)
|
||||
require.NoError(t, err)
|
||||
assert.True(t, resp.IsError)
|
||||
assert.Contains(t, resp.Content, "workspace not available")
|
||||
}
|
||||
|
||||
func TestComputerUseTool_Run_InvalidInput(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tool := chattool.NewComputerUseTool(workspacesdk.DesktopDisplayWidth, workspacesdk.DesktopDisplayHeight, func(_ context.Context) (workspacesdk.AgentConn, error) {
|
||||
return nil, xerrors.New("should not be called")
|
||||
}, quartz.NewReal())
|
||||
|
||||
call := fantasy.ToolCall{
|
||||
ID: "test-5",
|
||||
Name: "computer",
|
||||
Input: `{invalid json`,
|
||||
}
|
||||
|
||||
resp, err := tool.Run(context.Background(), call)
|
||||
require.NoError(t, err)
|
||||
assert.True(t, resp.IsError)
|
||||
assert.Contains(t, resp.Content, "invalid computer use input")
|
||||
}
|
||||
@@ -201,7 +201,7 @@ func CreateWorkspace(options CreateWorkspaceOptions) fantasy.AgentTool {
|
||||
Valid: true,
|
||||
},
|
||||
}); err != nil {
|
||||
options.Logger.Warn(ctx, "failed to persist chat workspace association",
|
||||
options.Logger.Error(ctx, "failed to persist chat workspace association",
|
||||
slog.F("chat_id", options.ChatID),
|
||||
slog.F("workspace_id", workspace.ID),
|
||||
slog.Error(err),
|
||||
|
||||
@@ -21,9 +21,10 @@ const (
|
||||
// maxOutputToModel is the maximum output sent to the LLM.
|
||||
maxOutputToModel = 32 << 10 // 32KB
|
||||
|
||||
// pollInterval is how often we check for process completion
|
||||
// in foreground mode.
|
||||
pollInterval = 200 * time.Millisecond
|
||||
// snapshotTimeout is how long a non-blocking fallback
|
||||
// request is allowed to take when retrieving a process
|
||||
// output snapshot after a blocking wait times out.
|
||||
snapshotTimeout = 30 * time.Second
|
||||
)
|
||||
|
||||
// nonInteractiveEnvVars are set on every process to prevent
|
||||
@@ -76,10 +77,10 @@ type ProcessToolOptions struct {
|
||||
|
||||
// ExecuteArgs are the parameters accepted by the execute tool.
|
||||
type ExecuteArgs struct {
|
||||
Command string `json:"command"`
|
||||
Timeout *string `json:"timeout,omitempty"`
|
||||
WorkDir *string `json:"workdir,omitempty"`
|
||||
RunInBackground *bool `json:"run_in_background,omitempty"`
|
||||
Command string `json:"command" description:"The shell command to execute."`
|
||||
Timeout *string `json:"timeout,omitempty" description:"Timeout duration (e.g. '30s', '5m'). Default is 10s. Only applies to foreground commands."`
|
||||
WorkDir *string `json:"workdir,omitempty" description:"Working directory for the command."`
|
||||
RunInBackground *bool `json:"run_in_background,omitempty" description:"Run this command in the background without blocking. Use for long-running processes like dev servers, file watchers, or builds that run longer than 5 seconds. Do NOT use shell & to background processes — it will not work correctly. Always use this parameter instead."`
|
||||
}
|
||||
|
||||
// Execute returns an AgentTool that runs a shell command in the
|
||||
@@ -87,7 +88,7 @@ type ExecuteArgs struct {
|
||||
func Execute(options ExecuteOptions) fantasy.AgentTool {
|
||||
return fantasy.NewAgentTool(
|
||||
"execute",
|
||||
"Execute a shell command in the workspace.",
|
||||
"Execute a shell command in the workspace. Use run_in_background=true for long-running processes (dev servers, file watchers, builds). Never use shell '&' for backgrounding. If the command times out, the response includes a background_process_id so you can retrieve output later with process_output.",
|
||||
func(ctx context.Context, args ExecuteArgs, _ fantasy.ToolCall) (fantasy.ToolResponse, error) {
|
||||
if options.GetWorkspaceConn == nil {
|
||||
return fantasy.NewTextErrorResponse("workspace connection resolver is not configured"), nil
|
||||
@@ -120,6 +121,16 @@ func executeTool(
|
||||
|
||||
background := args.RunInBackground != nil && *args.RunInBackground
|
||||
|
||||
// Detect shell-style backgrounding (trailing &) and promote to
|
||||
// background mode. Models sometimes use "cmd &" instead of the
|
||||
// run_in_background parameter, which causes the shell to fork
|
||||
// and exit immediately, leaving an untracked orphan process.
|
||||
trimmed := strings.TrimSpace(args.Command)
|
||||
if !background && strings.HasSuffix(trimmed, "&") && !strings.HasSuffix(trimmed, "&&") && !strings.HasSuffix(trimmed, "|&") {
|
||||
background = true
|
||||
args.Command = strings.TrimSpace(strings.TrimSuffix(trimmed, "&"))
|
||||
}
|
||||
|
||||
var workDir string
|
||||
if args.WorkDir != nil {
|
||||
workDir = *args.WorkDir
|
||||
@@ -161,7 +172,7 @@ func executeBackground(
|
||||
return fantasy.NewTextResponse(string(data))
|
||||
}
|
||||
|
||||
// executeForeground starts a process and polls for its
|
||||
// executeForeground starts a process and waits for its
|
||||
// completion, enforcing the configured timeout.
|
||||
func executeForeground(
|
||||
ctx context.Context,
|
||||
@@ -200,7 +211,7 @@ func executeForeground(
|
||||
return errorResult(fmt.Sprintf("start process: %v", err))
|
||||
}
|
||||
|
||||
result := pollProcess(cmdCtx, conn, resp.ID, timeout)
|
||||
result := waitForProcess(cmdCtx, conn, resp.ID, timeout)
|
||||
result.WallDurationMs = time.Since(start).Milliseconds()
|
||||
|
||||
// Add an advisory note for file-dump commands.
|
||||
@@ -225,62 +236,84 @@ func truncateOutput(output string) string {
|
||||
return output
|
||||
}
|
||||
|
||||
// pollProcess polls for process output until the process exits
|
||||
// or the context times out.
|
||||
func pollProcess(
|
||||
// waitForProcess waits for process completion using the
|
||||
// blocking process output API instead of polling.
|
||||
func waitForProcess(
|
||||
ctx context.Context,
|
||||
conn workspacesdk.AgentConn,
|
||||
processID string,
|
||||
timeout time.Duration,
|
||||
) ExecuteResult {
|
||||
ticker := time.NewTicker(pollInterval)
|
||||
defer ticker.Stop()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
// Timeout — get whatever output we have. Use a
|
||||
// fresh context since cmdCtx is already canceled.
|
||||
// Block until the process exits or the context is
|
||||
// canceled.
|
||||
resp, err := conn.ProcessOutput(ctx, processID, &workspacesdk.ProcessOutputOptions{
|
||||
Wait: true,
|
||||
})
|
||||
if err != nil {
|
||||
if ctx.Err() != nil {
|
||||
// Timeout: fetch final snapshot with a fresh
|
||||
// context. The blocking request was canceled
|
||||
// so the response body was lost.
|
||||
bgCtx, bgCancel := context.WithTimeout(
|
||||
context.Background(),
|
||||
5*time.Second,
|
||||
snapshotTimeout,
|
||||
)
|
||||
outputResp, outputErr := conn.ProcessOutput(bgCtx, processID)
|
||||
bgCancel()
|
||||
output := truncateOutput(outputResp.Output)
|
||||
timeoutMsg := fmt.Sprintf("command timed out after %s", timeout)
|
||||
if outputErr != nil {
|
||||
timeoutMsg += fmt.Sprintf(" (failed to get output: %v)", outputErr)
|
||||
}
|
||||
return ExecuteResult{
|
||||
Success: false,
|
||||
Output: output,
|
||||
ExitCode: -1,
|
||||
Error: timeoutMsg,
|
||||
Truncated: outputResp.Truncated,
|
||||
}
|
||||
case <-ticker.C:
|
||||
outputResp, err := conn.ProcessOutput(ctx, processID)
|
||||
defer bgCancel()
|
||||
resp, err = conn.ProcessOutput(bgCtx, processID, nil)
|
||||
if err != nil {
|
||||
return ExecuteResult{
|
||||
Success: false,
|
||||
Error: fmt.Sprintf("get process output: %v", err),
|
||||
Success: false,
|
||||
ExitCode: -1,
|
||||
Error: fmt.Sprintf("command timed out after %s; failed to get output: %v", timeout, err),
|
||||
BackgroundProcessID: processID,
|
||||
}
|
||||
}
|
||||
if !outputResp.Running {
|
||||
exitCode := 0
|
||||
if outputResp.ExitCode != nil {
|
||||
exitCode = *outputResp.ExitCode
|
||||
}
|
||||
output := truncateOutput(outputResp.Output)
|
||||
return ExecuteResult{
|
||||
Success: exitCode == 0,
|
||||
Output: output,
|
||||
ExitCode: exitCode,
|
||||
Truncated: outputResp.Truncated,
|
||||
}
|
||||
output := truncateOutput(resp.Output)
|
||||
return ExecuteResult{
|
||||
Success: false,
|
||||
Output: output,
|
||||
ExitCode: -1,
|
||||
Error: fmt.Sprintf("command timed out after %s", timeout),
|
||||
Truncated: resp.Truncated,
|
||||
BackgroundProcessID: processID,
|
||||
}
|
||||
}
|
||||
return ExecuteResult{
|
||||
Success: false,
|
||||
Error: fmt.Sprintf("get process output: %v", err),
|
||||
}
|
||||
}
|
||||
|
||||
// The server-side wait may return before the
|
||||
// process exits if maxWaitDuration is shorter than
|
||||
// the client's timeout. Retry if our context still
|
||||
// has time left.
|
||||
if resp.Running {
|
||||
if ctx.Err() == nil {
|
||||
// Still within the caller's timeout, retry.
|
||||
return waitForProcess(ctx, conn, processID, timeout)
|
||||
}
|
||||
output := truncateOutput(resp.Output)
|
||||
return ExecuteResult{
|
||||
Success: false,
|
||||
Output: output,
|
||||
ExitCode: -1,
|
||||
Error: fmt.Sprintf("command timed out after %s", timeout),
|
||||
Truncated: resp.Truncated,
|
||||
BackgroundProcessID: processID,
|
||||
}
|
||||
}
|
||||
|
||||
exitCode := 0
|
||||
if resp.ExitCode != nil {
|
||||
exitCode = *resp.ExitCode
|
||||
}
|
||||
output := truncateOutput(resp.Output)
|
||||
return ExecuteResult{
|
||||
Success: exitCode == 0,
|
||||
Output: output,
|
||||
ExitCode: exitCode,
|
||||
Truncated: resp.Truncated,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -310,10 +343,19 @@ func detectFileDump(command string) string {
|
||||
return ""
|
||||
}
|
||||
|
||||
const (
|
||||
// defaultProcessOutputTimeout is the default time the
|
||||
// process_output tool blocks waiting for new output or
|
||||
// process exit before returning. This avoids polling
|
||||
// loops that waste tokens and HTTP round-trips.
|
||||
defaultProcessOutputTimeout = 10 * time.Second
|
||||
)
|
||||
|
||||
// ProcessOutputArgs are the parameters accepted by the
|
||||
// process_output tool.
|
||||
type ProcessOutputArgs struct {
|
||||
ProcessID string `json:"process_id"`
|
||||
ProcessID string `json:"process_id"`
|
||||
WaitTimeout *string `json:"wait_timeout,omitempty" description:"Override the default 10s block duration. The call blocks until the process exits or this timeout is reached. Set to '0s' for an immediate snapshot without waiting."`
|
||||
}
|
||||
|
||||
// ProcessOutput returns an AgentTool that retrieves the output
|
||||
@@ -323,9 +365,13 @@ func ProcessOutput(options ProcessToolOptions) fantasy.AgentTool {
|
||||
"process_output",
|
||||
"Retrieve output from a background process. "+
|
||||
"Use the process_id returned by execute with "+
|
||||
"run_in_background=true. Returns the current output, "+
|
||||
"whether the process is still running, and the exit "+
|
||||
"code if it has finished.",
|
||||
"run_in_background=true or from a timed-out "+
|
||||
"execute's background_process_id. Blocks up to "+
|
||||
"10s for the process to exit, then returns the "+
|
||||
"output and exit_code. If still running after "+
|
||||
"the timeout, returns the output so far. Use "+
|
||||
"wait_timeout to override the default 10s wait "+
|
||||
"(e.g. '30s', or '0s' for an immediate snapshot).",
|
||||
func(ctx context.Context, args ProcessOutputArgs, _ fantasy.ToolCall) (fantasy.ToolResponse, error) {
|
||||
if options.GetWorkspaceConn == nil {
|
||||
return fantasy.NewTextErrorResponse("workspace connection resolver is not configured"), nil
|
||||
@@ -337,9 +383,42 @@ func ProcessOutput(options ProcessToolOptions) fantasy.AgentTool {
|
||||
if err != nil {
|
||||
return fantasy.NewTextErrorResponse(err.Error()), nil
|
||||
}
|
||||
resp, err := conn.ProcessOutput(ctx, args.ProcessID)
|
||||
|
||||
timeout := defaultProcessOutputTimeout
|
||||
if args.WaitTimeout != nil {
|
||||
parsed, err := time.ParseDuration(*args.WaitTimeout)
|
||||
if err != nil {
|
||||
return fantasy.NewTextErrorResponse(
|
||||
fmt.Sprintf("invalid wait_timeout %q: %v", *args.WaitTimeout, err),
|
||||
), nil
|
||||
}
|
||||
timeout = parsed
|
||||
}
|
||||
var opts *workspacesdk.ProcessOutputOptions
|
||||
// Save parent context before applying timeout.
|
||||
parentCtx := ctx
|
||||
if timeout > 0 {
|
||||
opts = &workspacesdk.ProcessOutputOptions{
|
||||
Wait: true,
|
||||
}
|
||||
var cancel context.CancelFunc
|
||||
ctx, cancel = context.WithTimeout(ctx, timeout)
|
||||
defer cancel()
|
||||
}
|
||||
resp, err := conn.ProcessOutput(ctx, args.ProcessID, opts)
|
||||
if err != nil {
|
||||
return errorResult(fmt.Sprintf("get process output: %v", err)), nil
|
||||
// If our wait timed out but the parent is still alive,
|
||||
// fetch a non-blocking snapshot.
|
||||
if ctx.Err() == nil || parentCtx.Err() != nil {
|
||||
return errorResult(fmt.Sprintf("get process output: %v", err)), nil
|
||||
}
|
||||
bgCtx, bgCancel := context.WithTimeout(parentCtx, snapshotTimeout)
|
||||
defer bgCancel()
|
||||
resp, err = conn.ProcessOutput(bgCtx, args.ProcessID, nil)
|
||||
if err != nil {
|
||||
return errorResult(fmt.Sprintf("get process output: %v", err)), nil
|
||||
}
|
||||
// Fall through to normal response handling below.
|
||||
}
|
||||
output := truncateOutput(resp.Output)
|
||||
exitCode := 0
|
||||
@@ -353,7 +432,7 @@ func ProcessOutput(options ProcessToolOptions) fantasy.AgentTool {
|
||||
Truncated: resp.Truncated,
|
||||
}
|
||||
if resp.Running {
|
||||
// Process is still running — success is not
|
||||
// Process is still running, success is not
|
||||
// yet determined.
|
||||
result.Success = true
|
||||
result.Note = "process is still running"
|
||||
|
||||
100
coderd/chatd/chattool/execute_internal_test.go
Normal file
100
coderd/chatd/chattool/execute_internal_test.go
Normal file
@@ -0,0 +1,100 @@
|
||||
package chattool
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
"unicode/utf8"
|
||||
|
||||
"charm.land/fantasy"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"go.uber.org/mock/gomock"
|
||||
|
||||
"github.com/coder/coder/v2/codersdk/workspacesdk"
|
||||
"github.com/coder/coder/v2/codersdk/workspacesdk/agentconnmock"
|
||||
"github.com/coder/coder/v2/testutil"
|
||||
)
|
||||
|
||||
func TestTruncateOutput(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
t.Run("EmptyOutput", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
result := runForegroundWithOutput(t, "")
|
||||
assert.Empty(t, result.Output)
|
||||
})
|
||||
|
||||
t.Run("ShortOutput", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
result := runForegroundWithOutput(t, "short")
|
||||
assert.Equal(t, "short", result.Output)
|
||||
})
|
||||
|
||||
t.Run("ExactlyAtLimit", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
output := strings.Repeat("a", maxOutputToModel)
|
||||
result := runForegroundWithOutput(t, output)
|
||||
assert.Equal(t, maxOutputToModel, len(result.Output))
|
||||
assert.Equal(t, output, result.Output)
|
||||
})
|
||||
|
||||
t.Run("OverLimit", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
output := strings.Repeat("b", maxOutputToModel+1024)
|
||||
result := runForegroundWithOutput(t, output)
|
||||
assert.Equal(t, maxOutputToModel, len(result.Output))
|
||||
})
|
||||
|
||||
t.Run("MultiByteCutMidCharacter", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
// Build output that places a 3-byte UTF-8 character
|
||||
// (U+2603, snowman ☃) right at the truncation boundary
|
||||
// so the cut falls mid-character.
|
||||
padding := strings.Repeat("x", maxOutputToModel-1)
|
||||
output := padding + "☃" // ☃ is 3 bytes, only 1 byte fits
|
||||
result := runForegroundWithOutput(t, output)
|
||||
assert.LessOrEqual(t, len(result.Output), maxOutputToModel)
|
||||
assert.True(t, utf8.ValidString(result.Output),
|
||||
"truncated output must be valid UTF-8")
|
||||
})
|
||||
}
|
||||
|
||||
// runForegroundWithOutput runs a foreground command through the
|
||||
// Execute tool with a mock that returns the given output, and
|
||||
// returns the parsed result.
|
||||
func runForegroundWithOutput(t *testing.T, output string) ExecuteResult {
|
||||
t.Helper()
|
||||
ctrl := gomock.NewController(t)
|
||||
mockConn := agentconnmock.NewMockAgentConn(ctrl)
|
||||
|
||||
mockConn.EXPECT().
|
||||
StartProcess(gomock.Any(), gomock.Any()).
|
||||
Return(workspacesdk.StartProcessResponse{ID: "proc-1"}, nil)
|
||||
exitCode := 0
|
||||
mockConn.EXPECT().
|
||||
ProcessOutput(gomock.Any(), "proc-1", gomock.Any()).
|
||||
Return(workspacesdk.ProcessOutputResponse{
|
||||
Running: false,
|
||||
ExitCode: &exitCode,
|
||||
Output: output,
|
||||
}, nil)
|
||||
|
||||
tool := Execute(ExecuteOptions{
|
||||
GetWorkspaceConn: func(_ context.Context) (workspacesdk.AgentConn, error) {
|
||||
return mockConn, nil
|
||||
},
|
||||
})
|
||||
ctx := testutil.Context(t, testutil.WaitMedium)
|
||||
resp, err := tool.Run(ctx, fantasy.ToolCall{
|
||||
ID: "call-1",
|
||||
Name: "execute",
|
||||
Input: `{"command":"echo test"}`,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
var result ExecuteResult
|
||||
require.NoError(t, json.Unmarshal([]byte(resp.Content), &result))
|
||||
return result
|
||||
}
|
||||
493
coderd/chatd/chattool/execute_test.go
Normal file
493
coderd/chatd/chattool/execute_test.go
Normal file
@@ -0,0 +1,493 @@
|
||||
package chattool_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"testing"
|
||||
|
||||
"charm.land/fantasy"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"go.uber.org/mock/gomock"
|
||||
"golang.org/x/xerrors"
|
||||
|
||||
"github.com/coder/coder/v2/coderd/chatd/chattool"
|
||||
"github.com/coder/coder/v2/codersdk/workspacesdk"
|
||||
"github.com/coder/coder/v2/codersdk/workspacesdk/agentconnmock"
|
||||
"github.com/coder/coder/v2/testutil"
|
||||
)
|
||||
|
||||
func TestExecuteTool(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
t.Run("EmptyCommand", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctrl := gomock.NewController(t)
|
||||
mockConn := agentconnmock.NewMockAgentConn(ctrl)
|
||||
|
||||
tool := newExecuteTool(t, mockConn)
|
||||
resp, err := tool.Run(context.Background(), fantasy.ToolCall{
|
||||
ID: "call-1",
|
||||
Name: "execute",
|
||||
Input: `{"command":""}`,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
assert.True(t, resp.IsError)
|
||||
assert.Contains(t, resp.Content, "command is required")
|
||||
})
|
||||
|
||||
t.Run("AmpersandDetection", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
command string
|
||||
runInBackground *bool
|
||||
wantCommand string
|
||||
wantBackground bool
|
||||
wantBackgroundResp bool // true if the response should contain a background_process_id
|
||||
comment string
|
||||
}{
|
||||
{
|
||||
name: "SimpleBackground",
|
||||
command: "cmd &",
|
||||
wantCommand: "cmd",
|
||||
wantBackground: true,
|
||||
wantBackgroundResp: true,
|
||||
comment: "Trailing & is correctly detected and stripped.",
|
||||
},
|
||||
{
|
||||
name: "TrailingDoubleAmpersand",
|
||||
command: "cmd &&",
|
||||
wantCommand: "cmd &&",
|
||||
wantBackground: false,
|
||||
wantBackgroundResp: false,
|
||||
comment: "Ends with &&, excluded by the && suffix check.",
|
||||
},
|
||||
{
|
||||
name: "NoAmpersand",
|
||||
command: "cmd",
|
||||
wantCommand: "cmd",
|
||||
wantBackground: false,
|
||||
wantBackgroundResp: false,
|
||||
},
|
||||
{
|
||||
name: "ChainThenBackground",
|
||||
command: "cmd1 && cmd2 &",
|
||||
wantCommand: "cmd1 && cmd2",
|
||||
wantBackground: true,
|
||||
wantBackgroundResp: true,
|
||||
comment: "Ends with & but not &&, so it gets promoted " +
|
||||
"to background and the trailing & is stripped. " +
|
||||
"The remaining command runs in background mode.",
|
||||
},
|
||||
{
|
||||
// "|&" is bash's pipe-stderr operator, not
|
||||
// backgrounding. It must not be detected as a
|
||||
// trailing "&".
|
||||
name: "BashPipeStderr",
|
||||
command: "cmd |&",
|
||||
wantCommand: "cmd |&",
|
||||
wantBackground: false,
|
||||
wantBackgroundResp: false,
|
||||
},
|
||||
{
|
||||
name: "AlreadyBackgroundWithTrailingAmpersand",
|
||||
command: "cmd &",
|
||||
runInBackground: ptr(true),
|
||||
wantCommand: "cmd &",
|
||||
wantBackground: true,
|
||||
wantBackgroundResp: true,
|
||||
comment: "When run_in_background is already true, " +
|
||||
"the stripping logic is skipped, preserving " +
|
||||
"the original command.",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctrl := gomock.NewController(t)
|
||||
mockConn := agentconnmock.NewMockAgentConn(ctrl)
|
||||
|
||||
var capturedReq workspacesdk.StartProcessRequest
|
||||
mockConn.EXPECT().
|
||||
StartProcess(gomock.Any(), gomock.Any()).
|
||||
DoAndReturn(func(_ context.Context, req workspacesdk.StartProcessRequest) (workspacesdk.StartProcessResponse, error) {
|
||||
capturedReq = req
|
||||
return workspacesdk.StartProcessResponse{ID: "proc-1"}, nil
|
||||
})
|
||||
|
||||
// For foreground cases, ProcessOutput is polled.
|
||||
exitCode := 0
|
||||
mockConn.EXPECT().
|
||||
ProcessOutput(gomock.Any(), "proc-1", gomock.Any()).
|
||||
Return(workspacesdk.ProcessOutputResponse{
|
||||
Running: false,
|
||||
ExitCode: &exitCode,
|
||||
}, nil).
|
||||
AnyTimes()
|
||||
|
||||
tool := newExecuteTool(t, mockConn)
|
||||
|
||||
input := map[string]any{"command": tc.command}
|
||||
if tc.runInBackground != nil {
|
||||
input["run_in_background"] = *tc.runInBackground
|
||||
}
|
||||
inputJSON, err := json.Marshal(input)
|
||||
require.NoError(t, err)
|
||||
|
||||
ctx := testutil.Context(t, testutil.WaitMedium)
|
||||
resp, err := tool.Run(ctx, fantasy.ToolCall{
|
||||
ID: "call-1",
|
||||
Name: "execute",
|
||||
Input: string(inputJSON),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
assert.False(t, resp.IsError, "response should not be an error")
|
||||
assert.Equal(t, tc.wantCommand, capturedReq.Command,
|
||||
"command passed to StartProcess")
|
||||
assert.Equal(t, tc.wantBackground, capturedReq.Background,
|
||||
"background flag passed to StartProcess")
|
||||
|
||||
var result chattool.ExecuteResult
|
||||
require.NoError(t, json.Unmarshal([]byte(resp.Content), &result))
|
||||
if tc.wantBackgroundResp {
|
||||
assert.NotEmpty(t, result.BackgroundProcessID,
|
||||
"expected background_process_id in response")
|
||||
} else {
|
||||
assert.Empty(t, result.BackgroundProcessID,
|
||||
"expected no background_process_id")
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("ForegroundSuccess", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctrl := gomock.NewController(t)
|
||||
mockConn := agentconnmock.NewMockAgentConn(ctrl)
|
||||
|
||||
var capturedReq workspacesdk.StartProcessRequest
|
||||
mockConn.EXPECT().
|
||||
StartProcess(gomock.Any(), gomock.Any()).
|
||||
DoAndReturn(func(_ context.Context, req workspacesdk.StartProcessRequest) (workspacesdk.StartProcessResponse, error) {
|
||||
capturedReq = req
|
||||
return workspacesdk.StartProcessResponse{ID: "proc-1"}, nil
|
||||
})
|
||||
exitCode := 0
|
||||
mockConn.EXPECT().
|
||||
ProcessOutput(gomock.Any(), "proc-1", gomock.Any()).
|
||||
Return(workspacesdk.ProcessOutputResponse{
|
||||
Running: false,
|
||||
ExitCode: &exitCode,
|
||||
Output: "hello world",
|
||||
}, nil)
|
||||
|
||||
tool := newExecuteTool(t, mockConn)
|
||||
ctx := testutil.Context(t, testutil.WaitMedium)
|
||||
resp, err := tool.Run(ctx, fantasy.ToolCall{
|
||||
ID: "call-1",
|
||||
Name: "execute",
|
||||
Input: `{"command":"echo hello"}`,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
assert.False(t, resp.IsError)
|
||||
|
||||
var result chattool.ExecuteResult
|
||||
require.NoError(t, json.Unmarshal([]byte(resp.Content), &result))
|
||||
assert.True(t, result.Success)
|
||||
assert.Equal(t, 0, result.ExitCode)
|
||||
assert.Equal(t, "hello world", result.Output)
|
||||
assert.Empty(t, result.BackgroundProcessID)
|
||||
assert.Equal(t, "true", capturedReq.Env["CODER_CHAT_AGENT"])
|
||||
})
|
||||
|
||||
t.Run("ForegroundNonZeroExit", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctrl := gomock.NewController(t)
|
||||
mockConn := agentconnmock.NewMockAgentConn(ctrl)
|
||||
|
||||
mockConn.EXPECT().
|
||||
StartProcess(gomock.Any(), gomock.Any()).
|
||||
Return(workspacesdk.StartProcessResponse{ID: "proc-1"}, nil)
|
||||
exitCode := 42
|
||||
mockConn.EXPECT().
|
||||
ProcessOutput(gomock.Any(), "proc-1", gomock.Any()).
|
||||
Return(workspacesdk.ProcessOutputResponse{
|
||||
Running: false,
|
||||
ExitCode: &exitCode,
|
||||
Output: "something failed",
|
||||
}, nil)
|
||||
|
||||
tool := newExecuteTool(t, mockConn)
|
||||
ctx := testutil.Context(t, testutil.WaitMedium)
|
||||
resp, err := tool.Run(ctx, fantasy.ToolCall{
|
||||
ID: "call-1",
|
||||
Name: "execute",
|
||||
Input: `{"command":"exit 42"}`,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
assert.False(t, resp.IsError)
|
||||
|
||||
var result chattool.ExecuteResult
|
||||
require.NoError(t, json.Unmarshal([]byte(resp.Content), &result))
|
||||
assert.False(t, result.Success)
|
||||
assert.Equal(t, 42, result.ExitCode)
|
||||
assert.Equal(t, "something failed", result.Output)
|
||||
})
|
||||
|
||||
t.Run("BackgroundExecution", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctrl := gomock.NewController(t)
|
||||
mockConn := agentconnmock.NewMockAgentConn(ctrl)
|
||||
|
||||
mockConn.EXPECT().
|
||||
StartProcess(gomock.Any(), gomock.Any()).
|
||||
DoAndReturn(func(_ context.Context, req workspacesdk.StartProcessRequest) (workspacesdk.StartProcessResponse, error) {
|
||||
assert.True(t, req.Background)
|
||||
return workspacesdk.StartProcessResponse{ID: "bg-42"}, nil
|
||||
})
|
||||
|
||||
tool := newExecuteTool(t, mockConn)
|
||||
ctx := testutil.Context(t, testutil.WaitMedium)
|
||||
resp, err := tool.Run(ctx, fantasy.ToolCall{
|
||||
ID: "call-1",
|
||||
Name: "execute",
|
||||
Input: `{"command":"sleep 999","run_in_background":true}`,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
assert.False(t, resp.IsError)
|
||||
|
||||
var result chattool.ExecuteResult
|
||||
require.NoError(t, json.Unmarshal([]byte(resp.Content), &result))
|
||||
assert.True(t, result.Success)
|
||||
assert.Equal(t, "bg-42", result.BackgroundProcessID)
|
||||
})
|
||||
|
||||
t.Run("Timeout", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctrl := gomock.NewController(t)
|
||||
mockConn := agentconnmock.NewMockAgentConn(ctrl)
|
||||
|
||||
mockConn.EXPECT().
|
||||
StartProcess(gomock.Any(), gomock.Any()).
|
||||
Return(workspacesdk.StartProcessResponse{ID: "proc-1"}, nil)
|
||||
|
||||
// First call (blocking wait) returns context error
|
||||
// because the 50ms timeout expires.
|
||||
mockConn.EXPECT().
|
||||
ProcessOutput(gomock.Any(), "proc-1", gomock.Any()).
|
||||
DoAndReturn(func(ctx context.Context, _ string, _ *workspacesdk.ProcessOutputOptions) (workspacesdk.ProcessOutputResponse, error) {
|
||||
<-ctx.Done()
|
||||
return workspacesdk.ProcessOutputResponse{}, ctx.Err()
|
||||
})
|
||||
// Second call (snapshot fallback) returns partial output.
|
||||
mockConn.EXPECT().
|
||||
ProcessOutput(gomock.Any(), "proc-1", gomock.Any()).
|
||||
Return(workspacesdk.ProcessOutputResponse{
|
||||
Running: true,
|
||||
Output: "partial output",
|
||||
}, nil)
|
||||
tool := newExecuteTool(t, mockConn)
|
||||
ctx := testutil.Context(t, testutil.WaitMedium)
|
||||
resp, err := tool.Run(ctx, fantasy.ToolCall{
|
||||
ID: "call-1",
|
||||
Name: "execute",
|
||||
// 50ms timeout expires during the blocking wait.
|
||||
Input: `{"command":"sleep 999","timeout":"50ms"}`,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
assert.False(t, resp.IsError)
|
||||
|
||||
var result chattool.ExecuteResult
|
||||
require.NoError(t, json.Unmarshal([]byte(resp.Content), &result))
|
||||
assert.False(t, result.Success)
|
||||
assert.Equal(t, -1, result.ExitCode)
|
||||
assert.Contains(t, result.Error, "timed out")
|
||||
assert.Equal(t, "partial output", result.Output)
|
||||
})
|
||||
|
||||
t.Run("StartProcessError", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctrl := gomock.NewController(t)
|
||||
mockConn := agentconnmock.NewMockAgentConn(ctrl)
|
||||
|
||||
mockConn.EXPECT().
|
||||
StartProcess(gomock.Any(), gomock.Any()).
|
||||
Return(workspacesdk.StartProcessResponse{}, xerrors.New("connection lost"))
|
||||
|
||||
tool := newExecuteTool(t, mockConn)
|
||||
ctx := testutil.Context(t, testutil.WaitMedium)
|
||||
resp, err := tool.Run(ctx, fantasy.ToolCall{
|
||||
ID: "call-1",
|
||||
Name: "execute",
|
||||
Input: `{"command":"echo hi"}`,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
// Errors from StartProcess are returned as a JSON body
|
||||
// with success=false, not as a ToolResponse error.
|
||||
assert.False(t, resp.IsError)
|
||||
|
||||
var result chattool.ExecuteResult
|
||||
require.NoError(t, json.Unmarshal([]byte(resp.Content), &result))
|
||||
assert.False(t, result.Success)
|
||||
assert.Contains(t, result.Error, "connection lost")
|
||||
})
|
||||
|
||||
t.Run("ProcessOutputError", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctrl := gomock.NewController(t)
|
||||
mockConn := agentconnmock.NewMockAgentConn(ctrl)
|
||||
|
||||
mockConn.EXPECT().
|
||||
StartProcess(gomock.Any(), gomock.Any()).
|
||||
Return(workspacesdk.StartProcessResponse{ID: "proc-1"}, nil)
|
||||
mockConn.EXPECT().
|
||||
ProcessOutput(gomock.Any(), "proc-1", gomock.Any()).
|
||||
Return(workspacesdk.ProcessOutputResponse{}, xerrors.New("agent disconnected"))
|
||||
|
||||
tool := newExecuteTool(t, mockConn)
|
||||
ctx := testutil.Context(t, testutil.WaitMedium)
|
||||
resp, err := tool.Run(ctx, fantasy.ToolCall{
|
||||
ID: "call-1",
|
||||
Name: "execute",
|
||||
Input: `{"command":"echo hi"}`,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
assert.False(t, resp.IsError)
|
||||
|
||||
var result chattool.ExecuteResult
|
||||
require.NoError(t, json.Unmarshal([]byte(resp.Content), &result))
|
||||
assert.False(t, result.Success)
|
||||
assert.Contains(t, result.Error, "agent disconnected")
|
||||
})
|
||||
|
||||
t.Run("GetWorkspaceConnNil", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
tool := chattool.Execute(chattool.ExecuteOptions{
|
||||
GetWorkspaceConn: nil,
|
||||
})
|
||||
ctx := testutil.Context(t, testutil.WaitMedium)
|
||||
resp, err := tool.Run(ctx, fantasy.ToolCall{
|
||||
ID: "call-1",
|
||||
Name: "execute",
|
||||
Input: `{"command":"echo hi"}`,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
assert.True(t, resp.IsError)
|
||||
assert.Contains(t, resp.Content, "not configured")
|
||||
})
|
||||
|
||||
t.Run("GetWorkspaceConnError", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
tool := chattool.Execute(chattool.ExecuteOptions{
|
||||
GetWorkspaceConn: func(_ context.Context) (workspacesdk.AgentConn, error) {
|
||||
return nil, xerrors.New("workspace offline")
|
||||
},
|
||||
})
|
||||
ctx := testutil.Context(t, testutil.WaitMedium)
|
||||
resp, err := tool.Run(ctx, fantasy.ToolCall{
|
||||
ID: "call-1",
|
||||
Name: "execute",
|
||||
Input: `{"command":"echo hi"}`,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
assert.True(t, resp.IsError)
|
||||
assert.Contains(t, resp.Content, "workspace offline")
|
||||
})
|
||||
}
|
||||
|
||||
func TestDetectFileDump(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
command string
|
||||
wantHit bool
|
||||
}{
|
||||
{
|
||||
name: "CatFile",
|
||||
command: "cat foo.txt",
|
||||
wantHit: true,
|
||||
},
|
||||
{
|
||||
name: "NotCatPrefix",
|
||||
command: "concatenate foo",
|
||||
wantHit: false,
|
||||
},
|
||||
{
|
||||
name: "GrepIncludeAll",
|
||||
command: "grep --include-all pattern",
|
||||
wantHit: true,
|
||||
},
|
||||
{
|
||||
name: "RgListFiles",
|
||||
command: "rg -l pattern",
|
||||
wantHit: true,
|
||||
},
|
||||
{
|
||||
name: "GrepRecursive",
|
||||
command: "grep -r pattern",
|
||||
wantHit: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctrl := gomock.NewController(t)
|
||||
mockConn := agentconnmock.NewMockAgentConn(ctrl)
|
||||
|
||||
mockConn.EXPECT().
|
||||
StartProcess(gomock.Any(), gomock.Any()).
|
||||
Return(workspacesdk.StartProcessResponse{ID: "proc-1"}, nil)
|
||||
exitCode := 0
|
||||
mockConn.EXPECT().
|
||||
ProcessOutput(gomock.Any(), "proc-1", gomock.Any()).
|
||||
Return(workspacesdk.ProcessOutputResponse{
|
||||
Running: false,
|
||||
ExitCode: &exitCode,
|
||||
Output: "output",
|
||||
}, nil)
|
||||
|
||||
tool := newExecuteTool(t, mockConn)
|
||||
ctx := testutil.Context(t, testutil.WaitMedium)
|
||||
input, err := json.Marshal(map[string]any{
|
||||
"command": tc.command,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
resp, err := tool.Run(ctx, fantasy.ToolCall{
|
||||
ID: "call-1",
|
||||
Name: "execute",
|
||||
Input: string(input),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
var result chattool.ExecuteResult
|
||||
require.NoError(t, json.Unmarshal([]byte(resp.Content), &result))
|
||||
if tc.wantHit {
|
||||
assert.Contains(t, result.Note, "read_file",
|
||||
"expected advisory note for %q", tc.command)
|
||||
} else {
|
||||
assert.Empty(t, result.Note,
|
||||
"expected no note for %q", tc.command)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// newExecuteTool creates an Execute tool wired to the given mock.
|
||||
func newExecuteTool(t *testing.T, mockConn *agentconnmock.MockAgentConn) fantasy.AgentTool {
|
||||
t.Helper()
|
||||
return chattool.Execute(chattool.ExecuteOptions{
|
||||
GetWorkspaceConn: func(_ context.Context) (workspacesdk.AgentConn, error) {
|
||||
return mockConn, nil
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
func ptr[T any](v T) *T {
|
||||
return &v
|
||||
}
|
||||
@@ -92,7 +92,7 @@ func TestAnthropicWebSearchRoundTrip(t *testing.T) {
|
||||
// Verify the chat completed and messages were persisted.
|
||||
chatData, err := client.GetChat(ctx, chat.ID)
|
||||
require.NoError(t, err)
|
||||
chatMsgs, err := client.GetChatMessages(ctx, chat.ID)
|
||||
chatMsgs, err := client.GetChatMessages(ctx, chat.ID, nil)
|
||||
require.NoError(t, err)
|
||||
t.Logf("Chat status after step 1: %s, messages: %d",
|
||||
chatData.Status, len(chatMsgs.Messages))
|
||||
@@ -154,7 +154,7 @@ func TestAnthropicWebSearchRoundTrip(t *testing.T) {
|
||||
// Verify the follow-up completed and produced content.
|
||||
chatData2, err := client.GetChat(ctx, chat.ID)
|
||||
require.NoError(t, err)
|
||||
chatMsgs2, err := client.GetChatMessages(ctx, chat.ID)
|
||||
chatMsgs2, err := client.GetChatMessages(ctx, chat.ID, nil)
|
||||
require.NoError(t, err)
|
||||
t.Logf("Chat status after step 2: %s, messages: %d",
|
||||
chatData2.Status, len(chatMsgs2.Messages))
|
||||
@@ -272,6 +272,156 @@ func logMessages(t *testing.T, msgs []codersdk.ChatMessage) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestOpenAIReasoningRoundTrip is an integration test that verifies
|
||||
// reasoning items from OpenAI's Responses API survive the full
|
||||
// persist → reconstruct → re-send cycle when Store: true. It sends
|
||||
// a query to a reasoning model, waits for completion, then sends a
|
||||
// follow-up message. If reasoning items are sent back without their
|
||||
// required following output item, the API rejects the second request:
|
||||
//
|
||||
// Item 'rs_xxx' of type 'reasoning' was provided without its
|
||||
// required following item.
|
||||
//
|
||||
// The test requires OPENAI_API_KEY to be set.
|
||||
func TestOpenAIReasoningRoundTrip(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
apiKey := os.Getenv("OPENAI_API_KEY")
|
||||
if apiKey == "" {
|
||||
t.Skip("OPENAI_API_KEY not set; skipping OpenAI integration test")
|
||||
}
|
||||
baseURL := os.Getenv("OPENAI_BASE_URL")
|
||||
|
||||
ctx := testutil.Context(t, testutil.WaitSuperLong)
|
||||
|
||||
// Stand up a full coderd with the agents experiment.
|
||||
deploymentValues := coderdtest.DeploymentValues(t)
|
||||
deploymentValues.Experiments = []string{string(codersdk.ExperimentAgents)}
|
||||
client := coderdtest.New(t, &coderdtest.Options{
|
||||
DeploymentValues: deploymentValues,
|
||||
})
|
||||
_ = coderdtest.CreateFirstUser(t, client)
|
||||
|
||||
// Configure an OpenAI provider with the real API key.
|
||||
_, err := client.CreateChatProvider(ctx, codersdk.CreateChatProviderConfigRequest{
|
||||
Provider: "openai",
|
||||
APIKey: apiKey,
|
||||
BaseURL: baseURL,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
// Create a model config for a reasoning model with Store: true
|
||||
// (the default). Using o4-mini because it always produces
|
||||
// reasoning items.
|
||||
contextLimit := int64(200000)
|
||||
isDefault := true
|
||||
reasoningSummary := "auto"
|
||||
_, err = client.CreateChatModelConfig(ctx, codersdk.CreateChatModelConfigRequest{
|
||||
Provider: "openai",
|
||||
Model: "o4-mini",
|
||||
ContextLimit: &contextLimit,
|
||||
IsDefault: &isDefault,
|
||||
ModelConfig: &codersdk.ChatModelCallConfig{
|
||||
ProviderOptions: &codersdk.ChatModelProviderOptions{
|
||||
OpenAI: &codersdk.ChatModelOpenAIProviderOptions{
|
||||
Store: ptr.Ref(true),
|
||||
ReasoningSummary: &reasoningSummary,
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
// --- Step 1: Send a message that triggers reasoning ---
|
||||
t.Log("Creating chat with reasoning query...")
|
||||
chat, err := client.CreateChat(ctx, codersdk.CreateChatRequest{
|
||||
Content: []codersdk.ChatInputPart{
|
||||
{
|
||||
Type: codersdk.ChatInputPartTypeText,
|
||||
Text: "What is 2+2? Be brief.",
|
||||
},
|
||||
},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
t.Logf("Chat created: %s (status=%s)", chat.ID, chat.Status)
|
||||
|
||||
// Stream events until the chat reaches a terminal status.
|
||||
events, closer, err := client.StreamChat(ctx, chat.ID, nil)
|
||||
require.NoError(t, err)
|
||||
defer closer.Close()
|
||||
|
||||
waitForChatDone(ctx, t, events, "step 1")
|
||||
|
||||
// Verify the chat completed and messages were persisted.
|
||||
chatData, err := client.GetChat(ctx, chat.ID)
|
||||
require.NoError(t, err)
|
||||
chatMsgs, err := client.GetChatMessages(ctx, chat.ID, nil)
|
||||
require.NoError(t, err)
|
||||
t.Logf("Chat status after step 1: %s, messages: %d",
|
||||
chatData.Status, len(chatMsgs.Messages))
|
||||
logMessages(t, chatMsgs.Messages)
|
||||
|
||||
require.Equal(t, codersdk.ChatStatusWaiting, chatData.Status,
|
||||
"chat should be in waiting status after step 1")
|
||||
|
||||
// Verify the assistant message has reasoning content.
|
||||
assistantMsg := findAssistantWithText(t, chatMsgs.Messages)
|
||||
require.NotNil(t, assistantMsg,
|
||||
"expected an assistant message with text content after step 1")
|
||||
|
||||
partTypes := partTypeSet(assistantMsg.Content)
|
||||
require.Contains(t, partTypes, codersdk.ChatMessagePartTypeReasoning,
|
||||
"assistant message should contain reasoning parts from o4-mini")
|
||||
require.Contains(t, partTypes, codersdk.ChatMessagePartTypeText,
|
||||
"assistant message should contain a text part")
|
||||
|
||||
// --- Step 2: Send a follow-up message ---
|
||||
// This is the critical test: if reasoning items are sent back
|
||||
// without their required following item, the API will reject
|
||||
// the request with:
|
||||
// Item 'rs_xxx' of type 'reasoning' was provided without its
|
||||
// required following item.
|
||||
t.Log("Sending follow-up message...")
|
||||
_, err = client.CreateChatMessage(ctx, chat.ID,
|
||||
codersdk.CreateChatMessageRequest{
|
||||
Content: []codersdk.ChatInputPart{
|
||||
{
|
||||
Type: codersdk.ChatInputPartTypeText,
|
||||
Text: "And what is 3+3? Be brief.",
|
||||
},
|
||||
},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
// Stream the follow-up response.
|
||||
events2, closer2, err := client.StreamChat(ctx, chat.ID, nil)
|
||||
require.NoError(t, err)
|
||||
defer closer2.Close()
|
||||
|
||||
waitForChatDone(ctx, t, events2, "step 2")
|
||||
|
||||
// Verify the follow-up completed and produced content.
|
||||
chatData2, err := client.GetChat(ctx, chat.ID)
|
||||
require.NoError(t, err)
|
||||
chatMsgs2, err := client.GetChatMessages(ctx, chat.ID, nil)
|
||||
require.NoError(t, err)
|
||||
t.Logf("Chat status after step 2: %s, messages: %d",
|
||||
chatData2.Status, len(chatMsgs2.Messages))
|
||||
logMessages(t, chatMsgs2.Messages)
|
||||
|
||||
require.Equal(t, codersdk.ChatStatusWaiting, chatData2.Status,
|
||||
"chat should be in waiting status after step 2")
|
||||
require.Greater(t, len(chatMsgs2.Messages), len(chatMsgs.Messages),
|
||||
"follow-up should have added more messages")
|
||||
|
||||
// The last assistant message should have text.
|
||||
lastAssistant := findLastAssistantWithText(t, chatMsgs2.Messages)
|
||||
require.NotNil(t, lastAssistant,
|
||||
"expected an assistant message with text in the follow-up")
|
||||
|
||||
t.Log("OpenAI reasoning round-trip test passed.")
|
||||
}
|
||||
|
||||
// partTypeSet returns the set of part types present in a message.
|
||||
func partTypeSet(parts []codersdk.ChatMessagePart) map[codersdk.ChatMessagePartType]struct{} {
|
||||
set := make(map[codersdk.ChatMessagePartType]struct{}, len(parts))
|
||||
|
||||
@@ -62,6 +62,7 @@ func (p *Server) maybeGenerateChatTitle(
|
||||
messages []database.ChatMessage,
|
||||
fallbackModel fantasy.LanguageModel,
|
||||
keys chatprovider.ProviderAPIKeys,
|
||||
generatedTitle *generatedChatTitle,
|
||||
logger slog.Logger,
|
||||
) {
|
||||
input, ok := titleInput(chat, messages)
|
||||
@@ -111,7 +112,8 @@ func (p *Server) maybeGenerateChatTitle(
|
||||
return
|
||||
}
|
||||
chat.Title = title
|
||||
p.publishChatPubsubEvent(chat, coderdpubsub.ChatEventKindTitleChange)
|
||||
generatedTitle.Store(title)
|
||||
p.publishChatPubsubEvent(chat, coderdpubsub.ChatEventKindTitleChange, nil)
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
@@ -13,6 +13,7 @@ import (
|
||||
"golang.org/x/xerrors"
|
||||
|
||||
"github.com/coder/coder/v2/coderd/chatd/chatprompt"
|
||||
"github.com/coder/coder/v2/coderd/chatd/chatprovider"
|
||||
"github.com/coder/coder/v2/coderd/database"
|
||||
coderdpubsub "github.com/coder/coder/v2/coderd/pubsub"
|
||||
"github.com/coder/coder/v2/codersdk"
|
||||
@@ -26,11 +27,30 @@ const (
|
||||
defaultSubagentWaitTimeout = 5 * time.Minute
|
||||
)
|
||||
|
||||
// computerUseSubagentSystemPrompt is the system prompt prepended to
|
||||
// every computer use subagent chat. It instructs the model on how to
|
||||
// interact with the desktop environment via the computer tool.
|
||||
const computerUseSubagentSystemPrompt = `You are a computer use agent with access to a desktop environment. You can see the screen, move the mouse, click, type, scroll, and drag.
|
||||
|
||||
Your primary tool is the "computer" tool which lets you interact with the desktop. After every action you take, you will receive a screenshot showing the current state of the screen. Use these screenshots to verify your actions and plan next steps.
|
||||
|
||||
Guidelines:
|
||||
- Always start by taking a screenshot to see the current state of the desktop.
|
||||
- Be precise with coordinates when clicking or typing.
|
||||
- Wait for UI elements to load before interacting with them.
|
||||
- If an action doesn't produce the expected result, try alternative approaches.
|
||||
- Report what you accomplished when done.`
|
||||
|
||||
type spawnAgentArgs struct {
|
||||
Prompt string `json:"prompt"`
|
||||
Title string `json:"title,omitempty"`
|
||||
}
|
||||
|
||||
type spawnComputerUseAgentArgs struct {
|
||||
Prompt string `json:"prompt"`
|
||||
Title string `json:"title,omitempty"`
|
||||
}
|
||||
|
||||
type waitAgentArgs struct {
|
||||
ChatID string `json:"chat_id"`
|
||||
TimeoutSeconds *int `json:"timeout_seconds,omitempty"`
|
||||
@@ -46,8 +66,34 @@ type closeAgentArgs struct {
|
||||
ChatID string `json:"chat_id"`
|
||||
}
|
||||
|
||||
func (p *Server) subagentTools(currentChat func() database.Chat) []fantasy.AgentTool {
|
||||
return []fantasy.AgentTool{
|
||||
// isAnthropicConfigured reports whether an Anthropic API key is
|
||||
// available, either from static provider keys or from the database.
|
||||
func (p *Server) isAnthropicConfigured(ctx context.Context) bool {
|
||||
if p.providerAPIKeys.APIKey("anthropic") != "" {
|
||||
return true
|
||||
}
|
||||
dbProviders, err := p.db.GetEnabledChatProviders(ctx)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
for _, prov := range dbProviders {
|
||||
if chatprovider.NormalizeProvider(prov.Provider) == "anthropic" && strings.TrimSpace(prov.APIKey) != "" {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (p *Server) isDesktopEnabled(ctx context.Context) bool {
|
||||
enabled, err := p.db.GetChatDesktopEnabled(ctx)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
return enabled
|
||||
}
|
||||
|
||||
func (p *Server) subagentTools(ctx context.Context, currentChat func() database.Chat) []fantasy.AgentTool {
|
||||
tools := []fantasy.AgentTool{
|
||||
fantasy.NewAgentTool(
|
||||
"spawn_agent",
|
||||
"Spawn a delegated child agent to work on a clearly scoped, "+
|
||||
@@ -213,6 +259,88 @@ func (p *Server) subagentTools(currentChat func() database.Chat) []fantasy.Agent
|
||||
},
|
||||
),
|
||||
}
|
||||
|
||||
// Only include the computer use tool when an Anthropic
|
||||
// provider is configured and desktop is enabled.
|
||||
if p.isAnthropicConfigured(ctx) && p.isDesktopEnabled(ctx) {
|
||||
tools = append(tools, fantasy.NewAgentTool(
|
||||
"spawn_computer_use_agent",
|
||||
"Spawn a dedicated computer use agent that can see the desktop "+
|
||||
"(take screenshots) and interact with it (mouse, keyboard, "+
|
||||
"scroll). The agent runs on a model optimized for computer "+
|
||||
"use and has the same workspace tools as a standard subagent "+
|
||||
"plus the native Anthropic computer tool. Use this for tasks "+
|
||||
"that require visual interaction with a desktop GUI (e.g. "+
|
||||
"browser automation, GUI testing, visual inspection). After "+
|
||||
"spawning, use wait_agent to collect the result.",
|
||||
func(ctx context.Context, args spawnComputerUseAgentArgs, _ fantasy.ToolCall) (fantasy.ToolResponse, error) {
|
||||
if currentChat == nil {
|
||||
return fantasy.NewTextErrorResponse("subagent callbacks are not configured"), nil
|
||||
}
|
||||
|
||||
parent := currentChat()
|
||||
if parent.ParentChatID.Valid {
|
||||
return fantasy.NewTextErrorResponse("delegated chats cannot create child subagents"), nil
|
||||
}
|
||||
|
||||
parent, err := p.db.GetChatByID(ctx, parent.ID)
|
||||
if err != nil {
|
||||
return fantasy.NewTextErrorResponse(err.Error()), nil
|
||||
}
|
||||
|
||||
prompt := strings.TrimSpace(args.Prompt)
|
||||
if prompt == "" {
|
||||
return fantasy.NewTextErrorResponse("prompt is required"), nil
|
||||
}
|
||||
|
||||
title := strings.TrimSpace(args.Title)
|
||||
if title == "" {
|
||||
title = subagentFallbackChatTitle(prompt)
|
||||
}
|
||||
|
||||
rootChatID := parent.ID
|
||||
if parent.RootChatID.Valid {
|
||||
rootChatID = parent.RootChatID.UUID
|
||||
}
|
||||
if parent.LastModelConfigID == uuid.Nil {
|
||||
return fantasy.NewTextErrorResponse("parent chat model config id is required"), nil
|
||||
}
|
||||
|
||||
// Create the child chat with Mode set to
|
||||
// computer_use. This signals runChat to use the
|
||||
// predefined computer use model and include the
|
||||
// computer tool.
|
||||
childChat, err := p.CreateChat(ctx, CreateOptions{
|
||||
OwnerID: parent.OwnerID,
|
||||
WorkspaceID: parent.WorkspaceID,
|
||||
ParentChatID: uuid.NullUUID{
|
||||
UUID: parent.ID,
|
||||
Valid: true,
|
||||
},
|
||||
RootChatID: uuid.NullUUID{
|
||||
UUID: rootChatID,
|
||||
Valid: true,
|
||||
},
|
||||
ModelConfigID: parent.LastModelConfigID,
|
||||
Title: title,
|
||||
ChatMode: database.NullChatMode{ChatMode: database.ChatModeComputerUse, Valid: true},
|
||||
SystemPrompt: computerUseSubagentSystemPrompt + "\n\n" + prompt,
|
||||
InitialUserContent: []codersdk.ChatMessagePart{codersdk.ChatMessageText(prompt)},
|
||||
})
|
||||
if err != nil {
|
||||
return fantasy.NewTextErrorResponse(err.Error()), nil
|
||||
}
|
||||
|
||||
return toolJSONResponse(map[string]any{
|
||||
"chat_id": childChat.ID.String(),
|
||||
"title": childChat.Title,
|
||||
"status": string(childChat.Status),
|
||||
}), nil
|
||||
},
|
||||
))
|
||||
}
|
||||
|
||||
return tools
|
||||
}
|
||||
|
||||
func parseSubagentToolChatID(raw string) (uuid.UUID, error) {
|
||||
|
||||
470
coderd/chatd/subagent_internal_test.go
Normal file
470
coderd/chatd/subagent_internal_test.go
Normal file
@@ -0,0 +1,470 @@
|
||||
package chatd
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"testing"
|
||||
|
||||
"charm.land/fantasy"
|
||||
"github.com/google/uuid"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"cdr.dev/slog/v3/sloggers/slogtest"
|
||||
"github.com/coder/coder/v2/coderd/chatd/chatprovider"
|
||||
"github.com/coder/coder/v2/coderd/chatd/chattool"
|
||||
"github.com/coder/coder/v2/coderd/database"
|
||||
"github.com/coder/coder/v2/coderd/database/dbauthz"
|
||||
"github.com/coder/coder/v2/coderd/database/dbgen"
|
||||
"github.com/coder/coder/v2/coderd/database/dbtestutil"
|
||||
"github.com/coder/coder/v2/coderd/database/pubsub"
|
||||
"github.com/coder/coder/v2/codersdk"
|
||||
"github.com/coder/coder/v2/testutil"
|
||||
)
|
||||
|
||||
func TestComputerUseSubagentSystemPrompt(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// Verify the system prompt constant is non-empty and contains
|
||||
// key instructions for the computer use agent.
|
||||
assert.NotEmpty(t, computerUseSubagentSystemPrompt)
|
||||
assert.Contains(t, computerUseSubagentSystemPrompt, "computer")
|
||||
assert.Contains(t, computerUseSubagentSystemPrompt, "screenshot")
|
||||
}
|
||||
|
||||
func TestSubagentFallbackChatTitle(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
input string
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "EmptyPrompt",
|
||||
input: "",
|
||||
want: "New Chat",
|
||||
},
|
||||
{
|
||||
name: "ShortPrompt",
|
||||
input: "Open Firefox",
|
||||
want: "Open Firefox",
|
||||
},
|
||||
{
|
||||
name: "LongPrompt",
|
||||
input: "Please open the Firefox browser and navigate to the settings page",
|
||||
want: "Please open the Firefox browser and...",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
got := subagentFallbackChatTitle(tt.input)
|
||||
assert.Equal(t, tt.want, got)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// newInternalTestServer creates a Server for internal tests with
|
||||
// custom provider API keys. The server is automatically closed
|
||||
// when the test finishes.
|
||||
func newInternalTestServer(
|
||||
t *testing.T,
|
||||
db database.Store,
|
||||
ps pubsub.Pubsub,
|
||||
keys chatprovider.ProviderAPIKeys,
|
||||
) *Server {
|
||||
t.Helper()
|
||||
|
||||
logger := slogtest.Make(t, &slogtest.Options{IgnoreErrors: true})
|
||||
server := New(Config{
|
||||
Logger: logger,
|
||||
Database: db,
|
||||
ReplicaID: uuid.New(),
|
||||
Pubsub: ps,
|
||||
// Use a very long interval so the background loop
|
||||
// does not interfere with test assertions.
|
||||
PendingChatAcquireInterval: testutil.WaitLong,
|
||||
ProviderAPIKeys: keys,
|
||||
})
|
||||
t.Cleanup(func() {
|
||||
require.NoError(t, server.Close())
|
||||
})
|
||||
return server
|
||||
}
|
||||
|
||||
// seedInternalChatDeps inserts an OpenAI provider and model config
|
||||
// into the database and returns the created user and model. This
|
||||
// deliberately does NOT create an Anthropic provider.
|
||||
func seedInternalChatDeps(
|
||||
ctx context.Context,
|
||||
t *testing.T,
|
||||
db database.Store,
|
||||
) (database.User, database.ChatModelConfig) {
|
||||
t.Helper()
|
||||
|
||||
user := dbgen.User(t, db, database.User{})
|
||||
_, err := db.InsertChatProvider(ctx, database.InsertChatProviderParams{
|
||||
Provider: "openai",
|
||||
DisplayName: "OpenAI",
|
||||
APIKey: "test-key",
|
||||
BaseUrl: "",
|
||||
ApiKeyKeyID: sql.NullString{},
|
||||
CreatedBy: uuid.NullUUID{UUID: user.ID, Valid: true},
|
||||
Enabled: true,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
model, err := db.InsertChatModelConfig(ctx, database.InsertChatModelConfigParams{
|
||||
Provider: "openai",
|
||||
Model: "gpt-4o-mini",
|
||||
DisplayName: "Test Model",
|
||||
CreatedBy: uuid.NullUUID{UUID: user.ID, Valid: true},
|
||||
UpdatedBy: uuid.NullUUID{UUID: user.ID, Valid: true},
|
||||
Enabled: true,
|
||||
IsDefault: true,
|
||||
ContextLimit: 128000,
|
||||
CompressionThreshold: 70,
|
||||
Options: json.RawMessage(`{}`),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
return user, model
|
||||
}
|
||||
|
||||
// findToolByName returns the tool with the given name from the
|
||||
// slice, or nil if no match is found.
|
||||
func findToolByName(tools []fantasy.AgentTool, name string) fantasy.AgentTool {
|
||||
for _, tool := range tools {
|
||||
if tool.Info().Name == name {
|
||||
return tool
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func chatdTestContext(t *testing.T) context.Context {
|
||||
t.Helper()
|
||||
return dbauthz.AsChatd(testutil.Context(t, testutil.WaitLong))
|
||||
}
|
||||
|
||||
func TestSpawnComputerUseAgent_NoAnthropicProvider(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
db, ps := dbtestutil.NewDB(t)
|
||||
require.NoError(t, db.UpsertChatDesktopEnabled(chatdTestContext(t), true))
|
||||
// No Anthropic key in ProviderAPIKeys.
|
||||
server := newInternalTestServer(t, db, ps, chatprovider.ProviderAPIKeys{})
|
||||
|
||||
ctx := chatdTestContext(t)
|
||||
user, model := seedInternalChatDeps(ctx, t, db)
|
||||
|
||||
// Create a root parent chat.
|
||||
parent, err := server.CreateChat(ctx, CreateOptions{
|
||||
OwnerID: user.ID,
|
||||
Title: "parent-no-anthropic",
|
||||
ModelConfigID: model.ID,
|
||||
InitialUserContent: []codersdk.ChatMessagePart{codersdk.ChatMessageText("hello")},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
// Re-fetch so LastModelConfigID is populated from the DB.
|
||||
parentChat, err := db.GetChatByID(ctx, parent.ID)
|
||||
require.NoError(t, err)
|
||||
|
||||
tools := server.subagentTools(ctx, func() database.Chat { return parentChat })
|
||||
tool := findToolByName(tools, "spawn_computer_use_agent")
|
||||
assert.Nil(t, tool, "spawn_computer_use_agent tool must be omitted when Anthropic is not configured")
|
||||
}
|
||||
|
||||
func TestSpawnComputerUseAgent_NotAvailableForChildChats(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
db, ps := dbtestutil.NewDB(t)
|
||||
require.NoError(t, db.UpsertChatDesktopEnabled(chatdTestContext(t), true))
|
||||
// Provide an Anthropic key so the provider check passes.
|
||||
server := newInternalTestServer(t, db, ps, chatprovider.ProviderAPIKeys{
|
||||
Anthropic: "test-anthropic-key",
|
||||
})
|
||||
|
||||
ctx := chatdTestContext(t)
|
||||
user, model := seedInternalChatDeps(ctx, t, db)
|
||||
|
||||
// Create a root parent chat.
|
||||
parent, err := server.CreateChat(ctx, CreateOptions{
|
||||
OwnerID: user.ID,
|
||||
Title: "root-parent",
|
||||
ModelConfigID: model.ID,
|
||||
InitialUserContent: []codersdk.ChatMessagePart{codersdk.ChatMessageText("hello")},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
// Create a child chat under the parent.
|
||||
child, err := server.CreateChat(ctx, CreateOptions{
|
||||
OwnerID: user.ID,
|
||||
ParentChatID: uuid.NullUUID{
|
||||
UUID: parent.ID,
|
||||
Valid: true,
|
||||
},
|
||||
RootChatID: uuid.NullUUID{
|
||||
UUID: parent.ID,
|
||||
Valid: true,
|
||||
},
|
||||
Title: "child-subagent",
|
||||
ModelConfigID: model.ID,
|
||||
InitialUserContent: []codersdk.ChatMessagePart{codersdk.ChatMessageText("do something")},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
// Re-fetch the child so ParentChatID is populated.
|
||||
childChat, err := db.GetChatByID(ctx, child.ID)
|
||||
require.NoError(t, err)
|
||||
require.True(t, childChat.ParentChatID.Valid,
|
||||
"child chat must have a parent")
|
||||
|
||||
// Get tools as if the child chat is the current chat.
|
||||
tools := server.subagentTools(ctx, func() database.Chat { return childChat })
|
||||
tool := findToolByName(tools, "spawn_computer_use_agent")
|
||||
require.NotNil(t, tool, "spawn_computer_use_agent tool must be present")
|
||||
|
||||
resp, err := tool.Run(ctx, fantasy.ToolCall{
|
||||
ID: "call-2",
|
||||
Name: "spawn_computer_use_agent",
|
||||
Input: `{"prompt":"open browser"}`,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.True(t, resp.IsError, "expected an error response")
|
||||
assert.Contains(t, resp.Content, "delegated chats cannot create child subagents")
|
||||
}
|
||||
|
||||
func TestSpawnComputerUseAgent_DesktopDisabled(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
db, ps := dbtestutil.NewDB(t)
|
||||
server := newInternalTestServer(t, db, ps, chatprovider.ProviderAPIKeys{
|
||||
Anthropic: "test-anthropic-key",
|
||||
})
|
||||
|
||||
ctx := chatdTestContext(t)
|
||||
user, model := seedInternalChatDeps(ctx, t, db)
|
||||
parent, err := server.CreateChat(ctx, CreateOptions{
|
||||
OwnerID: user.ID,
|
||||
Title: "parent-desktop-disabled",
|
||||
ModelConfigID: model.ID,
|
||||
InitialUserContent: []codersdk.ChatMessagePart{codersdk.ChatMessageText("hello")},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
parentChat, err := db.GetChatByID(ctx, parent.ID)
|
||||
require.NoError(t, err)
|
||||
|
||||
tools := server.subagentTools(ctx, func() database.Chat { return parentChat })
|
||||
tool := findToolByName(tools, "spawn_computer_use_agent")
|
||||
assert.Nil(t, tool, "spawn_computer_use_agent tool must be omitted when desktop is disabled")
|
||||
}
|
||||
|
||||
func TestSpawnComputerUseAgent_UsesComputerUseModelNotParent(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
db, ps := dbtestutil.NewDB(t)
|
||||
require.NoError(t, db.UpsertChatDesktopEnabled(chatdTestContext(t), true))
|
||||
// Provide an Anthropic key so the tool can proceed.
|
||||
server := newInternalTestServer(t, db, ps, chatprovider.ProviderAPIKeys{
|
||||
Anthropic: "test-anthropic-key",
|
||||
})
|
||||
|
||||
ctx := chatdTestContext(t)
|
||||
user, model := seedInternalChatDeps(ctx, t, db)
|
||||
|
||||
// The parent uses an OpenAI model.
|
||||
require.Equal(t, "openai", model.Provider,
|
||||
"seed helper must create an OpenAI model")
|
||||
|
||||
parent, err := server.CreateChat(ctx, CreateOptions{
|
||||
OwnerID: user.ID,
|
||||
Title: "parent-openai",
|
||||
ModelConfigID: model.ID,
|
||||
InitialUserContent: []codersdk.ChatMessagePart{codersdk.ChatMessageText("hello")},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
parentChat, err := db.GetChatByID(ctx, parent.ID)
|
||||
require.NoError(t, err)
|
||||
|
||||
tools := server.subagentTools(ctx, func() database.Chat { return parentChat })
|
||||
tool := findToolByName(tools, "spawn_computer_use_agent")
|
||||
require.NotNil(t, tool)
|
||||
|
||||
resp, err := tool.Run(ctx, fantasy.ToolCall{
|
||||
ID: "call-3",
|
||||
Name: "spawn_computer_use_agent",
|
||||
Input: `{"prompt":"take a screenshot"}`,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.False(t, resp.IsError, "expected success but got: %s", resp.Content)
|
||||
|
||||
// Parse the response to get the child chat ID.
|
||||
var result map[string]any
|
||||
require.NoError(t, json.Unmarshal([]byte(resp.Content), &result))
|
||||
childIDStr, ok := result["chat_id"].(string)
|
||||
require.True(t, ok, "response must contain chat_id")
|
||||
|
||||
childID, err := uuid.Parse(childIDStr)
|
||||
require.NoError(t, err)
|
||||
|
||||
childChat, err := db.GetChatByID(ctx, childID)
|
||||
require.NoError(t, err)
|
||||
|
||||
// The child must have Mode=computer_use which causes
|
||||
// runChat to override the model to the predefined computer
|
||||
// use model instead of using the parent's model config.
|
||||
require.True(t, childChat.Mode.Valid)
|
||||
assert.Equal(t, database.ChatModeComputerUse, childChat.Mode.ChatMode)
|
||||
|
||||
// The predefined computer use model is Anthropic, which
|
||||
// differs from the parent's OpenAI model. This confirms
|
||||
// that the child will not inherit the parent's model at
|
||||
// runtime.
|
||||
assert.NotEqual(t, model.Provider, chattool.ComputerUseModelProvider,
|
||||
"computer use model provider must differ from parent model provider")
|
||||
assert.Equal(t, "anthropic", chattool.ComputerUseModelProvider)
|
||||
assert.NotEmpty(t, chattool.ComputerUseModelName)
|
||||
}
|
||||
|
||||
func TestIsSubagentDescendant(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
db, ps := dbtestutil.NewDB(t)
|
||||
server := newInternalTestServer(t, db, ps, chatprovider.ProviderAPIKeys{})
|
||||
|
||||
ctx := chatdTestContext(t)
|
||||
user, model := seedInternalChatDeps(ctx, t, db)
|
||||
|
||||
// Build a chain: root -> child -> grandchild.
|
||||
root, err := server.CreateChat(ctx, CreateOptions{
|
||||
OwnerID: user.ID,
|
||||
Title: "root",
|
||||
ModelConfigID: model.ID,
|
||||
InitialUserContent: []codersdk.ChatMessagePart{codersdk.ChatMessageText("root")},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
child, err := server.CreateChat(ctx, CreateOptions{
|
||||
OwnerID: user.ID,
|
||||
ParentChatID: uuid.NullUUID{
|
||||
UUID: root.ID,
|
||||
Valid: true,
|
||||
},
|
||||
RootChatID: uuid.NullUUID{
|
||||
UUID: root.ID,
|
||||
Valid: true,
|
||||
},
|
||||
Title: "child",
|
||||
ModelConfigID: model.ID,
|
||||
InitialUserContent: []codersdk.ChatMessagePart{codersdk.ChatMessageText("child")},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
grandchild, err := server.CreateChat(ctx, CreateOptions{
|
||||
OwnerID: user.ID,
|
||||
ParentChatID: uuid.NullUUID{
|
||||
UUID: child.ID,
|
||||
Valid: true,
|
||||
},
|
||||
RootChatID: uuid.NullUUID{
|
||||
UUID: root.ID,
|
||||
Valid: true,
|
||||
},
|
||||
Title: "grandchild",
|
||||
ModelConfigID: model.ID,
|
||||
InitialUserContent: []codersdk.ChatMessagePart{codersdk.ChatMessageText("grandchild")},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
// Build a separate, unrelated chain.
|
||||
unrelated, err := server.CreateChat(ctx, CreateOptions{
|
||||
OwnerID: user.ID,
|
||||
Title: "unrelated-root",
|
||||
ModelConfigID: model.ID,
|
||||
InitialUserContent: []codersdk.ChatMessagePart{codersdk.ChatMessageText("unrelated")},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
unrelatedChild, err := server.CreateChat(ctx, CreateOptions{
|
||||
OwnerID: user.ID,
|
||||
ParentChatID: uuid.NullUUID{
|
||||
UUID: unrelated.ID,
|
||||
Valid: true,
|
||||
},
|
||||
RootChatID: uuid.NullUUID{
|
||||
UUID: unrelated.ID,
|
||||
Valid: true,
|
||||
},
|
||||
Title: "unrelated-child",
|
||||
ModelConfigID: model.ID,
|
||||
InitialUserContent: []codersdk.ChatMessagePart{codersdk.ChatMessageText("unrelated-child")},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
ancestor uuid.UUID
|
||||
target uuid.UUID
|
||||
want bool
|
||||
}{
|
||||
{
|
||||
name: "SameID",
|
||||
ancestor: root.ID,
|
||||
target: root.ID,
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "DirectChild",
|
||||
ancestor: root.ID,
|
||||
target: child.ID,
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "GrandChild",
|
||||
ancestor: root.ID,
|
||||
target: grandchild.ID,
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "Unrelated",
|
||||
ancestor: root.ID,
|
||||
target: unrelatedChild.ID,
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "RootChat",
|
||||
ancestor: child.ID,
|
||||
target: root.ID,
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "BrokenChain",
|
||||
ancestor: root.ID,
|
||||
target: uuid.New(),
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "NotDescendant",
|
||||
ancestor: unrelated.ID,
|
||||
target: child.ID,
|
||||
want: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctx := chatdTestContext(t)
|
||||
got, err := isSubagentDescendant(ctx, db, tt.ancestor, tt.target)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, tt.want, got)
|
||||
})
|
||||
}
|
||||
}
|
||||
218
coderd/chatd/subagent_test.go
Normal file
218
coderd/chatd/subagent_test.go
Normal file
@@ -0,0 +1,218 @@
|
||||
package chatd_test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/coder/coder/v2/coderd/chatd"
|
||||
"github.com/coder/coder/v2/coderd/database"
|
||||
"github.com/coder/coder/v2/coderd/database/dbtestutil"
|
||||
"github.com/coder/coder/v2/codersdk"
|
||||
"github.com/coder/coder/v2/testutil"
|
||||
)
|
||||
|
||||
func TestSpawnComputerUseAgent_CreatesChildWithChatMode(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
db, ps := dbtestutil.NewDB(t)
|
||||
server := newTestServer(t, db, ps, uuid.New())
|
||||
ctx := testutil.Context(t, testutil.WaitLong)
|
||||
user, model := seedChatDependencies(ctx, t, db)
|
||||
|
||||
// Create a parent chat.
|
||||
parent, err := server.CreateChat(ctx, chatd.CreateOptions{
|
||||
OwnerID: user.ID,
|
||||
Title: "parent",
|
||||
ModelConfigID: model.ID,
|
||||
InitialUserContent: []codersdk.ChatMessagePart{codersdk.ChatMessageText("hello")},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
// Simulate what spawn_computer_use_agent does: set ChatMode
|
||||
// to computer_use and provide a system prompt.
|
||||
prompt := "Use the desktop to open Firefox"
|
||||
|
||||
child, err := server.CreateChat(ctx, chatd.CreateOptions{
|
||||
OwnerID: parent.OwnerID,
|
||||
ParentChatID: uuid.NullUUID{
|
||||
UUID: parent.ID,
|
||||
Valid: true,
|
||||
},
|
||||
RootChatID: uuid.NullUUID{
|
||||
UUID: parent.ID,
|
||||
Valid: true,
|
||||
},
|
||||
ModelConfigID: model.ID,
|
||||
Title: "computer-use",
|
||||
ChatMode: database.NullChatMode{ChatMode: database.ChatModeComputerUse, Valid: true},
|
||||
SystemPrompt: "Computer use instructions\n\n" + prompt,
|
||||
InitialUserContent: []codersdk.ChatMessagePart{codersdk.ChatMessageText(prompt)},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
// Verify parent-child relationship.
|
||||
require.True(t, child.ParentChatID.Valid)
|
||||
require.Equal(t, parent.ID, child.ParentChatID.UUID)
|
||||
|
||||
// Verify the chat type is set correctly.
|
||||
require.True(t, child.Mode.Valid)
|
||||
assert.Equal(t, database.ChatModeComputerUse, child.Mode.ChatMode)
|
||||
|
||||
// Confirm via a fresh DB read as well.
|
||||
got, err := db.GetChatByID(ctx, child.ID)
|
||||
require.NoError(t, err)
|
||||
require.True(t, got.Mode.Valid)
|
||||
assert.Equal(t, database.ChatModeComputerUse, got.Mode.ChatMode)
|
||||
}
|
||||
|
||||
func TestSpawnComputerUseAgent_SystemPromptFormat(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
db, ps := dbtestutil.NewDB(t)
|
||||
server := newTestServer(t, db, ps, uuid.New())
|
||||
ctx := testutil.Context(t, testutil.WaitLong)
|
||||
user, model := seedChatDependencies(ctx, t, db)
|
||||
|
||||
parent, err := server.CreateChat(ctx, chatd.CreateOptions{
|
||||
OwnerID: user.ID,
|
||||
Title: "parent",
|
||||
ModelConfigID: model.ID,
|
||||
InitialUserContent: []codersdk.ChatMessagePart{codersdk.ChatMessageText("hello")},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
prompt := "Navigate to settings page"
|
||||
systemPrompt := "Computer use instructions\n\n" + prompt
|
||||
|
||||
child, err := server.CreateChat(ctx, chatd.CreateOptions{
|
||||
OwnerID: parent.OwnerID,
|
||||
ParentChatID: uuid.NullUUID{
|
||||
UUID: parent.ID,
|
||||
Valid: true,
|
||||
},
|
||||
RootChatID: uuid.NullUUID{
|
||||
UUID: parent.ID,
|
||||
Valid: true,
|
||||
},
|
||||
ModelConfigID: model.ID,
|
||||
Title: "computer-use-format",
|
||||
ChatMode: database.NullChatMode{ChatMode: database.ChatModeComputerUse, Valid: true},
|
||||
SystemPrompt: systemPrompt,
|
||||
InitialUserContent: []codersdk.ChatMessagePart{codersdk.ChatMessageText(prompt)},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
messages, err := db.GetChatMessagesForPromptByChatID(ctx, child.ID)
|
||||
require.NoError(t, err)
|
||||
|
||||
// The system message raw content is a JSON-encoded string.
|
||||
// It should contain the system prompt with the user prompt.
|
||||
var rawSystemContent string
|
||||
for _, msg := range messages {
|
||||
if msg.Role != "system" {
|
||||
continue
|
||||
}
|
||||
if msg.Content.Valid {
|
||||
rawSystemContent = string(msg.Content.RawMessage)
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
assert.Contains(t, rawSystemContent, prompt,
|
||||
"system prompt raw content should contain the user prompt")
|
||||
}
|
||||
|
||||
func TestSpawnComputerUseAgent_ChildIsListedUnderParent(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
db, ps := dbtestutil.NewDB(t)
|
||||
server := newTestServer(t, db, ps, uuid.New())
|
||||
ctx := testutil.Context(t, testutil.WaitLong)
|
||||
user, model := seedChatDependencies(ctx, t, db)
|
||||
|
||||
parent, err := server.CreateChat(ctx, chatd.CreateOptions{
|
||||
OwnerID: user.ID,
|
||||
Title: "parent",
|
||||
ModelConfigID: model.ID,
|
||||
InitialUserContent: []codersdk.ChatMessagePart{codersdk.ChatMessageText("hello")},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
prompt := "Check the UI layout"
|
||||
|
||||
child, err := server.CreateChat(ctx, chatd.CreateOptions{
|
||||
OwnerID: parent.OwnerID,
|
||||
ParentChatID: uuid.NullUUID{
|
||||
UUID: parent.ID,
|
||||
Valid: true,
|
||||
},
|
||||
RootChatID: uuid.NullUUID{
|
||||
UUID: parent.ID,
|
||||
Valid: true,
|
||||
},
|
||||
ModelConfigID: model.ID,
|
||||
Title: "computer-use-child",
|
||||
ChatMode: database.NullChatMode{ChatMode: database.ChatModeComputerUse, Valid: true},
|
||||
SystemPrompt: "Computer use instructions\n\n" + prompt,
|
||||
InitialUserContent: []codersdk.ChatMessagePart{codersdk.ChatMessageText(prompt)},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
// Verify the child is linked to the parent.
|
||||
fetchedChild, err := db.GetChatByID(ctx, child.ID)
|
||||
require.NoError(t, err)
|
||||
require.True(t, fetchedChild.ParentChatID.Valid)
|
||||
assert.Equal(t, parent.ID, fetchedChild.ParentChatID.UUID)
|
||||
}
|
||||
|
||||
func TestSpawnComputerUseAgent_RootChatIDPropagation(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
db, ps := dbtestutil.NewDB(t)
|
||||
server := newTestServer(t, db, ps, uuid.New())
|
||||
ctx := testutil.Context(t, testutil.WaitLong)
|
||||
user, model := seedChatDependencies(ctx, t, db)
|
||||
|
||||
// Create a root parent chat (no parent of its own).
|
||||
parent, err := server.CreateChat(ctx, chatd.CreateOptions{
|
||||
OwnerID: user.ID,
|
||||
Title: "root-parent",
|
||||
ModelConfigID: model.ID,
|
||||
InitialUserContent: []codersdk.ChatMessagePart{codersdk.ChatMessageText("hello")},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
prompt := "Take a screenshot"
|
||||
|
||||
child, err := server.CreateChat(ctx, chatd.CreateOptions{
|
||||
OwnerID: parent.OwnerID,
|
||||
ParentChatID: uuid.NullUUID{
|
||||
UUID: parent.ID,
|
||||
Valid: true,
|
||||
},
|
||||
RootChatID: uuid.NullUUID{
|
||||
UUID: parent.ID,
|
||||
Valid: true,
|
||||
},
|
||||
ModelConfigID: model.ID,
|
||||
Title: "computer-use-root-test",
|
||||
ChatMode: database.NullChatMode{ChatMode: database.ChatModeComputerUse, Valid: true},
|
||||
SystemPrompt: "Computer use instructions\n\n" + prompt,
|
||||
InitialUserContent: []codersdk.ChatMessagePart{codersdk.ChatMessageText(prompt)},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
// When the parent has no RootChatID, the child's RootChatID
|
||||
// should point to the parent.
|
||||
require.True(t, child.RootChatID.Valid)
|
||||
assert.Equal(t, parent.ID, child.RootChatID.UUID)
|
||||
|
||||
// Verify chat was retrieved correctly from the DB.
|
||||
got, err := db.GetChatByID(ctx, child.ID)
|
||||
require.NoError(t, err)
|
||||
assert.True(t, got.RootChatID.Valid)
|
||||
assert.Equal(t, parent.ID, got.RootChatID.UUID)
|
||||
}
|
||||
128
coderd/chatd/usagelimit.go
Normal file
128
coderd/chatd/usagelimit.go
Normal file
@@ -0,0 +1,128 @@
|
||||
package chatd
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"golang.org/x/xerrors"
|
||||
|
||||
"github.com/coder/coder/v2/coderd/database"
|
||||
"github.com/coder/coder/v2/coderd/database/dbauthz"
|
||||
"github.com/coder/coder/v2/codersdk"
|
||||
)
|
||||
|
||||
// ComputeUsagePeriodBounds returns the UTC-aligned start and end bounds for the
|
||||
// active usage-limit period containing now.
|
||||
func ComputeUsagePeriodBounds(now time.Time, period codersdk.ChatUsageLimitPeriod) (start, end time.Time) {
|
||||
utcNow := now.UTC()
|
||||
|
||||
switch period {
|
||||
case codersdk.ChatUsageLimitPeriodDay:
|
||||
start = time.Date(utcNow.Year(), utcNow.Month(), utcNow.Day(), 0, 0, 0, 0, time.UTC)
|
||||
end = start.AddDate(0, 0, 1)
|
||||
case codersdk.ChatUsageLimitPeriodWeek:
|
||||
// Walk backward to Monday of the current ISO week.
|
||||
// ISO 8601 weeks always start on Monday, so this never
|
||||
// crosses an ISO-week boundary.
|
||||
start = time.Date(utcNow.Year(), utcNow.Month(), utcNow.Day(), 0, 0, 0, 0, time.UTC)
|
||||
for start.Weekday() != time.Monday {
|
||||
start = start.AddDate(0, 0, -1)
|
||||
}
|
||||
end = start.AddDate(0, 0, 7)
|
||||
case codersdk.ChatUsageLimitPeriodMonth:
|
||||
start = time.Date(utcNow.Year(), utcNow.Month(), 1, 0, 0, 0, 0, time.UTC)
|
||||
end = start.AddDate(0, 1, 0)
|
||||
default:
|
||||
panic(fmt.Sprintf("unknown chat usage limit period: %q", period))
|
||||
}
|
||||
|
||||
return start, end
|
||||
}
|
||||
|
||||
// ResolveUsageLimitStatus resolves the current usage-limit status for userID.
|
||||
//
|
||||
// Note: There is a potential race condition where two concurrent messages
|
||||
// from the same user can both pass the limit check if processed in
|
||||
// parallel, allowing brief overage. This is acceptable because:
|
||||
// - Cost is only known after the LLM API returns.
|
||||
// - Overage is bounded by message cost × concurrency.
|
||||
// - Fail-open is the deliberate design choice for this feature.
|
||||
//
|
||||
// Architecture note: today this path enforces one period globally
|
||||
// (day/week/month) from config.
|
||||
// To support simultaneous periods, add nullable
|
||||
// daily/weekly/monthly_limit_micros columns on override tables, where NULL
|
||||
// means no limit for that period.
|
||||
// Then scan spend once over the widest active window with conditional SUMs
|
||||
// for each period and compare each spend/limit pair Go-side, blocking on
|
||||
// whichever period is tightest.
|
||||
func ResolveUsageLimitStatus(ctx context.Context, db database.Store, userID uuid.UUID, now time.Time) (*codersdk.ChatUsageLimitStatus, error) {
|
||||
//nolint:gocritic // AsChatd provides narrowly-scoped daemon access for
|
||||
// deployment config reads and cross-user chat spend aggregation.
|
||||
authCtx := dbauthz.AsChatd(ctx)
|
||||
|
||||
config, err := db.GetChatUsageLimitConfig(authCtx)
|
||||
if err != nil {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, nil //nolint:nilnil // Nil status cleanly signals disabled limits.
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
if !config.Enabled {
|
||||
return nil, nil //nolint:nilnil // Nil status cleanly signals disabled limits.
|
||||
}
|
||||
|
||||
period, ok := mapDBPeriodToSDK(config.Period)
|
||||
if !ok {
|
||||
return nil, xerrors.Errorf("invalid chat usage limit period %q", config.Period)
|
||||
}
|
||||
|
||||
// Resolve effective limit in a single query:
|
||||
// individual override > group limit > global default.
|
||||
effectiveLimit, err := db.ResolveUserChatSpendLimit(authCtx, userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// -1 means limits are disabled (shouldn't happen since we checked above,
|
||||
// but handle gracefully).
|
||||
if effectiveLimit < 0 {
|
||||
return nil, nil //nolint:nilnil // Nil status cleanly signals disabled limits.
|
||||
}
|
||||
|
||||
start, end := ComputeUsagePeriodBounds(now, period)
|
||||
|
||||
spendTotal, err := db.GetUserChatSpendInPeriod(authCtx, database.GetUserChatSpendInPeriodParams{
|
||||
UserID: userID,
|
||||
StartTime: start,
|
||||
EndTime: end,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &codersdk.ChatUsageLimitStatus{
|
||||
IsLimited: true,
|
||||
Period: period,
|
||||
SpendLimitMicros: &effectiveLimit,
|
||||
CurrentSpend: spendTotal,
|
||||
PeriodStart: start,
|
||||
PeriodEnd: end,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func mapDBPeriodToSDK(dbPeriod string) (codersdk.ChatUsageLimitPeriod, bool) {
|
||||
switch dbPeriod {
|
||||
case string(codersdk.ChatUsageLimitPeriodDay):
|
||||
return codersdk.ChatUsageLimitPeriodDay, true
|
||||
case string(codersdk.ChatUsageLimitPeriodWeek):
|
||||
return codersdk.ChatUsageLimitPeriodWeek, true
|
||||
case string(codersdk.ChatUsageLimitPeriodMonth):
|
||||
return codersdk.ChatUsageLimitPeriodMonth, true
|
||||
default:
|
||||
return "", false
|
||||
}
|
||||
}
|
||||
132
coderd/chatd/usagelimit_test.go
Normal file
132
coderd/chatd/usagelimit_test.go
Normal file
@@ -0,0 +1,132 @@
|
||||
package chatd //nolint:testpackage // Keeps chatd unit tests in the package.
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/coder/coder/v2/codersdk"
|
||||
)
|
||||
|
||||
func TestComputeUsagePeriodBounds(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
newYork, err := time.LoadLocation("America/New_York")
|
||||
if err != nil {
|
||||
t.Fatalf("load America/New_York: %v", err)
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
now time.Time
|
||||
period codersdk.ChatUsageLimitPeriod
|
||||
wantStart time.Time
|
||||
wantEnd time.Time
|
||||
}{
|
||||
{
|
||||
name: "day/mid_day",
|
||||
now: time.Date(2025, time.June, 15, 14, 30, 0, 0, time.UTC),
|
||||
period: codersdk.ChatUsageLimitPeriodDay,
|
||||
wantStart: time.Date(2025, time.June, 15, 0, 0, 0, 0, time.UTC),
|
||||
wantEnd: time.Date(2025, time.June, 16, 0, 0, 0, 0, time.UTC),
|
||||
},
|
||||
{
|
||||
name: "day/midnight_exactly",
|
||||
now: time.Date(2025, time.June, 15, 0, 0, 0, 0, time.UTC),
|
||||
period: codersdk.ChatUsageLimitPeriodDay,
|
||||
wantStart: time.Date(2025, time.June, 15, 0, 0, 0, 0, time.UTC),
|
||||
wantEnd: time.Date(2025, time.June, 16, 0, 0, 0, 0, time.UTC),
|
||||
},
|
||||
{
|
||||
name: "day/end_of_day",
|
||||
now: time.Date(2025, time.June, 15, 23, 59, 59, 0, time.UTC),
|
||||
period: codersdk.ChatUsageLimitPeriodDay,
|
||||
wantStart: time.Date(2025, time.June, 15, 0, 0, 0, 0, time.UTC),
|
||||
wantEnd: time.Date(2025, time.June, 16, 0, 0, 0, 0, time.UTC),
|
||||
},
|
||||
{
|
||||
name: "week/wednesday",
|
||||
now: time.Date(2025, time.June, 11, 10, 0, 0, 0, time.UTC),
|
||||
period: codersdk.ChatUsageLimitPeriodWeek,
|
||||
wantStart: time.Date(2025, time.June, 9, 0, 0, 0, 0, time.UTC),
|
||||
wantEnd: time.Date(2025, time.June, 16, 0, 0, 0, 0, time.UTC),
|
||||
},
|
||||
{
|
||||
name: "week/monday",
|
||||
now: time.Date(2025, time.June, 9, 0, 0, 0, 0, time.UTC),
|
||||
period: codersdk.ChatUsageLimitPeriodWeek,
|
||||
wantStart: time.Date(2025, time.June, 9, 0, 0, 0, 0, time.UTC),
|
||||
wantEnd: time.Date(2025, time.June, 16, 0, 0, 0, 0, time.UTC),
|
||||
},
|
||||
{
|
||||
name: "week/sunday",
|
||||
now: time.Date(2025, time.June, 15, 23, 0, 0, 0, time.UTC),
|
||||
period: codersdk.ChatUsageLimitPeriodWeek,
|
||||
wantStart: time.Date(2025, time.June, 9, 0, 0, 0, 0, time.UTC),
|
||||
wantEnd: time.Date(2025, time.June, 16, 0, 0, 0, 0, time.UTC),
|
||||
},
|
||||
{
|
||||
name: "week/year_boundary",
|
||||
now: time.Date(2024, time.December, 31, 12, 0, 0, 0, time.UTC),
|
||||
period: codersdk.ChatUsageLimitPeriodWeek,
|
||||
wantStart: time.Date(2024, time.December, 30, 0, 0, 0, 0, time.UTC),
|
||||
wantEnd: time.Date(2025, time.January, 6, 0, 0, 0, 0, time.UTC),
|
||||
},
|
||||
{
|
||||
name: "month/mid_month",
|
||||
now: time.Date(2025, time.June, 15, 0, 0, 0, 0, time.UTC),
|
||||
period: codersdk.ChatUsageLimitPeriodMonth,
|
||||
wantStart: time.Date(2025, time.June, 1, 0, 0, 0, 0, time.UTC),
|
||||
wantEnd: time.Date(2025, time.July, 1, 0, 0, 0, 0, time.UTC),
|
||||
},
|
||||
{
|
||||
name: "month/first_day",
|
||||
now: time.Date(2025, time.June, 1, 0, 0, 0, 0, time.UTC),
|
||||
period: codersdk.ChatUsageLimitPeriodMonth,
|
||||
wantStart: time.Date(2025, time.June, 1, 0, 0, 0, 0, time.UTC),
|
||||
wantEnd: time.Date(2025, time.July, 1, 0, 0, 0, 0, time.UTC),
|
||||
},
|
||||
{
|
||||
name: "month/last_day",
|
||||
now: time.Date(2025, time.June, 30, 23, 59, 59, 0, time.UTC),
|
||||
period: codersdk.ChatUsageLimitPeriodMonth,
|
||||
wantStart: time.Date(2025, time.June, 1, 0, 0, 0, 0, time.UTC),
|
||||
wantEnd: time.Date(2025, time.July, 1, 0, 0, 0, 0, time.UTC),
|
||||
},
|
||||
{
|
||||
name: "month/february",
|
||||
now: time.Date(2025, time.February, 15, 12, 0, 0, 0, time.UTC),
|
||||
period: codersdk.ChatUsageLimitPeriodMonth,
|
||||
wantStart: time.Date(2025, time.February, 1, 0, 0, 0, 0, time.UTC),
|
||||
wantEnd: time.Date(2025, time.March, 1, 0, 0, 0, 0, time.UTC),
|
||||
},
|
||||
{
|
||||
name: "month/leap_year_february",
|
||||
now: time.Date(2024, time.February, 29, 12, 0, 0, 0, time.UTC),
|
||||
period: codersdk.ChatUsageLimitPeriodMonth,
|
||||
wantStart: time.Date(2024, time.February, 1, 0, 0, 0, 0, time.UTC),
|
||||
wantEnd: time.Date(2024, time.March, 1, 0, 0, 0, 0, time.UTC),
|
||||
},
|
||||
{
|
||||
name: "day/non_utc_timezone",
|
||||
now: time.Date(2025, time.June, 15, 22, 0, 0, 0, newYork),
|
||||
period: codersdk.ChatUsageLimitPeriodDay,
|
||||
wantStart: time.Date(2025, time.June, 16, 0, 0, 0, 0, time.UTC),
|
||||
wantEnd: time.Date(2025, time.June, 17, 0, 0, 0, 0, time.UTC),
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
tc := tc
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
start, end := ComputeUsagePeriodBounds(tc.now, tc.period)
|
||||
if !start.Equal(tc.wantStart) {
|
||||
t.Errorf("start: got %v, want %v", start, tc.wantStart)
|
||||
}
|
||||
if !end.Equal(tc.wantEnd) {
|
||||
t.Errorf("end: got %v, want %v", end, tc.wantEnd)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
1295
coderd/chats.go
1295
coderd/chats.go
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user