Compare commits

..

50 Commits

Author SHA1 Message Date
Charlie Voiselle eee13c42a4 docs(cli): reference --oidc-group-mapping flag name instead of 'legacy'
Issue: Used internal nomenclature instead of user-facing flag name

The previous fix referenced 'legacy group name mapping' but users don't
know what that means - it's an internal implementation detail. Users
configure this via the --oidc-group-mapping flag.

Changed to: 'This filter is applied after the oidc-group-mapping.'

This directly references the flag name users would actually use, making
the relationship clear and actionable. Users can now understand:
- The regex filter applies to group names
- Group names may have been transformed by --oidc-group-mapping first
- They need to write regex patterns that match the mapped names

Example: If --oidc-group-mapping transforms 'developers' to 'dev-team',
the regex in --oidc-group-regex-filter will match against 'dev-team'.
2026-02-09 16:14:00 -05:00
Charlie Voiselle 65b48c0f84 docs(cli): fix help text for --oidc-group-regex-filter (clarify mapping order)
Issue: Removed ordering information when it was actually helpful

The previous correction removed the sentence about filter order to avoid
confusion, but this actually made the description LESS clear. Users need
to understand that the regex filter operates on group names AFTER any
legacy name mapping has been applied.

Example: If IdP sends 'developers' and LegacyNameMapping renames it to
'dev-team', the regex filter will match against 'dev-team', not 'developers'.

Changed to: 'This filter is applied after legacy group name mapping.'

This clarifies:
1. It's the LEGACY mapping (name→name) not the new Mapping (name→IDs)
2. The regex operates on potentially-renamed group names
3. The filter happens before the final ID mapping

Code reference: coderd/idpsync/group.go lines 379-398
- Line 380: LegacyNameMapping (name → name)
- Line 386: RegexFilter (on the potentially renamed name)
- Line 392: Mapping (name → []uuid.UUID)
2026-02-09 16:13:59 -05:00
Charlie Voiselle 30cdf29e52 docs(cli): fix help text for --oidc-group-regex-filter (final correction)
Issue: Previous description incorrectly stated filter order

The correction commit stated 'This filter is applied after the group mapping'
but the actual code order in coderd/idpsync/group.go lines 379-398 shows:
1. Legacy group mappings
2. Regex filter
3. (New) group mapping

Since the filter order is complex and the description was causing confusion,
removed the last sentence entirely. The first two sentences clearly explain
what the flag does without introducing incorrect ordering claims.

This follows the verification report's recommendation to remove the
confusing last sentence.
2026-02-09 16:13:59 -05:00
Charlie Voiselle b1d2bb6d71 docs(cli): fix help text for --external-auth-providers
Issue: Clarity - vague description

Changed 'External Authentication providers.' to 'Configure external authentication providers for Git and other services.' to explain what these providers are actually used for.
2026-02-09 16:13:59 -05:00
Charlie Voiselle 94bad2a956 docs(cli): fix help text for --workspace-prebuilds-reconciliation-backoff-lookback-period
Issue: Clarity - unclear purpose

Changed 'Interval to look back to determine number of failed prebuilds, which influences backoff' to 'Time period to look back when counting failed prebuilds to calculate the backoff delay' to clarify this determines the time window for counting failures.
2026-02-09 16:13:59 -05:00
Charlie Voiselle 111714c7ed docs(cli): fix help text for --workspace-prebuilds-reconciliation-backoff-interval
Issue: Clarity - confusing wording about backoff behavior

Changed 'Interval to increase reconciliation backoff by when prebuilds fail, after which a retry attempt is made' to 'Amount of time to add to the reconciliation backoff delay after each prebuild failure, before the next retry attempt is made' to clarify this is an incremental addition to the backoff delay.
2026-02-09 16:13:58 -05:00
Charlie Voiselle 1f9c516c5c docs(cli): fix help text for --workspace-prebuilds-failure-hard-limit
Issue: Clarity - unclear what 'hits the hard limit' means

Changed 'before a preset hits the hard limit' to 'before a preset is considered hard-limited and stops automatic prebuild creation' to explain what actually happens when the limit is reached.
2026-02-09 16:13:58 -05:00
Charlie Voiselle 3645c65bb2 docs(cli): fix help text for --workspace-hostname-suffix
Issue: Clarity - incomplete example hostname

Changed 'in SSH config and Coder Connect on Coder Desktop' to 'for SSH connections and Coder Connect' for conciseness. Updated the example from 'myworkspace.coder' to the full format 'agent.workspace.owner.coder' to show the complete hostname structure.
2026-02-09 16:13:58 -05:00
Charlie Voiselle d3d2d2fb1e docs(cli): fix help text for --workspace-agent-logs-retention
Issue: Clarity - ambiguous scope

Changed 'Logs from the latest build are always retained' to 'Logs from the latest build for each workspace are always retained' to clarify that this applies per-workspace, not just one latest build globally.
2026-02-09 16:13:58 -05:00
Charlie Voiselle 086fb1f5d5 docs(cli): fix help text for --block-direct-connections
Issue: Clarity - imprecise wording about STUN behavior

Clarified that 'Workspace agents' (not 'Workspaces') reach out to STUN servers, changed 'get their address' to 'discover their address', and simplified 'until they are restarted after this change has been made' to just 'until they are restarted'.
2026-02-09 16:13:58 -05:00
Charlie Voiselle a73a535a5b docs(cli): fix help text for --proxy-health-interval
Issue: Clarity - awkward phrasing

Changed 'in which coderd should be checking' to 'at which coderd checks' for more concise, natural phrasing.
2026-02-09 16:13:57 -05:00
Charlie Voiselle 96e01c3018 docs(cli): fix help text for --email-tls-cert-key-file
Issue: Clarity - vague description

Changed 'Certificate key file to use' to 'Private key file for the client certificate' to clarify this is the private key that pairs with --email-tls-cert-file.
2026-02-09 16:13:57 -05:00
Charlie Voiselle 6b10a0359b docs(cli): fix help text for --email-tls-cert-file
Issue: Clarity - vague description

Changed 'Certificate file to use' to 'Client certificate file for mutual TLS authentication' to clarify what this certificate is for and when it's needed.
2026-02-09 16:13:57 -05:00
Charlie Voiselle b62583ad4b docs(cli): fix help text for --oidc-user-role-default
Issue: Clarity - ambiguous relationship between defaults and synced roles

Added 'in addition to synced roles' to clarify that these defaults don't replace synced roles. Also clarified that 'member' is always assigned 'regardless of this setting' to avoid confusion about whether this setting affects the member role.
2026-02-09 16:13:57 -05:00
Charlie Voiselle 3d6727a2cb docs(cli): fix help text for --oidc-group-field
Issue: Clarity - unclear structure

Reordered to put the primary purpose first: 'OIDC claim field to use as the user's groups' before the conditional requirement. This makes the description more scannable and understandable.
2026-02-09 16:13:56 -05:00
Charlie Voiselle b163962a14 docs(cli): fix help text for --aibridge-circuit-breaker-interval
Issue: Clarity - confusing technical jargon

Changed 'Cyclic period of the closed state for clearing internal failure counts' to 'Time window for counting failures before resetting the failure count in the closed state' to explain what the interval actually does in clearer terms.
2026-02-09 16:13:56 -05:00
Charlie Voiselle 9aca4ea27c docs(cli): fix help text for --aibridge-circuit-breaker-enabled
Issue: Clarity - ambiguous error code description

Changed '(429, 503, 529 overloaded)' to '(HTTP 429, 503, 529)' and added 'and overload errors' to clarify that these are HTTP status codes and what they represent.
2026-02-09 16:13:56 -05:00
Charlie Voiselle b0c10131ea docs(cli): fix help text for --aibridge-retention
Issue: Clarity - wordy phrasing

Simplified 'Length of time to retain data such as interceptions and all related records (token, prompt, tool use)' to 'How long to retain AI Bridge data including interceptions, tokens, prompts, and tool usage records' for more natural, clearer phrasing.
2026-02-09 16:13:56 -05:00
Charlie Voiselle c8c7e13e96 docs(cli): fix help text for --aibridge-inject-coder-mcp-tools
Issue: Clarity - awkward phrasing and formatting

Changed 'Whether to inject' to 'Enable injection of' for consistency with other boolean flags. Simplified the requirements clause and changed double quotes to single quotes for consistency.
2026-02-09 16:13:55 -05:00
Charlie Voiselle 249b7ea38e docs(cli): fix help text for --aibridge-enabled
Issue: Clarity - unclear technical jargon

Changed 'Whether to start an in-memory aibridged instance' to 'Enable the embedded AI Bridge service to intercept and record AI provider requests' to explain what the feature actually does in user-friendly terms.
2026-02-09 16:13:55 -05:00
Charlie Voiselle 1333096e25 docs(cli): fix help text for --oidc-group-regex-filter (correction)
Issue: Previous fix introduced confusing circular wording

The previous commit incorrectly changed the ending to 'after the group mapping and regex filter' which is nonsensical since this flag configures THE regex filter itself. Reverted to the correct wording: 'after the group mapping'.

The only valid changes from the original are:
- Added comma after 'If provided'
- Simplified 'allows for filtering' to 'allows filtering'
2026-02-09 16:13:55 -05:00
Charlie Voiselle 54bc9324dd docs(cli): fix help text for --samesite-auth-cookie
Issue: Grammar - missing word

Added missing 'if' to read 'Controls if the SameSite property is set' instead of 'Controls the SameSite property is set'.
2026-02-09 16:13:55 -05:00
Charlie Voiselle 109e5f2b19 docs(cli): fix help text for --enable-authz-recordings
Issue: Grammar - acronym capitalization

Capitalized 'API' (Application Programming Interface) - should always be uppercase.
2026-02-09 16:13:55 -05:00
Charlie Voiselle ee176b4207 docs(cli): fix help text for --ssh-config-options
Issue: Grammar - missing space after period

Added missing space after period between sentences: 'commas.' + 'Using' → 'commas. ' + 'Using'.
2026-02-09 16:13:54 -05:00
Charlie Voiselle 7e1e16be33 docs(cli): fix help text for --prometheus-address
Issue: Grammar - proper noun capitalization

Capitalized 'Prometheus' as it's a proper noun.
2026-02-09 16:13:54 -05:00
Charlie Voiselle 5cfe8082ce docs(cli): fix help text for --prometheus-enable
Issue: Grammar - proper noun capitalization

Capitalized 'Prometheus' as it's a proper noun (the name of the monitoring system).
2026-02-09 16:13:54 -05:00
Charlie Voiselle 6b7f672834 docs(cli): fix help text for --allow-custom-quiet-hours
Issue: Grammar - awkward phrasing

Changed 'for workspaces to stop in' to 'for when workspaces are stopped' for more natural phrasing.
2026-02-09 16:13:53 -05:00
Charlie Voiselle c55f6252a1 docs(cli): fix help text for --tls-client-ca-file
Issue: Grammar - missing article

Added missing article 'the' before 'client' to read 'authenticity of the client'.
2026-02-09 16:13:53 -05:00
Charlie Voiselle 842553b677 docs(cli): fix help text for --tls-ciphers
Issue: Grammar - missing verb

Fixed missing 'are' in 'that allowed to be used' → 'that are allowed to be used'.
2026-02-09 16:13:53 -05:00
Charlie Voiselle 05a771ba77 docs(cli): fix help text for --derp-server-stun-addresses
Issue: Grammar - incorrect possessive

Fixed "it's" (contraction of "it is") → "its" (possessive). Should be 'Each STUN server will get its own DERP region'.
2026-02-09 16:13:53 -05:00
Charlie Voiselle 70a0d42e65 docs(cli): fix help text for --derp-server-region-name
Issue: Grammar - malformed sentence

Fixed malformed sentence 'Region name that for' → 'Region name to use for'. The original was missing a verb.
2026-02-09 16:13:52 -05:00
Charlie Voiselle 6b1d73b466 docs(cli): fix help text for --notifications-store-sync-buffer-size
Issue: Grammar - typo

Fixed typo: 'change' → 'chance'. Same typo as in --notifications-store-sync-interval.
2026-02-09 16:13:52 -05:00
Charlie Voiselle d7b9596145 docs(cli): fix help text for --notifications-store-sync-interval
Issue: Grammar - typo

Fixed typo: 'change' → 'chance'. The sentence should read 'the lower the chance of state inconsistency'.
2026-02-09 16:13:52 -05:00
Charlie Voiselle 7a0aa1a40a docs(cli): fix help text for --oidc-signups-disabled-text
Issue: Grammar - awkward phrasing

Changed 'The custom text to show on the error page informing about disabled OIDC signups' to 'Custom text to show on the error page when OIDC signups are disabled' for clearer, more direct phrasing. Removed unnecessary 'The' article.
2026-02-09 16:13:52 -05:00
Charlie Voiselle 4d8ea43e11 docs(cli): fix help text for --oidc-icon-url
Issue: Grammar - redundant phrasing

Changed 'URL pointing to the icon' to 'URL of the icon'. The phrase 'pointing to' is redundant since a URL inherently points to a resource.
2026-02-09 16:13:52 -05:00
Charlie Voiselle 6fddae98f6 docs(cli): fix help text for --oidc-group-regex-filter
Issue: Grammar - missing comma + simplification + filter order clarification

Added missing comma after 'If provided'. Simplified 'allows for filtering' to 'allows filtering'. Clarified filter order to match the actual implementation.
2026-02-09 16:13:51 -05:00
Charlie Voiselle e33fbb6087 docs(cli): fix help text for --oidc-group-mapping
Issue: Grammar - subject-verb agreement + awkward phrasing

Changed 'the group in Coder it should map to' to 'the groups in Coder they should map to' for proper plural agreement. Also simplified 'for when' to 'when'.
2026-02-09 16:13:51 -05:00
Charlie Voiselle 2337393e13 docs(cli): fix help text for --oidc-client-cert-file
Issue: Grammar - incorrect acronym capitalization

Changed 'Pem' to 'PEM', 'oauth2' to 'OAuth2', and 'x509' to 'X.509'. These are standard capitalizations for these acronyms and standards.
2026-02-09 16:13:51 -05:00
Charlie Voiselle d7357a1b0a docs(cli): fix help text for --oidc-client-key-file
Issue: Grammar - incorrect acronym capitalization

Changed 'Pem' to 'PEM' (Privacy Enhanced Mail), 'oauth2' to 'OAuth2', and 'IDP' to 'IdP' (Identity Provider). These are standard capitalizations for these acronyms.
2026-02-09 16:13:51 -05:00
Charlie Voiselle afbf1af29c docs(cli): fix help text for --oauth2-github-allow-everyone
Issue: Grammar - unclear and run-on sentence

Changed 'Allow all logins, setting this option means...' to 'Allow all GitHub users to authenticate. When enabled, allowed orgs and teams must be empty.' This separates the run-on sentence and clarifies what 'all logins' means (all GitHub users).
2026-02-09 16:13:50 -05:00
Charlie Voiselle 1d834c747c docs(cli): fix help text for --aibridge-circuit-breaker-failure-threshold
Issue: Grammar - subject-verb agreement

Changed 'triggers' to 'trigger' for correct subject-verb agreement. 'Number' is the subject, which takes the singular form, but 'failures' is the head of the relative clause 'that trigger...', making 'trigger' (plural) correct.
2026-02-09 16:13:50 -05:00
Charlie Voiselle a80edec752 docs(cli): fix help text for --aibridge-bedrock-access-key-secret
Issue: Grammar - wordy and redundant phrasing

Simplified from 'The access key secret to use with the access key to authenticate against' to 'AWS secret access key for authenticating with'. Uses standard AWS terminology and eliminates redundancy.
2026-02-09 16:13:50 -05:00
Charlie Voiselle 2a6473e8c6 docs(cli): fix help text for --aibridge-bedrock-access-key
Issue: Grammar - awkward phrasing

Changed 'The access key to authenticate against' to 'AWS access key for authenticating with' for consistency and clarity. Uses standard AWS terminology.
2026-02-09 16:13:50 -05:00
Charlie Voiselle 1f9c0b9b7f docs(cli): fix help text for --aibridge-anthropic-key
Issue: Grammar - awkward phrasing

Changed 'The key to authenticate against' to 'API key for authenticating with' for consistency with --aibridge-openai-key and more natural phrasing.
2026-02-09 16:13:49 -05:00
Charlie Voiselle 5494afabd8 docs(cli): fix help text for --aibridge-openai-key
Issue: Grammar - awkward phrasing

Changed 'The key to authenticate against' to 'API key for authenticating with' for more natural, concise phrasing. This matches standard API documentation conventions.
2026-02-09 16:13:49 -05:00
Charlie Voiselle 07c6e86a50 docs(cli): fix help text for --notifications-email-hello
Issue: Factually incorrect description of SMTP HELO/EHLO (deprecated alias)

Same issue as --email-hello. This is a deprecated alias but still needs the correct description. The HELO/EHLO command identifies the client to the server, not the server itself.

Fix: Clarified this identifies 'this client to the SMTP server'.
2026-02-09 16:13:49 -05:00
Charlie Voiselle b543821a1c docs(cli): fix help text for --email-hello
Issue: Factually incorrect description of SMTP HELO/EHLO

The description incorrectly stated this identifies 'the SMTP server' when it actually identifies the CLIENT to the server. The HELO/EHLO command is how the client introduces itself to the SMTP server during connection.

Fix: Clarified this identifies 'this client to the SMTP server' which accurately reflects the SMTP protocol.
2026-02-09 16:13:49 -05:00
Charlie Voiselle e8b7045a9b docs(cli): fix help text for --pprof-enable
Issue: Factually incorrect terminology

The description incorrectly stated pprof serves 'metrics' when it actually serves profiling data (CPU profiles, memory profiles, goroutines, etc.). Metrics are Prometheus's domain, not pprof's.

Fix: Changed 'metrics' to 'profiling endpoints' to accurately describe what pprof provides.
2026-02-09 16:13:48 -05:00
Charlie Voiselle 2571089528 docs(cli): fix help text for --oidc-user-role-mapping
Issue: Factually incorrect (confuses roles with groups) + grammar error

The description incorrectly stated this maps to 'groups in Coder' when it actually maps to site ROLES (member, admin, etc.). Also had a grammar error: 'will ignored' should be 'will be ignored'.

Fix: Corrected to clarify this maps OIDC role names to Coder role names, and fixed the grammar error.
2026-02-09 16:13:48 -05:00
Charlie Voiselle 1fb733fe1e docs(cli): fix help text for --oidc-allowed-groups
Issue: Factually incorrect filter order

The description incorrectly stated that the check is applied 'after the group mapping and before the regex filter'. This is wrong.

Fix: Updated to reflect actual behavior where the check is applied BEFORE any group mapping or filtering. Also clarified the positive case (users WITH at least one matching group are allowed) instead of the confusing double-negative phrasing.
2026-02-09 16:13:48 -05:00
2011 changed files with 31663 additions and 229497 deletions
-343
View File
@@ -1,343 +0,0 @@
---
name: deep-review
description: "Multi-reviewer code review. Spawns domain-specific reviewers in parallel, cross-checks findings, posts a single structured GitHub review."
---
# Deep Review
Multi-reviewer code review. Spawns domain-specific reviewers in parallel, cross-checks their findings for contradictions and convergence, then posts a single structured GitHub review with inline comments.
## When to use this skill
- PRs touching 3+ subsystems, >500 lines, or requiring domain-specific expertise (security, concurrency, database).
- When you want independent perspectives cross-checked against each other, not just a single-pass review.
Use `.claude/skills/code-review/` for focused single-domain changes or quick single-pass reviews.
**Prerequisite:** This skill requires the ability to spawn parallel subagents. If your agent runtime cannot spawn subagents, use code-review instead.
**Severity scales:** Deep-review uses P0P4 (consequence-based). Code-review uses 🔴🟡🔵. Both are valid; they serve different review depths. Approximate mapping: P0P1 ≈ 🔴, P2 ≈ 🟡, P3P4 ≈ 🔵.
## When NOT to use this skill
- Docs-only or config-only PRs (no code to structurally review). Use `.claude/skills/doc-check/` instead.
- Single-file changes under ~50 lines.
- The PR author asked for a quick review.
## 0. Proportionality check
Estimate scope before committing to a deep review. If the PR has fewer than 3 files and fewer than 100 lines changed, suggest code-review instead. If the PR is docs-only, suggest doc-check. Proceed only if the change warrants multi-reviewer analysis.
## 1. Scope the change
**Author independence.** Review with the same rigor regardless of who authored the PR. Don't soften findings because the author is the person who invoked this review, a maintainer, or a senior contributor. Don't harden findings because the author is a new contributor. The review's value comes from honest, consistent assessment.
Create the review output directory before anything else:
```sh
export REVIEW_DIR="/tmp/deep-review/$(date +%s)"
mkdir -p "$REVIEW_DIR"
```
**Re-review detection.** Check if you or a previous agent session already reviewed this PR:
```sh
gh pr view {number} --json reviews --jq '.reviews[] | select(.body | test("P[0-4]|\\*\\*Obs\\*\\*|\\*\\*Nit\\*\\*")) | .submittedAt' | head -1
```
If a prior agent review exists, you must produce a prior-findings classification table before proceeding. This is not optional — the table is an input to step 3 (reviewer prompts). Without it, reviewers will re-discover resolved findings.
1. Read every author response since the last review (inline replies, PR comments, commit messages).
2. Diff the branch to see what changed since the last review.
3. Engage with any author questions before re-raising findings.
4. Write `$REVIEW_DIR/prior-findings.md` with this format:
```markdown
# Prior findings from round {N}
| Finding | Author response | Status |
|---------|----------------|--------|
| P1 `file.go:42` wire-format break | Acknowledged, pushed fix in abc123 | Resolved |
| P2 `handler.go:15` missing auth check | "Middleware handles this" — see comment | Contested |
| P3 `db.go:88` naming | Agreed, will fix | Acknowledged |
```
Classify each finding as:
- **Resolved**: author pushed a code fix. Verify the fix addresses the finding's specific concern — not just that code changed in the relevant area. Check that the fix doesn't introduce new issues.
- **Acknowledged**: author agreed but deferred.
- **Contested**: author disagreed or raised a constraint. Write their argument in the table.
- **No response**: author didn't address it.
Only **Contested** and **No response** findings carry forward to the new review. Resolved and Acknowledged findings must not be re-raised.
**Scope the diff.** Get the file list from the diff, PR, or user. Skim for intent and note which layers are touched (frontend, backend, database, auth, concurrency, tests, docs).
For each changed file, briefly check the surrounding context:
- Config files (package.json, tsconfig, vite.config, etc.): scan the existing entries for naming conventions and structural patterns.
- New files: check if an existing file could have been extended instead.
- Comments in the diff: do they explain why, or just restate what the code does?
## 2. Pick reviewers
Match reviewer roles to layers touched. The Test Auditor, Edge Case Analyst, and Contract Auditor always run. Conditional reviewers activate when their domain is touched.
### Tier 1 — Structural reviewers
| Role | Focus | When |
| -------------------- | ----------------------------------------------------------- | ----------------------------------------------------------- |
| Test Auditor | Test authenticity, missing cases, readability | Always |
| Edge Case Analyst | Chaos testing, edge cases, hidden connections | Always |
| Contract Auditor | Contract fidelity, lifecycle completeness, semantic honesty | Always |
| Structural Analyst | Implicit assumptions, class-of-bug elimination | API design, type design, test structure, resource lifecycle |
| Performance Analyst | Hot paths, resource exhaustion, allocation patterns | Hot paths, loops, caches, resource lifecycle |
| Database Reviewer | PostgreSQL, data modeling, Go↔SQL boundary | Migrations, queries, schema, indexes |
| Security Reviewer | Auth, attack surfaces, input handling | Auth, new endpoints, input handling, tokens, secrets |
| Product Reviewer | Over-engineering, feature justification | New features, new config surfaces |
| Frontend Reviewer | UI state, render lifecycles, component design | Frontend changes, UI components, API response shape changes |
| Duplication Checker | Existing utilities, code reuse | New files, new helpers/utilities, new types or components |
| Go Architect | Package boundaries, API lifecycle, middleware | Go code, API design, middleware, package boundaries |
| Concurrency Reviewer | Goroutines, channels, locks, shutdown | Goroutines, channels, locks, context cancellation, shutdown |
### Tier 2 — Nit reviewers
| Role | Focus | File filter |
| ---------------------- | -------------------------------------------- | ----------------------------------- |
| Modernization Reviewer | Language-level improvements, stdlib patterns | Per-language (see below) |
| Style Reviewer | Naming, comments, consistency | `*.go` `*.ts` `*.tsx` `*.py` `*.sh` |
Tier 2 file filters:
- **Modernization Reviewer**: one instance per language present in the diff. Filter by extension:
- Go: `*.go` — reference `.claude/docs/GO.md` before reviewing.
- TypeScript: `*.ts` `*.tsx`
- React: `*.tsx` `*.jsx`
`.tsx` files match both TypeScript and React filters. Spawn both instances when the diff contains `.tsx` changes — TS covers language-level patterns; React covers component and hooks patterns. Before spawning, verify each instance's filter produces a non-empty diff. Skip instances whose filtered diff is empty.
- **Style Reviewer**: `*.go` `*.ts` `*.tsx` `*.py` `*.sh`
## 3. Spawn reviewers
Each reviewer writes findings to `$REVIEW_DIR/{role-name}.md` where `{role-name}` is the kebab-cased role name (e.g. `test-auditor`, `go-architect`). For Modernization Reviewer instances, qualify with the language: `modernization-reviewer-go.md`, `modernization-reviewer-ts.md`, `modernization-reviewer-react.md`. The orchestrator does not read reviewer findings from the subagent return text — it reads the files in step 4.
Spawn all Tier 1 and Tier 2 reviewers in parallel. Give each reviewer a reference (PR number, branch name), not the diff content. The reviewer fetches the diff itself. Reviewers are read-only — no worktrees needed.
**Tier 1 prompt:**
```text
Read `AGENTS.md` in this repository before starting.
You are the {Role Name} reviewer. Read your methodology in
`.agents/skills/deep-review/roles/{role-name}.md`.
Follow the review instructions in
`.agents/skills/deep-review/structural-reviewer-prompt.md`.
Review: {PR number / branch / commit range}.
Output file: {REVIEW_DIR}/{role-name}.md
```
**Tier 2 prompt:**
```text
Read `AGENTS.md` in this repository before starting.
You are the {Role Name} reviewer. Read your methodology in
`.agents/skills/deep-review/roles/{role-name}.md`.
Follow the review instructions in
`.agents/skills/deep-review/nit-reviewer-prompt.md`.
Review: {PR number / branch / commit range}.
File scope: {filter from step 2}.
Output file: {REVIEW_DIR}/{role-name}.md
```
For the Modernization Reviewer (Go), add after the methodology line:
> Read `.claude/docs/GO.md` as your Go language reference before reviewing.
For re-reviews, append to both Tier 1 and Tier 2 prompts:
> Prior findings and author responses are in {REVIEW_DIR}/prior-findings.md. Read it before reviewing. Do not re-raise Resolved or Acknowledged findings.
## 4. Cross-check findings
### 4a. Read findings from files
Read each reviewer's output file from `$REVIEW_DIR/` one at a time. One file per read — do not batch multiple reviewer files in parallel. Batching causes reviewer voices to blend in the context window, leading to misattribution (grabbing phrasing from one reviewer and attributing it to another).
For each file:
1. Read the file.
2. List each finding with its severity, location, and one-line summary.
3. Note the reviewer's exact evidence line for each finding.
If a file says "No findings," record that and move on. If a file is missing (reviewer crashed or timed out), note the gap and proceed — do not stall or silently drop the reviewer's perspective.
After reading all files, you have a finding inventory. Proceed to cross-check.
### 4b. Cross-check
Handle Tier 1 and Tier 2 findings separately before merging.
**Tier 2 nit findings:** Apply a lighter filter. Drop nits that are purely subjective, that duplicate what a linter already enforces, or that the author clearly made intentionally. Keep nits that have a practical benefit (clearer name, better error message, obsolete stdlib usage). Surviving nits stay as Nit.
**Tier 1 structural findings:** Before producing the final review, look across all findings for:
- **Contradictions.** Two reviewers recommending opposite approaches. Flag both and note the conflict.
- **Interactions.** One finding that solves or worsens another (e.g. a refactor suggestion that addresses a separate cleanup concern). Link them.
- **Convergence.** Two or more reviewers flagging the same function or component from different angles. Don't just merge at max(severity) and don't treat convergence as headcount ("more reviewers = higher confidence in the same thing"). After listing the convergent findings, trace the consequence chain _across_ them. One reviewer flags a resource leak, another flags an unbounded hang, a third flags infinite retries on reconnect — the combination means a single failure leaves a permanent resource drain with no recovery. That combined consequence may deserve its own finding at higher severity than any individual one.
- **Async findings.** When a finding mentions setState after unmount, unused cancellation signals, or missing error handling near an await: (1) find the setState or callback, (2) trace what renders or fires as a result, (3) ask "if this fires after the user navigated away, what do they see?" If the answer is "nothing" (a ref update, a console.log), it's P3. If the answer is "a dialog opens" or "state corrupts," upgrade. The severity depends on what's at the END of the async chain, not the start.
- **Mechanism vs. consequence.** Reviewers describe findings using mechanism vocabulary ("unused parameter", "duplicated code", "test passes by coincidence"), not consequence vocabulary ("dialog opens in wrong view", "attacker can bypass check", "removing this code has no test to catch it"). The Contract Auditor and Structural Analyst tend to frame findings by consequence already — use their framing directly. For mechanism-framed findings from other reviewers, restate the consequence before accepting the severity. Consequences include UX bugs, security gaps, data corruption, and silent regressions — not just things users see on screen.
- **Weak evidence.** Findings that assert a problem without demonstrating it. Downgrade or drop.
- **Unnecessary novelty.** New files, new naming patterns, new abstractions where the existing codebase already has a convention. If no reviewer flagged it but you see it, add it. If a reviewer flagged it as an observation, evaluate whether it should be a finding.
- **Scope creep.** Suggestions that go beyond reviewing what changed into redesigning what exists. Downgrade to P4.
- **Structural alternatives.** One reviewer proposes a design that eliminates a documented tradeoff, while others have zero findings because the current approach "works." Don't discount this as an outlier or scope creep. A structural alternative that removes the need for a tradeoff can be the highest-value output of the review. Preserve it at its original severity — the author decides whether to adopt it, but they need enough signal to evaluate it.
- **Pre-existing behavior.** "Pre-existing" doesn't erase severity. Check whether the PR introduced new code (comments, branches, error messages) that describes or depends on the pre-existing behavior incorrectly. The new code is in scope even when the underlying behavior isn't.
For each finding **and observation**, apply the severity test in **both directions**. Observations are not exempt — a reviewer may underrate a convention violation or a missing guarantee as Obs when the consequence warrants P3+:
- Downgrade: "Is this actually less severe than stated?"
- Upgrade: "Could this be worse than stated?"
When the severity spread among reviewers exceeds one level, note it explicitly. Only credit reviewers at or above the posted severity. A finding that survived 2+ independent reviewers needs an explicit counter-argument to drop. "Low risk" is not a counter when the reviewers already addressed it in their evidence.
Before forwarding a nit, form an independent opinion on whether it improves the code. Before rejecting a nit, verify you can prove it wrong, not just argue it's debatable.
Drop findings that don't survive this check. Adjust severity where the cross-check changes the picture.
After filtering both tiers, check for overlap: a nit that points at the same line as a Tier 1 finding can be folded into that comment rather than posted separately.
### 4c. Quoting discipline
When a finding survives cross-check, the reviewer's technical evidence is the source of record. Do not paraphrase it.
**Convergent findings — sharpest first.** When multiple reviewers flag the same issue:
1. Rank the converging findings by evidence quality.
2. Start from the sharpest individual finding as the base text.
3. Layer in only what other reviewers contributed that the base didn't cover (a concrete detail, a preemptive counter, a stronger framing).
4. Attribute to the 23 reviewers with the strongest evidence, not all N who noticed the same thing.
**Single-reviewer findings.** Go back to the reviewer's file and copy the evidence verbatim. The orchestrator owns framing, severity assessment, and practical judgment — those are your words. The technical claim and code-level evidence are the reviewer's words.
A posted finding has two voices:
- **Reviewer voice** (quoted): the specific technical observation and code evidence exactly as the reviewer wrote it.
- **Orchestrator voice** (original): severity framing, practical judgment ("worth fixing now because..."), scenario building, and conversational tone.
If you need to adjust a finding's scope (e.g. the reviewer said "file.go:42" but the real issue is broader), say so explicitly rather than silently rewriting the evidence.
**Attribution must show severity spread.** When reviewers disagree on severity, the attribution should reflect that — not flatten everyone to the posted severity. Show each reviewer's individual severity: `*(Security Reviewer P1, Concurrency Reviewer P1, Test Auditor P2)*` not `*(Security Reviewer, Concurrency Reviewer, Test Auditor)*`.
**Integrity check.** Before posting, verify that quoted evidence in findings actually corresponds to content in the diff. This guards against garbled cross-references from the file-reading step.
## 5. Post the review
When reviewing a GitHub PR, post findings as a proper GitHub review with inline comments, not a single comment dump.
**Review body.** Open with a short, friendly summary: what the change does well, what the overall impression is, and how many findings follow. Call out good work when you see it. A review that only lists problems teaches authors to dread your comments.
```text
Clean approach to X. The Y handling is particularly well done.
A couple things to look at: 1 P2, 1 P3, 3 nits across 5 inline
comments.
```
For re-reviews (round 2+), open with what was addressed:
```text
Thanks for fixing the wire-format break and the naming issue.
Fresh review found one new issue: 1 P2 across 1 inline comment.
```
Keep the review body to 24 sentences. Don't use markdown headers in the body — they render oversized in GitHub's review UI.
**Inline comments.** Every finding is an inline comment, pinned to the most relevant file and line. For findings that span multiple files, pin to the primary file (GitHub supports file-level comments when `position` is omitted or set to 1).
Inline comment format:
```text
**P{n}** One-sentence finding *(Reviewer Role)*
> Reviewer's evidence quoted verbatim from their file
Orchestrator's practical judgment: is this worth fixing now, or
is the current tradeoff acceptable? Scenario building, severity
reasoning, fix suggestions — these are your words.
```
For convergent findings (multiple reviewers, same issue):
```text
**P{n}** One-sentence finding *(Performance Analyst P1,
Contract Auditor P1, Test Auditor P2)*
> Sharpest reviewer's evidence as base text
> *Contract Auditor adds:* Additional detail from their file
Orchestrator's practical judgment.
```
For observations: `**Obs** One-sentence observation *(Role)* ...` For nits: `**Nit** One-sentence finding *(Role)* ...`
P3 findings and observations can be one-liners. Group multiple nits on the same file into one comment when they're co-located.
**Review event.** Always use `COMMENT`. Never use `REQUEST_CHANGES` — this isn't the norm in this repository. Never use `APPROVE` — approval is a human responsibility.
For P0 or P1 findings, add a note in the review body: "This review contains findings that may need attention before merge."
**Posting via GitHub API.**
The `gh api` endpoint for posting reviews routes through GraphQL by default. Field names differ from the REST API docs:
- Use `position` (diff-relative line number), not `line` + `side`. `side` is not a valid field in the GraphQL schema.
- `subject_type: "file"` is not recognized. Pin file-level comments to `position: 1` instead.
- Use `-X POST` with `--input` to force REST API routing.
To compute positions: save the PR diff to a file, then count lines from the first `@@` hunk header of each file's diff section. For new files, position = line number + 1 (the hunk header is position 1, first content line is position 2).
```sh
gh pr diff {number} > /tmp/pr.diff
```
Submit:
```sh
gh api -X POST \
repos/{owner}/{repo}/pulls/{number}/reviews \
--input review.json
```
Where `review.json`:
```json
{
"event": "COMMENT",
"body": "Summary of what's good and what to look at.\n1 P2, 1 P3 across 2 inline comments.",
"comments": [
{
"path": "file.go",
"position": 42,
"body": "**P1** Finding... *(Reviewer Role)*\n\n> Evidence..."
},
{
"path": "other.go",
"position": 1,
"body": "**P2** Cross-file finding... *(Reviewer Role)*\n\n> Evidence..."
}
]
}
```
**Tone guidance.** Frame design concerns as questions: "Could we use X instead?" — be direct only for correctness issues. Hedge design, not bugs. Build concrete scenarios to make concerns tangible. When uncertain, say so. See `.claude/docs/PR_STYLE_GUIDE.md` for PR conventions.
## Follow-up
After posting the review, monitor the PR for author responses. If the author pushes fixes or responds to findings, consider running a re-review (this skill, starting from step 1 with the re-review detection path). Allow time for the author to address multiple findings before re-reviewing — don't trigger on each individual response.
@@ -1,30 +0,0 @@
Get the diff for the review target specified in your prompt, filtered to the file scope specified, then review it.
- **PR:** `gh pr diff {number} -- {file filter from prompt}`
- **Branch:** `git diff origin/main...{branch} -- {file filter from prompt}`
- **Commit range:** `git diff {base}..{tip} -- {file filter from prompt}`
If the filtered diff is empty, say so in one line and stop.
You are a nit reviewer. Your job is to catch what the linter doesnt: naming, style, commenting, and language-level improvements. You are not looking for bugs or architecture issues — those are handled by other reviewers.
Write all findings to the output file specified in your prompt. Create the directory if it doesnt exist. The file is your deliverable — the orchestrator reads it, not your chat output. Your final message should just confirm the file path and how many findings you wrote (or that you found nothing).
Use this structure in the file:
---
**Nit** `file.go:42` — One-sentence finding.
Why it matters: brief explanation. If theres an obvious fix, mention it.
---
Rules:
- Use **Nit** for all findings. Dont use P0-P4 severity; that scale is for structural reviewers.
- Findings MUST reference specific lines or names. Vague style observations arent findings.
- Dont flag things the linter already catches (formatting, import order, missing error checks).
- Dont suggest changes that are purely subjective with no practical benefit.
- For comment quality standards (confidence threshold, avoiding speculation, verifying claims), see `.claude/skills/code-review/SKILL.md` Comment Standards section.
- If you find nothing, write a single line to the output file: "No findings."
@@ -1,12 +0,0 @@
# Concurrency Reviewer
**Lens:** Goroutines, channels, locks, shutdown sequences.
**Method:**
- Find specific interleavings that break. A select statement where case ordering starves one branch. An unbuffered channel that deadlocks under backpressure. A context cancellation that races with a send on a closed channel.
- Check shutdown sequences. Component A depends on component B, but B was already torn down. "Fire and forget" goroutines that are actually "fire and leak." Join points that never arrive because nobody is waiting.
- State the specific interleaving: "Thread A is at line X, thread B calls Y, the field is now Z." Don't say "this might have a race."
- Know the difference between "concurrent-safe" (mutex around everything) and "correct under concurrency" (design that makes races impossible).
**Scope boundaries:** You review concurrency. You don't review architecture, package boundaries, or test quality. If a structural redesign would eliminate a hazard, mention it, but the Structural Analyst owns that analysis.
@@ -1,25 +0,0 @@
# Contract Auditor
You review code by asking: **"What does this code promise, and does it keep that promise?"**
Every piece of code makes promises. An API endpoint promises a response shape. A status code promises semantics. A state transition promises reachability. An error message promises a diagnosis. A flag name promises a scope. A comment promises intent. Your job is to find where the implementation breaks the promise.
Every layer of the system, from bytes to humans, should say what it does and do what it says. False signals compound into bugs. A misleading name is a future misuse. A missing error path is a future outage. A flag that affects more than its name says is a future support ticket.
**Method — four modes, use all on every diff.** Modes 1 and 3 can surface the same issue from different angles (top-down from promise vs. bottom-up from signal). If they converge, report once and note both angles.
**1. Contract tracing.** Pick a promise the code makes (API shape, state transition, error message, config option, return type) and follow it through the implementation. Read every branch. Find where the promise breaks. Ask: does the implementation do what the name/comment/doc says? Does the error response match what the caller will see? Does the status code match the response body semantics? Does the flag/config affect exactly what its name and help text claim? When you find a break, state both sides: what was promised (quote the name, doc, annotation) and what actually happens (cite the code path, branch, return value).
**2. Lifecycle completeness.** For entities with managed lifecycles (connections, sessions, containers, agents, workspaces, jobs): model the state machine (init → ready → active → error → stopping → stopped/cleaned). Every transition must be reachable, reversible where appropriate, observable, safe under concurrent access, and correct during shutdown. Enumerate transitions. Find states that are reachable but shouldn't be, or necessary but unreachable. The most dangerous bug is a terminal state that blocks retry — the entity becomes immortal. Ask: what happens if this operation fails halfway? What state is the entity left in after an error? Can the user retry, or is the entity stuck? What happens if shutdown races with an in-progress operation? Does every path leave state consistent?
**3. Semantic honesty.** Every word in the codebase is a signal to the next reader. Audit signals for fidelity. Names: does the function/variable/constant name accurately describe what it does? A constant named after one concept that stores a different one is a lie. Comments: does the comment describe what the code actually does, or what it used to do? Error messages: does the message help the operator diagnose the problem, or does it mislead ("internal server error" when the fault is in the caller)? Types: does the type express the actual constraint, or would an enum prevent invalid states? Flags and config: does the flag's name and help text match its actual scope, or does it silently affect unrelated subsystems?
**4. Adversarial imagination.** Construct a specific scenario with a hostile or careless user, an environmental surprise, or a timing coincidence. Trace the system state step by step. Don't say "this has a race condition" — say "User A starts a process, triggers stop, then cancels the stop. The entity enters cancelled state. The previous stop never completed. The process runs in perpetuity." Don't say "this could be invalidated" — say "What happens if the scheduling config changes while cached? Each invalidation skips recomputation." Don't say "this auth flow might be insecure" — say "An attacker obtains a valid token for user A. They submit it alongside user B's identifier. Does the system verify the token-to-user binding, or does it accept any valid token?" Build the scenario. Name the actor. Describe the sequence. State the resulting system state. This mode surfaces broken invariants through specific narrative construction and systematic state enumeration, not through randomized chaos probing or fuzz-style edge case generation.
**Finding structure.** These are dimensions to analyze, not a rigid output format — adapt to whatever format the review context requires. For each finding, identify: (1) the promise — what the code claims, (2) the break — what actually happens, (3) the consequence — what a user, operator, or future developer will experience. Not every finding blocks. Findings that change runtime behavior or break a security boundary block. Misleading signals that will cause future misuse are worth fixing but may not block. Latent risks with no current trigger are worth noting.
**Calibration — high-signal patterns:** orphaned terminal states that block retry, precomputed values invalidated by changes the code doesn't track, flag/config scope wider than the name implies, documentation contradicting implementation, timing side channels leaking information the code tries to hide, missing error-path state updates (entity left in transitional state after failure), cross-entity confusion (credential for entity A accepted for entity B), unbounded context in handlers that should be bounded by server lifetime.
**Scope boundaries:** You trace promises and find where they break. You don't review performance optimization or language-level modernization. When adversarial imagination overlaps with edge case analysis or security review, keep your focus on broken contracts — other reviewers probe limits and trace attack surfaces from their own angle.
When you find nothing: say so. A clean review is a valid outcome. Don't manufacture findings to justify your existence.
@@ -1,11 +0,0 @@
# Database Reviewer
**Lens:** PostgreSQL, data modeling, Go↔SQL boundary.
**Method:**
- Check migration safety. A migration that looks safe on a dev database may take an ACCESS EXCLUSIVE lock on a 10M-row production table. Check for sequential scans hiding behind WHERE clauses that can't use the index.
- Check schema design for future cost. Will the next feature need a column that doesn't fit? A query that can't perform?
- Own the Go↔SQL boundary. Every value crossing the driver boundary has edge cases: nil slices becoming SQL NULL through `pq.Array`, `array_agg` returning NULL that propagates through WHERE clauses, COALESCE gaps in generated code, NOT NULL constraints violated by Go zero values. Check both sides.
**Scope boundaries:** You review database interactions. You don't review application logic, frontend code, or test quality.
@@ -1,11 +0,0 @@
# Duplication Checker
**Lens:** Existing utilities, code reuse.
**Method:**
- When a PR adds something new, check if something similar already exists: existing helpers, imported dependencies, type definitions, components. Search the codebase.
- Catch: hand-written interfaces that duplicate generated types, reimplemented string helpers when the dependency is already available, duplicate test fakes across packages, new components that are configurations of existing ones. A new page that could be a prop on an existing page. A new wrapper that could be a call to an existing function.
- Don't argue. Show where it already lives.
**Scope boundaries:** You check for duplication. You don't review correctness, performance, or security.
@@ -1,12 +0,0 @@
# Edge Case Analyst
**Lens:** Chaos testing, edge cases, hidden connections.
**Method:**
- Find hidden connections. Trace what looks independent and find it secretly attached: a change in one handler that breaks an unrelated handler through shared mutable state, a config option that silently affects a subsystem its author didn't know existed. Pull one thread and watch what moves.
- Find surface deception. Code that presents one face and hides another: a function that looks pure but writes to a global, a retry loop with an unreachable exit condition, an error handler that swallows the real error and returns a generic one, a test that passes for the wrong reason.
- Probe limits. What happens with empty input, maximum-size input, input in the wrong order, the same request twice in one millisecond, a valid payload with every optional field missing? What happens when the clock skews, the disk fills, the DNS lookup hangs?
- Rate potential, not just current severity. A dormant bug in a system with three users that will corrupt data at three thousand is more dangerous than a visible bug in a test helper. A race condition that only triggers under load is more dangerous than one that fails immediately.
**Scope boundaries:** You probe limits and find hidden connections. You don't review test quality, naming conventions, or documentation.
@@ -1,11 +0,0 @@
# Frontend Reviewer
**Lens:** UI state, render lifecycles, component design.
**Method:**
- Map every user-visible state: loading, polling, error, empty, abandoned, and the transitions between them. Find the gaps. A `return null` in a page component means any bug blanks the screen — degraded rendering is always better. Form state that vanishes on navigation is a lost route.
- Check cache invalidation gaps in React Query, `useEffect` used for work that belongs in query callbacks or event handlers, re-renders triggered by state changes that don't affect the output.
- When a backend change lands, ask: "What does this look like when it's loading, when it errors, when the list is empty, and when there are 10,000 items?"
**Scope boundaries:** You review frontend code. You don't review backend logic, database queries, or security (unless it's client-side auth handling).
@@ -1,12 +0,0 @@
# Go Architect
**Lens:** Package boundaries, API lifecycle, middleware.
**Method:**
- Check dependency direction. Logic flows downward: handlers call services, services call stores, stores talk to the database. When something reaches upward or sideways, flag it.
- Question whether every abstraction earns its indirection. An interface with one implementation is unnecessary. A handler doing business logic belongs in a service layer. A function whose parameter list keeps growing needs redesign, not another parameter.
- Check middleware ordering: auth before the handler it protects, rate limiting before the work it guards.
- Track API lifecycle. A shipped endpoint is a published contract. Check whether changed endpoints exist in a release, whether removing a field breaks semver, whether a new parameter will need support for years.
**Scope boundaries:** You review Go architecture. You don't review concurrency primitives, test quality, or frontend code.
@@ -1,12 +0,0 @@
# Modernization Reviewer
**Lens:** Language-level improvements, stdlib patterns.
**Method:**
- Read the version file first (go.mod, package.json, or equivalent). Don't suggest features the declared version doesn't support.
- Flag hand-rolled utilities the standard library now covers. Flag deprecated APIs still in active use. Flag patterns that were idiomatic years ago but have a clearly better replacement today.
- Name which version introduced the alternative.
- Only flag when the delta is worth the diff. If the old pattern works and the new one is only marginally better, pass.
**Scope boundaries:** You review language-level patterns. You don't review architecture, correctness, or security.
@@ -1,12 +0,0 @@
# Performance Analyst
**Lens:** Hot paths, resource exhaustion, invisible degradation.
**Method:**
- Trace the hot path through the call stack. Find the allocation that shouldn't be there, the lock that serializes what should be parallel, the query that crosses the network inside a loop.
- Find multiplication at scale. One goroutine per request is fine for ten users; at ten thousand, the scheduler chokes. One N+1 query is invisible in dev; in production, it's a thousand round trips. One copy in a loop is nothing; a million copies per second is an OOM.
- Find resource lifecycles where acquisition is guaranteed but release is not. Memory leaks that grow slowly. Goroutine counts that climb and never decrease. Caches with no eviction. Temp files cleaned only on the happy path.
- Calculate, don't guess. A cold path that runs once per deploy is not worth optimizing. A hot path that runs once per request is. Know the difference between a theoretical concern and a production kill shot. If you can't estimate the load, say so.
**Scope boundaries:** You review performance. You don't review correctness, naming, or test quality.
@@ -1,11 +0,0 @@
# Product Reviewer
**Lens:** Over-engineering, feature justification.
**Method:**
- Ask "do users actually need this?" Not "is this elegant" or "is this extensible." If the person using the product wouldn't notice the feature missing, it's overhead.
- Question complexity. Three layers of abstraction for something that could be a function. A notification system that spams a thousand users when ten are active. A config surface nobody asked for.
- Check proportionality. Is the solution sized to the problem? A 3-line bug shouldn't produce a 200-line refactor.
**Scope boundaries:** You review product sense. You don't review implementation correctness, concurrency, or security.
@@ -1,13 +0,0 @@
# Security Reviewer
**Lens:** Auth, attack surfaces, input handling.
**Method:**
- Trace every path from untrusted input to a dangerous sink: SQL, template rendering, shell execution, redirect targets, provisioner URLs.
- Find TOCTOU gaps where authorization is checked and then the resource is fetched again without re-checking. Find endpoints that require auth but don't verify the caller owns the resource.
- Spot secrets that leak through error messages, debug endpoints, or structured log fields. Question SSRF vectors through proxies and URL parameters that accept internal addresses.
- Insist on least privilege. Broad token scopes are attack surface. A permission granted "just in case" is a weakness. An API key with write access when read would suffice is unnecessary exposure.
- "The UI doesn't expose this" is not a security boundary.
**Scope boundaries:** You review security. You don't review performance, naming, or code style.
@@ -1,47 +0,0 @@
# Structural Analyst — Make the Implicit Visible
You review code by asking: **"What does this code assume that it doesn't express?"**
Every design carries implicit assumptions: lock ordering, startup ordering, message ordering, caller discipline, single-writer access, table cardinality, environmental availability. Your job is to find those assumptions and propose changes that make them visible in the code's structure, so the next editor can't accidentally violate them.
Eliminate the class of bug, not the instance. When you find a race condition, don't just fix the race — ask why the race was possible. The goal is a design where the bug _cannot exist_, not one where it merely doesn't exist today.
**Method — four modes, use all on every diff.**
**1. Structural redesign.** Find where correctness depends on something the code doesn't enforce. Propose alternatives where correctness falls out from the structure. Patterns:
- **Multiple locks**: deadlock depends on every future editor acquiring them in the right order. Propose one lock + condition variable.
- **Goroutine + channel coordination**: the goroutine's lifecycle must be managed, the channel drained, context must not deadlock. Propose timer/callback on the struct.
- **Manual unsubscribe with caller-supplied ID**: the caller must remember to unsubscribe correctly. Propose subscription interface with close method.
- **Hardcoded access control**: exceptions make the API brittle. Propose the policy system (RBAC, middleware).
- **PubSub carrying state**: messages aren't ordered with respect to transactions. Propose PubSub as notification only + database read for truth.
- **Startup ordering dependencies**: crash because a dependency is momentarily unreachable. Propose self-healing with retry/backoff.
- **Separate fields tracking the same data**: two representations must stay in sync manually. Propose deriving one from the other.
- **Append-only collections without replacement**: every consumer must handle stale entries. Propose replace semantics or explicit versioning.
Be concrete: name the type, the interface, the field, the method. Quote the specific implicit assumption being eliminated.
**2. Concurrency design review.** When you encounter concurrency patterns during structural analysis, ask whether a redesign from mode 1 would eliminate the hazard entirely. The Concurrency Reviewer owns the detailed interleaving analysis — your job is to spot where the _design_ makes races possible and propose structural alternatives that make them impossible.
**3. Test layer audit.** This is distinct from the Test Auditor, who checks whether tests are genuine and readable. You check whether tests verify behavior at the _right abstraction layer_. Flag:
- Integration tests hiding behind unit test names (test spins up the full stack for a database query — propose fixtures or fakes).
- Asserting intermediate states that depend on timing (propose aggregating to final state).
- Toy data masking query plan differences (one tenant, one user — propose realistic cardinality).
- Skipped tests hiding environment assumptions (propose asserting the expected failure instead).
- Test infrastructure that hides real bugs (fake doesn't use the same subsystem as real code).
- Missing timeout wrappers (system bug hangs the entire test suite).
When referencing project-specific test utilities, name them, but frame the principle generically.
**4. Dead weight audit.** Unnecessary code is an implicit claim that it matters. Every dead line misleads the next reader. Flag: unnecessary type conversions the runtime already handles, redundant interface compliance checks when the constructor already returns the interface, functions that used to abstract multiple cases but now wrap exactly one, security annotation comments that no longer apply after a type change, stale workarounds for bugs fixed in newer versions. If it does nothing, delete it. If it does something but the name doesn't say what, rename it.
**Finding structure.** These are dimensions to analyze, not a rigid output format — adapt to whatever format the review context requires. For each finding, identify: (1) the assumption — what the code relies on that it doesn't enforce, (2) the failure mode — how the assumption breaks, with a specific interleaving, caller mistake, or environmental condition, (3) the structural fix — a concrete alternative where the assumption is eliminated or made visible in types/interfaces/naming, specific enough to implement.
Ship pragmatically. If the code solves a real problem and the assumptions are bounded, approve it — but mark exactly where the implicit assumptions remain, so the debt is visible. "A few nits inline, but I don't need to review again" is a valid outcome. So is "this needs structural rework before it's safe to merge."
**Calibration — high-signal patterns:** two locks replaced by one lock + condition variable, background goroutine replaced by timer/callback on the struct, channel + manual unsubscribe replaced by subscription interface, PubSub as state carrier replaced by notification + database read, crash-on-startup replaced by retry-and-self-heal, authorization bypass via raw database store instead of wrapper, identity accumulating permissions over time, shallow clone sharing memory through pointer fields, unbounded context on database queries, integration test trap (lots of slow integration tests, few fast unit tests). Self-corrections that land mid-review — when you realize a finding is wrong, correct visibly rather than silently removing it. Visible correction beats silent edit.
**Scope boundaries:** You find implicit assumptions and propose structural fixes. You don't review concurrency primitives for low-level correctness in isolation — you review whether the concurrency _design_ can be replaced with something that eliminates the hazard entirely. You don't review test coverage metrics or assertion quality — you review whether tests are testing at the _right abstraction layer_. You don't trace promises through implementation — you find what the code takes for granted. You don't review package boundaries or API lifecycle conventions — you review whether the API's _structure_ makes misuse hard. If another reviewer's domain comes up while you're analyzing structure, flag it briefly but don't investigate further.
When you find nothing: say so. A clean review is a valid outcome.
@@ -1,13 +0,0 @@
# Style Reviewer
**Lens:** Naming, comments, consistency.
**Method:**
- Read every name fresh. If you can't use it correctly without reading the implementation, the name is wrong.
- Read every comment fresh. If it restates the line above it, it's noise. If the function has a surprising invariant and no comment, that's the one that needed one.
- Track patterns. If one misleading name appears, follow the scent through the whole diff. If `handle` means "transform" here, what does it mean in the next file? One inconsistency is a nit. A pattern of inconsistencies is a finding.
- Be direct. "This name is wrong" not "this name could perhaps be improved."
- Don't flag what the linter catches (formatting, import order, missing error checks). Focus on what no tool can see.
**Scope boundaries:** You review naming and style. You don't review architecture, correctness, or security.
@@ -1,12 +0,0 @@
# Test Auditor
**Lens:** Test authenticity, missing cases, readability.
**Method:**
- Distinguish real tests from fake ones. A real test proves behavior. A fake test executes code and proves nothing. Look for: tests that mock so aggressively they're testing the mock; table-driven tests where every row exercises the same code path; coverage tests that execute every line but check no result; integration tests that pass because the fake returns hardcoded success, not because the system works.
- Ask: if you deleted the feature this test claims to test, would the test still pass? If yes, the test is fake.
- Find the missing edge cases: empty input, boundary values, error paths that return wrapped nil, scenarios where two things happen at once. Ask why they're missing — too hard to set up, too slow to run, or nobody thought of it?
- Check test readability. A test nobody can read is a test nobody will maintain. Question tests coupled so tightly to implementation that any refactor breaks them. Question assertions on incidental details (call counts, internal state, execution order) when the test should assert outcomes.
**Scope boundaries:** You review tests. You don't review architecture, concurrency design, or security. If you spot something outside your lens, flag it briefly and move on.
@@ -1,47 +0,0 @@
Get the diff for the review target specified in your prompt, then review it.
Write all findings to the output file specified in your prompt. Create the directory if it doesnt exist. The file is your deliverable — the orchestrator reads it, not your chat output. Your final message should just confirm the file path and how many findings it contains (or that you found nothing).
- **PR:** `gh pr diff {number}`
- **Branch:** `git diff origin/main...{branch}`
- **Commit range:** `git diff {base}..{tip}`
You can report two kinds of things:
**Findings** — concrete problems with evidence.
**Observations** — things that work but are fragile, work by coincidence, or are worth knowing about for future changes. These arent bugs, theyre context. Mark them with `Obs`.
Use this structure in the file for each finding:
---
**P{n}** `file.go:42` — One-sentence finding.
Evidence: what you see in the code, and what goes wrong.
---
For observations:
---
**Obs** `file.go:42` — One-sentence observation.
Why it matters: brief explanation.
---
Rules:
- **Severity**: P0 (blocks merge), P1 (should fix before merge), P2 (consider fixing), P3 (minor), P4 (out of scope, cosmetic).
- Severity comes from **consequences**, not mechanism. “setState on unmounted component” is a mechanism. “Dialog opens in wrong view” is a consequence. “Attacker can upload active content” is a consequence. “Removing this check has no test to catch it” is a consequence. Rate the consequence, whether its a UX bug, a security gap, or a silent regression.
- When a finding involves async code (fetch, await, setTimeout), trace the full execution chain past the async boundary. What renders, what callbacks fire, what state changes? Rate based on what happens at the END of the chain, not the start.
- Findings MUST have evidence. An assertion without evidence is an opinion.
- Evidence should be specific (file paths, line numbers, scenarios) but concise. Write it like youre explaining to a colleague, not building a legal case.
- For each finding, include your practical judgment: is this worth fixing now, or is the current tradeoff acceptable? If theres an obvious fix, mention it briefly.
- Observations dont need evidence, just a clear explanation of why someone should know about this.
- Check the surrounding code for existing conventions. Flag when the change introduces a new pattern where an existing one would work (new file vs. extending existing, new naming scheme vs. established prefix, etc.).
- Note what the change does well. Good patterns are worth calling out so they get repeated.
- For comment quality standards (confidence threshold, avoiding speculation, verifying claims), see `.claude/skills/code-review/SKILL.md` Comment Standards section.
- If you find nothing, write a single line to the output file: “No findings.”
-72
View File
@@ -1,72 +0,0 @@
---
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.
-140
View File
@@ -1,140 +0,0 @@
---
name: refine-plan
description: Iteratively refine development plans using TDD methodology. Ensures plans are clear, actionable, and include red-green-refactor cycles with proper test coverage.
---
# Refine Development Plan
## Overview
Good plans eliminate ambiguity through clear requirements, break work into clear phases, and always include refactoring to capture implementation insights.
## When to Use This Skill
| Symptom | Example |
|-----------------------------|----------------------------------------|
| Unclear acceptance criteria | No definition of "done" |
| Vague implementation | Missing concrete steps or file changes |
| Missing/undefined tests | Tests mentioned only as afterthought |
| Absent refactor phase | No plan to improve code after it works |
| Ambiguous requirements | Multiple interpretations possible |
| Missing verification | No way to confirm the change works |
## Planning Principles
### 1. Plans Must Be Actionable and Unambiguous
Every step should be concrete enough that another agent could execute it without guessing.
- ❌ "Improve error handling" → ✓ "Add try-catch to API calls in user-service.ts, return 400 with error message"
- ❌ "Update tests" → ✓ "Add test case to auth.test.ts: 'should reject expired tokens with 401'"
NEVER include thinking output or other stream-of-consciousness prose mid-plan.
### 2. Push Back on Unclear Requirements
When requirements are ambiguous, ask questions before proceeding.
### 3. Tests Define Requirements
Writing test cases forces disambiguation. Use test definition as a requirements clarification tool.
### 4. TDD is Non-Negotiable
All plans follow: **Red → Green → Refactor**. The refactor phase is MANDATORY.
## The TDD Workflow
### Red Phase: Write Failing Tests First
**Purpose:** Define success criteria through concrete test cases.
**What to test:**
- Happy path (normal usage), edge cases (boundaries, empty/null), error conditions (invalid input, failures), integration points
**Test types:**
- Unit tests: Individual functions in isolation (most tests should be these - fast, focused)
- Integration tests: Component interactions (use for critical paths)
- E2E tests: Complete workflows (use sparingly)
**Write descriptive test cases:**
**If you can't write the test, you don't understand the requirement and MUST ask for clarification.**
### Green Phase: Make Tests Pass
**Purpose:** Implement minimal working solution.
Focus on correctness first. Hardcode if needed. Add just enough logic. Resist urge to "improve" code. Run tests frequently.
### Refactor Phase: Improve the Implementation
**Purpose:** Apply insights gained during implementation.
**This phase is MANDATORY.** During implementation you'll discover better structure, repeated patterns, and simplification opportunities.
**When to Extract vs Keep Duplication:**
This is highly subjective, so use the following rules of thumb combined with good judgement:
1) Follow the "rule of three": if the exact 10+ lines are repeated verbatim 3+ times, extract it.
2) The "wrong abstraction" is harder to fix than duplication.
3) If extraction would harm readability, prefer duplication.
**Common refactorings:**
- Rename for clarity
- Simplify complex conditionals
- Extract repeated code (if meets criteria above)
- Apply design patterns
**Constraints:**
- All tests must still pass after refactoring
- Don't add new features (that's a new Red phase)
## Plan Refinement Process
### Step 1: Review Current Plan for Completeness
- [ ] Clear context explaining why
- [ ] Specific, unambiguous requirements
- [ ] Test cases defined before implementation
- [ ] Step-by-step implementation approach
- [ ] Explicit refactor phase
- [ ] Verification steps
### Step 2: Identify Gaps
Look for missing tests, vague steps, no refactor phase, ambiguous requirements, missing verification.
### Step 3: Handle Unclear Requirements
If you can't write the plan without this information, ask the user. Otherwise, make reasonable assumptions and note them in the plan.
### Step 4: Define Test Cases
For each requirement, write concrete test cases. If you struggle to write test cases, you need more clarification.
### Step 5: Structure with Red-Green-Refactor
Organize the plan into three explicit phases.
### Step 6: Add Verification Steps
Specify how to confirm the change works (automated tests + manual checks).
## Tips for Success
1. **Start with tests:** If you can't write the test, you don't understand the requirement.
2. **Be specific:** "Update API" is not a step. "Add error handling to POST /users endpoint" is.
3. **Always refactor:** Even if code looks good, ask "How could this be clearer?"
4. **Question everything:** Ambiguity is the enemy.
5. **Think in phases:** Red → Green → Refactor.
6. **Keep plans manageable:** If plan exceeds ~10 files or >5 phases, consider splitting.
---
**Remember:** A good plan makes implementation straightforward. A vague plan leads to confusion, rework, and bugs.
+1 -1
View File
@@ -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 [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.
Git contributions follow a standard format with commit messages structured as `type: <message>`, where type is one of `feat`, `fix`, or `chore`.
## Development Workflow
+2 -2
View File
@@ -189,8 +189,8 @@ func (q *sqlQuerier) UpdateUser(ctx context.Context, arg UpdateUserParams) (User
### Common Debug Commands
```bash
# Run tests (starts Postgres automatically if needed)
make test
# Check database connection
make test-postgres
# Run specific database tests
go test ./coderd/database/... -run TestSpecificFunction
-249
View File
@@ -1,249 +0,0 @@
# Modern Go (1.181.26)
Reference for writing idiomatic Go. Covers what changed, what it
replaced, and what to reach for. Respect the project's `go.mod` `go`
line: don't emit features from a version newer than what the module
declares. Check `go.mod` before writing code.
## How modern Go thinks differently
**Generics** (1.18): Design reusable code with type parameters instead
of `interface{}` casts, code generation, or the `sort.Interface`
pattern. Use `any` for unconstrained types, `comparable` for map keys
and equality, `cmp.Ordered` for sortable types. Type inference usually
makes explicit type arguments unnecessary (improved in 1.21).
**Per-iteration loop variables** (1.22): Each loop iteration gets its
own variable copy. Closures inside loops capture the correct value. The
`v := v` shadow trick is dead. Remove it when you see it.
**Iterators** (1.23): `iter.Seq[V]` and `iter.Seq2[K,V]` are the
standard iterator types. Containers expose `.All()` methods returning
these. Combined with `slices.Collect`, `slices.Sorted`, `maps.Keys`,
etc., they replace ad-hoc "loop and append" code with composable,
lazy pipelines. When a sequence is consumed only once, prefer an
iterator over materializing a slice.
**Error trees** (1.201.26): Errors compose as trees, not chains.
`errors.Join` aggregates multiple errors. `fmt.Errorf` accepts multiple
`%w` verbs. `errors.Is`/`As` traverse the full tree. Custom error
types that wrap multiple causes must implement `Unwrap() []error` (the
slice form), not `Unwrap() error`, or tree traversal won't find the
children. `errors.AsType[T]` (1.26) is the type-safe way to match
error types. Propagate cancellation reasons with
`context.WithCancelCause`.
**Structured logging** (1.21): `log/slog` is the standard structured
logger. This project uses `cdr.dev/slog/v3` instead, which has a
different API. Do not use `log/slog` directly.
## Replace these patterns
The left column reflects common patterns from pre-1.22 Go. Write the
right column instead. The "Since" column tells you the minimum `go`
directive version required in `go.mod`.
| Old pattern | Modern replacement | Since |
|---|---|---|
| `interface{}` | `any` | 1.18 |
| `v := v` inside loops | remove it | 1.22 |
| `for i := 0; i < n; i++` | `for i := range n` | 1.22 |
| `for i := 0; i < b.N; i++` (benchmarks) | `for b.Loop()` (correct timing, future-proof) | 1.24 |
| `sort.Slice(s, func(i,j int) bool{…})` | `slices.SortFunc(s, cmpFn)` | 1.21 |
| `wg.Add(1); go func(){ defer wg.Done(); … }()` | `wg.Go(func(){…})` | 1.25 |
| `func ptr[T any](v T) *T { return &v }` | `new(expr)` e.g. `new(time.Now())` | 1.26 |
| `var target *E; errors.As(err, &target)` | `t, ok := errors.AsType[*E](err)` | 1.26 |
| Custom multi-error type | `errors.Join(err1, err2, …)` | 1.20 |
| Single `%w` for multiple causes | `fmt.Errorf("…: %w, %w", e1, e2)` | 1.20 |
| `rand.Seed(time.Now().UnixNano())` | delete it (auto-seeded); prefer `math/rand/v2` | 1.20/1.22 |
| `sync.Once` + captured variable | `sync.OnceValue(func() T {…})` / `OnceValues` | 1.21 |
| Custom `min`/`max` helpers | `min(a, b)` / `max(a, b)` builtins (any ordered type) | 1.21 |
| `for k := range m { delete(m, k) }` | `clear(m)` (also zeroes slices) | 1.21 |
| Index+slice or `SplitN(s, sep, 2)` | `strings.Cut(s, sep)` / `bytes.Cut` | 1.18 |
| `TrimPrefix` + check if anything was trimmed | `strings.CutPrefix` / `CutSuffix` (returns ok bool) | 1.20 |
| `strings.Split` + loop when no slice is needed | `strings.SplitSeq` / `Lines` / `FieldsSeq` (iterator, no alloc) | 1.24 |
| `"2006-01-02"` / `"2006-01-02 15:04:05"` / `"15:04:05"` | `time.DateOnly` / `time.DateTime` / `time.TimeOnly` | 1.20 |
| Manual `Before`/`After`/`Equal` chains for comparison | `time.Time.Compare` (returns -1/0/+1; works with `slices.SortFunc`) | 1.20 |
| Loop collecting map keys into slice | `slices.Sorted(maps.Keys(m))` | 1.23 |
| `fmt.Sprintf` + append to `[]byte` | `fmt.Appendf(buf, …)` (also `Append`, `Appendln`) | 1.18 |
| `reflect.TypeOf((*T)(nil)).Elem()` | `reflect.TypeFor[T]()` | 1.22 |
| `*(*[4]byte)(slice)` unsafe cast | `[4]byte(slice)` direct conversion | 1.20 |
| `atomic.LoadInt64` / `StoreInt64` | `atomic.Int64` (also `Bool`, `Uint64`, `Pointer[T]`) | 1.19 |
| `crypto/rand.Read(buf)` + hex/base64 encode | `crypto/rand.Text()` (one call) | 1.24 |
| Checking `crypto/rand.Read` error | don't: return is always nil | 1.24 |
| `time.Sleep` in tests | `testing/synctest` (deterministic fake clock) | 1.24/1.25 |
| `json:",omitempty"` on zero-value structs like `time.Time{}` | `json:",omitzero"` (uses `IsZero()` method) | 1.24 |
| `strings.Title` | `golang.org/x/text/cases` | 1.18 |
| `net.IP` in new code | `net/netip.Addr` (immutable, comparable, lighter) | 1.18 |
| `tools.go` with blank imports | `tool` directive in `go.mod` | 1.24 |
| `runtime.SetFinalizer` | `runtime.AddCleanup` (multiple per object, no pointer cycles) | 1.24 |
| `httputil.ReverseProxy.Director` | `.Rewrite` hook + `ProxyRequest` (Director deprecated in 1.26) | 1.20 |
| `sql.NullString`, `sql.NullInt64`, etc. | `sql.Null[T]` | 1.22 |
| Manual `ctx, cancel := context.WithCancel(…)` + `t.Cleanup(cancel)` | `t.Context()` (auto-canceled when test ends) | 1.24 |
| `if d < 0 { d = -d }` on durations | `d.Abs()` (handles `math.MinInt64`) | 1.19 |
| Implement only `TextMarshaler` | also implement `TextAppender` for alloc-free marshaling | 1.24 |
| Custom `Unwrap() error` on multi-cause errors | `Unwrap() []error` (slice form; required for tree traversal) | 1.20 |
## New capabilities
These enable things that weren't practical before. Reach for them in the
described situations.
| What | Since | When to use it |
|---|---|---|
| `cmp.Or(a, b, c)` | 1.22 | Defaults/fallback chains: returns first non-zero value. Replaces verbose `if a != "" { return a }` cascades. |
| `context.WithoutCancel(ctx)` | 1.21 | Background work that must outlive the request (e.g. async cleanup after HTTP response). Derived context keeps parent's values but ignores cancellation. |
| `context.AfterFunc(ctx, fn)` | 1.21 | Register cleanup that fires on context cancellation without spawning a goroutine that blocks on `<-ctx.Done()`. |
| `context.WithCancelCause` / `Cause` | 1.20 | When callers need to know WHY a context was canceled, not just that it was. Retrieve cause with `context.Cause(ctx)`. |
| `context.WithDeadlineCause` / `WithTimeoutCause` | 1.21 | Attach a domain-specific error to deadline/timeout expiry (e.g. distinguish "DB query timed out" from "HTTP request timed out"). |
| `errors.ErrUnsupported` | 1.21 | Standard sentinel for "not supported." Use instead of per-package custom sentinels. Check with `errors.Is`. |
| `http.ResponseController` | 1.20 | Per-request flush, hijack, and deadline control without type-asserting `ResponseWriter` to `http.Flusher` or `http.Hijacker`. |
| Enhanced `ServeMux` routing | 1.22 | `"GET /items/{id}"` patterns in `http.ServeMux`. Access with `r.PathValue("id")`. Wildcards: `{name}`, catch-all: `{path...}`, exact: `{$}`. Eliminates many third-party router dependencies. |
| `os.Root` / `OpenRoot` | 1.24 | Confined directory access that prevents symlink escape. 1.25 adds `MkdirAll`, `ReadFile`, `WriteFile` for real use. |
| `os.CopyFS` | 1.23 | Copy an entire `fs.FS` to local filesystem in one call. |
| `os/signal.NotifyContext` with cause | 1.26 | Cancellation cause identifies which signal (SIGTERM vs SIGINT) triggered shutdown. |
| `io/fs.SkipAll` / `filepath.SkipAll` | 1.20 | Return from `WalkDir` callback to stop walking entirely. Cleaner than a sentinel error. |
| `GOMEMLIMIT` env / `debug.SetMemoryLimit` | 1.19 | Soft memory limit for GC. Use alongside or instead of `GOGC` in memory-constrained containers. |
| `net/url.JoinPath` | 1.19 | Join URL path segments correctly. Replaces error-prone string concatenation. |
| `go test -skip` | 1.20 | Skip tests matching a pattern. Useful when running a subset of a large test suite. |
## Key packages
### `slices` (1.21, iterators added 1.23)
Replaces `sort.Slice`, manual search loops, and manual contains checks.
Search: `Contains`, `ContainsFunc`, `Index`, `IndexFunc`,
`BinarySearch`, `BinarySearchFunc`.
Sort: `Sort`, `SortFunc`, `SortStableFunc`, `IsSorted`, `IsSortedFunc`,
`Min`, `MinFunc`, `Max`, `MaxFunc`.
Transform: `Clone`, `Compact`, `CompactFunc`, `Grow`, `Clip`,
`Concat` (1.22), `Repeat` (1.23), `Reverse`, `Insert`, `Delete`,
`Replace`.
Compare: `Equal`, `EqualFunc`, `Compare`.
Iterators (1.23): `All`, `Values`, `Backward`, `Collect`, `AppendSeq`,
`Sorted`, `SortedFunc`, `SortedStableFunc`, `Chunk`.
### `maps` (1.21, iterators added 1.23)
Core: `Clone`, `Copy`, `Equal`, `EqualFunc`, `DeleteFunc`.
Iterators (1.23): `All`, `Keys`, `Values`, `Insert`, `Collect`.
### `cmp` (1.21, `Or` added 1.22)
`Ordered` constraint for any ordered type. `Compare(a, b)` returns
-1/0/+1. `Less(a, b)` returns bool. `Or(vals...)` returns first
non-zero value.
### `iter` (1.23)
`Seq[V]` is `func(yield func(V) bool)`. `Seq2[K,V]` is
`func(yield func(K, V) bool)`. Return these from your container's
`.All()` methods. Consume with `for v := range seq` or pass to
`slices.Collect`, `slices.Sorted`, `maps.Collect`, etc.
### `math/rand/v2` (1.22)
Replaces `math/rand`. `IntN` not `Intn`. Generic `N[T]()` for any
integer type. Default source is `ChaCha8` (crypto-quality). No global
`Seed`. Use `rand.New(source)` for reproducible sequences.
### `log/slog` (1.21)
`slog.Info`, `slog.Warn`, `slog.Error`, `slog.Debug` with key-value
pairs. `slog.With(attrs...)` for logger with preset fields.
`slog.GroupAttrs` (1.25) for clean group creation. Implement
`slog.Handler` for custom backends.
**Note:** This project uses `cdr.dev/slog/v3`, not `log/slog`. The
API is different. Read existing code for usage patterns.
## Pitfalls
Things that are easy to get wrong, even when you know the modern API
exists. Check your output against these.
**Version misuse.** The replacement table has a "Since" column. If the
project's `go.mod` says `go 1.22`, you cannot use `wg.Go` (1.25),
`errors.AsType` (1.26), `new(expr)` (1.26), `b.Loop()` (1.24), or
`testing/synctest` (1.24). Fall back to the older pattern. Always
check before reaching for a replacement.
**`slices.Sort` vs `slices.SortFunc`.** `slices.Sort` requires
`cmp.Ordered` types (int, string, float64, etc.). For structs, custom
types, or multi-field sorting, use `slices.SortFunc` with a comparator
function. Using `slices.Sort` on a non-ordered type is a compile error.
**`for range n` still binds the index.** `for range n` discards the
index. If you need it, write `for i := range n`. Writing
`for range n` and then trying to use `i` inside the loop is a compile
error.
**Don't hand-roll iterators when the stdlib returns one.** Functions
like `maps.Keys`, `slices.Values`, `strings.SplitSeq`, and
`strings.Lines` already return `iter.Seq` or `iter.Seq2`. Don't
reimplement them. Compose with `slices.Collect`, `slices.Sorted`, etc.
**Don't mix `math/rand` and `math/rand/v2`.** They have different
function names (`Intn` vs `IntN`) and different default sources. Pick
one per package. Prefer v2 for new code. The v1 global source is
auto-seeded since 1.20, so delete `rand.Seed` calls either way.
**Iterator protocol.** When implementing `iter.Seq`, you must respect
the `yield` return value. If `yield` returns `false`, stop iteration
immediately and return. Ignoring it violates the contract and causes
panics when consumers break out of `for range` loops early.
**`errors.Join` with nil.** `errors.Join` skips nil arguments. This is
intentional and useful for aggregating optional errors, but don't
assume the result is always non-nil. `errors.Join(nil, nil)` returns
nil.
**`cmp.Or` evaluates all arguments.** Unlike a chain of `if`
statements, `cmp.Or(a(), b(), c())` calls all three functions. If any
have side effects or are expensive, use `if`/`else` instead.
**Timer channel semantics changed in 1.23.** Code that checks
`len(timer.C)` to see if a value is pending no longer works (channel
capacity is 0). Use a non-blocking `select` receive instead:
`select { case <-timer.C: default: }`.
**`context.WithoutCancel` still propagates values.** The derived
context inherits all values from the parent. If any middleware stores
request-scoped state (deadlines, trace IDs) via `context.WithValue`,
the background work sees it. This is usually desired but can be
surprising if the values hold references that should not outlive the
request.
## Behavioral changes that affect code
- **Timers** (1.23): unstopped `Timer`/`Ticker` are GC'd immediately.
Channels are unbuffered: no stale values after `Reset`/`Stop`. You no
longer need `defer t.Stop()` to prevent leaks.
- **Error tree traversal** (1.20): `errors.Is`/`As` follow
`Unwrap() []error`, not just `Unwrap() error`. Multi-error types must
expose the slice form for child errors to be found.
- **`math/rand` auto-seeded** (1.20): the global RNG is auto-seeded.
`rand.Seed` is a no-op in 1.24+. Don't call it.
- **GODEBUG compat** (1.21): behavioral changes are gated by `go.mod`'s
`go` line. Upgrading the version opts into new defaults.
- **Build tags** (1.18): `//go:build` is the only syntax. `// +build`
is gone.
- **Tool install** (1.18): `go get` no longer builds. Use
`go install pkg@version`.
- **Doc comments** (1.19): support `[links]`, lists, and headings.
- **`go test -skip`** (1.20): skip tests by name pattern from the
command line.
- **`go fix ./...` modernizers** (1.26): auto-rewrites code to use
newer idioms. Run after Go version upgrades.
## Transparent improvements (no code changes)
Swiss Tables maps, Green Tea GC, PGO, faster `io.ReadAll`,
stack-allocated slices, reduced cgo overhead, container-aware
GOMAXPROCS. Free on upgrade.
+25 -7
View File
@@ -4,13 +4,22 @@ This guide documents the PR description style used in the Coder repository, base
## PR Title Format
Format: `type(scope): description`. See [CONTRIBUTING.md](docs/about/contributing/CONTRIBUTING.md#commit-messages) for full rules. PR titles are linted in CI.
Follow [Conventional Commits 1.0.0](https://www.conventionalcommits.org/en/v1.0.0/) format:
- 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
```text
type(scope): brief description
```
Examples:
**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:**
- `feat: add tracing to aibridge`
- `fix: move contexts to appropriate locations`
@@ -177,6 +186,16 @@ Dependabot PRs are auto-generated - don't try to match their verbose style for m
Changes from https://github.com/upstream/repo/pull/XXX/
```
## Attribution Footer
For AI-generated PRs, end with:
```markdown
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
```
## Creating PRs as Draft
**IMPORTANT**: Unless explicitly told otherwise, always create PRs as drafts using the `--draft` flag:
@@ -187,12 +206,11 @@ gh pr create --draft --title "..." --body "..."
After creating the PR, encourage the user to review it before marking as ready:
```text
```
I've created draft PR #XXXX. Please review the changes and mark it as ready for review when you're satisfied.
```
This allows the user to:
- Review the code changes before requesting reviews from maintainers
- Make additional adjustments if needed
- Ensure CI passes before notifying reviewers
+1
View File
@@ -67,6 +67,7 @@ coderd/
| `make test` | Run all Go tests |
| `make test RUN=TestFunctionName` | Run specific test |
| `go test -v ./path/to/package -run TestFunctionName` | Run test with verbose output |
| `make test-postgres` | Run tests with Postgres database |
| `make test-race` | Run tests with Go race detector |
| `make test-e2e` | Run end-to-end tests |
+4 -5
View File
@@ -109,6 +109,7 @@
- Run full test suite: `make test`
- Run specific test: `make test RUN=TestFunctionName`
- Run with Postgres: `make test-postgres`
- Run with race detector: `make test-race`
- Run end-to-end tests: `make test-e2e`
@@ -136,11 +137,9 @@ Then make your changes and push normally. Don't use `git push --force` unless th
## Commit Style
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
- 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`
- Keep message titles concise (~70 characters)
- Use imperative, present tense in commit titles
-4
View File
@@ -1,4 +0,0 @@
# All artifacts of the build processed are dumped here.
# Ignore it for docker context, as all Dockerfiles should build their own
# binaries.
build
+1
View File
@@ -1,6 +1,7 @@
name: "🐞 Bug"
description: "File a bug report."
title: "bug: "
labels: ["needs-triage"]
type: "Bug"
body:
- type: checkboxes
-9
View File
@@ -1,9 +0,0 @@
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'
+6 -3
View File
@@ -4,7 +4,10 @@ description: |
inputs:
version:
description: "The Go version to use."
default: "1.25.7"
default: "1.25.6"
use-preinstalled-go:
description: "Whether to use preinstalled Go."
default: "false"
use-cache:
description: "Whether to use the cache."
default: "true"
@@ -12,9 +15,9 @@ runs:
using: "composite"
steps:
- name: Setup Go
uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # v5.6.0
uses: actions/setup-go@0a12ed9d6a96ab950c8f026ed9f722fe0da7ef32 # v5.0.2
with:
go-version: ${{ inputs.version }}
go-version: ${{ inputs.use-preinstalled-go == 'false' && inputs.version || '' }}
cache: ${{ inputs.use-cache }}
- name: Install gotestsum
+1 -1
View File
@@ -7,5 +7,5 @@ runs:
- name: Install Terraform
uses: hashicorp/setup-terraform@b9cd54a3c349d3f38e8881555d616ced269862dd # v3.1.2
with:
terraform_version: 1.14.5
terraform_version: 1.14.1
terraform_wrapper: false
+5 -2
View File
@@ -64,14 +64,17 @@ runs:
TEST_PACKAGES: ${{ inputs.test-packages }}
RACE_DETECTION: ${{ inputs.race-detection }}
TS_DEBUG_DISCO: "true"
TS_DEBUG_DERP: "true"
LC_CTYPE: "en_US.UTF-8"
LC_ALL: "en_US.UTF-8"
run: |
set -euo pipefail
if [[ ${RACE_DETECTION} == true ]]; then
make test-race
gotestsum --junitfile="gotests.xml" --packages="${TEST_PACKAGES}" -- \
-tags=testsmallbatch \
-race \
-parallel "${TEST_NUM_PARALLEL_TESTS}" \
-p "${TEST_NUM_PARALLEL_PACKAGES}"
else
make test
fi
+146 -125
View File
@@ -35,7 +35,7 @@ jobs:
tailnet-integration: ${{ steps.filter.outputs.tailnet-integration }}
steps:
- name: Harden Runner
uses: step-security/harden-runner@fa2e9d605c4eeb9fcad4c99c224cee0c6c7f3594 # v2.16.0
uses: step-security/harden-runner@e3f713f2d8f53843e71c69a996d56f51aa9adfb9 # v2.14.1
with:
egress-policy: audit
@@ -45,7 +45,7 @@ jobs:
fetch-depth: 1
persist-credentials: false
- name: check changed files
uses: dorny/paths-filter@fbd0ab8f3e69293af611ebaee6363fc25e6d187d # v4.0.1
uses: dorny/paths-filter@de90cc6fb38fc0963ad72b210f1f284cd68cea36 # v3.0.2
id: filter
with:
filters: |
@@ -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@fa2e9d605c4eeb9fcad4c99c224cee0c6c7f3594 # v2.16.0
uses: step-security/harden-runner@e3f713f2d8f53843e71c69a996d56f51aa9adfb9 # v2.14.1
with:
egress-policy: audit
@@ -191,7 +191,7 @@ jobs:
# Check for any typos
- name: Check for typos
uses: crate-ci/typos@631208b7aac2daa8b707f55e7331f9112b0e062d # v1.44.0
uses: crate-ci/typos@2d0ce569feab1f8752f1dde43cc2f2aa53236e06 # v1.40.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@fa2e9d605c4eeb9fcad4c99c224cee0c6c7f3594 # v2.16.0
uses: step-security/harden-runner@e3f713f2d8f53843e71c69a996d56f51aa9adfb9 # v2.14.1
with:
egress-policy: audit
@@ -272,7 +272,7 @@ jobs:
if: ${{ !cancelled() }}
steps:
- name: Harden Runner
uses: step-security/harden-runner@fa2e9d605c4eeb9fcad4c99c224cee0c6c7f3594 # v2.16.0
uses: step-security/harden-runner@e3f713f2d8f53843e71c69a996d56f51aa9adfb9 # v2.14.1
with:
egress-policy: audit
@@ -315,7 +315,9 @@ jobs:
# Notifications require DB, we could start a DB instance here but
# let's just restore for now.
git checkout -- coderd/notifications/testdata/rendered-templates
make -j --output-sync -B gen
# no `-j` flag as `make` fails with:
# coderd/rbac/object_gen.go:1:1: syntax error: package statement must be first
make --output-sync -B gen
- name: Check for unstaged files
run: ./scripts/check_unstaged.sh
@@ -327,7 +329,7 @@ jobs:
timeout-minutes: 20
steps:
- name: Harden Runner
uses: step-security/harden-runner@fa2e9d605c4eeb9fcad4c99c224cee0c6c7f3594 # v2.16.0
uses: step-security/harden-runner@e3f713f2d8f53843e71c69a996d56f51aa9adfb9 # v2.14.1
with:
egress-policy: audit
@@ -366,9 +368,9 @@ jobs:
needs: changes
if: needs.changes.outputs.go == 'true' || needs.changes.outputs.ci == 'true' || github.ref == 'refs/heads/main'
# This timeout must be greater than the timeout set by `go test` in
# `make test` to ensure we receive a trace of running goroutines.
# Setting this to the timeout +5m should work quite well even if
# some of the preceding steps are slow.
# `make test-postgres` to ensure we receive a trace of running
# goroutines. Setting this to the timeout +5m should work quite well
# even if some of the preceding steps are slow.
timeout-minutes: 25
strategy:
fail-fast: false
@@ -379,7 +381,7 @@ jobs:
- windows-2022
steps:
- name: Harden Runner
uses: step-security/harden-runner@fa2e9d605c4eeb9fcad4c99c224cee0c6c7f3594 # v2.16.0
uses: step-security/harden-runner@e3f713f2d8f53843e71c69a996d56f51aa9adfb9 # v2.14.1
with:
egress-policy: audit
@@ -420,6 +422,10 @@ jobs:
- name: Setup Go
uses: ./.github/actions/setup-go
with:
# Runners have Go baked-in and Go will automatically
# download the toolchain configured in go.mod, so we don't
# need to reinstall it. It's faster on Windows runners.
use-preinstalled-go: ${{ runner.os == 'Windows' }}
use-cache: true
- name: Setup Terraform
@@ -475,17 +481,14 @@ jobs:
mkdir -p /tmp/tmpfs
sudo mount_tmpfs -o noowners -s 8g /tmp/tmpfs
# Install google-chrome for scaletests.
# As another concern, should we really have this kind of external dependency
# requirement on standard CI?
brew install google-chrome
# macOS will output "The default interactive shell is now zsh" intermittently in CI.
touch ~/.bash_profile && echo "export BASH_SILENCE_DEPRECATION_WARNING=1" >> ~/.bash_profile
- name: Increase PTY limit (macOS)
if: runner.os == 'macOS'
shell: bash
run: |
# Increase PTY limit to avoid exhaustion during tests.
# Default is 511; 999 is the maximum value on CI runner.
sudo sysctl -w kern.tty.ptmx_max=999
- name: Test with PostgreSQL Database (Linux)
if: runner.os == 'Linux'
uses: ./.github/actions/test-go-pg
@@ -537,7 +540,7 @@ jobs:
embedded-pg-cache: ${{ steps.embedded-pg-cache.outputs.embedded-pg-cache }}
- name: Upload failed test db dumps
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
with:
name: failed-test-db-dump-${{matrix.os}}
path: "**/*.test.sql"
@@ -569,13 +572,13 @@ jobs:
- changes
if: needs.changes.outputs.go == 'true' || needs.changes.outputs.ci == 'true' || github.ref == 'refs/heads/main'
# This timeout must be greater than the timeout set by `go test` in
# `make test` to ensure we receive a trace of running goroutines.
# Setting this to the timeout +5m should work quite well even if
# some of the preceding steps are slow.
# `make test-postgres` to ensure we receive a trace of running
# goroutines. Setting this to the timeout +5m should work quite well
# even if some of the preceding steps are slow.
timeout-minutes: 25
steps:
- name: Harden Runner
uses: step-security/harden-runner@fa2e9d605c4eeb9fcad4c99c224cee0c6c7f3594 # v2.16.0
uses: step-security/harden-runner@e3f713f2d8f53843e71c69a996d56f51aa9adfb9 # v2.14.1
with:
egress-policy: audit
@@ -637,7 +640,7 @@ jobs:
timeout-minutes: 25
steps:
- name: Harden Runner
uses: step-security/harden-runner@fa2e9d605c4eeb9fcad4c99c224cee0c6c7f3594 # v2.16.0
uses: step-security/harden-runner@e3f713f2d8f53843e71c69a996d56f51aa9adfb9 # v2.14.1
with:
egress-policy: audit
@@ -709,7 +712,7 @@ jobs:
timeout-minutes: 20
steps:
- name: Harden Runner
uses: step-security/harden-runner@fa2e9d605c4eeb9fcad4c99c224cee0c6c7f3594 # v2.16.0
uses: step-security/harden-runner@e3f713f2d8f53843e71c69a996d56f51aa9adfb9 # v2.14.1
with:
egress-policy: audit
@@ -736,7 +739,7 @@ jobs:
timeout-minutes: 20
steps:
- name: Harden Runner
uses: step-security/harden-runner@fa2e9d605c4eeb9fcad4c99c224cee0c6c7f3594 # v2.16.0
uses: step-security/harden-runner@e3f713f2d8f53843e71c69a996d56f51aa9adfb9 # v2.14.1
with:
egress-policy: audit
@@ -769,7 +772,7 @@ jobs:
name: ${{ matrix.variant.name }}
steps:
- name: Harden Runner
uses: step-security/harden-runner@fa2e9d605c4eeb9fcad4c99c224cee0c6c7f3594 # v2.16.0
uses: step-security/harden-runner@e3f713f2d8f53843e71c69a996d56f51aa9adfb9 # v2.14.1
with:
egress-policy: audit
@@ -818,7 +821,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@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
with:
name: failed-test-videos${{ matrix.variant.premium && '-premium' || '' }}
path: ./site/test-results/**/*.webm
@@ -826,7 +829,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@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
with:
name: coderd-debug-logs${{ matrix.variant.premium && '-premium' || '' }}
path: ./site/e2e/test-results/debug.log
@@ -834,7 +837,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@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
with:
name: debug-pprof-dumps${{ matrix.variant.premium && '-premium' || '' }}
path: ./site/test-results/**/debug-pprof-*.txt
@@ -849,7 +852,7 @@ jobs:
if: needs.changes.outputs.site == 'true' || needs.changes.outputs.ci == 'true'
steps:
- name: Harden Runner
uses: step-security/harden-runner@fa2e9d605c4eeb9fcad4c99c224cee0c6c7f3594 # v2.16.0
uses: step-security/harden-runner@e3f713f2d8f53843e71c69a996d56f51aa9adfb9 # v2.14.1
with:
egress-policy: audit
@@ -930,7 +933,7 @@ jobs:
steps:
- name: Harden Runner
uses: step-security/harden-runner@fa2e9d605c4eeb9fcad4c99c224cee0c6c7f3594 # v2.16.0
uses: step-security/harden-runner@e3f713f2d8f53843e71c69a996d56f51aa9adfb9 # v2.14.1
with:
egress-policy: audit
@@ -981,9 +984,6 @@ jobs:
run: |
make build/coder_docs_"$(./scripts/version.sh)".tgz
- name: Check for unstaged files
run: ./scripts/check_unstaged.sh
required:
runs-on: ubuntu-latest
needs:
@@ -1005,7 +1005,7 @@ jobs:
if: always()
steps:
- name: Harden Runner
uses: step-security/harden-runner@fa2e9d605c4eeb9fcad4c99c224cee0c6c7f3594 # v2.16.0
uses: step-security/harden-runner@e3f713f2d8f53843e71c69a996d56f51aa9adfb9 # v2.14.1
with:
egress-policy: audit
@@ -1034,6 +1034,83 @@ jobs:
echo "Required checks have passed"
# Builds the dylibs and upload it as an artifact so it can be embedded in the main build
build-dylib:
needs: changes
# We always build the dylibs on Go changes to verify we're not merging unbuildable code,
# but they need only be signed and uploaded on coder/coder main.
if: needs.changes.outputs.go == 'true' || needs.changes.outputs.ci == 'true' || github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/heads/release/')
runs-on: ${{ github.repository_owner == 'coder' && 'depot-macos-latest' || 'macos-latest' }}
steps:
# Harden Runner doesn't work on macOS
- name: Checkout
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
fetch-depth: 0
persist-credentials: false
- name: Setup GNU tools (macOS)
uses: ./.github/actions/setup-gnu-tools
- name: Switch XCode Version
uses: maxim-lobanov/setup-xcode@60606e260d2fc5762a71e64e74b2174e8ea3c8bd # v1.6.0
with:
xcode-version: "16.1.0"
- name: Setup Go
uses: ./.github/actions/setup-go
- name: Install rcodesign
if: ${{ github.repository_owner == 'coder' && (github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/heads/release/')) }}
run: |
set -euo pipefail
wget -O /tmp/rcodesign.tar.gz https://github.com/indygreg/apple-platform-rs/releases/download/apple-codesign%2F0.22.0/apple-codesign-0.22.0-macos-universal.tar.gz
sudo tar -xzf /tmp/rcodesign.tar.gz \
-C /usr/local/bin \
--strip-components=1 \
apple-codesign-0.22.0-macos-universal/rcodesign
rm /tmp/rcodesign.tar.gz
- name: Setup Apple Developer certificate and API key
if: ${{ github.repository_owner == 'coder' && (github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/heads/release/')) }}
run: |
set -euo pipefail
touch /tmp/{apple_cert.p12,apple_cert_password.txt,apple_apikey.p8}
chmod 600 /tmp/{apple_cert.p12,apple_cert_password.txt,apple_apikey.p8}
echo "$AC_CERTIFICATE_P12_BASE64" | base64 -d > /tmp/apple_cert.p12
echo "$AC_CERTIFICATE_PASSWORD" > /tmp/apple_cert_password.txt
echo "$AC_APIKEY_P8_BASE64" | base64 -d > /tmp/apple_apikey.p8
env:
AC_CERTIFICATE_P12_BASE64: ${{ secrets.AC_CERTIFICATE_P12_BASE64 }}
AC_CERTIFICATE_PASSWORD: ${{ secrets.AC_CERTIFICATE_PASSWORD }}
AC_APIKEY_P8_BASE64: ${{ secrets.AC_APIKEY_P8_BASE64 }}
- name: Build dylibs
run: |
set -euxo pipefail
./.github/scripts/retry.sh -- go mod download
make gen/mark-fresh
make build/coder-dylib
env:
CODER_SIGN_DARWIN: ${{ (github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/heads/release/')) && '1' || '0' }}
AC_CERTIFICATE_FILE: /tmp/apple_cert.p12
AC_CERTIFICATE_PASSWORD_FILE: /tmp/apple_cert_password.txt
- name: Upload build artifacts
if: ${{ github.repository_owner == 'coder' && (github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/heads/release/')) }}
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
with:
name: dylibs
path: |
./build/*.h
./build/*.dylib
retention-days: 7
- name: Delete Apple Developer certificate and API key
if: ${{ github.repository_owner == 'coder' && (github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/heads/release/')) }}
run: rm -f /tmp/{apple_cert.p12,apple_cert_password.txt,apple_apikey.p8}
check-build:
# This job runs make build to verify compilation on PRs.
# The build doesn't get signed, and is not suitable for usage, unlike the
@@ -1043,7 +1120,7 @@ jobs:
runs-on: ${{ github.repository_owner == 'coder' && 'depot-ubuntu-22.04-8' || 'ubuntu-latest' }}
steps:
- name: Harden Runner
uses: step-security/harden-runner@fa2e9d605c4eeb9fcad4c99c224cee0c6c7f3594 # v2.16.0
uses: step-security/harden-runner@e3f713f2d8f53843e71c69a996d56f51aa9adfb9 # v2.14.1
with:
egress-policy: audit
@@ -1080,6 +1157,7 @@ jobs:
# to main branch.
needs:
- changes
- build-dylib
if: (github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/heads/release/')) && needs.changes.outputs.docs-only == 'false' && !github.event.pull_request.head.repo.fork
runs-on: ${{ github.repository_owner == 'coder' && 'depot-ubuntu-22.04-8' || 'ubuntu-22.04' }}
permissions:
@@ -1097,7 +1175,7 @@ jobs:
IMAGE: ghcr.io/coder/coder-preview:${{ steps.build-docker.outputs.tag }}
steps:
- name: Harden Runner
uses: step-security/harden-runner@fa2e9d605c4eeb9fcad4c99c224cee0c6c7f3594 # v2.16.0
uses: step-security/harden-runner@e3f713f2d8f53843e71c69a996d56f51aa9adfb9 # v2.14.1
with:
egress-policy: audit
@@ -1108,7 +1186,7 @@ jobs:
persist-credentials: false
- name: GHCR Login
uses: docker/login-action@b45d80f862d83dbcd57f89517bcf500b2ab88fb2 # v4.0.0
uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0
with:
registry: ghcr.io
username: ${{ github.actor }}
@@ -1119,8 +1197,6 @@ jobs:
- name: Setup Go
uses: ./.github/actions/setup-go
with:
use-cache: false
- name: Install rcodesign
run: |
@@ -1187,6 +1263,18 @@ jobs:
- name: Setup GCloud SDK
uses: google-github-actions/setup-gcloud@aa5489c8933f4cc7a4f7d45035b3b1440c9c10db # v3.0.1
- name: Download dylibs
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0
with:
name: dylibs
path: ./build
- name: Insert dylibs
run: |
mv ./build/*amd64.dylib ./site/out/bin/coder-vpn-darwin-amd64.dylib
mv ./build/*arm64.dylib ./site/out/bin/coder-vpn-darwin-arm64.dylib
mv ./build/*arm64.h ./site/out/bin/coder-vpn-darwin-dylib.h
- name: Build
run: |
set -euxo pipefail
@@ -1200,10 +1288,11 @@ jobs:
make -j \
build/coder_linux_{amd64,arm64,armv7} \
build/coder_"$version"_windows_amd64.zip \
build/coder_"$version"_linux_{amd64,arm64,armv7}.{tar.gz,deb}
build/coder_"$version"_linux_amd64.{tar.gz,deb}
env:
# The Windows and Darwin slim binaries must be signed for Coder
# Desktop to accept them.
# The Windows slim binary must be signed for Coder Desktop to accept
# it. The darwin executables don't need to be signed, but the dylibs
# do (see above).
CODER_SIGN_WINDOWS: "1"
CODER_WINDOWS_RESOURCES: "1"
CODER_SIGN_GPG: "1"
@@ -1217,35 +1306,12 @@ jobs:
EV_CERTIFICATE_PATH: /tmp/ev_cert.pem
GCLOUD_ACCESS_TOKEN: ${{ steps.gcloud_auth.outputs.access_token }}
JSIGN_PATH: /tmp/jsign-6.0.jar
# Enable React profiling build and discoverable source maps
# for the dogfood deployment (dev.coder.com). This also
# applies to release/* branch builds, but those still
# produce coder-preview images, not release images.
# Release images are built by release.yaml (no profiling).
CODER_REACT_PROFILING: "true"
# 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
@@ -1327,7 +1393,7 @@ jobs:
id: attest_main
if: github.ref == 'refs/heads/main'
continue-on-error: true
uses: actions/attest@59d89421af93a897026c735860bf21b6eb4f7b26 # v4.1.0
uses: actions/attest@e59cbc1ad1ac2d59339667419eb8cdde6eb61e3d # v3.2.0
with:
subject-name: "ghcr.io/coder/coder-preview:main"
predicate-type: "https://slsa.dev/provenance/v1"
@@ -1364,7 +1430,7 @@ jobs:
id: attest_latest
if: github.ref == 'refs/heads/main'
continue-on-error: true
uses: actions/attest@59d89421af93a897026c735860bf21b6eb4f7b26 # v4.1.0
uses: actions/attest@e59cbc1ad1ac2d59339667419eb8cdde6eb61e3d # v3.2.0
with:
subject-name: "ghcr.io/coder/coder-preview:latest"
predicate-type: "https://slsa.dev/provenance/v1"
@@ -1401,7 +1467,7 @@ jobs:
id: attest_version
if: github.ref == 'refs/heads/main'
continue-on-error: true
uses: actions/attest@59d89421af93a897026c735860bf21b6eb4f7b26 # v4.1.0
uses: actions/attest@e59cbc1ad1ac2d59339667419eb8cdde6eb61e3d # v3.2.0
with:
subject-name: "ghcr.io/coder/coder-preview:${{ steps.build-docker.outputs.tag }}"
predicate-type: "https://slsa.dev/provenance/v1"
@@ -1463,60 +1529,15 @@ jobs:
^v
prune-untagged: true
- name: Upload build artifact (coder-linux-amd64.tar.gz)
- name: Upload build artifacts
if: github.ref == 'refs/heads/main'
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
with:
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
name: coder
path: |
./build/*.zip
./build/*.tar.gz
./build/*.deb
retention-days: 7
# Deploy is handled in deploy.yaml so we can apply concurrency limits.
@@ -1551,7 +1572,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@fa2e9d605c4eeb9fcad4c99c224cee0c6c7f3594 # v2.16.0
uses: step-security/harden-runner@e3f713f2d8f53843e71c69a996d56f51aa9adfb9 # v2.14.1
with:
egress-policy: audit
@@ -19,9 +19,6 @@ on:
default: ""
type: string
permissions:
contents: read
jobs:
classify-severity:
name: AI Severity Classification
@@ -35,6 +32,7 @@ jobs:
permissions:
contents: read
issues: write
actions: write
steps:
- name: Determine Issue Context
+1 -3
View File
@@ -31,9 +31,6 @@ on:
default: ""
type: string
permissions:
contents: read
jobs:
code-review:
name: AI Code Review
@@ -54,6 +51,7 @@ jobs:
permissions:
contents: read
pull-requests: write
actions: write
steps:
- name: Check if secrets are available
-141
View File
@@ -23,44 +23,6 @@ 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:
@@ -83,109 +45,6 @@ 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:
-23
View File
@@ -1,23 +0,0 @@
# This workflow triggers a Vercel deploy hook which builds+deploys coder.com
# (a Next.js app), to keep coder.com/docs URLs in sync with docs/manifest.json
#
# https://vercel.com/docs/deploy-hooks#triggering-a-deploy-hook
name: Update coder.com/docs
on:
push:
branches:
- main
paths:
- "docs/manifest.json"
permissions: {}
jobs:
deploy-docs:
runs-on: ubuntu-latest
steps:
- name: Deploy docs site
run: |
curl -X POST "${{ secrets.DEPLOY_DOCS_VERCEL_WEBHOOK }}"
+19 -15
View File
@@ -36,7 +36,7 @@ jobs:
verdict: ${{ steps.check.outputs.verdict }} # DEPLOY or NOOP
steps:
- name: Harden Runner
uses: step-security/harden-runner@fa2e9d605c4eeb9fcad4c99c224cee0c6c7f3594 # v2.16.0
uses: step-security/harden-runner@e3f713f2d8f53843e71c69a996d56f51aa9adfb9 # v2.14.1
with:
egress-policy: audit
@@ -61,11 +61,11 @@ jobs:
if: needs.should-deploy.outputs.verdict == 'DEPLOY'
permissions:
contents: read
id-token: write # to authenticate to EKS cluster
id-token: write
packages: write # to retag image as dogfood
steps:
- name: Harden Runner
uses: step-security/harden-runner@fa2e9d605c4eeb9fcad4c99c224cee0c6c7f3594 # v2.16.0
uses: step-security/harden-runner@e3f713f2d8f53843e71c69a996d56f51aa9adfb9 # v2.14.1
with:
egress-policy: audit
@@ -76,29 +76,33 @@ jobs:
persist-credentials: false
- name: GHCR Login
uses: docker/login-action@b45d80f862d83dbcd57f89517bcf500b2ab88fb2 # v4.0.0
uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Configure AWS Credentials
uses: aws-actions/configure-aws-credentials@8df5847569e6427dd6c4fb1cf565c83acfa8afa7 # v6.0.0
- name: Authenticate to Google Cloud
uses: google-github-actions/auth@7c6bc770dae815cd3e89ee6cdf493a5fab2cc093 # v3.0.0
with:
role-to-assume: ${{ vars.AWS_DOGFOOD_DEPLOY_ROLE }}
aws-region: ${{ vars.AWS_DOGFOOD_DEPLOY_REGION }}
workload_identity_provider: ${{ vars.GCP_WORKLOAD_ID_PROVIDER }}
service_account: ${{ vars.GCP_SERVICE_ACCOUNT }}
- 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 Google Cloud SDK
uses: google-github-actions/setup-gcloud@aa5489c8933f4cc7a4f7d45035b3b1440c9c10db # v3.0.1
- 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.8.2"
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
# Retag image as dogfood while maintaining the multi-arch manifest
- name: Tag image as dogfood
@@ -142,7 +146,7 @@ jobs:
needs: deploy
steps:
- name: Harden Runner
uses: step-security/harden-runner@fa2e9d605c4eeb9fcad4c99c224cee0c6c7f3594 # v2.16.0
uses: step-security/harden-runner@e3f713f2d8f53843e71c69a996d56f51aa9adfb9 # v2.14.1
with:
egress-policy: audit
+1 -3
View File
@@ -34,9 +34,6 @@ on:
default: ""
type: string
permissions:
contents: read
jobs:
doc-check:
name: Analyze PR for Documentation Updates Needed
@@ -59,6 +56,7 @@ jobs:
permissions:
contents: read
pull-requests: write
actions: write
steps:
- name: Check if secrets are available
+4 -4
View File
@@ -38,7 +38,7 @@ jobs:
if: github.repository_owner == 'coder'
steps:
- name: Harden Runner
uses: step-security/harden-runner@fa2e9d605c4eeb9fcad4c99c224cee0c6c7f3594 # v2.16.0
uses: step-security/harden-runner@e3f713f2d8f53843e71c69a996d56f51aa9adfb9 # v2.14.1
with:
egress-policy: audit
@@ -48,7 +48,7 @@ jobs:
persist-credentials: false
- name: Docker login
uses: docker/login-action@b45d80f862d83dbcd57f89517bcf500b2ab88fb2 # v4.0.0
uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0
with:
registry: ghcr.io
username: ${{ github.actor }}
@@ -58,11 +58,11 @@ jobs:
run: mkdir base-build-context
- name: Install depot.dev CLI
uses: depot/setup-action@15c09a5f77a0840ad4bce955686522a257853461 # v1.7.1
uses: depot/setup-action@b0b1ea4f69e92ebf5dea3f8713a1b0c37b2126a5 # v1.6.0
# This uses OIDC authentication, so no auth variables are required.
- name: Build base Docker image via depot.dev
uses: depot/build-push-action@5f3b3c2e5a00f0093de47f657aeaefcedff27d18 # v1.17.0
uses: depot/build-push-action@9785b135c3c76c33db102e45be96a25ab55cd507 # v1.16.2
with:
project: wl5hnrrkns
context: base-build-context
+1 -1
View File
@@ -30,7 +30,7 @@ jobs:
- name: Setup Node
uses: ./.github/actions/setup-node
- uses: tj-actions/changed-files@22103cc46bda19c2b464ffe86db46df6922fd323 # v45.0.7
- uses: tj-actions/changed-files@e0021407031f5be11a464abee9a0776171c79891 # v45.0.7
id: changed-files
with:
files: |
+6 -6
View File
@@ -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@fa2e9d605c4eeb9fcad4c99c224cee0c6c7f3594 # v2.16.0
uses: step-security/harden-runner@e3f713f2d8f53843e71c69a996d56f51aa9adfb9 # v2.14.1
with:
egress-policy: audit
@@ -75,20 +75,20 @@ jobs:
BRANCH_NAME: ${{ steps.branch-name.outputs.current_branch }}
- name: Set up Depot CLI
uses: depot/setup-action@15c09a5f77a0840ad4bce955686522a257853461 # v1.7.1
uses: depot/setup-action@b0b1ea4f69e92ebf5dea3f8713a1b0c37b2126a5 # v1.6.0
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0
uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.12.0
- name: Login to DockerHub
if: github.ref == 'refs/heads/main'
uses: docker/login-action@b45d80f862d83dbcd57f89517bcf500b2ab88fb2 # v4.0.0
uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_PASSWORD }}
- name: Build and push Non-Nix image
uses: depot/build-push-action@5f3b3c2e5a00f0093de47f657aeaefcedff27d18 # v1.17.0
uses: depot/build-push-action@9785b135c3c76c33db102e45be96a25ab55cd507 # v1.16.2
with:
project: b4q6ltmpzh
token: ${{ secrets.DEPOT_TOKEN }}
@@ -125,7 +125,7 @@ jobs:
id-token: write
steps:
- name: Harden Runner
uses: step-security/harden-runner@fa2e9d605c4eeb9fcad4c99c224cee0c6c7f3594 # v2.16.0
uses: step-security/harden-runner@e3f713f2d8f53843e71c69a996d56f51aa9adfb9 # v2.14.1
with:
egress-policy: audit
-65
View File
@@ -1,65 +0,0 @@
name: Linear Release
on:
push:
branches:
- main
# This event reads the workflow from the default branch (main), not the
# release branch. No cherry-pick needed.
# https://docs.github.com/en/actions/using-workflows/events-that-trigger-workflows#release
release:
types: [published]
permissions:
contents: read
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs:
sync:
name: Sync issues to Linear release
if: github.event_name == 'push'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
fetch-depth: 0
persist-credentials: false
- name: Sync issues
id: sync
uses: linear/linear-release-action@5cbaabc187ceb63eee9d446e62e68e5c29a03ae8 # v0.5.0
with:
access_key: ${{ secrets.LINEAR_ACCESS_KEY }}
command: sync
- name: Print release URL
if: steps.sync.outputs.release-url
run: echo "Synced to $RELEASE_URL"
env:
RELEASE_URL: ${{ steps.sync.outputs.release-url }}
complete:
name: Complete Linear release
if: github.event_name == 'release'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
- name: Complete release
id: complete
uses: linear/linear-release-action@5cbaabc187ceb63eee9d446e62e68e5c29a03ae8 # v0
with:
access_key: ${{ secrets.LINEAR_ACCESS_KEY }}
command: complete
version: ${{ github.event.release.tag_name }}
- name: Print release URL
if: steps.complete.outputs.release-url
run: echo "Completed $RELEASE_URL"
env:
RELEASE_URL: ${{ steps.complete.outputs.release-url }}
+9 -4
View File
@@ -16,9 +16,9 @@ jobs:
# when changing runner sizes
runs-on: ${{ matrix.os == 'macos-latest' && github.repository_owner == 'coder' && 'depot-macos-latest' || matrix.os == 'windows-2022' && github.repository_owner == 'coder' && 'depot-windows-2022-16' || matrix.os }}
# This timeout must be greater than the timeout set by `go test` in
# `make test` to ensure we receive a trace of running goroutines.
# Setting this to the timeout +5m should work quite well even if
# some of the preceding steps are slow.
# `make test-postgres` to ensure we receive a trace of running
# goroutines. Setting this to the timeout +5m should work quite well
# even if some of the preceding steps are slow.
timeout-minutes: 25
strategy:
fail-fast: false
@@ -28,7 +28,7 @@ jobs:
- windows-2022
steps:
- name: Harden Runner
uses: step-security/harden-runner@fa2e9d605c4eeb9fcad4c99c224cee0c6c7f3594 # v2.16.0
uses: step-security/harden-runner@e3f713f2d8f53843e71c69a996d56f51aa9adfb9 # v2.14.1
with:
egress-policy: audit
@@ -64,6 +64,11 @@ jobs:
- name: Setup Go
uses: ./.github/actions/setup-go
with:
# Runners have Go baked-in and Go will automatically
# download the toolchain configured in go.mod, so we don't
# need to reinstall it. It's faster on Windows runners.
use-preinstalled-go: ${{ runner.os == 'Windows' }}
- name: Setup Terraform
uses: ./.github/actions/setup-tf
+1 -1
View File
@@ -15,7 +15,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Harden Runner
uses: step-security/harden-runner@fa2e9d605c4eeb9fcad4c99c224cee0c6c7f3594 # v2.16.0
uses: step-security/harden-runner@e3f713f2d8f53843e71c69a996d56f51aa9adfb9 # v2.14.1
with:
egress-policy: audit
+1 -1
View File
@@ -19,7 +19,7 @@ jobs:
packages: write
steps:
- name: Harden Runner
uses: step-security/harden-runner@fa2e9d605c4eeb9fcad4c99c224cee0c6c7f3594 # v2.16.0
uses: step-security/harden-runner@e3f713f2d8f53843e71c69a996d56f51aa9adfb9 # v2.14.1
with:
egress-policy: audit
+7 -7
View File
@@ -39,7 +39,7 @@ jobs:
PR_OPEN: ${{ steps.check_pr.outputs.pr_open }}
steps:
- name: Harden Runner
uses: step-security/harden-runner@fa2e9d605c4eeb9fcad4c99c224cee0c6c7f3594 # v2.16.0
uses: step-security/harden-runner@e3f713f2d8f53843e71c69a996d56f51aa9adfb9 # v2.14.1
with:
egress-policy: audit
@@ -76,7 +76,7 @@ jobs:
runs-on: "ubuntu-latest"
steps:
- name: Harden Runner
uses: step-security/harden-runner@fa2e9d605c4eeb9fcad4c99c224cee0c6c7f3594 # v2.16.0
uses: step-security/harden-runner@e3f713f2d8f53843e71c69a996d56f51aa9adfb9 # v2.14.1
with:
egress-policy: audit
@@ -135,7 +135,7 @@ jobs:
PR_NUMBER: ${{ steps.pr_info.outputs.PR_NUMBER }}
- name: Check changed files
uses: dorny/paths-filter@fbd0ab8f3e69293af611ebaee6363fc25e6d187d # v4.0.1
uses: dorny/paths-filter@de90cc6fb38fc0963ad72b210f1f284cd68cea36 # v3.0.2
id: filter
with:
base: ${{ github.ref }}
@@ -184,7 +184,7 @@ jobs:
pull-requests: write # needed for commenting on PRs
steps:
- name: Harden Runner
uses: step-security/harden-runner@fa2e9d605c4eeb9fcad4c99c224cee0c6c7f3594 # v2.16.0
uses: step-security/harden-runner@e3f713f2d8f53843e71c69a996d56f51aa9adfb9 # v2.14.1
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@fa2e9d605c4eeb9fcad4c99c224cee0c6c7f3594 # v2.16.0
uses: step-security/harden-runner@e3f713f2d8f53843e71c69a996d56f51aa9adfb9 # v2.14.1
with:
egress-policy: audit
@@ -248,7 +248,7 @@ jobs:
uses: ./.github/actions/setup-sqlc
- name: GHCR Login
uses: docker/login-action@b45d80f862d83dbcd57f89517bcf500b2ab88fb2 # v4.0.0
uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.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@fa2e9d605c4eeb9fcad4c99c224cee0c6c7f3594 # v2.16.0
uses: step-security/harden-runner@e3f713f2d8f53843e71c69a996d56f51aa9adfb9 # v2.14.1
with:
egress-policy: audit
+2 -2
View File
@@ -14,12 +14,12 @@ jobs:
steps:
- name: Harden Runner
uses: step-security/harden-runner@fa2e9d605c4eeb9fcad4c99c224cee0c6c7f3594 # v2.16.0
uses: step-security/harden-runner@e3f713f2d8f53843e71c69a996d56f51aa9adfb9 # v2.14.1
with:
egress-policy: audit
- name: Run Schmoder CI
uses: benc-uk/workflow-dispatch@7a027648b88c2413826b6ddd6c76114894dc5ec4 # v1.3.1
uses: benc-uk/workflow-dispatch@e2e5e9a103e331dad343f381a29e654aea3cf8fc # v1.2.4
with:
workflow: ci.yaml
repo: coder/schmoder
+137 -15
View File
@@ -58,9 +58,87 @@ jobs:
if (!allowed) core.setFailed('Denied: requires maintain or admin');
# build-dylib is a separate job to build the dylib on macOS.
build-dylib:
runs-on: ${{ github.repository_owner == 'coder' && 'depot-macos-latest' || 'macos-latest' }}
needs: check-perms
steps:
# Harden Runner doesn't work on macOS.
- name: Checkout
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
fetch-depth: 0
persist-credentials: false
# If the event that triggered the build was an annotated tag (which our
# tags are supposed to be), actions/checkout has a bug where the tag in
# question is only a lightweight tag and not a full annotated tag. This
# command seems to fix it.
# https://github.com/actions/checkout/issues/290
- name: Fetch git tags
run: git fetch --tags --force
- name: Setup GNU tools (macOS)
uses: ./.github/actions/setup-gnu-tools
- name: Switch XCode Version
uses: maxim-lobanov/setup-xcode@60606e260d2fc5762a71e64e74b2174e8ea3c8bd # v1.6.0
with:
xcode-version: "16.1.0"
- name: Setup Go
uses: ./.github/actions/setup-go
- name: Install rcodesign
run: |
set -euo pipefail
wget -O /tmp/rcodesign.tar.gz https://github.com/indygreg/apple-platform-rs/releases/download/apple-codesign%2F0.22.0/apple-codesign-0.22.0-macos-universal.tar.gz
sudo tar -xzf /tmp/rcodesign.tar.gz \
-C /usr/local/bin \
--strip-components=1 \
apple-codesign-0.22.0-macos-universal/rcodesign
rm /tmp/rcodesign.tar.gz
- name: Setup Apple Developer certificate and API key
run: |
set -euo pipefail
touch /tmp/{apple_cert.p12,apple_cert_password.txt,apple_apikey.p8}
chmod 600 /tmp/{apple_cert.p12,apple_cert_password.txt,apple_apikey.p8}
echo "$AC_CERTIFICATE_P12_BASE64" | base64 -d > /tmp/apple_cert.p12
echo "$AC_CERTIFICATE_PASSWORD" > /tmp/apple_cert_password.txt
echo "$AC_APIKEY_P8_BASE64" | base64 -d > /tmp/apple_apikey.p8
env:
AC_CERTIFICATE_P12_BASE64: ${{ secrets.AC_CERTIFICATE_P12_BASE64 }}
AC_CERTIFICATE_PASSWORD: ${{ secrets.AC_CERTIFICATE_PASSWORD }}
AC_APIKEY_P8_BASE64: ${{ secrets.AC_APIKEY_P8_BASE64 }}
- name: Build dylibs
run: |
set -euxo pipefail
./.github/scripts/retry.sh -- go mod download
make gen/mark-fresh
make build/coder-dylib
env:
CODER_SIGN_DARWIN: 1
AC_CERTIFICATE_FILE: /tmp/apple_cert.p12
AC_CERTIFICATE_PASSWORD_FILE: /tmp/apple_cert_password.txt
- name: Upload build artifacts
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
with:
name: dylibs
path: |
./build/*.h
./build/*.dylib
retention-days: 7
- name: Delete Apple Developer certificate and API key
run: rm -f /tmp/{apple_cert.p12,apple_cert_password.txt,apple_apikey.p8}
release:
name: Build and publish
needs: [check-perms]
needs: [build-dylib, check-perms]
runs-on: ${{ github.repository_owner == 'coder' && 'depot-ubuntu-22.04-8' || 'ubuntu-latest' }}
permissions:
# Required to publish a release
@@ -80,7 +158,7 @@ jobs:
version: ${{ steps.version.outputs.version }}
steps:
- name: Harden Runner
uses: step-security/harden-runner@fa2e9d605c4eeb9fcad4c99c224cee0c6c7f3594 # v2.16.0
uses: step-security/harden-runner@e3f713f2d8f53843e71c69a996d56f51aa9adfb9 # v2.14.1
with:
egress-policy: audit
@@ -155,7 +233,7 @@ jobs:
cat "$CODER_RELEASE_NOTES_FILE"
- name: Docker Login
uses: docker/login-action@b45d80f862d83dbcd57f89517bcf500b2ab88fb2 # v4.0.0
uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0
with:
registry: ghcr.io
username: ${{ github.actor }}
@@ -163,8 +241,6 @@ jobs:
- name: Setup Go
uses: ./.github/actions/setup-go
with:
use-cache: false
- name: Setup Node
uses: ./.github/actions/setup-node
@@ -244,6 +320,18 @@ jobs:
- name: Setup GCloud SDK
uses: google-github-actions/setup-gcloud@aa5489c8933f4cc7a4f7d45035b3b1440c9c10db # v3.0.1
- name: Download dylibs
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0
with:
name: dylibs
path: ./build
- name: Insert dylibs
run: |
mv ./build/*amd64.dylib ./site/out/bin/coder-vpn-darwin-amd64.dylib
mv ./build/*arm64.dylib ./site/out/bin/coder-vpn-darwin-arm64.dylib
mv ./build/*arm64.h ./site/out/bin/coder-vpn-darwin-dylib.h
- name: Build binaries
run: |
set -euo pipefail
@@ -298,12 +386,12 @@ jobs:
- name: Install depot.dev CLI
if: steps.image-base-tag.outputs.tag != ''
uses: depot/setup-action@15c09a5f77a0840ad4bce955686522a257853461 # v1.7.1
uses: depot/setup-action@b0b1ea4f69e92ebf5dea3f8713a1b0c37b2126a5 # v1.6.0
# This uses OIDC authentication, so no auth variables are required.
- name: Build base Docker image via depot.dev
if: steps.image-base-tag.outputs.tag != ''
uses: depot/build-push-action@5f3b3c2e5a00f0093de47f657aeaefcedff27d18 # v1.17.0
uses: depot/build-push-action@9785b135c3c76c33db102e45be96a25ab55cd507 # v1.16.2
with:
project: wl5hnrrkns
context: base-build-context
@@ -360,7 +448,7 @@ jobs:
id: attest_base
if: ${{ !inputs.dry_run && steps.image-base-tag.outputs.tag != '' }}
continue-on-error: true
uses: actions/attest@59d89421af93a897026c735860bf21b6eb4f7b26 # v4.1.0
uses: actions/attest@e59cbc1ad1ac2d59339667419eb8cdde6eb61e3d # v3.2.0
with:
subject-name: ${{ steps.image-base-tag.outputs.tag }}
predicate-type: "https://slsa.dev/provenance/v1"
@@ -476,7 +564,7 @@ jobs:
id: attest_main
if: ${{ !inputs.dry_run }}
continue-on-error: true
uses: actions/attest@59d89421af93a897026c735860bf21b6eb4f7b26 # v4.1.0
uses: actions/attest@e59cbc1ad1ac2d59339667419eb8cdde6eb61e3d # v3.2.0
with:
subject-name: ${{ steps.build_docker.outputs.multiarch_image }}
predicate-type: "https://slsa.dev/provenance/v1"
@@ -520,7 +608,7 @@ jobs:
id: attest_latest
if: ${{ !inputs.dry_run && steps.build_docker.outputs.created_latest_tag == 'true' }}
continue-on-error: true
uses: actions/attest@59d89421af93a897026c735860bf21b6eb4f7b26 # v4.1.0
uses: actions/attest@e59cbc1ad1ac2d59339667419eb8cdde6eb61e3d # v3.2.0
with:
subject-name: ${{ steps.latest_tag.outputs.tag }}
predicate-type: "https://slsa.dev/provenance/v1"
@@ -667,7 +755,7 @@ jobs:
- name: Upload artifacts to actions (if dry-run)
if: ${{ inputs.dry_run }}
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
with:
name: release-artifacts
path: |
@@ -683,7 +771,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@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
with:
name: latest-sbom-artifact
path: ./coder_latest_sbom.spdx.json
@@ -702,11 +790,13 @@ jobs:
name: Publish to Homebrew tap
runs-on: ubuntu-latest
needs: release
if: ${{ !inputs.dry_run && inputs.release_channel == 'mainline' }}
if: ${{ !inputs.dry_run }}
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@fa2e9d605c4eeb9fcad4c99c224cee0c6c7f3594 # v2.16.0
uses: step-security/harden-runner@e3f713f2d8f53843e71c69a996d56f51aa9adfb9 # v2.14.1
with:
egress-policy: audit
@@ -782,7 +872,7 @@ jobs:
steps:
- name: Harden Runner
uses: step-security/harden-runner@fa2e9d605c4eeb9fcad4c99c224cee0c6c7f3594 # v2.16.0
uses: step-security/harden-runner@e3f713f2d8f53843e71c69a996d56f51aa9adfb9 # v2.14.1
with:
egress-policy: audit
@@ -865,3 +955,35 @@ jobs:
# different repo.
GH_TOKEN: ${{ secrets.CDRCI_GITHUB_TOKEN }}
VERSION: ${{ needs.release.outputs.version }}
# publish-sqlc pushes the latest schema to sqlc cloud.
# At present these pushes cannot be tagged, so the last push is always the latest.
publish-sqlc:
name: "Publish to schema sqlc cloud"
runs-on: "ubuntu-latest"
needs: release
if: ${{ !inputs.dry_run }}
steps:
- name: Harden Runner
uses: step-security/harden-runner@e3f713f2d8f53843e71c69a996d56f51aa9adfb9 # v2.14.1
with:
egress-policy: audit
- name: Checkout
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
fetch-depth: 1
persist-credentials: false
# We need golang to run the migration main.go
- name: Setup Go
uses: ./.github/actions/setup-go
- name: Setup sqlc
uses: ./.github/actions/setup-sqlc
- name: Push schema to sqlc cloud
# Don't block a release on this
continue-on-error: true
run: |
make sqlc-push
+2 -2
View File
@@ -20,7 +20,7 @@ jobs:
steps:
- name: Harden Runner
uses: step-security/harden-runner@fa2e9d605c4eeb9fcad4c99c224cee0c6c7f3594 # v2.16.0
uses: step-security/harden-runner@e3f713f2d8f53843e71c69a996d56f51aa9adfb9 # v2.14.1
with:
egress-policy: audit
@@ -39,7 +39,7 @@ jobs:
# Upload the results as artifacts.
- name: "Upload artifact"
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
with:
name: SARIF file
path: results.sarif
+114 -1
View File
@@ -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@fa2e9d605c4eeb9fcad4c99c224cee0c6c7f3594 # v2.16.0
uses: step-security/harden-runner@e3f713f2d8f53843e71c69a996d56f51aa9adfb9 # v2.14.1
with:
egress-policy: audit
@@ -63,3 +63,116 @@ jobs:
--data "{\"content\": \"$msg\"}" \
"${{ secrets.SLACK_SECURITY_FAILURE_WEBHOOK_URL }}"
trivy:
permissions:
security-events: write
runs-on: ${{ github.repository_owner == 'coder' && 'depot-ubuntu-22.04-8' || 'ubuntu-latest' }}
steps:
- name: Harden Runner
uses: step-security/harden-runner@e3f713f2d8f53843e71c69a996d56f51aa9adfb9 # v2.14.1
with:
egress-policy: audit
- name: Checkout
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
fetch-depth: 0
persist-credentials: false
- name: Setup Go
uses: ./.github/actions/setup-go
- name: Setup Node
uses: ./.github/actions/setup-node
- name: Setup sqlc
uses: ./.github/actions/setup-sqlc
- name: Install cosign
uses: ./.github/actions/install-cosign
- name: Install syft
uses: ./.github/actions/install-syft
- name: Install yq
run: go run github.com/mikefarah/yq/v4@v4.44.3
- name: Install mockgen
run: ./.github/scripts/retry.sh -- go install go.uber.org/mock/mockgen@v0.6.0
- name: Install protoc-gen-go
run: ./.github/scripts/retry.sh -- go install google.golang.org/protobuf/cmd/protoc-gen-go@v1.30
- name: Install protoc-gen-go-drpc
run: ./.github/scripts/retry.sh -- go install storj.io/drpc/cmd/protoc-gen-go-drpc@v0.0.34
- name: Install Protoc
run: |
# protoc must be in lockstep with our dogfood Dockerfile or the
# version in the comments will differ. This is also defined in
# ci.yaml.
set -euxo pipefail
cd dogfood/coder
mkdir -p /usr/local/bin
mkdir -p /usr/local/include
DOCKER_BUILDKIT=1 docker build . --target proto -t protoc
protoc_path=/usr/local/bin/protoc
docker run --rm --entrypoint cat protoc /tmp/bin/protoc > $protoc_path
chmod +x $protoc_path
protoc --version
# Copy the generated files to the include directory.
docker run --rm -v /usr/local/include:/target protoc cp -r /tmp/include/google /target/
ls -la /usr/local/include/google/protobuf/
stat /usr/local/include/google/protobuf/timestamp.proto
- name: Build Coder linux amd64 Docker image
id: build
run: |
set -euo pipefail
version="$(./scripts/version.sh)"
image_job="build/coder_${version}_linux_amd64.tag"
# This environment variable force make to not build packages and
# archives (which the Docker image depends on due to technical reasons
# related to concurrent FS writes).
export DOCKER_IMAGE_NO_PREREQUISITES=true
# This environment variables forces scripts/build_docker.sh to build
# the base image tag locally instead of using the cached version from
# the registry.
CODER_IMAGE_BUILD_BASE_TAG="$(CODER_IMAGE_BASE=coder-base ./scripts/image_tag.sh --version "$version")"
export CODER_IMAGE_BUILD_BASE_TAG
# We would like to use make -j here, but it doesn't work with the some recent additions
# to our code generation.
make "$image_job"
echo "image=$(cat "$image_job")" >> "$GITHUB_OUTPUT"
- name: Run Trivy vulnerability scanner
uses: aquasecurity/trivy-action@b6643a29fecd7f34b3597bc6acb0a98b03d33ff8
with:
image-ref: ${{ steps.build.outputs.image }}
format: sarif
output: trivy-results.sarif
severity: "CRITICAL,HIGH"
- name: Upload Trivy scan results to GitHub Security tab
uses: github/codeql-action/upload-sarif@5d4e8d1aca955e8d8589aabd499c5cae939e33c7 # v3.29.5
with:
sarif_file: trivy-results.sarif
category: "Trivy"
- name: Upload Trivy scan results as an artifact
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
with:
name: trivy
path: trivy-results.sarif
retention-days: 7
- name: Send Slack notification on failure
if: ${{ failure() }}
run: |
msg="❌ Trivy Failed\n\nhttps://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }}"
curl \
-qfsSL \
-X POST \
-H "Content-Type: application/json" \
--data "{\"content\": \"$msg\"}" \
"${{ secrets.SLACK_SECURITY_FAILURE_WEBHOOK_URL }}"
+4 -4
View File
@@ -18,12 +18,12 @@ jobs:
pull-requests: write
steps:
- name: Harden Runner
uses: step-security/harden-runner@fa2e9d605c4eeb9fcad4c99c224cee0c6c7f3594 # v2.16.0
uses: step-security/harden-runner@e3f713f2d8f53843e71c69a996d56f51aa9adfb9 # v2.14.1
with:
egress-policy: audit
- name: stale
uses: actions/stale@b5d41d4e1d5dceea10e7104786b73624c18a190f # v10.2.0
uses: actions/stale@997185467fa4f803885201cee163a9f38240193d # v10.1.1
with:
stale-issue-label: "stale"
stale-pr-label: "stale"
@@ -96,7 +96,7 @@ jobs:
contents: write
steps:
- name: Harden Runner
uses: step-security/harden-runner@fa2e9d605c4eeb9fcad4c99c224cee0c6c7f3594 # v2.16.0
uses: step-security/harden-runner@e3f713f2d8f53843e71c69a996d56f51aa9adfb9 # v2.14.1
with:
egress-policy: audit
@@ -120,7 +120,7 @@ jobs:
actions: write
steps:
- name: Harden Runner
uses: step-security/harden-runner@fa2e9d605c4eeb9fcad4c99c224cee0c6c7f3594 # v2.16.0
uses: step-security/harden-runner@e3f713f2d8f53843e71c69a996d56f51aa9adfb9 # v2.14.1
with:
egress-policy: audit
+1 -3
View File
@@ -26,9 +26,6 @@ on:
default: "traiage"
type: string
permissions:
contents: read
jobs:
traiage:
name: Triage GitHub Issue with Claude Code
@@ -41,6 +38,7 @@ jobs:
permissions:
contents: read
issues: write
actions: write
steps:
# This is only required for testing locally using nektos/act, so leaving commented out.
-295
View File
@@ -1,295 +0,0 @@
# 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}"
-5
View File
@@ -29,12 +29,7 @@ EDE = "EDE"
HELO = "HELO"
LKE = "LKE"
byt = "byt"
cpy = "cpy"
Cpy = "Cpy"
typ = "typ"
# file extensions used in seti icon theme
styl = "styl"
edn = "edn"
Inferrable = "Inferrable"
[files]
+1 -17
View File
@@ -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@fa2e9d605c4eeb9fcad4c99c224cee0c6c7f3594 # v2.16.0
uses: step-security/harden-runner@e3f713f2d8f53843e71c69a996d56f51aa9adfb9 # v2.14.1
with:
egress-policy: audit
@@ -30,22 +30,6 @@ jobs:
with:
persist-credentials: false
- name: Rewrite same-repo links for PR branch
if: github.event_name == 'pull_request'
env:
HEAD_SHA: ${{ github.event.pull_request.head.sha }}
run: |
# Rewrite same-repo blob/tree main links to the PR head SHA
# so that files or directories introduced in the PR are
# reachable during link checking.
{
echo 'replacementPatterns:'
echo " - pattern: \"https://github.com/coder/coder/blob/main/\""
echo " replacement: \"https://github.com/coder/coder/blob/${HEAD_SHA}/\""
echo " - pattern: \"https://github.com/coder/coder/tree/main/\""
echo " replacement: \"https://github.com/coder/coder/tree/${HEAD_SHA}/\""
} >> .github/.linkspector.yml
- name: Check Markdown links
uses: umbrelladocs/action-linkspector@652f85bc57bb1e7d4327260decc10aa68f7694c3 # v1.4.0
id: markdown-link-check
-4
View File
@@ -38,7 +38,6 @@ site/.swc
# Make target for updating generated/golden files (any dir).
.gen
/_gen/
.gen-golden
# Build
@@ -99,6 +98,3 @@ AGENTS.local.md
# Ignore plans written by AI agents.
PLAN.md
# Ignore any dev licenses
license.txt
+16 -122
View File
@@ -37,20 +37,19 @@ Only pause to ask for confirmation when:
## Essential Commands
| Task | Command | Notes |
|-----------------|--------------------------|-------------------------------------|
| **Development** | `./scripts/develop.sh` | ⚠️ Don't use manual build |
| **Build** | `make build` | Fat binaries (includes server) |
| **Build Slim** | `make build-slim` | Slim binaries |
| **Test** | `make test` | Full test suite |
| **Test Single** | `make test RUN=TestName` | Faster than full suite |
| **Test Race** | `make test-race` | Run tests with Go race detector |
| **Lint** | `make lint` | Always run after changes |
| **Generate** | `make gen` | After database changes |
| **Format** | `make fmt` | Auto-format code |
| **Clean** | `make clean` | Clean build artifacts |
| **Pre-commit** | `make pre-commit` | Fast CI checks (gen/fmt/lint/build) |
| **Pre-push** | `make pre-push` | Heavier CI checks (allowlisted) |
| Task | Command | Notes |
|-------------------|--------------------------|----------------------------------|
| **Development** | `./scripts/develop.sh` | ⚠️ Don't use manual build |
| **Build** | `make build` | Fat binaries (includes server) |
| **Build Slim** | `make build-slim` | Slim binaries |
| **Test** | `make test` | Full test suite |
| **Test Single** | `make test RUN=TestName` | Faster than full suite |
| **Test Postgres** | `make test-postgres` | Run tests with Postgres database |
| **Test Race** | `make test-race` | Run tests with Go race detector |
| **Lint** | `make lint` | Always run after changes |
| **Generate** | `make gen` | After database changes |
| **Format** | `make fmt` | Auto-format code |
| **Clean** | `make clean` | Clean build artifacts |
### Documentation Commands
@@ -100,75 +99,10 @@ app, err := api.Database.GetOAuth2ProviderAppByClientID(dbauthz.AsSystemRestrict
app, err := api.Database.GetOAuth2ProviderAppByClientID(ctx, clientID)
```
### API Design
- Add swagger annotations when introducing new HTTP endpoints. Do this in
the same change as the handler so the docs do not get missed before
release.
- For user-scoped or resource-scoped routes, prefer path parameters over
query parameters when that matches existing route patterns.
- For experimental or unstable API paths, skip public doc generation with
`// @x-apidocgen {"skip": true}` after the `@Router` annotation. This
keeps them out of the published API reference until they stabilize.
### Database Query Naming
- Use `ByX` when `X` is the lookup or filter column.
- Use `PerX` or `GroupedByX` when `X` is the aggregation or grouping
dimension.
- Avoid `ByX` names for grouped queries.
### Database-to-SDK Conversions
- Extract explicit db-to-SDK conversion helpers instead of inlining large
conversion blocks inside handlers.
- Keep nullable-field handling, type coercion, and response shaping in the
converter so handlers stay focused on request flow and authorization.
## Quick Reference
### Full workflows available in imported WORKFLOWS.md
### Git Hooks (MANDATORY - DO NOT SKIP)
**You MUST install and use the git hooks. NEVER bypass them with
`--no-verify`. Skipping hooks wastes CI cycles and is unacceptable.**
The first run will be slow as caches warm up. Consecutive runs are
**significantly faster** (often 10x) thanks to Go build cache,
generated file timestamps, and warm node_modules. This is NOT a
reason to skip them. Wait for hooks to complete before proceeding,
no matter how long they take.
```sh
git config core.hooksPath scripts/githooks
```
Two hooks run automatically:
- **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.
NEVER run `git config core.hooksPath` to change or disable hooks.
If a hook fails, fix the issue and retry. Do not work around the
failure by skipping the hook.
### Git Workflow
When working on existing PRs, check out the branch first:
@@ -217,26 +151,6 @@ 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
@@ -284,12 +198,13 @@ reviewer time and clutters the diff.
**Don't delete existing comments** that explain non-obvious behavior. These
comments preserve important context about why code works a certain way.
**When adding tests for new behavior**, read existing tests first to understand what's covered. Add new cases for uncovered behavior. Edit existing tests as needed, but don't change what they verify.
**When adding tests for new behavior**, add new test cases instead of modifying
existing ones. This preserves coverage for the original behavior and makes it
clear what the new test covers.
## Detailed Development Guides
@.claude/docs/ARCHITECTURE.md
@.claude/docs/GO.md
@.claude/docs/OAUTH2.md
@.claude/docs/TESTING.md
@.claude/docs/TROUBLESHOOTING.md
@@ -297,27 +212,6 @@ comments preserve important context about why code works a certain way.
@.claude/docs/PR_STYLE_GUIDE.md
@.claude/docs/DOCS_STYLE_GUIDE.md
If your agent tool does not auto-load `@`-referenced files, read these
manually before starting work:
**Always read:**
- `.claude/docs/WORKFLOWS.md` — dev server, git workflow, hooks
**Read when relevant to your task:**
- `.claude/docs/GO.md` — Go patterns and modern Go usage (any Go changes)
- `.claude/docs/TESTING.md` — testing patterns, race conditions (any test changes)
- `.claude/docs/DATABASE.md` — migrations, SQLC, audit table (any DB changes)
- `.claude/docs/ARCHITECTURE.md` — system overview (orientation or architecture work)
- `.claude/docs/PR_STYLE_GUIDE.md` — PR description format (when writing PRs)
- `.claude/docs/OAUTH2.md` — OAuth2 and RFC compliance (when touching auth)
- `.claude/docs/TROUBLESHOOTING.md` — common failures and fixes (when stuck)
- `.claude/docs/DOCS_STYLE_GUIDE.md` — docs conventions (when writing `docs/`)
**For frontend work**, also read `site/AGENTS.md` before making any changes
in `site/`.
## Local Configuration
These files may be gitignored, read manually if not auto-loaded.
+153 -430
View File
@@ -19,84 +19,10 @@ SHELL := bash
.SHELLFLAGS := -ceu
.ONESHELL:
# When MAKE_TIMED=1, replace SHELL with a wrapper that prints
# elapsed wall-clock time for each recipe. pre-commit and pre-push
# set this on their sub-makes so every parallel job reports its
# duration. Ad-hoc usage: make MAKE_TIMED=1 test
ifdef MAKE_TIMED
SHELL := $(CURDIR)/scripts/lib/timed-shell.sh
.SHELLFLAGS = $@ -ceu
export MAKE_TIMED
export MAKE_LOGDIR
endif
# This doesn't work on directories.
# See https://stackoverflow.com/questions/25752543/make-delete-on-error-for-directory-targets
.DELETE_ON_ERROR:
# Protect git-tracked generated files from deletion on interrupt.
# .DELETE_ON_ERROR is desirable for most targets but for files that
# are committed to git and serve as inputs to other rules, deletion
# is worse than a stale file — `git restore` is the recovery path.
.PRECIOUS: \
coderd/database/dump.sql \
coderd/database/querier.go \
coderd/database/unique_constraint.go \
coderd/database/dbmetrics/querymetrics.go \
coderd/database/dbauthz/dbauthz.go \
coderd/database/dbmock/dbmock.go \
coderd/database/pubsub/psmock/psmock.go \
agent/agentcontainers/acmock/acmock.go \
coderd/httpmw/loggermw/loggermock/loggermock.go \
codersdk/workspacesdk/agentconnmock/agentconnmock.go \
tailnet/tailnettest/coordinatormock.go \
tailnet/tailnettest/coordinateemock.go \
tailnet/tailnettest/workspaceupdatesprovidermock.go \
tailnet/tailnettest/subscriptionmock.go \
enterprise/aibridged/aibridgedmock/clientmock.go \
enterprise/aibridged/aibridgedmock/poolmock.go \
tailnet/proto/tailnet.pb.go \
agent/proto/agent.pb.go \
agent/agentsocket/proto/agentsocket.pb.go \
agent/boundarylogproxy/codec/boundary.pb.go \
provisionersdk/proto/provisioner.pb.go \
provisionerd/proto/provisionerd.pb.go \
vpn/vpn.pb.go \
enterprise/aibridged/proto/aibridged.pb.go \
site/src/api/typesGenerated.ts \
site/e2e/provisionerGenerated.ts \
site/src/api/chatModelOptionsGenerated.json \
site/src/api/rbacresourcesGenerated.ts \
site/src/api/countriesGenerated.ts \
site/src/theme/icons.json \
examples/examples.gen.json \
docs/manifest.json \
docs/admin/integrations/prometheus.md \
docs/admin/security/audit-logs.md \
docs/reference/cli/index.md \
coderd/apidoc/swagger.json \
coderd/rbac/object_gen.go \
coderd/rbac/scopes_constants_gen.go \
codersdk/rbacresources_gen.go \
codersdk/apikey_scopes_gen.go
# atomic_write runs a command, captures stdout into a temp file, and
# atomically replaces $@. An optional second argument is a formatting
# command that receives the temp file path as its argument.
# Usage: $(call atomic_write,GENERATE_CMD[,FORMAT_CMD])
define atomic_write
tmpdir=$$(mktemp -d -p _gen) && tmpfile=$$(realpath "$$tmpdir")/$(notdir $@) && \
$(1) > "$$tmpfile" && \
$(if $(2),$(2) "$$tmpfile" &&) \
mv "$$tmpfile" "$@" && rm -rf "$$tmpdir"
endef
# Shared temp directory for atomic writes. Lives at the project root
# so all targets share the same filesystem, and is gitignored.
# Order-only prerequisite: recipes that need it depend on | _gen
_gen:
mkdir -p _gen
# Don't print the commands in the file unless you specify VERBOSE. This is
# essentially the same as putting "@" at the start of each line.
ifndef VERBOSE
@@ -114,19 +40,11 @@ VERSION := $(shell ./scripts/version.sh)
POSTGRES_VERSION ?= 17
POSTGRES_IMAGE ?= us-docker.pkg.dev/coder-v2-images-public/public/postgres:$(POSTGRES_VERSION)
# Limit parallel Make jobs in pre-commit/pre-push. Defaults to
# nproc/4 (min 2) since test, lint, and build targets have internal
# parallelism. Override: make pre-push PARALLEL_JOBS=8
PARALLEL_JOBS ?= $(shell n=$$(nproc 2>/dev/null || sysctl -n hw.ncpu 2>/dev/null || echo 8); echo $$(( n / 4 > 2 ? n / 4 : 2 )))
# Use the highest ZSTD compression level in release builds to
# minimize artifact size. For non-release CI builds (e.g. main
# branch preview), use multithreaded level 6 which is ~99% faster
# at the cost of ~30% larger archives.
ifeq ($(CODER_RELEASE),true)
# Use the highest ZSTD compression level in CI.
ifdef CI
ZSTDFLAGS := -22 --ultra
else
ZSTDFLAGS := -6 -T0
ZSTDFLAGS := -6
endif
# Common paths to exclude from find commands, this rule is written so
@@ -135,11 +53,19 @@ endif
# Note, all find statements should be written with `.` or `./path` as
# 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 \)
-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/*' \) -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')
@@ -168,8 +94,12 @@ PACKAGE_OS_ARCHES := linux_amd64 linux_armv7 linux_arm64
# All architectures we build Docker images for (Linux only).
DOCKER_ARCHES := amd64 arm64 armv7
# All ${OS}_${ARCH} combos we build the desktop dylib for.
DYLIB_ARCHES := darwin_amd64 darwin_arm64
# Computed variables based on the above.
CODER_SLIM_BINARIES := $(addprefix build/coder-slim_$(VERSION)_,$(OS_ARCHES))
CODER_DYLIBS := $(foreach os_arch, $(DYLIB_ARCHES), build/coder-vpn_$(VERSION)_$(os_arch).dylib)
CODER_FAT_BINARIES := $(addprefix build/coder_$(VERSION)_,$(OS_ARCHES))
CODER_ALL_BINARIES := $(CODER_SLIM_BINARIES) $(CODER_FAT_BINARIES)
CODER_TAR_GZ_ARCHIVES := $(foreach os_arch, $(ARCHIVE_TAR_GZ), build/coder_$(VERSION)_$(os_arch).tar.gz)
@@ -331,6 +261,26 @@ $(CODER_ALL_BINARIES): go.mod go.sum \
fi
fi
# This task builds Coder Desktop dylibs
$(CODER_DYLIBS): go.mod go.sum $(MOST_GO_SRC_FILES)
@if [ "$(shell uname)" = "Darwin" ]; then
$(get-mode-os-arch-ext)
./scripts/build_go.sh \
--os "$$os" \
--arch "$$arch" \
--version "$(VERSION)" \
--output "$@" \
--dylib
else
echo "ERROR: Can't build dylib on non-Darwin OS" 1>&2
exit 1
fi
# This task builds both dylibs
build/coder-dylib: $(CODER_DYLIBS)
.PHONY: build/coder-dylib
# This task builds all archives. It parses the target name to get the metadata
# for the build, so it must be specified in this format:
# build/coder_${version}_${os}_${arch}.${format}
@@ -477,7 +427,6 @@ SITE_GEN_FILES := \
site/src/api/typesGenerated.ts \
site/src/api/rbacresourcesGenerated.ts \
site/src/api/countriesGenerated.ts \
site/src/api/chatModelOptionsGenerated.json \
site/src/theme/icons.json
site/out/index.html: \
@@ -506,26 +455,13 @@ 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)
YELLOW := $(shell tput setaf 3 2>/dev/null)
DIM := $(shell tput dim 2>/dev/null || tput setaf 8 2>/dev/null)
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
@@ -630,13 +566,9 @@ endif
# GitHub Actions linters are run in a separate CI job (lint-actions) that only
# triggers when workflow files change, so we skip them here when CI=true.
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)
lint: lint/shellcheck lint/go lint/ts lint/examples lint/helm lint/site-icons lint/markdown lint/check-scopes lint/migrations $(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
@@ -649,7 +581,7 @@ lint/ts: site/node_modules/.installed
lint/go:
./scripts/check_enterprise_imports.sh
./scripts/check_codersdk_imports.sh
linter_ver=$$(grep -oE 'GOLANGCI_LINT_VERSION=\S+' dogfood/coder/Dockerfile | cut -d '=' -f 2)
linter_ver=$(shell egrep -o 'GOLANGCI_LINT_VERSION=\S+' dogfood/coder/Dockerfile | cut -d '=' -f 2)
go run github.com/golangci/golangci-lint/cmd/golangci-lint@v$$linter_ver run
go tool github.com/coder/paralleltestctx/cmd/paralleltestctx -custom-funcs="testutil.Context" ./...
.PHONY: lint/go
@@ -664,11 +596,6 @@ lint/shellcheck: $(SHELL_SRC_FILES)
shellcheck --external-sources $(SHELL_SRC_FILES)
.PHONY: lint/shellcheck
lint/bootstrap:
bash scripts/check_bootstrap_quotes.sh
.PHONY: lint/bootstrap
lint/helm:
cd helm/
make lint
@@ -703,129 +630,13 @@ lint/migrations:
./scripts/check_pg_schema.sh "Fixtures" $(FIXTURE_FILES)
.PHONY: lint/migrations
TYPOS_VERSION := $(shell grep -oP 'crate-ci/typos@\S+\s+\#\s+v\K[0-9.]+' .github/workflows/ci.yaml)
# Map uname values to typos release asset names.
TYPOS_ARCH := $(shell uname -m)
ifeq ($(shell uname -s),Darwin)
TYPOS_OS := apple-darwin
else
TYPOS_OS := unknown-linux-musl
endif
build/typos-$(TYPOS_VERSION):
mkdir -p build/
curl -sSfL "https://github.com/crate-ci/typos/releases/download/v$(TYPOS_VERSION)/typos-v$(TYPOS_VERSION)-$(TYPOS_ARCH)-$(TYPOS_OS).tar.gz" \
| tar -xzf - -C build/ ./typos
mv build/typos "$@"
lint/typos: build/typos-$(TYPOS_VERSION)
build/typos-$(TYPOS_VERSION) --config .github/workflows/typos.toml
.PHONY: lint/typos
# pre-commit and pre-push mirror CI checks locally.
#
# pre-commit runs checks that don't need external services (Docker,
# Playwright). This is the git pre-commit hook default since Docker
# and browser issues in the local environment would otherwise block
# all commits.
#
# pre-push adds heavier checks: Go tests, JS tests, and site build.
# The pre-push hook is allowlisted, see scripts/githooks/pre-push.
#
# pre-commit uses two phases: gen+fmt first, then lint+build. This
# avoids races where gen's `go run` creates temporary .go files that
# lint's find-based checks pick up. Within each phase, targets run in
# parallel via -j. It fails if any tracked files have unstaged
# changes afterward.
define check-unstaged
unstaged="$$(git diff --name-only)"
if [[ -n $$unstaged ]]; then
echo "$(RED)✗ check unstaged changes$(RESET)"
echo "$$unstaged" | sed 's/^/ - /'
echo ""
echo "$(DIM) Verify generated changes are correct before staging:$(RESET)"
echo "$(DIM) git diff$(RESET)"
echo "$(DIM) git add -u && git commit$(RESET)"
exit 1
fi
endef
define check-untracked
untracked=$$(git ls-files --other --exclude-standard)
if [[ -n $$untracked ]]; then
echo "$(YELLOW)? check untracked files$(RESET)"
echo "$$untracked" | sed 's/^/ - /'
echo ""
echo "$(DIM) Review if these should be committed or added to .gitignore.$(RESET)"
fi
endef
pre-commit:
start=$$(date +%s)
logdir=$$(mktemp -d "$${TMPDIR:-/tmp}/coder-pre-commit.XXXXXX")
echo "$(BOLD)pre-commit$(RESET) ($$logdir)"
echo "gen + fmt:"
$(MAKE) --no-print-directory -j$(PARALLEL_JOBS) MAKE_TIMED=1 MAKE_LOGDIR=$$logdir gen fmt
$(check-unstaged)
echo "lint + build:"
$(MAKE) --no-print-directory -j$(PARALLEL_JOBS) MAKE_TIMED=1 MAKE_LOGDIR=$$logdir \
lint \
lint/typos \
build/coder-slim_$(GOOS)_$(GOARCH)$(GOOS_BIN_EXT)
$(check-unstaged)
$(check-untracked)
rm -rf $$logdir
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")
echo "$(BOLD)pre-push$(RESET) ($$logdir)"
echo "test + build site:"
$(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)"
.PHONY: pre-push
offlinedocs/check: offlinedocs/node_modules/.installed
cd offlinedocs/
pnpm format:check
pnpm lint
pnpm export
.PHONY: offlinedocs/check
# All files generated by the database should be added here, and this can be used
# as a target for jobs that need to run after the database is generated.
DB_GEN_FILES := \
coderd/database/dump.sql \
coderd/database/querier.go \
coderd/database/unique_constraint.go \
coderd/database/dbmetrics/querymetrics.go \
coderd/database/dbmetrics/dbmetrics.go \
coderd/database/dbauthz/dbauthz.go \
coderd/database/dbmock/dbmock.go
@@ -843,7 +654,6 @@ GEN_FILES := \
tailnet/proto/tailnet.pb.go \
agent/proto/agent.pb.go \
agent/agentsocket/proto/agentsocket.pb.go \
agent/boundarylogproxy/codec/boundary.pb.go \
provisionersdk/proto/provisioner.pb.go \
provisionerd/proto/provisionerd.pb.go \
vpn/vpn.pb.go \
@@ -860,7 +670,6 @@ GEN_FILES := \
coderd/apidoc/swagger.json \
docs/manifest.json \
provisioner/terraform/testdata/version \
scripts/metricsdocgen/generated_metrics \
site/e2e/provisionerGenerated.ts \
examples/examples.gen.json \
$(TAILNETTEST_MOCKS) \
@@ -900,24 +709,16 @@ gen/mark-fresh:
provisionersdk/proto/provisioner.pb.go \
provisionerd/proto/provisionerd.pb.go \
agent/agentsocket/proto/agentsocket.pb.go \
agent/boundarylogproxy/codec/boundary.pb.go \
vpn/vpn.pb.go \
enterprise/aibridged/proto/aibridged.pb.go \
coderd/database/dump.sql \
coderd/database/querier.go \
coderd/database/unique_constraint.go \
coderd/database/dbmetrics/querymetrics.go \
coderd/database/dbauthz/dbauthz.go \
coderd/database/dbmock/dbmock.go \
coderd/database/pubsub/psmock/psmock.go \
$(DB_GEN_FILES) \
site/src/api/typesGenerated.ts \
coderd/rbac/object_gen.go \
codersdk/rbacresources_gen.go \
coderd/rbac/scopes_constants_gen.go \
codersdk/apikey_scopes_gen.go \
site/src/api/rbacresourcesGenerated.ts \
site/src/api/countriesGenerated.ts \
site/src/api/chatModelOptionsGenerated.json \
docs/admin/integrations/prometheus.md \
docs/reference/cli/index.md \
docs/admin/security/audit-logs.md \
@@ -926,8 +727,8 @@ gen/mark-fresh:
site/e2e/provisionerGenerated.ts \
site/src/theme/icons.json \
examples/examples.gen.json \
scripts/metricsdocgen/generated_metrics \
$(TAILNETTEST_MOCKS) \
coderd/database/pubsub/psmock/psmock.go \
agent/agentcontainers/acmock/acmock.go \
agent/agentcontainers/dcspec/dcspec_gen.go \
coderd/httpmw/loggermw/loggermock/loggermock.go \
@@ -956,19 +757,9 @@ coderd/database/dump.sql: coderd/database/gen/dump/main.go $(wildcard coderd/dat
# Generates Go code for querying the database.
# coderd/database/queries.sql.go
# coderd/database/models.go
#
# NOTE: grouped target (&:) ensures generate.sh runs only once even
# with -j and all outputs are considered produced together. These
# files are all written by generate.sh (via sqlc and scripts/dbgen).
coderd/database/querier.go \
coderd/database/unique_constraint.go \
coderd/database/dbmetrics/querymetrics.go \
coderd/database/dbauthz/dbauthz.go &: \
coderd/database/sqlc.yaml \
coderd/database/dump.sql \
$(wildcard coderd/database/queries/*.sql)
SKIP_DUMP_SQL=1 ./coderd/database/generate.sh
touch coderd/database/querier.go coderd/database/unique_constraint.go coderd/database/dbmetrics/querymetrics.go coderd/database/dbauthz/dbauthz.go
coderd/database/querier.go: coderd/database/sqlc.yaml coderd/database/dump.sql $(wildcard coderd/database/queries/*.sql)
./coderd/database/generate.sh
touch "$@"
coderd/database/dbmock/dbmock.go: coderd/database/db.go coderd/database/querier.go
go generate ./coderd/database/dbmock/
@@ -1007,7 +798,7 @@ $(TAILNETTEST_MOCKS): tailnet/coordinator.go tailnet/service.go
touch "$@"
tailnet/proto/tailnet.pb.go: tailnet/proto/tailnet.proto
./scripts/atomic_protoc.sh \
protoc \
--go_out=. \
--go_opt=paths=source_relative \
--go-drpc_out=. \
@@ -1015,15 +806,15 @@ tailnet/proto/tailnet.pb.go: tailnet/proto/tailnet.proto
./tailnet/proto/tailnet.proto
agent/proto/agent.pb.go: agent/proto/agent.proto
./scripts/atomic_protoc.sh \
protoc \
--go_out=. \
--go_opt=paths=source_relative \
--go-drpc_out=. \
--go-drpc_opt=paths=source_relative \
./agent/proto/agent.proto
agent/agentsocket/proto/agentsocket.pb.go: agent/agentsocket/proto/agentsocket.proto agent/proto/agent.proto
./scripts/atomic_protoc.sh \
agent/agentsocket/proto/agentsocket.pb.go: agent/agentsocket/proto/agentsocket.proto
protoc \
--go_out=. \
--go_opt=paths=source_relative \
--go-drpc_out=. \
@@ -1031,7 +822,7 @@ agent/agentsocket/proto/agentsocket.pb.go: agent/agentsocket/proto/agentsocket.p
./agent/agentsocket/proto/agentsocket.proto
provisionersdk/proto/provisioner.pb.go: provisionersdk/proto/provisioner.proto
./scripts/atomic_protoc.sh \
protoc \
--go_out=. \
--go_opt=paths=source_relative \
--go-drpc_out=. \
@@ -1039,7 +830,7 @@ provisionersdk/proto/provisioner.pb.go: provisionersdk/proto/provisioner.proto
./provisionersdk/proto/provisioner.proto
provisionerd/proto/provisionerd.pb.go: provisionerd/proto/provisionerd.proto
./scripts/atomic_protoc.sh \
protoc \
--go_out=. \
--go_opt=paths=source_relative \
--go-drpc_out=. \
@@ -1047,110 +838,94 @@ provisionerd/proto/provisionerd.pb.go: provisionerd/proto/provisionerd.proto
./provisionerd/proto/provisionerd.proto
vpn/vpn.pb.go: vpn/vpn.proto
./scripts/atomic_protoc.sh \
protoc \
--go_out=. \
--go_opt=paths=source_relative \
./vpn/vpn.proto
agent/boundarylogproxy/codec/boundary.pb.go: agent/boundarylogproxy/codec/boundary.proto agent/proto/agent.proto
./scripts/atomic_protoc.sh \
--go_out=. \
--go_opt=paths=source_relative \
./agent/boundarylogproxy/codec/boundary.proto
enterprise/aibridged/proto/aibridged.pb.go: enterprise/aibridged/proto/aibridged.proto
./scripts/atomic_protoc.sh \
protoc \
--go_out=. \
--go_opt=paths=source_relative \
--go-drpc_out=. \
--go-drpc_opt=paths=source_relative \
./enterprise/aibridged/proto/aibridged.proto
site/src/api/typesGenerated.ts: site/node_modules/.installed $(wildcard scripts/apitypings/*) $(shell find ./codersdk $(FIND_EXCLUSIONS) -type f -name '*.go') | _gen
$(call atomic_write,go run -C ./scripts/apitypings main.go,./scripts/biome_format.sh)
site/src/api/typesGenerated.ts: site/node_modules/.installed $(wildcard scripts/apitypings/*) $(shell find ./codersdk $(FIND_EXCLUSIONS) -type f -name '*.go')
# -C sets the directory for the go run command
go run -C ./scripts/apitypings main.go > $@
(cd site/ && pnpm exec biome format --write src/api/typesGenerated.ts)
touch "$@"
site/e2e/provisionerGenerated.ts: site/node_modules/.installed provisionerd/proto/provisionerd.pb.go provisionersdk/proto/provisioner.pb.go
(cd site/ && pnpm run gen:provisioner)
touch "$@"
site/src/theme/icons.json: site/node_modules/.installed $(wildcard scripts/gensite/*) $(wildcard site/static/icon/*) | _gen
tmpdir=$$(mktemp -d -p _gen) && tmpfile=$$(realpath "$$tmpdir")/$(notdir $@) && \
go run ./scripts/gensite/ -icons "$$tmpfile" && \
./scripts/biome_format.sh "$$tmpfile" && \
mv "$$tmpfile" "$@" && rm -rf "$$tmpdir"
examples/examples.gen.json: scripts/examplegen/main.go examples/examples.go $(shell find ./examples/templates) | _gen
$(call atomic_write,go run ./scripts/examplegen/main.go)
coderd/rbac/object_gen.go: scripts/typegen/rbacobject.gotmpl scripts/typegen/main.go coderd/rbac/object.go coderd/rbac/policy/policy.go | _gen
$(call atomic_write,go run ./scripts/typegen/main.go rbac object)
site/src/theme/icons.json: site/node_modules/.installed $(wildcard scripts/gensite/*) $(wildcard site/static/icon/*)
go run ./scripts/gensite/ -icons "$@"
(cd site/ && pnpm exec biome format --write src/theme/icons.json)
touch "$@"
# NOTE: depends on object_gen.go because `go run` compiles
# coderd/rbac which includes it.
coderd/rbac/scopes_constants_gen.go: scripts/typegen/scopenames.gotmpl scripts/typegen/main.go coderd/rbac/policy/policy.go \
coderd/rbac/object_gen.go | _gen
# Write to a temp file first to avoid truncating the package
# during build since the generator imports the rbac package.
$(call atomic_write,go run ./scripts/typegen/main.go rbac scopenames)
examples/examples.gen.json: scripts/examplegen/main.go examples/examples.go $(shell find ./examples/templates)
go run ./scripts/examplegen/main.go > examples/examples.gen.json
touch "$@"
# NOTE: depends on object_gen.go and scopes_constants_gen.go because
# `go run` compiles coderd/rbac which includes both.
codersdk/rbacresources_gen.go: scripts/typegen/codersdk.gotmpl scripts/typegen/main.go coderd/rbac/object.go coderd/rbac/policy/policy.go \
coderd/rbac/object_gen.go coderd/rbac/scopes_constants_gen.go | _gen
# Write to a temp file to avoid truncating the target, which
# would break the codersdk package and any parallel build targets.
$(call atomic_write,go run scripts/typegen/main.go rbac codersdk)
coderd/rbac/object_gen.go: scripts/typegen/rbacobject.gotmpl scripts/typegen/main.go coderd/rbac/object.go coderd/rbac/policy/policy.go
tempdir=$(shell mktemp -d /tmp/typegen_rbac_object.XXXXXX)
go run ./scripts/typegen/main.go rbac object > "$$tempdir/object_gen.go"
mv -v "$$tempdir/object_gen.go" coderd/rbac/object_gen.go
rmdir -v "$$tempdir"
touch "$@"
# NOTE: depends on object_gen.go and scopes_constants_gen.go because
# `go run` compiles coderd/rbac which includes both.
codersdk/apikey_scopes_gen.go: scripts/apikeyscopesgen/main.go coderd/rbac/scopes_catalog.go coderd/rbac/scopes.go \
coderd/rbac/object_gen.go coderd/rbac/scopes_constants_gen.go | _gen
coderd/rbac/scopes_constants_gen.go: scripts/typegen/scopenames.gotmpl scripts/typegen/main.go coderd/rbac/policy/policy.go
# Generate typed low-level ScopeName constants from RBACPermissions
# Write to a temp file first to avoid truncating the package during build
# since the generator imports the rbac package.
tempfile=$(shell mktemp /tmp/scopes_constants_gen.XXXXXX)
go run ./scripts/typegen/main.go rbac scopenames > "$$tempfile"
mv -v "$$tempfile" coderd/rbac/scopes_constants_gen.go
touch "$@"
codersdk/rbacresources_gen.go: scripts/typegen/codersdk.gotmpl scripts/typegen/main.go coderd/rbac/object.go coderd/rbac/policy/policy.go
# Do no overwrite codersdk/rbacresources_gen.go directly, as it would make the file empty, breaking
# the `codersdk` package and any parallel build targets.
go run scripts/typegen/main.go rbac codersdk > /tmp/rbacresources_gen.go
mv /tmp/rbacresources_gen.go codersdk/rbacresources_gen.go
touch "$@"
codersdk/apikey_scopes_gen.go: scripts/apikeyscopesgen/main.go coderd/rbac/scopes_catalog.go coderd/rbac/scopes.go
# Generate SDK constants for external API key scopes.
$(call atomic_write,go run ./scripts/apikeyscopesgen)
go run ./scripts/apikeyscopesgen > /tmp/apikey_scopes_gen.go
mv /tmp/apikey_scopes_gen.go codersdk/apikey_scopes_gen.go
touch "$@"
# NOTE: depends on object_gen.go and scopes_constants_gen.go because
# `go run` compiles coderd/rbac which includes both.
site/src/api/rbacresourcesGenerated.ts: site/node_modules/.installed scripts/typegen/codersdk.gotmpl scripts/typegen/main.go coderd/rbac/object.go coderd/rbac/policy/policy.go \
coderd/rbac/object_gen.go coderd/rbac/scopes_constants_gen.go | _gen
$(call atomic_write,go run scripts/typegen/main.go rbac typescript,./scripts/biome_format.sh)
site/src/api/rbacresourcesGenerated.ts: site/node_modules/.installed scripts/typegen/codersdk.gotmpl scripts/typegen/main.go coderd/rbac/object.go coderd/rbac/policy/policy.go
go run scripts/typegen/main.go rbac typescript > "$@"
(cd site/ && pnpm exec biome format --write src/api/rbacresourcesGenerated.ts)
touch "$@"
site/src/api/countriesGenerated.ts: site/node_modules/.installed scripts/typegen/countries.tstmpl scripts/typegen/main.go codersdk/countries.go | _gen
$(call atomic_write,go run scripts/typegen/main.go countries,./scripts/biome_format.sh)
site/src/api/countriesGenerated.ts: site/node_modules/.installed scripts/typegen/countries.tstmpl scripts/typegen/main.go codersdk/countries.go
go run scripts/typegen/main.go countries > "$@"
(cd site/ && pnpm exec biome format --write src/api/countriesGenerated.ts)
touch "$@"
site/src/api/chatModelOptionsGenerated.json: scripts/modeloptionsgen/main.go codersdk/chats.go | _gen
$(call atomic_write,go run ./scripts/modeloptionsgen/main.go | tail -n +2,./scripts/biome_format.sh)
docs/admin/integrations/prometheus.md: node_modules/.installed scripts/metricsdocgen/main.go scripts/metricsdocgen/metrics
go run scripts/metricsdocgen/main.go
pnpm exec markdownlint-cli2 --fix ./docs/admin/integrations/prometheus.md
pnpm exec markdown-table-formatter ./docs/admin/integrations/prometheus.md
touch "$@"
scripts/metricsdocgen/generated_metrics: $(GO_SRC_FILES) | _gen
$(call atomic_write,go run ./scripts/metricsdocgen/scanner)
docs/reference/cli/index.md: node_modules/.installed scripts/clidocgen/main.go examples/examples.gen.json $(GO_SRC_FILES)
CI=true BASE_PATH="." go run ./scripts/clidocgen
pnpm exec markdownlint-cli2 --fix ./docs/reference/cli/*.md
pnpm exec markdown-table-formatter ./docs/reference/cli/*.md
touch "$@"
docs/admin/integrations/prometheus.md: node_modules/.installed scripts/metricsdocgen/main.go scripts/metricsdocgen/metrics scripts/metricsdocgen/generated_metrics | _gen
tmpdir=$$(mktemp -d -p _gen) && tmpfile=$$(realpath "$$tmpdir")/$(notdir $@) && cp "$@" "$$tmpfile" && \
go run scripts/metricsdocgen/main.go --prometheus-doc-file="$$tmpfile" && \
pnpm exec markdownlint-cli2 --fix "$$tmpfile" && \
pnpm exec markdown-table-formatter "$$tmpfile" && \
mv "$$tmpfile" "$@" && rm -rf "$$tmpdir"
docs/reference/cli/index.md: node_modules/.installed scripts/clidocgen/main.go examples/examples.gen.json $(GO_SRC_FILES) | _gen
tmpdir=$$(mktemp -d -p _gen) && \
tmpdir=$$(realpath "$$tmpdir") && \
mkdir -p "$$tmpdir/docs/reference/cli" && \
cp docs/manifest.json "$$tmpdir/docs/manifest.json" && \
CI=true DOCS_DIR="$$tmpdir/docs" go run ./scripts/clidocgen && \
pnpm exec markdownlint-cli2 --fix "$$tmpdir/docs/reference/cli/*.md" && \
pnpm exec markdown-table-formatter "$$tmpdir/docs/reference/cli/*.md" && \
for f in "$$tmpdir/docs/reference/cli/"*.md; do mv "$$f" "docs/reference/cli/$$(basename "$$f")"; done && \
rm -rf "$$tmpdir"
docs/admin/security/audit-logs.md: node_modules/.installed coderd/database/querier.go scripts/auditdocgen/main.go enterprise/audit/table.go coderd/rbac/object_gen.go | _gen
tmpdir=$$(mktemp -d -p _gen) && tmpfile=$$(realpath "$$tmpdir")/$(notdir $@) && cp "$@" "$$tmpfile" && \
go run scripts/auditdocgen/main.go --audit-doc-file="$$tmpfile" && \
pnpm exec markdownlint-cli2 --fix "$$tmpfile" && \
pnpm exec markdown-table-formatter "$$tmpfile" && \
mv "$$tmpfile" "$@" && rm -rf "$$tmpdir"
docs/admin/security/audit-logs.md: node_modules/.installed coderd/database/querier.go scripts/auditdocgen/main.go enterprise/audit/table.go coderd/rbac/object_gen.go
go run scripts/auditdocgen/main.go
pnpm exec markdownlint-cli2 --fix ./docs/admin/security/audit-logs.md
pnpm exec markdown-table-formatter ./docs/admin/security/audit-logs.md
touch "$@"
coderd/apidoc/.gen: \
node_modules/.installed \
@@ -1165,29 +940,18 @@ coderd/apidoc/.gen: \
scripts/apidocgen/generate.sh \
scripts/apidocgen/swaginit/main.go \
$(wildcard scripts/apidocgen/postprocess/*) \
$(wildcard scripts/apidocgen/markdown-template/*) | _gen
tmpdir=$$(mktemp -d -p _gen) && swagtmp=$$(mktemp -d -p _gen) && \
tmpdir=$$(realpath "$$tmpdir") && swagtmp=$$(realpath "$$swagtmp") && \
mkdir -p "$$tmpdir/reference/api" && \
cp docs/manifest.json "$$tmpdir/manifest.json" && \
SWAG_OUTPUT_DIR="$$swagtmp" APIDOCGEN_DOCS_DIR="$$tmpdir" ./scripts/apidocgen/generate.sh && \
pnpm exec markdownlint-cli2 --fix "$$tmpdir/reference/api/*.md" && \
pnpm exec markdown-table-formatter "$$tmpdir/reference/api/*.md" && \
./scripts/biome_format.sh "$$swagtmp/swagger.json" && \
for f in "$$tmpdir/reference/api/"*.md; do mv "$$f" "docs/reference/api/$$(basename "$$f")"; done && \
mv "$$tmpdir/manifest.json" _gen/manifest-staging.json && \
mv "$$swagtmp/docs.go" coderd/apidoc/docs.go && \
mv "$$swagtmp/swagger.json" coderd/apidoc/swagger.json && \
rm -rf "$$tmpdir" "$$swagtmp"
$(wildcard scripts/apidocgen/markdown-template/*)
./scripts/apidocgen/generate.sh
pnpm exec markdownlint-cli2 --fix ./docs/reference/api/*.md
pnpm exec markdown-table-formatter ./docs/reference/api/*.md
touch "$@"
docs/manifest.json: site/node_modules/.installed coderd/apidoc/.gen docs/reference/cli/index.md | _gen
tmpdir=$$(mktemp -d -p _gen) && tmpfile=$$(realpath "$$tmpdir")/$(notdir $@) && \
cp _gen/manifest-staging.json "$$tmpfile" && \
./scripts/biome_format.sh "$$tmpfile" && \
mv "$$tmpfile" "$@" && rm -rf "$$tmpdir"
docs/manifest.json: site/node_modules/.installed coderd/apidoc/.gen docs/reference/cli/index.md
(cd site/ && pnpm exec biome format --write ../docs/manifest.json)
touch "$@"
coderd/apidoc/swagger.json: site/node_modules/.installed coderd/apidoc/.gen
(cd site/ && pnpm exec biome format --write ../coderd/apidoc/swagger.json)
touch "$@"
update-golden-files:
@@ -1232,19 +996,11 @@ enterprise/tailnet/testdata/.gen-golden: $(wildcard enterprise/tailnet/testdata/
touch "$@"
helm/coder/tests/testdata/.gen-golden: $(wildcard helm/coder/tests/testdata/*.yaml) $(wildcard helm/coder/tests/testdata/*.golden) $(GO_SRC_FILES) $(wildcard helm/coder/tests/*_test.go)
if command -v helm >/dev/null 2>&1; then
TZ=UTC go test ./helm/coder/tests -run=TestUpdateGoldenFiles -update
else
echo "WARNING: helm not found; skipping helm/coder golden generation" >&2
fi
TZ=UTC go test ./helm/coder/tests -run=TestUpdateGoldenFiles -update
touch "$@"
helm/provisioner/tests/testdata/.gen-golden: $(wildcard helm/provisioner/tests/testdata/*.yaml) $(wildcard helm/provisioner/tests/testdata/*.golden) $(GO_SRC_FILES) $(wildcard helm/provisioner/tests/*_test.go)
if command -v helm >/dev/null 2>&1; then
TZ=UTC go test ./helm/provisioner/tests -run=TestUpdateGoldenFiles -update
else
echo "WARNING: helm not found; skipping helm/provisioner golden generation" >&2
fi
TZ=UTC go test ./helm/provisioner/tests -run=TestUpdateGoldenFiles -update
touch "$@"
coderd/.gen-golden: $(wildcard coderd/testdata/*/*.golden) $(GO_SRC_FILES) $(wildcard coderd/*_test.go)
@@ -1255,7 +1011,7 @@ coderd/notifications/.gen-golden: $(wildcard coderd/notifications/testdata/*/*.g
TZ=UTC go test ./coderd/notifications -run="Test.*Golden$$" -update
touch "$@"
provisioner/terraform/testdata/.gen-golden: $(wildcard provisioner/terraform/testdata/*/*.golden) $(wildcard provisioner/terraform/testdata/*/*/*.golden) $(GO_SRC_FILES) $(wildcard provisioner/terraform/*_test.go)
provisioner/terraform/testdata/.gen-golden: $(wildcard provisioner/terraform/testdata/*/*.golden) $(GO_SRC_FILES) $(wildcard provisioner/terraform/*_test.go)
TZ=UTC go test ./provisioner/terraform -run="Test.*Golden$$" -update
touch "$@"
@@ -1272,22 +1028,10 @@ else
GOTESTSUM_RETRY_FLAGS :=
endif
# Default to 8x8 parallelism to avoid overwhelming our workspaces.
# Race detection defaults to 4x4 because the detector adds significant
# CPU overhead. Override via TEST_NUM_PARALLEL_PACKAGES /
# TEST_NUM_PARALLEL_TESTS.
TEST_PARALLEL_PACKAGES := $(or $(TEST_NUM_PARALLEL_PACKAGES),8)
TEST_PARALLEL_TESTS := $(or $(TEST_NUM_PARALLEL_TESTS),8)
RACE_PARALLEL_PACKAGES := $(or $(TEST_NUM_PARALLEL_PACKAGES),4)
RACE_PARALLEL_TESTS := $(or $(TEST_NUM_PARALLEL_TESTS),4)
# Use testsmallbatch tag to reduce wireguard memory allocation in tests
# (from ~18GB to negligible). Recursively expanded so target-specific
# overrides of TEST_PARALLEL_* take effect (e.g. test-race lowers
# parallelism). CI job timeout is 25m (see test-go-pg in ci.yaml),
# keep the Go timeout 5m shorter so tests produce goroutine dumps
# instead of the CI runner killing the process with no output.
GOTEST_FLAGS = -tags=testsmallbatch -v -timeout 20m -p $(TEST_PARALLEL_PACKAGES) -parallel=$(TEST_PARALLEL_TESTS)
# default to 8x8 parallelism to avoid overwhelming our workspaces. Hopefully we can remove these defaults
# when we get our test suite's resource utilization under control.
# Use testsmallbatch tag to reduce wireguard memory allocation in tests (from ~18GB to negligible).
GOTEST_FLAGS := -tags=testsmallbatch -v -p $(or $(TEST_NUM_PARALLEL_PACKAGES),"8") -parallel=$(or $(TEST_NUM_PARALLEL_TESTS),"8")
# The most common use is to set TEST_COUNT=1 to avoid Go's test cache.
ifdef TEST_COUNT
@@ -1313,40 +1057,13 @@ endif
TEST_PACKAGES ?= ./...
test:
$(GIT_FLAGS) gotestsum --format standard-quiet \
$(GOTESTSUM_RETRY_FLAGS) \
--packages="$(TEST_PACKAGES)" \
-- \
$(GOTEST_FLAGS)
$(GIT_FLAGS) gotestsum --format standard-quiet $(GOTESTSUM_RETRY_FLAGS) --packages="$(TEST_PACKAGES)" -- $(GOTEST_FLAGS)
.PHONY: test
test-race: TEST_PARALLEL_PACKAGES := $(RACE_PARALLEL_PACKAGES)
test-race: TEST_PARALLEL_TESTS := $(RACE_PARALLEL_TESTS)
test-race:
$(GIT_FLAGS) gotestsum --format standard-quiet \
--junitfile="gotests.xml" \
$(GOTESTSUM_RETRY_FLAGS) \
--packages="$(TEST_PACKAGES)" \
-- \
-race \
$(GOTEST_FLAGS)
.PHONY: test-race
test-cli:
$(MAKE) test TEST_PACKAGES="./cli..."
.PHONY: test-cli
test-js: site/node_modules/.installed
cd site/
pnpm test:ci
.PHONY: test-js
test-storybook: site/node_modules/.installed
cd site/
pnpm playwright:install
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:
@@ -1358,22 +1075,37 @@ sqlc-cloud-is-setup:
sqlc-push: sqlc-cloud-is-setup test-postgres-docker
echo "--- sqlc push"
SQLC_DATABASE_URL="postgresql://postgres:postgres@localhost:5432/$$(go run scripts/migrate-ci/main.go)" \
SQLC_DATABASE_URL="postgresql://postgres:postgres@localhost:5432/$(shell go run scripts/migrate-ci/main.go)" \
sqlc push -f coderd/database/sqlc.yaml && echo "Passed sqlc push"
.PHONY: sqlc-push
sqlc-verify: sqlc-cloud-is-setup test-postgres-docker
echo "--- sqlc verify"
SQLC_DATABASE_URL="postgresql://postgres:postgres@localhost:5432/$$(go run scripts/migrate-ci/main.go)" \
SQLC_DATABASE_URL="postgresql://postgres:postgres@localhost:5432/$(shell go run scripts/migrate-ci/main.go)" \
sqlc verify -f coderd/database/sqlc.yaml && echo "Passed sqlc verify"
.PHONY: sqlc-verify
sqlc-vet: test-postgres-docker
echo "--- sqlc vet"
SQLC_DATABASE_URL="postgresql://postgres:postgres@localhost:5432/$$(go run scripts/migrate-ci/main.go)" \
SQLC_DATABASE_URL="postgresql://postgres:postgres@localhost:5432/$(shell go run scripts/migrate-ci/main.go)" \
sqlc vet -f coderd/database/sqlc.yaml && echo "Passed sqlc vet"
.PHONY: sqlc-vet
# When updating -timeout for this test, keep in sync with
# test-go-postgres (.github/workflows/coder.yaml).
# Do add coverage flags so that test caching works.
test-postgres: test-postgres-docker
# The postgres test is prone to failure, so we limit parallelism for
# more consistent execution.
$(GIT_FLAGS) gotestsum \
--junitfile="gotests.xml" \
--jsonfile="gotests.json" \
$(GOTESTSUM_RETRY_FLAGS) \
--packages="./..." -- \
-tags=testsmallbatch \
-timeout=20m \
-count=1
.PHONY: test-postgres
test-migrations: test-postgres-docker
echo "--- test migrations"
@@ -1389,24 +1121,13 @@ test-migrations: test-postgres-docker
# NOTE: we set --memory to the same size as a GitHub runner.
test-postgres-docker:
# If our container is already running, nothing to do.
if docker ps --filter "name=test-postgres-docker-${POSTGRES_VERSION}" --format '{{.Names}}' | grep -q .; then \
echo "test-postgres-docker-${POSTGRES_VERSION} is already running."; \
exit 0; \
fi
# If something else is on 5432, warn but don't fail.
if pg_isready -h 127.0.0.1 -q 2>/dev/null; then \
echo "WARNING: PostgreSQL is already running on 127.0.0.1:5432 (not our container)."; \
echo "Tests will use this instance. To use the Makefile's container, stop it first."; \
exit 0; \
fi
docker rm -f test-postgres-docker-${POSTGRES_VERSION} || true
# Try pulling up to three times to avoid CI flakes.
docker pull ${POSTGRES_IMAGE} || {
retries=2
for try in $$(seq 1 $${retries}); do
echo "Failed to pull image, retrying ($${try}/$${retries})..."
for try in $(seq 1 ${retries}); do
echo "Failed to pull image, retrying (${try}/${retries})..."
sleep 1
if docker pull ${POSTGRES_IMAGE}; then
break
@@ -1447,11 +1168,16 @@ test-postgres-docker:
-c log_statement=all
while ! pg_isready -h 127.0.0.1
do
echo "$$(date) - waiting for database to start"
echo "$(date) - waiting for database to start"
sleep 0.5
done
.PHONY: test-postgres-docker
# Make sure to keep this in sync with test-go-race from .github/workflows/ci.yaml.
test-race:
$(GIT_FLAGS) gotestsum --junitfile="gotests.xml" -- -tags=testsmallbatch -race -count=1 -parallel 4 -p 4 ./...
.PHONY: test-race
test-tailnet-integration:
env \
CODER_TAILNET_TESTS=true \
@@ -1480,7 +1206,6 @@ site/e2e/bin/coder: go.mod go.sum $(GO_SRC_FILES)
test-e2e: site/e2e/bin/coder site/node_modules/.installed site/out/index.html
cd site/
pnpm playwright:install
ifdef CI
DEBUG=pw:api pnpm playwright:test --forbid-only --workers 1
else
@@ -1495,5 +1220,3 @@ dogfood/coder/nix.hash: flake.nix flake.lock
count-test-databases:
PGPASSWORD=postgres psql -h localhost -U postgres -d coder_testing -P pager=off -c 'SELECT test_package, count(*) as count from test_databases GROUP BY test_package ORDER BY count DESC'
.PHONY: count-test-databases
.PHONY: count-test-databases
+9 -57
View File
@@ -16,6 +16,7 @@ import (
"os/user"
"path/filepath"
"slices"
"sort"
"strconv"
"strings"
"sync"
@@ -38,11 +39,8 @@ 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"
"github.com/coder/coder/v2/agent/agentproc"
"github.com/coder/coder/v2/agent/agentscripts"
"github.com/coder/coder/v2/agent/agentsocket"
"github.com/coder/coder/v2/agent/agentssh"
@@ -103,7 +101,6 @@ type Options struct {
Execer agentexec.Execer
Devcontainers bool
DevcontainerAPIOptions []agentcontainers.Option // Enable Devcontainers for these to be effective.
GitAPIOptions []agentgit.Option
Clock quartz.Clock
SocketServerEnabled bool
SocketPath string // Path for the agent socket server socket
@@ -114,12 +111,6 @@ type Client interface {
ConnectRPC28(ctx context.Context) (
proto.DRPCAgentClient28, tailnetproto.DRPCTailnetClient28, error,
)
// ConnectRPC28WithRole is like ConnectRPC28 but sends an explicit
// role query parameter to the server. The workspace agent should
// use role "agent" to enable connection monitoring.
ConnectRPC28WithRole(ctx context.Context, role string) (
proto.DRPCAgentClient28, tailnetproto.DRPCTailnetClient28, error,
)
tailnet.DERPMapRewriter
agentsdk.RefreshableSessionTokenProvider
}
@@ -219,7 +210,6 @@ func New(options Options) Agent {
devcontainers: options.Devcontainers,
containerAPIOptions: options.DevcontainerAPIOptions,
gitAPIOptions: options.GitAPIOptions,
socketPath: options.SocketPath,
socketServerEnabled: options.SocketServerEnabled,
boundaryLogProxySocketPath: options.BoundaryLogProxySocketPath,
@@ -305,12 +295,8 @@ type agent struct {
devcontainers bool
containerAPIOptions []agentcontainers.Option
containerAPI *agentcontainers.API
gitAPIOptions []agentgit.Option
filesAPI *agentfiles.API
gitAPI *agentgit.API
processAPI *agentproc.API
desktopAPI *agentdesktop.API
filesAPI *agentfiles.API
socketServerEnabled bool
socketPath string
@@ -382,20 +368,8 @@ func (a *agent) init() {
a.containerAPI = agentcontainers.NewAPI(a.logger.Named("containers"), containerAPIOpts...)
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, 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.filesAPI = agentfiles.NewAPI(a.logger.Named("files"), a.filesystem)
a.reconnectingPTYServer = reconnectingpty.NewServer(
a.logger.Named("reconnecting-pty"),
a.sshServer,
@@ -427,7 +401,7 @@ func (a *agent) initSocketServer() {
agentsocket.WithPath(a.socketPath),
)
if err != nil {
a.logger.Error(a.hardCtx, "failed to create socket server", slog.Error(err), slog.F("path", a.socketPath))
a.logger.Warn(a.hardCtx, "failed to create socket server", slog.Error(err), slog.F("path", a.socketPath))
return
}
@@ -437,12 +411,7 @@ func (a *agent) initSocketServer() {
// startBoundaryLogProxyServer starts the boundary log proxy socket server.
func (a *agent) startBoundaryLogProxyServer() {
if a.boundaryLogProxySocketPath == "" {
a.logger.Warn(a.hardCtx, "boundary log proxy socket path not defined; not starting proxy")
return
}
proxy := boundarylogproxy.NewServer(a.logger, a.boundaryLogProxySocketPath, a.prometheusRegistry)
proxy := boundarylogproxy.NewServer(a.logger, a.boundaryLogProxySocketPath)
if err := proxy.Start(); err != nil {
a.logger.Warn(a.hardCtx, "failed to start boundary log proxy", slog.Error(err))
return
@@ -1028,10 +997,8 @@ func (a *agent) run() (retErr error) {
return xerrors.Errorf("refresh token: %w", err)
}
// ConnectRPC returns the dRPC connection we use for the Agent and Tailnet v2+ APIs.
// We pass role "agent" to enable connection monitoring on the server, which tracks
// the agent's connectivity state (first_connected_at, last_connected_at, disconnected_at).
aAPI, tAPI, err := a.client.ConnectRPC28WithRole(a.hardCtx, "agent")
// ConnectRPC returns the dRPC connection we use for the Agent and Tailnet v2+ APIs
aAPI, tAPI, err := a.client.ConnectRPC28(a.hardCtx)
if err != nil {
return err
}
@@ -1042,13 +1009,6 @@ func (a *agent) run() (retErr error) {
}
}()
// The socket server accepts requests from processes running inside the workspace and forwards
// some of the requests to Coderd over the DRPC connection.
if a.socketServer != nil {
a.socketServer.SetAgentAPI(aAPI)
defer a.socketServer.ClearAgentAPI()
}
// A lot of routines need the agent API / tailnet API connection. We run them in their own
// goroutines in parallel, but errors in any routine will cause them all to exit so we can
// redial the coder server and retry.
@@ -1876,7 +1836,7 @@ func (a *agent) Collect(ctx context.Context, networkStats map[netlogtype.Connect
}()
}
wg.Wait()
slices.Sort(durations)
sort.Float64s(durations)
durationsLength := len(durations)
switch {
case durationsLength == 0:
@@ -2062,14 +2022,6 @@ func (a *agent) Close() error {
a.logger.Error(a.hardCtx, "container API close", slog.Error(err))
}
if err := a.processAPI.Close(); err != nil {
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 {
+9 -77
View File
@@ -713,15 +713,15 @@ func TestAgent_Session_TTY_MOTD_Update(t *testing.T) {
},
}
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
defer cancel()
setSBInterval := func(_ *agenttest.Client, opts *agent.Options) {
opts.ServiceBannerRefreshInterval = testutil.IntervalFast
opts.ServiceBannerRefreshInterval = 5 * time.Millisecond
}
//nolint:dogsled // Allow the blank identifiers.
conn, client, _, _, _ := setupAgent(t, agentsdk.Manifest{}, 0, setSBInterval)
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
defer cancel()
//nolint:paralleltest // These tests need to swap the banner func.
for _, port := range sshPorts {
sshClient, err := conn.SSHClientOnPort(ctx, port)
@@ -733,10 +733,7 @@ func TestAgent_Session_TTY_MOTD_Update(t *testing.T) {
for i, test := range tests {
t.Run(fmt.Sprintf("(:%d)/%d", port, i), func(t *testing.T) {
// Set new banner func and wait for the agent to call it to update the
// banner. We wait for two calls to ensure the value has been stored:
// the second call can only begin after the first iteration of
// fetchServiceBannerLoop completes (call + store), so after
// receiving two signals at least one store has happened.
// banner.
ready := make(chan struct{}, 2)
client.SetAnnouncementBannersFunc(func() ([]codersdk.BannerConfig, error) {
select {
@@ -745,8 +742,8 @@ func TestAgent_Session_TTY_MOTD_Update(t *testing.T) {
}
return []codersdk.BannerConfig{test.banner}, nil
})
testutil.TryReceive(ctx, t, ready)
testutil.TryReceive(ctx, t, ready)
<-ready
<-ready // Wait for two updates to ensure the value has propagated.
session, err := sshClient.NewSession()
require.NoError(t, err)
@@ -3043,62 +3040,6 @@ func TestAgent_Reconnect(t *testing.T) {
closer.Close()
}
func TestAgent_ReconnectNoLifecycleReemit(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitLong)
logger := testutil.Logger(t)
fCoordinator := tailnettest.NewFakeCoordinator()
agentID := uuid.New()
statsCh := make(chan *proto.Stats, 50)
derpMap, _ := tailnettest.RunDERPAndSTUN(t)
client := agenttest.NewClient(t,
logger,
agentID,
agentsdk.Manifest{
DERPMap: derpMap,
Scripts: []codersdk.WorkspaceAgentScript{{
Script: "echo hello",
Timeout: 30 * time.Second,
RunOnStart: true,
}},
},
statsCh,
fCoordinator,
)
defer client.Close()
closer := agent.New(agent.Options{
Client: client,
Logger: logger.Named("agent"),
})
defer closer.Close()
// Wait for the agent to reach Ready state.
require.Eventually(t, func() bool {
return slices.Contains(client.GetLifecycleStates(), codersdk.WorkspaceAgentLifecycleReady)
}, testutil.WaitShort, testutil.IntervalFast)
statesBefore := slices.Clone(client.GetLifecycleStates())
// Disconnect by closing the coordinator response channel.
call1 := testutil.RequireReceive(ctx, t, fCoordinator.CoordinateCalls)
close(call1.Resps)
// Wait for reconnect.
testutil.RequireReceive(ctx, t, fCoordinator.CoordinateCalls)
// Wait for a stats report as a deterministic steady-state proof.
testutil.RequireReceive(ctx, t, statsCh)
statesAfter := client.GetLifecycleStates()
require.Equal(t, statesBefore, statesAfter,
"lifecycle states should not be re-reported after reconnect")
closer.Close()
}
func TestAgent_WriteVSCodeConfigs(t *testing.T) {
t.Parallel()
logger := testutil.Logger(t)
@@ -3553,17 +3494,8 @@ func testSessionOutput(t *testing.T, session *ssh.Session, expected, unexpected
require.NoError(t, err)
ptty.WriteLine("exit 0")
waitErr := make(chan error, 1)
go func() {
waitErr <- session.Wait()
}()
select {
case err = <-waitErr:
require.NoError(t, err)
case <-time.After(testutil.WaitLong):
require.Fail(t, "timed out waiting for session to exit")
}
err = session.Wait()
require.NoError(t, err)
for _, unexpected := range unexpected {
require.NotContains(t, stdout.String(), unexpected, "should not show output")
-14
View File
@@ -57,26 +57,18 @@ 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
}
@@ -2697,9 +2689,7 @@ 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.
@@ -2831,9 +2821,7 @@ 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{
@@ -4938,11 +4926,9 @@ 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
@@ -433,7 +433,7 @@ func convertDockerInspect(raw []byte) ([]codersdk.WorkspaceAgentContainer, []str
}
portKeys := maps.Keys(in.NetworkSettings.Ports)
// Sort the ports for deterministic output.
slices.Sort(portKeys)
sort.Strings(portKeys)
// If we see the same port bound to both ipv4 and ipv6 loopback or unspecified
// interfaces to the same container port, there is no point in adding it multiple times.
loopbackHostPortContainerPorts := make(map[int]uint16, 0)
-521
View File
@@ -1,521 +0,0 @@
package agentdesktop
import (
"encoding/json"
"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/coder/v2/codersdk/workspacesdk"
"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 describe the declared model-facing desktop
// geometry. When provided, input coordinates are mapped from declared space
// to native desktop pixels 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()),
)
geometry := desktopGeometryForAction(cfg, action)
scaleXY := geometry.DeclaredPointToNative
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":
nativeX, nativeY, err := a.desktop.CursorPosition(ctx)
if err != nil {
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
Message: "Cursor position failed.",
Detail: err.Error(),
})
return
}
x, y := geometry.NativePointToDeclared(nativeX, nativeY)
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":
result, err := a.desktop.Screenshot(ctx, ScreenshotOptions{
TargetWidth: geometry.DeclaredWidth,
TargetHeight: geometry.DeclaredHeight,
})
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
resp.ScreenshotWidth = geometry.DeclaredWidth
resp.ScreenshotHeight = geometry.DeclaredHeight
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
}
func desktopGeometryForAction(cfg DisplayConfig, action DesktopAction) workspacesdk.DesktopGeometry {
declaredWidth := cfg.Width
declaredHeight := cfg.Height
if action.ScaledWidth != nil && *action.ScaledWidth > 0 {
declaredWidth = *action.ScaledWidth
}
if action.ScaledHeight != nil && *action.ScaledHeight > 0 {
declaredHeight = *action.ScaledHeight
}
return workspacesdk.NewDesktopGeometryWithDeclared(
cfg.Width,
cfg.Height,
declaredWidth,
declaredHeight,
)
}
// 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."
}
-576
View File
@@ -1,576 +0,0 @@
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
cursorPos [2]int
startCfg agentdesktop.DisplayConfig
vncConnErr error
screenshotErr error
screenshotRes agentdesktop.ScreenshotResult
lastShotOpts agentdesktop.ScreenshotOptions
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, opts agentdesktop.ScreenshotOptions) (agentdesktop.ScreenshotResult, error) {
f.lastShotOpts = opts
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 (f *fakeDesktop) CursorPosition(context.Context) (x int, y int, err error) {
return f.cursorPos[0], f.cursorPos[1], 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)
geometry := workspacesdk.DefaultDesktopGeometry()
fake := &fakeDesktop{
startCfg: agentdesktop.DisplayConfig{
Width: geometry.NativeWidth,
Height: geometry.NativeHeight,
},
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)
assert.Equal(t, "screenshot", result.Output)
assert.Equal(t, "base64data", result.ScreenshotData)
assert.Equal(t, geometry.NativeWidth, result.ScreenshotWidth)
assert.Equal(t, geometry.NativeHeight, result.ScreenshotHeight)
assert.Equal(t, agentdesktop.ScreenshotOptions{
TargetWidth: geometry.NativeWidth,
TargetHeight: geometry.NativeHeight,
}, fake.lastShotOpts)
}
func TestHandleAction_ScreenshotUsesDeclaredDimensionsFromRequest(t *testing.T) {
t.Parallel()
logger := slogtest.Make(t, nil)
fake := &fakeDesktop{
startCfg: agentdesktop.DisplayConfig{Width: 1920, Height: 1080},
screenshotRes: agentdesktop.ScreenshotResult{Data: "base64data"},
}
api := agentdesktop.NewAPI(logger, fake, nil)
defer api.Close()
sw := 1280
sh := 720
body := agentdesktop.DesktopAction{
Action: "screenshot",
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)
assert.Equal(t, agentdesktop.ScreenshotOptions{TargetWidth: 1280, TargetHeight: 720}, fake.lastShotOpts)
var result agentdesktop.DesktopActionResponse
err = json.NewDecoder(rr.Body).Decode(&result)
require.NoError(t, err)
assert.Equal(t, 1280, result.ScreenshotWidth)
assert.Equal(t, 720, 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)
}()
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)
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{
startCfg: agentdesktop.DisplayConfig{Width: 1920, Height: 1080},
}
api := agentdesktop.NewAPI(logger, fake, nil)
defer api.Close()
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)
assert.Equal(t, 960, fake.lastMove[0])
assert.Equal(t, 540, fake.lastMove[1])
}
func TestHandleAction_CoordinateScalingClampsToLastPixel(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()
sw := 1366
sh := 768
body := agentdesktop.DesktopAction{
Action: "mouse_move",
Coordinate: &[2]int{1365, 767},
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)
assert.Equal(t, 1919, fake.lastMove[0])
assert.Equal(t, 1079, 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)
fake := &fakeDesktop{}
api := agentdesktop.NewAPI(logger, fake, nil)
err := api.Close()
require.NoError(t, err)
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)
}
func TestHandleAction_CursorPositionReturnsDeclaredCoordinates(t *testing.T) {
t.Parallel()
logger := slogtest.Make(t, nil)
fake := &fakeDesktop{
startCfg: agentdesktop.DisplayConfig{Width: 1920, Height: 1080},
cursorPos: [2]int{960, 540},
}
api := agentdesktop.NewAPI(logger, fake, nil)
defer api.Close()
sw := 1280
sh := 720
body := agentdesktop.DesktopAction{
Action: "cursor_position",
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)
var resp agentdesktop.DesktopActionResponse
err = json.NewDecoder(rr.Body).Decode(&resp)
require.NoError(t, err)
// Native (960,540) in 1920x1080 should map to declared space in 1280x720.
assert.Equal(t, "x=640,y=360", resp.Output)
}
-91
View File
@@ -1,91 +0,0 @@
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
View File
@@ -1,399 +0,0 @@
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.DesktopNativeWidth, workspacesdk.DesktopNativeHeight))
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")
}
@@ -1,545 +0,0 @@
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)
+1 -5
View File
@@ -7,21 +7,18 @@ import (
"github.com/spf13/afero"
"cdr.dev/slog/v3"
"github.com/coder/coder/v2/agent/agentgit"
)
// API exposes file-related operations performed through the agent.
type API struct {
logger slog.Logger
filesystem afero.Fs
pathStore *agentgit.PathStore
}
func NewAPI(logger slog.Logger, filesystem afero.Fs, pathStore *agentgit.PathStore) *API {
func NewAPI(logger slog.Logger, filesystem afero.Fs) *API {
api := &API{
logger: logger,
filesystem: filesystem,
pathStore: pathStore,
}
return api
}
@@ -32,7 +29,6 @@ func (api *API) Routes() http.Handler {
r.Post("/list-directory", api.HandleLS)
r.Get("/read-file", api.HandleReadFile)
r.Get("/read-file-lines", api.HandleReadFileLines)
r.Post("/write-file", api.HandleWriteFile)
r.Post("/edit-files", api.HandleEditFiles)
+44 -570
View File
@@ -10,46 +10,21 @@ import (
"os"
"path/filepath"
"strconv"
"strings"
"syscall"
"github.com/google/uuid"
"github.com/icholy/replace"
"github.com/spf13/afero"
"golang.org/x/text/transform"
"golang.org/x/xerrors"
"cdr.dev/slog/v3"
"github.com/coder/coder/v2/agent/agentgit"
"github.com/coder/coder/v2/coderd/httpapi"
"github.com/coder/coder/v2/codersdk"
"github.com/coder/coder/v2/codersdk/workspacesdk"
)
// ReadFileLinesResponse is the JSON response for the line-based file reader.
type ReadFileLinesResponse struct {
// Success indicates whether the read was successful.
Success bool `json:"success"`
// FileSize is the original file size in bytes.
FileSize int64 `json:"file_size,omitempty"`
// TotalLines is the total number of lines in the file.
TotalLines int `json:"total_lines,omitempty"`
// LinesRead is the count of lines returned in this response.
LinesRead int `json:"lines_read,omitempty"`
// Content is the line-numbered file content.
Content string `json:"content,omitempty"`
// Error is the error message when success is false.
Error string `json:"error,omitempty"`
}
type HTTPResponseCode = int
// pendingEdit holds the computed result of a file edit, ready to
// be written to disk.
type pendingEdit struct {
path string
content string
mode os.FileMode
}
func (api *API) HandleReadFile(rw http.ResponseWriter, r *http.Request) {
ctx := r.Context()
@@ -128,166 +103,6 @@ func (api *API) streamFile(ctx context.Context, rw http.ResponseWriter, path str
return 0, nil
}
func (api *API) HandleReadFileLines(rw http.ResponseWriter, r *http.Request) {
ctx := r.Context()
query := r.URL.Query()
parser := httpapi.NewQueryParamParser().RequiredNotEmpty("path")
path := parser.String(query, "", "path")
offset := parser.PositiveInt64(query, 1, "offset")
limit := parser.PositiveInt64(query, 0, "limit")
maxFileSize := parser.PositiveInt64(query, workspacesdk.DefaultMaxFileSize, "max_file_size")
maxLineBytes := parser.PositiveInt64(query, workspacesdk.DefaultMaxLineBytes, "max_line_bytes")
maxResponseLines := parser.PositiveInt64(query, workspacesdk.DefaultMaxResponseLines, "max_response_lines")
maxResponseBytes := parser.PositiveInt64(query, workspacesdk.DefaultMaxResponseBytes, "max_response_bytes")
parser.ErrorExcessParams(query)
if len(parser.Errors) > 0 {
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
Message: "Query parameters have invalid values.",
Validations: parser.Errors,
})
return
}
resp := api.readFileLines(ctx, path, offset, limit, workspacesdk.ReadFileLinesLimits{
MaxFileSize: maxFileSize,
MaxLineBytes: int(maxLineBytes),
MaxResponseLines: int(maxResponseLines),
MaxResponseBytes: int(maxResponseBytes),
})
httpapi.Write(ctx, rw, http.StatusOK, resp)
}
func (api *API) readFileLines(_ context.Context, path string, offset, limit int64, limits workspacesdk.ReadFileLinesLimits) ReadFileLinesResponse {
errResp := func(msg string) ReadFileLinesResponse {
return ReadFileLinesResponse{Success: false, Error: msg}
}
if !filepath.IsAbs(path) {
return errResp(fmt.Sprintf("file path must be absolute: %q", path))
}
f, err := api.filesystem.Open(path)
if err != nil {
if errors.Is(err, os.ErrNotExist) {
return errResp(fmt.Sprintf("file does not exist: %s", path))
}
if errors.Is(err, os.ErrPermission) {
return errResp(fmt.Sprintf("permission denied: %s", path))
}
return errResp(fmt.Sprintf("open file: %s", err))
}
defer f.Close()
stat, err := f.Stat()
if err != nil {
return errResp(fmt.Sprintf("stat file: %s", err))
}
if stat.IsDir() {
return errResp(fmt.Sprintf("not a file: %s", path))
}
fileSize := stat.Size()
if fileSize > limits.MaxFileSize {
return errResp(fmt.Sprintf(
"file is %d bytes which exceeds the maximum of %d bytes. Use grep, sed, or awk to extract the content you need, or use offset and limit to read a portion.",
fileSize, limits.MaxFileSize,
))
}
// Read the entire file (up to MaxFileSize).
data, err := io.ReadAll(f)
if err != nil {
return errResp(fmt.Sprintf("read file: %s", err))
}
// Split into lines.
content := string(data)
// Handle empty file.
if content == "" {
return ReadFileLinesResponse{
Success: true,
FileSize: fileSize,
TotalLines: 0,
LinesRead: 0,
Content: "",
}
}
lines := strings.Split(content, "\n")
totalLines := len(lines)
// offset is 1-based line number.
if offset < 1 {
offset = 1
}
if offset > int64(totalLines) {
return errResp(fmt.Sprintf(
"offset %d is beyond the file length of %d lines",
offset, totalLines,
))
}
// Default limit.
if limit <= 0 {
limit = int64(limits.MaxResponseLines)
}
startIdx := int(offset - 1) // convert to 0-based
endIdx := startIdx + int(limit)
if endIdx > totalLines {
endIdx = totalLines
}
var numbered []string
totalBytesAccumulated := 0
for i := startIdx; i < endIdx; i++ {
line := lines[i]
// Per-line truncation.
if len(line) > limits.MaxLineBytes {
line = line[:limits.MaxLineBytes] + "... [truncated]"
}
// Format with 1-based line number.
numberedLine := fmt.Sprintf("%d\t%s", i+1, line)
lineBytes := len(numberedLine)
// Check total byte budget.
newTotal := totalBytesAccumulated + lineBytes
if len(numbered) > 0 {
newTotal++ // account for \n joiner
}
if newTotal > limits.MaxResponseBytes {
return errResp(fmt.Sprintf(
"output would exceed %d bytes. Read less at a time using offset and limit parameters.",
limits.MaxResponseBytes,
))
}
// Check line count.
if len(numbered) >= limits.MaxResponseLines {
return errResp(fmt.Sprintf(
"output would exceed %d lines. Read less at a time using offset and limit parameters.",
limits.MaxResponseLines,
))
}
numbered = append(numbered, numberedLine)
totalBytesAccumulated = newTotal
}
return ReadFileLinesResponse{
Success: true,
FileSize: fileSize,
TotalLines: totalLines,
LinesRead: len(numbered),
Content: strings.Join(numbered, "\n"),
}
}
func (api *API) HandleWriteFile(rw http.ResponseWriter, r *http.Request) {
ctx := r.Context()
@@ -311,13 +126,6 @@ func (api *API) HandleWriteFile(rw http.ResponseWriter, r *http.Request) {
return
}
// Track edited path for git watch.
if api.pathStore != nil {
if chatID, ancestorIDs, ok := agentgit.ExtractChatContext(r); ok {
api.pathStore.AddPaths(append([]uuid.UUID{chatID}, ancestorIDs...), []string{path})
}
}
httpapi.Write(ctx, rw, http.StatusOK, codersdk.Response{
Message: fmt.Sprintf("Successfully wrote to %q", path),
})
@@ -328,14 +136,8 @@ func (api *API) writeFile(ctx context.Context, r *http.Request, path string) (HT
return http.StatusBadRequest, xerrors.Errorf("file path must be absolute: %q", path)
}
resolved, err := api.resolveSymlink(path)
if err != nil {
return http.StatusInternalServerError, xerrors.Errorf("resolve symlink %q: %w", path, err)
}
path = resolved
dir := filepath.Dir(path)
err = api.filesystem.MkdirAll(dir, 0o755)
err := api.filesystem.MkdirAll(dir, 0o755)
if err != nil {
status := http.StatusInternalServerError
switch {
@@ -347,18 +149,25 @@ func (api *API) writeFile(ctx context.Context, r *http.Request, path string) (HT
return status, err
}
// Check if the target already exists so we can preserve its
// permissions on the temp file before rename.
var mode *os.FileMode
if stat, serr := api.filesystem.Stat(path); serr == nil {
if stat.IsDir() {
return http.StatusBadRequest, xerrors.Errorf("open %s: is a directory", path)
f, err := api.filesystem.Create(path)
if err != nil {
status := http.StatusInternalServerError
switch {
case errors.Is(err, os.ErrPermission):
status = http.StatusForbidden
case errors.Is(err, syscall.EISDIR):
status = http.StatusBadRequest
}
m := stat.Mode()
mode = &m
return status, err
}
defer f.Close()
_, 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))
}
return api.atomicWrite(ctx, path, mode, r.Body)
return 0, nil
}
func (api *API) HandleEditFiles(rw http.ResponseWriter, r *http.Request) {
@@ -376,23 +185,17 @@ func (api *API) HandleEditFiles(rw http.ResponseWriter, r *http.Request) {
return
}
// Phase 1: compute all edits in memory. If any file fails
// (bad path, search miss, permission error), bail before
// writing anything.
var pending []pendingEdit
var combinedErr error
status := http.StatusOK
for _, edit := range req.Files {
s, p, err := api.prepareFileEdit(edit.Path, edit.Edits)
s, err := api.editFile(r.Context(), edit.Path, edit.Edits)
// Keep the highest response status, so 500 will be preferred over 400, etc.
if s > status {
status = s
}
if err != nil {
combinedErr = errors.Join(combinedErr, err)
}
if p != nil {
pending = append(pending, *p)
}
}
if combinedErr != nil {
@@ -402,57 +205,24 @@ func (api *API) HandleEditFiles(rw http.ResponseWriter, r *http.Request) {
return
}
// Phase 2: write all files via atomicWrite. A failure here
// (e.g. disk full) can leave earlier files committed. True
// cross-file atomicity would require filesystem transactions.
for _, p := range pending {
mode := p.mode
s, err := api.atomicWrite(ctx, p.path, &mode, strings.NewReader(p.content))
if err != nil {
httpapi.Write(ctx, rw, s, codersdk.Response{
Message: err.Error(),
})
return
}
}
// Track edited paths for git watch.
if api.pathStore != nil {
if chatID, ancestorIDs, ok := agentgit.ExtractChatContext(r); ok {
filePaths := make([]string, 0, len(req.Files))
for _, f := range req.Files {
filePaths = append(filePaths, f.Path)
}
api.pathStore.AddPaths(append([]uuid.UUID{chatID}, ancestorIDs...), filePaths)
}
}
httpapi.Write(ctx, rw, http.StatusOK, codersdk.Response{
Message: "Successfully edited file(s)",
})
}
// prepareFileEdit validates, reads, and computes edits for a single
// file without writing anything to disk.
func (api *API) prepareFileEdit(path string, edits []workspacesdk.FileEdit) (int, *pendingEdit, error) {
func (api *API) editFile(ctx context.Context, path string, edits []workspacesdk.FileEdit) (int, error) {
if path == "" {
return http.StatusBadRequest, nil, xerrors.New("\"path\" is required")
return http.StatusBadRequest, xerrors.New("\"path\" is required")
}
if !filepath.IsAbs(path) {
return http.StatusBadRequest, nil, xerrors.Errorf("file path must be absolute: %q", path)
return http.StatusBadRequest, xerrors.Errorf("file path must be absolute: %q", path)
}
if len(edits) == 0 {
return http.StatusBadRequest, nil, xerrors.New("must specify at least one edit")
return http.StatusBadRequest, xerrors.New("must specify at least one edit")
}
resolved, err := api.resolveSymlink(path)
if err != nil {
return http.StatusInternalServerError, nil, xerrors.Errorf("resolve symlink %q: %w", path, err)
}
path = resolved
f, err := api.filesystem.Open(path)
if err != nil {
status := http.StatusInternalServerError
@@ -462,340 +232,44 @@ func (api *API) prepareFileEdit(path string, edits []workspacesdk.FileEdit) (int
case errors.Is(err, os.ErrPermission):
status = http.StatusForbidden
}
return status, nil, err
return status, err
}
defer f.Close()
stat, err := f.Stat()
if err != nil {
return http.StatusInternalServerError, nil, err
return http.StatusInternalServerError, err
}
if stat.IsDir() {
return http.StatusBadRequest, nil, xerrors.Errorf("open %s: not a file", path)
return http.StatusBadRequest, xerrors.Errorf("open %s: not a file", path)
}
data, err := io.ReadAll(f)
transforms := make([]transform.Transformer, len(edits))
for i, edit := range edits {
transforms[i] = replace.String(edit.Search, edit.Replace)
}
// Create an adjacent file to ensure it will be on the same device and can be
// moved atomically.
tmpfile, err := afero.TempFile(api.filesystem, filepath.Dir(path), filepath.Base(path))
if err != nil {
return http.StatusInternalServerError, nil, xerrors.Errorf("read %s: %w", path, err)
return http.StatusInternalServerError, err
}
content := string(data)
defer tmpfile.Close()
for _, edit := range edits {
var err error
content, err = fuzzyReplace(content, edit)
if err != nil {
return http.StatusBadRequest, nil, xerrors.Errorf("edit %s: %w", path, err)
}
}
return 0, &pendingEdit{
path: path,
content: content,
mode: stat.Mode(),
}, nil
}
// atomicWrite writes content from r to path via a temp file in the
// same directory. If the target exists, its permissions are preserved.
// On failure the temp file is cleaned up and the original is
// untouched.
func (api *API) atomicWrite(ctx context.Context, path string, mode *os.FileMode, r io.Reader) (int, error) {
dir := filepath.Dir(path)
tmpName := filepath.Join(dir, fmt.Sprintf(".%s.tmp.%s", filepath.Base(path), uuid.New().String()[:8]))
tmpfile, err := api.filesystem.OpenFile(tmpName, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o666)
_, err = io.Copy(tmpfile, replace.Chain(f, transforms...))
if err != nil {
status := http.StatusInternalServerError
if errors.Is(err, os.ErrPermission) {
status = http.StatusForbidden
if rerr := api.filesystem.Remove(tmpfile.Name()); rerr != nil {
api.logger.Warn(ctx, "unable to clean up temp file", slog.Error(rerr))
}
return status, err
return http.StatusInternalServerError, xerrors.Errorf("edit %s: %w", path, err)
}
cleanup := func() {
if err := api.filesystem.Remove(tmpName); err != nil {
api.logger.Warn(ctx, "unable to clean up temp file", slog.Error(err))
}
}
_, err = io.Copy(tmpfile, r)
err = api.filesystem.Rename(tmpfile.Name(), path)
if err != nil {
_ = tmpfile.Close()
cleanup()
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 {
cleanup()
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 mode != nil {
if err := api.filesystem.Chmod(tmpName, *mode); 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 {
cleanup()
status := http.StatusInternalServerError
if errors.Is(err, os.ErrPermission) {
status = http.StatusForbidden
}
return status, xerrors.Errorf("write %s: %w", path, err)
return http.StatusInternalServerError, err
}
return 0, nil
}
// resolveSymlink resolves a path through any symlinks so that
// subsequent operations (such as atomic rename) target the real
// file instead of replacing the symlink itself.
//
// The filesystem must implement afero.Lstater and afero.LinkReader
// for resolution to occur; if it does not (e.g. MemMapFs), the
// path is returned unchanged.
func (api *API) resolveSymlink(path string) (string, error) {
const maxDepth = 10
lstater, hasLstat := api.filesystem.(afero.Lstater)
if !hasLstat {
return path, nil
}
reader, hasReadlink := api.filesystem.(afero.LinkReader)
if !hasReadlink {
return path, nil
}
for range maxDepth {
info, _, err := lstater.LstatIfPossible(path)
if err != nil {
// If the file does not exist yet (new file write),
// there is nothing to resolve.
if errors.Is(err, os.ErrNotExist) {
return path, nil
}
return "", err
}
if info.Mode()&os.ModeSymlink == 0 {
return path, nil
}
target, err := reader.ReadlinkIfPossible(path)
if err != nil {
return "", err
}
if !filepath.IsAbs(target) {
target = filepath.Join(filepath.Dir(path), target)
}
path = target
}
return "", xerrors.Errorf("too many levels of symlinks resolving %q", path)
}
// 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).
//
// 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.
//
// 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) {
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.
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.
if len(searchLines) > 0 && searchLines[len(searchLines)-1] == "" {
searchLines = searchLines[:len(searchLines)-1]
}
trimRight := func(a, b string) bool {
return strings.TrimRight(a, " \t\r\n") == strings.TrimRight(b, " \t\r\n")
}
trimAll := func(a, b string) bool {
return strings.TrimSpace(a) == strings.TrimSpace(b)
}
// Pass 2 trim trailing whitespace on each line.
if result, matched, err := fuzzyReplaceLines(contentLines, searchLines, replace, trimRight, edit.ReplaceAll); matched {
return result, err
}
// Pass 3 trim all leading and trailing whitespace
// (indentation-tolerant). The replacement is inserted verbatim;
// callers must provide correctly indented replacement text.
if result, matched, err := fuzzyReplaceLines(contentLines, searchLines, replace, trimAll, edit.ReplaceAll); matched {
return result, err
}
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
// searchLines according to the provided `eq` function. It returns the start and
// end (exclusive) indices into contentLines of the match.
func seekLines(contentLines, searchLines []string, eq func(a, b string) bool) (start, end int, ok bool) {
if len(searchLines) == 0 {
return 0, 0, true
}
if len(searchLines) > len(contentLines) {
return 0, 0, false
}
outer:
for i := 0; i <= len(contentLines)-len(searchLines); i++ {
for j, sLine := range searchLines {
if !eq(contentLines[i+j], sLine) {
continue outer
}
}
return i, i + len(searchLines), true
}
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 {
var b strings.Builder
for _, l := range contentLines[:start] {
_, _ = b.WriteString(l)
}
_, _ = b.WriteString(replacement)
for _, l := range contentLines[end:] {
_, _ = b.WriteString(l)
}
return b.String()
}
// fuzzyReplaceLines handles fuzzy matching passes (2 and 3) for
// fuzzyReplace. When replaceAll is false and there are multiple
// matches, an error is returned. When replaceAll is true, all
// non-overlapping matches are replaced.
//
// Returns (result, true, nil) on success, ("", false, nil) when
// searchLines don't match at all, or ("", true, err) when the match
// is ambiguous.
//
//nolint:revive // replaceAll is a direct pass-through of the user's flag, not a control coupling.
func fuzzyReplaceLines(
contentLines, searchLines []string,
replace string,
eq func(a, b string) bool,
replaceAll bool,
) (string, bool, error) {
start, end, ok := seekLines(contentLines, searchLines, eq)
if !ok {
return "", false, nil
}
if !replaceAll {
if count := countLineMatches(contentLines, searchLines, eq); count > 1 {
return "", true, 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), true, nil
}
// Replace all: collect all match positions, then apply from last
// to first to preserve indices.
type lineMatch struct{ start, end int }
var matches []lineMatch
for i := 0; i <= len(contentLines)-len(searchLines); {
found := true
for j, sLine := range searchLines {
if !eq(contentLines[i+j], sLine) {
found = false
break
}
}
if found {
matches = append(matches, lineMatch{i, i + len(searchLines)})
i += len(searchLines) // skip past this match
} else {
i++
}
}
// Apply replacements from last to first.
repLines := strings.SplitAfter(replace, "\n")
for i := len(matches) - 1; i >= 0; i-- {
m := matches[i]
newLines := make([]string, 0, m.start+len(repLines)+(len(contentLines)-m.end))
newLines = append(newLines, contentLines[:m.start]...)
newLines = append(newLines, repLines...)
newLines = append(newLines, contentLines[m.end:]...)
contentLines = newLines
}
var b strings.Builder
for _, l := range contentLines {
_, _ = b.WriteString(l)
}
return b.String(), true, nil
}
+7 -842
View File
@@ -11,13 +11,9 @@ import (
"os"
"path/filepath"
"runtime"
"strings"
"syscall"
"testing"
"testing/iotest"
"github.com/go-chi/chi/v5"
"github.com/google/uuid"
"github.com/spf13/afero"
"github.com/stretchr/testify/require"
"golang.org/x/xerrors"
@@ -25,7 +21,6 @@ import (
"cdr.dev/slog/v3"
"cdr.dev/slog/v3/sloggers/slogtest"
"github.com/coder/coder/v2/agent/agentfiles"
"github.com/coder/coder/v2/agent/agentgit"
"github.com/coder/coder/v2/codersdk"
"github.com/coder/coder/v2/codersdk/workspacesdk"
"github.com/coder/coder/v2/testutil"
@@ -121,7 +116,7 @@ func TestReadFile(t *testing.T) {
}
return nil
})
api := agentfiles.NewAPI(logger, fs, nil)
api := agentfiles.NewAPI(logger, fs)
dirPath := filepath.Join(tmpdir, "a-directory")
err := fs.MkdirAll(dirPath, 0o755)
@@ -301,7 +296,7 @@ func TestWriteFile(t *testing.T) {
}
return nil
})
api := agentfiles.NewAPI(logger, fs, nil)
api := agentfiles.NewAPI(logger, fs)
dirPath := filepath.Join(tmpdir, "directory")
err := fs.MkdirAll(dirPath, 0o755)
@@ -400,83 +395,6 @@ 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()
@@ -496,7 +414,7 @@ func TestEditFiles(t *testing.T) {
}
return nil
})
api := agentfiles.NewAPI(logger, fs, nil)
api := agentfiles.NewAPI(logger, fs)
dirPath := filepath.Join(tmpdir, "directory")
err := fs.MkdirAll(dirPath, 0o755)
@@ -636,8 +554,6 @@ func TestEditFiles(t *testing.T) {
},
errCode: http.StatusInternalServerError,
errors: []string{"rename failed"},
// Original file must survive the failed rename.
expected: map[string]string{failRenameFilePath: "foo bar"},
},
{
name: "Edit1",
@@ -656,9 +572,7 @@ func TestEditFiles(t *testing.T) {
expected: map[string]string{filepath.Join(tmpdir, "edit1"): "bar bar"},
},
{
// When the second edit creates ambiguity (two "bar"
// occurrences), it should fail.
name: "EditEditAmbiguous",
name: "EditEdit", // Edits affect previous edits.
contents: map[string]string{filepath.Join(tmpdir, "edit-edit"): "foo bar"},
edits: []workspacesdk.FileEdits{
{
@@ -675,33 +589,7 @@ func TestEditFiles(t *testing.T) {
},
},
},
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"},
expected: map[string]string{filepath.Join(tmpdir, "edit-edit"): "qux qux"},
},
{
name: "Multiline",
@@ -761,180 +649,6 @@ func TestEditFiles(t *testing.T) {
filepath.Join(tmpdir, "file3"): "edited3 3",
},
},
{
name: "TrailingWhitespace",
contents: map[string]string{filepath.Join(tmpdir, "trailing-ws"): "foo \nbar\t\t\nbaz"},
edits: []workspacesdk.FileEdits{
{
Path: filepath.Join(tmpdir, "trailing-ws"),
Edits: []workspacesdk.FileEdit{
{
Search: "foo\nbar\nbaz",
Replace: "replaced",
},
},
},
},
expected: map[string]string{filepath.Join(tmpdir, "trailing-ws"): "replaced"},
},
{
name: "TabsVsSpaces",
contents: map[string]string{filepath.Join(tmpdir, "tabs-vs-spaces"): "\tif true {\n\t\tfoo()\n\t}"},
edits: []workspacesdk.FileEdits{
{
Path: filepath.Join(tmpdir, "tabs-vs-spaces"),
Edits: []workspacesdk.FileEdit{
{
// Search uses spaces but file uses tabs.
Search: " if true {\n foo()\n }",
Replace: "\tif true {\n\t\tbar()\n\t}",
},
},
},
},
expected: map[string]string{filepath.Join(tmpdir, "tabs-vs-spaces"): "\tif true {\n\t\tbar()\n\t}"},
},
{
name: "DifferentIndentDepth",
contents: map[string]string{filepath.Join(tmpdir, "indent-depth"): "\t\t\tdeep()\n\t\t\tnested()"},
edits: []workspacesdk.FileEdits{
{
Path: filepath.Join(tmpdir, "indent-depth"),
Edits: []workspacesdk.FileEdit{
{
// Search has wrong indent depth (1 tab instead of 3).
Search: "\tdeep()\n\tnested()",
Replace: "\t\t\tdeep()\n\t\t\tchanged()",
},
},
},
},
expected: map[string]string{filepath.Join(tmpdir, "indent-depth"): "\t\t\tdeep()\n\t\t\tchanged()"},
},
{
name: "ExactMatchPreferred",
contents: map[string]string{filepath.Join(tmpdir, "exact-preferred"): "hello world"},
edits: []workspacesdk.FileEdits{
{
Path: filepath.Join(tmpdir, "exact-preferred"),
Edits: []workspacesdk.FileEdit{
{
Search: "hello world",
Replace: "goodbye world",
},
},
},
},
expected: map[string]string{filepath.Join(tmpdir, "exact-preferred"): "goodbye world"},
},
{
name: "NoMatchErrors",
contents: map[string]string{filepath.Join(tmpdir, "no-match"): "original content"},
edits: []workspacesdk.FileEdits{
{
Path: filepath.Join(tmpdir, "no-match"),
Edits: []workspacesdk.FileEdit{
{
Search: "this does not exist in the file",
Replace: "whatever",
},
},
},
},
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"},
},
{
// replace_all with fuzzy trailing-whitespace match.
name: "ReplaceAllFuzzyTrailing",
contents: map[string]string{filepath.Join(tmpdir, "ra-fuzzy-trail"): "hello \nworld\nhello \nagain"},
edits: []workspacesdk.FileEdits{
{
Path: filepath.Join(tmpdir, "ra-fuzzy-trail"),
Edits: []workspacesdk.FileEdit{
{
Search: "hello\n",
Replace: "bye\n",
ReplaceAll: true,
},
},
},
},
expected: map[string]string{filepath.Join(tmpdir, "ra-fuzzy-trail"): "bye\nworld\nbye\nagain"},
},
{
// replace_all with fuzzy indent match (pass 3).
name: "ReplaceAllFuzzyIndent",
contents: map[string]string{filepath.Join(tmpdir, "ra-fuzzy-indent"): "\t\talpha\n\t\tbeta\n\t\talpha\n\t\tgamma"},
edits: []workspacesdk.FileEdits{
{
Path: filepath.Join(tmpdir, "ra-fuzzy-indent"),
Edits: []workspacesdk.FileEdit{
{
// Search uses different indentation (spaces instead of tabs).
Search: " alpha\n",
Replace: "\t\tREPLACED\n",
ReplaceAll: true,
},
},
},
},
expected: map[string]string{filepath.Join(tmpdir, "ra-fuzzy-indent"): "\t\tREPLACED\n\t\tbeta\n\t\tREPLACED\n\t\tgamma"},
},
{
name: "MixedWhitespaceMultiline",
contents: map[string]string{filepath.Join(tmpdir, "mixed-ws"): "func main() {\n\tresult := compute()\n\tfmt.Println(result)\n}"},
edits: []workspacesdk.FileEdits{
{
Path: filepath.Join(tmpdir, "mixed-ws"),
Edits: []workspacesdk.FileEdit{
{
// Search uses spaces, file uses tabs.
Search: " result := compute()\n fmt.Println(result)\n",
Replace: "\tresult := compute()\n\tlog.Println(result)\n",
},
},
},
},
expected: map[string]string{filepath.Join(tmpdir, "mixed-ws"): "func main() {\n\tresult := compute()\n\tlog.Println(result)\n}"},
},
{
name: "MultiError",
contents: map[string]string{
@@ -969,10 +683,8 @@ func TestEditFiles(t *testing.T) {
},
},
},
// No files should be modified when any edit fails
// (atomic multi-file semantics).
expected: map[string]string{
filepath.Join(tmpdir, "file8"): "file 8",
filepath.Join(tmpdir, "file8"): "edited8 8",
},
// Higher status codes will override lower ones, so in this case the 404
// takes priority over the 403.
@@ -982,44 +694,8 @@ func TestEditFiles(t *testing.T) {
"file9: file does not exist",
},
},
{
// Valid edits on files A and C, but file B has a
// search miss. None should be written.
name: "AtomicMultiFile_OneFailsNoneWritten",
contents: map[string]string{
filepath.Join(tmpdir, "atomic-a"): "aaa",
filepath.Join(tmpdir, "atomic-b"): "bbb",
filepath.Join(tmpdir, "atomic-c"): "ccc",
},
edits: []workspacesdk.FileEdits{
{
Path: filepath.Join(tmpdir, "atomic-a"),
Edits: []workspacesdk.FileEdit{
{Search: "aaa", Replace: "AAA"},
},
},
{
Path: filepath.Join(tmpdir, "atomic-b"),
Edits: []workspacesdk.FileEdit{
{Search: "NOTFOUND", Replace: "XXX"},
},
},
{
Path: filepath.Join(tmpdir, "atomic-c"),
Edits: []workspacesdk.FileEdit{
{Search: "ccc", Replace: "CCC"},
},
},
},
errCode: http.StatusBadRequest,
errors: []string{"search string not found"},
expected: map[string]string{
filepath.Join(tmpdir, "atomic-a"): "aaa",
filepath.Join(tmpdir, "atomic-b"): "bbb",
filepath.Join(tmpdir, "atomic-c"): "ccc",
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
@@ -1061,514 +737,3 @@ 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()
pathStore := agentgit.NewPathStore()
logger := slogtest.Make(t, nil)
fs := afero.NewMemMapFs()
api := agentfiles.NewAPI(logger, fs, pathStore)
testPath := filepath.Join(os.TempDir(), "test.txt")
chatID := uuid.New()
ancestorID := uuid.New()
ancestorJSON, _ := json.Marshal([]string{ancestorID.String()})
body := strings.NewReader("hello world")
req := httptest.NewRequest(http.MethodPost, "/write-file?path="+testPath, body)
req.Header.Set(workspacesdk.CoderChatIDHeader, chatID.String())
req.Header.Set(workspacesdk.CoderAncestorChatIDsHeader, string(ancestorJSON))
rr := httptest.NewRecorder()
r := chi.NewRouter()
r.Post("/write-file", api.HandleWriteFile)
r.ServeHTTP(rr, req)
require.Equal(t, http.StatusOK, rr.Code)
// Verify PathStore was updated for both chat and ancestor.
paths := pathStore.GetPaths(chatID)
require.Equal(t, []string{testPath}, paths)
ancestorPaths := pathStore.GetPaths(ancestorID)
require.Equal(t, []string{testPath}, ancestorPaths)
}
func TestHandleWriteFile_NoChatHeaders_NoPathStoreUpdate(t *testing.T) {
t.Parallel()
pathStore := agentgit.NewPathStore()
logger := slogtest.Make(t, nil)
fs := afero.NewMemMapFs()
api := agentfiles.NewAPI(logger, fs, pathStore)
testPath := filepath.Join(os.TempDir(), "test.txt")
body := strings.NewReader("hello world")
req := httptest.NewRequest(http.MethodPost, "/write-file?path="+testPath, body)
rr := httptest.NewRecorder()
r := chi.NewRouter()
r.Post("/write-file", api.HandleWriteFile)
r.ServeHTTP(rr, req)
require.Equal(t, http.StatusOK, rr.Code)
// PathStore should be globally empty since no chat headers were set.
require.Equal(t, 0, pathStore.Len())
}
func TestHandleWriteFile_Failure_NoPathStoreUpdate(t *testing.T) {
t.Parallel()
pathStore := agentgit.NewPathStore()
logger := slogtest.Make(t, nil)
fs := afero.NewMemMapFs()
api := agentfiles.NewAPI(logger, fs, pathStore)
chatID := uuid.New()
// Write to a relative path (should fail with 400).
body := strings.NewReader("hello world")
req := httptest.NewRequest(http.MethodPost, "/write-file?path=relative/path.txt", body)
req.Header.Set(workspacesdk.CoderChatIDHeader, chatID.String())
rr := httptest.NewRecorder()
r := chi.NewRouter()
r.Post("/write-file", api.HandleWriteFile)
r.ServeHTTP(rr, req)
require.Equal(t, http.StatusBadRequest, rr.Code)
// PathStore should NOT be updated on failure.
paths := pathStore.GetPaths(chatID)
require.Empty(t, paths)
}
func TestHandleEditFiles_ChatHeaders_UpdatesPathStore(t *testing.T) {
t.Parallel()
pathStore := agentgit.NewPathStore()
logger := slogtest.Make(t, nil)
fs := afero.NewMemMapFs()
api := agentfiles.NewAPI(logger, fs, pathStore)
testPath := filepath.Join(os.TempDir(), "test.txt")
// Create the file first.
require.NoError(t, afero.WriteFile(fs, testPath, []byte("hello"), 0o644))
chatID := uuid.New()
editReq := workspacesdk.FileEditRequest{
Files: []workspacesdk.FileEdits{
{
Path: testPath,
Edits: []workspacesdk.FileEdit{
{Search: "hello", Replace: "world"},
},
},
},
}
body, _ := json.Marshal(editReq)
req := httptest.NewRequest(http.MethodPost, "/edit-files", bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
req.Header.Set(workspacesdk.CoderChatIDHeader, chatID.String())
rr := httptest.NewRecorder()
r := chi.NewRouter()
r.Post("/edit-files", api.HandleEditFiles)
r.ServeHTTP(rr, req)
require.Equal(t, http.StatusOK, rr.Code)
paths := pathStore.GetPaths(chatID)
require.Equal(t, []string{testPath}, paths)
}
func TestHandleEditFiles_Failure_NoPathStoreUpdate(t *testing.T) {
t.Parallel()
pathStore := agentgit.NewPathStore()
logger := slogtest.Make(t, nil)
fs := afero.NewMemMapFs()
api := agentfiles.NewAPI(logger, fs, pathStore)
chatID := uuid.New()
// Edit a non-existent file (should fail with 404).
editReq := workspacesdk.FileEditRequest{
Files: []workspacesdk.FileEdits{
{
Path: "/nonexistent/file.txt",
Edits: []workspacesdk.FileEdit{
{Search: "hello", Replace: "world"},
},
},
},
}
body, _ := json.Marshal(editReq)
req := httptest.NewRequest(http.MethodPost, "/edit-files", bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
req.Header.Set(workspacesdk.CoderChatIDHeader, chatID.String())
rr := httptest.NewRecorder()
r := chi.NewRouter()
r.Post("/edit-files", api.HandleEditFiles)
r.ServeHTTP(rr, req)
require.NotEqual(t, http.StatusOK, rr.Code)
// PathStore should NOT be updated on failure.
paths := pathStore.GetPaths(chatID)
require.Empty(t, paths)
}
func TestReadFileLines(t *testing.T) {
t.Parallel()
tmpdir := os.TempDir()
noPermsFilePath := filepath.Join(tmpdir, "no-perms-lines")
logger := slogtest.Make(t, &slogtest.Options{IgnoreErrors: true}).Leveled(slog.LevelDebug)
fs := newTestFs(afero.NewMemMapFs(), func(call, file string) error {
if file == noPermsFilePath {
return os.ErrPermission
}
return nil
})
api := agentfiles.NewAPI(logger, fs, nil)
dirPath := filepath.Join(tmpdir, "a-directory-lines")
err := fs.MkdirAll(dirPath, 0o755)
require.NoError(t, err)
emptyFilePath := filepath.Join(tmpdir, "empty-file")
err = afero.WriteFile(fs, emptyFilePath, []byte(""), 0o644)
require.NoError(t, err)
basicFilePath := filepath.Join(tmpdir, "basic-file")
err = afero.WriteFile(fs, basicFilePath, []byte("line1\nline2\nline3"), 0o644)
require.NoError(t, err)
longLine := string(bytes.Repeat([]byte("x"), 1025))
longLineFilePath := filepath.Join(tmpdir, "long-line-file")
err = afero.WriteFile(fs, longLineFilePath, []byte(longLine), 0o644)
require.NoError(t, err)
largeFilePath := filepath.Join(tmpdir, "large-file")
err = afero.WriteFile(fs, largeFilePath, bytes.Repeat([]byte("x"), 1<<20+1), 0o644)
require.NoError(t, err)
tests := []struct {
name string
path string
offset int64
limit int64
expSuccess bool
expError string
expContent string
expTotal int
expRead int
expSize int64
// useCodersdk is set for cases where the handler returns
// codersdk.Response (query param validation) instead of ReadFileLinesResponse.
useCodersdk bool
}{
{
name: "NoPath",
path: "",
useCodersdk: true,
expError: "is required",
},
{
name: "RelativePath",
path: "relative/path",
expError: "file path must be absolute",
},
{
name: "NonExistent",
path: filepath.Join(tmpdir, "does-not-exist"),
expError: "file does not exist",
},
{
name: "IsDir",
path: dirPath,
expError: "not a file",
},
{
name: "NoPermissions",
path: noPermsFilePath,
expError: "permission denied",
},
{
name: "EmptyFile",
path: emptyFilePath,
expSuccess: true,
expTotal: 0,
expRead: 0,
expSize: 0,
},
{
name: "BasicRead",
path: basicFilePath,
expSuccess: true,
expContent: "1\tline1\n2\tline2\n3\tline3",
expTotal: 3,
expRead: 3,
expSize: int64(len("line1\nline2\nline3")),
},
{
name: "Offset2",
path: basicFilePath,
offset: 2,
expSuccess: true,
expContent: "2\tline2\n3\tline3",
expTotal: 3,
expRead: 2,
expSize: int64(len("line1\nline2\nline3")),
},
{
name: "Limit1",
path: basicFilePath,
limit: 1,
expSuccess: true,
expContent: "1\tline1",
expTotal: 3,
expRead: 1,
expSize: int64(len("line1\nline2\nline3")),
},
{
name: "Offset2Limit1",
path: basicFilePath,
offset: 2,
limit: 1,
expSuccess: true,
expContent: "2\tline2",
expTotal: 3,
expRead: 1,
expSize: int64(len("line1\nline2\nline3")),
},
{
name: "OffsetBeyondFile",
path: basicFilePath,
offset: 100,
expError: "offset 100 is beyond the file length of 3 lines",
},
{
name: "LongLineTruncation",
path: longLineFilePath,
expSuccess: true,
expContent: "1\t" + string(bytes.Repeat([]byte("x"), 1024)) + "... [truncated]",
expTotal: 1,
expRead: 1,
expSize: 1025,
},
{
name: "LargeFile",
path: largeFilePath,
expError: "exceeds the maximum",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
defer cancel()
w := httptest.NewRecorder()
r := httptest.NewRequestWithContext(ctx, http.MethodGet, fmt.Sprintf("/read-file-lines?path=%s&offset=%d&limit=%d", tt.path, tt.offset, tt.limit), nil)
api.Routes().ServeHTTP(w, r)
if tt.useCodersdk {
// Query param validation errors return codersdk.Response.
require.Equal(t, http.StatusBadRequest, w.Code)
require.Contains(t, w.Body.String(), tt.expError)
return
}
var resp agentfiles.ReadFileLinesResponse
err := json.NewDecoder(w.Body).Decode(&resp)
require.NoError(t, err)
if tt.expSuccess {
require.Equal(t, http.StatusOK, w.Code)
require.True(t, resp.Success)
require.Equal(t, tt.expContent, resp.Content)
require.Equal(t, tt.expTotal, resp.TotalLines)
require.Equal(t, tt.expRead, resp.LinesRead)
require.Equal(t, tt.expSize, resp.FileSize)
} else {
require.Equal(t, http.StatusOK, w.Code)
require.False(t, resp.Success)
require.Contains(t, resp.Error, tt.expError)
}
})
}
}
func TestWriteFile_FollowsSymlinks(t *testing.T) {
t.Parallel()
if runtime.GOOS == "windows" {
t.Skip("symlinks 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)
// Create a real file and a symlink pointing to it.
realPath := filepath.Join(dir, "real.txt")
err := afero.WriteFile(osFs, realPath, []byte("original"), 0o644)
require.NoError(t, err)
linkPath := filepath.Join(dir, "link.txt")
err = os.Symlink(realPath, linkPath)
require.NoError(t, err)
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitShort)
defer cancel()
// Write through the symlink.
w := httptest.NewRecorder()
r := httptest.NewRequestWithContext(ctx, http.MethodPost,
fmt.Sprintf("/write-file?path=%s", linkPath),
bytes.NewReader([]byte("updated")))
api.Routes().ServeHTTP(w, r)
require.Equal(t, http.StatusOK, w.Code)
// The symlink must still be a symlink.
fi, err := os.Lstat(linkPath)
require.NoError(t, err)
require.NotZero(t, fi.Mode()&os.ModeSymlink, "symlink was replaced")
// The real file must have the new content.
data, err := os.ReadFile(realPath)
require.NoError(t, err)
require.Equal(t, "updated", string(data))
}
func TestEditFiles_FollowsSymlinks(t *testing.T) {
t.Parallel()
if runtime.GOOS == "windows" {
t.Skip("symlinks 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)
// Create a real file and a symlink pointing to it.
realPath := filepath.Join(dir, "real.txt")
err := afero.WriteFile(osFs, realPath, []byte("hello world"), 0o644)
require.NoError(t, err)
linkPath := filepath.Join(dir, "link.txt")
err = os.Symlink(realPath, linkPath)
require.NoError(t, err)
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitShort)
defer cancel()
body := workspacesdk.FileEditRequest{
Files: []workspacesdk.FileEdits{
{
Path: linkPath,
Edits: []workspacesdk.FileEdit{
{
Search: "hello",
Replace: "goodbye",
},
},
},
},
}
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)
// The symlink must still be a symlink.
fi, err := os.Lstat(linkPath)
require.NoError(t, err)
require.NotZero(t, fi.Mode()&os.ModeSymlink, "symlink was replaced")
// The real file must have the edited content.
data, err := os.ReadFile(realPath)
require.NoError(t, err)
require.Equal(t, "goodbye world", string(data))
}
-441
View File
@@ -1,441 +0,0 @@
// Package agentgit provides a WebSocket-based service for watching git
// repository changes on the agent. It is mounted at /api/v0/git/watch
// and allows clients to subscribe to file paths, triggering scans of
// the corresponding git repositories.
package agentgit
import (
"bytes"
"context"
"os"
"os/exec"
"path/filepath"
"strings"
"sync"
"time"
"github.com/dustin/go-humanize"
"golang.org/x/xerrors"
"cdr.dev/slog/v3"
"github.com/coder/coder/v2/codersdk"
"github.com/coder/quartz"
)
// Option configures the git watch service.
type Option func(*Handler)
// WithClock sets a controllable clock for testing. Defaults to
// quartz.NewReal().
func WithClock(c quartz.Clock) Option {
return func(h *Handler) {
h.clock = c
}
}
// WithGitBinary overrides the git binary path (for testing).
func WithGitBinary(path string) Option {
return func(h *Handler) {
h.gitBin = path
}
}
const (
// scanCooldown is the minimum interval between successive scans.
scanCooldown = 1 * time.Second
// fallbackPollInterval is the safety-net poll period used when no
// filesystem events arrive.
fallbackPollInterval = 30 * time.Second
// maxTotalDiffSize is the maximum size of the combined
// unified diff for an entire repository sent over the wire.
// This must stay under the WebSocket message size limit.
maxTotalDiffSize = 3 * 1024 * 1024 // 3 MiB
)
// Handler manages per-connection git watch state.
type Handler struct {
logger slog.Logger
clock quartz.Clock
gitBin string // path to git binary; empty means "git" (from PATH)
mu sync.Mutex
repoRoots map[string]struct{} // watched repo roots
lastSnapshots map[string]repoSnapshot // last emitted snapshot per repo
lastScanAt time.Time // when the last scan completed
scanTrigger chan struct{} // buffered(1), poked by triggers
}
// repoSnapshot captures the last emitted state for delta comparison.
type repoSnapshot struct {
branch string
remoteOrigin string
unifiedDiff string
}
// NewHandler creates a new git watch handler.
func NewHandler(logger slog.Logger, opts ...Option) *Handler {
h := &Handler{
logger: logger,
clock: quartz.NewReal(),
gitBin: "git",
repoRoots: make(map[string]struct{}),
lastSnapshots: make(map[string]repoSnapshot),
scanTrigger: make(chan struct{}, 1),
}
for _, opt := range opts {
opt(h)
}
// Check if git is available.
if _, err := exec.LookPath(h.gitBin); err != nil {
h.logger.Warn(context.Background(), "git binary not found, git scanning disabled")
}
return h
}
// gitAvailable returns true if the configured git binary can be found
// in PATH.
func (h *Handler) gitAvailable() bool {
_, err := exec.LookPath(h.gitBin)
return err == nil
}
// Subscribe processes a subscribe message, resolving paths to git repo
// roots and adding new repos to the watch set. Returns true if any new
// repo roots were added.
func (h *Handler) Subscribe(paths []string) bool {
if !h.gitAvailable() {
return false
}
h.mu.Lock()
defer h.mu.Unlock()
added := false
for _, p := range paths {
if !filepath.IsAbs(p) {
continue
}
p = filepath.Clean(p)
root, err := findRepoRoot(h.gitBin, p)
if err != nil {
// Not a git path — silently ignore.
continue
}
if _, ok := h.repoRoots[root]; ok {
continue
}
h.repoRoots[root] = struct{}{}
added = true
}
return added
}
// RequestScan pokes the scan trigger so the run loop performs a scan.
func (h *Handler) RequestScan() {
select {
case h.scanTrigger <- struct{}{}:
default:
// Already pending.
}
}
// Scan performs a scan of all subscribed repos and computes deltas
// against the previously emitted snapshots.
func (h *Handler) Scan(ctx context.Context) *codersdk.WorkspaceAgentGitServerMessage {
if !h.gitAvailable() {
return nil
}
h.mu.Lock()
roots := make([]string, 0, len(h.repoRoots))
for r := range h.repoRoots {
roots = append(roots, r)
}
h.mu.Unlock()
if len(roots) == 0 {
return nil
}
now := h.clock.Now().UTC()
var repos []codersdk.WorkspaceAgentRepoChanges
// Perform all I/O outside the lock to avoid blocking
// AddPaths/GetPaths/Subscribe callers during disk-heavy scans.
type scanResult struct {
root string
changes codersdk.WorkspaceAgentRepoChanges
err error
}
results := make([]scanResult, 0, len(roots))
for _, root := range roots {
changes, err := getRepoChanges(ctx, h.logger, h.gitBin, root)
results = append(results, scanResult{root: root, changes: changes, err: err})
}
// Re-acquire the lock only to commit snapshot updates.
h.mu.Lock()
defer h.mu.Unlock()
for _, res := range results {
if res.err != nil {
if isRepoDeleted(h.gitBin, res.root) {
// Repo root or .git directory was removed.
// Emit a removal entry, then evict from watch set.
removal := codersdk.WorkspaceAgentRepoChanges{
RepoRoot: res.root,
Removed: true,
}
delete(h.repoRoots, res.root)
delete(h.lastSnapshots, res.root)
repos = append(repos, removal)
} else {
// Transient error — log and skip without
// removing the repo from the watch set.
h.logger.Warn(ctx, "scan repo failed",
slog.F("root", res.root),
slog.Error(res.err),
)
}
continue
}
prev, hasPrev := h.lastSnapshots[res.root]
if hasPrev &&
prev.branch == res.changes.Branch &&
prev.remoteOrigin == res.changes.RemoteOrigin &&
prev.unifiedDiff == res.changes.UnifiedDiff {
// No change in this repo since last emit.
continue
}
// Update snapshot.
h.lastSnapshots[res.root] = repoSnapshot{
branch: res.changes.Branch,
remoteOrigin: res.changes.RemoteOrigin,
unifiedDiff: res.changes.UnifiedDiff,
}
repos = append(repos, res.changes)
}
h.lastScanAt = now
if len(repos) == 0 {
return nil
}
return &codersdk.WorkspaceAgentGitServerMessage{
Type: codersdk.WorkspaceAgentGitServerMessageTypeChanges,
ScannedAt: &now,
Repositories: repos,
}
}
// RunLoop runs the main event loop that listens for refresh requests
// and fallback poll ticks. It calls scanFn whenever a scan should
// happen (rate-limited to scanCooldown). It blocks until ctx is
// canceled.
func (h *Handler) RunLoop(ctx context.Context, scanFn func()) {
fallbackTicker := h.clock.NewTicker(fallbackPollInterval)
defer fallbackTicker.Stop()
for {
select {
case <-ctx.Done():
return
case <-h.scanTrigger:
h.rateLimitedScan(ctx, scanFn)
case <-fallbackTicker.C:
h.rateLimitedScan(ctx, scanFn)
}
}
}
func (h *Handler) rateLimitedScan(ctx context.Context, scanFn func()) {
h.mu.Lock()
elapsed := h.clock.Since(h.lastScanAt)
if elapsed < scanCooldown {
h.mu.Unlock()
// Wait for cooldown then scan.
remaining := scanCooldown - elapsed
timer := h.clock.NewTimer(remaining)
defer timer.Stop()
select {
case <-ctx.Done():
return
case <-timer.C:
}
scanFn()
return
}
h.mu.Unlock()
scanFn()
}
// isRepoDeleted returns true when the repo root directory or its .git
// entry no longer represents a valid git repository. This
// distinguishes a genuine repo deletion from a transient scan error
// (e.g. lock contention).
//
// It handles three deletion cases:
// 1. The repo root directory itself was removed.
// 2. The .git entry (directory or file) was removed.
// 3. The .git entry is a file (worktree/submodule) whose target
// gitdir was removed. In this case .git exists on disk but
// `git rev-parse --git-dir` fails because the referenced
// directory is gone.
func isRepoDeleted(gitBin string, repoRoot string) bool {
if _, err := os.Stat(repoRoot); os.IsNotExist(err) {
return true
}
gitPath := filepath.Join(repoRoot, ".git")
fi, err := os.Stat(gitPath)
if os.IsNotExist(err) {
return true
}
// If .git is a regular file (worktree or submodule), the actual
// git object store lives elsewhere. Validate that the target is
// still reachable by running git rev-parse.
if err == nil && !fi.IsDir() {
cmd := exec.CommandContext(context.Background(), gitBin, "-C", repoRoot, "rev-parse", "--git-dir")
if err := cmd.Run(); err != nil {
return true
}
}
return false
}
// findRepoRoot uses `git rev-parse --show-toplevel` to find the
// repository root for the given path.
func findRepoRoot(gitBin string, p string) (string, error) {
// If p is a file, start from its parent directory.
dir := p
if info, err := os.Stat(dir); err != nil || !info.IsDir() {
dir = filepath.Dir(dir)
}
cmd := exec.CommandContext(context.Background(), gitBin, "rev-parse", "--show-toplevel")
cmd.Dir = dir
out, err := cmd.Output()
if err != nil {
return "", xerrors.Errorf("no git repo found for %s", p)
}
root := filepath.FromSlash(strings.TrimSpace(string(out)))
// Resolve symlinks and short (8.3) names on Windows so the
// returned root matches paths produced by Go's filepath APIs.
if resolved, evalErr := filepath.EvalSymlinks(root); evalErr == nil {
root = resolved
}
return root, nil
}
// getRepoChanges reads the current state of a git repository using
// the git CLI. It returns branch, remote origin, and a unified diff.
func getRepoChanges(ctx context.Context, logger slog.Logger, gitBin string, repoRoot string) (codersdk.WorkspaceAgentRepoChanges, error) {
result := codersdk.WorkspaceAgentRepoChanges{
RepoRoot: repoRoot,
}
// Verify this is still a valid git repository before doing
// anything else. This catches deleted repos early.
verifyCmd := exec.CommandContext(ctx, gitBin, "-C", repoRoot, "rev-parse", "--git-dir")
if err := verifyCmd.Run(); err != nil {
return result, xerrors.Errorf("not a git repository: %w", err)
}
// Read branch name.
branchCmd := exec.CommandContext(ctx, gitBin, "-C", repoRoot, "symbolic-ref", "--short", "HEAD")
if out, err := branchCmd.Output(); err == nil {
result.Branch = strings.TrimSpace(string(out))
} else {
logger.Debug(ctx, "failed to read HEAD", slog.F("root", repoRoot), slog.Error(err))
}
// Read remote origin URL.
remoteCmd := exec.CommandContext(ctx, gitBin, "-C", repoRoot, "config", "--get", "remote.origin.url")
if out, err := remoteCmd.Output(); err == nil {
result.RemoteOrigin = strings.TrimSpace(string(out))
}
// Compute unified diff.
// `git diff HEAD` shows both staged and unstaged changes vs HEAD.
// For repos with no commits yet, fall back to showing untracked
// files only.
diff, err := computeGitDiff(ctx, logger, gitBin, repoRoot)
if err != nil {
return result, xerrors.Errorf("compute diff: %w", err)
}
result.UnifiedDiff = diff
if len(result.UnifiedDiff) > maxTotalDiffSize {
result.UnifiedDiff = "Total diff too large to show. Size: " + humanize.IBytes(uint64(len(result.UnifiedDiff))) + ". Showing branch and remote only."
}
return result, nil
}
// computeGitDiff produces a unified diff string for the repository by
// combining `git diff HEAD` (staged + unstaged changes) with diffs
// for untracked files.
func computeGitDiff(ctx context.Context, logger slog.Logger, gitBin string, repoRoot string) (string, error) {
var diffParts []string
// Check if the repo has any commits.
hasCommits := true
checkCmd := exec.CommandContext(ctx, gitBin, "-C", repoRoot, "rev-parse", "HEAD")
if err := checkCmd.Run(); err != nil {
hasCommits = false
}
if hasCommits {
// `git diff HEAD` captures both staged and unstaged changes
// relative to HEAD in a single unified diff.
cmd := exec.CommandContext(ctx, gitBin, "-C", repoRoot, "diff", "HEAD")
out, err := cmd.Output()
if err != nil {
return "", xerrors.Errorf("git diff HEAD: %w", err)
}
if len(out) > 0 {
diffParts = append(diffParts, string(out))
}
}
// Show untracked files as diffs too.
// `git ls-files --others --exclude-standard` lists untracked,
// non-ignored files.
lsCmd := exec.CommandContext(ctx, gitBin, "-C", repoRoot, "ls-files", "--others", "--exclude-standard")
lsOut, err := lsCmd.Output()
if err != nil {
logger.Debug(ctx, "failed to list untracked files", slog.F("root", repoRoot), slog.Error(err))
return strings.Join(diffParts, ""), nil
}
untrackedFiles := strings.Split(strings.TrimSpace(string(lsOut)), "\n")
for _, f := range untrackedFiles {
f = strings.TrimSpace(f)
if f == "" {
continue
}
// Use `git diff --no-index /dev/null <file>` to generate
// a unified diff for untracked files.
var stdout bytes.Buffer
untrackedCmd := exec.CommandContext(ctx, gitBin, "-C", repoRoot, "diff", "--no-index", "--", "/dev/null", f)
untrackedCmd.Stdout = &stdout
// git diff --no-index exits with 1 when files differ,
// which is expected. We ignore the error and check for
// output instead.
_ = untrackedCmd.Run()
if stdout.Len() > 0 {
diffParts = append(diffParts, stdout.String())
}
}
return strings.Join(diffParts, ""), nil
}
File diff suppressed because it is too large Load Diff
-147
View File
@@ -1,147 +0,0 @@
package agentgit
import (
"context"
"net/http"
"github.com/go-chi/chi/v5"
"github.com/google/uuid"
"cdr.dev/slog/v3"
"github.com/coder/coder/v2/coderd/httpapi"
"github.com/coder/coder/v2/codersdk"
"github.com/coder/coder/v2/codersdk/wsjson"
"github.com/coder/websocket"
)
// API exposes the git watch HTTP routes for the agent.
type API struct {
logger slog.Logger
opts []Option
pathStore *PathStore
}
// NewAPI creates a new git watch API.
func NewAPI(logger slog.Logger, pathStore *PathStore, opts ...Option) *API {
return &API{
logger: logger,
pathStore: pathStore,
opts: opts,
}
}
// Routes returns the chi router for mounting at /api/v0/git.
func (a *API) Routes() http.Handler {
r := chi.NewRouter()
r.Get("/watch", a.handleWatch)
return r
}
func (a *API) handleWatch(rw http.ResponseWriter, r *http.Request) {
ctx := r.Context()
conn, err := websocket.Accept(rw, r, &websocket.AcceptOptions{
CompressionMode: websocket.CompressionNoContextTakeover,
})
if err != nil {
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
Message: "Failed to accept WebSocket.",
Detail: err.Error(),
})
return
}
// 4 MiB read limit — subscribe messages with many paths can exceed the
// default 32 KB limit. Matches the SDK/proxy side.
conn.SetReadLimit(1 << 22)
stream := wsjson.NewStream[
codersdk.WorkspaceAgentGitClientMessage,
codersdk.WorkspaceAgentGitServerMessage,
](conn, websocket.MessageText, websocket.MessageText, a.logger)
ctx, cancel := context.WithCancel(ctx)
defer cancel()
go httpapi.HeartbeatClose(ctx, a.logger, cancel, conn)
handler := NewHandler(a.logger, a.opts...)
// scanAndSend performs a scan and sends results if there are
// changes.
scanAndSend := func() {
msg := handler.Scan(ctx)
if msg != nil {
if err := stream.Send(*msg); err != nil {
a.logger.Debug(ctx, "failed to send changes", slog.Error(err))
cancel()
}
}
}
// If a chat_id query parameter is provided and the PathStore is
// available, subscribe to path updates for this chat.
chatIDStr := r.URL.Query().Get("chat_id")
if chatIDStr != "" && a.pathStore != nil {
chatID, parseErr := uuid.Parse(chatIDStr)
if parseErr == nil {
// Subscribe to future path updates BEFORE reading
// existing paths. This ordering guarantees no
// notification from AddPaths is lost: any call that
// lands before Subscribe is picked up by GetPaths
// below, and any call after Subscribe delivers a
// notification on the channel.
notifyCh, unsubscribe := a.pathStore.Subscribe(chatID)
defer unsubscribe()
// Load any paths that are already tracked for this chat.
existingPaths := a.pathStore.GetPaths(chatID)
if len(existingPaths) > 0 {
handler.Subscribe(existingPaths)
handler.RequestScan()
}
go func() {
for {
select {
case <-ctx.Done():
return
case <-notifyCh:
paths := a.pathStore.GetPaths(chatID)
handler.Subscribe(paths)
handler.RequestScan()
}
}
}()
}
}
// Start the main run loop in a goroutine.
go handler.RunLoop(ctx, scanAndSend)
// Read client messages.
updates := stream.Chan()
for {
select {
case <-ctx.Done():
_ = stream.Close(websocket.StatusGoingAway)
return
case msg, ok := <-updates:
if !ok {
return
}
switch msg.Type {
case codersdk.WorkspaceAgentGitClientMessageTypeRefresh:
handler.RequestScan()
default:
if err := stream.Send(codersdk.WorkspaceAgentGitServerMessage{
Type: codersdk.WorkspaceAgentGitServerMessageTypeError,
Message: "unknown message type",
}); err != nil {
return
}
}
}
}
}
-35
View File
@@ -1,35 +0,0 @@
package agentgit
import (
"encoding/json"
"net/http"
"github.com/google/uuid"
"github.com/coder/coder/v2/codersdk/workspacesdk"
)
// ExtractChatContext reads chat identity headers from the request.
// Returns zero values if headers are absent (non-chat request).
func ExtractChatContext(r *http.Request) (chatID uuid.UUID, ancestorIDs []uuid.UUID, ok bool) {
raw := r.Header.Get(workspacesdk.CoderChatIDHeader)
if raw == "" {
return uuid.Nil, nil, false
}
chatID, err := uuid.Parse(raw)
if err != nil {
return uuid.Nil, nil, false
}
rawAncestors := r.Header.Get(workspacesdk.CoderAncestorChatIDsHeader)
if rawAncestors != "" {
var ids []string
if err := json.Unmarshal([]byte(rawAncestors), &ids); err == nil {
for _, s := range ids {
if id, err := uuid.Parse(s); err == nil {
ancestorIDs = append(ancestorIDs, id)
}
}
}
}
return chatID, ancestorIDs, true
}
-148
View File
@@ -1,148 +0,0 @@
package agentgit_test
import (
"encoding/json"
"net/http/httptest"
"testing"
"github.com/google/uuid"
"github.com/stretchr/testify/require"
"github.com/coder/coder/v2/agent/agentgit"
"github.com/coder/coder/v2/codersdk/workspacesdk"
)
func TestExtractChatContext(t *testing.T) {
t.Parallel()
validID := uuid.MustParse("aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee")
ancestor1 := uuid.MustParse("11111111-2222-3333-4444-555555555555")
ancestor2 := uuid.MustParse("66666666-7777-8888-9999-aaaaaaaaaaaa")
tests := []struct {
name string
chatID string // empty means header not set
setChatID bool // whether to set the chat ID header at all
ancestors string // empty means header not set
setAncestors bool // whether to set the ancestor header at all
wantChatID uuid.UUID
wantAncestorIDs []uuid.UUID
wantOK bool
}{
{
name: "NoHeadersPresent",
setChatID: false,
setAncestors: false,
wantChatID: uuid.Nil,
wantAncestorIDs: nil,
wantOK: false,
},
{
name: "ValidChatID_NoAncestors",
chatID: validID.String(),
setChatID: true,
setAncestors: false,
wantChatID: validID,
wantAncestorIDs: nil,
wantOK: true,
},
{
name: "ValidChatID_ValidAncestors",
chatID: validID.String(),
setChatID: true,
ancestors: mustMarshalJSON(t, []string{
ancestor1.String(),
ancestor2.String(),
}),
setAncestors: true,
wantChatID: validID,
wantAncestorIDs: []uuid.UUID{ancestor1, ancestor2},
wantOK: true,
},
{
name: "MalformedChatID",
chatID: "not-a-uuid",
setChatID: true,
setAncestors: false,
wantChatID: uuid.Nil,
wantAncestorIDs: nil,
wantOK: false,
},
{
name: "ValidChatID_MalformedAncestorJSON",
chatID: validID.String(),
setChatID: true,
ancestors: `{this is not json}`,
setAncestors: true,
wantChatID: validID,
wantAncestorIDs: nil,
wantOK: true,
},
{
// Only valid UUIDs in the array are returned; invalid
// entries are silently skipped.
name: "ValidChatID_PartialValidAncestorUUIDs",
chatID: validID.String(),
setChatID: true,
ancestors: mustMarshalJSON(t, []string{
ancestor1.String(),
"bad-uuid",
ancestor2.String(),
}),
setAncestors: true,
wantChatID: validID,
wantAncestorIDs: []uuid.UUID{ancestor1, ancestor2},
wantOK: true,
},
{
// Header is explicitly set to an empty string, which
// Header.Get returns as "".
name: "EmptyChatIDHeader",
chatID: "",
setChatID: true,
setAncestors: false,
wantChatID: uuid.Nil,
wantAncestorIDs: nil,
wantOK: false,
},
{
name: "ValidChatID_EmptyAncestorHeader",
chatID: validID.String(),
setChatID: true,
ancestors: "",
setAncestors: true,
wantChatID: validID,
wantAncestorIDs: nil,
wantOK: true,
},
}
for _, tt := range tests {
tt := tt
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
r := httptest.NewRequest("GET", "/", nil)
if tt.setChatID {
r.Header.Set(workspacesdk.CoderChatIDHeader, tt.chatID)
}
if tt.setAncestors {
r.Header.Set(workspacesdk.CoderAncestorChatIDsHeader, tt.ancestors)
}
chatID, ancestorIDs, ok := agentgit.ExtractChatContext(r)
require.Equal(t, tt.wantOK, ok, "ok mismatch")
require.Equal(t, tt.wantChatID, chatID, "chatID mismatch")
require.Equal(t, tt.wantAncestorIDs, ancestorIDs, "ancestorIDs mismatch")
})
}
}
// mustMarshalJSON marshals v to a JSON string, failing the test on error.
func mustMarshalJSON(t *testing.T, v any) string {
t.Helper()
b, err := json.Marshal(v)
require.NoError(t, err)
return string(b)
}
-136
View File
@@ -1,136 +0,0 @@
package agentgit
import (
"slices"
"sync"
"github.com/google/uuid"
)
// PathStore tracks which file paths each chat has touched.
// It is safe for concurrent use.
type PathStore struct {
mu sync.RWMutex
chatPaths map[uuid.UUID]map[string]struct{}
subscribers map[uuid.UUID][]chan<- struct{}
}
// NewPathStore creates a new PathStore.
func NewPathStore() *PathStore {
return &PathStore{
chatPaths: make(map[uuid.UUID]map[string]struct{}),
subscribers: make(map[uuid.UUID][]chan<- struct{}),
}
}
// AddPaths adds paths to every chat in chatIDs and notifies
// their subscribers. Zero-value UUIDs are silently skipped.
func (ps *PathStore) AddPaths(chatIDs []uuid.UUID, paths []string) {
affected := make([]uuid.UUID, 0, len(chatIDs))
for _, id := range chatIDs {
if id != uuid.Nil {
affected = append(affected, id)
}
}
if len(affected) == 0 {
return
}
ps.mu.Lock()
for _, id := range affected {
m, ok := ps.chatPaths[id]
if !ok {
m = make(map[string]struct{})
ps.chatPaths[id] = m
}
for _, p := range paths {
m[p] = struct{}{}
}
}
ps.mu.Unlock()
ps.notifySubscribers(affected)
}
// Notify sends a signal to all subscribers of the given chat IDs
// without adding any paths. Zero-value UUIDs are silently skipped.
func (ps *PathStore) Notify(chatIDs []uuid.UUID) {
affected := make([]uuid.UUID, 0, len(chatIDs))
for _, id := range chatIDs {
if id != uuid.Nil {
affected = append(affected, id)
}
}
if len(affected) == 0 {
return
}
ps.notifySubscribers(affected)
}
// notifySubscribers sends a non-blocking signal to all subscriber
// channels for the given chat IDs.
func (ps *PathStore) notifySubscribers(chatIDs []uuid.UUID) {
ps.mu.RLock()
toNotify := make([]chan<- struct{}, 0)
for _, id := range chatIDs {
toNotify = append(toNotify, ps.subscribers[id]...)
}
ps.mu.RUnlock()
for _, ch := range toNotify {
select {
case ch <- struct{}{}:
default:
}
}
}
// GetPaths returns all paths tracked for a chat, deduplicated
// and sorted lexicographically.
func (ps *PathStore) GetPaths(chatID uuid.UUID) []string {
ps.mu.RLock()
defer ps.mu.RUnlock()
m := ps.chatPaths[chatID]
if len(m) == 0 {
return nil
}
out := make([]string, 0, len(m))
for p := range m {
out = append(out, p)
}
slices.Sort(out)
return out
}
// Len returns the number of chat IDs that have tracked paths.
func (ps *PathStore) Len() int {
ps.mu.RLock()
defer ps.mu.RUnlock()
return len(ps.chatPaths)
}
// Subscribe returns a channel that receives a signal whenever
// paths change for chatID, along with an unsubscribe function
// that removes the channel.
func (ps *PathStore) Subscribe(chatID uuid.UUID) (<-chan struct{}, func()) {
ch := make(chan struct{}, 1)
ps.mu.Lock()
ps.subscribers[chatID] = append(ps.subscribers[chatID], ch)
ps.mu.Unlock()
unsub := func() {
ps.mu.Lock()
defer ps.mu.Unlock()
subs := ps.subscribers[chatID]
for i, s := range subs {
if s == ch {
ps.subscribers[chatID] = append(subs[:i], subs[i+1:]...)
break
}
}
}
return ch, unsub
}
-268
View File
@@ -1,268 +0,0 @@
package agentgit_test
import (
"sync"
"testing"
"time"
"github.com/google/uuid"
"github.com/stretchr/testify/require"
"github.com/coder/coder/v2/agent/agentgit"
"github.com/coder/coder/v2/testutil"
)
func TestPathStore_AddPaths_StoresForChatAndAncestors(t *testing.T) {
t.Parallel()
ps := agentgit.NewPathStore()
chatID := uuid.New()
ancestor1 := uuid.New()
ancestor2 := uuid.New()
ps.AddPaths([]uuid.UUID{chatID, ancestor1, ancestor2}, []string{"/a", "/b"})
// All three IDs should see the paths.
require.Equal(t, []string{"/a", "/b"}, ps.GetPaths(chatID))
require.Equal(t, []string{"/a", "/b"}, ps.GetPaths(ancestor1))
require.Equal(t, []string{"/a", "/b"}, ps.GetPaths(ancestor2))
// An unrelated chat should see nothing.
require.Nil(t, ps.GetPaths(uuid.New()))
}
func TestPathStore_AddPaths_SkipsNilUUIDs(t *testing.T) {
t.Parallel()
ps := agentgit.NewPathStore()
// A nil chatID should be a no-op.
ps.AddPaths([]uuid.UUID{uuid.Nil}, []string{"/x"})
require.Nil(t, ps.GetPaths(uuid.Nil))
// A nil ancestor should be silently skipped.
chatID := uuid.New()
ps.AddPaths([]uuid.UUID{chatID, uuid.Nil}, []string{"/y"})
require.Equal(t, []string{"/y"}, ps.GetPaths(chatID))
require.Nil(t, ps.GetPaths(uuid.Nil))
}
func TestPathStore_GetPaths_DeduplicatedSorted(t *testing.T) {
t.Parallel()
ps := agentgit.NewPathStore()
chatID := uuid.New()
ps.AddPaths([]uuid.UUID{chatID}, []string{"/z", "/a", "/m", "/a", "/z"})
ps.AddPaths([]uuid.UUID{chatID}, []string{"/a", "/b"})
got := ps.GetPaths(chatID)
require.Equal(t, []string{"/a", "/b", "/m", "/z"}, got)
}
func TestPathStore_Subscribe_ReceivesNotification(t *testing.T) {
t.Parallel()
ps := agentgit.NewPathStore()
chatID := uuid.New()
ch, unsub := ps.Subscribe(chatID)
defer unsub()
ps.AddPaths([]uuid.UUID{chatID}, []string{"/file"})
ctx := testutil.Context(t, testutil.WaitShort)
select {
case <-ch:
// Success.
case <-ctx.Done():
t.Fatal("timed out waiting for notification")
}
}
func TestPathStore_Subscribe_MultipleSubscribers(t *testing.T) {
t.Parallel()
ps := agentgit.NewPathStore()
chatID := uuid.New()
ch1, unsub1 := ps.Subscribe(chatID)
defer unsub1()
ch2, unsub2 := ps.Subscribe(chatID)
defer unsub2()
ps.AddPaths([]uuid.UUID{chatID}, []string{"/file"})
ctx := testutil.Context(t, testutil.WaitShort)
for i, ch := range []<-chan struct{}{ch1, ch2} {
select {
case <-ch:
// OK
case <-ctx.Done():
t.Fatalf("subscriber %d did not receive notification", i)
}
}
}
func TestPathStore_Unsubscribe_StopsNotifications(t *testing.T) {
t.Parallel()
ps := agentgit.NewPathStore()
chatID := uuid.New()
ch, unsub := ps.Subscribe(chatID)
unsub()
ps.AddPaths([]uuid.UUID{chatID}, []string{"/file"})
// AddPaths sends synchronously via a non-blocking send to the
// buffered channel, so if a notification were going to arrive
// it would already be in the channel by now.
select {
case <-ch:
t.Fatal("received notification after unsubscribe")
default:
// Expected: no notification.
}
}
func TestPathStore_Subscribe_AncestorNotification(t *testing.T) {
t.Parallel()
ps := agentgit.NewPathStore()
chatID := uuid.New()
ancestor := uuid.New()
// Subscribe to the ancestor, then add paths via the child.
ch, unsub := ps.Subscribe(ancestor)
defer unsub()
ps.AddPaths([]uuid.UUID{chatID, ancestor}, []string{"/file"})
ctx := testutil.Context(t, testutil.WaitShort)
select {
case <-ch:
// Success.
case <-ctx.Done():
t.Fatal("ancestor subscriber did not receive notification")
}
}
func TestPathStore_Notify_NotifiesWithoutAddingPaths(t *testing.T) {
t.Parallel()
ps := agentgit.NewPathStore()
chatID := uuid.New()
ch, unsub := ps.Subscribe(chatID)
defer unsub()
ps.Notify([]uuid.UUID{chatID})
ctx := testutil.Context(t, testutil.WaitShort)
select {
case <-ch:
// Success.
case <-ctx.Done():
t.Fatal("timed out waiting for notification")
}
require.Nil(t, ps.GetPaths(chatID))
}
func TestPathStore_Notify_SkipsNilUUIDs(t *testing.T) {
t.Parallel()
ps := agentgit.NewPathStore()
chatID := uuid.New()
ch, unsub := ps.Subscribe(chatID)
defer unsub()
ps.Notify([]uuid.UUID{uuid.Nil})
// Notify sends synchronously via a non-blocking send to the
// buffered channel, so if a notification were going to arrive
// it would already be in the channel by now.
select {
case <-ch:
t.Fatal("received notification for nil UUID")
default:
// Expected: no notification.
}
require.Nil(t, ps.GetPaths(chatID))
}
func TestPathStore_Notify_AncestorNotification(t *testing.T) {
t.Parallel()
ps := agentgit.NewPathStore()
chatID := uuid.New()
ancestorID := uuid.New()
// Subscribe to the ancestor, then notify via the child.
ch, unsub := ps.Subscribe(ancestorID)
defer unsub()
ps.Notify([]uuid.UUID{chatID, ancestorID})
ctx := testutil.Context(t, testutil.WaitShort)
select {
case <-ch:
// Success.
case <-ctx.Done():
t.Fatal("ancestor subscriber did not receive notification")
}
require.Nil(t, ps.GetPaths(ancestorID))
}
func TestPathStore_ConcurrentSafety(t *testing.T) {
t.Parallel()
ps := agentgit.NewPathStore()
const goroutines = 20
const iterations = 50
chatIDs := make([]uuid.UUID, goroutines)
for i := range chatIDs {
chatIDs[i] = uuid.New()
}
var wg sync.WaitGroup
wg.Add(goroutines * 2) // writers + readers
// Writers.
for i := range goroutines {
go func(idx int) {
defer wg.Done()
for j := range iterations {
ancestors := []uuid.UUID{chatIDs[(idx+1)%goroutines]}
path := []string{
"/file-" + chatIDs[idx].String() + "-" + time.Now().Format(time.RFC3339Nano),
"/iter-" + string(rune('0'+j%10)),
}
ps.AddPaths(append([]uuid.UUID{chatIDs[idx]}, ancestors...), path)
}
}(i)
}
// Readers.
for i := range goroutines {
go func(idx int) {
defer wg.Done()
for range iterations {
_ = ps.GetPaths(chatIDs[idx])
}
}(i)
}
wg.Wait()
// Verify every chat has at least the paths it wrote.
for _, id := range chatIDs {
paths := ps.GetPaths(id)
require.NotEmpty(t, paths, "chat %s should have paths", id)
}
}
-281
View File
@@ -1,281 +0,0 @@
package agentproc
import (
"context"
"encoding/json"
"errors"
"fmt"
"net/http"
"sort"
"time"
"github.com/go-chi/chi/v5"
"github.com/google/uuid"
"cdr.dev/slog/v3"
"github.com/coder/coder/v2/agent/agentexec"
"github.com/coder/coder/v2/agent/agentgit"
"github.com/coder/coder/v2/coderd/httpapi"
"github.com/coder/coder/v2/codersdk"
"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
manager *manager
pathStore *agentgit.PathStore
}
// 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, workingDir func() string) *API {
return &API{
logger: logger,
manager: newManager(logger, execer, updateEnv, workingDir),
pathStore: pathStore,
}
}
// Close shuts down the process manager, killing all running
// processes.
func (api *API) Close() error {
return api.manager.Close()
}
// Routes returns the HTTP handler for process-related routes.
func (api *API) Routes() http.Handler {
r := chi.NewRouter()
r.Post("/start", api.handleStartProcess)
r.Get("/list", api.handleListProcesses)
r.Get("/{id}/output", api.handleProcessOutput)
r.Post("/{id}/signal", api.handleSignalProcess)
return r
}
// handleStartProcess starts a new process.
func (api *API) handleStartProcess(rw http.ResponseWriter, r *http.Request) {
ctx := r.Context()
var req workspacesdk.StartProcessRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
Message: "Request body must be valid JSON.",
Detail: err.Error(),
})
return
}
if req.Command == "" {
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
Message: "Command is required.",
})
return
}
var chatID string
if id, _, ok := agentgit.ExtractChatContext(r); ok {
chatID = id.String()
}
proc, err := api.manager.start(req, chatID)
if err != nil {
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
Message: "Failed to start process.",
Detail: err.Error(),
})
return
}
// Notify git watchers after the process finishes so that
// file changes made by the command are visible in the scan.
// If a workdir is provided, track it as a path as well.
if api.pathStore != nil {
if chatID, ancestorIDs, ok := agentgit.ExtractChatContext(r); ok {
allIDs := append([]uuid.UUID{chatID}, ancestorIDs...)
go func() {
<-proc.done
if req.WorkDir != "" {
api.pathStore.AddPaths(allIDs, []string{req.WorkDir})
} else {
api.pathStore.Notify(allIDs)
}
}()
}
}
httpapi.Write(ctx, rw, http.StatusOK, workspacesdk.StartProcessResponse{
ID: proc.id,
Started: true,
})
}
// handleListProcesses lists all tracked processes.
func (api *API) handleListProcesses(rw http.ResponseWriter, r *http.Request) {
ctx := r.Context()
var chatID string
if id, _, ok := agentgit.ExtractChatContext(r); ok {
chatID = id.String()
}
infos := api.manager.list(chatID)
// Sort by running state (running first), then by started_at
// descending so the most recent processes appear first.
sort.Slice(infos, func(i, j int) bool {
if infos[i].Running != infos[j].Running {
return infos[i].Running
}
return infos[i].StartedAt > infos[j].StartedAt
})
// Cap the response to avoid bloating LLM context.
const maxListProcesses = 10
if len(infos) > maxListProcesses {
infos = infos[:maxListProcesses]
}
httpapi.Write(ctx, rw, http.StatusOK, workspacesdk.ListProcessesResponse{
Processes: infos,
})
}
// handleProcessOutput returns the output of a process.
func (api *API) handleProcessOutput(rw http.ResponseWriter, r *http.Request) {
ctx := r.Context()
id := chi.URLParam(r, "id")
proc, ok := api.manager.get(id)
if !ok {
httpapi.Write(ctx, rw, http.StatusNotFound, codersdk.Response{
Message: fmt.Sprintf("Process %q not found.", id),
})
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)
// Add headroom beyond the wait timeout so there's time to
// write the response after the blocking wait completes.
if err := rc.SetWriteDeadline(time.Now().Add(maxWaitDuration + 30*time.Second)); 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()
httpapi.Write(ctx, rw, http.StatusOK, workspacesdk.ProcessOutputResponse{
Output: output,
Truncated: truncated,
Running: info.Running,
ExitCode: info.ExitCode,
})
}
// handleSignalProcess sends a signal to a running process.
func (api *API) handleSignalProcess(rw http.ResponseWriter, r *http.Request) {
ctx := r.Context()
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{
Message: "Request body must be valid JSON.",
Detail: err.Error(),
})
return
}
if req.Signal == "" {
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
Message: "Signal is required.",
})
return
}
if req.Signal != "kill" && req.Signal != "terminate" {
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
Message: fmt.Sprintf(
"Unsupported signal %q. Use \"kill\" or \"terminate\".",
req.Signal,
),
})
return
}
if err := api.manager.signal(id, req.Signal); err != nil {
switch {
case errors.Is(err, errProcessNotFound):
httpapi.Write(ctx, rw, http.StatusNotFound, codersdk.Response{
Message: fmt.Sprintf("Process %q not found.", id),
})
case errors.Is(err, errProcessNotRunning):
httpapi.Write(ctx, rw, http.StatusConflict, codersdk.Response{
Message: fmt.Sprintf(
"Process %q is not running.", id,
),
})
default:
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
Message: "Failed to signal process.",
Detail: err.Error(),
})
}
return
}
httpapi.Write(ctx, rw, http.StatusOK, codersdk.Response{
Message: fmt.Sprintf(
"Signal %q sent to process %q.", req.Signal, id,
),
})
}
File diff suppressed because it is too large Load Diff
-326
View File
@@ -1,326 +0,0 @@
package agentproc
import (
"fmt"
"strings"
"sync"
"github.com/coder/coder/v2/codersdk/workspacesdk"
)
const (
// MaxHeadBytes is the number of bytes retained from the
// beginning of the output for LLM consumption.
MaxHeadBytes = 16 << 10 // 16KB
// MaxTailBytes is the number of bytes retained from the
// end of the output for LLM consumption.
MaxTailBytes = 16 << 10 // 16KB
// MaxLineLength is the maximum length of a single line
// before it is truncated. This prevents minified files
// or other long single-line output from consuming the
// entire buffer.
MaxLineLength = 2048
// lineTruncationSuffix is appended to lines that exceed
// MaxLineLength.
lineTruncationSuffix = " ... [truncated]"
)
// HeadTailBuffer is a thread-safe buffer that captures process
// output and provides head+tail truncation for LLM consumption.
// It implements io.Writer so it can be used directly as
// cmd.Stdout or cmd.Stderr.
//
// The buffer stores up to MaxHeadBytes from the beginning of
// the output and up to MaxTailBytes from the end in a ring
// buffer, keeping total memory usage bounded regardless of
// 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
}
// NewHeadTailBuffer creates a new HeadTailBuffer with the
// default head and tail sizes.
func NewHeadTailBuffer() *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 {
b := &HeadTailBuffer{
maxHead: maxHead,
maxTail: maxTail,
}
b.cond = sync.NewCond(&b.mu)
return b
}
// Write implements io.Writer. It is safe for concurrent use.
// All bytes are accepted; the return value always equals
// len(p) with a nil error.
func (b *HeadTailBuffer) Write(p []byte) (int, error) {
if len(p) == 0 {
return 0, nil
}
b.mu.Lock()
defer b.mu.Unlock()
n := len(p)
b.totalBytes += n
// Fill head buffer if it is not yet full.
if !b.headFull {
remaining := b.maxHead - len(b.head)
if remaining > 0 {
take := remaining
if take > len(p) {
take = len(p)
}
b.head = append(b.head, p[:take]...)
p = p[take:]
if len(b.head) >= b.maxHead {
b.headFull = true
}
}
if len(p) == 0 {
return n, nil
}
}
// Write remaining bytes into the tail ring buffer.
b.writeTail(p)
return n, nil
}
// writeTail appends data to the tail ring buffer. The caller
// must hold b.mu.
func (b *HeadTailBuffer) writeTail(p []byte) {
if b.maxTail <= 0 {
return
}
// Lazily allocate the tail buffer on first use.
if b.tail == nil {
b.tail = make([]byte, b.maxTail)
}
for len(p) > 0 {
// Write as many bytes as fit starting at tailPos.
space := b.maxTail - b.tailPos
take := space
if take > len(p) {
take = len(p)
}
copy(b.tail[b.tailPos:b.tailPos+take], p[:take])
p = p[take:]
b.tailPos += take
if b.tailPos >= b.maxTail {
b.tailPos = 0
b.tailFull = true
}
}
}
// tailBytes returns the current tail contents in order. The
// caller must hold b.mu.
func (b *HeadTailBuffer) tailBytes() []byte {
if b.tail == nil {
return nil
}
if !b.tailFull {
// Haven't wrapped yet; data is [0, tailPos).
return b.tail[:b.tailPos]
}
// Wrapped: data is [tailPos, maxTail) + [0, tailPos).
out := make([]byte, b.maxTail)
n := copy(out, b.tail[b.tailPos:])
copy(out[n:], b.tail[:b.tailPos])
return out
}
// Bytes returns a copy of the raw buffer contents. If no
// truncation has occurred the full output is returned;
// otherwise the head and tail portions are concatenated.
func (b *HeadTailBuffer) Bytes() []byte {
b.mu.Lock()
defer b.mu.Unlock()
tail := b.tailBytes()
if len(tail) == 0 {
out := make([]byte, len(b.head))
copy(out, b.head)
return out
}
out := make([]byte, len(b.head)+len(tail))
copy(out, b.head)
copy(out[len(b.head):], tail)
return out
}
// Len returns the number of bytes currently stored in the
// buffer.
func (b *HeadTailBuffer) Len() int {
b.mu.Lock()
defer b.mu.Unlock()
tailLen := 0
if b.tailFull {
tailLen = b.maxTail
} else if b.tail != nil {
tailLen = b.tailPos
}
return len(b.head) + tailLen
}
// TotalWritten returns the total number of bytes written to
// the buffer, which may exceed the stored capacity.
func (b *HeadTailBuffer) TotalWritten() int {
b.mu.Lock()
defer b.mu.Unlock()
return b.totalBytes
}
// Output returns the truncated output suitable for LLM
// consumption, along with truncation metadata. If the total
// output fits within the head buffer alone, the full output is
// returned with nil truncation info. Otherwise the head and
// tail are joined with an omission marker and long lines are
// truncated.
func (b *HeadTailBuffer) Output() (string, *workspacesdk.ProcessTruncation) {
b.mu.Lock()
head := make([]byte, len(b.head))
copy(head, b.head)
tail := b.tailBytes()
total := b.totalBytes
headFull := b.headFull
b.mu.Unlock()
storedLen := len(head) + len(tail)
// If everything fits, no head/tail split is needed.
if !headFull || len(tail) == 0 {
out := truncateLines(string(head))
if total == 0 {
return "", nil
}
return out, nil
}
// We have both head and tail data, meaning the total
// output exceeded the head capacity. Build the
// combined output with an omission marker.
omitted := total - storedLen
headStr := truncateLines(string(head))
tailStr := truncateLines(string(tail))
var sb strings.Builder
_, _ = sb.WriteString(headStr)
if omitted > 0 {
_, _ = sb.WriteString(fmt.Sprintf(
"\n\n... [omitted %d bytes] ...\n\n",
omitted,
))
} else {
// Head and tail are contiguous but were stored
// separately because the head filled up.
_, _ = sb.WriteString("\n")
}
_, _ = sb.WriteString(tailStr)
result := sb.String()
return result, &workspacesdk.ProcessTruncation{
OriginalBytes: total,
RetainedBytes: len(result),
OmittedBytes: omitted,
Strategy: "head_tail",
}
}
// truncateLines scans the input line by line and truncates
// any line longer than MaxLineLength.
func truncateLines(s string) string {
if len(s) <= MaxLineLength {
// Fast path: if the entire string is shorter than
// the max line length, no line can exceed it.
return s
}
var b strings.Builder
b.Grow(len(s))
for len(s) > 0 {
idx := strings.IndexByte(s, '\n')
var line string
if idx == -1 {
line = s
s = ""
} else {
line = s[:idx]
s = s[idx+1:]
}
if len(line) > MaxLineLength {
// Truncate preserving the suffix length so the
// total does not exceed a reasonable size.
cut := MaxLineLength - len(lineTruncationSuffix)
if cut < 0 {
cut = 0
}
_, _ = b.WriteString(line[:cut])
_, _ = b.WriteString(lineTruncationSuffix)
} else {
_, _ = b.WriteString(line)
}
// Re-add the newline unless this was the final
// segment without a trailing newline.
if idx != -1 {
_ = b.WriteByte('\n')
}
}
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()
defer b.mu.Unlock()
b.head = nil
b.tail = nil
b.tailPos = 0
b.tailFull = false
b.headFull = false
b.closed = false
b.totalBytes = 0
b.cond.Broadcast()
}
-338
View File
@@ -1,338 +0,0 @@
package agentproc_test
import (
"fmt"
"strings"
"sync"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/coder/coder/v2/agent/agentproc"
)
func TestHeadTailBuffer_EmptyBuffer(t *testing.T) {
t.Parallel()
buf := agentproc.NewHeadTailBuffer()
out, info := buf.Output()
require.Empty(t, out)
require.Nil(t, info)
require.Equal(t, 0, buf.Len())
require.Equal(t, 0, buf.TotalWritten())
require.Empty(t, buf.Bytes())
}
func TestHeadTailBuffer_SmallOutput(t *testing.T) {
t.Parallel()
buf := agentproc.NewHeadTailBuffer()
data := "hello world\n"
n, err := buf.Write([]byte(data))
require.NoError(t, err)
require.Equal(t, len(data), n)
out, info := buf.Output()
require.Equal(t, data, out)
require.Nil(t, info, "small output should not be truncated")
require.Equal(t, len(data), buf.Len())
require.Equal(t, len(data), buf.TotalWritten())
}
func TestHeadTailBuffer_ExactlyHeadSize(t *testing.T) {
t.Parallel()
buf := agentproc.NewHeadTailBuffer()
// Build data that is exactly MaxHeadBytes using short
// lines so that line truncation does not apply.
line := strings.Repeat("x", 79) + "\n" // 80 bytes per line
count := agentproc.MaxHeadBytes / len(line)
pad := agentproc.MaxHeadBytes - (count * len(line))
data := strings.Repeat(line, count) + strings.Repeat("y", pad)
require.Equal(t, agentproc.MaxHeadBytes, len(data),
"test data must be exactly MaxHeadBytes")
n, err := buf.Write([]byte(data))
require.NoError(t, err)
require.Equal(t, agentproc.MaxHeadBytes, n)
out, info := buf.Output()
require.Equal(t, data, out)
require.Nil(t, info, "output fitting in head should not be truncated")
require.Equal(t, agentproc.MaxHeadBytes, buf.Len())
}
func TestHeadTailBuffer_HeadPlusTailNoOmission(t *testing.T) {
t.Parallel()
// Use a small buffer so we can test the boundary where
// head fills and tail starts but nothing is omitted.
// With maxHead=10, maxTail=10, writing exactly 20 bytes
// means head gets 10, tail gets 10, omitted = 0.
buf := agentproc.NewHeadTailBufferSized(10, 10)
data := "0123456789abcdefghij" // 20 bytes
n, err := buf.Write([]byte(data))
require.NoError(t, err)
require.Equal(t, 20, n)
out, info := buf.Output()
require.NotNil(t, info)
require.Equal(t, 0, info.OmittedBytes)
require.Equal(t, "head_tail", info.Strategy)
// The output should contain both head and tail.
require.Contains(t, out, "0123456789")
require.Contains(t, out, "abcdefghij")
}
func TestHeadTailBuffer_LargeOutputTruncation(t *testing.T) {
t.Parallel()
// Use small head/tail so truncation is easy to verify.
buf := agentproc.NewHeadTailBufferSized(10, 10)
// Write 100 bytes: head=10, tail=10, omitted=80.
data := strings.Repeat("A", 50) + strings.Repeat("Z", 50)
n, err := buf.Write([]byte(data))
require.NoError(t, err)
require.Equal(t, 100, n)
out, info := buf.Output()
require.NotNil(t, info)
require.Equal(t, 100, info.OriginalBytes)
require.Equal(t, 80, info.OmittedBytes)
require.Equal(t, "head_tail", info.Strategy)
// Head should be first 10 bytes (all A's).
require.True(t, strings.HasPrefix(out, "AAAAAAAAAA"))
// Tail should be last 10 bytes (all Z's).
require.True(t, strings.HasSuffix(out, "ZZZZZZZZZZ"))
// Omission marker should be present.
require.Contains(t, out, "... [omitted 80 bytes] ...")
require.Equal(t, 20, buf.Len())
require.Equal(t, 100, buf.TotalWritten())
}
func TestHeadTailBuffer_MultiMBStaysBounded(t *testing.T) {
t.Parallel()
buf := agentproc.NewHeadTailBuffer()
// Write 5MB of data in chunks.
chunk := []byte(strings.Repeat("x", 4096) + "\n")
totalWritten := 0
for totalWritten < 5*1024*1024 {
n, err := buf.Write(chunk)
require.NoError(t, err)
require.Equal(t, len(chunk), n)
totalWritten += n
}
// Memory should be bounded to head+tail.
require.LessOrEqual(t, buf.Len(),
agentproc.MaxHeadBytes+agentproc.MaxTailBytes)
require.Equal(t, totalWritten, buf.TotalWritten())
out, info := buf.Output()
require.NotNil(t, info)
require.Equal(t, totalWritten, info.OriginalBytes)
require.Greater(t, info.OmittedBytes, 0)
require.NotEmpty(t, out)
}
func TestHeadTailBuffer_LongLineTruncation(t *testing.T) {
t.Parallel()
buf := agentproc.NewHeadTailBuffer()
// Write a line longer than MaxLineLength.
longLine := strings.Repeat("m", agentproc.MaxLineLength+500)
_, err := buf.Write([]byte(longLine + "\n"))
require.NoError(t, err)
out, _ := buf.Output()
lines := strings.Split(strings.TrimRight(out, "\n"), "\n")
require.Len(t, lines, 1)
require.LessOrEqual(t, len(lines[0]), agentproc.MaxLineLength)
require.True(t, strings.HasSuffix(lines[0], "... [truncated]"))
}
func TestHeadTailBuffer_LongLineInTail(t *testing.T) {
t.Parallel()
// Use small buffers so we can force data into the tail.
buf := agentproc.NewHeadTailBufferSized(20, 5000)
// Fill head with short data.
_, err := buf.Write([]byte("head data goes here\n"))
require.NoError(t, err)
// Now write a very long line into the tail.
longLine := strings.Repeat("T", agentproc.MaxLineLength+100)
_, err = buf.Write([]byte(longLine + "\n"))
require.NoError(t, err)
out, info := buf.Output()
require.NotNil(t, info)
// The long line in the tail should be truncated.
require.Contains(t, out, "... [truncated]")
}
func TestHeadTailBuffer_ConcurrentWrites(t *testing.T) {
t.Parallel()
buf := agentproc.NewHeadTailBuffer()
const goroutines = 10
const writes = 1000
var wg sync.WaitGroup
wg.Add(goroutines)
for g := range goroutines {
go func() {
defer wg.Done()
line := fmt.Sprintf("goroutine-%d: data\n", g)
for range writes {
_, err := buf.Write([]byte(line))
assert.NoError(t, err)
}
}()
}
wg.Wait()
// Verify totals are consistent.
require.Greater(t, buf.TotalWritten(), 0)
require.Greater(t, buf.Len(), 0)
out, _ := buf.Output()
require.NotEmpty(t, out)
}
func TestHeadTailBuffer_TruncationInfoFields(t *testing.T) {
t.Parallel()
buf := agentproc.NewHeadTailBufferSized(10, 10)
// Write enough to cause omission.
data := strings.Repeat("D", 50)
_, err := buf.Write([]byte(data))
require.NoError(t, err)
_, info := buf.Output()
require.NotNil(t, info)
require.Equal(t, 50, info.OriginalBytes)
require.Equal(t, 30, info.OmittedBytes)
require.Equal(t, "head_tail", info.Strategy)
// RetainedBytes is the length of the formatted output
// string including the omission marker.
require.Greater(t, info.RetainedBytes, 0)
}
func TestHeadTailBuffer_MultipleSmallWrites(t *testing.T) {
t.Parallel()
buf := agentproc.NewHeadTailBuffer()
// Write one byte at a time.
expected := "hello world"
for i := range len(expected) {
n, err := buf.Write([]byte{expected[i]})
require.NoError(t, err)
require.Equal(t, 1, n)
}
out, info := buf.Output()
require.Equal(t, expected, out)
require.Nil(t, info)
}
func TestHeadTailBuffer_WriteEmptySlice(t *testing.T) {
t.Parallel()
buf := agentproc.NewHeadTailBuffer()
n, err := buf.Write([]byte{})
require.NoError(t, err)
require.Equal(t, 0, n)
require.Equal(t, 0, buf.TotalWritten())
}
func TestHeadTailBuffer_Reset(t *testing.T) {
t.Parallel()
buf := agentproc.NewHeadTailBuffer()
_, err := buf.Write([]byte("some data"))
require.NoError(t, err)
require.Greater(t, buf.Len(), 0)
buf.Reset()
require.Equal(t, 0, buf.Len())
require.Equal(t, 0, buf.TotalWritten())
out, info := buf.Output()
require.Empty(t, out)
require.Nil(t, info)
}
func TestHeadTailBuffer_BytesReturnsCopy(t *testing.T) {
t.Parallel()
buf := agentproc.NewHeadTailBuffer()
_, err := buf.Write([]byte("original"))
require.NoError(t, err)
b := buf.Bytes()
require.Equal(t, []byte("original"), b)
// Mutating the returned slice should not affect the
// buffer.
b[0] = 'X'
require.Equal(t, []byte("original"), buf.Bytes())
}
func TestHeadTailBuffer_RingBufferWraparound(t *testing.T) {
t.Parallel()
// Use a tail of 10 bytes and write enough to wrap
// around multiple times.
buf := agentproc.NewHeadTailBufferSized(5, 10)
// Fill head (5 bytes).
_, err := buf.Write([]byte("HEADD"))
require.NoError(t, err)
// Write 25 bytes into tail, wrapping 2.5 times.
_, err = buf.Write([]byte("0123456789"))
require.NoError(t, err)
_, err = buf.Write([]byte("abcdefghij"))
require.NoError(t, err)
_, err = buf.Write([]byte("ABCDE"))
require.NoError(t, err)
out, info := buf.Output()
require.NotNil(t, info)
// Tail should contain the last 10 bytes: "fghijABCDE".
require.True(t, strings.HasSuffix(out, "fghijABCDE"),
"expected tail to be last 10 bytes, got: %q", out)
}
func TestHeadTailBuffer_MultipleLinesTruncated(t *testing.T) {
t.Parallel()
buf := agentproc.NewHeadTailBuffer()
short := "short line\n"
long := strings.Repeat("L", agentproc.MaxLineLength+100) + "\n"
_, err := buf.Write([]byte(short + long + short))
require.NoError(t, err)
out, _ := buf.Output()
lines := strings.Split(strings.TrimRight(out, "\n"), "\n")
require.Len(t, lines, 3)
require.Equal(t, "short line", lines[0])
require.True(t, strings.HasSuffix(lines[1], "... [truncated]"))
require.Equal(t, "short line", lines[2])
}
-26
View File
@@ -1,26 +0,0 @@
//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
View File
@@ -1,20 +0,0 @@
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()
}
-375
View File
@@ -1,375 +0,0 @@
package agentproc
import (
"context"
"fmt"
"os"
"os/exec"
"sync"
"syscall"
"time"
"github.com/google/uuid"
"golang.org/x/xerrors"
"cdr.dev/slog/v3"
"github.com/coder/coder/v2/agent/agentexec"
"github.com/coder/coder/v2/codersdk/workspacesdk"
"github.com/coder/quartz"
)
var (
errProcessNotFound = xerrors.New("process not found")
errProcessNotRunning = xerrors.New("process is not running")
// exitedProcessReapAge is how long an exited process is
// kept before being automatically removed from the map.
exitedProcessReapAge = 5 * time.Minute
)
// process represents a running or completed process.
type process struct {
mu sync.Mutex
id string
command string
workDir string
background bool
chatID string
cmd *exec.Cmd
cancel context.CancelFunc
buf *HeadTailBuffer
running bool
exitCode *int
startedAt int64
exitedAt *int64
done chan struct{} // closed when process exits
}
// info returns a snapshot of the process state.
func (p *process) info() workspacesdk.ProcessInfo {
p.mu.Lock()
defer p.mu.Unlock()
return workspacesdk.ProcessInfo{
ID: p.id,
Command: p.command,
WorkDir: p.workDir,
Background: p.background,
Running: p.running,
ExitCode: p.exitCode,
StartedAt: p.startedAt,
ExitedAt: p.exitedAt,
}
}
// output returns the truncated output from the process buffer
// along with optional truncation metadata.
func (p *process) output() (string, *workspacesdk.ProcessTruncation) {
return p.buf.Output()
}
// 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)
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), workingDir func() string) *manager {
return &manager{
logger: logger,
execer: execer,
clock: quartz.NewReal(),
procs: make(map[string]*process),
updateEnv: updateEnv,
workingDir: workingDir,
}
}
// start spawns a new process. Both foreground and background
// processes use a long-lived context so the process survives
// the HTTP request lifecycle. The background flag only affects
// client-side polling behavior.
func (m *manager) start(req workspacesdk.StartProcessRequest, chatID string) (*process, error) {
m.mu.Lock()
if m.closed {
m.mu.Unlock()
return nil, xerrors.New("manager is closed")
}
m.mu.Unlock()
id := uuid.New().String()
// Use a cancellable context so Close() can terminate
// all processes. context.Background() is the parent so
// the process is not tied to any HTTP request.
ctx, cancel := context.WithCancel(context.Background())
cmd := m.execer.CommandContext(ctx, "sh", "-c", req.Command)
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
// still holding the stdout/stderr pipes open.
cmd.WaitDelay = 5 * time.Second
buf := NewHeadTailBuffer()
cmd.Stdout = buf
cmd.Stderr = buf
// Build the process environment. If the manager has an
// updateEnv hook (provided by the agent), use it to get the
// full agent environment including GIT_ASKPASS, CODER_* vars,
// etc. Otherwise fall back to the current process env.
baseEnv := os.Environ()
if m.updateEnv != nil {
updated, err := m.updateEnv(baseEnv)
if err != nil {
m.logger.Warn(
context.Background(),
"failed to update command environment, falling back to os env",
slog.Error(err),
)
} else {
baseEnv = updated
}
}
// Always set cmd.Env explicitly so that req.Env overrides
// are applied on top of the full agent environment.
cmd.Env = baseEnv
for k, v := range req.Env {
cmd.Env = append(cmd.Env, fmt.Sprintf("%s=%s", k, v))
}
if err := cmd.Start(); err != nil {
cancel()
return nil, xerrors.Errorf("start process: %w", err)
}
now := m.clock.Now().Unix()
proc := &process{
id: id,
command: req.Command,
workDir: cmd.Dir,
background: req.Background,
chatID: chatID,
cmd: cmd,
cancel: cancel,
buf: buf,
running: true,
startedAt: now,
done: make(chan struct{}),
}
m.mu.Lock()
if m.closed {
m.mu.Unlock()
// Manager closed between our check and now. Kill the
// process we just started.
cancel()
_ = cmd.Wait()
return nil, xerrors.New("manager is closed")
}
m.procs[id] = proc
m.mu.Unlock()
go func() {
err := cmd.Wait()
exitedAt := m.clock.Now().Unix()
proc.mu.Lock()
proc.running = false
proc.exitedAt = &exitedAt
code := 0
if err != nil {
// Extract the exit code from the error.
var exitErr *exec.ExitError
if xerrors.As(err, &exitErr) {
code = exitErr.ExitCode()
} else {
// Unknown error; use -1 as a sentinel.
code = -1
m.logger.Warn(
context.Background(),
"process wait returned non-exit error",
slog.F("id", id),
slog.Error(err),
)
}
}
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)
}()
return proc, nil
}
// get returns a process by ID.
func (m *manager) get(id string) (*process, bool) {
m.mu.Lock()
defer m.mu.Unlock()
proc, ok := m.procs[id]
return proc, ok
}
// list returns info about all tracked processes. Exited
// processes older than exitedProcessReapAge are removed.
// If chatID is non-empty, only processes belonging to that
// chat are returned.
func (m *manager) list(chatID string) []workspacesdk.ProcessInfo {
m.mu.Lock()
defer m.mu.Unlock()
now := m.clock.Now()
infos := make([]workspacesdk.ProcessInfo, 0, len(m.procs))
for id, proc := range m.procs {
info := proc.info()
// Reap processes that exited more than 5 minutes ago
// to prevent unbounded map growth.
if !info.Running && info.ExitedAt != nil {
exitedAt := time.Unix(*info.ExitedAt, 0)
if now.Sub(exitedAt) > exitedProcessReapAge {
delete(m.procs, id)
continue
}
}
// Filter by chatID if provided.
if chatID != "" && proc.chatID != chatID {
continue
}
infos = append(infos, info)
}
return infos
}
// signal sends a signal to a running process. It returns
// sentinel errors errProcessNotFound and errProcessNotRunning
// so callers can distinguish failure modes.
func (m *manager) signal(id string, sig string) error {
m.mu.Lock()
proc, ok := m.procs[id]
m.mu.Unlock()
if !ok {
return errProcessNotFound
}
proc.mu.Lock()
defer proc.mu.Unlock()
if !proc.running {
return errProcessNotRunning
}
switch sig {
case "kill":
// 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":
// 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:
return xerrors.Errorf("unsupported signal %q", sig)
}
return nil
}
// Close kills all running processes and prevents new ones from
// starting. It cancels each process's context, which causes
// CommandContext to kill the process and its pipe goroutines to
// drain.
func (m *manager) Close() error {
m.mu.Lock()
if m.closed {
m.mu.Unlock()
return nil
}
m.closed = true
procs := make([]*process, 0, len(m.procs))
for _, p := range m.procs {
procs = append(procs, p)
}
m.mu.Unlock()
for _, p := range procs {
p.cancel()
}
// Wait for all processes to exit.
for _, p := range procs {
<-p.done
}
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 ""
}
+2 -2
View File
@@ -398,11 +398,11 @@ func (r *Runner) run(ctx context.Context, script codersdk.WorkspaceAgentScript,
},
})
if err != nil {
logger.Warn(ctx, "reporting script completed", slog.Error(err))
logger.Error(ctx, fmt.Sprintf("reporting script completed: %s", err.Error()))
}
})
if err != nil {
logger.Warn(ctx, "reporting script completed: track command goroutine", slog.Error(err))
logger.Error(ctx, fmt.Sprintf("reporting script completed: track command goroutine: %s", err.Error()))
}
}()
-6
View File
@@ -8,7 +8,6 @@ import (
"storj.io/drpc/drpcconn"
"github.com/coder/coder/v2/agent/agentsocket/proto"
agentproto "github.com/coder/coder/v2/agent/proto"
"github.com/coder/coder/v2/agent/unit"
)
@@ -133,11 +132,6 @@ func (c *Client) SyncStatus(ctx context.Context, unitName unit.ID) (SyncStatusRe
}, nil
}
// UpdateAppStatus forwards an app status update to coderd via the agent.
func (c *Client) UpdateAppStatus(ctx context.Context, req *agentproto.UpdateAppStatusRequest) (*agentproto.UpdateAppStatusResponse, error) {
return c.client.UpdateAppStatus(ctx, req)
}
// SyncStatusResponse contains the status information for a unit.
type SyncStatusResponse struct {
UnitName unit.ID `table:"unit,default_sort" json:"unit_name"`
+102 -115
View File
@@ -7,7 +7,6 @@
package proto
import (
proto "github.com/coder/coder/v2/agent/proto"
protoreflect "google.golang.org/protobuf/reflect/protoreflect"
protoimpl "google.golang.org/protobuf/runtime/protoimpl"
reflect "reflect"
@@ -650,98 +649,90 @@ var file_agent_agentsocket_proto_agentsocket_proto_rawDesc = []byte{
0x6b, 0x65, 0x74, 0x2f, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x2f, 0x61, 0x67, 0x65, 0x6e, 0x74, 0x73,
0x6f, 0x63, 0x6b, 0x65, 0x74, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x12, 0x14, 0x63, 0x6f, 0x64,
0x65, 0x72, 0x2e, 0x61, 0x67, 0x65, 0x6e, 0x74, 0x73, 0x6f, 0x63, 0x6b, 0x65, 0x74, 0x2e, 0x76,
0x31, 0x1a, 0x17, 0x61, 0x67, 0x65, 0x6e, 0x74, 0x2f, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x2f, 0x61,
0x67, 0x65, 0x6e, 0x74, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x22, 0x0d, 0x0a, 0x0b, 0x50, 0x69,
0x6e, 0x67, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x22, 0x0e, 0x0a, 0x0c, 0x50, 0x69, 0x6e,
0x67, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x22, 0x26, 0x0a, 0x10, 0x53, 0x79, 0x6e,
0x63, 0x53, 0x74, 0x61, 0x72, 0x74, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x12, 0x0a,
0x04, 0x75, 0x6e, 0x69, 0x74, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x75, 0x6e, 0x69,
0x74, 0x22, 0x13, 0x0a, 0x11, 0x53, 0x79, 0x6e, 0x63, 0x53, 0x74, 0x61, 0x72, 0x74, 0x52, 0x65,
0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x22, 0x44, 0x0a, 0x0f, 0x53, 0x79, 0x6e, 0x63, 0x57, 0x61,
0x6e, 0x74, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x12, 0x0a, 0x04, 0x75, 0x6e, 0x69,
0x31, 0x22, 0x0d, 0x0a, 0x0b, 0x50, 0x69, 0x6e, 0x67, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74,
0x22, 0x0e, 0x0a, 0x0c, 0x50, 0x69, 0x6e, 0x67, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65,
0x22, 0x26, 0x0a, 0x10, 0x53, 0x79, 0x6e, 0x63, 0x53, 0x74, 0x61, 0x72, 0x74, 0x52, 0x65, 0x71,
0x75, 0x65, 0x73, 0x74, 0x12, 0x12, 0x0a, 0x04, 0x75, 0x6e, 0x69, 0x74, 0x18, 0x01, 0x20, 0x01,
0x28, 0x09, 0x52, 0x04, 0x75, 0x6e, 0x69, 0x74, 0x22, 0x13, 0x0a, 0x11, 0x53, 0x79, 0x6e, 0x63,
0x53, 0x74, 0x61, 0x72, 0x74, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x22, 0x44, 0x0a,
0x0f, 0x53, 0x79, 0x6e, 0x63, 0x57, 0x61, 0x6e, 0x74, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74,
0x12, 0x12, 0x0a, 0x04, 0x75, 0x6e, 0x69, 0x74, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04,
0x75, 0x6e, 0x69, 0x74, 0x12, 0x1d, 0x0a, 0x0a, 0x64, 0x65, 0x70, 0x65, 0x6e, 0x64, 0x73, 0x5f,
0x6f, 0x6e, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x09, 0x64, 0x65, 0x70, 0x65, 0x6e, 0x64,
0x73, 0x4f, 0x6e, 0x22, 0x12, 0x0a, 0x10, 0x53, 0x79, 0x6e, 0x63, 0x57, 0x61, 0x6e, 0x74, 0x52,
0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x22, 0x29, 0x0a, 0x13, 0x53, 0x79, 0x6e, 0x63, 0x43,
0x6f, 0x6d, 0x70, 0x6c, 0x65, 0x74, 0x65, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x12,
0x0a, 0x04, 0x75, 0x6e, 0x69, 0x74, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x75, 0x6e,
0x69, 0x74, 0x22, 0x16, 0x0a, 0x14, 0x53, 0x79, 0x6e, 0x63, 0x43, 0x6f, 0x6d, 0x70, 0x6c, 0x65,
0x74, 0x65, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x22, 0x26, 0x0a, 0x10, 0x53, 0x79,
0x6e, 0x63, 0x52, 0x65, 0x61, 0x64, 0x79, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x12,
0x0a, 0x04, 0x75, 0x6e, 0x69, 0x74, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x75, 0x6e,
0x69, 0x74, 0x22, 0x29, 0x0a, 0x11, 0x53, 0x79, 0x6e, 0x63, 0x52, 0x65, 0x61, 0x64, 0x79, 0x52,
0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x14, 0x0a, 0x05, 0x72, 0x65, 0x61, 0x64, 0x79,
0x18, 0x01, 0x20, 0x01, 0x28, 0x08, 0x52, 0x05, 0x72, 0x65, 0x61, 0x64, 0x79, 0x22, 0x27, 0x0a,
0x11, 0x53, 0x79, 0x6e, 0x63, 0x53, 0x74, 0x61, 0x74, 0x75, 0x73, 0x52, 0x65, 0x71, 0x75, 0x65,
0x73, 0x74, 0x12, 0x12, 0x0a, 0x04, 0x75, 0x6e, 0x69, 0x74, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09,
0x52, 0x04, 0x75, 0x6e, 0x69, 0x74, 0x22, 0xb6, 0x01, 0x0a, 0x0e, 0x44, 0x65, 0x70, 0x65, 0x6e,
0x64, 0x65, 0x6e, 0x63, 0x79, 0x49, 0x6e, 0x66, 0x6f, 0x12, 0x12, 0x0a, 0x04, 0x75, 0x6e, 0x69,
0x74, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x75, 0x6e, 0x69, 0x74, 0x12, 0x1d, 0x0a,
0x0a, 0x64, 0x65, 0x70, 0x65, 0x6e, 0x64, 0x73, 0x5f, 0x6f, 0x6e, 0x18, 0x02, 0x20, 0x01, 0x28,
0x09, 0x52, 0x09, 0x64, 0x65, 0x70, 0x65, 0x6e, 0x64, 0x73, 0x4f, 0x6e, 0x22, 0x12, 0x0a, 0x10,
0x53, 0x79, 0x6e, 0x63, 0x57, 0x61, 0x6e, 0x74, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65,
0x22, 0x29, 0x0a, 0x13, 0x53, 0x79, 0x6e, 0x63, 0x43, 0x6f, 0x6d, 0x70, 0x6c, 0x65, 0x74, 0x65,
0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x12, 0x0a, 0x04, 0x75, 0x6e, 0x69, 0x74, 0x18,
0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x75, 0x6e, 0x69, 0x74, 0x22, 0x16, 0x0a, 0x14, 0x53,
0x79, 0x6e, 0x63, 0x43, 0x6f, 0x6d, 0x70, 0x6c, 0x65, 0x74, 0x65, 0x52, 0x65, 0x73, 0x70, 0x6f,
0x6e, 0x73, 0x65, 0x22, 0x26, 0x0a, 0x10, 0x53, 0x79, 0x6e, 0x63, 0x52, 0x65, 0x61, 0x64, 0x79,
0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x12, 0x0a, 0x04, 0x75, 0x6e, 0x69, 0x74, 0x18,
0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x75, 0x6e, 0x69, 0x74, 0x22, 0x29, 0x0a, 0x11, 0x53,
0x79, 0x6e, 0x63, 0x52, 0x65, 0x61, 0x64, 0x79, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65,
0x12, 0x14, 0x0a, 0x05, 0x72, 0x65, 0x61, 0x64, 0x79, 0x18, 0x01, 0x20, 0x01, 0x28, 0x08, 0x52,
0x05, 0x72, 0x65, 0x61, 0x64, 0x79, 0x22, 0x27, 0x0a, 0x11, 0x53, 0x79, 0x6e, 0x63, 0x53, 0x74,
0x61, 0x74, 0x75, 0x73, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x12, 0x0a, 0x04, 0x75,
0x6e, 0x69, 0x74, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x75, 0x6e, 0x69, 0x74, 0x22,
0xb6, 0x01, 0x0a, 0x0e, 0x44, 0x65, 0x70, 0x65, 0x6e, 0x64, 0x65, 0x6e, 0x63, 0x79, 0x49, 0x6e,
0x66, 0x6f, 0x12, 0x12, 0x0a, 0x04, 0x75, 0x6e, 0x69, 0x74, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09,
0x52, 0x04, 0x75, 0x6e, 0x69, 0x74, 0x12, 0x1d, 0x0a, 0x0a, 0x64, 0x65, 0x70, 0x65, 0x6e, 0x64,
0x73, 0x5f, 0x6f, 0x6e, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x09, 0x64, 0x65, 0x70, 0x65,
0x6e, 0x64, 0x73, 0x4f, 0x6e, 0x12, 0x27, 0x0a, 0x0f, 0x72, 0x65, 0x71, 0x75, 0x69, 0x72, 0x65,
0x64, 0x5f, 0x73, 0x74, 0x61, 0x74, 0x75, 0x73, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0e,
0x72, 0x65, 0x71, 0x75, 0x69, 0x72, 0x65, 0x64, 0x53, 0x74, 0x61, 0x74, 0x75, 0x73, 0x12, 0x25,
0x0a, 0x0e, 0x63, 0x75, 0x72, 0x72, 0x65, 0x6e, 0x74, 0x5f, 0x73, 0x74, 0x61, 0x74, 0x75, 0x73,
0x18, 0x04, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0d, 0x63, 0x75, 0x72, 0x72, 0x65, 0x6e, 0x74, 0x53,
0x74, 0x61, 0x74, 0x75, 0x73, 0x12, 0x21, 0x0a, 0x0c, 0x69, 0x73, 0x5f, 0x73, 0x61, 0x74, 0x69,
0x73, 0x66, 0x69, 0x65, 0x64, 0x18, 0x05, 0x20, 0x01, 0x28, 0x08, 0x52, 0x0b, 0x69, 0x73, 0x53,
0x61, 0x74, 0x69, 0x73, 0x66, 0x69, 0x65, 0x64, 0x22, 0x91, 0x01, 0x0a, 0x12, 0x53, 0x79, 0x6e,
0x63, 0x53, 0x74, 0x61, 0x74, 0x75, 0x73, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12,
0x16, 0x0a, 0x06, 0x73, 0x74, 0x61, 0x74, 0x75, 0x73, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52,
0x06, 0x73, 0x74, 0x61, 0x74, 0x75, 0x73, 0x12, 0x19, 0x0a, 0x08, 0x69, 0x73, 0x5f, 0x72, 0x65,
0x61, 0x64, 0x79, 0x18, 0x02, 0x20, 0x01, 0x28, 0x08, 0x52, 0x07, 0x69, 0x73, 0x52, 0x65, 0x61,
0x64, 0x79, 0x12, 0x48, 0x0a, 0x0c, 0x64, 0x65, 0x70, 0x65, 0x6e, 0x64, 0x65, 0x6e, 0x63, 0x69,
0x65, 0x73, 0x18, 0x03, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x24, 0x2e, 0x63, 0x6f, 0x64, 0x65, 0x72,
0x2e, 0x61, 0x67, 0x65, 0x6e, 0x74, 0x73, 0x6f, 0x63, 0x6b, 0x65, 0x74, 0x2e, 0x76, 0x31, 0x2e,
0x44, 0x65, 0x70, 0x65, 0x6e, 0x64, 0x65, 0x6e, 0x63, 0x79, 0x49, 0x6e, 0x66, 0x6f, 0x52, 0x0c,
0x64, 0x65, 0x70, 0x65, 0x6e, 0x64, 0x65, 0x6e, 0x63, 0x69, 0x65, 0x73, 0x32, 0x9f, 0x05, 0x0a,
0x0b, 0x41, 0x67, 0x65, 0x6e, 0x74, 0x53, 0x6f, 0x63, 0x6b, 0x65, 0x74, 0x12, 0x4d, 0x0a, 0x04,
0x50, 0x69, 0x6e, 0x67, 0x12, 0x21, 0x2e, 0x63, 0x6f, 0x64, 0x65, 0x72, 0x2e, 0x61, 0x67, 0x65,
0x6e, 0x74, 0x73, 0x6f, 0x63, 0x6b, 0x65, 0x74, 0x2e, 0x76, 0x31, 0x2e, 0x50, 0x69, 0x6e, 0x67,
0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x22, 0x2e, 0x63, 0x6f, 0x64, 0x65, 0x72, 0x2e,
0x61, 0x67, 0x65, 0x6e, 0x74, 0x73, 0x6f, 0x63, 0x6b, 0x65, 0x74, 0x2e, 0x76, 0x31, 0x2e, 0x50,
0x69, 0x6e, 0x67, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x5c, 0x0a, 0x09, 0x53,
0x79, 0x6e, 0x63, 0x53, 0x74, 0x61, 0x72, 0x74, 0x12, 0x26, 0x2e, 0x63, 0x6f, 0x64, 0x65, 0x72,
0x2e, 0x61, 0x67, 0x65, 0x6e, 0x74, 0x73, 0x6f, 0x63, 0x6b, 0x65, 0x74, 0x2e, 0x76, 0x31, 0x2e,
0x53, 0x79, 0x6e, 0x63, 0x53, 0x74, 0x61, 0x72, 0x74, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74,
0x1a, 0x27, 0x2e, 0x63, 0x6f, 0x64, 0x65, 0x72, 0x2e, 0x61, 0x67, 0x65, 0x6e, 0x74, 0x73, 0x6f,
0x63, 0x6b, 0x65, 0x74, 0x2e, 0x76, 0x31, 0x2e, 0x53, 0x79, 0x6e, 0x63, 0x53, 0x74, 0x61, 0x72,
0x74, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x59, 0x0a, 0x08, 0x53, 0x79, 0x6e,
0x63, 0x57, 0x61, 0x6e, 0x74, 0x12, 0x25, 0x2e, 0x63, 0x6f, 0x64, 0x65, 0x72, 0x2e, 0x61, 0x67,
0x65, 0x6e, 0x74, 0x73, 0x6f, 0x63, 0x6b, 0x65, 0x74, 0x2e, 0x76, 0x31, 0x2e, 0x53, 0x79, 0x6e,
0x63, 0x57, 0x61, 0x6e, 0x74, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x26, 0x2e, 0x63,
0x6f, 0x64, 0x65, 0x72, 0x2e, 0x61, 0x67, 0x65, 0x6e, 0x74, 0x73, 0x6f, 0x63, 0x6b, 0x65, 0x74,
0x2e, 0x76, 0x31, 0x2e, 0x53, 0x79, 0x6e, 0x63, 0x57, 0x61, 0x6e, 0x74, 0x52, 0x65, 0x73, 0x70,
0x6f, 0x6e, 0x73, 0x65, 0x12, 0x65, 0x0a, 0x0c, 0x53, 0x79, 0x6e, 0x63, 0x43, 0x6f, 0x6d, 0x70,
0x6c, 0x65, 0x74, 0x65, 0x12, 0x29, 0x2e, 0x63, 0x6f, 0x64, 0x65, 0x72, 0x2e, 0x61, 0x67, 0x65,
0x6e, 0x74, 0x73, 0x6f, 0x63, 0x6b, 0x65, 0x74, 0x2e, 0x76, 0x31, 0x2e, 0x53, 0x79, 0x6e, 0x63,
0x43, 0x6f, 0x6d, 0x70, 0x6c, 0x65, 0x74, 0x65, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a,
0x2a, 0x2e, 0x63, 0x6f, 0x64, 0x65, 0x72, 0x2e, 0x61, 0x67, 0x65, 0x6e, 0x74, 0x73, 0x6f, 0x63,
0x6b, 0x65, 0x74, 0x2e, 0x76, 0x31, 0x2e, 0x53, 0x79, 0x6e, 0x63, 0x43, 0x6f, 0x6d, 0x70, 0x6c,
0x65, 0x74, 0x65, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x5c, 0x0a, 0x09, 0x53,
0x79, 0x6e, 0x63, 0x52, 0x65, 0x61, 0x64, 0x79, 0x12, 0x26, 0x2e, 0x63, 0x6f, 0x64, 0x65, 0x72,
0x2e, 0x61, 0x67, 0x65, 0x6e, 0x74, 0x73, 0x6f, 0x63, 0x6b, 0x65, 0x74, 0x2e, 0x76, 0x31, 0x2e,
0x53, 0x79, 0x6e, 0x63, 0x52, 0x65, 0x61, 0x64, 0x79, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74,
0x1a, 0x27, 0x2e, 0x63, 0x6f, 0x64, 0x65, 0x72, 0x2e, 0x61, 0x67, 0x65, 0x6e, 0x74, 0x73, 0x6f,
0x63, 0x6b, 0x65, 0x74, 0x2e, 0x76, 0x31, 0x2e, 0x53, 0x79, 0x6e, 0x63, 0x52, 0x65, 0x61, 0x64,
0x79, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x5f, 0x0a, 0x0a, 0x53, 0x79, 0x6e,
0x63, 0x53, 0x74, 0x61, 0x74, 0x75, 0x73, 0x12, 0x27, 0x2e, 0x63, 0x6f, 0x64, 0x65, 0x72, 0x2e,
0x09, 0x52, 0x09, 0x64, 0x65, 0x70, 0x65, 0x6e, 0x64, 0x73, 0x4f, 0x6e, 0x12, 0x27, 0x0a, 0x0f,
0x72, 0x65, 0x71, 0x75, 0x69, 0x72, 0x65, 0x64, 0x5f, 0x73, 0x74, 0x61, 0x74, 0x75, 0x73, 0x18,
0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0e, 0x72, 0x65, 0x71, 0x75, 0x69, 0x72, 0x65, 0x64, 0x53,
0x74, 0x61, 0x74, 0x75, 0x73, 0x12, 0x25, 0x0a, 0x0e, 0x63, 0x75, 0x72, 0x72, 0x65, 0x6e, 0x74,
0x5f, 0x73, 0x74, 0x61, 0x74, 0x75, 0x73, 0x18, 0x04, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0d, 0x63,
0x75, 0x72, 0x72, 0x65, 0x6e, 0x74, 0x53, 0x74, 0x61, 0x74, 0x75, 0x73, 0x12, 0x21, 0x0a, 0x0c,
0x69, 0x73, 0x5f, 0x73, 0x61, 0x74, 0x69, 0x73, 0x66, 0x69, 0x65, 0x64, 0x18, 0x05, 0x20, 0x01,
0x28, 0x08, 0x52, 0x0b, 0x69, 0x73, 0x53, 0x61, 0x74, 0x69, 0x73, 0x66, 0x69, 0x65, 0x64, 0x22,
0x91, 0x01, 0x0a, 0x12, 0x53, 0x79, 0x6e, 0x63, 0x53, 0x74, 0x61, 0x74, 0x75, 0x73, 0x52, 0x65,
0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x16, 0x0a, 0x06, 0x73, 0x74, 0x61, 0x74, 0x75, 0x73,
0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x06, 0x73, 0x74, 0x61, 0x74, 0x75, 0x73, 0x12, 0x19,
0x0a, 0x08, 0x69, 0x73, 0x5f, 0x72, 0x65, 0x61, 0x64, 0x79, 0x18, 0x02, 0x20, 0x01, 0x28, 0x08,
0x52, 0x07, 0x69, 0x73, 0x52, 0x65, 0x61, 0x64, 0x79, 0x12, 0x48, 0x0a, 0x0c, 0x64, 0x65, 0x70,
0x65, 0x6e, 0x64, 0x65, 0x6e, 0x63, 0x69, 0x65, 0x73, 0x18, 0x03, 0x20, 0x03, 0x28, 0x0b, 0x32,
0x24, 0x2e, 0x63, 0x6f, 0x64, 0x65, 0x72, 0x2e, 0x61, 0x67, 0x65, 0x6e, 0x74, 0x73, 0x6f, 0x63,
0x6b, 0x65, 0x74, 0x2e, 0x76, 0x31, 0x2e, 0x44, 0x65, 0x70, 0x65, 0x6e, 0x64, 0x65, 0x6e, 0x63,
0x79, 0x49, 0x6e, 0x66, 0x6f, 0x52, 0x0c, 0x64, 0x65, 0x70, 0x65, 0x6e, 0x64, 0x65, 0x6e, 0x63,
0x69, 0x65, 0x73, 0x32, 0xbb, 0x04, 0x0a, 0x0b, 0x41, 0x67, 0x65, 0x6e, 0x74, 0x53, 0x6f, 0x63,
0x6b, 0x65, 0x74, 0x12, 0x4d, 0x0a, 0x04, 0x50, 0x69, 0x6e, 0x67, 0x12, 0x21, 0x2e, 0x63, 0x6f,
0x64, 0x65, 0x72, 0x2e, 0x61, 0x67, 0x65, 0x6e, 0x74, 0x73, 0x6f, 0x63, 0x6b, 0x65, 0x74, 0x2e,
0x76, 0x31, 0x2e, 0x50, 0x69, 0x6e, 0x67, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x22,
0x2e, 0x63, 0x6f, 0x64, 0x65, 0x72, 0x2e, 0x61, 0x67, 0x65, 0x6e, 0x74, 0x73, 0x6f, 0x63, 0x6b,
0x65, 0x74, 0x2e, 0x76, 0x31, 0x2e, 0x50, 0x69, 0x6e, 0x67, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e,
0x73, 0x65, 0x12, 0x5c, 0x0a, 0x09, 0x53, 0x79, 0x6e, 0x63, 0x53, 0x74, 0x61, 0x72, 0x74, 0x12,
0x26, 0x2e, 0x63, 0x6f, 0x64, 0x65, 0x72, 0x2e, 0x61, 0x67, 0x65, 0x6e, 0x74, 0x73, 0x6f, 0x63,
0x6b, 0x65, 0x74, 0x2e, 0x76, 0x31, 0x2e, 0x53, 0x79, 0x6e, 0x63, 0x53, 0x74, 0x61, 0x72, 0x74,
0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x27, 0x2e, 0x63, 0x6f, 0x64, 0x65, 0x72, 0x2e,
0x61, 0x67, 0x65, 0x6e, 0x74, 0x73, 0x6f, 0x63, 0x6b, 0x65, 0x74, 0x2e, 0x76, 0x31, 0x2e, 0x53,
0x79, 0x6e, 0x63, 0x53, 0x74, 0x61, 0x74, 0x75, 0x73, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74,
0x1a, 0x28, 0x2e, 0x63, 0x6f, 0x64, 0x65, 0x72, 0x2e, 0x61, 0x67, 0x65, 0x6e, 0x74, 0x73, 0x6f,
0x63, 0x6b, 0x65, 0x74, 0x2e, 0x76, 0x31, 0x2e, 0x53, 0x79, 0x6e, 0x63, 0x53, 0x74, 0x61, 0x74,
0x75, 0x73, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x62, 0x0a, 0x0f, 0x55, 0x70,
0x64, 0x61, 0x74, 0x65, 0x41, 0x70, 0x70, 0x53, 0x74, 0x61, 0x74, 0x75, 0x73, 0x12, 0x26, 0x2e,
0x63, 0x6f, 0x64, 0x65, 0x72, 0x2e, 0x61, 0x67, 0x65, 0x6e, 0x74, 0x2e, 0x76, 0x32, 0x2e, 0x55,
0x70, 0x64, 0x61, 0x74, 0x65, 0x41, 0x70, 0x70, 0x53, 0x74, 0x61, 0x74, 0x75, 0x73, 0x52, 0x65,
0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x27, 0x2e, 0x63, 0x6f, 0x64, 0x65, 0x72, 0x2e, 0x61, 0x67,
0x65, 0x6e, 0x74, 0x2e, 0x76, 0x32, 0x2e, 0x55, 0x70, 0x64, 0x61, 0x74, 0x65, 0x41, 0x70, 0x70,
0x53, 0x74, 0x61, 0x74, 0x75, 0x73, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x42, 0x33,
0x5a, 0x31, 0x67, 0x69, 0x74, 0x68, 0x75, 0x62, 0x2e, 0x63, 0x6f, 0x6d, 0x2f, 0x63, 0x6f, 0x64,
0x65, 0x72, 0x2f, 0x63, 0x6f, 0x64, 0x65, 0x72, 0x2f, 0x76, 0x32, 0x2f, 0x61, 0x67, 0x65, 0x6e,
0x74, 0x2f, 0x61, 0x67, 0x65, 0x6e, 0x74, 0x73, 0x6f, 0x63, 0x6b, 0x65, 0x74, 0x2f, 0x70, 0x72,
0x6f, 0x74, 0x6f, 0x62, 0x06, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x33,
0x79, 0x6e, 0x63, 0x53, 0x74, 0x61, 0x72, 0x74, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65,
0x12, 0x59, 0x0a, 0x08, 0x53, 0x79, 0x6e, 0x63, 0x57, 0x61, 0x6e, 0x74, 0x12, 0x25, 0x2e, 0x63,
0x6f, 0x64, 0x65, 0x72, 0x2e, 0x61, 0x67, 0x65, 0x6e, 0x74, 0x73, 0x6f, 0x63, 0x6b, 0x65, 0x74,
0x2e, 0x76, 0x31, 0x2e, 0x53, 0x79, 0x6e, 0x63, 0x57, 0x61, 0x6e, 0x74, 0x52, 0x65, 0x71, 0x75,
0x65, 0x73, 0x74, 0x1a, 0x26, 0x2e, 0x63, 0x6f, 0x64, 0x65, 0x72, 0x2e, 0x61, 0x67, 0x65, 0x6e,
0x74, 0x73, 0x6f, 0x63, 0x6b, 0x65, 0x74, 0x2e, 0x76, 0x31, 0x2e, 0x53, 0x79, 0x6e, 0x63, 0x57,
0x61, 0x6e, 0x74, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x65, 0x0a, 0x0c, 0x53,
0x79, 0x6e, 0x63, 0x43, 0x6f, 0x6d, 0x70, 0x6c, 0x65, 0x74, 0x65, 0x12, 0x29, 0x2e, 0x63, 0x6f,
0x64, 0x65, 0x72, 0x2e, 0x61, 0x67, 0x65, 0x6e, 0x74, 0x73, 0x6f, 0x63, 0x6b, 0x65, 0x74, 0x2e,
0x76, 0x31, 0x2e, 0x53, 0x79, 0x6e, 0x63, 0x43, 0x6f, 0x6d, 0x70, 0x6c, 0x65, 0x74, 0x65, 0x52,
0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x2a, 0x2e, 0x63, 0x6f, 0x64, 0x65, 0x72, 0x2e, 0x61,
0x67, 0x65, 0x6e, 0x74, 0x73, 0x6f, 0x63, 0x6b, 0x65, 0x74, 0x2e, 0x76, 0x31, 0x2e, 0x53, 0x79,
0x6e, 0x63, 0x43, 0x6f, 0x6d, 0x70, 0x6c, 0x65, 0x74, 0x65, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e,
0x73, 0x65, 0x12, 0x5c, 0x0a, 0x09, 0x53, 0x79, 0x6e, 0x63, 0x52, 0x65, 0x61, 0x64, 0x79, 0x12,
0x26, 0x2e, 0x63, 0x6f, 0x64, 0x65, 0x72, 0x2e, 0x61, 0x67, 0x65, 0x6e, 0x74, 0x73, 0x6f, 0x63,
0x6b, 0x65, 0x74, 0x2e, 0x76, 0x31, 0x2e, 0x53, 0x79, 0x6e, 0x63, 0x52, 0x65, 0x61, 0x64, 0x79,
0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x27, 0x2e, 0x63, 0x6f, 0x64, 0x65, 0x72, 0x2e,
0x61, 0x67, 0x65, 0x6e, 0x74, 0x73, 0x6f, 0x63, 0x6b, 0x65, 0x74, 0x2e, 0x76, 0x31, 0x2e, 0x53,
0x79, 0x6e, 0x63, 0x52, 0x65, 0x61, 0x64, 0x79, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65,
0x12, 0x5f, 0x0a, 0x0a, 0x53, 0x79, 0x6e, 0x63, 0x53, 0x74, 0x61, 0x74, 0x75, 0x73, 0x12, 0x27,
0x2e, 0x63, 0x6f, 0x64, 0x65, 0x72, 0x2e, 0x61, 0x67, 0x65, 0x6e, 0x74, 0x73, 0x6f, 0x63, 0x6b,
0x65, 0x74, 0x2e, 0x76, 0x31, 0x2e, 0x53, 0x79, 0x6e, 0x63, 0x53, 0x74, 0x61, 0x74, 0x75, 0x73,
0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x28, 0x2e, 0x63, 0x6f, 0x64, 0x65, 0x72, 0x2e,
0x61, 0x67, 0x65, 0x6e, 0x74, 0x73, 0x6f, 0x63, 0x6b, 0x65, 0x74, 0x2e, 0x76, 0x31, 0x2e, 0x53,
0x79, 0x6e, 0x63, 0x53, 0x74, 0x61, 0x74, 0x75, 0x73, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73,
0x65, 0x42, 0x33, 0x5a, 0x31, 0x67, 0x69, 0x74, 0x68, 0x75, 0x62, 0x2e, 0x63, 0x6f, 0x6d, 0x2f,
0x63, 0x6f, 0x64, 0x65, 0x72, 0x2f, 0x63, 0x6f, 0x64, 0x65, 0x72, 0x2f, 0x76, 0x32, 0x2f, 0x61,
0x67, 0x65, 0x6e, 0x74, 0x2f, 0x61, 0x67, 0x65, 0x6e, 0x74, 0x73, 0x6f, 0x63, 0x6b, 0x65, 0x74,
0x2f, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x06, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x33,
}
var (
@@ -758,21 +749,19 @@ func file_agent_agentsocket_proto_agentsocket_proto_rawDescGZIP() []byte {
var file_agent_agentsocket_proto_agentsocket_proto_msgTypes = make([]protoimpl.MessageInfo, 13)
var file_agent_agentsocket_proto_agentsocket_proto_goTypes = []interface{}{
(*PingRequest)(nil), // 0: coder.agentsocket.v1.PingRequest
(*PingResponse)(nil), // 1: coder.agentsocket.v1.PingResponse
(*SyncStartRequest)(nil), // 2: coder.agentsocket.v1.SyncStartRequest
(*SyncStartResponse)(nil), // 3: coder.agentsocket.v1.SyncStartResponse
(*SyncWantRequest)(nil), // 4: coder.agentsocket.v1.SyncWantRequest
(*SyncWantResponse)(nil), // 5: coder.agentsocket.v1.SyncWantResponse
(*SyncCompleteRequest)(nil), // 6: coder.agentsocket.v1.SyncCompleteRequest
(*SyncCompleteResponse)(nil), // 7: coder.agentsocket.v1.SyncCompleteResponse
(*SyncReadyRequest)(nil), // 8: coder.agentsocket.v1.SyncReadyRequest
(*SyncReadyResponse)(nil), // 9: coder.agentsocket.v1.SyncReadyResponse
(*SyncStatusRequest)(nil), // 10: coder.agentsocket.v1.SyncStatusRequest
(*DependencyInfo)(nil), // 11: coder.agentsocket.v1.DependencyInfo
(*SyncStatusResponse)(nil), // 12: coder.agentsocket.v1.SyncStatusResponse
(*proto.UpdateAppStatusRequest)(nil), // 13: coder.agent.v2.UpdateAppStatusRequest
(*proto.UpdateAppStatusResponse)(nil), // 14: coder.agent.v2.UpdateAppStatusResponse
(*PingRequest)(nil), // 0: coder.agentsocket.v1.PingRequest
(*PingResponse)(nil), // 1: coder.agentsocket.v1.PingResponse
(*SyncStartRequest)(nil), // 2: coder.agentsocket.v1.SyncStartRequest
(*SyncStartResponse)(nil), // 3: coder.agentsocket.v1.SyncStartResponse
(*SyncWantRequest)(nil), // 4: coder.agentsocket.v1.SyncWantRequest
(*SyncWantResponse)(nil), // 5: coder.agentsocket.v1.SyncWantResponse
(*SyncCompleteRequest)(nil), // 6: coder.agentsocket.v1.SyncCompleteRequest
(*SyncCompleteResponse)(nil), // 7: coder.agentsocket.v1.SyncCompleteResponse
(*SyncReadyRequest)(nil), // 8: coder.agentsocket.v1.SyncReadyRequest
(*SyncReadyResponse)(nil), // 9: coder.agentsocket.v1.SyncReadyResponse
(*SyncStatusRequest)(nil), // 10: coder.agentsocket.v1.SyncStatusRequest
(*DependencyInfo)(nil), // 11: coder.agentsocket.v1.DependencyInfo
(*SyncStatusResponse)(nil), // 12: coder.agentsocket.v1.SyncStatusResponse
}
var file_agent_agentsocket_proto_agentsocket_proto_depIdxs = []int32{
11, // 0: coder.agentsocket.v1.SyncStatusResponse.dependencies:type_name -> coder.agentsocket.v1.DependencyInfo
@@ -782,16 +771,14 @@ var file_agent_agentsocket_proto_agentsocket_proto_depIdxs = []int32{
6, // 4: coder.agentsocket.v1.AgentSocket.SyncComplete:input_type -> coder.agentsocket.v1.SyncCompleteRequest
8, // 5: coder.agentsocket.v1.AgentSocket.SyncReady:input_type -> coder.agentsocket.v1.SyncReadyRequest
10, // 6: coder.agentsocket.v1.AgentSocket.SyncStatus:input_type -> coder.agentsocket.v1.SyncStatusRequest
13, // 7: coder.agentsocket.v1.AgentSocket.UpdateAppStatus:input_type -> coder.agent.v2.UpdateAppStatusRequest
1, // 8: coder.agentsocket.v1.AgentSocket.Ping:output_type -> coder.agentsocket.v1.PingResponse
3, // 9: coder.agentsocket.v1.AgentSocket.SyncStart:output_type -> coder.agentsocket.v1.SyncStartResponse
5, // 10: coder.agentsocket.v1.AgentSocket.SyncWant:output_type -> coder.agentsocket.v1.SyncWantResponse
7, // 11: coder.agentsocket.v1.AgentSocket.SyncComplete:output_type -> coder.agentsocket.v1.SyncCompleteResponse
9, // 12: coder.agentsocket.v1.AgentSocket.SyncReady:output_type -> coder.agentsocket.v1.SyncReadyResponse
12, // 13: coder.agentsocket.v1.AgentSocket.SyncStatus:output_type -> coder.agentsocket.v1.SyncStatusResponse
14, // 14: coder.agentsocket.v1.AgentSocket.UpdateAppStatus:output_type -> coder.agent.v2.UpdateAppStatusResponse
8, // [8:15] is the sub-list for method output_type
1, // [1:8] is the sub-list for method input_type
1, // 7: coder.agentsocket.v1.AgentSocket.Ping:output_type -> coder.agentsocket.v1.PingResponse
3, // 8: coder.agentsocket.v1.AgentSocket.SyncStart:output_type -> coder.agentsocket.v1.SyncStartResponse
5, // 9: coder.agentsocket.v1.AgentSocket.SyncWant:output_type -> coder.agentsocket.v1.SyncWantResponse
7, // 10: coder.agentsocket.v1.AgentSocket.SyncComplete:output_type -> coder.agentsocket.v1.SyncCompleteResponse
9, // 11: coder.agentsocket.v1.AgentSocket.SyncReady:output_type -> coder.agentsocket.v1.SyncReadyResponse
12, // 12: coder.agentsocket.v1.AgentSocket.SyncStatus:output_type -> coder.agentsocket.v1.SyncStatusResponse
7, // [7:13] is the sub-list for method output_type
1, // [1:7] is the sub-list for method input_type
1, // [1:1] is the sub-list for extension type_name
1, // [1:1] is the sub-list for extension extendee
0, // [0:1] is the sub-list for field type_name
@@ -3,8 +3,6 @@ option go_package = "github.com/coder/coder/v2/agent/agentsocket/proto";
package coder.agentsocket.v1;
import "agent/proto/agent.proto";
message PingRequest {}
message PingResponse {}
@@ -68,6 +66,4 @@ service AgentSocket {
rpc SyncReady(SyncReadyRequest) returns (SyncReadyResponse);
// Get the status of a unit and list its dependencies.
rpc SyncStatus(SyncStatusRequest) returns (SyncStatusResponse);
// Update app status, forwarded to coderd.
rpc UpdateAppStatus(coder.agent.v2.UpdateAppStatusRequest) returns (coder.agent.v2.UpdateAppStatusResponse);
}
+1 -42
View File
@@ -7,7 +7,6 @@ package proto
import (
context "context"
errors "errors"
proto1 "github.com/coder/coder/v2/agent/proto"
protojson "google.golang.org/protobuf/encoding/protojson"
proto "google.golang.org/protobuf/proto"
drpc "storj.io/drpc"
@@ -45,7 +44,6 @@ type DRPCAgentSocketClient interface {
SyncComplete(ctx context.Context, in *SyncCompleteRequest) (*SyncCompleteResponse, error)
SyncReady(ctx context.Context, in *SyncReadyRequest) (*SyncReadyResponse, error)
SyncStatus(ctx context.Context, in *SyncStatusRequest) (*SyncStatusResponse, error)
UpdateAppStatus(ctx context.Context, in *proto1.UpdateAppStatusRequest) (*proto1.UpdateAppStatusResponse, error)
}
type drpcAgentSocketClient struct {
@@ -112,15 +110,6 @@ func (c *drpcAgentSocketClient) SyncStatus(ctx context.Context, in *SyncStatusRe
return out, nil
}
func (c *drpcAgentSocketClient) UpdateAppStatus(ctx context.Context, in *proto1.UpdateAppStatusRequest) (*proto1.UpdateAppStatusResponse, error) {
out := new(proto1.UpdateAppStatusResponse)
err := c.cc.Invoke(ctx, "/coder.agentsocket.v1.AgentSocket/UpdateAppStatus", drpcEncoding_File_agent_agentsocket_proto_agentsocket_proto{}, in, out)
if err != nil {
return nil, err
}
return out, nil
}
type DRPCAgentSocketServer interface {
Ping(context.Context, *PingRequest) (*PingResponse, error)
SyncStart(context.Context, *SyncStartRequest) (*SyncStartResponse, error)
@@ -128,7 +117,6 @@ type DRPCAgentSocketServer interface {
SyncComplete(context.Context, *SyncCompleteRequest) (*SyncCompleteResponse, error)
SyncReady(context.Context, *SyncReadyRequest) (*SyncReadyResponse, error)
SyncStatus(context.Context, *SyncStatusRequest) (*SyncStatusResponse, error)
UpdateAppStatus(context.Context, *proto1.UpdateAppStatusRequest) (*proto1.UpdateAppStatusResponse, error)
}
type DRPCAgentSocketUnimplementedServer struct{}
@@ -157,13 +145,9 @@ func (s *DRPCAgentSocketUnimplementedServer) SyncStatus(context.Context, *SyncSt
return nil, drpcerr.WithCode(errors.New("Unimplemented"), drpcerr.Unimplemented)
}
func (s *DRPCAgentSocketUnimplementedServer) UpdateAppStatus(context.Context, *proto1.UpdateAppStatusRequest) (*proto1.UpdateAppStatusResponse, error) {
return nil, drpcerr.WithCode(errors.New("Unimplemented"), drpcerr.Unimplemented)
}
type DRPCAgentSocketDescription struct{}
func (DRPCAgentSocketDescription) NumMethods() int { return 7 }
func (DRPCAgentSocketDescription) NumMethods() int { return 6 }
func (DRPCAgentSocketDescription) Method(n int) (string, drpc.Encoding, drpc.Receiver, interface{}, bool) {
switch n {
@@ -221,15 +205,6 @@ func (DRPCAgentSocketDescription) Method(n int) (string, drpc.Encoding, drpc.Rec
in1.(*SyncStatusRequest),
)
}, DRPCAgentSocketServer.SyncStatus, true
case 6:
return "/coder.agentsocket.v1.AgentSocket/UpdateAppStatus", drpcEncoding_File_agent_agentsocket_proto_agentsocket_proto{},
func(srv interface{}, ctx context.Context, in1, in2 interface{}) (drpc.Message, error) {
return srv.(DRPCAgentSocketServer).
UpdateAppStatus(
ctx,
in1.(*proto1.UpdateAppStatusRequest),
)
}, DRPCAgentSocketServer.UpdateAppStatus, true
default:
return "", nil, nil, nil, false
}
@@ -334,19 +309,3 @@ func (x *drpcAgentSocket_SyncStatusStream) SendAndClose(m *SyncStatusResponse) e
}
return x.CloseSend()
}
type DRPCAgentSocket_UpdateAppStatusStream interface {
drpc.Stream
SendAndClose(*proto1.UpdateAppStatusResponse) error
}
type drpcAgentSocket_UpdateAppStatusStream struct {
drpc.Stream
}
func (x *drpcAgentSocket_UpdateAppStatusStream) SendAndClose(m *proto1.UpdateAppStatusResponse) error {
if err := x.MsgSend(m, drpcEncoding_File_agent_agentsocket_proto_agentsocket_proto{}); err != nil {
return err
}
return x.CloseSend()
}
+1 -4
View File
@@ -8,13 +8,10 @@ import "github.com/coder/coder/v2/apiversion"
// - Initial release
// - Ping
// - Sync operations: SyncStart, SyncWant, SyncComplete, SyncWait, SyncStatus
//
// API v1.1:
// - UpdateAppStatus RPC (forwarded to coderd)
const (
CurrentMajor = 1
CurrentMinor = 1
CurrentMinor = 0
)
var CurrentVersion = apiversion.New(CurrentMajor, CurrentMinor)
-12
View File
@@ -12,7 +12,6 @@ import (
"cdr.dev/slog/v3"
"github.com/coder/coder/v2/agent/agentsocket/proto"
agentproto "github.com/coder/coder/v2/agent/proto"
"github.com/coder/coder/v2/agent/unit"
"github.com/coder/coder/v2/codersdk/drpcsdk"
)
@@ -121,17 +120,6 @@ func (s *Server) Close() error {
return nil
}
// SetAgentAPI sets the agent API client used to forward requests
// to coderd.
func (s *Server) SetAgentAPI(api agentproto.DRPCAgentClient28) {
s.service.SetAgentAPI(api)
}
// ClearAgentAPI clears the agent API client.
func (s *Server) ClearAgentAPI() {
s.service.ClearAgentAPI()
}
func (s *Server) acceptConnections() {
// In an edge case, Close() might race with acceptConnections() and set s.listener to nil.
// Therefore, we grab a copy of the listener under a lock. We might still get a nil listener,
+103 -2
View File
@@ -1,22 +1,37 @@
package agentsocket_test
import (
"context"
"path/filepath"
"runtime"
"testing"
"github.com/google/uuid"
"github.com/spf13/afero"
"github.com/stretchr/testify/require"
"cdr.dev/slog/v3"
"github.com/coder/coder/v2/agent"
"github.com/coder/coder/v2/agent/agentsocket"
"github.com/coder/coder/v2/agent/agenttest"
agentproto "github.com/coder/coder/v2/agent/proto"
"github.com/coder/coder/v2/codersdk/agentsdk"
"github.com/coder/coder/v2/tailnet"
"github.com/coder/coder/v2/tailnet/tailnettest"
"github.com/coder/coder/v2/testutil"
)
func TestServer(t *testing.T) {
t.Parallel()
if runtime.GOOS == "windows" {
t.Skip("agentsocket is not supported on Windows")
}
t.Run("StartStop", func(t *testing.T) {
t.Parallel()
socketPath := testutil.AgentSocketPath(t)
socketPath := filepath.Join(t.TempDir(), "test.sock")
logger := slog.Make().Leveled(slog.LevelDebug)
server, err := agentsocket.NewServer(logger, agentsocket.WithPath(socketPath))
require.NoError(t, err)
@@ -26,7 +41,7 @@ func TestServer(t *testing.T) {
t.Run("AlreadyStarted", func(t *testing.T) {
t.Parallel()
socketPath := testutil.AgentSocketPath(t)
socketPath := filepath.Join(t.TempDir(), "test.sock")
logger := slog.Make().Leveled(slog.LevelDebug)
server1, err := agentsocket.NewServer(logger, agentsocket.WithPath(socketPath))
require.NoError(t, err)
@@ -34,4 +49,90 @@ func TestServer(t *testing.T) {
_, err = agentsocket.NewServer(logger, agentsocket.WithPath(socketPath))
require.ErrorContains(t, err, "create socket")
})
t.Run("AutoSocketPath", func(t *testing.T) {
t.Parallel()
socketPath := filepath.Join(t.TempDir(), "test.sock")
logger := slog.Make().Leveled(slog.LevelDebug)
server, err := agentsocket.NewServer(logger, agentsocket.WithPath(socketPath))
require.NoError(t, err)
require.NoError(t, server.Close())
})
}
func TestServerWindowsNotSupported(t *testing.T) {
t.Parallel()
if runtime.GOOS != "windows" {
t.Skip("this test only runs on Windows")
}
t.Run("NewServer", func(t *testing.T) {
t.Parallel()
socketPath := filepath.Join(t.TempDir(), "test.sock")
logger := slog.Make().Leveled(slog.LevelDebug)
_, err := agentsocket.NewServer(logger, agentsocket.WithPath(socketPath))
require.ErrorContains(t, err, "agentsocket is not supported on Windows")
})
t.Run("NewClient", func(t *testing.T) {
t.Parallel()
_, err := agentsocket.NewClient(context.Background(), agentsocket.WithPath("test.sock"))
require.ErrorContains(t, err, "agentsocket is not supported on Windows")
})
}
func TestAgentInitializesOnWindowsWithoutSocketServer(t *testing.T) {
t.Parallel()
if runtime.GOOS != "windows" {
t.Skip("this test only runs on Windows")
}
ctx := testutil.Context(t, testutil.WaitShort)
logger := testutil.Logger(t).Named("agent")
derpMap, _ := tailnettest.RunDERPAndSTUN(t)
coordinator := tailnet.NewCoordinator(logger)
t.Cleanup(func() {
_ = coordinator.Close()
})
statsCh := make(chan *agentproto.Stats, 50)
agentID := uuid.New()
manifest := agentsdk.Manifest{
AgentID: agentID,
AgentName: "test-agent",
WorkspaceName: "test-workspace",
OwnerName: "test-user",
WorkspaceID: uuid.New(),
DERPMap: derpMap,
}
client := agenttest.NewClient(t, logger.Named("agenttest"), agentID, manifest, statsCh, coordinator)
t.Cleanup(client.Close)
options := agent.Options{
Client: client,
Filesystem: afero.NewMemMapFs(),
Logger: logger.Named("agent"),
ReconnectingPTYTimeout: testutil.WaitShort,
EnvironmentVariables: map[string]string{},
SocketPath: "",
}
agnt := agent.New(options)
t.Cleanup(func() {
_ = agnt.Close()
})
startup := testutil.TryReceive(ctx, t, client.GetStartup())
require.NotNil(t, startup, "agent should send startup message")
err := agnt.Close()
require.NoError(t, err, "agent should close cleanly")
}
+1 -38
View File
@@ -3,46 +3,22 @@ package agentsocket
import (
"context"
"errors"
"sync"
"golang.org/x/xerrors"
"cdr.dev/slog/v3"
"github.com/coder/coder/v2/agent/agentsocket/proto"
agentproto "github.com/coder/coder/v2/agent/proto"
"github.com/coder/coder/v2/agent/unit"
)
var _ proto.DRPCAgentSocketServer = (*DRPCAgentSocketService)(nil)
var (
ErrUnitManagerNotAvailable = xerrors.New("unit manager not available")
ErrAgentAPINotConnected = xerrors.New("agent not connected to coderd")
)
var ErrUnitManagerNotAvailable = xerrors.New("unit manager not available")
// DRPCAgentSocketService implements the DRPC agent socket service.
type DRPCAgentSocketService struct {
unitManager *unit.Manager
logger slog.Logger
mu sync.Mutex
agentAPI agentproto.DRPCAgentClient28
}
// SetAgentAPI sets the agent API client used to forward requests
// to coderd. This is called when the agent connects to coderd.
func (s *DRPCAgentSocketService) SetAgentAPI(api agentproto.DRPCAgentClient28) {
s.mu.Lock()
defer s.mu.Unlock()
s.agentAPI = api
}
// ClearAgentAPI clears the agent API client. This is called when
// the agent disconnects from coderd.
func (s *DRPCAgentSocketService) ClearAgentAPI() {
s.mu.Lock()
defer s.mu.Unlock()
s.agentAPI = nil
}
// Ping responds to a ping request to check if the service is alive.
@@ -174,16 +150,3 @@ func (s *DRPCAgentSocketService) SyncStatus(_ context.Context, req *proto.SyncSt
Dependencies: depInfos,
}, nil
}
// UpdateAppStatus forwards an app status update to coderd via the
// agent API. Returns an error if the agent is not connected.
func (s *DRPCAgentSocketService) UpdateAppStatus(ctx context.Context, req *agentproto.UpdateAppStatusRequest) (*agentproto.UpdateAppStatusResponse, error) {
s.mu.Lock()
api := s.agentAPI
s.mu.Unlock()
if api == nil {
return nil, ErrAgentAPINotConnected
}
return api.UpdateAppStatus(ctx, req)
}
+17 -148
View File
@@ -2,29 +2,18 @@ package agentsocket_test
import (
"context"
"path/filepath"
"runtime"
"testing"
"github.com/stretchr/testify/require"
"golang.org/x/xerrors"
"cdr.dev/slog/v3"
"github.com/coder/coder/v2/agent/agentsocket"
agentproto "github.com/coder/coder/v2/agent/proto"
"github.com/coder/coder/v2/agent/unit"
"github.com/coder/coder/v2/testutil"
)
// fakeAgentAPI implements just the UpdateAppStatus method of
// DRPCAgentClient28 for testing. Calling any other method will panic.
type fakeAgentAPI struct {
agentproto.DRPCAgentClient28
updateAppStatus func(context.Context, *agentproto.UpdateAppStatusRequest) (*agentproto.UpdateAppStatusResponse, error)
}
func (m *fakeAgentAPI) UpdateAppStatus(ctx context.Context, req *agentproto.UpdateAppStatusRequest) (*agentproto.UpdateAppStatusResponse, error) {
return m.updateAppStatus(ctx, req)
}
// newSocketClient creates a DRPC client connected to the Unix socket at the given path.
func newSocketClient(ctx context.Context, t *testing.T, socketPath string) *agentsocket.Client {
t.Helper()
@@ -41,10 +30,14 @@ func newSocketClient(ctx context.Context, t *testing.T, socketPath string) *agen
func TestDRPCAgentSocketService(t *testing.T) {
t.Parallel()
if runtime.GOOS == "windows" {
t.Skip("agentsocket is not supported on Windows")
}
t.Run("Ping", func(t *testing.T) {
t.Parallel()
socketPath := testutil.AgentSocketPath(t)
socketPath := filepath.Join(testutil.TempDirUnixSocket(t), "test.sock")
ctx := testutil.Context(t, testutil.WaitShort)
server, err := agentsocket.NewServer(
slog.Make().Leveled(slog.LevelDebug),
@@ -64,7 +57,7 @@ func TestDRPCAgentSocketService(t *testing.T) {
t.Run("NewUnit", func(t *testing.T) {
t.Parallel()
socketPath := testutil.AgentSocketPath(t)
socketPath := filepath.Join(testutil.TempDirUnixSocket(t), "test.sock")
ctx := testutil.Context(t, testutil.WaitShort)
server, err := agentsocket.NewServer(
slog.Make().Leveled(slog.LevelDebug),
@@ -86,7 +79,7 @@ func TestDRPCAgentSocketService(t *testing.T) {
t.Run("UnitAlreadyStarted", func(t *testing.T) {
t.Parallel()
socketPath := testutil.AgentSocketPath(t)
socketPath := filepath.Join(testutil.TempDirUnixSocket(t), "test.sock")
ctx := testutil.Context(t, testutil.WaitShort)
server, err := agentsocket.NewServer(
slog.Make().Leveled(slog.LevelDebug),
@@ -116,7 +109,7 @@ func TestDRPCAgentSocketService(t *testing.T) {
t.Run("UnitAlreadyCompleted", func(t *testing.T) {
t.Parallel()
socketPath := testutil.AgentSocketPath(t)
socketPath := filepath.Join(testutil.TempDirUnixSocket(t), "test.sock")
ctx := testutil.Context(t, testutil.WaitShort)
server, err := agentsocket.NewServer(
slog.Make().Leveled(slog.LevelDebug),
@@ -155,7 +148,7 @@ func TestDRPCAgentSocketService(t *testing.T) {
t.Run("UnitNotReady", func(t *testing.T) {
t.Parallel()
socketPath := testutil.AgentSocketPath(t)
socketPath := filepath.Join(testutil.TempDirUnixSocket(t), "test.sock")
ctx := testutil.Context(t, testutil.WaitShort)
server, err := agentsocket.NewServer(
slog.Make().Leveled(slog.LevelDebug),
@@ -185,7 +178,7 @@ func TestDRPCAgentSocketService(t *testing.T) {
t.Run("NewUnits", func(t *testing.T) {
t.Parallel()
socketPath := testutil.AgentSocketPath(t)
socketPath := filepath.Join(testutil.TempDirUnixSocket(t), "test.sock")
ctx := testutil.Context(t, testutil.WaitShort)
server, err := agentsocket.NewServer(
slog.Make().Leveled(slog.LevelDebug),
@@ -210,7 +203,7 @@ func TestDRPCAgentSocketService(t *testing.T) {
t.Run("DependencyAlreadyRegistered", func(t *testing.T) {
t.Parallel()
socketPath := testutil.AgentSocketPath(t)
socketPath := filepath.Join(testutil.TempDirUnixSocket(t), "test.sock")
ctx := testutil.Context(t, testutil.WaitShort)
server, err := agentsocket.NewServer(
slog.Make().Leveled(slog.LevelDebug),
@@ -245,7 +238,7 @@ func TestDRPCAgentSocketService(t *testing.T) {
t.Run("DependencyAddedAfterDependentStarted", func(t *testing.T) {
t.Parallel()
socketPath := testutil.AgentSocketPath(t)
socketPath := filepath.Join(testutil.TempDirUnixSocket(t), "test.sock")
ctx := testutil.Context(t, testutil.WaitShort)
server, err := agentsocket.NewServer(
slog.Make().Leveled(slog.LevelDebug),
@@ -287,7 +280,7 @@ func TestDRPCAgentSocketService(t *testing.T) {
t.Run("UnregisteredUnit", func(t *testing.T) {
t.Parallel()
socketPath := testutil.AgentSocketPath(t)
socketPath := filepath.Join(testutil.TempDirUnixSocket(t), "test.sock")
ctx := testutil.Context(t, testutil.WaitShort)
server, err := agentsocket.NewServer(
slog.Make().Leveled(slog.LevelDebug),
@@ -306,7 +299,7 @@ func TestDRPCAgentSocketService(t *testing.T) {
t.Run("UnitNotReady", func(t *testing.T) {
t.Parallel()
socketPath := testutil.AgentSocketPath(t)
socketPath := filepath.Join(testutil.TempDirUnixSocket(t), "test.sock")
ctx := testutil.Context(t, testutil.WaitShort)
server, err := agentsocket.NewServer(
slog.Make().Leveled(slog.LevelDebug),
@@ -330,7 +323,7 @@ func TestDRPCAgentSocketService(t *testing.T) {
t.Run("UnitReady", func(t *testing.T) {
t.Parallel()
socketPath := testutil.AgentSocketPath(t)
socketPath := filepath.Join(testutil.TempDirUnixSocket(t), "test.sock")
ctx := testutil.Context(t, testutil.WaitShort)
server, err := agentsocket.NewServer(
slog.Make().Leveled(slog.LevelDebug),
@@ -364,128 +357,4 @@ func TestDRPCAgentSocketService(t *testing.T) {
require.True(t, ready)
})
})
t.Run("UpdateAppStatus", func(t *testing.T) {
t.Parallel()
t.Run("NotConnected", func(t *testing.T) {
t.Parallel()
socketPath := testutil.AgentSocketPath(t)
ctx := testutil.Context(t, testutil.WaitShort)
server, err := agentsocket.NewServer(
slog.Make().Leveled(slog.LevelDebug),
agentsocket.WithPath(socketPath),
)
require.NoError(t, err)
defer server.Close()
client := newSocketClient(ctx, t, socketPath)
_, err = client.UpdateAppStatus(ctx, &agentproto.UpdateAppStatusRequest{
Slug: "test-app",
State: agentproto.UpdateAppStatusRequest_WORKING,
Message: "doing stuff",
})
require.ErrorContains(t, err, "not connected")
})
t.Run("ForwardsToAgentAPI", func(t *testing.T) {
t.Parallel()
socketPath := testutil.AgentSocketPath(t)
ctx := testutil.Context(t, testutil.WaitShort)
server, err := agentsocket.NewServer(
slog.Make().Leveled(slog.LevelDebug),
agentsocket.WithPath(socketPath),
)
require.NoError(t, err)
defer server.Close()
var gotReq *agentproto.UpdateAppStatusRequest
mock := &fakeAgentAPI{
updateAppStatus: func(_ context.Context, req *agentproto.UpdateAppStatusRequest) (*agentproto.UpdateAppStatusResponse, error) {
gotReq = req
return &agentproto.UpdateAppStatusResponse{}, nil
},
}
server.SetAgentAPI(mock)
client := newSocketClient(ctx, t, socketPath)
resp, err := client.UpdateAppStatus(ctx, &agentproto.UpdateAppStatusRequest{
Slug: "test-app",
State: agentproto.UpdateAppStatusRequest_IDLE,
Message: "all done",
Uri: "https://example.com",
})
require.NoError(t, err)
require.NotNil(t, resp)
require.NotNil(t, gotReq)
require.Equal(t, "test-app", gotReq.Slug)
require.Equal(t, agentproto.UpdateAppStatusRequest_IDLE, gotReq.State)
require.Equal(t, "all done", gotReq.Message)
require.Equal(t, "https://example.com", gotReq.Uri)
})
t.Run("ForwardsError", func(t *testing.T) {
t.Parallel()
socketPath := testutil.AgentSocketPath(t)
ctx := testutil.Context(t, testutil.WaitShort)
server, err := agentsocket.NewServer(
slog.Make().Leveled(slog.LevelDebug),
agentsocket.WithPath(socketPath),
)
require.NoError(t, err)
defer server.Close()
mock := &fakeAgentAPI{
updateAppStatus: func(context.Context, *agentproto.UpdateAppStatusRequest) (*agentproto.UpdateAppStatusResponse, error) {
return nil, xerrors.New("app not found")
},
}
server.SetAgentAPI(mock)
client := newSocketClient(ctx, t, socketPath)
_, err = client.UpdateAppStatus(ctx, &agentproto.UpdateAppStatusRequest{
Slug: "nonexistent",
State: agentproto.UpdateAppStatusRequest_WORKING,
Message: "testing",
})
require.ErrorContains(t, err, "app not found")
})
t.Run("ClearAgentAPI", func(t *testing.T) {
t.Parallel()
socketPath := testutil.AgentSocketPath(t)
ctx := testutil.Context(t, testutil.WaitShort)
server, err := agentsocket.NewServer(
slog.Make().Leveled(slog.LevelDebug),
agentsocket.WithPath(socketPath),
)
require.NoError(t, err)
defer server.Close()
mock := &fakeAgentAPI{
updateAppStatus: func(context.Context, *agentproto.UpdateAppStatusRequest) (*agentproto.UpdateAppStatusResponse, error) {
return &agentproto.UpdateAppStatusResponse{}, nil
},
}
server.SetAgentAPI(mock)
server.ClearAgentAPI()
client := newSocketClient(ctx, t, socketPath)
_, err = client.UpdateAppStatus(ctx, &agentproto.UpdateAppStatusRequest{
Slug: "test-app",
State: agentproto.UpdateAppStatusRequest_WORKING,
Message: "should fail",
})
require.ErrorContains(t, err, "not connected")
})
})
}
+6 -47
View File
@@ -4,60 +4,19 @@ package agentsocket
import (
"context"
"fmt"
"net"
"os"
"os/user"
"strings"
"github.com/Microsoft/go-winio"
"golang.org/x/xerrors"
)
const defaultSocketPath = `\\.\pipe\com.coder.agentsocket`
func createSocket(path string) (net.Listener, error) {
if path == "" {
path = defaultSocketPath
}
if !strings.HasPrefix(path, `\\.\pipe\`) {
return nil, xerrors.Errorf("%q is not a valid local socket path", path)
}
user, err := user.Current()
if err != nil {
return nil, fmt.Errorf("unable to look up current user: %w", err)
}
sid := user.Uid
// SecurityDescriptor is in SDDL format. c.f.
// https://learn.microsoft.com/en-us/windows/win32/secauthz/security-descriptor-string-format for full details.
// D: indicates this is a Discretionary Access Control List (DACL), which is Windows-speak for ACLs that allow or
// deny access (as opposed to SACL which controls audit logging).
// P indicates that this DACL is "protected" from being modified thru inheritance
// () delimit access control entries (ACEs), here we only have one, which, allows (A) generic all (GA) access to our
// specific user's security ID (SID).
//
// Note that although Microsoft docs at https://learn.microsoft.com/en-us/windows/win32/ipc/named-pipes warns that
// named pipes are accessible from remote machines in the general case, the `winio` package sets the flag
// windows.FILE_PIPE_REJECT_REMOTE_CLIENTS when creating pipes, so connections from remote machines are always
// denied. This is important because we sort of expect customers to run the Coder agent under a generic user
// account unless they are very sophisticated. We don't want this socket to cross the boundary of the local machine.
configuration := &winio.PipeConfig{
SecurityDescriptor: fmt.Sprintf("D:P(A;;GA;;;%s)", sid),
}
listener, err := winio.ListenPipe(path, configuration)
if err != nil {
return nil, xerrors.Errorf("failed to open named pipe: %w", err)
}
return listener, nil
func createSocket(_ string) (net.Listener, error) {
return nil, xerrors.New("agentsocket is not supported on Windows")
}
func cleanupSocket(path string) error {
return os.Remove(path)
func cleanupSocket(_ string) error {
return nil
}
func dialSocket(ctx context.Context, path string) (net.Conn, error) {
return winio.DialPipeContext(ctx, path)
func dialSocket(_ context.Context, _ string) (net.Conn, error) {
return nil, xerrors.New("agentsocket is not supported on Windows")
}
-10
View File
@@ -110,11 +110,6 @@ type Config struct {
// X11DisplayOffset is the offset to add to the X11 display number.
// Default is 10.
X11DisplayOffset *int
// X11MaxPort overrides the highest port used for X11 forwarding
// listeners. Defaults to X11MaxPort (6200). Useful in tests
// to shrink the port range and reduce the number of sessions
// required.
X11MaxPort *int
// BlockFileTransfer restricts use of file transfer applications.
BlockFileTransfer bool
// ReportConnection.
@@ -163,10 +158,6 @@ func NewServer(ctx context.Context, logger slog.Logger, prometheusRegistry *prom
offset := X11DefaultDisplayOffset
config.X11DisplayOffset = &offset
}
if config.X11MaxPort == nil {
maxPort := X11MaxPort
config.X11MaxPort = &maxPort
}
if config.UpdateEnv == nil {
config.UpdateEnv = func(current []string) ([]string, error) { return current, nil }
}
@@ -210,7 +201,6 @@ func NewServer(ctx context.Context, logger slog.Logger, prometheusRegistry *prom
x11HandlerErrors: metrics.x11HandlerErrors,
fs: fs,
displayOffset: *config.X11DisplayOffset,
maxPort: *config.X11MaxPort,
sessions: make(map[*x11Session]struct{}),
connections: make(map[net.Conn]struct{}),
network: func() X11Network {
+1 -2
View File
@@ -57,7 +57,6 @@ type x11Forwarder struct {
x11HandlerErrors *prometheus.CounterVec
fs afero.Fs
displayOffset int
maxPort int
// network creates X11 listener sockets. Defaults to osNet{}.
network X11Network
@@ -315,7 +314,7 @@ func (x *x11Forwarder) evictLeastRecentlyUsedSession() {
// the next available port starting from X11StartPort and displayOffset.
func (x *x11Forwarder) createX11Listener(ctx context.Context) (ln net.Listener, display int, err error) {
// Look for an open port to listen on.
for port := X11StartPort + x.displayOffset; port <= x.maxPort; port++ {
for port := X11StartPort + x.displayOffset; port <= X11MaxPort; port++ {
if ctx.Err() != nil {
return nil, -1, ctx.Err()
}
+2 -7
View File
@@ -142,13 +142,8 @@ func TestServer_X11_EvictionLRU(t *testing.T) {
// Use in-process networking for X11 forwarding.
inproc := testutil.NewInProcNet()
// Limit port range so we only need a handful of sessions to fill it
// (the default 190 ports may easily timeout or conflict with other
// ports on the system).
maxPort := agentssh.X11StartPort + agentssh.X11DefaultDisplayOffset + 5
cfg := &agentssh.Config{
X11Net: inproc,
X11MaxPort: &maxPort,
X11Net: inproc,
}
s, err := agentssh.NewServer(ctx, logger, prometheus.NewRegistry(), fs, agentexec.DefaultExecer, cfg)
@@ -177,7 +172,7 @@ func TestServer_X11_EvictionLRU(t *testing.T) {
// configured port range.
startPort := agentssh.X11StartPort + agentssh.X11DefaultDisplayOffset
maxSessions := maxPort - startPort + 1 - 1 // -1 for the blocked port
maxSessions := agentssh.X11MaxPort - startPort + 1 - 1 // -1 for the blocked port
require.Greater(t, maxSessions, 0, "expected a positive maxSessions value")
// shellSession holds references to the session and its standard streams so
-1
View File
@@ -24,7 +24,6 @@ func New(t testing.TB, coderURL *url.URL, agentToken string, opts ...func(*agent
var o agent.Options
log := testutil.Logger(t).Named("agent")
o.Logger = log
o.SocketPath = testutil.AgentSocketPath(t)
for _, opt := range opts {
opt(&o)
-10
View File
@@ -124,12 +124,6 @@ func (c *Client) Close() {
c.derpMapOnce.Do(func() { close(c.derpMapUpdates) })
}
func (c *Client) ConnectRPC28WithRole(ctx context.Context, _ string) (
agentproto.DRPCAgentClient28, proto.DRPCTailnetClient28, error,
) {
return c.ConnectRPC28(ctx)
}
func (c *Client) ConnectRPC28(ctx context.Context) (
agentproto.DRPCAgentClient28, proto.DRPCTailnetClient28, error,
) {
@@ -235,10 +229,6 @@ type FakeAgentAPI struct {
pushResourcesMonitoringUsageFunc func(*agentproto.PushResourcesMonitoringUsageRequest) (*agentproto.PushResourcesMonitoringUsageResponse, error)
}
func (*FakeAgentAPI) UpdateAppStatus(context.Context, *agentproto.UpdateAppStatusRequest) (*agentproto.UpdateAppStatusResponse, error) {
panic("unimplemented")
}
func (f *FakeAgentAPI) GetManifest(context.Context, *agentproto.GetManifestRequest) (*agentproto.Manifest, error) {
return f.manifest, nil
}

Some files were not shown because too many files have changed in this diff Show More