From c8805982083d3bf5816d47fc73ad0c2cc8c22557 Mon Sep 17 00:00:00 2001 From: untra Date: Fri, 11 Sep 2026 17:57:05 -0600 Subject: [PATCH 1/3] advanced, stricter linting ; module adjustments --- .eslintrc.base.json | 12 - .githooks/pre-push | 8 +- .github/dependabot.yml | 3 - .github/workflows/build.yaml | 2 - .oxlintrc.jsonc | 243 ++ CLAUDE.md | 4 +- Dockerfile | 7 +- Dockerfile.local | 10 +- agnt-plugin/alert.js | 2 +- agnt-plugin/create-ticket.js | 2 +- agnt-plugin/export.js | 2 +- agnt-plugin/launch.js | 2 +- agnt-plugin/queue.js | 2 +- agnt-plugin/run-step.js | 10 +- bindings/CoderConfig.ts | 4 +- bindings/RestApiConfig.ts | 9 +- bindings/SectionDefinition.ts | 2 +- bindings/SectionHealth.ts | 2 +- bun.lock | 56 + coder-module/README.md | 80 +- coder-module/main.test.ts | 41 +- coder-module/main.tf | 58 +- coder-module/run.sh | 37 +- crates/relay/src/channel_session.rs | 6 +- crates/relay/src/client.rs | 2 +- crates/relay/src/hub.rs | 10 +- docs/configuration/index.md | 10 + docs/delegators/index.md | 10 +- docs/getting-started/platform-support.md | 1 - docs/getting-started/platforms/coder.md | 136 +- docs/getting-started/platforms/kubernetes.md | 71 +- docs/maturity/index.md | 2 +- docs/schemas/config.json | 205 +- docs/schemas/config.md | 65 +- docs/schemas/openapi.json | 176 +- docs/schemas/state.json | 98 + docs/startup/index.md | 10 +- package.json | 8 +- scripts/ci/check-coder-module.sh | 51 +- shared/types.ts | 33 +- src/agents/launcher/coder.rs | 379 ++- src/app/tickets.rs | 3 +- src/config.rs | 9 +- src/config/targets.rs | 36 +- src/rest/middleware/auth.rs | 18 +- src/ui/status_panel.rs | 58 +- tests/docs_structure.rs | 14 +- tests/svg_icon_standard.rs | 12 +- tests/vertical_parity.rs | 22 +- tests/workflow_mapper_contract.rs | 2 +- ui/package.json | 3 +- ui/src/Layout.tsx | 2 +- ui/src/components/SectionCard.tsx | 4 +- ui/src/components/TicketDetailPanel.tsx | 14 +- ui/src/host.ts | 4 +- ui/src/routes/AgentDetailPage.tsx | 14 +- ui/src/routes/ConfigPage.tsx | 2 +- ui/src/routes/DashboardPage.tsx | 10 +- ui/src/routes/DevicePage.tsx | 3 +- ui/src/routes/IssueTypesPage.tsx | 13 +- ui/src/routes/LoginPage.tsx | 8 +- ui/src/routes/ModelProvidersPage.tsx | 113 +- ui/src/routes/QueuePage.tsx | 14 +- ui/src/routes/SecurityPage.tsx | 37 +- ui/src/routes/SetupPage.tsx | 11 +- ui/src/routes/StatusPage.tsx | 4 +- ui/src/sections-context.tsx | 16 +- ui/vite.config.ts | 2 +- vscode-extension/eslint.config.mjs | 77 - vscode-extension/package-lock.json | 2236 ++++++----------- vscode-extension/package.json | 10 +- vscode-extension/scripts/copy-types.js | 4 +- vscode-extension/src/api-client.ts | 4 +- vscode-extension/src/auth/credentials.ts | 6 +- vscode-extension/src/auth/device-flow.ts | 2 +- vscode-extension/src/auth/sign-in.ts | 9 +- vscode-extension/src/config-panel.ts | 14 +- vscode-extension/src/config-paths.ts | 4 +- vscode-extension/src/extension.ts | 40 +- vscode-extension/src/git-onboarding.ts | 6 +- vscode-extension/src/issuetype-service.ts | 2 +- vscode-extension/src/kanban-onboarding.ts | 13 +- vscode-extension/src/launch-dialog.ts | 8 +- vscode-extension/src/launch-manager.ts | 15 +- vscode-extension/src/mcp-connect.ts | 12 +- vscode-extension/src/operator-binary.ts | 12 +- vscode-extension/src/opr8r.ts | 6 +- .../src/sections/config-section.ts | 2 +- .../src/sections/connections-section.ts | 29 +- .../src/sections/kanban-section.ts | 2 +- vscode-extension/src/sections/types.ts | 4 +- vscode-extension/src/status-provider.ts | 6 +- vscode-extension/src/terminal-manager.ts | 4 +- vscode-extension/src/ticket-parser.ts | 4 +- vscode-extension/src/ticket-provider.ts | 20 +- vscode-extension/src/tickets-dir.ts | 4 +- vscode-extension/src/types.ts | 46 +- vscode-extension/src/walkthrough.ts | 23 +- vscode-extension/src/webhook-server.ts | 35 +- vscode-extension/test/runTest.ts | 4 +- .../test/suite/api-client.test.ts | 24 +- .../test/suite/auth/credentials.test.ts | 8 +- .../test/suite/auth/device-flow.test.ts | 5 +- .../test/suite/command-registration.test.ts | 4 +- .../test/suite/config-panel.test.ts | 10 +- vscode-extension/test/suite/extension.test.ts | 2 +- .../test/suite/helpers/credentials.ts | 2 +- vscode-extension/test/suite/index.ts | 10 +- .../test/suite/integration.test.ts | 16 +- .../test/suite/issuetype-service.test.ts | 15 +- .../test/suite/manifest-parity.test.ts | 6 +- .../test/suite/mcp-connect.test.ts | 13 +- .../test/suite/operator-binary.test.ts | 31 +- .../test/suite/status-provider.test.ts | 21 +- .../test/suite/terminal-manager.test.ts | 2 +- .../test/suite/ticket-parser.test.ts | 8 +- .../test/suite/ticket-provider.test.ts | 18 +- .../test/suite/walkthrough.test.ts | 13 +- vscode-extension/tsconfig.json | 2 +- vscode-extension/tsconfig.webview.json | 2 +- vscode-extension/webpack.webview.config.js | 2 +- vscode-extension/webview-ui/App.tsx | 16 + .../webview-ui/components/SidebarNav.tsx | 4 +- .../components/kanban/MappingPanel.tsx | 15 +- .../components/kanban/MappingRow.tsx | 2 +- .../components/kanban/ProjectRow.tsx | 14 +- .../components/kanban/ProviderCard.tsx | 3 +- .../sections/CodingAgentsSection.tsx | 12 +- .../sections/KanbanProvidersSection.tsx | 4 +- .../sections/ModelProvidersSection.tsx | 44 +- vscode-extension/webview-ui/types/messages.ts | 6 +- vscode-extension/webview-ui/vscodeApi.ts | 4 +- webcomponents/package.json | 3 +- webcomponents/src/elements.ts | 2 +- .../elements/operator-collection-search.ts | 8 +- .../elements/operator-workflow-explorer.tsx | 38 +- webcomponents/src/shared/theme.ts | 15 +- webcomponents/src/workflow/issuetype-to-ir.ts | 28 +- webcomponents/vite.elements.config.ts | 2 +- webcomponents/vite.react.config.ts | 2 +- zed-extension/src/lib.rs | 24 +- 141 files changed, 3037 insertions(+), 2405 deletions(-) delete mode 100644 .eslintrc.base.json create mode 100644 .oxlintrc.jsonc delete mode 100644 vscode-extension/eslint.config.mjs diff --git a/.eslintrc.base.json b/.eslintrc.base.json deleted file mode 100644 index 41b622f1..00000000 --- a/.eslintrc.base.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "$schema": "https://json.schemastore.org/eslintrc", - "rules": { - "@typescript-eslint/no-unused-vars": ["error", { "argsIgnorePattern": "^_" }], - "@typescript-eslint/no-explicit-any": "error", - "@typescript-eslint/explicit-function-return-type": "off", - "@typescript-eslint/explicit-module-boundary-types": "off", - "curly": "error", - "eqeqeq": ["error", "always", { "null": "ignore" }], - "no-throw-literal": "error" - } -} diff --git a/.githooks/pre-push b/.githooks/pre-push index 6a3a55f4..aa04b9e8 100755 --- a/.githooks/pre-push +++ b/.githooks/pre-push @@ -1,9 +1,5 @@ #!/usr/bin/env bash -# Committed pre-push hook. Runs the fast lint gate (`make fmt clippy`) before -# any push so formatting/clippy failures are caught locally instead of on CI. -# Tests are deliberately excluded — they are too slow for a push gate; run -# `make check` or `scripts/cicdprep.sh` before opening a PR. -# +# Committed pre-push hook. Runs the fast lint gate (`make fmt clippy`) before any push so formatting/clippy failures are caught locally # Enable once per clone: make install-hooks (sets core.hooksPath=.githooks) # Bypass in an emergency: git push --no-verify set -euo pipefail @@ -15,7 +11,7 @@ cd "$repo_root" echo "pre-push: running lint checks (fmt + clippy)…" if ! make fmt clippy; then echo - echo "pre-push: lint checks failed — push aborted." >&2 + echo "pre-push: lint checks failed - push aborted." >&2 echo "Fix the issues above, or bypass with 'git push --no-verify' (not recommended)." >&2 exit 1 fi diff --git a/.github/dependabot.yml b/.github/dependabot.yml index a9d6615d..c0f22055 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -78,6 +78,3 @@ updates: - dependency-name: "actions/*" cooldown: default-days: 7 - semver-major-days: 30 - semver-minor-days: 14 - semver-patch-days: 3 diff --git a/.github/workflows/build.yaml b/.github/workflows/build.yaml index 860874e0..c47a522c 100644 --- a/.github/workflows/build.yaml +++ b/.github/workflows/build.yaml @@ -53,8 +53,6 @@ jobs: restore-keys: ${{ runner.os }}-cargo- # The frontend is typed against these; generate before anything compiles. - # bindings/ is committed — the script regenerates and fails on any - # checksum difference vs the checkout (modified or newly exported types). - name: Generate TypeScript bindings and verify they are committed run: | scripts/check-bindings-fresh.sh || { diff --git a/.oxlintrc.jsonc b/.oxlintrc.jsonc new file mode 100644 index 00000000..0c6e2b63 --- /dev/null +++ b/.oxlintrc.jsonc @@ -0,0 +1,243 @@ +{ + "$schema": "./node_modules/oxlint/configuration_schema.json", + "plugins": [ + "eslint", + "typescript", + "unicorn", + "oxc", + "react", + "react-perf", + "jsx-a11y", + "promise" + ], + "categories": { + "correctness": "error", + "suspicious": "error", + "perf": "error", + "pedantic": "off", + "style": "off", + "restriction": "off", + "nursery": "off" + }, + + "options": { "typeAware": true }, + + "env": { "es2022": true }, + + "settings": { + "react": { "version": "19.0" } + }, + + "ignorePatterns": [ + "**/node_modules/**", + "**/dist/**", + "**/out/**", + "**/*.d.ts", + "webcomponents/src/generated/**", + "vscode-extension/src/generated/**", + "vscode-extension/shared/**", + "vscode-extension/.vscode-test/**", + "**/.claude/**", + "target/**", + "docs/_site/**", + "docs/assets/js/**", + "agnt-plugin/**", + "coder-module/**", + "bindings/**", + "shared/**", + "scripts/**", + "zed-extension/**", + "opr8r/**" + ], + + "rules": { + "eslint/no-unused-vars": ["error", { + "args": "after-used", + "argsIgnorePattern": "^_", + "varsIgnorePattern": "^_", + "caughtErrorsIgnorePattern": "^_", + "ignoreRestSiblings": true + }], + "eslint/no-void": ["error", { "allowAsStatement": true }], + "typescript/no-explicit-any": "error", + "eslint/curly": ["error", "all"], + "eslint/eqeqeq": ["error", "always", { "null": "ignore" }], + "typescript/explicit-function-return-type": "off", + "typescript/explicit-module-boundary-types": "off", + // Was webview-ui-only under eslint; no reason for that scoping. + "typescript/consistent-type-assertions": ["error", { + "assertionStyle": "as", + "objectLiteralTypeAssertions": "never" + }], + + // ---- Disabled: these fire on deliberate repo-wide conventions ---- + // React 19 automatic runtime ("jsx": "react-jsx"). + "react/react-in-jsx-scope": "off", + // Collides with the ^_ unused-arg convention above. + "eslint/no-underscore-dangle": "off", + // Sequential awaits are deliberate in vscode-extension orchestration. + "eslint/no-await-in-loop": "off", + // Fires on every `(await res.json()) as T`; recommended or strict either. + "typescript/no-unsafe-type-assertion": "off", + "react/rules-of-hooks": "error", + "react/jsx-no-target-blank": "error", + "react/jsx-no-useless-fragment": "error", + "jsx-a11y/anchor-ambiguous-text": "error", + "typescript/ban-ts-comment": "error", + "typescript/adjacent-overload-signatures": "error", + "typescript/consistent-type-exports": "error", + "typescript/no-empty-object-type": "error", + "typescript/no-unsafe-function-type": "error", + "typescript/prefer-enum-initializers": "error", + "typescript/prefer-literal-enum-member": "error", + "typescript/prefer-optional-chain": "error", + "eslint/prefer-const": ["error", { "destructuring": "all" }], + "eslint/no-else-return": ["error", { "allowElseIf": false }], + "eslint/one-var": ["error", "never"], + "eslint/no-use-before-define": ["error", { + "functions": false, + "classes": false, + "variables": false + }], + "eslint/no-array-constructor": "error", + "eslint/no-case-declarations": "error", + "eslint/no-constructor-return": "error", + "eslint/no-inner-declarations": "error", + "eslint/no-prototype-builtins": "error", + "eslint/no-self-compare": "error", + "eslint/no-label-var": "error", + "eslint/no-labels": "error", + "eslint/no-extra-label": "error", + "eslint/no-lone-blocks": "error", + "eslint/no-proto": "error", + "eslint/no-regex-spaces": "error", + "eslint/no-script-url": "error", + "eslint/no-sequences": "error", + "eslint/default-case-last": "error", + "eslint/radix": "error", + "eslint/prefer-arrow-callback": "error", + "eslint/prefer-exponentiation-operator": "error", + "eslint/prefer-numeric-literals": "error", + "eslint/prefer-regex-literals": "error", + "eslint/prefer-rest-params": "error", + "oxc/no-const-enum": "error", + "unicorn/new-for-builtins": "error", + "unicorn/no-document-cookie": "error", + "unicorn/no-instanceof-array": "error", + "unicorn/no-useless-switch-case": "error", + "unicorn/prefer-array-index-of": "error", + "unicorn/prefer-date-now": "error", + "unicorn/prefer-node-protocol": "error", + "unicorn/prefer-number-properties": ["error", { "checkInfinity": true }], + "unicorn/require-post-message-target-origin": "off", + "eslint/no-console": "error", + "eslint/no-fallthrough": "error", + "eslint/no-redeclare": "error", + "eslint/prefer-template": "error", + "react/jsx-curly-brace-presence": "error", + "react/no-danger": "error", + "typescript/consistent-type-imports": "error", + "typescript/no-empty-interface": "error", + "typescript/no-inferrable-types": "error", + "typescript/no-invalid-void-type": "error", + "typescript/prefer-function-type": "error", + + // ---- type-aware (tsgolint); these are pedantic so must be listed ---- + "typescript/no-misused-promises": ["error", { + "checksVoidReturn": { "attributes": false } + }], + "typescript/only-throw-error": "error", + "typescript/require-await": "error", + "typescript/prefer-promise-reject-errors": "error", + "typescript/restrict-plus-operands": "error", + "typescript/return-await": ["error", "in-try-catch"], + "typescript/switch-exhaustiveness-check": ["error", { + "allowDefaultCaseForExhaustiveSwitch": true, + "considerDefaultExhaustiveForUnions": true + }], + "typescript/prefer-nullish-coalescing": ["error", { + "ignoreTernaryTests": true, + "ignorePrimitives": { "string": true } + }], + "typescript/no-deprecated": "warn", + + // ---- type-aware deferred (noisy or structurally wrong here) ---- + // bindings/ types are ts-rs-generated and assert more than the wire + // guarantees, so defensive runtime checks look "unnecessary". + "typescript/no-unnecessary-condition": "off", + "typescript/strict-boolean-expressions": "off", + "typescript/no-confusing-void-expression": "off", + "typescript/promise-function-async": "off", + "typescript/prefer-readonly-parameter-types": "off", + "typescript/strict-void-return": "off", + + // no-unsafe-* is error only in vscode-extension/src (eslint parity there). + "typescript/no-unsafe-argument": "off", + "typescript/no-unsafe-assignment": "off", + "typescript/no-unsafe-call": "off", + "typescript/no-unsafe-member-access": "off", + "typescript/no-unsafe-return": "off", + + // ---- react-perf: new class of finding, land as warn ---- + "react-perf/jsx-no-new-object-as-prop": "warn", + "react-perf/jsx-no-new-array-as-prop": "warn", + "react-perf/jsx-no-new-function-as-prop": "warn", + "react-perf/jsx-no-jsx-as-prop": "warn" + }, + + "overrides": [ + { + "files": ["ui/src/**/*.ts", "ui/src/**/*.tsx"], + "env": { "browser": true, "es2022": true } + }, + { + "files": ["webcomponents/src/**/*.ts", "webcomponents/src/**/*.tsx"], + "env": { "browser": true, "es2022": true } + }, + { + "files": ["webcomponents/src/**/*.test.ts", "webcomponents/src/**/*.test.tsx"], + "globals": { "Bun": "readonly" }, + "rules": { "eslint/no-console": "off" } + }, + { + "files": ["vscode-extension/src/**/*.ts"], + "env": { "node": true, "es2022": true }, + "rules": { + // Parity with the recommendedTypeChecked config this replaces + "typescript/no-unsafe-argument": "error", + "typescript/no-unsafe-assignment": "error", + "typescript/no-unsafe-call": "error", + "typescript/no-unsafe-member-access": "error", + "typescript/no-unsafe-return": "error", + "typescript/no-require-imports": "error", + "typescript/no-namespace": "error" + } + }, + { + "files": ["vscode-extension/test/**/*.ts"], + "env": { "node": true, "mocha": true, "es2022": true }, + "rules": { + "eslint/no-console": "off", + "unicorn/consistent-function-scoping": "off", + "typescript/unbound-method": "off", + "typescript/no-misused-promises": "off" + } + }, + { + "files": [ + "webcomponents/scripts/**", + "vscode-extension/scripts/**", + "vscode-extension/src/webhook-server.ts", + "vscode-extension/src/walkthrough.ts" + ], + "rules": { "eslint/no-console": "off" } + }, + { + "files": [ + "vscode-extension/webview-ui/**/*.ts", + "vscode-extension/webview-ui/**/*.tsx" + ], + "env": { "browser": true, "es2022": true } + } + ] +} diff --git a/CLAUDE.md b/CLAUDE.md index c959a31b..b238a329 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -124,7 +124,7 @@ Full command list: `docs/cli/` (auto-generated). ## Architecture -Grouped map of `src/` (not exhaustive — `ls src/` for the full list): +Grouped map of `src/` (not exhaustive - `ls src/` for the full list): ``` src/ @@ -175,7 +175,7 @@ Execution mode is declared per issue type (`mode` in the issuetype schema): ## State Management Persistent state lives under `paths.state` (default `.tickets/operator/`); -`state.json` holds queue/agent state — schema documented at `/schemas/state/`. +`state.json` holds queue/agent state - schema documented at `/schemas/state/`. Per-ticket worktrees default to `~/.operator/worktrees`. ## Ticket Workflow diff --git a/Dockerfile b/Dockerfile index 019a1cd4..d1becffb 100644 --- a/Dockerfile +++ b/Dockerfile @@ -11,10 +11,11 @@ LABEL org.opencontainers.image.title="Operator" \ ARG TARGETARCH # Substrate Operator needs to launch agents: git (VCS ops), tmux (session -# wrapper), ca-certificates (TLS to LLM/kanban APIs). The LLM CLI (claude / -# codex / gemini) and its auth are supplied by the user via a derived image or env vars +# wrapper), ca-certificates (TLS to LLM/kanban APIs), openssh-client (every +# ssh and coder target launch, and git over SSH remotes), curl (in-pod reachability checks). +# The LLM CLI (claude / codex / gemini) and its auth are supplied by the user via a derived image or env vars RUN apt-get update \ - && apt-get install -y --no-install-recommends ca-certificates git tmux \ + && apt-get install -y --no-install-recommends ca-certificates curl git openssh-client tmux \ && rm -rf /var/lib/apt/lists/* # CI stages the prebuilt release binaries as {operator,opr8r}-linux-${TARGETARCH} diff --git a/Dockerfile.local b/Dockerfile.local index 0ead793e..540c902a 100644 --- a/Dockerfile.local +++ b/Dockerfile.local @@ -57,11 +57,13 @@ LABEL org.opencontainers.image.title="Operator" \ org.opencontainers.image.licenses="MIT" # Substrate Operator needs to launch agents: git (VCS ops), tmux (session -# wrapper), ca-certificates (TLS to LLM/kanban APIs). The LLM CLI (claude / -# codex / gemini) and its auth are supplied by the user via a derived image or -# a mount + env vars -- not baked in here. +# wrapper), ca-certificates (TLS to LLM/kanban APIs), openssh-client (every +# ssh and coder target launch, and git over SSH remotes), curl (in-pod +# reachability checks). The LLM CLI (claude / codex / gemini) and its auth are +# supplied by the user via a derived image or a mount + env vars -- not baked +# in here. RUN apt-get update \ - && apt-get install -y --no-install-recommends ca-certificates git tmux \ + && apt-get install -y --no-install-recommends ca-certificates curl git openssh-client tmux \ && rm -rf /var/lib/apt/lists/* # Both halves ship: agent sessions launched by the operator server call the diff --git a/agnt-plugin/alert.js b/agnt-plugin/alert.js index 674a9e5d..e20da9eb 100644 --- a/agnt-plugin/alert.js +++ b/agnt-plugin/alert.js @@ -1,4 +1,4 @@ -// operator-alert — POST /api/v1/alerts +// operator-alert - POST /api/v1/alerts import { callOperator } from "./lib/operator-client.js"; class AlertTool { diff --git a/agnt-plugin/create-ticket.js b/agnt-plugin/create-ticket.js index cbf9cf25..4c577cfb 100644 --- a/agnt-plugin/create-ticket.js +++ b/agnt-plugin/create-ticket.js @@ -1,4 +1,4 @@ -// operator-create-ticket — POST /api/v1/tickets +// operator-create-ticket - POST /api/v1/tickets import { callOperator } from "./lib/operator-client.js"; class CreateTicketTool { diff --git a/agnt-plugin/export.js b/agnt-plugin/export.js index 5d86a253..71ace0a1 100644 --- a/agnt-plugin/export.js +++ b/agnt-plugin/export.js @@ -1,4 +1,4 @@ -// operator-export-workflow — POST /api/v1/tickets/{id}/workflow-export?format=... +// operator-export-workflow - POST /api/v1/tickets/{id}/workflow-export?format=... import { callOperator } from "./lib/operator-client.js"; class ExportWorkflowTool { diff --git a/agnt-plugin/launch.js b/agnt-plugin/launch.js index 05a5f846..c9b1d361 100644 --- a/agnt-plugin/launch.js +++ b/agnt-plugin/launch.js @@ -1,4 +1,4 @@ -// operator-launch-agent — POST /api/v1/tickets/{id}/launch +// operator-launch-agent - POST /api/v1/tickets/{id}/launch import { callOperator } from "./lib/operator-client.js"; class LaunchAgentTool { diff --git a/agnt-plugin/queue.js b/agnt-plugin/queue.js index e1778b91..d85b8cc3 100644 --- a/agnt-plugin/queue.js +++ b/agnt-plugin/queue.js @@ -1,4 +1,4 @@ -// operator-queue-status — GET /api/v1/queue/status +// operator-queue-status - GET /api/v1/queue/status import { callOperator } from "./lib/operator-client.js"; class QueueStatusTool { diff --git a/agnt-plugin/run-step.js b/agnt-plugin/run-step.js index 084d6e65..2fabedfa 100644 --- a/agnt-plugin/run-step.js +++ b/agnt-plugin/run-step.js @@ -1,11 +1,9 @@ -// operator-run-step — the node type emitted by `operator workflow export --format agnt`. +// operator-run-step - the node type emitted by `operator workflow export --format agnt`. // // Each exported node represents one issuetype step and carries -// { ticket, step, prompt, ... } in its config. This tool reads `ticket` and asks -// Operator to run it via the launch endpoint. Operator sequences its own steps -// internally, so the per-step nodes are a faithful visualization of the ticket's -// shape; executing them drives the one underlying Operator ticket (the launch -// endpoint's relaunch path tolerates a ticket that is already in progress). +// { ticket, step, prompt, ... } in its config. This tool reads `ticket` and asks Operator to run it via the launch endpoint. +// Operator sequences its own steps internally, so the per-step nodes are a faithful visualization of the ticket's shape; +// executing them drives the one underlying Operator ticket (the launch endpoint's relaunch path tolerates a ticket that is already in progress). import { callOperator } from "./lib/operator-client.js"; class RunStepTool { diff --git a/bindings/CoderConfig.ts b/bindings/CoderConfig.ts index 7d530807..4d2efce7 100644 --- a/bindings/CoderConfig.ts +++ b/bindings/CoderConfig.ts @@ -25,7 +25,7 @@ token_env: string, */ name_prefix: string, /** - * Project root inside the workspace (None = workspace $HOME) + * Project root inside the workspace (None = /home/coder/{project}) */ workdir?: string | null, /** @@ -42,6 +42,6 @@ create_timeout_secs: bigint, */ callback_url?: string | null, /** - * Passthrough `-p` template parameters for `coder create` + * Passthrough `--parameter` template parameters for `coder create` */ parameters?: { [key in string]: string }, }; diff --git a/bindings/RestApiConfig.ts b/bindings/RestApiConfig.ts index c80234d5..0f27f64b 100644 --- a/bindings/RestApiConfig.ts +++ b/bindings/RestApiConfig.ts @@ -9,9 +9,7 @@ export type RestApiConfig = { */ enabled: boolean, /** - * Address the REST API binds to. Defaults to `127.0.0.1` (local only) so - * the server — which reports the project directory name — is not reachable - * from other hosts. Set to `0.0.0.0` to expose it on all interfaces. + * Address the REST API binds to. Defaults to `127.0.0.1` (local only) so the server is not reachable from other hosts. Set to `0.0.0.0` to expose it on all interfaces. */ host: string, /** @@ -23,9 +21,6 @@ port: number, */ cors_origins: Array, /** - * Externally reachable base URL (e.g. `https://operator.example.com`). - * - * OAuth and MCP descriptor URLs are generated from this rather than from the request's `Host` header, - * which a caller controls. Defaults to request host, which is correct for a loopback bind and wrong behind a reverse proxy. + * Externally reachable base URL (e.g. `https://operator.example.com`). Defaults to request host. */ public_url: string | null, }; diff --git a/bindings/SectionDefinition.ts b/bindings/SectionDefinition.ts index 3b0bb8d3..ccf09058 100644 --- a/bindings/SectionDefinition.ts +++ b/bindings/SectionDefinition.ts @@ -2,6 +2,6 @@ import type { SectionId } from "./SectionId"; /** - * Declarative section metadata — shared between TUI and `VSCode`. + * Declarative section metadata - shared between TUI and `VSCode`. */ export type SectionDefinition = { id: SectionId, label: string, prerequisites: Array, }; diff --git a/bindings/SectionHealth.ts b/bindings/SectionHealth.ts index 422f6754..7b74734e 100644 --- a/bindings/SectionHealth.ts +++ b/bindings/SectionHealth.ts @@ -1,6 +1,6 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. /** - * Health state of a section — controls the header color. + * Health state of a section - controls the header color. */ export type SectionHealth = "Green" | "Yellow" | "Red" | "Gray"; diff --git a/bun.lock b/bun.lock index fe87e775..e93384e5 100644 --- a/bun.lock +++ b/bun.lock @@ -5,6 +5,8 @@ "": { "name": "operator-docs", "devDependencies": { + "oxlint": "1.81.0", + "oxlint-tsgolint": "7.0.2001", "typedoc": "^0.27.0", "typescript": "^5.0.0", }, @@ -13,6 +15,56 @@ "packages": { "@gerrit0/mini-shiki": ["@gerrit0/mini-shiki@1.27.2", "", { "dependencies": { "@shikijs/engine-oniguruma": "^1.27.2", "@shikijs/types": "^1.27.2", "@shikijs/vscode-textmate": "^10.0.1" } }, "sha512-GeWyHz8ao2gBiUW4OJnQDxXQnFgZQwwQk05t/CVVgNBN7/rK8XZ7xY6YhLVv9tH3VppWWmr9DCl3MwemB/i+Og=="], + "@oxlint-tsgolint/darwin-arm64": ["@oxlint-tsgolint/darwin-arm64@7.0.2001", "", { "os": "darwin", "cpu": "arm64" }, "sha512-CUJEdbSZ54+Xy9OXqOhWLTKZKV0BBiV7C2i/ygyVmXtkUNXx5YCzN8DpSSshTAKktoL7S+tnQ/ftFG/i7X896w=="], + + "@oxlint-tsgolint/darwin-x64": ["@oxlint-tsgolint/darwin-x64@7.0.2001", "", { "os": "darwin", "cpu": "x64" }, "sha512-pXfBb5BqONCcgrXQNUZWXgiYmRSWJzd97S8i41VVOh6ut0tyo+cJ5FKFpczDHxiVNfj/3e7c9B4MtztNdpIVCw=="], + + "@oxlint-tsgolint/linux-arm64": ["@oxlint-tsgolint/linux-arm64@7.0.2001", "", { "os": "linux", "cpu": "arm64" }, "sha512-roP7zujb/QDPzDwEKsFFpzNHHy91/Y7oX9vQXk78ekyZtcQj1QXDIMH33gjDdHBfRl4K9pZ36xhRgrP4Zr+R8A=="], + + "@oxlint-tsgolint/linux-x64": ["@oxlint-tsgolint/linux-x64@7.0.2001", "", { "os": "linux", "cpu": "x64" }, "sha512-UDezNqdECVmngu2TPnjaS1YoAmcTaBoI5lV9vk3VahBxoi+I5r9k3iJTT7qZoYWOXTD/7T7bNcwRgrocR6BscQ=="], + + "@oxlint-tsgolint/win32-arm64": ["@oxlint-tsgolint/win32-arm64@7.0.2001", "", { "os": "win32", "cpu": "arm64" }, "sha512-uJZhqB6pdXLuN+AD1F5082byyQti/NPmJA77GtcFlmT2HzRelqbNls3SaIqxpjdFgvSBF9g0yOKGBkGFg7kX8Q=="], + + "@oxlint-tsgolint/win32-x64": ["@oxlint-tsgolint/win32-x64@7.0.2001", "", { "os": "win32", "cpu": "x64" }, "sha512-FkDRm8hx9OwzGQqyWG1tO5QrTLRApff9DzSgpz9QZau37BR8d1VYKOxMLGf6shPZntJFoTwIIJYT68VndYDCog=="], + + "@oxlint/binding-android-arm-eabi": ["@oxlint/binding-android-arm-eabi@1.81.0", "", { "os": "android", "cpu": "arm" }, "sha512-IcCRsXiedJoJopY6mpZUBEeVFsUrutmrG7dZ87zMuKJlhg70Ora9bBl1WcCxZQtyI10YpnVdEso5oCg7YcfSHw=="], + + "@oxlint/binding-android-arm64": ["@oxlint/binding-android-arm64@1.81.0", "", { "os": "android", "cpu": "arm64" }, "sha512-GRrIPyTGVhx3L3h+0T5xT2A0jFAcdPv4+IfuXpGDLIdl6XeYhgg/zw72A5ILZoUgRqZuM8F1y+V/gfDriXSxzQ=="], + + "@oxlint/binding-darwin-arm64": ["@oxlint/binding-darwin-arm64@1.81.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-qNQ9tXRgLuKbqSV1S2h9h4KPHjbovO7RRR2/enUOtHzTkFZ7B9X5zqqHJua8dRyc7dBy7Aoyq5pqTSLFVcAzGQ=="], + + "@oxlint/binding-darwin-x64": ["@oxlint/binding-darwin-x64@1.81.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-q0QTm32jWga2Gv4j7IaVZN0jYMi9UV73sWVgFtDA4iIfqwMCLLZ3ve+9KwfYtsaKZSgQhmPaogeZWqDZpcY1Pw=="], + + "@oxlint/binding-freebsd-x64": ["@oxlint/binding-freebsd-x64@1.81.0", "", { "os": "freebsd", "cpu": "x64" }, "sha512-/+8wVWDXEC7wHVAhOc59Fw/SkMc1arLkFD8iQCaSsmzenK1X4doFqquL9H1wrtGUzaiycVqkf/sSpcILK6W1UA=="], + + "@oxlint/binding-linux-arm-gnueabihf": ["@oxlint/binding-linux-arm-gnueabihf@1.81.0", "", { "os": "linux", "cpu": "arm" }, "sha512-4xt422FEgioRq9hAL4Tq7fujGUWnc8z1BJ+Oi8RN8vB8axaP+sdK6a2xdlcQCCYnJg9QMuMFS0AucuIFx/EacA=="], + + "@oxlint/binding-linux-arm-musleabihf": ["@oxlint/binding-linux-arm-musleabihf@1.81.0", "", { "os": "linux", "cpu": "arm" }, "sha512-u3vna8KdGplH4DRCW9K54D68fcMo7IxVrkCJWwXnIhwtBdnDnYrmzOUA/XjmBlPpcLsgw9Z5BNdY4za9+Dj+MQ=="], + + "@oxlint/binding-linux-arm64-gnu": ["@oxlint/binding-linux-arm64-gnu@1.81.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-3j9k+gsYsE7nv71GWotXsqsa2l9/aJenD7dVHNt/CBvsb0SgRjSMnHFeP59IXUAl1wvVFhqGl2wJNMwWU3UBlA=="], + + "@oxlint/binding-linux-arm64-musl": ["@oxlint/binding-linux-arm64-musl@1.81.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-k5iAp3dNxW0/uDCBY+WSm8jKB2szu7SkEQZdgRRpDXvuDd69vvDcqhB3A/pWCfCwXyenjNjFn9Td1fVoyAc+Yg=="], + + "@oxlint/binding-linux-ppc64-gnu": ["@oxlint/binding-linux-ppc64-gnu@1.81.0", "", { "os": "linux", "cpu": "ppc64" }, "sha512-TFqLja3uYmVSte6nof9GWrex9Z8WgdZrNiLC6Te5rXGDqXB2y4j/26iFhwosXiAFqDhE9JJVuuCkDKLwptTn1g=="], + + "@oxlint/binding-linux-riscv64-gnu": ["@oxlint/binding-linux-riscv64-gnu@1.81.0", "", { "os": "linux", "cpu": "none" }, "sha512-UEcySvGS0NOVo7h7n7CYyJL9+6gFAh7Zc/ToDXVScFvzHSTIxtzkMVU30rmQ6+nQ1LF+UdiRDdJajpDu+OylLg=="], + + "@oxlint/binding-linux-riscv64-musl": ["@oxlint/binding-linux-riscv64-musl@1.81.0", "", { "os": "linux", "cpu": "none" }, "sha512-H+diDbhD00+wI1IRP8Kz88x/lat+DgtoBJzoTthS16xkTJGNaEkfb8gzmd1rzc/2uDQQMl7GNl+JFUacVeWxIA=="], + + "@oxlint/binding-linux-s390x-gnu": ["@oxlint/binding-linux-s390x-gnu@1.81.0", "", { "os": "linux", "cpu": "s390x" }, "sha512-8znJ/5TekjOKg1j1Acho4PJMdiAHLtlcXuWEiipOhAMV6rQcXdmDdXCbheyDczN6TjBwiNfjcP81k4AthrKRzw=="], + + "@oxlint/binding-linux-x64-gnu": ["@oxlint/binding-linux-x64-gnu@1.81.0", "", { "os": "linux", "cpu": "x64" }, "sha512-Q2Wj70yFsvn5QjlmifFzbj4H+kJy53bwqc41o1fzoM7MpLV1NIbhg/LpWXRfC6KOkSAdUx1Wd8VJsdPmhp/HRA=="], + + "@oxlint/binding-linux-x64-musl": ["@oxlint/binding-linux-x64-musl@1.81.0", "", { "os": "linux", "cpu": "x64" }, "sha512-cPInHp/ddEe5qkyK2IiyQ8Q3Mp2oLLEhhsGgTK2oZx4L6+llGam1H1yBvJZ7qHfOXj8N3hxBS8sj4tO+gtFlIg=="], + + "@oxlint/binding-openharmony-arm64": ["@oxlint/binding-openharmony-arm64@1.81.0", "", { "os": "none", "cpu": "arm64" }, "sha512-0CQxSX4ajqm07AHBf5U33qQzXKdd7wtq/oTL/7vpY6RNNuxrRi8W4bqUV1Jyu/vj+9KmxQyDhxfeVX1nQL6kfg=="], + + "@oxlint/binding-win32-arm64-msvc": ["@oxlint/binding-win32-arm64-msvc@1.81.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-l0hbeISm9673hVrrQU8j/p2M7YH9Ouoj7p7E/QM55NTrKVLP+P3PF8hLu+OY+x0VtGRW+ggiQKZqmdYps9H+TA=="], + + "@oxlint/binding-win32-ia32-msvc": ["@oxlint/binding-win32-ia32-msvc@1.81.0", "", { "os": "win32", "cpu": "ia32" }, "sha512-ksqPP5jbFXcYreEQ7zdJh06rJQBymCTyGRCdaXjfcf2aG4f8KxUWY5wcgYHmaTK+FJ4bPG5sUAdOX+6trnH1JA=="], + + "@oxlint/binding-win32-x64-msvc": ["@oxlint/binding-win32-x64-msvc@1.81.0", "", { "os": "win32", "cpu": "x64" }, "sha512-IZuUCwGw9emG5JtCp+fYGB+Z4OWEoeEcM8R5BA1pYw63/ieYFVdcU2ylxTpHbVHSenZnsYE+ZZ20uHAJszQ4cA=="], + "@shikijs/engine-oniguruma": ["@shikijs/engine-oniguruma@1.29.2", "", { "dependencies": { "@shikijs/types": "1.29.2", "@shikijs/vscode-textmate": "^10.0.1" } }, "sha512-7iiOx3SG8+g1MnlzZVDYiaeHe7Ez2Kf2HrJzdmGwkRisT7r4rak0e655AcM/tF9JG/kg5fMNYlLLKglbN7gBqA=="], "@shikijs/types": ["@shikijs/types@1.29.2", "", { "dependencies": { "@shikijs/vscode-textmate": "^10.0.1", "@types/hast": "^3.0.4" } }, "sha512-VJjK0eIijTZf0QSTODEXCqinjBn0joAHQ+aPSBzrv4O2d/QSbsMw+ZeSRx03kV34Hy7NzUvV/7NqfYGRLrASmw=="], @@ -41,6 +93,10 @@ "minimatch": ["minimatch@9.0.5", "", { "dependencies": { "brace-expansion": "^2.0.1" } }, "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow=="], + "oxlint": ["oxlint@1.81.0", "", { "optionalDependencies": { "@oxlint/binding-android-arm-eabi": "1.81.0", "@oxlint/binding-android-arm64": "1.81.0", "@oxlint/binding-darwin-arm64": "1.81.0", "@oxlint/binding-darwin-x64": "1.81.0", "@oxlint/binding-freebsd-x64": "1.81.0", "@oxlint/binding-linux-arm-gnueabihf": "1.81.0", "@oxlint/binding-linux-arm-musleabihf": "1.81.0", "@oxlint/binding-linux-arm64-gnu": "1.81.0", "@oxlint/binding-linux-arm64-musl": "1.81.0", "@oxlint/binding-linux-ppc64-gnu": "1.81.0", "@oxlint/binding-linux-riscv64-gnu": "1.81.0", "@oxlint/binding-linux-riscv64-musl": "1.81.0", "@oxlint/binding-linux-s390x-gnu": "1.81.0", "@oxlint/binding-linux-x64-gnu": "1.81.0", "@oxlint/binding-linux-x64-musl": "1.81.0", "@oxlint/binding-openharmony-arm64": "1.81.0", "@oxlint/binding-win32-arm64-msvc": "1.81.0", "@oxlint/binding-win32-ia32-msvc": "1.81.0", "@oxlint/binding-win32-x64-msvc": "1.81.0" }, "peerDependencies": { "oxlint-tsgolint": ">=7.0.2001", "vite-plus": "*" }, "optionalPeers": ["oxlint-tsgolint", "vite-plus"], "bin": { "oxlint": "bin/oxlint" } }, "sha512-HyrJYqeoOCL0iqaLEzGewGT48ZX99P3hxYh8udAF9RGGIghSamkXE4ClUyBpEDNqasamThgmlPbuMOe7SAZmHg=="], + + "oxlint-tsgolint": ["oxlint-tsgolint@7.0.2001", "", { "optionalDependencies": { "@oxlint-tsgolint/darwin-arm64": "7.0.2001", "@oxlint-tsgolint/darwin-x64": "7.0.2001", "@oxlint-tsgolint/linux-arm64": "7.0.2001", "@oxlint-tsgolint/linux-x64": "7.0.2001", "@oxlint-tsgolint/win32-arm64": "7.0.2001", "@oxlint-tsgolint/win32-x64": "7.0.2001" }, "bin": { "tsgolint": "./bin/tsgolint.js" } }, "sha512-KjK/XLcXr1DSyonKhsuFqJRiuKqcyG9j3LJ8nkOsrLzGvodBPqzHOKauy10asLMDI0sUpvb+1sxlzff3udZvfg=="], + "punycode.js": ["punycode.js@2.3.1", "", {}, "sha512-uxFIHU0YlHYhDQtV4R9J6a52SLx28BCjT+4ieh7IGbgwVJWO+km431c4yRlREUAsAmt/uMjQUyQHNEPf0M39CA=="], "typedoc": ["typedoc@0.27.9", "", { "dependencies": { "@gerrit0/mini-shiki": "^1.24.0", "lunr": "^2.3.9", "markdown-it": "^14.1.0", "minimatch": "^9.0.5", "yaml": "^2.6.1" }, "peerDependencies": { "typescript": "5.0.x || 5.1.x || 5.2.x || 5.3.x || 5.4.x || 5.5.x || 5.6.x || 5.7.x || 5.8.x" }, "bin": { "typedoc": "bin/typedoc" } }, "sha512-/z585740YHURLl9DN2jCWe6OW7zKYm6VoQ93H0sxZ1cwHQEQrUn5BJrEnkWhfzUdyO+BLGjnKUZ9iz9hKloFDw=="], diff --git a/coder-module/README.md b/coder-module/README.md index 48f5becc..a7a3a536 100644 --- a/coder-module/README.md +++ b/coder-module/README.md @@ -12,22 +12,24 @@ Run [Operator](https://github.com/untra/operator) as a background REST API serve The module downloads the operator binary and the `opr8r` client from GitHub releases, generates configuration, starts the API server, and exposes the dashboard through the Coder workspace UI with automatic healthchecks. +> This module runs Operator **inside** a workspace. To run Operator elsewhere (a Kubernetes deployment, say) and have it *spawn* Coder workspaces as agent targets, you do not need this module at all — configure a `[[targets]]` entry with `kind = "coder"` instead. See the [Coder platform guide](https://operator.untra.io/getting-started/platforms/coder/). + ## Usage ```tf module "operator" { source = "registry.coder.com/untra/operator/coder" - version = "1.0.0" agent_id = coder_agent.main.id } ``` +Pin `version` to a published module release for reproducible builds. `install_version` is separate — it selects the Operator release the module downloads, and defaults to the version this module shipped with. + ### Custom configuration ```tf module "operator" { source = "registry.coder.com/untra/operator/coder" - version = "1.0.0" agent_id = coder_agent.main.id port = 7008 max_parallel_agents = 4 @@ -37,10 +39,11 @@ module "operator" { ### Full TOML override +`config_toml` is written verbatim — quotes, `$` and backticks all survive, because the value is base64-encoded on the way into the startup script. + ```tf module "operator" { source = "registry.coder.com/untra/operator/coder" - version = "1.0.0" agent_id = coder_agent.main.id config_toml = <<-EOT [rest_api] @@ -49,22 +52,74 @@ module "operator" { [agents] max_parallel = 4 - health_check_interval = 30 [sessions] wrapper = "tmux" - - [[delegators]] - name = "default" - tool = "claude-code" - model = "sonnet" EOT } ``` +Setting `config_toml` replaces the generated config entirely, including the `[[targets]]` block described below — declare the target yourself if you need both. + +### Spawning child agent workspaces + +Set `agent_template` and the generated config gains a `[[targets]]` entry, so tickets launched from this workspace create **sibling** workspaces from that template rather than running agents locally. + +```tf +module "operator" { + source = "registry.coder.com/untra/operator/coder" + agent_id = coder_agent.main.id + agent_template = "operator-agent" + workdir = "/home/coder/project" +} +``` + +This mode has prerequisites the basic mode does not — see below. + +## Variables + +| Variable | Type | Default | Description | +|----------|------|---------|-------------| +| `agent_id` | `string` | *(required)* | The ID of a Coder agent | +| `port` | `number` | `7008` | Port for the Operator REST API server | +| `display_name` | `string` | `"Operator"` | Display name in the Coder dashboard | +| `slug` | `string` | `"operator"` | Application slug | +| `install_version` | `string` | *(module version)* | Operator GitHub release tag to install | +| `install_prefix` | `string` | `"/tmp/operator"` | Directory to install the binaries into | +| `log_path` | `string` | `"/tmp/operator.log"` | Path to write log output | +| `config_toml` | `string` | `""` | Raw TOML written verbatim instead of the generated config | +| `max_parallel_agents` | `number` | `2` | Maximum number of parallel agents | +| `session_wrapper` | `string` | `"tmux"` | Session wrapper (`tmux`, `cmux`, or `zellij`) | +| `share` | `string` | `"owner"` | Dashboard sharing level (`owner`, `authenticated`, or `public`) | +| `order` | `number` | `null` | Position of the app in the dashboard (lower = first) | +| `group` | `string` | `null` | Group that this app belongs to | +| `offline` | `bool` | `false` | Skip downloading; requires a pre-installed binary at `install_prefix` | +| `use_cached` | `bool` | `false` | Reuse a cached binary if present, otherwise download | + +### Child-workspace spawning + +All optional. `agent_template` is the switch — leave it empty and no `[[targets]]` entry is written and the rest are ignored. Keys left unset are omitted from the config so Operator's own defaults apply. + +| Variable | Type | Default | Description | +|----------|------|---------|-------------| +| `agent_template` | `string` | `""` | Coder template child agent workspaces are created from. Empty disables the coder target. | +| `coder_token_env` | `string` | `"CODER_SESSION_TOKEN"` | Name of the env var holding the Coder **user session token** used to spawn workspaces | +| `callback_url` | `string` | `""` | Control-plane-reachable `OPERATOR_API_URL` override, so detached multi-step survives SSH tunnel loss. Empty keeps the reverse-tunnel default. | +| `name_prefix` | `string` | `""` | Workspace name prefix for deterministic per-ticket naming | +| `workdir` | `string` | `""` | Project root inside spawned workspaces | +| `stop_on_complete` | `bool` | `null` | Stop a spawned workspace when its ticket completes (never deletes) | +| `create_timeout_secs` | `number` | `null` | Bound on workspace create plus agent-ready wait, in seconds | + ## Prerequisites -The workspace image must include `tmux` (or your chosen `session_wrapper`) for operator to spawn agent sessions. Most Coder workspace images include tmux by default. +The workspace image must include `tmux` (or your chosen `session_wrapper`) for Operator to spawn agent sessions. Most Coder workspace images include tmux by default. + +Setting `agent_template` adds two more: + +- **`ssh` in the workspace image** (`openssh-client`). Operator launches child-workspace agents over real `ssh`. The `coder` CLI is used too, but Operator fetches it from your deployment if it is not already on `PATH`. +- **A Coder user session token** in the variable named by `coder_token_env`. This is **not** the ambient `CODER_AGENT_TOKEN` (see below) and the module does not provision it — supply it yourself, e.g. through a template `env` block backed by a Coder parameter or secret. + +> **Blast radius:** a Coder user session token can create, delete, and SSH into every workspace its user owns. Scope the account accordingly. ## Coder Workspace Context @@ -72,6 +127,7 @@ Coder automatically injects environment variables into every workspace that oper - `CODER_WORKSPACE_NAME` - workspace identifier - `CODER_WORKSPACE_OWNER` - workspace owner username -- `CODER_AGENT_TOKEN` - agent authentication token +- `CODER_URL` - deployment URL, which is what the coder target reads by default +- `CODER_AGENT_TOKEN` - **agent** authentication token, scoped to this one workspace -No operator configuration is needed to access these - they are ambient in the workspace environment. +No operator configuration is needed to access these. Note that `CODER_AGENT_TOKEN` is not a substitute for the user session token child-workspace spawning needs — it cannot create workspaces. Operator also strips the session-token variable from every agent's environment before launching it, on every target kind. diff --git a/coder-module/main.test.ts b/coder-module/main.test.ts index 3e7a1e93..ae3594e2 100644 --- a/coder-module/main.test.ts +++ b/coder-module/main.test.ts @@ -98,6 +98,45 @@ describe("operator", async () => { }); const script = findResourceInstance(state, "coder_script").script; - expect(script).toContain(customConfig); + expect(script).toContain(Buffer.from(customConfig).toString("base64")); + }); + + // The value reaches run.sh through a shell assignment, so an unencoded + // hand-off silently ate every quote and expanded every $VAR. + it("survives quotes and dollar signs in config_toml", async () => { + const customConfig = '[sessions]\nwrapper = "tmux"\nhome = "$HOME"\n'; + const state = await runTerraformApply(import.meta.dir, { + agent_id: "foo", + config_toml: customConfig, + }); + + const script = findResourceInstance(state, "coder_script").script; + const encoded = Buffer.from(customConfig).toString("base64"); + expect(script).toContain(encoded); + expect(Buffer.from(encoded, "base64").toString()).toBe(customConfig); + }); + + it("writes the optional coder target keys only when set", async () => { + const bare = await runTerraformApply(import.meta.dir, { + agent_id: "foo", + agent_template: "operator-agent", + }); + const bareScript = findResourceInstance(bare, "coder_script").script; + expect(bareScript).toContain('name_prefix=""'); + expect(bareScript).toContain('stop_on_complete=""'); + + const full = await runTerraformApply(import.meta.dir, { + agent_id: "foo", + agent_template: "operator-agent", + name_prefix: "op", + workdir: "/home/coder/proj", + stop_on_complete: "false", + create_timeout_secs: "600", + }); + const fullScript = findResourceInstance(full, "coder_script").script; + expect(fullScript).toContain('name_prefix="op"'); + expect(fullScript).toContain('workdir="/home/coder/proj"'); + expect(fullScript).toContain('stop_on_complete="false"'); + expect(fullScript).toContain('create_timeout_secs="600"'); }); }); diff --git a/coder-module/main.tf b/coder-module/main.tf index c922c2c6..bbc6012f 100644 --- a/coder-module/main.tf +++ b/coder-module/main.tf @@ -59,6 +59,31 @@ variable "config_toml" { default = "" } +# Optional coder-target knobs. Empty/null means "leave it out of the generated config" +variable "name_prefix" { + type = string + description = "Workspace name prefix for deterministic per-ticket naming. Empty uses Operator's default." + default = "" +} + +variable "workdir" { + type = string + description = "Project root inside spawned agent workspaces. Empty uses Operator's default." + default = "" +} + +variable "stop_on_complete" { + type = bool + description = "Stop a spawned workspace when its ticket completes (never deletes). Null uses Operator's default." + default = null +} + +variable "create_timeout_secs" { + type = number + description = "Bound on workspace create plus agent-ready wait, in seconds. Null uses Operator's default." + default = null +} + variable "max_parallel_agents" { type = number description = "Maximum number of parallel agents operator can run." @@ -76,8 +101,9 @@ variable "session_wrapper" { } variable "share" { - type = string - default = "owner" + type = string + description = "Dashboard sharing level for the Operator app." + default = "owner" validation { condition = contains(["owner", "authenticated", "public"], var.share) error_message = "share must be one of: owner, authenticated, public." @@ -134,18 +160,22 @@ resource "coder_script" "operator" { display_name = "Operator" icon = "/icon/terminal.svg" script = templatefile("${path.module}/run.sh", { - VERSION = var.install_version, - PORT = var.port, - INSTALL_PREFIX = var.install_prefix, - LOG_PATH = var.log_path, - CONFIG_TOML = var.config_toml, - MAX_PARALLEL = var.max_parallel_agents, - SESSION_WRAPPER = var.session_wrapper, - OFFLINE = var.offline, - USE_CACHED = var.use_cached, - AGENT_TEMPLATE = var.agent_template, - CODER_TOKEN_ENV = var.coder_token_env, - CALLBACK_URL = var.callback_url, + VERSION = var.install_version, + PORT = var.port, + INSTALL_PREFIX = var.install_prefix, + LOG_PATH = var.log_path, + CONFIG_TOML_B64 = base64encode(var.config_toml), + MAX_PARALLEL = var.max_parallel_agents, + SESSION_WRAPPER = var.session_wrapper, + OFFLINE = var.offline, + USE_CACHED = var.use_cached, + AGENT_TEMPLATE = var.agent_template, + CODER_TOKEN_ENV = var.coder_token_env, + CALLBACK_URL = var.callback_url, + NAME_PREFIX = var.name_prefix, + WORKDIR = var.workdir, + STOP_ON_COMPLETE = var.stop_on_complete == null ? "" : tostring(var.stop_on_complete), + CREATE_TIMEOUT_SECS = var.create_timeout_secs == null ? "" : tostring(var.create_timeout_secs), }) run_on_start = true diff --git a/coder-module/run.sh b/coder-module/run.sh index c82aac12..da34feaa 100755 --- a/coder-module/run.sh +++ b/coder-module/run.sh @@ -88,15 +88,32 @@ fi mkdir -p .tickets/operator .tickets/queue -# Bind template values to shell variables so conditionals below are real runtime checks -config_toml="${CONFIG_TOML}" +CONFIG_FILE=.tickets/operator/config.toml + +# Bind template values to shell variables so conditionals below are real runtime checks. +# CONFIG_TOML arrives base64-encoded: templatefile() escapes only the interpolation +# marker, so a raw value's quotes and dollar signs are mangled before reaching the file. +config_toml_b64="${CONFIG_TOML_B64}" agent_template="${AGENT_TEMPLATE}" callback_url="${CALLBACK_URL}" +name_prefix="${NAME_PREFIX}" +workdir="${WORKDIR}" +stop_on_complete="${STOP_ON_COMPLETE}" +create_timeout_secs="${CREATE_TIMEOUT_SECS}" + +append_kv() { + [ -n "$2" ] || return 0 + if [ "$3" = quoted ]; then + echo "$1 = \"$2\"" >> "$CONFIG_FILE" + else + echo "$1 = $2" >> "$CONFIG_FILE" + fi +} -if [ -n "$config_toml" ]; then - echo "$config_toml" > .tickets/operator/config.toml +if [ -n "$config_toml_b64" ]; then + printf '%s' "$config_toml_b64" | base64 -d > "$CONFIG_FILE" else - cat > .tickets/operator/config.toml < "$CONFIG_FILE" <> .tickets/operator/config.toml <> "$CONFIG_FILE" <> .tickets/operator/config.toml - fi + append_kv callback_url "$callback_url" quoted + append_kv name_prefix "$name_prefix" quoted + append_kv workdir "$workdir" quoted + append_kv stop_on_complete "$stop_on_complete" bare + append_kv create_timeout_secs "$create_timeout_secs" bare fi fi diff --git a/crates/relay/src/channel_session.rs b/crates/relay/src/channel_session.rs index 64cbb70b..393ece23 100644 --- a/crates/relay/src/channel_session.rs +++ b/crates/relay/src/channel_session.rs @@ -173,7 +173,7 @@ impl ChannelSession { { ServerMsg::Peers { peers, .. } => Ok(peers), ServerMsg::Err { code, message, .. } => { - Err(anyhow::anyhow!("list_peers error: {code:?} — {message:?}")) + Err(anyhow::anyhow!("list_peers error: {code:?} - {message:?}")) } other => Err(anyhow::anyhow!("unexpected list_peers response: {other:?}")), } @@ -235,7 +235,7 @@ impl ChannelSession { { ServerMsg::Ack { .. } => Ok(()), ServerMsg::Err { code, message, .. } => { - Err(anyhow::anyhow!("rename error: {code:?} — {message:?}")) + Err(anyhow::anyhow!("rename error: {code:?} - {message:?}")) } other => Err(anyhow::anyhow!("unexpected rename response: {other:?}")), } @@ -289,7 +289,7 @@ async fn route_msg( let _ = tx.send(count); } } - // Ack/Peers/Err without correlation ID — ignore + // Ack/Peers/Err without correlation ID - ignore _ => {} } } diff --git a/crates/relay/src/client.rs b/crates/relay/src/client.rs index 2e7afd18..02ea7b5a 100644 --- a/crates/relay/src/client.rs +++ b/crates/relay/src/client.rs @@ -1,6 +1,6 @@ //! Thin relay client for connecting to the hub from opr8r or the relay-channel binary. //! -//! Handles connection, registration, and rename. Does not manage reconnection — +//! Handles connection, registration, and rename. Does not manage reconnection - //! that is the caller's responsibility for long-lived use cases. use std::path::{Path, PathBuf}; diff --git a/crates/relay/src/hub.rs b/crates/relay/src/hub.rs index 3a5a058f..615fa2f0 100644 --- a/crates/relay/src/hub.rs +++ b/crates/relay/src/hub.rs @@ -1,4 +1,4 @@ -//! Relay hub — tokio actor that owns the peer registry and routes ask/reply/broadcast messages. +//! Relay hub - tokio actor that owns the peer registry and routes ask/reply/broadcast messages. //! //! The hub runs embedded in operator's async runtime (lifetime = operator lifetime). //! No idle-shutdown timer: the hub exits only when operator exits. @@ -61,7 +61,7 @@ impl RelayHub { )); } _ => { - // Stale socket — remove it + // Stale socket - remove it let _ = std::fs::remove_file(&socket_path); } } @@ -373,7 +373,7 @@ impl HubState { if let Some(entry) = self.id_to_entry.get_mut(&conn_id) { entry.name = new_name.clone(); } - // Update pending asks — must happen before ack (matches TS ordering) + // Update pending asks - must happen before ack (matches TS ordering) self.update_name_on_rename(&old_name, &new_name); self.send_to_id(conn_id, ServerMsg::Ack { req_id }); } @@ -542,7 +542,7 @@ impl HubState { let cmd_tx_clone = cmd_tx.clone(); let timeout_task = tokio::spawn(async move { tokio::time::sleep(Duration::from_millis(timeout_ms)).await; - // Broadcast timeouts don't send errors — replies just stop arriving + // Broadcast timeouts don't send errors - replies just stop arriving let _ = cmd_tx_clone .send(HubCommand::TimeoutExpired { ask_id: ask_id_clone, @@ -1248,7 +1248,7 @@ mod tests { .await; let _ = caller.recv().await; // ack - // bob replies — should reach carol (formerly alice) + // bob replies - should reach carol (formerly alice) target .send(&ClientMsg::Reply { ask_id: "a6".into(), diff --git a/docs/configuration/index.md b/docs/configuration/index.md index 6707c0b2..3831c541 100644 --- a/docs/configuration/index.md +++ b/docs/configuration/index.md @@ -279,6 +279,16 @@ cors_origins = [] branch_format = "{type}/{ticket_id}" use_worktrees = false +[git.gitea] +enabled = false +token_env = "GITEA_TOKEN" +wip_prefix = "WIP: " + +[git.forgejo] +enabled = false +token_env = "FORGEJO_TOKEN" +wip_prefix = "WIP: " + [git.github] enabled = false token_env = "" diff --git a/docs/delegators/index.md b/docs/delegators/index.md index 87edb173..2f388d55 100644 --- a/docs/delegators/index.md +++ b/docs/delegators/index.md @@ -138,11 +138,11 @@ variables, and the token variable is stripped from every agent's spawn environment on all target kinds. **Blast radius:** a Coder session token can create, delete, and SSH into every workspace its user owns - scope accordingly. -Known limitation: prompt files are written on the operator side, so a coder -target currently requires the workspace to reach them (e.g. Operator itself -running inside a Coder workspace via the -[coder module](/getting-started/platforms/coder/)); `callback_url` keeps -multi-step chains reporting when the SSH tunnel drops. +No shared filesystem is required: the prompt and command payload are written on the operator side and pushed over SSH into the workspace before the session starts, so Operator can drive Coder from anywhere it can reach the deployment - a [Kubernetes deployment](/getting-started/platforms/kubernetes/#coder-targets), a server, or a laptop. +`callback_url` keeps multi-step chains reporting when the SSH tunnel drops. + +The `coder` CLI is resolved from `PATH`, then a cache in the state directory, and is otherwise downloaded from the deployment itself - so nothing has to be baked into an image and the CLI cannot drift from the server. `ssh` does have to be present. Keep `url_env` and `token_env` at their default names unless you have a reason not to: the SSH `ProxyCommand` runs the CLI as a subprocess, and +it reads `CODER_URL` / `CODER_SESSION_TOKEN` from the environment it inherits. Remote constraints for ssh and coder targets: worktrees and relay MCP injection are forced off, and the zellij session wrapper is unsupported. See diff --git a/docs/getting-started/platform-support.md b/docs/getting-started/platform-support.md index dbc27d66..c07a6a9f 100644 --- a/docs/getting-started/platform-support.md +++ b/docs/getting-started/platform-support.md @@ -70,7 +70,6 @@ Operator also ships as a container image and a Helm chart. Both are Linux-only |--------------|--------|-------| | Docker image `untra/operator` | ✅ Supported | Multi-arch. See [Docker](/getting-started/platforms/docker/) | | Helm chart `oci://ghcr.io/untra/charts/operator` | ⚠️ Alpha | Single-replica StatefulSet, ReadWriteOnce persistence. See [Kubernetes](/getting-started/platforms/kubernetes/) | -| Example Helmfile | ⚠️ Alpha | `examples/helmfile.yaml` in the repository | | Feature | Status | Reason | Workaround | |---------|--------|--------|------------| diff --git a/docs/getting-started/platforms/coder.md b/docs/getting-started/platforms/coder.md index 6b1d2923..f277c7dd 100644 --- a/docs/getting-started/platforms/coder.md +++ b/docs/getting-started/platforms/coder.md @@ -1,31 +1,42 @@ --- title: "Coder" -description: "Run Operator as a background service in Coder workspaces via Terraform module." +description: "Run Operator inside a Coder workspace, or point Operator at Coder to spawn per-ticket agent workspaces." layout: doc --- -Supported +Alpha -Run [Operator](https://operator.untra.io) as a background REST API server inside your [Coder](https://coder.com) workspace. The module downloads the operator binary from GitHub releases, generates configuration, starts the API server, and exposes the dashboard through the Coder workspace UI with automatic healthchecks. +[Operator](https://operator.untra.io) and [Coder](https://coder.com) fit together in two directions. They are independent — pick the one that matches where Operator runs. + +| | Operator runs | Agents run | Set up with | +|---|---|---|---| +| **[Inside a workspace](#operator-inside-a-coder-workspace)** | in a Coder workspace | in that same workspace | the Terraform module | +| **[Targeting Coder](#operator-targeting-coder)** | anywhere (Kubernetes, a server, your laptop) | in per-ticket Coder workspaces | a `[[targets]]` entry | + +The two can be combined: Operator inside a workspace can also spawn *sibling* workspaces. See [child agent workspaces](#child-agent-workspaces). + +## Operator inside a Coder workspace + +A Terraform module runs Operator as a background REST API server in the workspace, and exposes its dashboard as a Coder app with healthchecks. **Registry:** [`registry.coder.com/untra/operator/coder`](https://registry.coder.com/modules/operator) -## Usage +Templates and modules do different jobs here: a **template** is the whole workspace blueprint (cloud, compute, storage), while a **module** adds one feature inside it. Operator is a module — you drop it into a template you already have. + +### Usage ```tf module "operator" { source = "registry.coder.com/untra/operator/coder" - version = "1.0.0" agent_id = coder_agent.main.id } ``` -### Custom configuration +Pin `version` to a published module release for reproducible builds. `install_version` is a separate knob that selects which Operator release the module downloads. ```tf module "operator" { source = "registry.coder.com/untra/operator/coder" - version = "1.0.0" agent_id = coder_agent.main.id port = 7008 max_parallel_agents = 4 @@ -35,10 +46,11 @@ module "operator" { ### Full TOML override +`config_toml` is written verbatim and replaces the generated config entirely. + ```tf module "operator" { source = "registry.coder.com/untra/operator/coder" - version = "1.0.0" agent_id = coder_agent.main.id config_toml = <<-EOT [rest_api] @@ -47,20 +59,14 @@ module "operator" { [agents] max_parallel = 4 - health_check_interval = 30 [sessions] wrapper = "tmux" - - [[delegators]] - name = "default" - tool = "claude-code" - model = "sonnet" EOT } ``` -## Variables +### Variables | Variable | Type | Default | Description | |----------|------|---------|-------------| @@ -68,8 +74,8 @@ module "operator" { | `port` | `number` | `7008` | The port for the operator REST API server | | `display_name` | `string` | `"Operator"` | Display name in the Coder dashboard | | `slug` | `string` | `"operator"` | Application slug | -| `install_version` | `string` | `"{{ site.version }}"` | GitHub release tag to install | -| `install_prefix` | `string` | `"/tmp/operator"` | Directory to install the binary into | +| `install_version` | `string` | `"{{ site.version }}"` | Operator GitHub release tag to install | +| `install_prefix` | `string` | `"/tmp/operator"` | Directory to install the binaries into | | `log_path` | `string` | `"/tmp/operator.log"` | Path to write log output | | `config_toml` | `string` | `""` | Raw TOML config (written verbatim instead of auto-generated config) | | `max_parallel_agents` | `number` | `2` | Maximum number of parallel agents | @@ -77,24 +83,43 @@ module "operator" { | `share` | `string` | `"owner"` | Dashboard sharing level (`owner`, `authenticated`, or `public`) | | `order` | `number` | `null` | Position of the app in the Coder dashboard (lower = first) | | `group` | `string` | `null` | Group that this app belongs to | -| `offline` | `bool` | `false` | Skip downloading; requires pre-installed binary at `install_prefix` | +| `offline` | `bool` | `false` | Skip downloading; requires a pre-installed binary at `install_prefix` | | `use_cached` | `bool` | `false` | Use cached binary if present, otherwise download | -## Prerequisites +### Child agent workspaces + +Set `agent_template` and the generated config gains a `[[targets]]` entry with `kind = "coder"`, so tickets launched from this workspace create sibling workspaces from that template instead of running agents locally. These variables are ignored unless `agent_template` is set, and any left unset are omitted from the config so Operator's own defaults apply. + +| Variable | Type | Default | Description | +|----------|------|---------|-------------| +| `agent_template` | `string` | `""` | Template child agent workspaces are created from. Empty disables the coder target. | +| `coder_token_env` | `string` | `"CODER_SESSION_TOKEN"` | Name of the env var holding the Coder **user session token** | +| `callback_url` | `string` | `""` | Control-plane-reachable `OPERATOR_API_URL` override; empty keeps the reverse-tunnel default | +| `name_prefix` | `string` | `""` | Workspace name prefix for deterministic per-ticket naming | +| `workdir` | `string` | `""` | Project root inside spawned workspaces | +| `stop_on_complete` | `bool` | `null` | Stop a spawned workspace when its ticket completes (never deletes) | +| `create_timeout_secs` | `number` | `null` | Bound on workspace create plus agent-ready wait, in seconds | + +This mode needs a **user session token**, which is not the ambient `CODER_AGENT_TOKEN` — that one is scoped to a single workspace and cannot create others. The module does not provision it; supply it through your template. A user session token can create, delete, and SSH into every workspace its user owns, so scope the account accordingly. + +### Prerequisites The workspace image must include `tmux` (or your chosen `session_wrapper`) for Operator to spawn agent sessions. Most Coder workspace images include tmux by default. -## Coder Workspace Context +For child agent workspaces, the image also needs `ssh` (`openssh-client`), since agents are launched over real SSH. + +### Coder workspace context Coder automatically injects environment variables into every workspace that Operator can reference in ticket templates and agent prompts: - `CODER_WORKSPACE_NAME` — workspace identifier - `CODER_WORKSPACE_OWNER` — workspace owner username -- `CODER_AGENT_TOKEN` — agent authentication token +- `CODER_URL` — deployment URL, which the coder target reads by default +- `CODER_AGENT_TOKEN` — agent authentication token, scoped to this workspace No Operator configuration is needed to access these — they are ambient in the workspace environment. -## How It Works +### How it works 1. The module runs a startup script that detects the workspace architecture (`linux-x86_64` or `linux-arm64`) 2. Downloads the Operator binary from GitHub releases (or uses a cached/pre-installed binary), then the `opr8r` client binary that agent sessions call to report step completion for multi-step workflows. @@ -102,20 +127,81 @@ No Operator configuration is needed to access these — they are ambient in the 4. Starts `operator api` as a background process 5. Registers the Operator dashboard as a Coder app with healthchecks polling `/api/v1/health` every 5 seconds +## Operator targeting Coder + +Here Operator runs outside Coder — most often as the [Kubernetes deployment](/getting-started/platforms/kubernetes/) — and provisions a Coder workspace per ticket. The Terraform module is not involved. + +Declare a target. `template` is an allowlist: agents can only ever land on the template you name here. + +```toml +[[targets]] +name = "cloud" +kind = "coder" +template = "operator-agent" +``` + +Then reference it from a delegator, or set it as the default target. Full field reference: [execution targets](/delegators/#execution-targets). + +### Credentials + +Operator reads two environment variables, resolved **by name** so the values never enter the config file or the state store: + +| Variable | Default name | Holds | +|----------|--------------|-------| +| `url_env` | `CODER_URL` | Your deployment URL, e.g. `https://coder.example.com` | +| `token_env` | `CODER_SESSION_TOKEN` | A Coder user session token | + +A session token can create, delete, and SSH into every workspace its user owns, so give Operator its own service account rather than a human's credentials. Operator strips the token variable from every agent's spawn environment, on every target kind — including `local` agents, which would otherwise read it straight out of `env`. + +Keep both variables at their default names unless you have a reason not to. The SSH `ProxyCommand` runs the `coder` CLI as a subprocess, and the CLI reads these canonical names from the inherited environment. + +### The `coder` CLI + +Operator does not bundle the CLI. It resolves one in this order: + +1. `coder` on `PATH` +2. A previously downloaded copy in the state directory, at `.tickets/operator/bin/coder` +3. Otherwise it downloads `{CODER_URL}/bin/coder-linux-{amd64,arm64}` — your deployment serves a CLI matching its own version — and caches it at (2) + +So a container needs no CLI baked in, and the CLI can never drift from the server it talks to. It does need `ssh` and outbound network access to the deployment. The official image ships `openssh-client`; if you supply your own, include it. + +### Workspace lifecycle + +- **Naming is deterministic:** `{name_prefix}-{project}-{ticket_id}`, sanitized and capped at Coder's 32-character limit. Relaunching a ticket reuses its workspace. +- **Create or start:** absent workspaces are created from `template`; existing ones on that template are started. +- **Collisions are refused:** a workspace of the same name on a *different* template stops the launch rather than being reused, so Operator can never adopt a workspace a human made. +- **Never deleted:** completed workspaces are stopped (when `stop_on_complete` is set). Reclamation stays with your Coder autostop and autodelete policy. + +Operator writes its own per-workspace SSH config fragment under `.tickets/operator/ssh/` rather than running `coder config-ssh`, which would rewrite `~/.ssh/config`. The fragment proxies through `coder ssh --stdio` and skips host-key checking, matching what `coder config-ssh` writes for its own hosts: the Coder tailnet is the authentication boundary, and per-ticket workspaces are too short-lived for trust-on-first-use to be meaningful. + +### Constraints + +For `coder` targets, as for `ssh` targets, git worktrees and relay MCP injection are forced off, and the `zellij` session wrapper is unsupported. + ## Troubleshooting -### Binary download fails +### Binary download fails (module) 1. Check that the `install_version` matches a valid [GitHub release tag](https://github.com/untra/operator/releases) 2. Verify the workspace has internet access (or use `offline = true` with a pre-installed binary) 3. Check logs at the configured `log_path` (default: `/tmp/operator.log`) -### Healthcheck timeout +### Healthcheck timeout (module) 1. Verify the port is not already in use: `ss -tlnp | grep 7008` 2. Check operator logs: `cat /tmp/operator.log` 3. Ensure the session wrapper (tmux by default) is installed in the workspace image -### Port conflicts +### Port conflicts (module) Change the `port` variable to an unused port. Remember to update any other services or extensions that connect to the Operator API. + +### A coder target fails to launch + +Operator fails fast and names what is missing. In order: + +1. **A missing environment variable** — the error names it. Confirm `CODER_URL` and the session token are present in Operator's own environment, not just the agent's. +2. **The CLI download fails** — the error names the URL it tried. Usually egress: from a container, check reachability directly, e.g. `curl -sSf $CODER_URL/api/v2/buildinfo`. In Kubernetes this is commonly Coder's *own* ingress NetworkPolicy declining to admit Operator's namespace, which is a fix on the Coder side. +3. **`coder create` fails** — the message is Coder's own, verbatim. Template permissions and workspace quotas surface here. +4. **The workspace never becomes reachable over SSH** within `create_timeout_secs` — the template's agent is not starting, or `ssh` is missing from Operator's environment. +5. **A refused name collision** — a workspace of that name already exists on another template. Rename or remove it. diff --git a/docs/getting-started/platforms/kubernetes.md b/docs/getting-started/platforms/kubernetes.md index 89397a9e..525ac07a 100644 --- a/docs/getting-started/platforms/kubernetes.md +++ b/docs/getting-started/platforms/kubernetes.md @@ -13,14 +13,14 @@ The chart deploys a single-replica StatefulSet with a persistent workspace volum ## What the chart does not contain -Stated up front, because it is the first thing worth knowing about running an agent orchestrator in your cluster: +Stated up front, because it is the first thing worth knowing about running an agent orchestrator in the cluster: - **No Docker socket** is mounted. - **No Kubernetes controller.** Operator does not watch, create, or reconcile cluster resources. - **No Role, RoleBinding, or ClusterRole** is created. - The ServiceAccount sets `automountServiceAccountToken: false`, so the pod has no Kubernetes API credential at all. -Operator in your cluster is an application with a volume and a port. It cannot reach the Kubernetes API, because it has no token and no client to use one with. +Operator in a kubernetes cluster is an application with a volume and a port. It cannot reach the Kubernetes API, because it has no token and no client to use one with. ## Install @@ -66,8 +66,7 @@ kubectl -n operator delete secret operator-bootstrap ## DNS and TLS -The chart does not manage certificates. Create the TLS Secret yourself, or let -cert-manager create it, then point the Ingress at it. +The chart does not manage certificates. Create the TLS Secret independently, or let cert-manager create it, then point the Ingress at it. With cert-manager: @@ -114,8 +113,9 @@ persistence: storageClass: fast-ssd ``` -The volume is mounted at `/op` and holds the workspace, repositories, -`.tickets/`, and the authentication database. It is the only durable state. +The volume is mounted at `/op` and holds the workspace, repositories, `.tickets/`, and the authentication database. It is the only durable state — `$HOME` and `/tmp` are emptyDir mounts and are discarded on every restart. + +Two things land here that are easy to overlook, both under `.tickets/operator/`: `ssh/` holds the per-workspace SSH config fragments for [Coder targets](#coder-targets), and `bin/` caches the `coder` CLI when Operator downloads one. Keeping them on the volume is why a pod restart does not re-download the CLI. **Horizontal scaling is not supported.** Operator is a single-writer process over a ReadWriteOnce volume with a local queue and a local SQLite database. @@ -149,17 +149,16 @@ networkPolicy: - 192.168.0.0/16 ``` -Operator needs egress to your model provider, kanban provider, and Git host. It -does not need egress to the rest of your cluster. +Operator needs egress to the model provider, kanban provider, and Git host. It does not need egress to the rest of the cluster — unless you use [Coder targets](#coder-targets), which need to reach the Coder deployment. + +Note default: with `enabled: true` and an empty `egress.to`, the rendered policy permits DNS. An empty list is deny-all, not allow-all. ## Custom agent images -The base image ships `git`, `tmux`, and `ca-certificates`, but **no agent CLI** -— no `claude`, `codex`, or `gemini`, and no credentials for them. Supply your -own image: +The base image ships `git`, `tmux`, `openssh-client`, `curl`, and `ca-certificates`, but **no agent CLI** — no `claude`, `codex`, or `gemini`, and no credentials for them. ```dockerfile -FROM untra/operator:0.2.6 +FROM untra/operator:0.2.7 USER root RUN apt-get update && apt-get install -y --no-install-recommends nodejs npm \ && npm install -g @anthropic-ai/claude-code \ @@ -170,7 +169,7 @@ USER 10001 ```yaml image: repository: registry.example.com/operator-claude - tag: "0.2.6" + tag: "0.2.7" ``` Provide the agent's credentials as environment variables from a Secret: @@ -185,6 +184,46 @@ Note that an agent process runs as the same user as Operator and can read these. That is inherent to the current execution model — see [the trust boundary discussion](/security/#the-agent-process-is-inside-the-trust-boundary). +## Coder targets + +Operator can run agents in per-ticket [Coder](/getting-started/platforms/coder/#operator-targeting-coder) +workspaces instead of in its own pod. From a Kubernetes deployment that needs three things. + +**1. Credentials, by name.** Operator reads the deployment URL and a user session token from environment variables. Put them in a Secret and reference it — the chart has no dedicated values for this: + +```yaml +extraEnvFrom: + - secretRef: + name: operator-coder +``` + +```bash +kubectl -n operator create secret generic operator-coder --from-literal=CODER_URL=https://coder.example.com --from-literal=CODER_SESSION_TOKEN= +``` + +Give Operator its own Coder service account. A session token can create, delete, and SSH into every workspace its user owns. + +**2. Egress to Coder.** If `networkPolicy.enabled` is true, add the Coder namespace explicitly: + +```yaml +networkPolicy: + enabled: true + egress: + allowDNS: true + to: + - namespaceSelector: + matchLabels: + kubernetes.io/metadata.name: coder +``` + +Coder's own ingress NetworkPolicy has to admit Operator's namespace too. + +```bash +kubectl -n operator exec operator-0 -- curl -sSf https://coder.example.com/api/v2/buildinfo +``` + +**3. Nothing else.** The image already ships `openssh-client`, and Operator downloads the `coder` CLI from the deployment on first use, caching it on the persistent volume at `.tickets/operator/bin/coder`. No custom image, no initContainer, and no relaxing of `readOnlyRootFilesystem` — the cache and the SSH fragments both live under `/op`. + ## Security context Applied by default; you should not need to change any of it: @@ -222,8 +261,7 @@ Back up the persistent volume. It holds everything: workspace, tickets, state, a Treat the backup as sensitive — it contains the authentication database, which holds the token signing key. -To restore, pre-create the PersistentVolumeClaim the StatefulSet expects, backed -by your snapshot, before installing the chart. A StatefulSet adopts an existing claim whose name matches its `volumeClaimTemplate`, which is `workspace--0`: +To restore, pre-create the PersistentVolumeClaim the StatefulSet expects, backed by the snapshot, before installing the chart. A StatefulSet adopts an existing claim whose name matches its `volumeClaimTemplate`, which is `workspace--0`: ```yaml apiVersion: v1 @@ -285,8 +323,7 @@ even on a perfectly healthy pod. ### Agents fail to launch -The base image intentionally omits the agent CLI. Confirm your derived image -provides an authenticated `claude`, `codex`, or `gemini` on `PATH`: +The base image intentionally omits the agent CLI. Confirm the derived image provides an authenticated `claude`, `codex`, or `gemini` on `PATH`: ```bash kubectl -n operator exec statefulset/operator -- sh -c 'command -v claude' diff --git a/docs/maturity/index.md b/docs/maturity/index.md index 1cb4ee53..47c2b262 100644 --- a/docs/maturity/index.md +++ b/docs/maturity/index.md @@ -45,7 +45,7 @@ Operator integrates with many providers and tools across several **verticals**. | Bitbucket | ![Proto](https://img.shields.io/badge/Proto-6B7280) | - | | Azure DevOps | ![Proto](https://img.shields.io/badge/Proto-6B7280) | - | | Forgejo | ![Proto](https://img.shields.io/badge/Proto-6B7280) | - | -| Gitea | ![Proto](https://img.shields.io/badge/Proto-6B7280) | - | +| Gitea | ![Alpha](https://img.shields.io/badge/Alpha-6495ED) | [Gitea](https://operator.untra.io/getting-started/git/gitea/) | ## Session diff --git a/docs/schemas/config.json b/docs/schemas/config.json index e3528cbc..3cd9e402 100644 --- a/docs/schemas/config.json +++ b/docs/schemas/config.json @@ -97,12 +97,25 @@ "enabled": true, "host": "127.0.0.1", "port": 7008, - "cors_origins": [] + "cors_origins": [], + "public_url": null } }, "git": { "$ref": "#/$defs/GitConfig", "default": { + "gitea": { + "enabled": false, + "token_env": "GITEA_TOKEN", + "host": null, + "wip_prefix": "WIP: " + }, + "forgejo": { + "enabled": false, + "token_env": "FORGEJO_TOKEN", + "host": null, + "wip_prefix": "WIP: " + }, "provider": null, "github": { "enabled": false, @@ -1118,12 +1131,20 @@ "default": 7008 }, "cors_origins": { - "description": "CORS allowed origins (empty = allow all)", + "description": "CORS allowed origins. Empty means **same-origin only**", "type": "array", "items": { "type": "string" }, "default": [] + }, + "public_url": { + "description": "Externally reachable base URL (e.g. `https://operator.example.com`).\n\nOAuth and MCP descriptor URLs are generated from this rather than from the request's `Host` header,\nwhich a caller controls. Defaults to request host, which is correct for a loopback bind and wrong behind a reverse proxy.", + "type": [ + "string", + "null" + ], + "default": null } } }, @@ -1131,6 +1152,35 @@ "description": "Git provider configuration for PR/MR operations", "type": "object", "properties": { + "identity": { + "description": "Default commit identity for delegated work.", + "anyOf": [ + { + "$ref": "#/$defs/GitIdentityConfig" + }, + { + "type": "null" + } + ] + }, + "gitea": { + "$ref": "#/$defs/GiteaConfig", + "default": { + "enabled": false, + "token_env": "GITEA_TOKEN", + "host": null, + "wip_prefix": "WIP: " + } + }, + "forgejo": { + "$ref": "#/$defs/ForgejoConfig", + "default": { + "enabled": false, + "token_env": "FORGEJO_TOKEN", + "host": null, + "wip_prefix": "WIP: " + } + }, "provider": { "description": "Active provider (auto-detected from remote URL if not specified)", "anyOf": [ @@ -1172,6 +1222,72 @@ } } }, + "GitIdentityConfig": { + "description": "Commit identity template for delegated work.", + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "email": { + "type": "string" + } + }, + "required": [ + "name", + "email" + ] + }, + "GiteaConfig": { + "type": "object", + "properties": { + "enabled": { + "type": "boolean", + "default": false + }, + "token_env": { + "type": "string", + "default": "GITEA_TOKEN" + }, + "host": { + "description": "HTTPS host or base URL; defaults to gitea.com.", + "type": [ + "string", + "null" + ], + "default": null + }, + "wip_prefix": { + "type": "string", + "default": "WIP: " + } + } + }, + "ForgejoConfig": { + "type": "object", + "properties": { + "enabled": { + "type": "boolean", + "default": false + }, + "token_env": { + "type": "string", + "default": "FORGEJO_TOKEN" + }, + "host": { + "description": "HTTPS host or base URL; defaults to codeberg.org.", + "type": [ + "string", + "null" + ], + "default": null + }, + "wip_prefix": { + "type": "string", + "default": "WIP: " + } + } + }, "GitProviderConfig": { "description": "Git provider selection", "oneOf": [ @@ -1484,6 +1600,17 @@ "description": "Agent delegator configuration for autonomous ticket launching\n\nA delegator is a named {tool, model} pairing with optional launch configuration\nthat can be used to launch agents for tickets.", "type": "object", "properties": { + "git": { + "description": "Optional Git identity, HTTPS credential reference, and runtime settings.", + "anyOf": [ + { + "$ref": "#/$defs/GitExecutionConfig" + }, + { + "type": "null" + } + ] + }, "name": { "description": "Unique name for this delegator (e.g., \"claude-opus-auto\")", "type": "string" @@ -1559,6 +1686,76 @@ "model" ] }, + "GitExecutionConfig": { + "description": "Git settings owned by a named delegator.", + "type": "object", + "properties": { + "identity": { + "anyOf": [ + { + "$ref": "#/$defs/GitIdentityConfig" + }, + { + "type": "null" + } + ], + "default": null + }, + "credentials": { + "anyOf": [ + { + "$ref": "#/$defs/GitCredentialConfig" + }, + { + "type": "null" + } + ], + "default": null + }, + "settings": { + "type": "array", + "items": { + "$ref": "#/$defs/GitConfigEntry" + }, + "default": [] + } + } + }, + "GitCredentialConfig": { + "description": "Supplied HTTPS credential, bound to a repository; contains no secret value.", + "type": "object", + "properties": { + "repository_url": { + "type": "string" + }, + "username": { + "type": "string" + }, + "token_env": { + "type": "string" + } + }, + "required": [ + "repository_url", + "username", + "token_env" + ] + }, + "GitConfigEntry": { + "type": "object", + "properties": { + "key": { + "type": "string" + }, + "value": { + "type": "string" + } + }, + "required": [ + "key", + "value" + ] + }, "DelegatorLaunchConfig": { "description": "Launch configuration for a delegator\n\nControls how the delegator launches agents. Optional fields use tri-state\nsemantics: `None` = inherit from global config, `Some(true/false)` = override.", "type": "object", @@ -1855,7 +2052,7 @@ "default": "op" }, "workdir": { - "description": "Project root inside the workspace (None = workspace $HOME)", + "description": "Project root inside the workspace (None = /home/coder/{project})", "type": [ "string", "null" @@ -1881,7 +2078,7 @@ ] }, "parameters": { - "description": "Passthrough `-p` template parameters for `coder create`", + "description": "Passthrough `--parameter` template parameters for `coder create`", "type": "object", "additionalProperties": { "type": "string" diff --git a/docs/schemas/config.md b/docs/schemas/config.md index 9c9d7bd8..1da5c729 100644 --- a/docs/schemas/config.md +++ b/docs/schemas/config.md @@ -365,7 +365,8 @@ REST API server configuration | `enabled` | `boolean` | No | Whether the REST API is enabled | | `host` | `string` | No | Address the REST API binds to. Defaults to `127.0.0.1` (local only) so the server — which reports the project directory name — is not reachable from other hosts. Set to `0.0.0.0` to expose it on all interfaces. | | `port` | `integer` | No | Port for the REST API server | -| `cors_origins` | `array` | No | CORS allowed origins (empty = allow all) | +| `cors_origins` | `array` | No | CORS allowed origins. Empty means **same-origin only** | +| `public_url` | `string` \| `null` | No | Externally reachable base URL (e.g. `https://operator.example.com`). OAuth and MCP descriptor URLs are generated from this rather than from the request's `Host` header, which a caller controls. Defaults to request host, which is correct for a loopback bind and wrong behind a reverse proxy. | ### GitConfig @@ -373,12 +374,42 @@ Git provider configuration for PR/MR operations | Property | Type | Required | Description | | --- | --- | --- | --- | +| `identity` | object | No | Default commit identity for delegated work. | +| `gitea` | → `GiteaConfig` | No | | +| `forgejo` | → `ForgejoConfig` | No | | | `provider` | object | No | Active provider (auto-detected from remote URL if not specified) | | `github` | → `GitHubConfig` | No | GitHub-specific configuration | | `gitlab` | → `GitLabConfig` | No | GitLab-specific configuration | | `branch_format` | `string` | No | Branch naming format (e.g., "{type}/{ticket_id}-{slug}") | | `use_worktrees` | `boolean` | No | Whether to use git worktrees for per-ticket isolation (default: false) When false, tickets work directly in the project directory with branches | +### GitIdentityConfig + +Commit identity template for delegated work. + +| Property | Type | Required | Description | +| --- | --- | --- | --- | +| `name` | `string` | Yes | | +| `email` | `string` | Yes | | + +### GiteaConfig + +| Property | Type | Required | Description | +| --- | --- | --- | --- | +| `enabled` | `boolean` | No | | +| `token_env` | `string` | No | | +| `host` | `string` \| `null` | No | HTTPS host or base URL; defaults to gitea.com. | +| `wip_prefix` | `string` | No | | + +### ForgejoConfig + +| Property | Type | Required | Description | +| --- | --- | --- | --- | +| `enabled` | `boolean` | No | | +| `token_env` | `string` | No | | +| `host` | `string` \| `null` | No | HTTPS host or base URL; defaults to codeberg.org. | +| `wip_prefix` | `string` | No | | + ### GitProviderConfig Git provider selection @@ -535,6 +566,7 @@ that can be used to launch agents for tickets. | Property | Type | Required | Description | | --- | --- | --- | --- | +| `git` | object | No | Optional Git identity, HTTPS credential reference, and runtime settings. | | `name` | `string` | Yes | Unique name for this delegator (e.g., "claude-opus-auto") | | `llm_tool` | `string` | Yes | LLM tool name (must match a detected tool, e.g., "claude", "codex") | | `model` | `string` | Yes | Model alias (e.g., "opus", "sonnet", "gpt-4o") | @@ -547,6 +579,33 @@ that can be used to launch agents for tickets. | `x_openai` | object | No | Opaque OpenAI-namespaced extension fields, preserved verbatim across an `AgentProfile` round-trip (e.g. `instructions`, `tools`, `tool_resources`, `metadata`, thread refs). Mirror of [`Self::x_agnt`]; never interpreted. | | `unmapped_core` | object | No | Opaque carry for `AgentProfile` shared-core fields Operator cannot model first-class (`system_prompt` / `skills` / `mcp_servers` / `tools`) so an import→export round-trip is lossless. Distinct from `x_agnt`: these are shared-core fields, not AGNT-specific, so folding them into `x_agnt` would corrupt that namespace. Operator never interprets this. | +### GitExecutionConfig + +Git settings owned by a named delegator. + +| Property | Type | Required | Description | +| --- | --- | --- | --- | +| `identity` | object | No | | +| `credentials` | object | No | | +| `settings` | `array` | No | | + +### GitCredentialConfig + +Supplied HTTPS credential, bound to a repository; contains no secret value. + +| Property | Type | Required | Description | +| --- | --- | --- | --- | +| `repository_url` | `string` | Yes | | +| `username` | `string` | Yes | | +| `token_env` | `string` | Yes | | + +### GitConfigEntry + +| Property | Type | Required | Description | +| --- | --- | --- | --- | +| `key` | `string` | Yes | | +| `value` | `string` | Yes | | + ### DelegatorLaunchConfig Launch configuration for a delegator @@ -645,11 +704,11 @@ SSH remote-launch path. There is no `enabled` field — presence in | `url_env` | `string` | No | Env var NAME holding the Coder deployment URL | | `token_env` | `string` | No | Env var NAME holding the Coder session token. The variable is stripped from every agent's spawn environment on all target kinds. | | `name_prefix` | `string` | No | Workspace name prefix for deterministic per-ticket naming | -| `workdir` | `string` \| `null` | No | Project root inside the workspace (None = workspace $HOME) | +| `workdir` | `string` \| `null` | No | Project root inside the workspace (None = /home/coder/{project}) | | `stop_on_complete` | `boolean` | No | Stop the workspace when the ticket completes (never delete) | | `create_timeout_secs` | `integer` | No | Bound on workspace create + agent-ready wait | | `callback_url` | `string` \| `null` | No | Control-plane-reachable `OPERATOR_API_URL` override for detached multi-step (empty/None = reverse tunnel default) | -| `parameters` | `object` | No | Passthrough `-p` template parameters for `coder create` | +| `parameters` | `object` | No | Passthrough `--parameter` template parameters for `coder create` | ### SshTarget diff --git a/docs/schemas/openapi.json b/docs/schemas/openapi.json index 1396ab1c..4bb11d88 100644 --- a/docs/schemas/openapi.json +++ b/docs/schemas/openapi.json @@ -5967,6 +5967,17 @@ ], "description": "Optional display name for UI" }, + "git": { + "oneOf": [ + { + "type": "null" + }, + { + "$ref": "#/components/schemas/GitExecutionConfig", + "description": "Optional Git identity, HTTPS credential reference, and runtime settings." + } + ] + }, "launch_config": { "oneOf": [ { @@ -6021,6 +6032,17 @@ ], "description": "Optional display name" }, + "git": { + "oneOf": [ + { + "type": "null" + }, + { + "$ref": "#/components/schemas/GitExecutionConfig", + "description": "Optional Git identity, HTTPS credential reference, and runtime settings." + } + ] + }, "launch_config": { "oneOf": [ { @@ -6273,7 +6295,6 @@ "review_type": { "type": "string", "description": "Type of review required: \"none\", \"plan\", \"visual\", \"pr\", \"proof\"" - "description": "Type of review required: \"none\", \"plan\", \"visual\", \"pr\", \"proof\"" } } }, @@ -6581,6 +6602,17 @@ ], "description": "Optional display name" }, + "git": { + "oneOf": [ + { + "type": "null" + }, + { + "$ref": "#/components/schemas/GitExecutionConfig", + "description": "Optional Git identity, HTTPS credential reference, and runtime settings." + } + ] + }, "launch_config": { "oneOf": [ { @@ -6674,10 +6706,6 @@ "health_ok": { "type": "boolean" }, - "health_ok": { - "type": "boolean", - "description": "Whether the tool passed its health check at detection on startup" - }, "min_version": { "type": [ "string", @@ -7026,6 +7054,89 @@ } } }, + "GitConfigEntry": { + "type": "object", + "required": [ + "key", + "value" + ], + "properties": { + "key": { + "type": "string" + }, + "value": { + "type": "string" + } + } + }, + "GitCredentialConfig": { + "type": "object", + "description": "Supplied HTTPS credential, bound to a repository; contains no secret value.", + "required": [ + "repository_url", + "username", + "token_env" + ], + "properties": { + "repository_url": { + "type": "string" + }, + "token_env": { + "type": "string" + }, + "username": { + "type": "string" + } + } + }, + "GitExecutionConfig": { + "type": "object", + "description": "Git settings owned by a named delegator.", + "properties": { + "credentials": { + "oneOf": [ + { + "type": "null" + }, + { + "$ref": "#/components/schemas/GitCredentialConfig" + } + ] + }, + "identity": { + "oneOf": [ + { + "type": "null" + }, + { + "$ref": "#/components/schemas/GitIdentityConfig" + } + ] + }, + "settings": { + "type": "array", + "items": { + "$ref": "#/components/schemas/GitConfigEntry" + } + } + } + }, + "GitIdentityConfig": { + "type": "object", + "description": "Commit identity template for delegated work.", + "required": [ + "name", + "email" + ], + "properties": { + "email": { + "type": "string" + }, + "name": { + "type": "string" + } + } + }, "GithubCredentials": { "type": "object", "description": "Ephemeral GitHub Projects credentials supplied by a client during onboarding.\n\nThe token must have `project` (or `read:project`) scope. A repo-only token\n(the kind used for `GITHUB_TOKEN` and operator's git provider) will be\nrejected at validation time with a friendly \"lacks `project` scope\" error.", @@ -8499,7 +8610,49 @@ }, "review_type": { "type": "string", - "description": "Review type: \"none\", \"plan\", \"visual\", \"pr\"" + "description": "Review type: \"none\", \"plan\", \"visual\", \"pr\", \"proof\"" + } + } + }, + "OAuthErrorCode": { + "type": "string", + "description": "OAuth error codes Operator emits.", + "enum": [ + "authorization_pending", + "slow_down", + "expired_token", + "access_denied", + "invalid_grant", + "invalid_request", + "invalid_client", + "invalid_scope", + "unsupported_grant_type" + ] + }, + "OAuthErrorResponse": { + "type": "object", + "description": "Standardized OAuth error, shaped per RFC 6749 §5.2 so stock clients can\ninterpret it — notably `authorization_pending` and `slow_down`, which a\ndevice-flow client polls against.", + "required": [ + "error" + ], + "properties": { + "error": { + "$ref": "#/components/schemas/OAuthErrorCode", + "description": "Machine-readable error code." + }, + "error_description": { + "type": [ + "string", + "null" + ], + "description": "Human-readable explanation." + }, + "error_uri": { + "type": [ + "string", + "null" + ], + "description": "Documentation link." } } }, @@ -10694,6 +10847,17 @@ ], "description": "Optional display name for UI." }, + "git": { + "oneOf": [ + { + "type": "null" + }, + { + "$ref": "#/components/schemas/GitExecutionConfig", + "description": "Optional Git identity, HTTPS credential reference, and runtime settings." + } + ] + }, "launch_config": { "oneOf": [ { diff --git a/docs/schemas/state.json b/docs/schemas/state.json index 802dc8ac..4cb4f75a 100644 --- a/docs/schemas/state.json +++ b/docs/schemas/state.json @@ -56,6 +56,18 @@ "AgentState": { "type": "object", "properties": { + "git_context": { + "description": "Non-secret Git configuration captured at launch.", + "anyOf": [ + { + "$ref": "#/$defs/GitExecutionConfig" + }, + { + "type": "null" + } + ], + "default": null + }, "id": { "type": "string" }, @@ -294,6 +306,92 @@ "paired" ] }, + "GitExecutionConfig": { + "description": "Git settings owned by a named delegator.", + "type": "object", + "properties": { + "identity": { + "anyOf": [ + { + "$ref": "#/$defs/GitIdentityConfig" + }, + { + "type": "null" + } + ], + "default": null + }, + "credentials": { + "anyOf": [ + { + "$ref": "#/$defs/GitCredentialConfig" + }, + { + "type": "null" + } + ], + "default": null + }, + "settings": { + "type": "array", + "items": { + "$ref": "#/$defs/GitConfigEntry" + }, + "default": [] + } + } + }, + "GitIdentityConfig": { + "description": "Commit identity template for delegated work.", + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "email": { + "type": "string" + } + }, + "required": [ + "name", + "email" + ] + }, + "GitCredentialConfig": { + "description": "Supplied HTTPS credential, bound to a repository; contains no secret value.", + "type": "object", + "properties": { + "repository_url": { + "type": "string" + }, + "username": { + "type": "string" + }, + "token_env": { + "type": "string" + } + }, + "required": [ + "repository_url", + "username", + "token_env" + ] + }, + "GitConfigEntry": { + "type": "object", + "properties": { + "key": { + "type": "string" + }, + "value": { + "type": "string" + } + }, + "required": [ + "key", + "value" + ] + }, "StepLaunchContext": { "description": "Launch context fixed at launch time, persisted with the agent record, and\nread back by `complete_step` to build subsequent step commands.\n\nThe persisted context is the baseline for a ticket's whole chain; per-step\n`agent` overrides from the step schema apply on top for that step only.", "type": "object", diff --git a/docs/startup/index.md b/docs/startup/index.md index 500ae05d..170855aa 100644 --- a/docs/startup/index.md +++ b/docs/startup/index.md @@ -77,11 +77,11 @@ Worktrees allow multiple agents to work on different tickets simultaneously with Operator has a single human account, `admin`. -This terminal and the CLI need no password: a loopback process authenticates with an owner-only token file in the state directory. A browser cannot read that file, so the web dashboard stays locked until an admin password exists. +This terminal and the CLI need no password: a loopback process authenticates with an owner-only token file in the state directory. A browser cannot read that file, so the web dashboard stays locked until an admin password exists. -Leave both fields blank to skip. You can set one later with `operator auth bootstrap` or from the /setup page. +Leave both fields blank to skip. You can set one later with `operator auth bootstrap` or from the /setup page. -The password must be at least 12 characters. This step is hidden when an admin account already exists. +The password must be at least 12 characters. This step is hidden when an admin account already exists. **Navigation**: Tab to switch fields, Enter to continue (blank to skip), Esc to go back @@ -170,9 +170,9 @@ Select a preset collection of issue types: *Browse and select hosted collections (only shown if Browse chosen)* -Pick one or more curated collections published at operator.untra.io. +Pick one or more curated collections published at operator.untra.io. -The list is fetched from the collections manifest; if it cannot be reached, the collections bundled with Operator are offered instead. Each collection brings its own issue types and workflow steps. +The list is fetched from the collections manifest; if it cannot be reached, the collections bundled with Operator are offered instead. Each collection brings its own issue types and workflow steps. Selections are additive - choose as many as apply. diff --git a/package.json b/package.json index 13b74eb4..e129483d 100644 --- a/package.json +++ b/package.json @@ -3,9 +3,15 @@ "private": true, "description": "Documentation generation for Operator TypeScript types", "scripts": { - "docs:typescript": "typedoc" + "docs:typescript": "typedoc", + "lint": "oxlint --type-aware", + "lint:ui": "oxlint --type-aware ui/src", + "lint:webcomponents": "oxlint --type-aware webcomponents/src", + "lint:vscode": "oxlint --type-aware vscode-extension/src vscode-extension/test vscode-extension/webview-ui" }, "devDependencies": { + "oxlint": "1.81.0", + "oxlint-tsgolint": "7.0.2001", "typedoc": "^0.27.0", "typescript": "^5.0.0" } diff --git a/scripts/ci/check-coder-module.sh b/scripts/ci/check-coder-module.sh index 2364ec15..b50971ac 100755 --- a/scripts/ci/check-coder-module.sh +++ b/scripts/ci/check-coder-module.sh @@ -27,7 +27,7 @@ VARS=' PORT = 7008, INSTALL_PREFIX = "/tmp/operator", LOG_PATH = "/tmp/operator.log", - CONFIG_TOML = "", + CONFIG_TOML_B64 = "", MAX_PARALLEL = 2, SESSION_WRAPPER = "tmux", OFFLINE = false, @@ -35,6 +35,32 @@ VARS=' AGENT_TEMPLATE = "operator-agent", CODER_TOKEN_ENV = "CODER_SESSION_TOKEN", CALLBACK_URL = "", + NAME_PREFIX = "", + WORKDIR = "", + STOP_ON_COMPLETE = "", + CREATE_TIMEOUT_SECS = "", +' + +# Second pass: every optional branch populated, and a config_toml carrying the +# quotes and `$` that the base64 hand-off exists to protect. Rendering only the +# empty case is how a value-mangling bug stays invisible to bash -n. +VARS_POPULATED=' + VERSION = "0.0.0", + PORT = 7008, + INSTALL_PREFIX = "/tmp/operator", + LOG_PATH = "/tmp/operator.log", + CONFIG_TOML_B64 = base64encode("[sessions]\nwrapper = \"tmux\"\nhome = \"$HOME\"\n"), + MAX_PARALLEL = 2, + SESSION_WRAPPER = "tmux", + OFFLINE = false, + USE_CACHED = false, + AGENT_TEMPLATE = "operator-agent", + CODER_TOKEN_ENV = "CODER_SESSION_TOKEN", + CALLBACK_URL = "https://operator.example.com", + NAME_PREFIX = "op", + WORKDIR = "/home/coder/proj", + STOP_ON_COMPLETE = "true", + CREATE_TIMEOUT_SECS = "600", ' extract_keys() { @@ -54,18 +80,23 @@ fi RENDER="$(mktemp -d)" trap 'rm -rf "$RENDER"' EXIT -cat > "$RENDER/main.tf" < "$dir/main.tf" </dev/null + "$TF" -chdir="$dir" apply -auto-approve -input=false >/dev/null + "$TF" -chdir="$dir" output -raw s > "$dir/rendered.sh" + bash -n "$dir/rendered.sh" + shellcheck -S error "$dir/rendered.sh" + echo "coder-module rendered startup script OK ($label)" +} -"$TF" -chdir="$RENDER" init -input=false >/dev/null -"$TF" -chdir="$RENDER" apply -auto-approve -input=false >/dev/null -"$TF" -chdir="$RENDER" output -raw s > "$RENDER/rendered.sh" - -bash -n "$RENDER/rendered.sh" -shellcheck -S error "$RENDER/rendered.sh" -echo "coder-module rendered startup script OK" +render_and_check defaults "$VARS" +render_and_check populated "$VARS_POPULATED" diff --git a/shared/types.ts b/shared/types.ts index 51c3cb33..aa1ef078 100644 --- a/shared/types.ts +++ b/shared/types.ts @@ -425,9 +425,16 @@ host: string, */ port: number, /** - * CORS allowed origins (empty = allow all) + * CORS allowed origins. Empty means **same-origin only** */ -cors_origins: Array, }; +cors_origins: Array, +/** + * Externally reachable base URL (e.g. `https://operator.example.com`). + * + * OAuth and MCP descriptor URLs are generated from this rather than from the request's `Host` header, + * which a caller controls. Defaults to request host, which is correct for a loopback bind and wrong behind a reverse proxy. + */ +public_url: string | null, }; export type LlmToolsConfig = { /** @@ -557,6 +564,10 @@ global: Array, project: Array, }; export type Delegator = { +/** + * Optional Git identity, HTTPS credential reference, and runtime settings. + */ +git?: GitExecutionConfig | null, /** * Unique name for this delegator (e.g., "claude-opus-auto") */ @@ -726,6 +737,10 @@ x_agnt?: JsonValue | null, x_openai?: JsonValue | null, }; export type XOperator = { +/** + * Optional Git identity, HTTPS credential reference, and runtime settings. + */ +git?: GitExecutionConfig | null, /** * Optional display name for UI. */ @@ -825,7 +840,11 @@ project_collection_prefs: { [key in string]: string }, */ multi_agent_groups: Array, }; -export type AgentState = { id: string, ticket_id: string, ticket_type: string, project: string, status: string, started_at: string, last_activity: string, last_message: string | null, paired: boolean, +export type AgentState = { +/** + * Non-secret Git configuration captured at launch. + */ +git_context: GitExecutionConfig | null, id: string, ticket_id: string, ticket_type: string, project: string, status: string, started_at: string, last_activity: string, last_message: string | null, paired: boolean, /** * The terminal session name for this agent (for recovery) */ @@ -1281,6 +1300,10 @@ skills: Array, total: number, }; export type DelegatorResponse = { +/** + * Optional Git identity, HTTPS credential reference, and runtime settings. + */ +git?: GitExecutionConfig | null, /** * Unique name */ @@ -1326,6 +1349,10 @@ delegators: Array, total: number, }; export type CreateDelegatorRequest = { +/** + * Optional Git identity, HTTPS credential reference, and runtime settings. + */ +git?: GitExecutionConfig | null, /** * Unique name for the delegator */ diff --git a/src/agents/launcher/coder.rs b/src/agents/launcher/coder.rs index 48d2b271..ed60ee57 100644 --- a/src/agents/launcher/coder.rs +++ b/src/agents/launcher/coder.rs @@ -1,28 +1,37 @@ //! Coder workspace target: lifecycle + SSH alias provisioning. //! //! A coder target's execution shape is an SSH target with a -//! dynamically-provisioned alias — the launch itself reuses `remote.rs` +//! dynamically-provisioned alias - the launch itself reuses `remote.rs` //! unchanged. This module owns only what is Coder-specific: deterministic //! workspace naming, create/start lifecycle (never delete), the SSH config //! fragment (`ProxyCommand coder ssh --stdio`), and the git checkout on the //! workspace. Identity is a plain user session token resolved from the //! environment **by name** and never written to disk. +//! +//! The `coder` CLI is preferred from `PATH` and otherwise fetched from the +//! deployment itself, so its version can never drift from the server it talks +//! to and nothing has to be baked into the Operator image. -use std::path::PathBuf; +use std::ffi::OsStr; +use std::path::{Path, PathBuf}; use std::process::Command; use anyhow::{Context, Result}; use crate::config::{CoderConfig, Config, RemoteHost}; -use super::prompt::shell_escape; +use super::prompt::{shell_escape, shell_escape_if_needed}; /// Coder caps workspace names at 32 characters. const MAX_WORKSPACE_NAME: usize = 32; /// Over budget: keep this much of the readable key, then `-` + 6 hex of hash. const TRUNCATED_KEY_LEN: usize = 25; +/// Binary name looked up on `PATH` and used for the download cache. +const CODER_CLI_BIN: &str = "coder"; +/// Bound on fetching the CLI from the deployment. +const CODER_CLI_DOWNLOAD_TIMEOUT_SECS: u64 = 120; -/// Resolved Coder credentials — env values read at launch time, held only in +/// Resolved Coder credentials - env values read at launch time, held only in /// memory and injected into `coder` child processes under the CLI's standard /// variable names. #[derive(Debug)] @@ -93,24 +102,39 @@ pub fn workspace_alias(workspace: &str) -> String { format!("op-coder-{workspace}") } -/// SSH config fragment content: `coder ssh --stdio` as a `ProxyCommand`, so -/// real `ssh` — with the full flag set (`-t`, `-R`) — works over the Coder -/// tailnet. Operator writes its own fragment rather than running +/// SSH config fragment content: `coder ssh --stdio` as a `ProxyCommand`, so real `ssh` works over the Coder +/// Operator writes its own fragment rather than running /// `coder config-ssh`, which rewrites the user's `~/.ssh/config`. -pub fn ssh_fragment(workspace: &str) -> String { +/// +/// The `ProxyCommand` carries the *resolved* binary path: `ssh` spawns it +/// itself, so a bare `coder` would resolve against ssh's PATH and miss a +/// CLI that was downloaded to the state directory. +/// +/// Host-key checking is off, matching what `coder config-ssh` writes for its +/// own hosts. The tailnet reached through the `ProxyCommand` is the +/// authentication boundary; the workspace host key adds nothing on top of it, +/// and per-ticket workspaces are ephemeral enough that trust-on-first-use +/// would only accumulate dead `known_hosts` entries. +pub fn ssh_fragment(coder_bin: &Path, workspace: &str) -> String { format!( - "Host {alias}\n ProxyCommand coder ssh --stdio {workspace}\n User coder\n", + "Host {alias}\n ProxyCommand {bin} ssh --stdio {workspace}\n User coder\n StrictHostKeyChecking no\n UserKnownHostsFile /dev/null\n LogLevel ERROR\n", alias = workspace_alias(workspace), + bin = shell_escape_if_needed(&coder_bin.to_string_lossy()), ) } /// Write the per-workspace fragment under `.tickets/operator/ssh/` and return -/// its path. Idempotent — keyed by workspace name. -pub(crate) fn write_ssh_fragment(config: &Config, workspace: &str) -> Result { +/// its path. Idempotent - keyed by workspace name. +pub(crate) fn write_ssh_fragment( + config: &Config, + coder_bin: &Path, + workspace: &str, +) -> Result { let ssh_dir = config.tickets_path().join("operator/ssh"); std::fs::create_dir_all(&ssh_dir).context("Failed to create ssh fragment directory")?; let path = ssh_dir.join(format!("{workspace}.config")); - std::fs::write(&path, ssh_fragment(workspace)).context("Failed to write ssh fragment")?; + std::fs::write(&path, ssh_fragment(coder_bin, workspace)) + .context("Failed to write ssh fragment")?; Ok(path) } @@ -122,14 +146,14 @@ pub(crate) struct WorkspaceInfo { } /// What provisioning must do for a workspace, decided from `coder list` -/// output. Pure — directly unit-testable. +/// output. Pure - directly unit-testable. #[derive(Debug, PartialEq)] pub(crate) enum WorkspaceAction { /// Exists on our template: `coder start` (no-op if running) Start, /// Absent: `coder create --template -y` Create, - /// Exists on a DIFFERENT template: refuse — guards against colliding + /// Exists on a DIFFERENT template: refuse - guards against colliding /// with a human's workspace of the same name. Refuse { existing_template: String }, } @@ -150,7 +174,7 @@ pub(crate) fn decide_workspace_action( /// Git checkout script run on the workspace over ssh: reuse a matching /// checkout (fetch + ticket branch), otherwise clone then branch. Branch /// naming stays in Rust (the caller passes the `git.branch_format`-derived -/// name) — never duplicated into a Coder template. +/// name) - never duplicated into a Coder template. pub(crate) fn checkout_script(workdir: &str, remote_url: &str, branch: &str) -> String { let dir = shell_escape(workdir); let url = shell_escape(remote_url); @@ -161,11 +185,11 @@ pub(crate) fn checkout_script(workdir: &str, remote_url: &str, branch: &str) -> } /// Run a `coder` CLI invocation with the session injected under the CLI's -/// standard env names. Errors surface Coder's stderr verbatim — quota and +/// standard env names. Errors surface Coder's stderr verbatim - quota and /// permission failures are the control plane's message, not ours to /// reinterpret. -fn run_coder(session: &CoderSession, args: &[&str]) -> Result { - let output = Command::new("coder") +fn run_coder(coder_bin: &Path, session: &CoderSession, args: &[&str]) -> Result { + let output = Command::new(coder_bin) .args(args) .env("CODER_URL", &session.url) .env("CODER_SESSION_TOKEN", &session.token) @@ -182,8 +206,13 @@ fn run_coder(session: &CoderSession, args: &[&str]) -> Result { } /// Look up a workspace by exact name via `coder list --output json`. -fn find_workspace(session: &CoderSession, name: &str) -> Result> { +fn find_workspace( + coder_bin: &Path, + session: &CoderSession, + name: &str, +) -> Result> { let out = run_coder( + coder_bin, session, &[ "list", @@ -197,8 +226,114 @@ fn find_workspace(session: &CoderSession, name: &str) -> Result PathBuf { + config.state_path().join("bin").join(CODER_CLI_BIN) +} + +fn is_executable(path: &Path) -> bool { + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + std::fs::metadata(path) + .map(|m| m.is_file() && m.permissions().mode() & 0o111 != 0) + .unwrap_or(false) + } + #[cfg(not(unix))] + { + path.is_file() + } +} + +/// Locate an already-present CLI: `PATH` first, then the download cache. +/// `None` means one has to be fetched. Takes `PATH` as an argument rather than +/// reading the environment so it stays pure and test-parallel-safe. +fn resolve_coder_cli(path_var: Option<&OsStr>, cache: &Path) -> Option { + let on_path = path_var.and_then(|paths| { + std::env::split_paths(paths) + .map(|dir| dir.join(CODER_CLI_BIN)) + .find(|candidate| is_executable(candidate)) + }); + on_path.or_else(|| is_executable(cache).then(|| cache.to_path_buf())) +} + +/// A Coder deployment serves a CLI matching its own version at `/bin/`. +fn coder_download_url(base: &str, arch: &str) -> Result { + let suffix = match arch { + "x86_64" => "amd64", + "aarch64" => "arm64", + other => anyhow::bail!( + "No Coder CLI download exists for architecture '{other}'; install `coder` on PATH" + ), + }; + Ok(format!( + "{}/bin/coder-linux-{suffix}", + base.trim_end_matches('/') + )) +} + +/// Fetch the CLI from the deployment into `dest`. Downloads to a sibling +/// temp file and renames, so a killed process can never leave a truncated +/// binary that later looks like a valid cache hit. +fn download_coder_cli(base_url: &str, dest: &Path) -> Result { + let url = coder_download_url(base_url, std::env::consts::ARCH)?; + let dir = dest + .parent() + .context("Coder CLI cache path has no parent directory")?; + std::fs::create_dir_all(dir).with_context(|| { + format!( + "Failed to create the Coder CLI cache directory at {}", + dir.display() + ) + })?; + + let client = reqwest::blocking::Client::builder() + .timeout(std::time::Duration::from_secs( + CODER_CLI_DOWNLOAD_TIMEOUT_SECS, + )) + .build() + .context("Failed to build the HTTP client for the Coder CLI download")?; + let bytes = client + .get(&url) + .send() + .and_then(reqwest::blocking::Response::error_for_status) + .and_then(reqwest::blocking::Response::bytes) + .with_context(|| { + format!( + "Failed to fetch the Coder CLI from {url}. Operator does not bundle it -- either \ + the deployment is unreachable from here (check egress rules) or you can install \ + `coder` on PATH yourself" + ) + })?; + + let staged = dir.join(format!("{CODER_CLI_BIN}.download")); + std::fs::write(&staged, &bytes) + .with_context(|| format!("Failed to write the Coder CLI to {}", staged.display()))?; + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + std::fs::set_permissions(&staged, std::fs::Permissions::from_mode(0o755)) + .context("Failed to mark the downloaded Coder CLI executable")?; + } + std::fs::rename(&staged, dest) + .with_context(|| format!("Failed to install the Coder CLI at {}", dest.display()))?; + tracing::info!(url = %url, path = %dest.display(), "Downloaded the Coder CLI"); + Ok(dest.to_path_buf()) +} + +/// Resolve the `coder` CLI, preferring one already on `PATH` and otherwise +/// using (or populating) the cache under the state directory. +fn ensure_coder_cli(config: &Config, session: &CoderSession) -> Result { + let cache = coder_cli_cache_path(config); + match resolve_coder_cli(std::env::var_os("PATH").as_deref(), &cache) { + Some(found) => Ok(found), + None => download_coder_cli(&session.url, &cache), + } +} + /// Provision the workspace for a ticket and return the `RemoteHost` the -/// shared remote launch tail consumes. Blocking — workspace creation is +/// shared remote launch tail consumes. Blocking - workspace creation is /// bounded by `create_timeout_secs`. pub(crate) fn provision_workspace( config: &Config, @@ -217,17 +352,14 @@ pub(crate) fn provision_workspace( )?; } } - // Fail fast before any lifecycle action: credentials, then CLI presence. + // Fail fast before any lifecycle action: credentials, then the CLI (whose + // download source is the deployment the credentials just named). let session = resolve_session(coder)?; - if !cli_available() { - anyhow::bail!( - "Coder target requires the `coder` CLI on PATH; install it from your deployment" - ); - } + let coder_bin = ensure_coder_cli(config, &session)?; let workspace = workspace_name(&coder.name_prefix, project, ticket_id); match decide_workspace_action( - find_workspace(&session, &workspace)?.as_ref(), + find_workspace(&coder_bin, &session, &workspace)?.as_ref(), &coder.template, ) { WorkspaceAction::Refuse { existing_template } => anyhow::bail!( @@ -236,7 +368,7 @@ pub(crate) fn provision_workspace( coder.template ), WorkspaceAction::Start => { - run_coder(&session, &["start", &workspace, "--no-wait"]).map(|_| ())?; + run_coder(&coder_bin, &session, &["start", &workspace, "--no-wait"]).map(|_| ())?; } WorkspaceAction::Create => { let mut args: Vec = vec![ @@ -253,18 +385,18 @@ pub(crate) fn provision_workspace( args.push(format!("{k}={v}")); } let arg_refs: Vec<&str> = args.iter().map(String::as_str).collect(); - run_coder(&session, &arg_refs).map(|_| ())?; + run_coder(&coder_bin, &session, &arg_refs).map(|_| ())?; } } - let fragment = write_ssh_fragment(config, &workspace)?; + let fragment = write_ssh_fragment(config, &coder_bin, &workspace)?; let alias = workspace_alias(&workspace); let workdir = coder .workdir .clone() .unwrap_or_else(|| format!("/home/coder/{project}")); - wait_for_ssh(&fragment, &alias, coder.create_timeout_secs)?; + wait_for_ssh(&session, &fragment, &alias, coder.create_timeout_secs)?; // Ensure the checkout before the agent lands in the workdir. if let (Some(url), Some(branch)) = (remote_url, branch) { @@ -284,7 +416,7 @@ pub(crate) fn provision_workspace( let path = super::prompt::shell_escape(&runtime.path.to_string_lossy()); script = format!(". {path}/env.sh\ntrap 'rm -rf -- {path}' EXIT\n{script}"); } - run_ssh(&fragment, &alias, &script) + run_ssh(&session, &fragment, &alias, &script) .with_context(|| format!("Failed to prepare checkout on workspace '{workspace}'"))?; } Ok(RemoteHost { @@ -296,28 +428,38 @@ pub(crate) fn provision_workspace( }) } -/// Stop the workspace (never delete — reclamation is the Coder admin's +/// Stop the workspace (never delete - reclamation is the Coder admin's /// autostop/autodelete policy). Best-effort by design. -pub fn stop_workspace(coder: &CoderConfig, workspace: &str) -> Result<()> { +pub fn stop_workspace(config: &Config, coder: &CoderConfig, workspace: &str) -> Result<()> { let session = resolve_session(coder)?; - run_coder(&session, &["stop", workspace, "--yes"]).map(|_| ()) + let coder_bin = ensure_coder_cli(config, &session)?; + run_coder(&coder_bin, &session, &["stop", workspace, "--yes"]).map(|_| ()) } -fn cli_available() -> bool { - Command::new("which") - .arg("coder") - .output() - .map(|o| o.status.success()) - .unwrap_or(false) +/// `ssh` spawns the fragment's `ProxyCommand` itself, and that `coder` +/// subprocess reads the CLI's own canonical variable names. Inject them here +/// so a target configured with custom `url_env` / `token_env` names still +/// authenticates -- in-process only, never written to the fragment on disk. +fn coder_ssh_command(session: &CoderSession, fragment: &Path) -> Command { + let mut command = Command::new("ssh"); + command + .args(["-F".as_ref(), fragment.as_os_str()]) + .env("CODER_URL", &session.url) + .env("CODER_SESSION_TOKEN", &session.token); + command } /// Poll ssh connectivity through the provisioned alias until the workspace /// agent answers, bounded by `timeout_secs`. -fn wait_for_ssh(fragment: &std::path::Path, alias: &str, timeout_secs: u64) -> Result<()> { +fn wait_for_ssh( + session: &CoderSession, + fragment: &Path, + alias: &str, + timeout_secs: u64, +) -> Result<()> { let deadline = std::time::Instant::now() + std::time::Duration::from_secs(timeout_secs); loop { - let ok = Command::new("ssh") - .args(["-F".as_ref(), fragment.as_os_str()]) + let ok = coder_ssh_command(session, fragment) .args(["-o", "BatchMode=yes", "-o", "ConnectTimeout=10"]) .arg(alias) .arg("true") @@ -337,9 +479,8 @@ fn wait_for_ssh(fragment: &std::path::Path, alias: &str, timeout_secs: u64) -> R } } -fn run_ssh(fragment: &std::path::Path, alias: &str, script: &str) -> Result<()> { - let status = Command::new("ssh") - .args(["-F".as_ref(), fragment.as_os_str()]) +fn run_ssh(session: &CoderSession, fragment: &Path, alias: &str, script: &str) -> Result<()> { + let status = coder_ssh_command(session, fragment) .args(["-o", "BatchMode=yes"]) .arg(alias) .arg(script) @@ -390,7 +531,7 @@ pub fn stop_on_complete_for_agent(config: &Config, agent: &crate::state::AgentSt if !coder.stop_on_complete { return; } - match stop_workspace(&coder, &workspace) { + match stop_workspace(config, &coder, &workspace) { Ok(()) => tracing::info!(workspace = %workspace, "Stopped coder workspace on completion"), Err(e) => tracing::warn!(workspace = %workspace, error = %e, "Failed to stop workspace"), } @@ -430,14 +571,145 @@ mod tests { assert_ne!(name, other); } + /// Create an executable stub at `path`, parent dirs included. + fn touch_executable(path: &std::path::Path) { + std::fs::create_dir_all(path.parent().unwrap()).unwrap(); + std::fs::write(path, "#!/bin/sh\n").unwrap(); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o755)).unwrap(); + } + } + #[test] fn test_ssh_fragment_shape() { - let frag = ssh_fragment("op-proj-feat-1"); + let frag = ssh_fragment( + std::path::Path::new("/usr/local/bin/coder"), + "op-proj-feat-1", + ); assert!(frag.contains("Host op-coder-op-proj-feat-1")); - assert!(frag.contains("ProxyCommand coder ssh --stdio op-proj-feat-1")); + assert!( + frag.contains("ProxyCommand /usr/local/bin/coder ssh --stdio op-proj-feat-1"), + "ProxyCommand must carry the resolved absolute path, not a bare `coder`: {frag}" + ); assert!(frag.contains("User coder")); } + #[test] + fn test_ssh_fragment_disables_host_key_checking() { + let frag = ssh_fragment(std::path::Path::new("/usr/local/bin/coder"), "ws-1"); + // HOME is an emptyDir in the Helm deployment, so there is no + // known_hosts and BatchMode ssh would fail verification every launch. + assert!(frag.contains("StrictHostKeyChecking no")); + assert!(frag.contains("UserKnownHostsFile /dev/null")); + assert!(frag.contains("LogLevel ERROR")); + } + + #[test] + fn test_ssh_fragment_quotes_a_path_needing_it() { + let frag = ssh_fragment(std::path::Path::new("/opt/my coder/coder"), "ws-1"); + assert!( + frag.contains("ProxyCommand '/opt/my coder/coder' ssh --stdio ws-1"), + "a path with a space must survive sh parsing: {frag}" + ); + } + + #[test] + fn test_coder_download_url_for_arch() { + assert_eq!( + coder_download_url("https://coder.example.com", "x86_64").unwrap(), + "https://coder.example.com/bin/coder-linux-amd64" + ); + assert_eq!( + coder_download_url("https://coder.example.com", "aarch64").unwrap(), + "https://coder.example.com/bin/coder-linux-arm64" + ); + } + + #[test] + fn test_coder_download_url_trims_trailing_slash() { + assert_eq!( + coder_download_url("https://coder.example.com/", "x86_64").unwrap(), + "https://coder.example.com/bin/coder-linux-amd64" + ); + } + + #[test] + fn test_coder_download_url_unsupported_arch_names_it() { + let err = coder_download_url("https://c.example.com", "riscv64") + .unwrap_err() + .to_string(); + assert!(err.contains("riscv64"), "error must name the arch: {err}"); + } + + #[test] + fn test_resolve_coder_cli_prefers_path_over_cache() { + let temp = tempfile::tempdir().unwrap(); + let bin_dir = temp.path().join("path-bin"); + let on_path = bin_dir.join("coder"); + touch_executable(&on_path); + let cache = temp.path().join("state/bin/coder"); + touch_executable(&cache); + + let path_var = std::env::join_paths([bin_dir.as_path()]).unwrap(); + assert_eq!( + resolve_coder_cli(Some(path_var.as_os_str()), &cache), + Some(on_path), + "a PATH entry must win over the downloaded cache" + ); + } + + #[test] + fn test_resolve_coder_cli_falls_back_to_cached_binary() { + let temp = tempfile::tempdir().unwrap(); + let empty_dir = temp.path().join("empty"); + std::fs::create_dir_all(&empty_dir).unwrap(); + let cache = temp.path().join("state/bin/coder"); + touch_executable(&cache); + + let path_var = std::env::join_paths([empty_dir.as_path()]).unwrap(); + assert_eq!( + resolve_coder_cli(Some(path_var.as_os_str()), &cache), + Some(cache) + ); + } + + #[test] + fn test_resolve_coder_cli_none_when_absent_everywhere() { + let temp = tempfile::tempdir().unwrap(); + let empty_dir = temp.path().join("empty"); + std::fs::create_dir_all(&empty_dir).unwrap(); + let path_var = std::env::join_paths([empty_dir.as_path()]).unwrap(); + assert_eq!( + resolve_coder_cli( + Some(path_var.as_os_str()), + &temp.path().join("state/bin/coder") + ), + None, + "absent everywhere must signal a download, not a bogus path" + ); + } + + #[test] + fn test_coder_cli_cache_path_lives_under_state() { + let temp = tempfile::tempdir().unwrap(); + let config = Config { + paths: crate::config::PathsConfig { + tickets: temp.path().to_string_lossy().to_string(), + projects: temp.path().to_string_lossy().to_string(), + state: temp.path().join("s").to_string_lossy().to_string(), + worktrees: temp.path().join("w").to_string_lossy().to_string(), + }, + ..Default::default() + }; + // The state dir is the PVC in the Helm deployment; HOME is an emptyDir. + assert_eq!( + coder_cli_cache_path(&config), + temp.path().join("s/bin/coder") + ); + } + #[test] fn test_decide_workspace_action_template_mismatch_refuses() { let existing = WorkspaceInfo { @@ -512,11 +784,12 @@ mod tests { }, ..Default::default() }; - let p1 = write_ssh_fragment(&config, "ws-1").unwrap(); - let p2 = write_ssh_fragment(&config, "ws-1").unwrap(); + let bin = std::path::Path::new("/usr/local/bin/coder"); + let p1 = write_ssh_fragment(&config, bin, "ws-1").unwrap(); + let p2 = write_ssh_fragment(&config, bin, "ws-1").unwrap(); assert_eq!(p1, p2); assert!(std::fs::read_to_string(&p1) .unwrap() - .contains("ProxyCommand coder ssh --stdio ws-1")); + .contains("ProxyCommand /usr/local/bin/coder ssh --stdio ws-1")); } } diff --git a/src/app/tickets.rs b/src/app/tickets.rs index 8fc494bf..ae21ae0e 100644 --- a/src/app/tickets.rs +++ b/src/app/tickets.rs @@ -484,8 +484,7 @@ mod admin_password_tests { #[test] fn test_invalid_password_surfaces_as_an_error() { - // The wizard validates first, so this only happens if that check is - // bypassed — it must still not create a weak account silently. + // The wizard validates first, so this only happens if that check is bypassed let store = AuthStore::in_memory().unwrap(); assert!(persist_admin_password(&store, Some("short")).is_err()); } diff --git a/src/config.rs b/src/config.rs index c88d6469..b8e52acd 100644 --- a/src/config.rs +++ b/src/config.rs @@ -286,9 +286,7 @@ pub struct RestApiConfig { /// Whether the REST API is enabled #[serde(default = "default_rest_enabled")] pub enabled: bool, - /// Address the REST API binds to. Defaults to `127.0.0.1` (local only) so - /// the server — which reports the project directory name — is not reachable - /// from other hosts. Set to `0.0.0.0` to expose it on all interfaces. + /// Address the REST API binds to. Defaults to `127.0.0.1` (local only) so the server is not reachable from other hosts. Set to `0.0.0.0` to expose it on all interfaces. #[serde(default = "default_rest_host")] pub host: String, /// Port for the REST API server @@ -297,10 +295,7 @@ pub struct RestApiConfig { /// CORS allowed origins. Empty means **same-origin only** #[serde(default)] pub cors_origins: Vec, - /// Externally reachable base URL (e.g. `https://operator.example.com`). - /// - /// OAuth and MCP descriptor URLs are generated from this rather than from the request's `Host` header, - /// which a caller controls. Defaults to request host, which is correct for a loopback bind and wrong behind a reverse proxy. + /// Externally reachable base URL (e.g. `https://operator.example.com`). Defaults to request host. #[serde(default)] pub public_url: Option, } diff --git a/src/config/targets.rs b/src/config/targets.rs index 281d1060..75fd59cd 100644 --- a/src/config/targets.rs +++ b/src/config/targets.rs @@ -136,7 +136,7 @@ pub struct CoderConfig { /// Workspace name prefix for deterministic per-ticket naming #[serde(default = "default_coder_name_prefix")] pub name_prefix: String, - /// Project root inside the workspace (None = workspace $HOME) + /// Project root inside the workspace (None = /home/coder/{project}) #[serde(default, skip_serializing_if = "Option::is_none")] pub workdir: Option, /// Stop the workspace when the ticket completes (never delete) @@ -149,7 +149,7 @@ pub struct CoderConfig { /// multi-step (empty/None = reverse tunnel default) #[serde(default, skip_serializing_if = "Option::is_none")] pub callback_url: Option, - /// Passthrough `-p` template parameters for `coder create` + /// Passthrough `--parameter` template parameters for `coder create` #[serde(default, skip_serializing_if = "std::collections::HashMap::is_empty")] pub parameters: std::collections::HashMap, } @@ -350,6 +350,38 @@ template = "operator-agent" } } + /// The coder-module's `run.sh` writes exactly this block. Parsing it here + /// keeps the Terraform module and `CoderConfig` from drifting apart. + #[test] + fn test_target_def_coder_toml_matches_coder_module_output() { + let toml_src = r#" +name = "coder-agents" +kind = "coder" +template = "operator-agent" +token_env = "CODER_SESSION_TOKEN" +callback_url = "https://op.example.com" +name_prefix = "op" +workdir = "/home/coder/proj" +stop_on_complete = true +create_timeout_secs = 600 +"#; + let def: TargetDef = toml::from_str(toml_src).unwrap(); + assert_eq!(def.name, "coder-agents"); + match &def.kind { + TargetKind::Coder(c) => { + assert_eq!(c.template, "operator-agent"); + assert_eq!(c.name_prefix, "op"); + assert_eq!(c.workdir.as_deref(), Some("/home/coder/proj")); + assert_eq!(c.callback_url.as_deref(), Some("https://op.example.com")); + assert_eq!(c.create_timeout_secs, 600); + assert!(c.stop_on_complete); + // Not emitted by the module: the workspace gets CODER_URL ambiently. + assert_eq!(c.url_env, "CODER_URL"); + } + other => panic!("expected coder kind, got {other:?}"), + } + } + #[test] fn test_target_def_local_toml() { let def: TargetDef = toml::from_str("name = \"here\"\nkind = \"local\"\n").unwrap(); diff --git a/src/rest/middleware/auth.rs b/src/rest/middleware/auth.rs index f55ba15c..543a0bd5 100644 --- a/src/rest/middleware/auth.rs +++ b/src/rest/middleware/auth.rs @@ -1,8 +1,8 @@ //! The authorization layer. //! //! One `middleware::from_fn_with_state` layer decides every request. It runs -//! over the *composed* router — the documented API routes, the Swagger UI, and -//! the config-gated MCP transport routes — so no surface can be mounted outside +//! over the *composed* router - the documented API routes, the Swagger UI, and +//! the config-gated MCP transport routes - so no surface can be mounted outside //! its reach. //! //! The decision is: resolve a principal from the request's credentials, look up @@ -35,10 +35,8 @@ pub const CSRF_HEADER: &str = "x-operator-csrf"; /// Paths served to an unauthenticated browser so it can render the login, bootstrap, and device-approval screens. /// /// This is the whole SPA bundle, unavoidably: the dashboard uses fragment -/// routing, so `#/login` and `#/config` are indistinguishable to the server — -/// it sees one request for `/` either way. The bundle carries no workspace data -/// or credentials; everything it displays arrives over authenticated API calls. -/// See `docs/security/#the-dashboard-bundle-is-public`. +/// routing, so `#/login` and `#/config` are indistinguishable to the server. +/// The bundle carries no workspace data or credentials; everything it displays arrives over authenticated API calls. fn is_public_asset(path: &str) -> bool { !(path.starts_with("/api/") || path.starts_with("/swagger-ui") || path.starts_with("/api-docs")) } @@ -49,7 +47,7 @@ fn is_public_asset(path: &str) -> bool { /// `routes!` entry, so they never produce a `MatchedPath` and cannot be listed /// in `ROUTE_RULES`. They still need classifying: the spec enumerates every /// endpoint this server exposes, which is not something to hand out -/// anonymously — but an authenticated admin should be able to open it. +/// anonymously - but an authenticated admin should be able to open it. fn unmatched_access(path: &str) -> Option { if path.starts_with("/swagger-ui") || path.starts_with("/api-docs") { return Some(Access::Scoped(Scope::Read)); @@ -165,7 +163,7 @@ fn origin_authority(origin: &str) -> Option<&str> { /// Three cases count as acceptable, and the first is easy to forget: a browser /// sends `Origin` on a **same-origin** POST too. Checking only the configured /// CORS allowlist therefore blocked the dashboard's own mutations, since that -/// list is empty by default — and a curl test never catches it, because curl +/// list is empty by default - and a curl test never catches it, because curl /// sends no `Origin` at all. fn origin_is_acceptable(headers: &HeaderMap, allowed: &[String]) -> bool { let Some(origin) = headers.get(header::ORIGIN).and_then(|v| v.to_str().ok()) else { @@ -206,7 +204,7 @@ pub async fn authorize( // Prefer the route table. Fall back to path-based classification when the // table has no entry: `SwaggerUi` mounts its own wildcard route, so it does - // produce a `MatchedPath` — just not one that can appear in `ROUTE_RULES`. + // produce a `MatchedPath` - just not one that can appear in `ROUTE_RULES`. // The fallback still denies any unclassified `/api/` path. let access = matched .as_deref() @@ -479,7 +477,7 @@ mod tests { fn test_same_origin_mutation_is_accepted_with_no_configured_origins() { // Regression: the dashboard's own POSTs were rejected because browsers // send `Origin` on same-origin mutations too and the default - // `cors_origins` list is empty. curl never reproduced it — curl sends + // `cors_origins` list is empty. curl never reproduced it - curl sends // no Origin header, so the check passed there. for (origin, host) in [ ("http://127.0.0.1:7008", "127.0.0.1:7008"), diff --git a/src/ui/status_panel.rs b/src/ui/status_panel.rs index 7d80d895..940514f2 100644 --- a/src/ui/status_panel.rs +++ b/src/ui/status_panel.rs @@ -52,7 +52,7 @@ pub enum SectionId { Workflows, } -/// Health state of a section — controls the header color. +/// Health state of a section - controls the header color. #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, TS)] #[ts(export)] pub enum SectionHealth { @@ -105,7 +105,7 @@ impl SectionId { } } -/// Declarative section metadata — shared between TUI and `VSCode`. +/// Declarative section metadata - shared between TUI and `VSCode`. #[derive(Debug, Clone, Serialize, Deserialize, TS)] #[ts(export)] #[allow(dead_code)] @@ -189,7 +189,7 @@ pub struct TreeRow { pub icon: StatusIcon, /// Optional vendor-brand basename (e.g. "ollama") for surfaces that render /// logos (the web UI). The TUI ignores this and renders [`icon`](Self::icon) - /// as a semantic ANSI glyph — brand logos can't be drawn in a terminal. + /// as a semantic ANSI glyph - brand logos can't be drawn in a terminal. pub brand_icon: Option, pub is_header: bool, pub actions: ActionSet, @@ -328,20 +328,20 @@ pub enum McpHttpStatus { NotMounted, } -/// Which button was pressed — maps to ABXY gamepad layout. +/// Which button was pressed - maps to ABXY gamepad layout. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum ActionButton { - /// A (Enter) — primary/affirm/activate + /// A (Enter) - primary/affirm/activate A, - /// B (Esc/Backspace) — go back, collapse parent + /// B (Esc/Backspace) - go back, collapse parent B, - /// X (Shift+Enter) — special/tertiary action + /// X (Shift+Enter) - special/tertiary action X, - /// Y (Ctrl+Enter) — contextual refresh/update + /// Y (Ctrl+Enter) - contextual refresh/update Y, } -/// Display metadata for an action — short title for TUI and title+tooltip for `VSCode`. +/// Display metadata for an action - short title for TUI and title+tooltip for `VSCode`. #[derive(Debug, Clone)] pub struct ActionMeta { /// Short label (max 6 chars) shown right-aligned on the selected row in TUI, @@ -355,15 +355,15 @@ pub struct ActionMeta { /// Four action slots mapped to ABXY gamepad buttons. #[derive(Debug, Clone)] pub struct ActionSet { - /// A (Enter) — primary/affirm/activate + /// A (Enter) - primary/affirm/activate pub primary: StatusAction, - /// B (Esc) — go back, collapse parent + /// B (Esc) - go back, collapse parent pub back: StatusAction, - /// X (Shift+Enter) — special/tertiary + /// X (Shift+Enter) - special/tertiary pub special: StatusAction, /// Display metadata for the special action (shown in TUI and `VSCode`). pub special_meta: Option, - /// Y (Ctrl+Enter) — contextual refresh + /// Y (Ctrl+Enter) - contextual refresh pub refresh: StatusAction, /// Display metadata for the refresh action. pub refresh_meta: Option, @@ -658,8 +658,8 @@ impl StatusSnapshot { /// Build a snapshot from config alone, with default (non-live) runtime fields. /// - /// Shared by the TUI dashboard — which overrides the runtime fields - /// (`api_status`, wrapper/mcp/acp liveness, editor env) with live state — + /// Shared by the TUI dashboard - which overrides the runtime fields + /// (`api_status`, wrapper/mcp/acp liveness, editor env) with live state - /// and the REST `/api/v1/sections` endpoint, which uses the config-derived /// result as-is. `issue_types` is passed in because the TUI and REST source /// it from different registries. Everything else here is derived purely from @@ -729,7 +729,7 @@ impl StatusSnapshot { }) .collect(); - // Model servers — user-declared plus implicit vendor builtins. + // Model servers - user-declared plus implicit vendor builtins. let mut model_servers: Vec = config .model_servers .iter() @@ -767,7 +767,7 @@ impl StatusSnapshot { _ => false, }; - // Managed projects — names from config, resolved against the projects base dir. + // Managed projects - names from config, resolved against the projects base dir. let projects_base = Path::new(&config.paths.projects); let managed_projects: Vec = config .projects @@ -812,7 +812,7 @@ impl StatusSnapshot { wrapper_type: config.sessions.wrapper.display_name().to_string(), operator_inside_wrapper: config.sessions.wrapper.is_active_context(), operator_version: env!("CARGO_PKG_VERSION").to_string(), - // Runtime field — callers with live state override this. + // Runtime field - callers with live state override this. api_status: RestApiStatus::Stopped, kanban_providers, llm_tools, @@ -876,7 +876,7 @@ pub trait StatusSection { /// Which section IDs must be Green before this section is visible. fn prerequisites(&self) -> &[SectionId]; - /// Current health state — determines header color. + /// Current health state - determines header color. fn health(&self, snapshot: &StatusSnapshot) -> SectionHealth; /// Summary description shown next to the section header. @@ -1044,7 +1044,7 @@ pub fn build_section_dtos(snapshot: &StatusSnapshot) -> Vec PathBuf { /// Deserializing through the strict structs is itself the shape test: it /// rejects a 4th nesting level, `icon` on items, `codicon` on leaves, and any -/// unknown key — exactly what `docs/_includes/sidebar.html` would silently drop. +/// unknown key - exactly what `docs/_includes/sidebar.html` would silently drop. fn load_nav() -> Nav { let raw = std::fs::read_to_string(repo_path("docs/_data/navigation.yml")) .expect("docs/_data/navigation.yml should be readable"); @@ -279,7 +279,7 @@ fn test_nav_vertical_leaves_map_to_catalog() { for leaf in leaves { assert!( catalog_urls.contains(&leaf.url) || NAV_EXTRA_PAGES.contains(&leaf.url.as_str()), - "nav leaf '{}' ({}) under '{item_url}' advertises a page with no catalog entry — \ + "nav leaf '{}' ({}) under '{item_url}' advertises a page with no catalog entry - \ add it to src/integrations/catalog.rs or NAV_EXTRA_PAGES", leaf.title, leaf.url @@ -352,7 +352,7 @@ fn test_nav_icons_match_catalog() { if let Some(stem) = name.strip_suffix(".svg") { assert!( referenced.contains(stem), - "docs/assets/icons/{name} is referenced by no navigation.yml entry — remove it or wire it up" + "docs/assets/icons/{name} is referenced by no navigation.yml entry - remove it or wire it up" ); } } @@ -397,7 +397,7 @@ fn test_nav_titles_match_pages() { .unwrap_or_else(|| panic!("page for nav url '{url}' has no front-matter title")); assert_eq!( title, page_title, - "nav title for '{url}' differs from the page's front-matter title — \ + "nav title for '{url}' differs from the page's front-matter title - \ align them or add a NAV_TITLE_EXCEPTIONS entry" ); }; @@ -429,7 +429,7 @@ fn test_docs_pages_reachable() { let url = page_url(&page); assert!( reachable.contains(&url), - "docs/{page} ({url}) is published but unreachable from navigation.yml — \ + "docs/{page} ({url}) is published but unreachable from navigation.yml - \ add a nav entry or extend NAV_ORPHAN_ALLOWLIST" ); } @@ -508,7 +508,7 @@ fn test_no_duplicate_body_h1() { let first_line = body.lines().find(|l| !l.trim().is_empty()).unwrap_or(""); assert!( !first_line.starts_with("# "), - "docs/{page} opens with a body H1 ('{first_line}') — the doc layout already \ + "docs/{page} opens with a body H1 ('{first_line}') - the doc layout already \ renders the front-matter title; remove the duplicate heading" ); } diff --git a/tests/svg_icon_standard.rs b/tests/svg_icon_standard.rs index ca43ee5a..a5c0fbc2 100644 --- a/tests/svg_icon_standard.rs +++ b/tests/svg_icon_standard.rs @@ -40,7 +40,7 @@ const ICON_GLOBS: &[&str] = &[ /// recolor or resize safely. const EXEMPT: &[(&str, &str)] = &[( "docs/assets/img/operator_logo.svg", - "full-color brand wordmark, not a monochrome glyph — it is never tinted, \ + "full-color brand wordmark, not a monochrome glyph - it is never tinted, \ inlined, or rendered at icon sizes", )]; @@ -100,7 +100,7 @@ fn icon_files() -> Vec { files.sort(); assert!( !files.is_empty(), - "found no icons under {ICON_GLOBS:?} — has the layout moved?" + "found no icons under {ICON_GLOBS:?} - has the layout moved?" ); files } @@ -180,7 +180,7 @@ fn test_every_icon_matches_the_operator_icon_standard() { ); } - // 5. No pinned color or size — the container decides both. + // 5. No pinned color or size - the container decides both. for caps in attr_re.captures_iter(&svg) { let attr = &caps[1]; assert!( @@ -193,7 +193,7 @@ fn test_every_icon_matches_the_operator_icon_standard() { // 6. Nothing that breaks when the file is inlined into a page. assert!( !svg.contains("javascript:") && !handler_re.is_match(&svg), - "{name}: event handlers and javascript: URLs are not allowed — these files \ + "{name}: event handlers and javascript: URLs are not allowed - these files \ are inlined verbatim into generated pages" ); assert!( @@ -273,7 +273,7 @@ fn test_exemptions_are_real_and_still_needed() { let full = repo_root().join(path); assert!( full.is_file(), - "exempt file {path} no longer exists — remove it from EXEMPT ({reason})" + "exempt file {path} no longer exists - remove it from EXEMPT ({reason})" ); let svg = std::fs::read_to_string(&full).unwrap(); let compliant = svg_re @@ -282,7 +282,7 @@ fn test_exemptions_are_real_and_still_needed() { && svg.matches(" "✓", Some(_) => "✗", - None => "—", + None => "-", }; println!( "{:<14} | {:<18} | {:<6} | {:<5} | {:<5} | {}", @@ -259,7 +259,7 @@ fn test_vertical_parity_summary() { e.status.label(), badge_ok, docs_ok, - e.docs_url().unwrap_or_else(|| "—".to_string()), + e.docs_url().unwrap_or_else(|| "-".to_string()), ); } println!(); diff --git a/tests/workflow_mapper_contract.rs b/tests/workflow_mapper_contract.rs index d78ae91a..a5c1a665 100644 --- a/tests/workflow_mapper_contract.rs +++ b/tests/workflow_mapper_contract.rs @@ -95,7 +95,7 @@ fn test_generated_types_are_copied_before_every_compile_step() { serde_json::from_str(&read("webcomponents/package.json")).expect("package.json parses"); let scripts = pkg["scripts"].as_object().expect("scripts object"); - for step in ["typecheck", "test", "build"] { + for step in ["typecheck", "test", "build", "lint"] { let script = scripts[step].as_str().unwrap_or(""); assert!( script.contains("copy-types"), diff --git a/ui/package.json b/ui/package.json index 52522df0..aa623c77 100644 --- a/ui/package.json +++ b/ui/package.json @@ -7,7 +7,8 @@ "dev": "vite", "build": "vite build", "preview": "vite preview", - "typecheck": "tsc --noEmit" + "typecheck": "tsc --noEmit", + "lint": "cd .. && bun run lint:ui" }, "dependencies": { "@vscode/codicons": "^0.0.45", diff --git a/ui/src/Layout.tsx b/ui/src/Layout.tsx index a2b02ba7..95bc6ee5 100644 --- a/ui/src/Layout.tsx +++ b/ui/src/Layout.tsx @@ -77,7 +77,7 @@ function NavGroup({ label, keys }: { label: string; keys: readonly string[] }) { // with a header (title + close) above the caller-supplied node. function RightPanel() { const { content, title, close } = useRightPanel(); - if (!content) return null; + if (!content) {return null;} return (