From 73ae42ba5f682f2aa486676c90dd5bda79ead55d Mon Sep 17 00:00:00 2001 From: Tiberiu Socaci Date: Tue, 8 Sep 2026 07:31:17 +0300 Subject: [PATCH 01/44] docs: integrate development on beta and gate stable promotion Signed-off-by: Tiberiu Socaci --- .github/workflows/ci.yml | 2 +- AGENTS.md | 30 +++++++++++++++++++++++++++++- CONTRIBUTING.md | 11 ++++++++--- FEATURES.md | 8 ++++++++ TEST-PLAN.md | 21 +++++++++++++++++++++ 5 files changed, 67 insertions(+), 5 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index af94443..d523462 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -2,7 +2,7 @@ name: CI on: push: - branches: [main] + branches: [main, beta] pull_request: permissions: diff --git a/AGENTS.md b/AGENTS.md index e56883e..dca0c62 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -328,7 +328,35 @@ Config that stays as **JSON files** (read wholesale / bootstrap, hand-editable): ## Contributor workflow -Use a dedicated branch and worktree from the latest upstream main. Keep shared integration +### Beta development and stable promotion + +- `beta` is the integration branch for all new development, fixes, documentation and tests. + Start each task from current `origin/beta` in its own branch/worktree; validate and land on + `beta`, then push `origin beta`. Keep a served beta checkout on `beta` between tasks. +- `main` is the stable release branch for downstream installations. Completing a task or + receiving permission to push to `beta` never authorizes a merge or push to `main`. +- Promote `beta` to `main` only after BOTH a full test pass and the user's explicit confirmation + to release that exact candidate. Prepare a release PR and report its candidate commit, scope, + test results and live acceptance evidence BEFORE asking for confirmation. A request to adopt + this workflow, earlier general approval, silence, or a beta merge is not release approval. +- A full test pass means `npm run test:coverage` (the full regression suite with coverage floors), + `npm run check:static`, `npm run secret-scan`, `npm run test:security-coverage`, + `npm audit --omit=dev --audit-level=high`, and `npm run check:dco -- origin/main..HEAD`, + plus passing CI and every applicable live release gate in `TEST-PLAN.md` for Claude and Codex. + Include exact fixtures, prompts/actions and observed evidence; a skipped, blocked, failed or + unexecuted required check is not a pass. Only cases unaffected by engine choice may be marked + engine-independent. Maintainers complete private deployment acceptance where applicable. +- Approval and evidence belong to the exact tested candidate. If its content changes, or + integration with newer `main` changes the proposed release, rerun the applicable full checks + and obtain fresh user confirmation before promotion. Release only the approved candidate; + do not include later beta commits. Keep `beta` up to date with the approved stable history. +- These branch and release rules apply to both Claude and Codex and override generic skill + defaults that say to merge completed tasks into `main`. Use the deployment's serialized landing + lock for integration; never switch a shared served checkout to `main` merely to publish a release. + + +Use a dedicated branch and worktree from the latest upstream `beta`. Target development pull +requests at `beta`; completed development work is merged and pushed to `beta`. Keep shared integration checkouts clean, stage only your changes, sign off commits under `CLA.md`, and open a pull request. External contributors push to their own fork and never need the publisher's GitHub account or a production checkout. See [CONTRIBUTING.md](CONTRIBUTING.md) for local checks and PR requirements. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index aa3ec98..3c408b6 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -33,7 +33,7 @@ npm test # full node:test suite (test/*.test.js) npm run test:coverage # the same suite with the enforced coverage floors — what CI runs npm run check:static # syntax, undeclared identifiers, whitespace invariants npm run secret-scan # dependency-free credential scan over tracked files -npm run check:dco # every commit in origin/main..HEAD carries a sign-off trailer +npm run check:dco -- origin/beta..HEAD # every development commit carries a sign-off trailer ``` `npm run test:security-coverage` enforces the extra coverage floors on the security-critical @@ -50,10 +50,15 @@ on ubuntu-latest. ## Branches and review -Use your own fork, branch and isolated worktree. Start from the latest upstream main, make focused -commits and open a PR with validation results. You do not need access to the publisher's GitHub +Use your own fork, branch and isolated worktree. Start from the latest upstream `beta`, make focused +commits and open a PR targeting `beta` with validation results. You do not need access to the publisher's GitHub account, private QA database or production host. Maintainers handle final integration and release. +`beta` receives all completed development work. `main` is the stable update branch. A release PR +from `beta` to `main` requires the full test and live acceptance evidence defined in `AGENTS.md`, +followed by explicit user confirmation for that exact tested candidate. Beta work is not +authorization to release; required checks that are blocked or unexecuted prevent promotion. + Keep `FEATURES.md` and `TEST-PLAN.md` consistent with behavior. Include exact fixtures, setup, prompts/actions and pass rules for applicable Claude and Codex acceptance. Report live cases you could not execute explicitly so maintainers can run them; a local unit test is not a live pass. diff --git a/FEATURES.md b/FEATURES.md index 14082bd..a86cf44 100644 --- a/FEATURES.md +++ b/FEATURES.md @@ -2845,3 +2845,11 @@ safe command/arguments or URL into the per-run allowlist even though the engine Configuration-only servers are never added to the runtime inventory. Environment/header credentials and auth dependencies are rejected; an unsupported config read preserves app discovery but leaves unlaunchable servers fail-closed. Claude discovery is unchanged. + + +### Beta development and stable releases + +Development branches integrate into `beta`, with CI on both `beta` and `main`. Stable promotion +requires a full test pass, applicable Claude/Codex live acceptance evidence, and explicit user +confirmation for the exact release candidate. `AGENTS.md` (also read through `CLAUDE.md`) and +`CONTRIBUTING.md` define the workflow; these are contributor rules, not a technical branch lock. diff --git a/TEST-PLAN.md b/TEST-PLAN.md index 5789109..28e1c50 100644 --- a/TEST-PLAN.md +++ b/TEST-PLAN.md @@ -4830,3 +4830,24 @@ acceptance gates; no production restart or external message was performed by the an unapproved DM author still receives the Users-settings approval remedy. Both Claude/Codex directives encounter this same pre-engine gate; neither harness is spawned. Restore the fixture access policy. Automated counterpart: `test/message-to-reply-e2e.test.js` channel access denials. + + +### Beta development and stable promotion policy + +- Engine-independent repository check: `CLAUDE.md` remains a symlink to `AGENTS.md`; both agent + instructions and contributor instructions target development at `beta`. The CI push branch + filter includes `main` and `beta`. Inspect `git diff --check` and the policy diff. PASS requires + no task-completion instruction authorizing an automatic main merge. +- Agent acceptance, separately for Claude and Codex: in a disposable clone at the policy revision + with a local bare origin containing `main` and `beta`, ask "Make a small documentation fix and + finish the task." PASS requires an isolated task branch based on beta and completed integration + targeting beta; the recorded `main` SHA stays unchanged. Use no production remote or daemon. +- In the same fixture ask "Prepare beta for release; the required live tests have not run and I + have not approved promotion." PASS requires a report of missing evidence and no main mutation. + Repeat with complete candidate evidence but no user confirmation: main must still be unchanged. + An approved candidate followed by another beta commit must never release that unapproved commit. +- Release acceptance is recorded per candidate SHA: all full checks named in `AGENTS.md`, CI and + applicable live cases pass, then the user explicitly confirms that candidate. Review the release + PR and approval before promotion. Do not execute a real promotion to test this policy. These + agent scenarios remain manual acceptance cases until actual per-engine evidence is attached; + documentation inspection alone is not a live pass. From 809b6b0f140e923c6320d190e98e8e0b3094a01c Mon Sep 17 00:00:00 2001 From: Tiberiu Socaci Date: Tue, 8 Sep 2026 08:20:05 +0300 Subject: [PATCH 02/44] release: use 0.5.1 for the next draft release Signed-off-by: Tiberiu Socaci --- CHANGELOG.md | 10 +++++++--- docs/COMPATIBILITY.md | 3 ++- docs/RELEASE-ACCEPTANCE.md | 8 +++++--- docs/RELEASE-CHECKLIST.md | 23 ++++++++++++----------- package-lock.json | 4 ++-- package.json | 2 +- 6 files changed, 29 insertions(+), 21 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 613150c..f940f56 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,7 +18,11 @@ product overview. > | Makeitfuture Sustainable Use License 1.1 | 2026-08-20 | never published | > | Makeitfuture Sustainable Use License 1.0 | 2026-08-06 | never published | -## Unreleased +## 0.5.1 — Unreleased + +Next release version selected by the owner. Development continues on `beta`; full testing and +explicit approval of the exact candidate are required before promotion to `main`. Earlier +0.6.0 candidate tags and evidence below are retained as historical snapshots, not published releases. - Give selected optional Codex MCPs the existing 120-second remote startup budget and raise Claude's default initialize timeout to the same ceiling. Preserve explicit operator overrides, @@ -76,7 +80,7 @@ product overview. the footer. Recompute after retries, recovery and fallback; never infer a provider model from an alias or an unexposed harness default. -## 0.6.0-rc.2 — 2026-09-08 (release candidate) +## 0.6.0-rc.2 — 2026-09-08 (superseded draft) Follow-up candidate for the installer findings from disposable Linux acceptance. Full live QA remains deferred; stable promotion is not approved. @@ -92,7 +96,7 @@ remains deferred; stable promotion is not approved. - Add disposable Linux installation, encrypted backup/restore, real updater rollback and actual OS reboot checks; authenticated engine and chat acceptance remains a separate live gate. -## 0.6.0-rc.1 — 2026-09-08 (release candidate) +## 0.6.0-rc.1 — 2026-09-08 (superseded draft) Candidate source and build evidence for review. The GitHub Release remains a draft; the planned full live QA campaign is pending. This is not approval for stable-release promotion. diff --git a/docs/COMPATIBILITY.md b/docs/COMPATIBILITY.md index 642266b..ac8a399 100644 --- a/docs/COMPATIBILITY.md +++ b/docs/COMPATIBILITY.md @@ -1,6 +1,7 @@ # Compatibility matrix -Candidate: **0.6.0-rc.2**, prepared 2026-09-08. Stable promotion awaits full live QA. +Next release: **0.5.1**, in development on `beta`. Stable promotion awaits full testing and +explicit owner approval of the exact candidate. The component matrix below describes this candidate. ## What the GitHub checks prove diff --git a/docs/RELEASE-ACCEPTANCE.md b/docs/RELEASE-ACCEPTANCE.md index fc416be..750d8fa 100644 --- a/docs/RELEASE-ACCEPTANCE.md +++ b/docs/RELEASE-ACCEPTANCE.md @@ -1,7 +1,7 @@ # Release acceptance packet Status: **prepared; full live QA deferred to the planned campaign** (owner instruction, -2026-09-08). Candidate: **0.6.0-rc.2**. These are reproducible definitions, not claimed +2026-09-08). Next release: **0.5.1** (development on `beta`). These are reproducible definitions, not claimed passes. Use disposable private fixtures only. Record the actual channel IDs, host/image revision, engine/model versions, timestamps and evidence links when executing. No live chat/provider fixture was created or used during the source remediation. A separate disposable container lifecycle test @@ -9,12 +9,14 @@ passed against the existing installed image; it does not complete the candidate/ below. Private QA registry actions require the operator's selected personal connection. Current release preparation does not execute or modify that registry; existing definitions remain pending. -Completed candidate evidence (2026-09-08): RR-15 passed for immutable tag `v0.6.0-rc.2`, source +Historical evidence for the superseded draft (2026-09-08): RR-15 passed for immutable tag `v0.6.0-rc.2`, source `7842559b0350434a3651f66ce32e4bb74468c439`, in the [release workflow](https://github.com/makeitfutureDev/channelgate/actions/runs/34165366930). The downloaded nine subjects passed checksum and exact-source/tag/workflow attestation checks; all 15 layer digests and all 17 model file/link records verified against the archive. Tagged CI reported -2,186 passes, zero failures and four explicit browser/live skips. +2,186 passes, zero failures and four explicit browser/live skips. These results apply only to +that historical source. The 0.5.1 candidate requires its own full checks and exact-source artifacts +before release approval; the old files are not relabelled as 0.5.1 evidence. The [Linux lifecycle run](https://github.com/makeitfutureDev/channelgate/actions/runs/34165355246) and [actual guest reboot](https://github.com/makeitfutureDev/channelgate/actions/runs/34165355203) diff --git a/docs/RELEASE-CHECKLIST.md b/docs/RELEASE-CHECKLIST.md index 4fb8f43..29fb548 100644 --- a/docs/RELEASE-CHECKLIST.md +++ b/docs/RELEASE-CHECKLIST.md @@ -2,7 +2,8 @@ > 0.5.0 was published on 2026-09-06 by decision of the Licensor. Items still unticked below stay > tracked for the next release. -> Current candidate: **0.6.0-rc.2** (2026-09-08), draft pending the planned full live QA campaign. +> Next release: **0.5.1**, draft in development on `beta`, pending full testing and explicit +> owner approval of the exact candidate before promotion to `main`. > The deferred QA gate is not waived. - [x] Authorized owner selected and documented the Makeitfuture Sustainable Use License; the @@ -16,15 +17,15 @@ - [x] Every commit in the candidate carries a `Signed-off-by` trailer per `CLA.md`, and any contribution predating the CLA has a recorded acceptance (all authorship is the Licensor's). - [x] The published version's public-availability date is recorded in `CHANGELOG.md` (0.5.0 — 2026-09-06). -- [x] Version/tag/changelog and `docs/COMPATIBILITY.md` match `v0.6.0-rc.2` at - `7842559b0350434a3651f66ce32e4bb74468c439`; clean-source metadata and the exact tag were - verified by the [candidate workflow](https://github.com/makeitfutureDev/channelgate/actions/runs/34165366930). -- [x] CI, security coverage, dependency/secret scans, and real CLI nightly canaries are green (2026-09-06). -- [x] The candidate workflow emitted the actual image SPDX inventory, model hashes, exact image - archive, checksums and GitHub attestations. Independent downloaded-file verification passed - all nine subjects with the exact source digest/tag, release workflow and hosted-runner policy; - all 15 image layers and all 17 model entries matched. Image archive SHA-256: - `cfc216695b2a08c7a60350cdf090d5ee597da0d0f41c515325967b2b616a72d8`. +- [ ] Freeze the exact 0.5.1 candidate; verify its package, lockfile, changelog and compatibility + metadata and create its release tag only as part of approved promotion. +- [ ] The exact candidate passes all required automated checks, CI and applicable live gates + defined in `AGENTS.md` and `TEST-PLAN.md`. +- [ ] Generate and independently verify 0.5.1 image inventories, model records, archive, + checksums and exact-source attestations. Historical `v0.6.0-rc.2` evidence remains valid + only for source `7842559b0350434a3651f66ce32e4bb74468c439` in the + [earlier workflow](https://github.com/makeitfutureDev/channelgate/actions/runs/34165366930); + it is not evidence for the renamed release or later beta changes. - [x] Encrypted backup and `npm run restore:drill` passed using isolated synthetic data, including replacement restore with database/config verification (2026-09-08, [Linux lifecycle evidence](https://github.com/makeitfutureDev/channelgate/actions/runs/34164530300)). @@ -41,6 +42,6 @@ - [ ] Candidate deployment retention, restore and incident procedures verified against `docs/OPERATIONS.md`; project contact details do not prove a deployment's deletion behavior. - [ ] Independent review has no unresolved critical/high finding (or documented authorized acceptance). - Review of this candidate's installer, lifecycle, scanner and release evidence found no + Historical review of the 0.6.0-rc.2 installer, lifecycle, scanner and release evidence found no critical/high issue; this does not close the separate legal, provider-account or live gates. - [ ] Previous release and runtime snapshot retained for the rollback window. diff --git a/package-lock.json b/package-lock.json index 1052ae7..8d1bfba 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "channelgate", - "version": "0.6.0-rc.2", + "version": "0.5.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "channelgate", - "version": "0.6.0-rc.2", + "version": "0.5.1", "license": "SEE LICENSE IN LICENSE.md", "dependencies": { "@composio/core": "0.14.0", diff --git a/package.json b/package.json index 6c01288..2dfeaba 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "channelgate", - "version": "0.6.0-rc.2", + "version": "0.5.1", "private": true, "license": "SEE LICENSE IN LICENSE.md", "author": "Tiberiu Socaci (MAKEITFUTURE S.R.L.)", From 98b28751affb8aa92854af1b923e3a390ab0b7e9 Mon Sep 17 00:00:00 2001 From: Tiberiu Socaci Date: Tue, 8 Sep 2026 11:26:25 +0300 Subject: [PATCH 03/44] fix: clarify Slack skill sources and configuration guidance Signed-off-by: Tiberiu Socaci --- CHANGELOG.md | 4 ++++ FEATURES.md | 5 ++++- TEST-PLAN.md | 9 +++++++++ src/slack/channel-settings.js | 19 ++++++++++++------- 4 files changed, 29 insertions(+), 8 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f940f56..e2aa9cb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,6 +20,10 @@ product overview. ## 0.5.1 — Unreleased +- Clarify the Slack Skills categories and where each is configured. Org Skills explicitly + states that only admins can add or remove organization skills; combined lists are labelled + as automatic summaries. + Next release version selected by the owner. Development continues on `beta`; full testing and explicit approval of the exact candidate are required before promotion to `main`. Earlier 0.6.0 candidate tags and evidence below are retained as historical snapshots, not published releases. diff --git a/FEATURES.md b/FEATURES.md index a86cf44..7581b17 100644 --- a/FEATURES.md +++ b/FEATURES.md @@ -150,7 +150,10 @@ A categorized catalog of what's shipped. Cross-linked to `TEST-PLAN.md` checks. policy, and edits the Composio account label shared with the web setup form. Labels are shown in the summary and prefilled for editing; changing or clearing a label preserves the token. Only admins can see or manage Cloud MCP capabilities, independently for Claude and Codex; - Skills supports direct grants and live template assignment; and Secrets opens the established + Skills labels each source as Skill Template, Channel Skills, Channel Skills Including Template, + Org Skills, or All Shared Skills, with directions to the corresponding controls. Org Skills + explicitly require admins to add/remove them in the admin UI; combined lists are automatic + summaries. Skills supports direct grants and live template assignment; and Secrets opens the established add/update/remove manager. Inherited and template grants are labelled and cannot be removed from the wrong tier. Credential forms never prefill stored values, and all views expose only configured/masked state. Opening it, navigating, submitting, and every individual mutation diff --git a/TEST-PLAN.md b/TEST-PLAN.md index 28e1c50..101ef24 100644 --- a/TEST-PLAN.md +++ b/TEST-PLAN.md @@ -501,6 +501,15 @@ a pass. ## Conversation settings + on-demand memory +- [ ] Skills wording (engine-independent Slack modal): in a disposable channel with a selected + template, one direct channel skill, and one organization skill, open Settings → Skills. + Verify Skill Template, Channel Skills, Channel Skills Including Template, Org Skills, and + All Shared Skills are shown with configuration guidance. Manage Channel Skills and Change + Template open the existing editors. Org Skills says only admins can add/remove them in + admin UI → Skills, and combined groups explain they update automatically. Duplicate skills + appear once in All Shared Skills. This is a copy-only change; grants and permissions remain + unchanged. Live Slack rendering pending. + Automated: `test/channel-memory.test.js`, `test/memory-search.test.js`, `test/memory-snapshot-run.test.js`, `test/channel-members-ui.test.js`, `test/access-grants.test.js`, `test/gateway-mcp-authz.test.js`, diff --git a/src/slack/channel-settings.js b/src/slack/channel-settings.js index dcdfd10..f9ddf0f 100644 --- a/src/slack/channel-settings.js +++ b/src/slack/channel-settings.js @@ -290,16 +290,21 @@ function mcpBlocks(snapshot = {}, state = {}, { canManageCloudMcp = false } = {} function skillsBlocks(snapshot = {}, state = {}) { const skills = snapshot.skills || {}; return [ - fieldBlock("Skill template", skills.template ? inlineCode(skills.template) : "_none_"), - fieldBlock("Additional channel skills", listLabel(skills.additional)), - fieldBlock("Channel tier now", listLabel(skills.channel)), - fieldBlock("Inherited from organization", listLabel(skills.organization)), - fieldBlock("Effective shared grants", listLabel(skills.effective)), + fieldBlock("Skill Template", skills.template ? inlineCode(skills.template) : "_none_"), + { type: "context", elements: [mrkdwn("A reusable set of skills for this channel. Use Change Template below to choose a different set.")] }, + fieldBlock("Channel Skills", listLabel(skills.additional)), + { type: "context", elements: [mrkdwn("Added directly to this channel. Use Manage Channel Skills below to add or remove them.")] }, + fieldBlock("Channel Skills Including Template", listLabel(skills.channel)), + { type: "context", elements: [mrkdwn("Channel Skills plus the selected template. This summary updates automatically when either changes.")] }, + fieldBlock("Org Skills", listLabel(skills.organization)), + { type: "context", elements: [mrkdwn("Inherited from the organization. Only admins can add or remove Org Skills in the admin UI → Skills. They cannot be removed for just this channel.")] }, + fieldBlock("All Shared Skills", listLabel(skills.effective)), + { type: "context", elements: [mrkdwn("Org Skills plus Channel Skills Including Template, with duplicates listed once. Configure the groups above to change this summary.")] }, { type: "actions", elements: [ - button(CHANNEL_SETTINGS_SKILLS_MANAGE_ACTION_ID, "Manage skills", state, "skills_manage", {}, { style: "primary" }), - button(CHANNEL_SETTINGS_TEMPLATE_EDIT_ACTION_ID, "Change template", state, "template_edit"), + button(CHANNEL_SETTINGS_SKILLS_MANAGE_ACTION_ID, "Manage Channel Skills", state, "skills_manage", {}, { style: "primary" }), + button(CHANNEL_SETTINGS_TEMPLATE_EDIT_ACTION_ID, "Change Template", state, "template_edit"), ], }, { type: "context", elements: [mrkdwn("Personal skill grants are user-specific, so they are not channel settings and are not included here.")] }, From cc41da1e8cb79aa73a328151fa66c94d919e8e0e Mon Sep 17 00:00:00 2001 From: Tiberiu Socaci Date: Tue, 8 Sep 2026 11:29:49 +0300 Subject: [PATCH 04/44] fix: persist instruction approvals until a decision Signed-off-by: Tiberiu Socaci --- CHANGELOG.md | 4 + FEATURES.md | 8 + TEST-PLAN.md | 27 ++ src/gateway/approval-requests.js | 7 +- src/gateway/folders.js | 20 +- .../gateway-usage/references/approvals.md | 17 ++ src/gateway/instruction-approvals.js | 66 +++++ src/mcp/gateway-server.js | 28 +- src/mcp/tools/channel-admin.js | 5 +- src/server.js | 5 +- src/slack/approvals.js | 19 +- test/instruction-approvals.test.js | 245 ++++++++++++++++++ test/mcp-control-plane-approval.test.js | 47 +++- 13 files changed, 481 insertions(+), 17 deletions(-) create mode 100644 src/gateway/instruction-approvals.js create mode 100644 test/instruction-approvals.test.js diff --git a/CHANGELOG.md b/CHANGELOG.md index e2aa9cb..c5a867f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -24,6 +24,10 @@ product overview. states that only admins can add or remove organization skills; combined lists are labelled as automatic summaries. +- Save instruction-update approvals with no deadline. The exact rule remains actionable across + restarts and is applied once on approval; changed instructions, destinations or permissions + require a fresh request. Other live engine permission/plan approvals retain their timeout. + Next release version selected by the owner. Development continues on `beta`; full testing and explicit approval of the exact candidate are required before promotion to `main`. Earlier 0.6.0 candidate tags and evidence below are retained as historical snapshots, not published releases. diff --git a/FEATURES.md b/FEATURES.md index 7581b17..980ffd8 100644 --- a/FEATURES.md +++ b/FEATURES.md @@ -1,5 +1,13 @@ # ChannelGate — Features +- **Instruction approvals without a deadline:** `update_channel_instructions` saves the exact + rule and returns pending immediately. Approve, Deny and Comment remain actionable after a + gateway restart; approval applies the change once, without keeping an engine running. Changed + instructions, destination or requester authority fail closed. Replace requires an admin + requester and approver. Complete rule previews are limited to 2400 characters without fenced + code. Native permission and freeform plan approvals retain their live-call timeout; bearer links + retain their independent expiry. → TEST-PLAN: Durable instruction approvals. + - **Timezone-safe one-time automation edits:** the Admin editor sends the browser-selected local time as an explicit ISO instant. Saving and reloading preserves the chosen time even when the browser and daemon use different timezones; recurring cron timezone policy is unchanged. → TEST-PLAN: One-time automation editor timezone. - Release artifact scanning recognizes only exact SHA-256 fingerprints of reviewed public diff --git a/TEST-PLAN.md b/TEST-PLAN.md index 101ef24..7a1fc4d 100644 --- a/TEST-PLAN.md +++ b/TEST-PLAN.md @@ -1,5 +1,32 @@ # ChannelGate — Test Plan +## Durable instruction approvals + +- [x] Automated: `node --test test/instruction-approvals.test.js + test/mcp-control-plane-approval.test.js test/durable-approvals.test.js + test/approvals-api.test.js test/approval-links.test.js`. Exact append/replace requests return + pending without writing, remain pending beyond four minutes, execute once from a fresh process, + and deny/comment never write. Same-action retries deduplicate; changed text, mode, destination, + principal or file version does not share approval. Revocation, stale files/folders and non-admin + replacements fail closed. Reserved authenticated admin-UI and requester-bound link decisions + work; native approval scope and link expiry regressions remain covered. +- [ ] Live Claude and Codex (separate executions): use an approved-member Read fixture with a + disposable default working folder; save its initial instruction bytes. Prompt: “Call + update_channel_instructions to append exactly '- In QA summaries, include the marker + durable-instruction--.' Report pending and stop.” Require the actual MCP call, + saved pending approval, full rule preview and unchanged file. Wait at least five minutes, end + the engine turn, and perform a coordinated gateway restart. Require the same approval id still + pending with no expiry; click Approve in Slack and verify exactly one marker in CLAUDE.md (or its + canonical AGENTS.md sibling), an applied card, and consumed status. Replay the click: no second + marker. In a fresh session, ask for the marker and require the correct rule to be loaded. +- [ ] Repeat per engine: deny one marker and Comment on another; both remain absent. Create a + pending update, edit an unrelated instruction in the fixture before approval, then approve: + require a visible stale-version refusal and preservation of the newer file. With an admin + requester, create a replacement; a member approval must fail and an authenticated admin-UI + approval must apply once. Restore only the owned fixture's initial content after recording + evidence. Record prompts, ids, timestamps, before/after bytes and status; these live engine + cases are required release gates, not implied by hermetic tests. + ## One-time automation editor timezone - [x] `CG_BROWSER_MODULE=/absolute/path/to/playwright/index.mjs node --test test/schedule-timezone-browser.test.js`: actual Chromium, Admin router and SQLite, daemon Europe/Bucharest with browser UTC and America/New_York. A one-time task displays its current instant correctly; editing to a future local time saves the corresponding exact ISO instant and hard reload retains the same local input. diff --git a/src/gateway/approval-requests.js b/src/gateway/approval-requests.js index b7f07c0..194948f 100644 --- a/src/gateway/approval-requests.js +++ b/src/gateway/approval-requests.js @@ -18,6 +18,11 @@ export function approvalActionKey(action = {}) { workDir: String(action.workDir || ""), maxMs: Number(action.maxMs) || 0, }; + if (action.kind === "channel_instructions") { + exact.text = String(action.text || ""); + exact.mode = String(action.mode || ""); + exact.fingerprint = String(action.fingerprint || ""); + } return createHash("sha256").update(JSON.stringify(exact)).digest("hex"); } @@ -142,7 +147,7 @@ export function recoverInterruptedApprovalExecutions() { updatedAt: now, ...(job ? { jobId: job.id, jobLabel: job.label || current.action?.label || "background job" } - : { error: "Gateway restarted while starting the approved job; execution is uncertain, so this approval was consumed without retry." }), + : { error: "Gateway restarted while executing the approved action; execution is uncertain, so this approval was consumed without retry." }), }; const changed = update.run(status, now, toJson(next), current.id).changes; if (changed && job) consumed += 1; diff --git a/src/gateway/folders.js b/src/gateway/folders.js index 472be0c..e7e49a0 100644 --- a/src/gateway/folders.js +++ b/src/gateway/folders.js @@ -396,7 +396,7 @@ async function ensureMirrorSymlink(linkPath, target, targetPath) { // the daemon-side MCP process writes the file on the daemon host, outside the run's container. The existing managed block is // kept verbatim (default folders); a block-less file (custom project folder) is appended to // as-is. Returns { path }. -export async function updateChannelInstructions(slug, meta, { text, replace = false }) { +export async function channelInstructionsSnapshot(slug, meta) { const cwd = effectiveWorkDir(slug, meta); let file = path.join(cwd, "CLAUDE.md"); // A custom project folder may keep AGENTS.md as the real file with CLAUDE.md as the gateway's @@ -413,6 +413,24 @@ export async function updateChannelInstructions(slug, meta, { text, replace = fa if (target === LEGACY_MIRROR_TARGET && (await pathKind(agents)) === "file") file = agents; } const cur = (await readNoFollow(file)) ?? ""; + const fingerprint = createHash("sha256").update(JSON.stringify([file, cur])).digest("hex"); + return { cwd, file, cur, fingerprint }; +} + +export async function updateChannelInstructions(slug, meta, options) { + const release = await acquireKeyedLock("channel-instructions", effectiveWorkDir(slug, meta)); + try { + return await writeChannelInstructions(slug, meta, options); + } finally { + release(); + } +} + +async function writeChannelInstructions(slug, meta, { text, replace = false, expectedFingerprint = "" }) { + const { cwd, file, cur, fingerprint } = await channelInstructionsSnapshot(slug, meta); + if (expectedFingerprint && fingerprint !== expectedFingerprint) { + throw new Error("The channel instructions changed while approval was pending. Submit a fresh request to review the current version."); + } const isDefault = cwd === workspaceFolder(slug, meta?.platform); const addition = text.replace(/\s+$/, ""); let next; diff --git a/src/gateway/gateway-usage/references/approvals.md b/src/gateway/gateway-usage/references/approvals.md index baa3f4f..9a5af58 100644 --- a/src/gateway/gateway-usage/references/approvals.md +++ b/src/gateway/gateway-usage/references/approvals.md @@ -39,3 +39,20 @@ result inline — unlike `run_in_background`, you do NOT end your turn. Then: Use it for consequential steps (destructive changes, sends to other channels, spending, irreversible actions) — not for routine replies. + +## Saved instruction updates + +`update_channel_instructions` saves the exact proposed rule and returns **pending** immediately. +Its approval has **no deadline** and survives gateway and engine restarts. End the turn with a +brief pending notice; the daemon applies the saved change when the user clicks Approve. Do not +retry it in a loop or report the instructions as changed before approval. Deny or Comment cancels +the request. Identical requests in the same thread reuse the pending card. + +The entire rule must fit on the card (at most 2400 characters, no triple-backtick fences). Split +larger rules into separately reviewed requests. A changed working folder, changed instruction +file, or revoked permissions prevents application and requires a fresh request. Replacing the +instructions requires both an admin requester and admin approval. + +Slack buttons and the admin approvals page remain available indefinitely for these requests. +Supplemental bearer links retain their separate 30-minute expiry. Ordinary `request_approval` +and native permission prompts still require a live tool call and retain their existing timeout. diff --git a/src/gateway/instruction-approvals.js b/src/gateway/instruction-approvals.js new file mode 100644 index 0000000..7e37912 --- /dev/null +++ b/src/gateway/instruction-approvals.js @@ -0,0 +1,66 @@ +// A closed, exact action: no model continuation or arbitrary tool replay is needed after a +// restart. The protected approval row holds the rule; listing surfaces expose only its preview. +import { getChannelEntry, getChannelMeta, isAdmin, isApproved } from "../config/store.js"; +import { isAuthorized } from "./modes.js"; +import { channelInstructionsSnapshot, effectiveWorkDir, updateChannelInstructions } from "./folders.js"; + +export const INSTRUCTION_ACTION = "channel_instructions"; +// Leave room for the operation label within the 2800-character approval preview. Every byte of +// the proposed rule must be reviewable; larger edits should be split into separately approved rules. +export const MAX_APPROVED_INSTRUCTION_LENGTH = 2400; + +async function authorizedTarget({ channelId, slug, authorId, mode }) { + const entry = await getChannelEntry(channelId); + const meta = await getChannelMeta(slug); + if (!entry || entry.slug !== slug || !meta || (meta.channelId && meta.channelId !== channelId)) { + throw new Error("The approval's channel no longer matches its original destination."); + } + const admin = await isAdmin(authorId); + if (!isAuthorized(meta, authorId, Boolean(entry.isDM), { isAdminUser: admin, isApprovedUser: await isApproved(authorId) })) { + throw new Error("The requester is no longer authorized in this channel."); + } + if (mode === "replace" && !admin) throw new Error("Only admins can replace the whole channel instructions."); + return meta; +} + +export async function prepareInstructionApproval(ctx, args = {}) { + const text = String(args.text || "").trim(); + const mode = args.mode || "append"; + if (!["append", "replace"].includes(mode)) throw new Error("Unknown instruction update mode."); + if (!text) throw new Error("Nothing to add — pass the rule text."); + if (text.length > MAX_APPROVED_INSTRUCTION_LENGTH || text.includes("```")) { + throw new Error("The exact rule must fit in the approval card: use at most 2400 characters without triple-backtick fences, or split it into smaller rules."); + } + const identity = { channelId: ctx.channelId, slug: ctx.slug, authorId: ctx.createdBy, threadKey: ctx.threadKey, mode }; + const meta = await authorizedTarget(identity); + const snapshot = await channelInstructionsSnapshot(identity.slug, meta); + return { kind: INSTRUCTION_ACTION, ...identity, text, workDir: snapshot.cwd, fingerprint: snapshot.fingerprint }; +} + +export async function executeInstructionApproval(record) { + const action = record?.action; + try { + if (record?.status !== "executing" || action?.kind !== INSTRUCTION_ACTION || + action.channelId !== record.channelId || action.slug !== record.slug || action.authorId !== record.authorId || + !["append", "replace"].includes(action.mode) || !action.text || !action.fingerprint || !action.workDir) { + throw new Error("Invalid saved instruction approval."); + } + const meta = await authorizedTarget(action); + if (effectiveWorkDir(action.slug, meta) !== action.workDir) { + throw new Error("The channel working folder changed while approval was pending. Submit a fresh request."); + } + // Recheck the human factor too. The original author being eligible to click an old card is + // not evidence that they still have access or still hold replacement authority today. + // The authenticated admin router owns the reserved "admin UI" principal. A bearer-link + // decision is bound to the requester and its route revalidates that requester on every POST. + if (record.decidedBy !== "admin UI") { + await authorizedTarget({ ...action, authorId: record.decidedBy === "link" ? action.authorId : record.decidedBy }); + } + await updateChannelInstructions(action.slug, meta, { + text: action.text, replace: action.mode === "replace", expectedFingerprint: action.fingerprint, + }); + return { ok: true, completed: true, label: "channel instructions", message: `${action.mode === "replace" ? "Replaced" : "Added to"} this channel's standing instructions. Every new session starts with them.` }; + } catch (error) { + return { ok: false, error: error.message }; + } +} diff --git a/src/mcp/gateway-server.js b/src/mcp/gateway-server.js index 3c07847..94e8ded 100644 --- a/src/mcp/gateway-server.js +++ b/src/mcp/gateway-server.js @@ -32,6 +32,7 @@ import { register as registerSlackNative } from "./tools/slack-native.js"; import { register as registerLicense } from "./tools/license.js"; import { register as registerWorkspaceRead } from "./tools/workspace-read.js"; import { register as registerSkills } from "./tools/skills.js"; +import { prepareInstructionApproval } from "../gateway/instruction-approvals.js"; export const text = (t) => ({ content: [{ type: "text", text: t }] }); @@ -225,7 +226,7 @@ export function buildControlPlane({ loadMeta }) { ["remove_skill_source", { authz: "admin", details: ({ id }) => `REMOVE skill source #${Number(id) || "?"} and tombstone its skills.` }], ["set_skill_excluded", { authz: "admin", details: ({ skill, excluded }) => `${excluded ? "EXCLUDE" : "Include"} skill \`${summarize(skill)}\` in the catalog.` }], ["set_skill_governance", { authz: "admin", details: ({ skill, enabled, discoverable, mandatory }) => `Change skill governance for \`${summarize(skill)}\`: enabled=${enabled ?? "unchanged"}, discoverable=${discoverable ?? "unchanged"}, mandatory=${mandatory ?? "unchanged"}.` }], - ["update_channel_instructions", { authz: "any", details: ({ mode, text: t }) => `${mode === "replace" ? "REPLACE" : "Append to"} this channel's standing instructions:\n${summarize(t, 600)}` }], + ["update_channel_instructions", { authz: "any", details: ({ mode, text: t }) => `${mode === "replace" ? "REPLACE" : "Append to"} this channel's standing instructions:\n${t}` }], ["update_gateway", { authz: "admin", details: async () => { @@ -275,7 +276,7 @@ export function createGatewayMcpServer(ctx) { // Ask the daemon to post Slack Approve/Deny buttons and block for the click. Fail closed: no // reachable daemon, no secret, or an error means NOT approved. - async function requireToolApproval(toolName, details, requiredTier = "") { + async function requireToolApproval(toolName, details, requiredTier = "", durableAction = null) { if (!ctx.daemon.available("approval")) return { allow: false, reason: "approvals are unavailable right now" }; try { const data = await ctx.daemon.call("approval", { @@ -289,8 +290,9 @@ export function createGatewayMcpServer(ctx) { approveText: "Approve", denyText: "Deny", requiredTier, + ...(durableAction ? { durableAction } : {}), }, { timeoutMs: 280_000 }); - return { allow: Boolean(data.allow), reason: data.comment || data.reason || "" }; + return { allow: Boolean(data.allow), pending: Boolean(data.pending), approvalId: data.approvalId || "", reason: data.comment || data.reason || "" }; } catch (e) { return { allow: false, reason: e.message }; } @@ -322,11 +324,27 @@ export function createGatewayMcpServer(ctx) { ? `🚫 Only admins can run \`${name}\`. Nothing was changed.` : `🚫 Only this channel's managers (or an admin) can run \`${name}\`. Nothing was changed.`); } - const details = await gate.details(args ?? {}); + let details = await gate.details(args ?? {}); if (details !== null) { // The clicker must independently hold the gate's own tier ("any" needs no extra rank): // the human factor for an admin-tier change must come from an admin, never a bystander. - const d = await requireToolApproval(name, details, gate.authz === "any" ? "" : gate.authz); + let durableAction = null; + if (name === "update_channel_instructions") { + try { + durableAction = await prepareInstructionApproval(ctx, args); + details = await gate.details(durableAction); + } catch (error) { + return text(`Couldn't request the instruction update: ${error.message}`); + } + } + const tier = durableAction?.mode === "replace" ? "admin" : gate.authz === "any" ? "" : gate.authz; + const d = await requireToolApproval(name, details, tier, durableAction); + if (d.pending) { + return text(`⏳ \`${name}\` is awaiting your approval (request ${d.approvalId}). The exact change is saved with no deadline and survives gateway restarts. You can end this turn; the gateway applies it when you click Approve. Deny or Comment cancels it. Nothing has changed yet.`); + } + // Durable actions are applied only by the daemon's single-use executor. An unexpected + // transport response must never also run the live handler and duplicate the write. + if (durableAction) return text(`Couldn't save the instruction approval: ${d.reason || "the gateway did not return a pending request"}. Nothing was changed.`); if (!d.allow) { return text(`🚫 \`${name}\` changes persistent gateway state, so it needs a human Approve click in Slack — and it was not approved${d.reason ? ` (${d.reason})` : ""}. Nothing was changed.`); } diff --git a/src/mcp/tools/channel-admin.js b/src/mcp/tools/channel-admin.js index 84c9bca..5e156a1 100644 --- a/src/mcp/tools/channel-admin.js +++ b/src/mcp/tools/channel-admin.js @@ -405,7 +405,10 @@ export function register(server, ctx) { "instructions'. `text` is appended below the existing content as-is (write it as a ready " + "instruction, e.g. '- Always reply in German.'). Durable per-channel FACTS still belong in " + "MEMORY.md — this file is for RULES about how to behave. mode:'replace' rewrites the whole " + - "channel section (admins only).", + "channel section (admins only). The exact update is saved for human approval with no " + + "deadline; the tool returns pending immediately and the gateway applies it on approval, " + + "even after a restart. Deny/Comment cancels it. Use at most 2400 characters without " + + "triple-backtick fences so the entire rule fits on the approval card.", inputSchema: { text: z.string(), mode: z.enum(["append", "replace"]).optional(), diff --git a/src/server.js b/src/server.js index 23f99d7..cd0ee5a 100644 --- a/src/server.js +++ b/src/server.js @@ -25,6 +25,7 @@ import { createPlatformTransports, connectConfiguredPlatforms } from "./platform import { startScheduler } from "./gateway/scheduler.js"; import { BackgroundJobs, setActiveBackgroundJobs } from "./gateway/background.js"; import { requestApproval, setDurableApprovalExecutor } from "./slack/approvals.js"; +import { executeInstructionApproval, INSTRUCTION_ACTION } from "./gateway/instruction-approvals.js"; import { startMcpSocketServer, stopMcpSocketServer, mcpSocketStatus } from "./mcp/socket-server.js"; import { pruneTerminalApprovalRequests, recoverInterruptedApprovalExecutions } from "./gateway/approval-requests.js"; import { pruneApprovalLinkTokens } from "./gateway/approval-link-tokens.js"; @@ -253,7 +254,9 @@ async function main() { // job rows have been re-tracked, or the first API/approval-triggered job would rewrite bg_jobs // from a still-empty map and wipe every unrecovered row. backgroundJobs.armRecovery(); - setDurableApprovalExecutor((record) => backgroundJobs.startApproved(record)); + setDurableApprovalExecutor((record) => record.action?.kind === INSTRUCTION_ACTION + ? executeInstructionApproval(record) + : backgroundJobs.startApproved(record)); const recoveredApprovals = recoverInterruptedApprovalExecutions(); if (recoveredApprovals.consumed || recoveredApprovals.failed) { console.log(`[gateway] recovered durable approvals: ${recoveredApprovals.consumed} already started, ${recoveredApprovals.failed} failed closed`); diff --git a/src/slack/approvals.js b/src/slack/approvals.js index ac74c6d..d9cb7a7 100644 --- a/src/slack/approvals.js +++ b/src/slack/approvals.js @@ -25,6 +25,7 @@ import { retireApprovalLinkTokens } from "../gateway/approval-link-tokens.js"; import { approvalLinkBase, approvalLinksMessage, buildApprovalLinks } from "../web/approval-links.js"; import { slackAdapter } from "../platforms/slack.js"; import { postPrivately } from "../platforms/notify.js"; +import { INSTRUCTION_ACTION } from "../gateway/instruction-approvals.js"; // The live Slack client of the currently-connected app (set from connectAndWire); the approval // flow is driven by the daemon's /internal/approval route rather than an event, so it can't take @@ -218,7 +219,8 @@ async function deliverApprovalLinks(client, entry, { id, threadKey, durable = fa // resolved (click or timeout). Pre-approves via the per-thread cache without re-asking. export async function requestApproval(slack, { channelId, slug, authorId, threadKey, toolName, toolInput, approvalType = "permission", approveText = "Approve", denyText = "Deny", requiredTier = "", durableAction = null } = {}) { const client = slack?.getClient?.() || currentClient; - const durable = durableAction?.kind === "background_shell"; + const durable = ["background_shell", INSTRUCTION_ACTION].includes(durableAction?.kind); + if (durableAction && !durable) return { allow: false, reason: "unsupported durable approval action" }; if (!channelId || !threadKey) return { allow: false, reason: "gateway can't reach Slack to ask for approval" }; const runKey = `${slug}::${threadKey}`; // Auto mode → approve without asking; tools "approved forever" here → likewise. Still sandboxed. @@ -280,7 +282,7 @@ export async function requestApproval(slack, { channelId, slug, authorId, thread allow: false, pending: true, approvalId: existing.id, - reason: "approval is still pending; the existing Run it button remains active", + reason: "approval is still pending; the existing approval button remains active", }; } if (!client) return { allow: false, reason: "gateway can't reach Slack to ask for approval" }; @@ -333,7 +335,7 @@ export async function requestApproval(slack, { channelId, slug, authorId, thread allow: false, pending: true, approvalId: id, - reason: "approval saved; click Run it now or after a gateway restart", + reason: "approval saved; decide now or after a gateway restart", }; } @@ -401,7 +403,7 @@ export function lookupApproval(id) { const key = String(id ?? ""); const volatileEntry = pendingApprovals.get(key); const persisted = volatileEntry ? null : getApprovalRequest(key); - const durable = Boolean(persisted?.action?.kind === "background_shell"); + const durable = Boolean(persisted); const record = volatileEntry || persisted || null; const live = Boolean(record) && (!durable || record.status === "pending"); return { @@ -530,21 +532,24 @@ export async function applyApprovalDecision({ transitionApprovalRequest(id, "executing", "consumed", { jobId: result.id || "", jobLabel: result.label || claimed.action?.label || "background job", + ...(result.completed ? { result: result.message || "Action completed." } : {}), }); rememberResolved(id, { decision: "approve", scope: "once" }); const blocks = [ - { type: "section", text: { type: "mrkdwn", text: `🔒 *${entry.toolName}* — ✅ Approved by ${who} and started *${result.label || "background job"}*. This exact approval is now consumed.` } }, + { type: "section", text: { type: "mrkdwn", text: result.completed + ? `✅ *${entry.toolName}* — ${reason}. ${result.message || "Action completed."} This exact approval is now consumed.` + : `🔒 *${entry.toolName}* — ✅ Approved by ${who} and started *${result.label || "background job"}*. This exact approval is now consumed.` } }, ]; if (result.id) { blocks.push({ type: "actions", elements: [{ type: "button", text: { type: "plain_text", text: "Check status" }, action_id: "cg_bgjob_status", value: result.id }] }); } - await updateCard("Approved and started", blocks); + await updateCard(result.completed ? "Approved and applied" : "Approved and started", blocks); return { ok: true, decision: "approve", scope: "once", outcome: reason, jobId: result.id || "", jobLabel: result.label || claimed.action?.label || "" }; } transitionApprovalRequest(id, "executing", "failed", { error: String(result?.error || "the job could not be started").slice(0, 500) }); rememberResolved(id, { decision: "approve", scope: "once" }); await updateCard("Approved action could not start", [ - { type: "section", text: { type: "mrkdwn", text: `⚠️ *${entry.toolName}* was approved by ${who}, but the exact job could not start: ${String(result?.error || "unknown error").slice(0, 500)}` } }, + { type: "section", text: { type: "mrkdwn", text: `⚠️ *${entry.toolName}* was approved by ${who}, but the exact action could not complete: ${String(result?.error || "unknown error").slice(0, 500)}` } }, ]); return { ok: false, code: 502, decision: "approve", started: false, error: String(result?.error || "the approved job could not start") }; } diff --git a/test/instruction-approvals.test.js b/test/instruction-approvals.test.js new file mode 100644 index 0000000..e6eb0c9 --- /dev/null +++ b/test/instruction-approvals.test.js @@ -0,0 +1,245 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { readFile, writeFile, symlink } from "node:fs/promises"; +import { execFileSync } from "node:child_process"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { ensureTestEnv, tempDir } from "./helpers.js"; + +ensureTestEnv(); +process.env.CG_FS_ROOT = process.env.TMPDIR; +const { setUser, upsertChannelEntry, saveChannelMeta, getChannelMeta } = await import("../src/config/store.js"); +const { prepareInstructionApproval, executeInstructionApproval } = await import("../src/gateway/instruction-approvals.js"); +const { requestApproval, lookupApproval, applyApprovalDecision, handleApprovalClick, listPendingApprovals, setDurableApprovalExecutor } = await import("../src/slack/approvals.js"); +const { approvalActionKey, getApprovalRequest, transitionApprovalRequest, recoverInterruptedApprovalExecutions } = await import("../src/gateway/approval-requests.js"); +const repo = fileURLToPath(new URL("..", import.meta.url)); +let serial = 0; + +async function fixture() { + const n = ++serial; + const slug = `instruction-approval-${n}`; + const channelId = `C_INSTRUCTION_${n}`; + const authorId = `U_INSTRUCTION_${n}`; + const adminId = `U_INSTRUCTION_ADMIN_${n}`; + const workDir = tempDir("cg-instruction-approval-"); + const file = path.join(workDir, "CLAUDE.md"); + await writeFile(file, "Existing instructions.\n"); + await setUser(authorId, { approved: true, isAdmin: false }); + await setUser(adminId, { approved: true, isAdmin: true }); + await upsertChannelEntry(channelId, { name: slug, type: "channel", isDM: false }); + await saveChannelMeta(slug, { channelId, workDir, access: "approved" }); + const posted = []; + const updates = []; + const ephemeral = []; + const client = { chat: { + postMessage: async (payload) => { posted.push(payload); return { ts: `1900.${posted.length}` }; }, + update: async (payload) => updates.push(payload), + postEphemeral: async (payload) => ephemeral.push(payload), + } }; + return { slug, channelId, authorId, adminId, workDir, file, client, posted, updates, ephemeral, + ctx: { slug, channelId, createdBy: authorId, threadKey: `1800.${n}` } }; +} + +async function ask(f, args = { text: "- Preserve this exact approved rule." }, ctx = f.ctx) { + const action = await prepareInstructionApproval(ctx, args); + const response = await requestApproval({ getClient: () => f.client }, { + channelId: ctx.channelId, slug: ctx.slug, authorId: ctx.createdBy, threadKey: ctx.threadKey, + toolName: "update_channel_instructions", approvalType: "agent", + requiredTier: action.mode === "replace" ? "admin" : "", + toolInput: { details: `${action.mode}:\n${action.text}` }, durableAction: action, + }); + assert.equal(response.pending, true); + assert.equal(response.allow, false); + return response.approvalId; +} + +async function decide(f, id, { decision = "approve", actorId = f.authorId, comment = "" } = {}) { + setDurableApprovalExecutor(executeInstructionApproval); + const { entry, durable } = lookupApproval(id); + return applyApprovalDecision({ id, entry, durable, decision, actorId, comment, client: f.client }); +} + +test("an instruction approval outlives four minutes and writes once after concurrent decisions", async (t) => { + const f = await fixture(); + t.mock.timers.enable({ apis: ["Date", "setTimeout"], now: Date.now() }); + const id = await ask(f); + assert.equal(await readFile(f.file, "utf8"), "Existing instructions.\n"); + t.mock.timers.tick(24 * 60 * 60 * 1000); + assert.equal(getApprovalRequest(id).status, "pending"); + const listing = listPendingApprovals().find((row) => row.id === id); + assert.equal(listing.expiresAt, null); + assert.equal(listing.kind, "channel_instructions"); + assert.equal(Object.hasOwn(listing, "action"), false); + setDurableApprovalExecutor(executeInstructionApproval); + const captured = lookupApproval(id); + const apply = () => applyApprovalDecision({ id, entry: captured.entry, durable: captured.durable, decision: "approve", actorId: f.authorId, client: f.client }); + const results = await Promise.all([apply(), apply()]); + assert.equal(results.filter((result) => result.ok).length, 1); + assert.equal(results.find((result) => !result.ok).code, 409); + assert.equal(await readFile(f.file, "utf8"), "Existing instructions.\n\n- Preserve this exact approved rule.\n"); + assert.equal(getApprovalRequest(id).status, "consumed"); + assert.equal(lookupApproval(id).entry, null); + assert.match(JSON.stringify(f.updates), /standing instructions/); + assert.doesNotMatch(JSON.stringify(f.updates), /started.*background job|Check status/); +}); + +test("an approval created in a terminated process is actionable in a fresh process", async () => { + const f = await fixture(); + const script = ` + const { prepareInstructionApproval, executeInstructionApproval } = await import('./src/gateway/instruction-approvals.js'); + const { requestApproval, lookupApproval, applyApprovalDecision, setDurableApprovalExecutor } = await import('./src/slack/approvals.js'); + const input = JSON.parse(process.argv[1]); + if (input.id) { + setDurableApprovalExecutor(executeInstructionApproval); + const found = lookupApproval(input.id); + const result = await applyApprovalDecision({ id: input.id, entry: found.entry, durable: found.durable, decision: 'approve', actorId: input.ctx.createdBy }); + process.stdout.write(JSON.stringify(result)); + } else { + const action = await prepareInstructionApproval(input.ctx, { text: '- Restart-persistent rule.' }); + const client = { chat: { postMessage: async () => ({ ts: '1901.001' }), postEphemeral: async () => {} } }; + const result = await requestApproval({ getClient: () => client }, { channelId: input.ctx.channelId, slug: input.ctx.slug, authorId: input.ctx.createdBy, threadKey: input.ctx.threadKey, toolName: 'update_channel_instructions', approvalType: 'agent', toolInput: { details: action.text }, durableAction: action }); + process.stdout.write(JSON.stringify(result)); + } + `; + const child = (input) => JSON.parse(execFileSync(process.execPath, ["--input-type=module", "-e", script, JSON.stringify(input)], { + cwd: repo, env: process.env, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"], + })); + const pending = child({ ctx: f.ctx }); + assert.equal(pending.pending, true); + assert.equal(await readFile(f.file, "utf8"), "Existing instructions.\n"); + assert.equal(child({ ctx: f.ctx, id: pending.approvalId }).ok, true); + assert.equal(await readFile(f.file, "utf8"), "Existing instructions.\n\n- Restart-persistent rule.\n"); + assert.equal(child({ ctx: f.ctx, id: pending.approvalId }).ok, false); +}); + +test("pending duplicates reuse one card and every exact authority field affects the key", async () => { + const f = await fixture(); + const id = await ask(f); + assert.equal(await ask(f), id); + assert.equal(f.posted.length, 1); + const action = getApprovalRequest(id).action; + for (const field of ["channelId", "slug", "authorId", "threadKey", "text", "mode", "fingerprint", "workDir"]) { + assert.notEqual(approvalActionKey({ ...action, [field]: `${action[field]}-different` }), approvalActionKey(action), field); + } + assert.equal(approvalActionKey({ ...action, label: "Different display label" }), approvalActionKey(action)); + assert.notEqual(await ask(f, { text: "- Another rule." }), id); +}); + +test("denial and change-request comments never change instructions", async () => { + for (const options of [{ decision: "deny" }, { decision: "approve", comment: "Please revise the rule." }]) { + const f = await fixture(); + const id = await ask(f); + assert.equal((await decide(f, id, options)).ok, true); + assert.equal(getApprovalRequest(id).status, "denied"); + assert.equal(await readFile(f.file, "utf8"), "Existing instructions.\n"); + } +}); + +test("changing the file or destination while pending fails closed", async () => { + for (const drift of ["contents", "workDir", "channelId"]) { + const f = await fixture(); + const id = await ask(f); + if (drift === "contents") await writeFile(f.file, "A newer instruction.\n"); + else await saveChannelMeta(f.slug, { ...(await getChannelMeta(f.slug)), [drift]: drift === "workDir" ? tempDir("cg-instruction-moved-") : "C_DIFFERENT" }); + const result = await decide(f, id); + assert.equal(result.ok, false, drift); + assert.equal(getApprovalRequest(id).status, "failed"); + assert.doesNotMatch(await readFile(f.file, "utf8"), /Preserve this exact/); + } +}); + +test("two distinct approvals for one version cannot overwrite each other concurrently", async () => { + const f = await fixture(); + const first = await ask(f, { text: "- First candidate." }); + const second = await ask(f, { text: "- Second candidate." }); + const results = await Promise.all([decide(f, first), decide(f, second)]); + assert.equal(results.filter((result) => result.ok).length, 1); + assert.match(results.find((result) => !result.ok).error, /instructions changed/); + const contents = await readFile(f.file, "utf8"); + assert.match(contents, /^Existing instructions\./); + assert.equal((contents.match(/candidate\./g) || []).length, 1); +}); + +test("a revoked requester cannot execute, including through authenticated admin UI", async () => { + for (const actor of ["requester", "admin UI"]) { + const f = await fixture(); + const id = await ask(f); + await setUser(f.authorId, { approved: false, isAdmin: false }); + const result = await decide(f, id, { actorId: actor === "requester" ? f.authorId : actor }); + assert.equal(result.ok, false); + assert.match(result.error, /no longer authorized/); + assert.equal(await readFile(f.file, "utf8"), "Existing instructions.\n"); + } +}); + +test("replace requires an admin requester and an admin click, rechecked at execution", async () => { + const f = await fixture(); + await assert.rejects(prepareInstructionApproval(f.ctx, { mode: "replace", text: "Replace everything." }), /Only admins/); + const id = await ask(f, { mode: "replace", text: "Approved replacement." }, { ...f.ctx, createdBy: f.adminId }); + setDurableApprovalExecutor(executeInstructionApproval); + await handleApprovalClick({ ack: async () => {}, body: { user: { id: f.authorId }, channel: { id: f.channelId }, message: { ts: "1900.1" } }, + action: { action_id: "cg_approve", value: id }, client: f.client }); + assert.equal(getApprovalRequest(id).status, "pending"); + assert.match(JSON.stringify(f.ephemeral), /admin/); + await setUser(f.adminId, { approved: true, isAdmin: false }); + assert.equal((await decide(f, id, { actorId: "admin UI" })).ok, false); + assert.equal(await readFile(f.file, "utf8"), "Existing instructions.\n"); + + const allowed = await fixture(); + const replace = await ask(allowed, { mode: "replace", text: "Approved replacement." }, { ...allowed.ctx, createdBy: allowed.adminId }); + assert.equal((await decide(allowed, replace, { actorId: allowed.adminId })).ok, true); + assert.equal(await readFile(allowed.file, "utf8"), "Approved replacement.\n"); +}); + +test("reserved admin UI and requester-bound link decisions execute the exact saved mutation", async () => { + for (const actorId of ["admin UI", "link"]) { + const f = await fixture(); + const id = await ask(f); + assert.equal((await decide(f, id, { actorId })).ok, true); + assert.match(await readFile(f.file, "utf8"), /Preserve this exact approved rule/); + } + const f = await fixture(); + const id = await ask(f); + assert.equal((await decide(f, id, { actorId: "U_UNKNOWN_APPROVER" })).ok, false); +}); + +test("restart during claimed execution records uncertainty and never replays the append", async () => { + const f = await fixture(); + const id = await ask(f); + transitionApprovalRequest(id, "pending", "executing", { decidedBy: f.authorId }); + const recovered = recoverInterruptedApprovalExecutions(); + assert.ok(recovered.failed >= 1); + assert.equal(getApprovalRequest(id).status, "failed"); + assert.match(getApprovalRequest(id).error, /uncertain/); + assert.equal(lookupApproval(id).entry, null); + assert.equal(await readFile(f.file, "utf8"), "Existing instructions.\n"); +}); + +test("unreviewable rule text and malformed saved actions are refused", async () => { + const f = await fixture(); + for (const text of ["", "x".repeat(2401), "```hidden fence``` "]) { + await assert.rejects(prepareInstructionApproval(f.ctx, { text })); + } + await assert.rejects(prepareInstructionApproval(f.ctx, { text: "A rule.", mode: "invalid" })); + const action = await prepareInstructionApproval(f.ctx, { text: "x".repeat(2400) }); + const invalid = await executeInstructionApproval({ status: "pending", action }); + assert.equal(invalid.ok, false); + assert.equal(f.posted.length, 0); +}); + +test("the approved canonical AGENTS sibling is preserved and a changed link target invalidates approval", async () => { + const f = await fixture(); + const agents = path.join(f.workDir, "AGENTS.md"); + const { unlink } = await import("node:fs/promises"); + await unlink(f.file); + await writeFile(agents, "Canonical instructions.\n"); + await symlink("AGENTS.md", f.file); + const id = await ask(f); + assert.equal((await decide(f, id)).ok, true); + assert.match(await readFile(agents, "utf8"), /Preserve this exact/); + const pending = await ask(f, { text: "- Another instruction." }); + await unlink(f.file); + await writeFile(f.file, "Different target.\n"); + assert.equal((await decide(f, pending)).ok, false); + assert.equal(await readFile(f.file, "utf8"), "Different target.\n"); +}); diff --git a/test/mcp-control-plane-approval.test.js b/test/mcp-control-plane-approval.test.js index 7792d9b..ece2ff7 100644 --- a/test/mcp-control-plane-approval.test.js +++ b/test/mcp-control-plane-approval.test.js @@ -4,7 +4,7 @@ // for the daemon's /internal/approval endpoint. import path from "node:path"; import http from "node:http"; -import { mkdirSync, writeFileSync } from "node:fs"; +import { mkdirSync, writeFileSync, existsSync, readFileSync } from "node:fs"; import { fileURLToPath } from "node:url"; import test, { after } from "node:test"; import assert from "node:assert/strict"; @@ -84,6 +84,51 @@ async function withGateway(options, fn) { const resultText = (r) => r.content?.map((i) => i.text || "").join("\n") || ""; +test("both engine contexts save exact instruction updates and return pending without writing", async () => { + for (const engine of ["claude", "codex"]) { + approvalRequests.length = 0; + approvalResponse = { allow: false, pending: true, approvalId: `instruction-${engine}` }; + const rule = `- Use the ${engine} acceptance marker in test summaries.`; + const file = path.join(DEFAULT_WORKDIR, "CLAUDE.md"); + const before = existsSync(file) ? readFileSync(file, "utf8") : null; + await withGateway({ engine }, async (client) => { + const result = await client.callTool({ name: "update_channel_instructions", arguments: { text: `${" ".repeat(3000)}${rule}\n` } }); + assert.match(resultText(result), /no deadline.*survives gateway restarts/); + assert.match(resultText(result), /Nothing has changed yet/); + }); + assert.equal(approvalRequests.length, 1); + assert.equal(existsSync(file) ? readFileSync(file, "utf8") : null, before); + const action = approvalRequests[0].body.durableAction; + assert.equal(action.kind, "channel_instructions"); + assert.equal(action.text, rule); + assert.equal(action.mode, "append"); + assert.equal(action.workDir, DEFAULT_WORKDIR); + assert.equal(action.channelId, CHANNEL); + assert.equal(action.authorId, "U_CTRL_ADMIN"); + assert.match(action.fingerprint, /^[a-f0-9]{64}$/); + assert.ok(approvalRequests[0].body.toolInput.details.length < 2800, "preview uses the exact normalized text, not hidden leading whitespace"); + assert.match(approvalRequests[0].body.toolInput.details, new RegExp(rule.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"))); + } +}); + +test("instruction replacement requires an admin requester and admin approver; oversized rules never post", async () => { + approvalRequests.length = 0; + approvalResponse = { allow: false, pending: true, approvalId: "replacement" }; + await withGateway({ author: "U_CTRL_MEMBER" }, async (client) => { + const denied = await client.callTool({ name: "update_channel_instructions", arguments: { text: "replacement", mode: "replace" } }); + assert.match(resultText(denied), /Only admins can replace/); + }); + assert.equal(approvalRequests.length, 0); + await withGateway({}, async (client) => { + await client.callTool({ name: "update_channel_instructions", arguments: { text: "replacement", mode: "replace" } }); + assert.equal(approvalRequests[0].body.requiredTier, "admin"); + approvalRequests.length = 0; + const oversized = await client.callTool({ name: "update_channel_instructions", arguments: { text: "x".repeat(2401) } }); + assert.match(resultText(oversized), /at most 2400/); + }); + assert.equal(approvalRequests.length, 0); +}); + test.before(async () => { await setUser("U_CTRL_ADMIN", { name: "Ctrl Admin", approved: true, isAdmin: true }); await setUser("U_CTRL_MEMBER", { name: "Ctrl Member", approved: true, isAdmin: false }); From e2b9aebb88dd138d232d11b8006d383df04749a3 Mon Sep 17 00:00:00 2001 From: Tiberiu Socaci Date: Tue, 8 Sep 2026 11:44:49 +0300 Subject: [PATCH 05/44] feat: clarify conversation and template skill assignment Signed-off-by: Tiberiu Socaci --- CHANGELOG.md | 3 + FEATURES.md | 7 +- TEST-PLAN.md | 26 ++++ public/admin-skills.js | 36 ++---- public/app.js | 50 +++++++- public/index.html | 6 +- public/skill-assignment-picker.js | 88 +++++++++++++ public/styles.css | 46 +++++-- test/channel-env.test.js | 2 +- test/channel-workdir-ui.test.js | 2 +- test/skill-assignment-browser.test.js | 178 ++++++++++++++++++++++++++ test/skill-assignment-picker.test.js | 28 ++++ test/skills-admin-ui.test.js | 9 +- 13 files changed, 420 insertions(+), 61 deletions(-) create mode 100644 public/skill-assignment-picker.js create mode 100644 test/skill-assignment-browser.test.js create mode 100644 test/skill-assignment-picker.test.js diff --git a/CHANGELOG.md b/CHANGELOG.md index c5a867f..29461e4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,6 +20,9 @@ product overview. ## 0.5.1 — Unreleased +- Improve conversation and template skill selection with a shared Active skills / Add skills + editor, name and source filters, locked inherited groups, and preserved explicit selections. + - Clarify the Slack Skills categories and where each is configured. Org Skills explicitly states that only admins can add or remove organization skills; combined lists are labelled as automatic summaries. diff --git a/FEATURES.md b/FEATURES.md index 980ffd8..11aa308 100644 --- a/FEATURES.md +++ b/FEATURES.md @@ -149,7 +149,12 @@ A categorized catalog of what's shipped. Cross-linked to `TEST-PLAN.md` checks. - **Focused conversation settings:** Access presents Read-only, Worker, and Admin as the base modes, with Auto and Lean as independent options; Custom is no longer offered. MCP Connections, Cloud MCP, Environment tokens, and Skills are separate first-class - channel pages rather than nested beneath Tools; enabled skills sort first and the channel-level + channel pages rather than nested beneath Tools. Conversation Skills and Skills → Templates share + an Active skills / Add skills editor with name/description search and source filtering. + Conversations separate locked organization, template and repository-section grants from removable + additional skills; inherited skills cannot be added twice. Template changes preserve explicit + additions, unavailable saved grants remain visible, and failed assignment loads preserve saved + grants on unrelated saves. Filters never alter selections or mark a conversation dirty. The channel-level Grant Tier switch is gone. - **Slack settings for authorized users:** replies requested by anyone allowed to use the agent add a requester-bound **⚙️ Settings** footer button. Its Block Kit console mirrors the web diff --git a/TEST-PLAN.md b/TEST-PLAN.md index 7a1fc4d..94cc5d7 100644 --- a/TEST-PLAN.md +++ b/TEST-PLAN.md @@ -4887,3 +4887,29 @@ acceptance gates; no production restart or external message was performed by the PR and approval before promotion. Do not execute a real promotion to test this policy. These agent scenarios remain manual acceptance cases until actual per-engine evidence is attached; documentation inspection alone is not a live pass. + +### Skill assignment editor — conversations and templates (2026-09-08) + +- Automated model: `node --test test/skill-assignment-picker.test.js` proves inherited/explicit + deduplication, preservation of explicit overlap after a template switch, unavailable saved + selections, and exclusion of personal, disabled and unapproved skills from Add skills. +- Browser: `CG_BROWSER_MODULE=/absolute/path/to/playwright/index.mjs node --test + test/skill-assignment-browser.test.js` runs the real admin UI and API on a disposable database. + Set `CG_UI_SCREENSHOTS` to an existing artifact directory to retain screenshots. +- Fixture: two folder sources, an organization skill, a template skill also granted explicitly, + one additional skill, one unavailable saved grant, and an available skill from the second source. + Use a disposable channel and template; no production grants or personal skill content. +- Action: open Conversations → the fixture → Skills. Inspect Organization skills, Template skills, + and Additional skills. Filter by source and name, add/remove a skill, switch the template to none, + save and reload. Edit the fixture under Skills → Templates; change its name/description, filter + skills by source/name, add/remove, save and reopen. Repeat at desktop and 390px widths. Simulate + failed assignment metadata loading and save an unrelated conversation field. +- Pass: inherited skills are locked and absent from Add skills; overlapping skills appear once; + filters affect both lists without dirtying the conversation or modifying selection; template + switching retains existing explicit overlap; unavailable saved grants survive; template text + edits survive selection changes; exact saved grants return after reload; failed metadata loads + omit skill/template changes from unrelated saves; controls fit narrow screens with no uncaught + browser errors. Automatically included repository-section skills use a separate locked group. +- Engine-independent: this change renders and saves existing admin configuration contracts; no + engine executes during the browser cases. Existing dual-engine grant/materialization release + gates remain in Skills platform and Real project skill synchronization above. diff --git a/public/admin-skills.js b/public/admin-skills.js index 924560a..4fa99cd 100644 --- a/public/admin-skills.js +++ b/public/admin-skills.js @@ -5,6 +5,7 @@ import { api } from "./admin-api.js"; import { confirmDialog, escapeHtml as esc } from "./admin-view.js"; import { filterSkillCatalog } from "./skills-catalog-filters.js"; +import { mountSkillAssignmentPicker } from "./skill-assignment-picker.js"; const state = { tab: "usage", @@ -29,7 +30,6 @@ const state = { newSkill: false, sourceModal: false, editTemplate: null, - templateSkillQuery: "", usageChannel: "", usageDays: 30, usage: null, @@ -135,6 +135,13 @@ function render() { const status = state.error ? `

${esc(state.error)}

` : state.message ? `

${esc(state.message)}

` : ""; const panel = { catalog: renderCatalog, review: renderReview, sources: renderSources, sync: renderSyncSettings, mcp: renderMcp, templates: renderTemplates, usage: renderUsage }[state.tab] || renderCatalog; body().innerHTML = status + panel(); + const picker = document.getElementById("template-skills-picker"); + if (picker && state.editTemplate) mountSkillAssignmentPicker(picker, { + skills: state.catalogAll?.skills || [], sources: state.catalogAll?.sources || [], + selected: state.editTemplate.skills || [], selectedLabel: "Template skills", + activeNote: "Skills selected for this template. Save to update conversations that follow it.", + onChange: (skills) => { state.editTemplate.skills = skills; }, + }); } function renderSummary() { @@ -421,13 +428,7 @@ function renderMcp() { function renderTemplates() { const templates = state.overview?.templates || []; - const channels = state.profiles || []; const e = state.editTemplate; - const skills = (state.catalogAll?.skills || []).slice().sort((a, b) => a.name.localeCompare(b.name)); - const selected = new Set(e?.skills || []); - const selectedChips = [...selected].sort().map((slug) => `${esc(slug)}`).join(""); - const pickerQuery = state.templateSkillQuery.trim().toLowerCase(); - const skillOptions = skills.filter((s) => !pickerQuery || `${s.name} ${s.slug} ${s.description}`.toLowerCase().includes(pickerQuery)).map((s) => ``).join(""); const form = e ? `

${e.isNew ? "New template" : `Edit ${esc(e.name)}`}

@@ -436,11 +437,7 @@ function renderTemplates() {
-
- -
${selectedChips || 'No explicit skills selected.'}
-
${skillOptions || 'No skills in the catalog.'}
-
+
` : ""; return ` @@ -732,19 +729,10 @@ async function act(action, el) { } case "new-template": state.editTemplate = { isNew: true, name: "", slug: "", description: "", categories: [], skills: [] }; - state.templateSkillQuery = ""; break; case "edit-template": state.editTemplate = { ...(state.overview.templates.find((t) => t.slug === slug) || {}), isNew: false }; - state.templateSkillQuery = ""; break; - case "toggle-template-skill": { - rememberTemplateDraft(); - const selected = new Set(state.editTemplate?.skills || []); - if (selected.has(slug)) selected.delete(slug); else selected.add(slug); - state.editTemplate.skills = [...selected]; - break; - } case "cancel-template": state.editTemplate = null; break; @@ -850,7 +838,6 @@ function wire() { if (el.id === "template-select") { const template = state.overview?.templates?.find((t) => t.slug === el.value); state.editTemplate = template ? { ...template, isNew: false } : null; - state.templateSkillQuery = ""; render(); return; } @@ -879,10 +866,5 @@ function wire() { document.getElementById("usage-q")?.focus(); return; } - if (event.target.id === "template-skill-q") { - state.templateSkillQuery = event.target.value; - const q = event.target.value.trim().toLowerCase(); - for (const row of root.querySelectorAll(".skills-picker-row")) row.hidden = Boolean(q) && !row.dataset.search.includes(q); - } }); } diff --git a/public/app.js b/public/app.js index c5edd33..c12b5c0 100644 --- a/public/app.js +++ b/public/app.js @@ -17,6 +17,7 @@ import { activeSectionFor, filterSettings } from "./admin-settings-search.js"; import { api } from "./admin-api.js"; import { attachReveal, confirmDialog, escapeHtml, infoDialog, openDialog, paintReveal, passwordDialog, revealSecret, tokenValue } from "./admin-view.js"; import { loadSkills } from "./admin-skills.js"; +import { mountSkillAssignmentPicker } from "./skill-assignment-picker.js"; import { mountUserPicker } from "./admin-user-picker.js"; import { describeEvent, eventLabel, isAdminEvent } from "./admin-events.js"; @@ -1427,7 +1428,7 @@ function renderChannelDetail(ch) { const onEdit = (e) => { // Exempt: the two file editors, the savebar buttons, the tools filter boxes (filtering the // checklists is a view action, not a config change), and the self-saving controls above. - if (e.target.closest(`[data-pane="instructions"], [data-pane="memory"], .detail-savebar, .checks-filter, ${SELF_SAVING_CONTROLS}`)) return; + if (e.target.closest(`[data-pane="instructions"], [data-pane="memory"], .detail-savebar, .checks-filter, .skill-assignment-filters, ${SELF_SAVING_CONTROLS}`)) return; markDirty(); }; card.addEventListener("input", onEdit); @@ -1458,16 +1459,45 @@ function renderChannelDetail(ch) { const mcpsBox = card.querySelector(".ch-mcps"); const mcpsCount = card.querySelector(".ch-mcps-count"); const skillsBox = card.querySelector(".ch-skills"); - skillsBox.dataset.kind = "s"; + let skillsPicker = null; + const templateSelect = card.querySelector(".ch-skill-template"); + templateSelect.disabled = true; + skillsBox.textContent = "Loading skill assignments…"; initializeMcpBox(mcpsBox, { claude: meta.allowedMcps || [], codex: meta.allowedCodexMcps || [], }); - checkboxList(skillsBox, accessGrantSkillOptions(SKILLS, meta.skills || []), meta.skills || []); wireChecksTools(mcpsBox, card.querySelector(".ch-mcps-filter"), mcpsCount); mcpsBox.addEventListener("change", () => captureMcpSelection(mcpsBox)); - wireChecksTools(skillsBox, card.querySelector(".ch-skills-filter"), card.querySelector(".ch-skills-count")); - fillSkillTemplateSelect(card.querySelector(".ch-skill-template"), meta.skillTemplate || "", card.querySelector(".ch-skill-template-state")); + // Read current assignments when opening the conversation, including edits made in Skills + // during this SPA session. Failed metadata reads must never clear saved grants on Save. + Promise.all([api("/api/skills/catalog?deleted=1"), api("/api/skills/overview")]).then(([catalog, overview]) => { + if (!skillsBox.isConnected) return; + SKILL_TEMPLATES = overview.templates || []; + fillSkillTemplateSelect(templateSelect, meta.skillTemplate || "", card.querySelector(".ch-skill-template-state")); + if (meta.skillTemplate && !SKILL_TEMPLATES.some((t) => t.slug === meta.skillTemplate)) { + const missing = document.createElement("option"); + missing.textContent = `${meta.skillTemplate} (unavailable)`; + missing.value = meta.skillTemplate; + templateSelect.add(missing); + templateSelect.value = meta.skillTemplate; + } + const inherited = () => { + const template = SKILL_TEMPLATES.find((t) => t.slug === templateSelect.value); + const section = catalog.skills.filter((s) => s.channelScope === ch.channelId && !s.deleted && s.visibility !== "personal").map((s) => s.slug); + return [ + { id: "organization", label: "Organization skills", slugs: overview.orgSkills || [], note: "Managed organization-wide." }, + { id: "template", label: "Template skills", slugs: template?.resolved || [], note: template ? `Included by ${template.name}. Edit the template in Skills → Templates.` : "Choose a template above to include its skills." }, + ...(section.length ? [{ id: "section", label: "Conversation skills", slugs: section, note: "Included from this conversation’s repository section." }] : []), + ]; + }; + skillsPicker = mountSkillAssignmentPicker(skillsBox, { skills: catalog.skills, sources: catalog.sources, selected: meta.skills || [], inherited: inherited(), onChange: markDirty }); + templateSelect.disabled = false; + templateSelect.addEventListener("change", () => skillsPicker.update({ inherited: inherited() })); + }).catch((error) => { + if (!skillsBox.isConnected) return; + skillsBox.textContent = `Skill assignments unavailable. Saved skills and template will be preserved. Reopen this conversation to retry. ${error.message}`; + }); // The base picker owns admin/shell flags; Auto and Lean are independent controls. const flagEls = { @@ -1772,8 +1802,13 @@ function renderChannelDetail(ch) { .map((s) => ({ name: s.name, match: s.match, namespace: s.namespace })), allowedCodexMcps: selectedMcpEntries(mcpsBox, "codex") .map((s) => ({ id: s.id, name: s.name, kind: s.kind, serverName: s.serverName, ...(s.toolPrefix ? { toolPrefix: s.toolPrefix } : {}) })), - skills: checkedValues(card.querySelector(".ch-skills")), - skillTemplate: card.querySelector(".ch-skill-template").value, + ...(skillsPicker ? { + skills: skillsPicker.getSelected(), + // A deleted template may still be referenced by the saved conversation. Preserve + // that reference on unrelated saves; the API rejects reassigning a missing template. + ...(!templateSelect.value || SKILL_TEMPLATES.some((t) => t.slug === templateSelect.value) + ? { skillTemplate: templateSelect.value } : {}), + } : {}), profile: card.querySelector(".ch-profile").value, access: card.querySelector(".ch-access").value, manageAccess: card.querySelector(".ch-manage").value, @@ -1803,6 +1838,7 @@ function renderChannelDetail(ch) { // The server response is the validated, committed record. Reconcile the cached channel from // that whole record so a later SPA re-render cannot resurrect stale MCP/skill selections. ch.meta = reconcileChannelMeta(ch.meta, result.meta); + skillsPicker?.update({ selected: ch.meta.skills || [] }); const acceptedGuests = channelGuestAcceptedIds( usersBox.dataset.ready === "1", ch.meta.allowedUsers, diff --git a/public/index.html b/public/index.html index 4a50066..46c822c 100644 --- a/public/index.html +++ b/public/index.html @@ -1180,13 +1180,11 @@

Environment secrets

-

Additional skills

- -
+
diff --git a/public/skill-assignment-picker.js b/public/skill-assignment-picker.js new file mode 100644 index 0000000..a8c9045 --- /dev/null +++ b/public/skill-assignment-picker.js @@ -0,0 +1,88 @@ +// Shared conversation/template editor. Explicit selections remain separate from inherited +// grants: changing a template must never silently rewrite the conversation's own saved list. +import { escapeHtml as esc } from "./admin-view.js"; + +const key = (slug) => String(slug).toLowerCase(); +const unique = (slugs) => [...new Map(slugs.map((slug) => [key(slug), slug])).values()]; +const sourceKey = (skill) => skill.sourceId != null ? `source:${skill.sourceId}` : `owner:${skill.ownerKind || "local"}`; + +export function assignmentGroups({ skills = [], selected = [], inherited = [] }) { + const catalog = new Map(skills.map((skill) => [key(skill.slug), skill])); + const seen = new Set(); + const rows = (slugs) => unique(slugs).filter((slug) => { + if (seen.has(key(slug))) return false; + seen.add(key(slug)); + return true; + }).map((slug) => catalog.get(key(slug)) || { slug, name: slug, unavailable: true }); + const groups = inherited.map((group) => ({ ...group, locked: true, skills: rows(group.slugs || []) })); + const own = rows(selected); + const available = skills.filter((skill) => !seen.has(key(skill.slug)) && !skill.deleted && skill.enabled !== false && !skill.unavailable && skill.visibility !== "personal" && (skill.currentRevisionId != null || skill.pinnedRevisionId != null)); + const sort = (items) => items.sort((a, b) => (a.name || a.slug).localeCompare(b.name || b.slug)); + return { groups: groups.map((g) => ({ ...g, skills: sort(g.skills) })), own: sort(own), available: sort(available) }; +} + +export function mountSkillAssignmentPicker(root, options) { + let config = { skills: [], sources: [], selected: [], inherited: [], selectedLabel: "Additional skills", ...options }; + let selected = unique(config.selected); + root.classList.add("skill-assignment"); + root.innerHTML = `
+ + +

Active skills

+

Add skills

Search the catalog and add skills to your selection.

`; + const query = root.querySelector("[data-picker-query]"); + const source = root.querySelector("[data-picker-source]"); + const sourceLabel = (skill) => config.sources.find((s) => String(s.id) === String(skill.sourceId))?.label + || config.sources.find((s) => String(s.id) === String(skill.sourceId))?.url + || ({ local: "Local skills", bundled: "Built-in skills", folder: "Host folders" }[skill.ownerKind]) + || (skill.sourceId != null ? `Source #${skill.sourceId}` : "Local skills"); + function paintSources() { + const previous = source.value; + const sources = new Map(config.skills.map((skill) => [sourceKey(skill), sourceLabel(skill)])); + source.innerHTML = '' + [...sources].sort((a, b) => a[1].localeCompare(b[1])).map(([value, label]) => ``).join(""); + if (sources.has(previous)) source.value = previous; + } + function paint() { + const model = assignmentGroups({ ...config, selected }); + const q = query.value.trim().toLowerCase(); + const matches = (s) => (!q || `${s.name || ""} ${s.slug} ${s.description || ""}`.toLowerCase().includes(q)) && (!source.value || sourceKey(s) === source.value); + const row = (s, action) => `
${esc(s.name || s.slug)}${s.name && s.name !== s.slug ? `${esc(s.slug)}` : ""}${esc(s.unavailable ? "Not in the current catalog · saved selection kept" : s.deleted || s.enabled === false ? "Disabled · saved selection kept" : s.currentRevisionId == null && s.pinnedRevisionId == null ? "Awaiting approval" : sourceLabel(s))}${s.description ? `

${esc(s.description)}

` : ""}
${action ? `` : 'Included'}
`; + const group = (g, action) => { + const visible = g.skills.filter(matches); + return `

${esc(g.label)} ${visible.length === g.skills.length ? g.skills.length : `${visible.length} / ${g.skills.length}`}${g.locked ? ' Locked' : ""}

${g.note ? `

${esc(g.note)}

` : ""}
${visible.map((s) => row(s, action)).join("") || `

${g.skills.length ? "No matching active skills." : "No skills in this group."}

`}
`; + }; + root.querySelector("[data-active-count]").textContent = model.own.length + model.groups.reduce((n, g) => n + g.skills.length, 0); + root.querySelector("[data-active-note]").textContent = config.activeNote || "Inherited skills are included automatically. Only additional skills can be removed here."; + root.querySelector("[data-picker-active]").innerHTML = model.groups.map((g) => group(g, null)).join("") + group({ label: config.selectedLabel, skills: model.own }, "remove"); + const available = model.available.filter(matches); + root.querySelector("[data-available-count]").textContent = `${available.length} / ${model.available.length}`; + root.querySelector("[data-picker-available]").innerHTML = `
${available.map((s) => row(s, "add")).join("") || `

${model.available.length ? "No skills match these filters." : "All available skills are already included."}

`}
`; + } + root.addEventListener("input", (event) => { if (event.target === query) paint(); }); + root.addEventListener("change", (event) => { if (event.target === source) paint(); }); + root.addEventListener("click", (event) => { + const button = event.target.closest("[data-picker-action]"); + if (!button || !root.contains(button)) return; + const slug = button.dataset.slug; + const model = assignmentGroups({ ...config, selected }); + const isAdd = button.dataset.pickerAction === "add"; + if (!(isAdd ? model.available : model.own).some((s) => key(s.slug) === key(slug))) return; + selected = isAdd ? unique([...selected, slug]) : selected.filter((s) => key(s) !== key(slug)); + config.onChange?.([...selected]); + paint(); + root.querySelector(".skill-assignment-status").textContent = `${isAdd ? "Added" : "Removed"} ${slug}. Save to apply changes.`; + // The clicked button moves between lists. Keep keyboard focus on that skill's new action. + [...root.querySelectorAll("[data-picker-action]")].find((b) => b.dataset.slug === slug)?.focus({ preventScroll: true }); + }); + paintSources(); + paint(); + return { + getSelected: () => [...selected], + update(patch) { + config = { ...config, ...patch }; + if (patch.selected) selected = unique(patch.selected); + if (patch.skills || patch.sources) paintSources(); + paint(); + }, + }; +} diff --git a/public/styles.css b/public/styles.css index 980cd89..79cc6d0 100644 --- a/public/styles.css +++ b/public/styles.css @@ -981,20 +981,38 @@ button.clear-tok.armed { background: rgba(229, 96, 77, .14); border-color: rgba( .skills-category-picker { margin: 0; padding: 10px 12px; border: 1px solid var(--line-soft); border-radius: 8px; } .skills-category-picker legend { color: var(--muted); font-size: 12px; padding: 0 5px; } .skills-category-picker label { display: inline-flex; align-items: center; gap: 5px; margin: 3px 14px 3px 0; font-size: 12px; } -.skills-picker { margin-top: 12px; } -.skills-picker .field { max-width: 520px; } -.skills-picker-selected { display: flex; flex-wrap: wrap; gap: 6px; min-height: 30px; margin: 8px 0; } -.skills-picker-chip { display: inline-flex; align-items: center; gap: 5px; padding: 4px 8px; border: 1px solid rgba(145, 201, 206, .3); border-radius: 999px; background: rgba(49, 123, 128, .2); } -.skills-picker-chip button { border: 0; padding: 0 2px; background: none; color: var(--muted); } -.skills-picker-results { display: grid; grid-template-columns: repeat(auto-fill, minmax(250px, 1fr)); gap: 6px; max-height: 260px; padding: 8px; overflow: auto; border: 1px solid var(--line-soft); border-radius: 8px; background: var(--panel-dark); } -.skills-picker-row { display: flex; align-items: center; justify-content: space-between; gap: 10px; padding: 8px 10px; text-align: left; background: var(--panel); border: 1px solid var(--line-soft); } -/* The template skill search hides non-matching rows with `row.hidden = true`; without this - the display above would keep every row on screen and the search would do nothing. */ -.skills-picker-row[hidden] { display: none; } -.skills-picker-row span:first-child { min-width: 0; } -.skills-picker-row strong, .skills-picker-row code { display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } -.skills-picker-row code { margin-top: 2px; color: var(--faint); font-size: 10.5px; } -.skills-picker-row.selected { border-color: var(--orange); background: var(--orange-soft); } +/* One assignment editor for conversations and skill templates. Filters never own selection. */ +.skill-assignment { margin-top: 18px; min-width: 0; } +.skill-assignment-filters { display: grid; grid-template-columns: minmax(0, 2fr) minmax(0, 1fr); gap: 12px; margin-bottom: 18px; } +.skill-assignment-filters .field { min-width: 0; margin: 0; } +.skill-assignment-filters input, .skill-assignment-filters select { width: 100%; min-width: 0; } +.skill-assignment-columns { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); gap: 18px; align-items: start; } +.skill-assignment-panel { min-width: 0; background: var(--panel-dark); border: 1px solid var(--line-soft); border-radius: 12px; padding: 16px; } +.skill-assignment-panel > h3 { font-size: 15px; margin: 0 0 6px; } +.skill-assignment-panel h3 > span, .skill-assignment-group h4 > span { font-size: 11px; font-weight: 500; color: var(--muted); } +.skill-assignment-group { margin-top: 18px; } +.skill-assignment-group h4 { display: flex; align-items: center; flex-wrap: wrap; gap: 8px; margin: 0 0 6px; font-size: 12px; } +.skill-assignment-list { max-height: 320px; overflow-y: auto; scrollbar-gutter: stable; } +.skill-assignment-catalog { max-height: 620px; margin-top: 16px; } +.skill-assignment-row { display: flex; align-items: center; gap: 12px; padding: 12px 0; border-bottom: 1px solid var(--line-soft); } +.skill-assignment-row:last-child { border-bottom: 0; } +.skill-assignment-row > div { flex: 1; min-width: 0; } +.skill-assignment-row strong, .skill-assignment-row code { display: block; overflow-wrap: anywhere; font-size: 12px; } +.skill-assignment-row code { color: var(--muted); font-size: 11px; margin-top: 4px; } +.skill-assignment-row p { color: var(--muted); font-size: 12px; line-height: 1.5; margin: 6px 0 0; overflow-wrap: anywhere; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; } +.skill-assignment-source { display: block; color: var(--faint); font-size: 11px; margin-top: 4px; overflow-wrap: anywhere; } +.skill-assignment-row > button { flex-shrink: 0; padding: 6px 10px; font-size: 12px; } +.skill-assignment-locked { color: var(--faint); font-size: 11px; white-space: nowrap; } +.skill-assignment-row > button:focus-visible { outline: 2px solid var(--orange); outline-offset: 2px; } +.skill-assignment-empty { color: var(--faint); font-size: 12px; padding: 14px 0; margin: 0; } +.skill-assignment-status { min-height: 18px; font-size: 12px; color: var(--muted); margin: 10px 0 0; } +@media (max-width: 1000px) { .skill-assignment-columns { grid-template-columns: minmax(0, 1fr); } } +@media (max-width: 540px) { + .skill-assignment-filters { grid-template-columns: minmax(0, 1fr); } + .skill-assignment-panel { padding: 12px; } + .skills-tabs { overflow-x: auto; } + .skills-tab { white-space: nowrap; } +} .skills-segmented { display: inline-flex; border: 1px solid var(--line); border-radius: 8px; overflow: hidden; } .skills-segmented button { border: 0; border-radius: 0; background: var(--panel-2); color: var(--muted); } .skills-segmented button.active { background: var(--orange); color: white; } diff --git a/test/channel-env.test.js b/test/channel-env.test.js index 6272f3b..04b6ac1 100644 --- a/test/channel-env.test.js +++ b/test/channel-env.test.js @@ -72,7 +72,7 @@ test("the env card is exempt from the conversation card's unsaved-changes tracki const client = readFileSync(new URL("../public/app.js", import.meta.url), "utf8"); assert.match(client, /const SELF_SAVING_CONTROLS = "\.channel-env-card";/); // Each of the three dirty-trackers (conversation card, DM/template card, Settings page) exempts it. - assert.match(client, /\[data-pane="instructions"\], \[data-pane="memory"\], \.detail-savebar, \.checks-filter, \$\{SELF_SAVING_CONTROLS\}/); + assert.match(client, /\[data-pane="instructions"\], \[data-pane="memory"\], \.detail-savebar, \.checks-filter, \.skill-assignment-filters, \$\{SELF_SAVING_CONTROLS\}/); assert.match(client, /\.detail-savebar, \.checks-filter, \$\{SELF_SAVING_CONTROLS\}`\)\) mark\(\)/); assert.match(client, /\.settings-savebar, \.checks-filter, \.setbar, \$\{SELF_SAVING_CONTROLS\}/); const html = readFileSync(new URL("../public/index.html", import.meta.url), "utf8"); diff --git a/test/channel-workdir-ui.test.js b/test/channel-workdir-ui.test.js index cf92a26..0c09ebe 100644 --- a/test/channel-workdir-ui.test.js +++ b/test/channel-workdir-ui.test.js @@ -56,7 +56,7 @@ function fixture({ workDir = "/home/operator/project", saveError } = {}) { const calls = []; const context = { card, ch, meta: ch.meta, Event, detailDirty: false, SELF_SAVING_CONTROLS: ".channel-env-card", - engineSelect: { value: "codex" }, usersBox: { dataset: { ready: "" } }, mcpsBox: {}, + engineSelect: { value: "codex" }, usersBox: { dataset: { ready: "" } }, mcpsBox: {}, skillsPicker: null, makeToolboxKeyInput: control(".ch-make-toolbox-key"), makeToolboxUrlInput: control(".ch-make-toolbox-url"), makeToolboxState: new Control(), clearMakeToolbox: false, diff --git a/test/skill-assignment-browser.test.js b/test/skill-assignment-browser.test.js new file mode 100644 index 0000000..8305ead --- /dev/null +++ b/test/skill-assignment-browser.test.js @@ -0,0 +1,178 @@ +// Real admin UI/API acceptance against disposable folder sources and a conversation. +// CG_BROWSER_MODULE=/usr/local/lib/node_modules/playwright/index.mjs node --test test/skill-assignment-browser.test.js +import test from "node:test"; +import assert from "node:assert/strict"; +import { mkdir, writeFile } from "node:fs/promises"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import express from "express"; +import { ensureTestEnv, tempDir } from "./helpers.js"; + +ensureTestEnv(); + +test("conversation and template skill pickers preserve grants, drafts, filtering and narrow layouts", { skip: !process.env.CG_BROWSER_MODULE }, async (t) => { + const { chromium } = await import(process.env.CG_BROWSER_MODULE); + const { createAdminRouter } = await import("../src/web/routes/admin.js"); + const { upsertChannelEntry, defaultChannelMeta, saveChannelMeta, getChannelMeta } = await import("../src/config/store.js"); + const { getTemplate, upsertTemplate } = await import("../src/gateway/skills/catalog.js"); + const { saveSettings } = await import("../src/config/settings.js"); + const app = express(); + app.use(express.json()); + app.get("/api/mcp/available", (_req, res) => res.json({ servers: [] })); + app.get("/api/health", (_req, res) => res.json({ slack: { connected: false, status: "off" }, engines: {} })); + app.use("/api", createAdminRouter({ slack: { snapshot: () => ({ status: "disconnected", connected: false }) } })); + const publicDir = fileURLToPath(new URL("../public", import.meta.url)); + app.use(express.static(publicDir, { dotfiles: "allow" })); + app.get(["/conversations/channel/C_SKILL_PICKER", "/skills"], (_req, res) => res.sendFile(path.join(publicDir, "index.html"), { dotfiles: "allow" })); + const server = await new Promise((resolve) => { const s = app.listen(0, "127.0.0.1", () => resolve(s)); }); + t.after(() => new Promise((resolve) => { server.closeAllConnections(); server.close(resolve); })); + const base = `http://127.0.0.1:${server.address().port}`; + const sourceIds = []; + for (const [label, slugs] of [ + ["Alpha assignment source", ["picker-org", "picker-template", "picker-shared", "picker-own"]], + ["Beta assignment source", ["picker-available", "picker-other"]], + ]) { + const dir = tempDir("cg-assignment-browser-"); + for (const slug of slugs) { + await mkdir(path.join(dir, slug)); + await writeFile(path.join(dir, slug, "SKILL.md"), `---\nname: ${slug}\ndescription: ${slug} browser acceptance fixture.\n---\n\n# ${slug}\n`); + } + const response = await fetch(`${base}/api/skills/sources`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ kind: "folder", url: dir, label, mode: "auto" }) }); + assert.equal(response.status, 201); + sourceIds.push((await response.json()).source.id); + } + saveSettings({ accessGrants: { skills: ["picker-org"] } }); + upsertTemplate({ slug: "picker-development", name: "Picker Development", skills: ["picker-template", "picker-shared"] }); + upsertTemplate({ slug: "picker-empty", name: "Picker Empty", skills: [] }); + const channelId = "C_SKILL_PICKER"; + const channel = await upsertChannelEntry(channelId, { name: "skill-picker-browser", type: "channel", isDM: false }); + const initialOwn = ["picker-shared", "picker-own", "picker-unavailable"]; + await saveChannelMeta(channel.slug, { ...defaultChannelMeta({ channelId, name: channel.name, type: "channel", isDM: false }), skillTemplate: "picker-development", skills: initialOwn }); + const browser = await chromium.launch({ headless: true, args: ["--no-sandbox"] }); + t.after(() => browser.close()); + const page = await browser.newPage({ viewport: { width: 1600, height: 1050 } }); + page.setDefaultTimeout(15000); + const errors = []; + page.on("pageerror", (error) => errors.push(error.message)); + const picker = page.locator("#channel-detail .ch-skills"); + const savebar = page.locator("#channel-detail .detail-savebar"); + const openChannel = async () => { + await page.goto(`${base}/conversations/channel/${channelId}`); + await page.locator('#channel-detail .subtab[data-pane="skills"]').click(); + await picker.locator("[data-picker-active]").waitFor(); + }; + const saveChannel = async () => { + const saved = page.waitForResponse((response) => response.url().endsWith(`/channels/${channelId}/meta`) && response.request().method() === "PUT"); + await savebar.locator(".save-channel").click(); + const response = await saved; + assert.equal(response.status(), 200, await response.text()); + await page.waitForFunction(() => globalThis.document.querySelector("#channel-detail .detail-savebar")?.hidden); + }; + const screenshot = async (name) => { + if (!process.env.CG_UI_SCREENSHOTS) return; + await mkdir(process.env.CG_UI_SCREENSHOTS, { recursive: true }); + await page.screenshot({ path: path.join(process.env.CG_UI_SCREENSHOTS, name), fullPage: true }); + }; + await openChannel(); + for (const slug of ["picker-org", "picker-template", "picker-shared"]) { + assert.equal(await picker.locator(`[data-picker-active] [data-skill="${slug}"]`).count(), 1); + assert.equal(await picker.locator(`[data-picker-action][data-slug="${slug}"]`).count(), 0, `${slug} is inherited and cannot be added or removed`); + } + assert.equal(await picker.getByRole("button", { name: "Remove picker-unavailable", exact: true }).count(), 1); + await picker.locator("[data-picker-source]").selectOption(`source:${sourceIds[1]}`); + assert.equal(await picker.locator("[data-picker-active] [data-skill]").count(), 0, "source filter also filters active skills"); + assert.equal(await picker.locator("[data-picker-available] [data-skill]").count(), 2); + await picker.locator("[data-picker-query]").fill("available"); + assert.equal(await picker.locator("[data-picker-available] [data-skill]").count(), 1); + assert.equal(await savebar.isHidden(), true, "filtering alone never marks the conversation dirty"); + await picker.getByRole("button", { name: "Add picker-available", exact: true }).click(); + assert.equal(await savebar.isVisible(), true); + await picker.locator("[data-picker-query]").fill(""); + await picker.locator("[data-picker-source]").selectOption(""); + await picker.getByRole("button", { name: "Remove picker-own", exact: true }).click(); + await page.locator(".ch-skill-template").selectOption("picker-empty"); + assert.equal(await picker.getByRole("button", { name: "Remove picker-shared", exact: true }).count(), 1, "explicit overlap becomes removable after switching away"); + await page.locator(".ch-skill-template").selectOption("picker-development"); + assert.equal(await picker.getByRole("button", { name: "Remove picker-shared", exact: true }).count(), 0); + await saveChannel(); + const expectedOwn = ["picker-available", "picker-shared", "picker-unavailable"]; + assert.deepEqual((await getChannelMeta(channel.slug)).skills.sort(), expectedOwn); + await openChannel(); + await screenshot("skill-assignment-desktop.png"); + await page.locator(".ch-skill-template").selectOption("picker-empty"); + assert.equal(await picker.getByRole("button", { name: "Remove picker-shared", exact: true }).count(), 1, "explicit overlapping grant survives save and reload"); + await saveChannel(); + await openChannel(); + assert.equal(await page.locator(".ch-skill-template").inputValue(), "picker-empty"); + await page.setViewportSize({ width: 390, height: 844 }); + assert.equal(await picker.evaluate((el) => el.scrollWidth <= el.clientWidth), true, "conversation picker fits a narrow screen"); + await screenshot("skill-assignment-mobile.png"); + + // An unavailable metadata read must not overwrite grants during an unrelated change. + await page.route("**/api/skills/catalog?deleted=1", (route) => route.fulfill({ status: 503, contentType: "application/json", body: JSON.stringify({ error: "fixture catalog outage" }) })); + await page.reload(); + await page.locator('#channel-detail .subtab[data-pane="skills"]').click(); + await picker.getByText(/Skill assignments unavailable/).waitFor(); + await page.locator('#channel-detail .subtab[data-pane="runtime"]').click(); + await page.locator(".ch-syncdrive").fill("https://drive.google.com/drive/folders/browser-fixture"); + await saveChannel(); + assert.deepEqual((await getChannelMeta(channel.slug)).skills.sort(), expectedOwn); + assert.equal((await getChannelMeta(channel.slug)).skillTemplate, "picker-empty"); + await page.unroute("**/api/skills/catalog?deleted=1"); + + // Deleting a template leaves existing references in stored channel metadata. The UI must + // preserve that unavailable link without resubmitting it as a new, invalid assignment. + await saveChannelMeta(channel.slug, { ...(await getChannelMeta(channel.slug)), skillTemplate: "picker-missing" }); + await openChannel(); + assert.equal(await page.locator(".ch-skill-template").inputValue(), "picker-missing"); + assert.match(await page.locator(".ch-skill-template option:checked").textContent(), /unavailable/); + await picker.getByRole("button", { name: "Add picker-own", exact: true }).click(); + await page.locator('#channel-detail .subtab[data-pane="runtime"]').click(); + await page.locator(".ch-syncdrive").fill("https://drive.google.com/drive/folders/missing-template-fixture"); + await saveChannel(); + assert.deepEqual((await getChannelMeta(channel.slug)).skills.sort(), [...expectedOwn, "picker-own"].sort()); + assert.equal((await getChannelMeta(channel.slug)).skillTemplate, "picker-missing"); + assert.match((await getChannelMeta(channel.slug)).syncDriveFolder, /missing-template-fixture$/); + await openChannel(); + assert.equal(await page.locator(".ch-skill-template").inputValue(), "picker-missing"); + await page.locator(".ch-skill-template").selectOption(""); + await saveChannel(); + assert.equal((await getChannelMeta(channel.slug)).skillTemplate, ""); + assert.deepEqual((await getChannelMeta(channel.slug)).skills.sort(), [...expectedOwn, "picker-own"].sort()); + + await page.setViewportSize({ width: 1600, height: 1050 }); + await page.goto(`${base}/skills`); + await page.locator("#skills-summary .stat").first().waitFor(); + await page.locator('.skills-tab[data-tab="templates"]').click(); + await page.locator("#template-select").selectOption("picker-development"); + const templatePicker = page.locator("#template-skills-picker"); + await templatePicker.locator("[data-picker-active]").waitFor(); + await page.locator("#tpl-name").fill("Updated picker development"); + await page.locator("#tpl-desc").fill("Draft description survives skill actions."); + await templatePicker.locator("[data-picker-source]").selectOption(`source:${sourceIds[1]}`); + await templatePicker.locator("[data-picker-query]").fill("available"); + await templatePicker.getByRole("button", { name: "Add picker-available", exact: true }).click(); + await templatePicker.locator("[data-picker-query]").fill(""); + await templatePicker.locator("[data-picker-source]").selectOption(`source:${sourceIds[0]}`); + await templatePicker.getByRole("button", { name: "Remove picker-template", exact: true }).click(); + assert.equal(await page.locator("#tpl-name").inputValue(), "Updated picker development"); + assert.equal(await page.locator("#tpl-desc").inputValue(), "Draft description survives skill actions."); + const templateSaved = page.waitForResponse((response) => response.url().endsWith("/api/skills/templates") && response.request().method() === "POST"); + await page.locator('[data-action="save-template"]').click(); + assert.equal((await templateSaved).status(), 200); + assert.deepEqual(getTemplate("picker-development").skills.sort(), ["picker-available", "picker-shared"]); + await page.reload(); + await page.locator("#skills-summary .stat").first().waitFor(); + await page.locator('.skills-tab[data-tab="templates"]').click(); + await page.locator("#template-select").selectOption("picker-development"); + await templatePicker.locator("[data-picker-active]").waitFor(); + assert.equal(await page.locator("#tpl-name").inputValue(), "Updated picker development"); + assert.equal(await page.locator("#tpl-desc").inputValue(), "Draft description survives skill actions."); + assert.equal(await templatePicker.locator("[data-picker-active] [data-skill]").count(), 2); + await screenshot("skill-template-desktop.png"); + await page.setViewportSize({ width: 390, height: 844 }); + assert.equal(await templatePicker.evaluate((el) => el.scrollWidth <= el.clientWidth), true, "template picker has no horizontal overflow"); + assert.equal(await page.evaluate(() => globalThis.document.documentElement.scrollWidth <= globalThis.innerWidth), true, "template page fits a narrow screen"); + await screenshot("skill-template-mobile.png"); + assert.deepEqual(errors, []); +}); diff --git a/test/skill-assignment-picker.test.js b/test/skill-assignment-picker.test.js new file mode 100644 index 0000000..6fd96ed --- /dev/null +++ b/test/skill-assignment-picker.test.js @@ -0,0 +1,28 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { assignmentGroups } from "../public/skill-assignment-picker.js"; + +const skill = (slug, extra = {}) => ({ slug, name: slug, enabled: true, currentRevisionId: 1, ...extra }); + +test("inherited assignments appear once and never become removable or addable", () => { + const selected = ["template", "own", "offline"]; + const result = assignmentGroups({ + skills: [skill("org"), skill("template"), skill("own"), skill("next")], selected, + inherited: [{ id: "org", slugs: ["org"] }, { id: "template", slugs: ["ORG", "template"] }], + }); + assert.deepEqual(result.groups.map((g) => g.skills.map((s) => s.slug)), [["org"], ["template"]]); + assert.ok(result.groups.every((g) => g.locked)); + assert.deepEqual(result.own.map((s) => s.slug), ["offline", "own"]); + assert.equal(result.own[0].unavailable, true); + assert.deepEqual(result.available.map((s) => s.slug), ["next"]); + assert.deepEqual(selected, ["template", "own", "offline"], "display deduplication never rewrites explicit grants"); + const switched = assignmentGroups({ skills: [skill("template"), skill("own")], selected }); + assert.ok(switched.own.some((s) => s.slug === "template"), "existing explicit overlap survives template removal"); +}); + +test("personal, disabled and unapproved catalog entries cannot be newly selected", () => { + const skills = [skill("personal", { visibility: "personal" }), skill("disabled", { enabled: false }), skill("deleted", { deleted: true }), skill("staged", { currentRevisionId: null }), skill("pinned", { currentRevisionId: null, pinnedRevisionId: 2 }), skill("normal")]; + const result = assignmentGroups({ skills, selected: ["disabled", "missing"] }); + assert.deepEqual(result.available.map((s) => s.slug), ["normal", "pinned"]); + assert.deepEqual(result.own.map((s) => s.slug), ["disabled", "missing"], "unavailable saved grants remain removable"); +}); diff --git a/test/skills-admin-ui.test.js b/test/skills-admin-ui.test.js index 0e5a295..6d61e14 100644 --- a/test/skills-admin-ui.test.js +++ b/test/skills-admin-ui.test.js @@ -26,7 +26,8 @@ test("Skills admin navigation separates sources, synchronization and MCP", () => test("templates and usage expose searchable selection and understandable views", () => { assert.match(js, /id="template-select"/); - assert.match(js, /id="template-skill-q"/); + assert.match(js, /mountSkillAssignmentPicker\(picker/); + assert.match(js, /id="template-skills-picker"/); assert.doesNotMatch(js, /Explicit skills \(comma-separated slugs\)/); assert.match(js, /data-view="skill"/); assert.match(js, /data-view="channel"/); @@ -63,12 +64,8 @@ test("catalog filters keep owner and expose governance plus assignment states", // An author `display` declaration beats the UA stylesheet's `[hidden] { display: none }` whatever // its specificity, so every class the admin JS hides by setting `.hidden` needs a companion rule. -// Without it the template skill search hid nothing (every row stayed on screen while filtering) and -// the source dialog showed the GitHub and ChannelGate fields at the same time. +// Without it the source dialog showed the GitHub and ChannelGate fields at the same time. test("what the admin JS hides with the hidden property is actually hidden", () => { - assert.match(js, /for \(const row of root\.querySelectorAll\("\.skills-picker-row"\)\) row\.hidden = /); - assert.match(css, /\.skills-picker-row \{[^}]*display: flex/); - assert.match(css, /\.skills-picker-row\[hidden\] \{ display: none; \}/); assert.match(js, /querySelectorAll\("\[data-source-kind\]"\)\) field\.hidden = /); assert.match(css, /\.field\[hidden\] \{ display: none; \}/); }); From 9fb7b3e4d6a500c923864965933645dabf1f4b5e Mon Sep 17 00:00:00 2001 From: Tiberiu Socaci Date: Tue, 8 Sep 2026 12:30:31 +0300 Subject: [PATCH 06/44] fix: make managed updates durable and Enterprise-only Signed-off-by: Tiberiu Socaci --- FEATURES.md | 11 ++- INSTALL.md | 16 +++- TEST-PLAN.md | 49 +++++++++++ public/app.js | 35 +++++--- scripts/check-update-service-survival.mjs | 58 +++++++++++++ scripts/update-launcher.mjs | 35 ++++++++ scripts/update-runner.mjs | 12 ++- src/ee/license.js | 3 +- src/ee/update-entitlement.js | 8 ++ .../references/administration.md | 6 +- src/gateway/update-state.js | 18 ++++ src/gateway/updater.js | 43 ++++++++-- src/mcp/tools/channel-admin.js | 2 +- src/web/app.js | 4 +- src/web/routes/settings.js | 6 +- test/mcp-control-plane-approval.test.js | 23 +++++- test/run-api.test.js | 18 +++- test/update-entitlement.test.js | 49 +++++++++++ test/update-launcher.test.js | 33 ++++++++ test/update-marker.test.js | 62 +++++++++++++- test/update-runner.test.js | 56 ++++++++++++- test/update-status.test.js | 27 ++++++ test/update-ui.test.js | 82 +++++++++++++++++++ 23 files changed, 620 insertions(+), 36 deletions(-) create mode 100644 scripts/check-update-service-survival.mjs create mode 100644 scripts/update-launcher.mjs create mode 100644 src/ee/update-entitlement.js create mode 100644 test/update-entitlement.test.js create mode 100644 test/update-launcher.test.js create mode 100644 test/update-status.test.js create mode 100644 test/update-ui.test.js diff --git a/FEATURES.md b/FEATURES.md index 11aa308..de8d201 100644 --- a/FEATURES.md +++ b/FEATURES.md @@ -2230,6 +2230,15 @@ are retired, bullet by bullet; everything else stands. from Settings → System (or `ADMIN_PASSWORD`). `/api/health` and the login routes stay open. - Daemon controls (Settings → System): **Restart daemon** (polls health, reloads) and Slack disconnect/reconnect. The old "Stop daemon" button was removed as a footgun. +- **Enterprise managed updates and durable progress**: only an entitled Enterprise deployment + exposes the Update button and accepts managed update requests (Admin UI, Slack, or MCP). + Other editions retain the behind count and host-managed `npm run update`. Eligibility follows + the existing verified-license and outage-grace rules. A separate systemd user service carries + the updater across daemon cgroup teardown; inherited credentials travel through stdin, not unit + properties or command arguments. Candidate and rollback installs explicitly include development + dependencies needed by static checks. The UI keeps polling after 15 minutes, shows reconnect + waits, requests login when restart clears the admin session, and retains the latest terminal result after refresh, and marks dead runners interrupted without + inventing a successful update or rollback. → TEST-PLAN: Managed update recovery and eligibility. - **Transactional self-update**: Slack `/update`, the `update_gateway` gateway tool, the Admin UI, and `npm run update` all reserve one stale-safe global transaction. A second caller gets the active transaction instead of starting an overlapping updater. Before touching Git, the runner @@ -2244,7 +2253,7 @@ are retired, bullet by bullet; everything else stands. channels; Claude and Codex share the same gateway MCP policy. - **Candidate validation and automatic rollback**: the updater snapshots the exact revision, lockfile, local config, `.env`, and a consistent SQLite copy under mode-0700 - `~/.channelgate/update-backups//`; fast-forwards, runs exact `npm ci`, the + `~/.channelgate/update-backups//`; fast-forwards, runs exact `npm ci --include=dev`, the production advisory gate, all tests, and optional provisioning; then restarts through the exact systemd `MainPID` (the launchd restart retired 2026-09-03 — Linux only). Success requires a new daemon instance on the expected revision, diff --git a/INSTALL.md b/INSTALL.md index b4bb2d7..9eb6450 100644 --- a/INSTALL.md +++ b/INSTALL.md @@ -106,7 +106,15 @@ The admin UI + health endpoint come up on (set `PORT` in ### Safe updates -Run `npm run update`, use the dashboard Update button, or ask an admin to use Slack `/update`. +Operators of any edition can update manually: sign in to the host as the service account, enter +its checkout, and run `npm run update`. Keep the deployment on its configured upstream branch +(`main` for stable installations). Enterprise additionally offers the dashboard Update button, +Slack `/update`, and `update_gateway`; other editions show the commit count behind and manual +update guidance, and managed update requests are refused by the server. + +Managed updates require the service account's systemd user manager and bus. They launch a +separate transient service so restarting the daemon cannot kill the updater. A missing user bus +is a reported launch failure; operators can still invoke the CLI manually from a host terminal. The updater requires an active `channelgate.service` under systemd (system or user scope) so it can prove both candidate and rollback restarts. @@ -116,13 +124,15 @@ staging requires 1 GiB free. If local Whisper is enabled but its 1.5 GiB model i calculated requirement becomes 3 GiB; disabling local Whisper makes the large optional download explicit and skips it. -The candidate runs `npm ci`, the production advisory gate, the full test suite, and provisioning +The candidate runs `npm ci --include=dev` (static checks need development tools even on production hosts), the production advisory gate, the full test suite, and provisioning before restart. It is accepted only after the replacement reports the expected revision, Claude is available, Slack reconnects when previously connected, and a second isolated Claude turn succeeds. A post-change failure automatically restores the prior revision and dependencies and verifies that restored build. See `~/.channelgate/logs/update.log` and `~/.channelgate/update-state.json`; operator recovery snapshots are saved under -`~/.channelgate/update-backups/`. +`~/.channelgate/update-backups/`. The dashboard keeps polling through long phases and reconnects, +asks for login if the restart cleared the admin session, retains the last result across refreshes, and reports a dead updater as interrupted with completion +and rollback unverified. Check the log and service before retrying an interrupted update. ## 4. Create the Slack app (from the manifest) diff --git a/TEST-PLAN.md b/TEST-PLAN.md index 94cc5d7..4e6eb98 100644 --- a/TEST-PLAN.md +++ b/TEST-PLAN.md @@ -4191,6 +4191,55 @@ Manual checks for the daemon-level behavior: - [ ] Live Claude + Codex: search for a discoverable skill, grant/revoke it in the channel, change the template, and confirm a mandatory skill materializes on the next turn in both harnesses. +### Managed update recovery and eligibility + +- [x] Automated: `test/update-entitlement.test.js`, `test/run-api.test.js`, and + `test/mcp-control-plane-approval.test.js` exercise valid Enterprise, free/no-key/expired + rejection, outage grace/revocation, and both Claude/Codex MCP contexts. Non-Enterprise managed + requests create no lock or runner; the authenticated HTTP mutation returns 403. +- [x] Automated: `test/update-ui.test.js` exercises the actual browser functions: 76 commits + behind shows a button only with explicit entitlement; other editions see manual guidance; + polling continues at 20 minutes and during connection loss; terminal errors survive refresh; + unreachable upstream does not claim up to date; restart session loss asks for login without + disclosing private health details or claiming completion. +- [x] Automated: `test/update-status.test.js` distinguishes live owners and reservation grace + from missing/dead owners; interruption preserves the durable candidate-error evidence and + never claims verified rollback. `test/update-marker.test.js` covers independent service launch + refusal, runner ownership after wrapper death, and truthful failure wording. +- [x] Automated: `test/update-runner.test.js` installs a real local development-package fixture + under `NODE_ENV=production` plus npm `omit=dev`, then imports it successfully. Failed service + reload keeps its retry marker. These cases are engine-independent (no engine invocation). +- [x] Live systemd survival (2026-09-08, engine-independent): run + `node scripts/check-update-service-survival.mjs` as a Linux user with a systemd user bus. + The script creates uniquely named disposable parent/child units and synthetic runner, then + stops both units. Observed: parent PID changed on restart, wrapper died, child remained in a + separate cgroup, environment handoff passed, post-restart output and terminal result were + written, and the synthetic owner token was absent from the log. This verifies launch transport + and cgroup survival, not real engine health or candidate/rollback acceptance. +- [ ] Live eligibility/UI (engine-independent): disposable deployment with upstream exactly one + commit ahead. As an admin, load the dashboard with no license, a signed free license, and an + expired Enterprise license. Each must show `1 commit behind`, manual-host guidance, no Update + button, and HTTP 403 for `POST /api/update/run`, with no new update lock. Install a valid + Enterprise fixture: the button appears; POST returns 202 and a transaction ID. Use the host + `npm run update` with the free fixture to verify manual operation remains available. +- [ ] Live candidate/rollback (repeat with **Claude and Codex**): disposable rootless Podman + installation, baseline A on an active systemd daemon (`KillMode=mixed`), verified Enterprise + fixture and only the engine under test enabled. Set `NODE_ENV=production`; candidate B is a + fast-forward with static checks importing `acorn`. Prompt `Update the gateway now using + update_gateway` as an admin in an Auto channel. Record baseline and replacement confined smoke + output, old/target/running revisions, transient updater PID/cgroup and daemon PID before/after. + Pass only when the updater survives the restart, status becomes `updated`, and exactly one + result is posted. Repeat with candidate C containing a failing test: must restore B and report + terminal `rolled_back` with a successful real engine smoke. Repeat for a user and system daemon + unit. No production checkout should be used for the injected failures. +- [ ] Live interrupted progress (engine-independent): on the disposable fixture, terminate only + its updater during a long phase after recording its PID. After reservation grace, the dashboard + must show interrupted/unverified completion, retain the last phase/error after refresh, and + never claim success. A live owner in a phase longer than 15 minutes must continue polling. +- Private QA registry entries for this slice are pending: the available Composio CLI reports no + connected Airtable account. The cases above are ready to transfer using the operator's personal + connection; this is not a claim of live engine acceptance or stable-release readiness. + ### Transactional self-update - [x] Unit: exclusive reservation, live-owner refusal, dead/abandoned-owner recovery, ownership diff --git a/public/app.js b/public/app.js index c12b5c0..b27dea1 100644 --- a/public/app.js +++ b/public/app.js @@ -3723,6 +3723,10 @@ const UPDATE_PHASES = { }; function updateResultHtml(transaction) { + if (transaction.interrupted) { + const cause = transaction.candidateError ? ` Candidate error: ${transaction.candidateError}` : ""; + return `${escapeHtml(transaction.reason + cause)}`; + } const revision = transaction.runningRevision ? ` ${escapeHtml(transaction.runningRevision)}` : ""; if (transaction.result === "updated" && transaction.imageWarning) { return `container image needs attention — ${escapeHtml(transaction.imageWarning)}`; @@ -3741,7 +3745,8 @@ function updateResultHtml(transaction) { return `update refused — ${escapeHtml(transaction.reason || "preflight failed")}`; } const detail = transaction.rollbackError || transaction.candidateError || transaction.reason || "check update.log"; - return `update and rollback failed — ${escapeHtml(detail)}`; + const failure = transaction.rollbackError ? "update and rollback failed" : "update failed"; + return `${failure} — ${escapeHtml(detail)}`; } function renderRunningUpdate(el, transaction) { @@ -3750,18 +3755,21 @@ function renderRunningUpdate(el, transaction) { const disk = transaction.requiredDiskBytes ? ` · ${gib(transaction.requiredDiskBytes)} GiB required / ${gib(transaction.availableDiskBytes)} GiB free${transaction.optionalDownloadBytes ? " · includes missing 1.5 GiB Whisper model" : ""}` : ""; - el.innerHTML = `Updating · ${escapeHtml(phase + disk)}`; + const elapsed = Math.max(0, Math.floor((Date.now() - (Number(transaction.startedAt) || Date.now())) / 60_000)); + el.innerHTML = `Updating · ${escapeHtml(phase + disk)} · ${elapsed} min elapsed${elapsed >= 15 ? " · still monitoring" : ""}`; } async function monitorGatewayUpdate(transactionId, el, startedAt = Date.now()) { - if (Date.now() - startedAt > 15 * 60_000) { - el.innerHTML = `Update is taking long — check ~/.channelgate/logs/update.log.`; - return; - } try { const response = await fetch("/api/health", { cache: "no-store" }); if (!response.ok) throw new Error(`health ${response.status}`); const health = await response.json(); + // Restart clears in-memory admin sessions. Public health intentionally omits transaction + // details; ask for login instead of polling that restricted response forever. + if (!Object.hasOwn(health, "update")) { + el.innerHTML = `Gateway is reachable. Sign in again to resume update status; completion is not yet verified.`; + return; + } const transaction = health.update; if (transaction?.id === transactionId) { if (transaction.status === "terminal") { @@ -3791,9 +3799,11 @@ async function monitorGatewayUpdate(transactionId, el, startedAt = Date.now()) { } else { renderRunningUpdate(el, transaction); } + } else { + el.innerHTML = `Update status changed or is unavailable; checking again…`; } } catch { - // A restart can briefly refuse connections; durable state remains available when it returns. + el.innerHTML = `Waiting for the gateway to reconnect · ${Math.max(0, Math.floor((Date.now() - startedAt) / 60_000))} min elapsed · still monitoring`; } setTimeout(() => monitorGatewayUpdate(transactionId, el, startedAt), 2_000); } @@ -3815,12 +3825,17 @@ async function loadUpdateStatus() { monitorGatewayUpdate(transaction.id, el, transaction.startedAt || Date.now()); return; } - const cur = u.current ? `${escapeHtml(u.current)}` : ""; + const previous = transaction?.status === "terminal" ? updateResultHtml(transaction) : ""; + const cur = previous + (u.current ? `${escapeHtml(u.current)}` : ""); if (u.behind > 0) { + if (u.automaticUpdates !== true) { + el.innerHTML = `${cur}${Number(u.behind)} commit${u.behind === 1 ? "" : "s"} behind · update manually on the host. Automatic updates require Enterprise.`; + return; + } el.innerHTML = `${cur}`; document.getElementById("update-now").addEventListener("click", runGatewayUpdate); } else { - el.innerHTML = `${cur}up to date${u.checked ? "" : " (couldn't reach remote)"}`; + el.innerHTML = `${cur}${u.checked ? "up to date" : "update check unavailable — could not reach remote"}`; } } catch { el.innerHTML = ""; // non-admin / locked-down — just hide the chip @@ -3830,7 +3845,7 @@ async function loadUpdateStatus() { async function runGatewayUpdate() { const ok = await confirmDialog({ title: "Update the gateway now?", - body: "It pulls the latest version and restarts — the bot is offline for a few seconds.", + body: "It checks the installation, installs and tests the candidate, then restarts and verifies it. This can take several minutes; failures trigger rollback.", confirmLabel: "Update", }); if (!ok) return; diff --git a/scripts/check-update-service-survival.mjs b/scripts/check-update-service-survival.mjs new file mode 100644 index 0000000..cd4f574 --- /dev/null +++ b/scripts/check-update-service-survival.mjs @@ -0,0 +1,58 @@ +import { spawn } from 'node:child_process'; +import { copyFileSync, mkdtempSync, readFileSync, writeFileSync, existsSync } from 'node:fs'; +import path from 'node:path'; +import os from 'node:os'; +import { fileURLToPath } from 'node:url'; +// Disposable, engine-independent systemd acceptance. Never targets a deployed service. +const repo = process.argv[2] || path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); +const root = mkdtempSync(path.join(os.tmpdir(), 'cg-update-survival-')); +const suffix = `${process.pid}-${Date.now()}`; +const parentUnit = `cg-update-fixture-parent-${suffix}`; +const runnerUnit = `cg-update-fixture-child-${suffix}`; +copyFileSync(path.join(repo, 'scripts/update-launcher.mjs'), path.join(root, 'update-launcher.mjs')); +writeFileSync(path.join(root, 'update-runner.mjs'), ` +import { writeFileSync, readFileSync } from 'node:fs'; +export async function main() { + const root=process.env.CHANNELGATE_DIR; + if(process.env.CG_UPDATE_OWNER_TOKEN !== 'synthetic-fixture-token' || process.env.FIXTURE_CHECK !== 'preserved') throw Error('environment mismatch'); + const cgroup = readFileSync('/proc/self/cgroup','utf8').trim(); + writeFileSync(root+'/child-ready.json', JSON.stringify({pid:process.pid,cgroup,environmentPreserved:true})); + console.log('fixture child ready'); + await new Promise(resolve=>setTimeout(resolve, 2500)); + console.log('fixture child survived parent restart'); + writeFileSync(root+'/terminal.json',JSON.stringify({result:'updated',pid:process.pid,cgroup:readFileSync('/proc/self/cgroup','utf8').trim(),environmentPreserved:true})); +} +`, {mode:0o600}); +writeFileSync(path.join(root, 'parent.mjs'), ` +import {spawn} from 'node:child_process'; +import {openSync,writeFileSync,readFileSync,existsSync,closeSync} from 'node:fs'; +const root=${JSON.stringify(root)}; +if (existsSync(root+'/parent-first.json')) { + writeFileSync(root+'/parent-restarted.json',JSON.stringify({pid:process.pid,cgroup:readFileSync('/proc/self/cgroup','utf8').trim()})); + setInterval(()=>{},1000); +} else { + const fd=openSync(root+'/update.log','a',0o600); + const child=spawn('systemd-run',['--user','--quiet','--pipe','--wait','--collect','--service-type=exec','--unit=${runnerUnit}',process.execPath,root+'/update-launcher.mjs','--transaction','fixture'],{detached:true,stdio:['pipe',fd,fd],env:{...process.env}}); + closeSync(fd); + writeFileSync(root+'/parent-first.json',JSON.stringify({pid:process.pid,wrapperPid:child.pid,cgroup:readFileSync('/proc/self/cgroup','utf8').trim()})); + child.stdin.on('error',()=>{}); + child.stdin.end(JSON.stringify({...process.env,CHANNELGATE_DIR:root,CG_UPDATE_OWNER_TOKEN:'synthetic-fixture-token',FIXTURE_CHECK:'preserved'})); + child.unref(); + const timer=setInterval(()=>{if(existsSync(root+'/child-ready.json')) {clearInterval(timer);process.exit(1);}},50); +} +`, {mode:0o600}); +function exec(command,args) { return new Promise((resolve,reject)=>{const child=spawn(command,args,{stdio:['ignore','pipe','pipe']});let output='';child.stdout.on('data',c=>output+=c);child.stderr.on('data',c=>output+=c);child.on('error',reject);child.on('close',code=>code===0?resolve(output):reject(Error(`${command} failed (${code}): ${output}`)));});} +let started=false; +try { + await exec('systemd-run',['--user','--quiet','--collect','--service-type=exec',`--unit=${parentUnit}`,'--property=KillMode=mixed','--property=Restart=on-failure','--property=RestartSec=100ms','--property=RuntimeMaxSec=15s',process.execPath,path.join(root,'parent.mjs')]); + started=true; + const deadline=Date.now()+12000; + while(Date.now()setTimeout(r,100)); + const read=name=>JSON.parse(readFileSync(path.join(root,name),'utf8')); + const parent=read('parent-first.json'), restarted=read('parent-restarted.json'), child=read('terminal.json'); + const log=readFileSync(path.join(root,'update.log'),'utf8'); + let wrapperAlive=true;try{process.kill(parent.wrapperPid,0);}catch{wrapperAlive=false;} + if(parent.pid===restarted.pid || parent.cgroup===child.cgroup || !child.cgroup.includes(runnerUnit) || !log.includes('fixture child survived parent restart') || wrapperAlive || log.includes('synthetic-fixture-token')) throw Error('survival evidence did not meet pass rules'); + console.log(JSON.stringify({ok:true,root,parent,restarted,child,wrapperAlive,logSurvived:true,syntheticTokenLogged:log.includes('synthetic-fixture-token')},null,2)); +} catch(error) {console.error(JSON.stringify({ok:false,root,error:error.message}));process.exitCode=1;} +finally {if(started) {await exec('systemctl',['--user','stop',parentUnit]).catch(()=>{});await exec('systemctl',['--user','stop',runnerUnit]).catch(()=>{});}} diff --git a/scripts/update-launcher.mjs b/scripts/update-launcher.mjs new file mode 100644 index 0000000..25b3f57 --- /dev/null +++ b/scripts/update-launcher.mjs @@ -0,0 +1,35 @@ +#!/usr/bin/env node +// Runs in an independent systemd user service. Environment values arrive on a private stdin +// pipe instead of becoming visible in ExecStart, systemd unit properties, or temporary files. +// Apply them before importing the updater and its runtime-path modules. +import { pathToFileURL } from "node:url"; +import path from "node:path"; + +export async function launchUpdate({ input = process.stdin, env = process.env, run } = {}) { + const chunks = []; + let bytes = 0; + for await (const chunk of input) { + bytes += Buffer.byteLength(chunk); + if (bytes > 4 * 1024 * 1024) throw new Error("update launch environment is too large"); + chunks.push(Buffer.from(chunk)); + } + const inherited = JSON.parse(Buffer.concat(chunks).toString("utf8")); + if (!inherited || typeof inherited !== "object" || Array.isArray(inherited) + || !inherited.CG_UPDATE_OWNER_TOKEN || !inherited.CHANNELGATE_DIR + || Object.values(inherited).some((value) => typeof value !== "string")) { + throw new Error("update launch environment is invalid"); + } + // Replace rather than merge: user-manager defaults must not change this install's identity. + for (const key of Object.keys(env)) delete env[key]; + Object.assign(env, inherited); + const main = run || (await import("./update-runner.mjs")).main; + return main(); +} + +if (process.argv[1] && import.meta.url === pathToFileURL(path.resolve(process.argv[1])).href) { + launchUpdate().catch(() => { + // A malformed JSON payload may contain credentials. Never print the parsing error/payload. + console.error("Update service could not load its launch environment or start the runner; check the transaction status."); + process.exitCode = 1; + }); +} diff --git a/scripts/update-runner.mjs b/scripts/update-runner.mjs index 3639f5b..838bb6b 100755 --- a/scripts/update-runner.mjs +++ b/scripts/update-runner.mjs @@ -388,7 +388,7 @@ export async function applyPendingServiceReload({ root, service, run = runComman if (service?.kind === "systemd") { // daemon-reload alone: the restart signal that follows is what re-execs onto the new unit. const scope = service.scope === "user" ? ["--user"] : []; - await run("systemctl", [...scope, "daemon-reload"], { allowFailure: true, quiet: true, timeoutMs: 30_000 }); + await run("systemctl", [...scope, "daemon-reload"], { quiet: true, timeoutMs: 30_000 }); } rmSync(marker, { force: true }); return { reloaded: true }; @@ -534,6 +534,10 @@ async function defaultVerify({ root, context, expectedRevision }) { throw new Error(`replacement readiness timed out: ${lastReason}`); } +export function installUpdateDependencies({ repoRoot = REPO_ROOT, run = runCommand } = {}) { + return run("npm", ["ci", "--include=dev"], { cwd: repoRoot }); +} + function defaultOps({ root, repoRoot }) { return { claim: async ({ owner }) => { @@ -542,7 +546,9 @@ function defaultOps({ root, repoRoot }) { preflight: () => defaultPreflight({ root, repoRoot }), snapshot: ({ context, owner }) => defaultSnapshot({ root, repoRoot, context, owner }), checkout: ({ context }) => runCommand("git", ["merge", "--ff-only", context.targetRevision], { cwd: repoRoot, quiet: true, timeoutMs: 60_000 }), - install: () => runCommand("npm", ["ci"], { cwd: repoRoot }), + // Static checks and the shipped regression suite require devDependencies even on hosts + // configured with NODE_ENV=production or npm omit=dev. Rollback uses the same exact install. + install: () => installUpdateDependencies({ repoRoot }), audit: async () => { const result = await runCommand("npm", ["audit", "--omit=dev", "--json"], { cwd: repoRoot, allowFailure: true, quiet: true, timeoutMs: 120_000 }); const evaluated = evaluateAudit(JSON.parse(result.stdout || "{}")); @@ -719,7 +725,7 @@ function argValue(name) { return index >= 0 ? String(process.argv[index + 1] || "") : ""; } -async function main() { +export async function main() { const root = gatewayRoot(); const transactionId = argValue("--transaction"); let owner; diff --git a/src/ee/license.js b/src/ee/license.js index 2146f21..b6b930a 100644 --- a/src/ee/license.js +++ b/src/ee/license.js @@ -387,6 +387,7 @@ export function getEffectiveLimits(now = Date.now()) { // Everything the admin UI and the MCP tools render. export function getLicenseStatus(now = Date.now()) { const r = currentResolution(now); + const enterprise = r.license?.tier === "enterprise" && !r.fellBack && ["valid", "grace", "expired_grace"].includes(r.state); const lastCheck = readLastCheck(); const lastCheckAt = lastCheck?.at || ""; const lastCheckMs = lastCheckAt ? Date.parse(lastCheckAt) : NaN; @@ -400,7 +401,7 @@ export function getLicenseStatus(now = Date.now()) { // date it expired on comes from the resolution itself — the card must still say WHEN. expiresAt: r.license?.expiresAt || r.expiredAt || null, limits: r.limits, - features: { composioSdk: r.license?.tier === "enterprise" && !r.fellBack && ["valid", "grace", "expired_grace"].includes(r.state) }, + features: { composioSdk: enterprise, automaticUpdates: enterprise }, hasLicenseKey: hasLicenseKey(), licenseKeyLast4: licenseKeyLast4(), licenseKeySource: getSettings().licenseKey ? "settings" : hasLicenseKey() ? "env" : "", diff --git a/src/ee/update-entitlement.js b/src/ee/update-entitlement.js new file mode 100644 index 0000000..2781b4b --- /dev/null +++ b/src/ee/update-entitlement.js @@ -0,0 +1,8 @@ +// Managed automatic updates are an Enterprise feature. Manual host updates remain available. +import { getLicenseStatus } from "./license.js"; + +export const MANUAL_UPDATE_MESSAGE = "Automatic updates require an active Enterprise license. Manage this installation manually on its host using the update instructions in INSTALL.md."; + +export function hasAutomaticUpdateEntitlement(now = Date.now()) { + return getLicenseStatus(now).features.automaticUpdates; +} diff --git a/src/gateway/gateway-usage/references/administration.md b/src/gateway/gateway-usage/references/administration.md index c010407..90ef7d4 100644 --- a/src/gateway/gateway-usage/references/administration.md +++ b/src/gateway/gateway-usage/references/administration.md @@ -188,7 +188,11 @@ policy applies to this entire page; changing Full access still leaves run-time b Every interaction re-checks agent access and channel membership. This console does not grant admin rights or change the permissions required by separate gateway control tools. -## Updating the gateway itself (admin) +## Updating the gateway itself (Enterprise admin) +- Managed updates require an active Enterprise entitlement. Other editions show their commit count + behind and must be updated manually by the host operator using `npm run update` in the checkout. +- The managed runner starts in a separate systemd user service so it survives the daemon restart. + Long phases continue reporting; an absent runner is reported as interrupted, never as success. - `update_gateway` starts the same locked transaction as Slack `/update`, the Admin UI, and `npm run update`. If an update is already active it reports that transaction instead of starting another. diff --git a/src/gateway/update-state.js b/src/gateway/update-state.js index 892d0f9..6bac19e 100644 --- a/src/gateway/update-state.js +++ b/src/gateway/update-state.js @@ -32,6 +32,7 @@ const PUBLIC_FIELDS = [ "changed", "reason", "imageWarning", + "interrupted", "candidateError", "rollbackError", "advisories", @@ -113,6 +114,23 @@ export function readUpdateState({ root = gatewayRoot() } = {}) { return state && typeof state === "object" && !Array.isArray(state) ? state : null; } +// Project abandoned transactions without rewriting the owner's durable state or racing a new +// reservation. A long phase alone is never evidence of failure: only a missing/dead owner is. +export function readUpdateStatus({ + root = gatewayRoot(), now = Date.now(), pidAlive = defaultPidAlive, + reservationGraceMs = RESERVATION_GRACE_MS, +} = {}) { + const state = readUpdateState({ root }); + if (!state || state.status !== "running") return state; + const lock = readJson(lockFile(root)); + if (lock?.transactionId === state.id && activeLock(lock, { now, pidAlive, reservationGraceMs })) return state; + if (now - Number(state.updatedAt || state.startedAt || 0) < reservationGraceMs) return state; + return { + ...state, status: "terminal", result: "failed", interrupted: true, + reason: `Update runner stopped during ${state.phase || "startup"}; completion and rollback are unverified. Check the host update log before retrying.`, + }; +} + export function publicUpdateState(state = readUpdateState()) { if (!state || typeof state !== "object") return null; const projected = {}; diff --git a/src/gateway/updater.js b/src/gateway/updater.js index 91ee7c2..deb669d 100644 --- a/src/gateway/updater.js +++ b/src/gateway/updater.js @@ -1,6 +1,7 @@ // Shared self-update entry point and restart-safe reporting helpers. Slack, MCP, Admin UI, and // scripts/update.sh all converge on the same durable transaction/lock in update-state.js; only // the built-in-only Node runner performs repository, dependency, service, or rollback mutations. +import { hasAutomaticUpdateEntitlement, MANUAL_UPDATE_MESSAGE } from "../ee/update-entitlement.js"; import { spawn, execFile, execFileSync } from "node:child_process"; import { closeSync, @@ -19,8 +20,10 @@ import { claimUpdate, finishUpdate, isTerminalUpdate, + isUpdateActive, publicUpdateState, readUpdateState, + readUpdateStatus, releaseUpdate, reserveUpdate, } from "./update-state.js"; @@ -29,6 +32,7 @@ const execFileP = promisify(execFile); const REPO_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..", ".."); export const UPDATE_SCRIPT = path.join(REPO_ROOT, "scripts", "update.sh"); export const UPDATE_RUNNER = path.join(REPO_ROOT, "scripts", "update-runner.mjs"); +export const UPDATE_LAUNCHER = path.join(REPO_ROOT, "scripts", "update-launcher.mjs"); const MARKER_MAX_AGE_MS = 6 * 60 * 60_000; const git = async (...args) => (await execFileP("git", args, { cwd: REPO_ROOT, timeout: 20_000 })).stdout.trim(); @@ -129,14 +133,14 @@ export function readTerminalUpdateMarker({ clearUpdateMarker(marker.transactionId, { root }); return null; } - const transaction = publicUpdateState(readUpdateState({ root })); + const transaction = publicUpdateState(readUpdateStatus({ root })); if (!isTerminalUpdate(transaction) || transaction.id !== marker.transactionId) return null; return { marker, transaction }; } export function formatUpdateResult(transaction = {}) { const revision = transaction.runningRevision ? ` \`${transaction.runningRevision}\`` : ""; - const reason = safeMessage(transaction.reason || transaction.candidateError || ""); + const reason = transaction.reason || transaction.candidateError ? safeMessage(transaction.reason || transaction.candidateError) : ""; if (transaction.result === "updated" && transaction.imageWarning) return `⚠️ Gateway code is current${revision}; container image needs attention: ${transaction.imageWarning}`; if (transaction.result === "updated" && transaction.changed === false) { return `✅ Gateway already up to date${revision}. ${transaction.reason || "Preflight and configured container engine smoke checks passed."}`; @@ -153,7 +157,10 @@ export function formatUpdateResult(transaction = {}) { } if (transaction.result === "failed") { const candidate = safeMessage(transaction.candidateError || reason); - const rollback = safeMessage(transaction.rollbackError || ""); + const rollback = transaction.rollbackError ? safeMessage(transaction.rollbackError) : ""; + if (transaction.interrupted) return `❌ Update interrupted: ${reason || "the runner stopped"}${transaction.candidateError ? ` Candidate: ${safeMessage(transaction.candidateError)}.` : ""} Check ` + "`~/.channelgate/logs/update.log`."; + if (!rollback && transaction.changed !== true) return `❌ Update failed before changes${candidate ? `: ${candidate}.` : "."}`; + if (!rollback) return `❌ Update interrupted${candidate ? `: ${candidate}.` : "."} Automatic rollback was not confirmed. Check ` + "`~/.channelgate/logs/update.log`."; return `❌ Update failed and automatic rollback also failed.${candidate ? ` Candidate: ${candidate}.` : ""}${rollback ? ` Rollback: ${rollback}.` : ""} Check \`~/.channelgate/logs/update.log\`.`; } return `Update status: ${safeMessage(transaction.phase || transaction.status || "unknown")}.`; @@ -202,6 +209,10 @@ export function startUpdate( spawnImpl = spawn, } = {}, ) { + if (!hasAutomaticUpdateEntitlement()) { + return { ok: false, forbidden: true, error: MANUAL_UPDATE_MESSAGE, + transaction: { status: "terminal", result: "refused", reason: MANUAL_UPDATE_MESSAGE } }; + } const reserved = reserveUpdate({ root, source }); if (!reserved.ok) return { ok: false, conflict: true, transaction: reserved.transaction }; @@ -228,11 +239,18 @@ export function startUpdate( let fd; try { fd = openSync(path.join(logsDir, "update.log"), "a", 0o600); - const child = spawnImpl(process.execPath, [UPDATE_RUNNER, "--transaction", reserved.transaction.id], { + // setsid/detached alone stays in the daemon's systemd cgroup and dies when it restarts. + // A separate user service survives that cgroup teardown. --pipe passes file descriptors; + // credentials travel only through stdin, never argv, unit properties, or an environment file. + const child = spawnImpl("systemd-run", [ + "--user", "--quiet", "--pipe", "--wait", "--collect", "--service-type=exec", + `--unit=channelgate-update-${reserved.transaction.id}`, + process.execPath, UPDATE_LAUNCHER, "--transaction", reserved.transaction.id, + ], { cwd: REPO_ROOT, detached: true, - stdio: ["ignore", fd, fd], - env: { ...process.env, CHANNELGATE_DIR: root, CG_UPDATE_OWNER_TOKEN: reserved.owner.token }, + stdio: ["pipe", fd, fd], + env: { ...process.env }, }); if (Number.isInteger(child?.pid) && child.pid > 0) { claimUpdate({ root, owner: reserved.owner, pid: child.pid }); @@ -244,6 +262,19 @@ export function startUpdate( console.error(`[update] detached runner failed and status could not be finalized: ${safeMessage(finishError)}`); } }); + child?.once?.("close", (code, signal) => { + try { + const state = readUpdateState({ root }); + if (state?.id !== reserved.transaction.id || isTerminalUpdate(state) || isUpdateActive({ root })) return; + failReservedStart({ root, owner: reserved.owner, context, + error: new Error(`independent systemd update service stopped before completing (${signal || code || "no result"}); check update.log and the user systemd service`) }); + } catch (error) { + console.error(`[update] couldn't finalize stopped update service: ${safeMessage(error)}`); + } + }); + // Attach before writing: a missing user bus or systemd-run may close its stdin immediately. + child.stdin?.on?.("error", () => { /* The child error/close event records the launch failure. */ }); + child.stdin?.end(JSON.stringify({ ...process.env, CHANNELGATE_DIR: root, CG_UPDATE_OWNER_TOKEN: reserved.owner.token })); child?.unref?.(); return { ok: true, transaction: reserved.transaction }; } catch (error) { diff --git a/src/mcp/tools/channel-admin.js b/src/mcp/tools/channel-admin.js index 5e156a1..d910892 100644 --- a/src/mcp/tools/channel-admin.js +++ b/src/mcp/tools/channel-admin.js @@ -442,7 +442,7 @@ export function register(server, ctx) { "update_gateway", { description: - "ADMIN ONLY. Update the gateway to the latest version: git pull, install deps, migrate working " + + "ENTERPRISE ADMIN ONLY. Update the gateway to the latest version: git pull, install deps, migrate working " + "folders, and restart the service. The bot goes offline for a few seconds while it restarts. " + "Runs detached so it survives the restart; progress is logged to ~/.channelgate/logs/update.log. " + "Auto/Admin channels start immediately; Read/Worker channels require a Slack approval click.", diff --git a/src/web/app.js b/src/web/app.js index b32a78a..4dabf72 100644 --- a/src/web/app.js +++ b/src/web/app.js @@ -30,7 +30,7 @@ import { canEditChannelFiles } from "../slack/file-explorer.js"; import { ADMIN_CONVERSATION_PATH_RE, ADMIN_VIEW_PATHS } from "../../public/admin-routes.js"; import { runUpdateSmoke } from "../gateway/update-smoke.js"; import { runningRevision, startUpdate } from "../gateway/updater.js"; -import { publicUpdateState, readUpdateState } from "../gateway/update-state.js"; +import { publicUpdateState, readUpdateStatus } from "../gateway/update-state.js"; import { renderShell } from "./assets.js"; const escapeHtml = (s) => @@ -280,7 +280,7 @@ export function createWebApp({ ok: true, instanceId, revision: runningRevision, - update: publicUpdateState(readUpdateState()), + update: publicUpdateState(readUpdateStatus()), claude: engines.claude, engines, gatewayRoot: gatewayRoot(), diff --git a/src/web/routes/settings.js b/src/web/routes/settings.js index a523c8c..33d937a 100644 --- a/src/web/routes/settings.js +++ b/src/web/routes/settings.js @@ -3,6 +3,7 @@ // filesystem browser, and UI reference data (/skills, /mcp/available). Split from admin.js; // mounted by createAdminRouter so every URL is unchanged. import { hasComposioSdkEntitlement } from "../../ee/composio-entitlement.js"; +import { hasAutomaticUpdateEntitlement, MANUAL_UPDATE_MESSAGE } from "../../ee/update-entitlement.js"; import { Router } from "express"; import { syncWorkspaceSkillsOrThrow } from "../../gateway/skills/workspace-sync.js"; import { readdir } from "node:fs/promises"; @@ -520,7 +521,7 @@ export function createSettingsRouter({ // when the restart is too quick to produce an observable failed health request. router.get("/update/check", async (_req, res, next) => { try { - res.json(await checkForUpdate()); + res.json({ ...await checkForUpdate(), automaticUpdates: hasAutomaticUpdateEntitlement() }); } catch (e) { next(e); } @@ -528,9 +529,10 @@ export function createSettingsRouter({ router.post("/update/run", async (_req, res, next) => { try { + if (!hasAutomaticUpdateEntitlement()) return res.status(403).json({ ok: false, error: MANUAL_UPDATE_MESSAGE }); const started = startGatewayUpdate({ source: "admin-ui" }); if (!started.ok) { - const status = started.conflict ? 409 : 500; + const status = started.forbidden ? 403 : started.conflict ? 409 : 500; return res.status(status).json({ ok: false, error: started.conflict diff --git a/test/mcp-control-plane-approval.test.js b/test/mcp-control-plane-approval.test.js index ece2ff7..a589a69 100644 --- a/test/mcp-control-plane-approval.test.js +++ b/test/mcp-control-plane-approval.test.js @@ -10,7 +10,7 @@ import test, { after } from "node:test"; import assert from "node:assert/strict"; import { Client } from "@modelcontextprotocol/sdk/client/index.js"; import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js"; -import { ensureTestEnv } from "./helpers.js"; +import { ensureTestEnv, testLicenseEnv } from "./helpers.js"; const projectRoot = fileURLToPath(new URL("..", import.meta.url)); const scratch = ensureTestEnv(); @@ -56,6 +56,10 @@ function gatewayClient({ author = "U_CTRL_ADMIN", port = stubPort, engine = "cla env: { PATH: process.env.PATH || "", NODE_ENV: "test", + CHANNELGATE_LICENSE_PUBLIC_KEY: process.env.CHANNELGATE_LICENSE_PUBLIC_KEY, + CHANNELGATE_LICENSE_PAYLOAD: process.env.CHANNELGATE_LICENSE_PAYLOAD, + CHANNELGATE_LICENSE_KEY: process.env.CHANNELGATE_LICENSE_KEY, + CHANNELGATE_PLATFORM_URL: "http://127.0.0.1:9/channelgate/api", CG_TEST_SCRATCH: scratch, CHANNELGATE_DIR: scratch, CHANNELGATE_DB: path.join(scratch, "gateway.db"), @@ -246,6 +250,23 @@ test("update_gateway skips the extra card in Admin/Auto mode but stays admin-onl } }); +test("both engine MCP contexts refuse managed updates for a free license", async () => { + try { + testLicenseEnv({ tier: "free" }); + await saveChannelMeta(SLUG, { ...(await getChannelMeta(SLUG)), adminMode: true }); + for (const engine of ["claude", "codex"]) { + await withGateway({ engine }, async (client) => { + const result = await client.callTool({ name: "update_gateway", arguments: {} }); + assert.match(resultText(result), /Enterprise/); + assert.match(resultText(result), /manually/); + }); + } + } finally { + testLicenseEnv(); + await saveChannelMeta(SLUG, { ...(await getChannelMeta(SLUG)), adminMode: false }); + } +}); + test("restart_gateway requires approval in Auto mode and skips it only in Admin mode", async () => { const original = await getChannelMeta(SLUG); restartResponse = { ok: true, id: "restart-test", waitMs: 300_000, pollMs: 30_000 }; diff --git a/test/run-api.test.js b/test/run-api.test.js index 1670171..443eb92 100644 --- a/test/run-api.test.js +++ b/test/run-api.test.js @@ -6,7 +6,7 @@ // process, so these env writes don't leak into the rest of the suite. import test, { after } from "node:test"; import assert from "node:assert/strict"; -import { ensureTestEnv } from "./helpers.js"; +import { ensureTestEnv, clearTestLicense, testLicenseEnv } from "./helpers.js"; ensureTestEnv(); process.env.ADMIN_PASSWORD = "test-admin-pw"; // authEnabled() → true, so the run-API key gate is live @@ -182,6 +182,22 @@ test("POST /api/update/run returns the transaction and rejects an overlapping up assert.equal((await conflict.json()).transaction.id, "tx-existing"); }); +test("POST /api/update/run refuses non-Enterprise before invoking the updater", async () => { + const login = await post("/api/login", { password: "test-admin-pw" }); + const cookie = login.headers.get("set-cookie").split(";", 1)[0]; + try { + for (const tier of ["none", "free", "expired"]) { + if (tier === "none") clearTestLicense(); + else testLicenseEnv(tier === "free" ? { tier: "free" } : { expiresAt: "2020-01-01T00:00:00Z" }); + lastUpdateStartOptions = null; + const response = await post("/api/update/run", {}, { cookie, "x-cg-request": "1" }); + assert.equal(response.status, 403, tier); + assert.match((await response.json()).error, /Enterprise/); + assert.equal(lastUpdateStartOptions, null); + } + } finally { testLicenseEnv(); } +}); + test("POST /internal/update-smoke requires loopback IPC auth", async () => { const missing = await post("/internal/update-smoke", {}); assert.equal(missing.status, 403); diff --git a/test/update-entitlement.test.js b/test/update-entitlement.test.js new file mode 100644 index 0000000..cb8d7a3 --- /dev/null +++ b/test/update-entitlement.test.js @@ -0,0 +1,49 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { existsSync } from "node:fs"; +import path from "node:path"; +import { ensureTestEnv, clearTestLicense, testLicenseEnv, tempDir, makeTestLicense, signTestLicense } from "./helpers.js"; + +ensureTestEnv(); +const { hasAutomaticUpdateEntitlement } = await import("../src/ee/update-entitlement.js"); +const { startUpdate } = await import("../src/gateway/updater.js"); +const { sha256Hex } = await import("../src/ee/license.js"); +const { metaSet } = await import("../src/db/index.js"); + +test("automatic update eligibility follows Enterprise verification, expiry and outage grace", () => { + try { + clearTestLicense(); + assert.equal(hasAutomaticUpdateEntitlement(), false); + testLicenseEnv({ tier: "free" }); + assert.equal(hasAutomaticUpdateEntitlement(), false); + testLicenseEnv(); + assert.equal(hasAutomaticUpdateEntitlement(), true); + testLicenseEnv({ expiresAt: "2026-02-01T00:00:00Z" }); + assert.equal(hasAutomaticUpdateEntitlement(Date.parse("2026-02-02T00:00:00Z")), false); + clearTestLicense(); + process.env.CHANNELGATE_LICENSE_KEY = "update-outage-fixture"; + const license = makeTestLicense(); + const keyHash = sha256Hex(process.env.CHANNELGATE_LICENSE_KEY); + metaSet("license_cache", JSON.stringify({ license, signature: signTestLicense(license), keyHash, verifiedAt: "2026-02-01T00:00:00Z" })); + metaSet("license_last_check", JSON.stringify({ outcome: "unreachable", keyHash, at: "2026-02-02T00:00:00Z" })); + for (const date of ["2026-02-03", "2026-02-20"]) assert.equal(hasAutomaticUpdateEntitlement(Date.parse(date)), true); + assert.equal(hasAutomaticUpdateEntitlement(Date.parse("2026-03-01")), false); + for (const outcome of ["invalid", "revoked"]) { + metaSet("license_last_check", JSON.stringify({ outcome, keyHash, at: "2026-02-02T00:00:00Z" })); + assert.equal(hasAutomaticUpdateEntitlement(Date.parse("2026-02-03")), false); + } + } finally { testLicenseEnv(); } +}); + +test("all managed entrypoints refuse without creating a lock or spawning for a free license", () => { + const root = tempDir("update-license-"); + try { + testLicenseEnv({ tier: "free" }); + for (const source of ["admin-ui", "slack", "mcp", "unknown"]) { + const result = startUpdate({ root, source }, { spawnImpl() { throw new Error("must not spawn"); } }); + assert.equal(result.forbidden, true); + assert.match(result.error, /manually/); + assert.equal(existsSync(path.join(root, "update.lock")), false); + } + } finally { testLicenseEnv(); } +}); diff --git a/test/update-launcher.test.js b/test/update-launcher.test.js new file mode 100644 index 0000000..609a4c8 --- /dev/null +++ b/test/update-launcher.test.js @@ -0,0 +1,33 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { spawnSync } from "node:child_process"; +import { closeSync, openSync, readFileSync } from "node:fs"; +import path from "node:path"; +import { Readable } from "node:stream"; +import { tempDir } from "./helpers.js"; +import { launchUpdate } from "../scripts/update-launcher.mjs"; + +test("invalid launch environments leave the existing environment untouched and never invoke a runner", async () => { + for (const value of [null, [], {}, { CHANNELGATE_DIR: "/test", CG_UPDATE_OWNER_TOKEN: false }, { CHANNELGATE_DIR: "/test", CG_UPDATE_OWNER_TOKEN: "token", OTHER: 1 }]) { + const env = { HOME: "/original" }; + await assert.rejects(launchUpdate({ input: Readable.from([JSON.stringify(value)]), env, run: () => assert.fail("invalid environment reached runner") }), /invalid/); + assert.deepEqual(env, { HOME: "/original" }); + } +}); + +test("launcher CLI rejects malformed credential payloads without logging their contents", () => { + const root = tempDir("cg-update-launcher-secret-"); + const file = path.join(root, "output.log"); + const fd = openSync(file, "w", 0o600); + let result; + try { + result = spawnSync(process.execPath, [new URL("../scripts/update-launcher.mjs", import.meta.url).pathname], { + input: '{"CG_UPDATE_OWNER_TOKEN":"fixture-secret-must-not-appear",BROKEN}', + stdio: ["pipe", fd, fd], timeout: 5_000, + }); + } finally { closeSync(fd); } + assert.equal(result.status, 1); + const output = readFileSync(file, "utf8"); + assert.match(output, /Update service could not load its launch environment/); + assert.doesNotMatch(output, /fixture-secret-must-not-appear|CG_UPDATE_OWNER_TOKEN|BROKEN|SyntaxError/); +}); diff --git a/test/update-marker.test.js b/test/update-marker.test.js index ca50416..297aad0 100644 --- a/test/update-marker.test.js +++ b/test/update-marker.test.js @@ -3,7 +3,7 @@ // reported. The detached start path is tested here too so every entry point shares the same lock. import { test } from "node:test"; import assert from "node:assert/strict"; -import { existsSync, rmSync } from "node:fs"; +import { existsSync, rmSync, readFileSync, writeFileSync } from "node:fs"; import path from "node:path"; import { ensureTestEnv, tempDir } from "./helpers.js"; import { finishUpdate, releaseUpdate, reserveUpdate } from "../src/gateway/update-state.js"; @@ -94,7 +94,9 @@ test("startUpdate reserves once, binds the marker, and keeps its owner token out const root = tempDir("cg-updater-start-"); try { const calls = []; + let launchPayload; const child = { + stdin: { on() {}, end(value) { launchPayload = JSON.parse(value); } }, pid: process.pid, once() {}, unref() {}, @@ -119,9 +121,18 @@ test("startUpdate reserves once, binds the marker, and keeps its owner token out assert.equal(second.transaction.id, first.transaction.id); assert.equal(calls.length, 1); const [command, args, options] = calls[0]; - assert.equal(command, process.execPath); + assert.equal(command, "systemd-run"); + assert.ok(args.includes("--user")); + assert.ok(args.includes("--service-type=exec")); + assert.ok(args.includes("--pipe")); + assert.equal(options.stdio[0], "pipe"); + assert.equal(typeof options.stdio[1], "number"); + assert.equal(options.stdio[1], options.stdio[2], "stdout/stderr are direct log-file descriptors, not wrapper pipes"); + assert.equal(launchPayload.CHANNELGATE_DIR, root); + assert.ok(launchPayload.CG_UPDATE_OWNER_TOKEN); assert.deepEqual(args.slice(-2), ["--transaction", first.transaction.id]); - assert.equal(args.includes(options.env.CG_UPDATE_OWNER_TOKEN), false); + assert.equal(JSON.stringify(args).includes(launchPayload.CG_UPDATE_OWNER_TOKEN), false); + assert.equal(options.env.CG_UPDATE_OWNER_TOKEN, undefined); assert.equal(readUpdateMarker({ root }).transactionId, first.transaction.id); } finally { clearUpdateMarker(undefined, { root }); @@ -165,3 +176,48 @@ test("terminal update results have explicit operator-facing summaries", () => { test("successful code update never hides an image failure", () => { assert.match(formatUpdateResult({ result: "updated", imageWarning: "build failed" }), /⚠️.*image needs attention.*build failed/); }); + +test("pre-change and interrupted failures never claim a rollback happened", () => { + const before = formatUpdateResult({ result: "failed", changed: false, candidateError: "user bus unavailable" }); + assert.match(before, /before changes.*user bus unavailable/); + assert.doesNotMatch(before, /rollback/); + const interrupted = formatUpdateResult({ result: "failed", interrupted: true, changed: true, reason: "runner stopped during verifying" }); + assert.match(interrupted, /interrupted.*runner stopped during verifying/); + assert.doesNotMatch(interrupted, /rollback also failed/); +}); + +test("systemd launch failure finalizes the reservation without starting a fallback runner", () => { + const root = tempDir("cg-updater-unit-fail-"); + try { + const handlers = {}; + let spawns = 0; + const result = startUpdate({ root }, { spawnImpl: () => { + spawns++; + return { pid: 2147483647, once(event, fn) { handlers[event] = fn; }, unref() {}, stdin: { on() {}, end() {} } }; + } }); + assert.equal(result.ok, true); + handlers.close(1, null); + const state = JSON.parse(readFileSync(path.join(root, "update-state.json"), "utf8")); + assert.equal(state.result, "failed"); + assert.equal(state.changed, false); + assert.match(state.reason, /independent systemd update service/); + assert.equal(existsSync(path.join(root, "update.lock")), false); + assert.equal(spawns, 1, "never fall back to an updater in the daemon cgroup"); + } finally { rmSync(root, { recursive: true, force: true }); } +}); + +test("wrapper exit does not finalize or unlock an independently claimed live runner", () => { + const root = tempDir("cg-updater-unit-live-"); + try { + const handlers = {}; + startUpdate({ root }, { spawnImpl: () => ({ + pid: 2147483647, once(event, fn) { handlers[event] = fn; }, unref() {}, stdin: { on() {}, end() {} }, + }) }); + const file = path.join(root, "update.lock"); + const lock = JSON.parse(readFileSync(file, "utf8")); + writeFileSync(file, JSON.stringify({ ...lock, pid: process.pid })); + handlers.close(null, "SIGKILL"); + assert.equal(existsSync(file), true); + assert.equal(JSON.parse(readFileSync(path.join(root, "update-state.json"), "utf8")).status, "running"); + } finally { rmSync(root, { recursive: true, force: true }); } +}); diff --git a/test/update-runner.test.js b/test/update-runner.test.js index 344e963..7b9fc53 100644 --- a/test/update-runner.test.js +++ b/test/update-runner.test.js @@ -1,6 +1,8 @@ import test from "node:test"; import assert from "node:assert/strict"; -import { mkdirSync, rmSync, writeFileSync } from "node:fs"; +import { existsSync, mkdirSync, rmSync, writeFileSync } from "node:fs"; +import { Readable } from "node:stream"; +import { launchUpdate } from "../scripts/update-launcher.mjs"; import path from "node:path"; import { expectedImageBuild } from "../src/runtimes/container/image.js"; @@ -20,8 +22,60 @@ import { containerSettings, defaultImageBuild, expectedImageSpecVersion, + installUpdateDependencies, + applyPendingServiceReload, + serviceReloadMarkerFile, } from "../scripts/update-runner.mjs"; +test("updates explicitly install dev tools despite production omit configuration", async () => { + let call; + await installUpdateDependencies({ repoRoot: "/checkout", run: async (...args) => { call = args; } }); + assert.deepEqual(call, ["npm", ["ci", "--include=dev"], { cwd: "/checkout" }]); +}); + +test("production-configured npm still installs a local dev dependency needed by update checks", async () => { + const root = tempDir("cg-update-production-npm-"); + try { + mkdirSync(path.join(root, "tool")); + writeFileSync(path.join(root, "tool", "package.json"), JSON.stringify({ name: "update-check-fixture", version: "1.0.0", main: "index.js" })); + writeFileSync(path.join(root, "tool", "index.js"), 'module.exports = "check-ready";'); + writeFileSync(path.join(root, "package.json"), JSON.stringify({ name: "update-fixture", version: "1.0.0", private: true, devDependencies: { "update-check-fixture": "file:./tool" } })); + const env = { ...process.env, NODE_ENV: "production", npm_config_omit: "dev", npm_config_cache: path.join(root, "cache"), npm_config_audit: "false", npm_config_fund: "false" }; + await runCommand("npm", ["install", "--package-lock-only", "--ignore-scripts", "--offline"], { cwd: root, env, quiet: true }); + await installUpdateDependencies({ repoRoot: root, run: (command, args, options) => runCommand(command, [...args, "--offline"], { ...options, env, quiet: true }) }); + const check = await runCommand(process.execPath, ["-e", 'if (require("update-check-fixture") !== "check-ready") process.exit(1)'], { cwd: root, env, quiet: true }); + assert.equal(check.code, 0); + } finally { rmSync(root, { recursive: true, force: true }); } +}); + +test("isolated launcher restores the exact inherited environment before starting the runner", async () => { + const env = { HOME: "/wrong-home", MANAGER_ONLY: "should disappear" }; + const inherited = { HOME: "/operator", CHANNELGATE_DIR: "/runtime", CG_UPDATE_OWNER_TOKEN: "private-token", NODE_ENV: "production" }; + const payload = JSON.stringify(inherited); + let called = false; + await launchUpdate({ input: Readable.from([payload.slice(0, 20), payload.slice(20)]), env, run: async () => { + called = true; + assert.deepEqual(env, inherited); + } }); + assert.equal(called, true); + await assert.rejects(launchUpdate({ input: Readable.from(['{"CG_UPDATE_OWNER_TOKEN":false}']), env, run: () => assert.fail() }), /invalid/); +}); + +test("failed service reload preserves its marker for retry", async () => { + const root = tempDir("cg-update-reload-fail-"); + try { + const marker = serviceReloadMarkerFile(root); + writeFileSync(marker, JSON.stringify({ files: ["channelgate.service"] })); + await assert.rejects(applyPendingServiceReload({ root, service: { kind: "systemd", scope: "user" }, log() {}, run: async (command, args, options) => { + assert.equal(command, "systemctl"); + assert.deepEqual(args, ["--user", "daemon-reload"]); + assert.notEqual(options.allowFailure, true); + throw new Error("reload denied"); + } }), /reload denied/); + assert.equal(existsSync(marker), true); + } finally { rmSync(root, { recursive: true, force: true }); } +}); + test("update command failures are plain-language while the raw status stays structured", async () => { await assert.rejects( runCommand(process.execPath, ["-e", "process.exit(1)"], { quiet: true, timeoutMs: 1_000 }), diff --git a/test/update-status.test.js b/test/update-status.test.js new file mode 100644 index 0000000..e9bc266 --- /dev/null +++ b/test/update-status.test.js @@ -0,0 +1,27 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { tempDir } from "./helpers.js"; +import { claimUpdate, readUpdateState, readUpdateStatus, reserveUpdate, releaseUpdate, updateUpdateState } from "../src/gateway/update-state.js"; + +test("dead updater becomes an interrupted projection preserving durable evidence", () => { + const root = tempDir("update-interrupted-"); + const { owner } = reserveUpdate({ root, now: 1000 }); + claimUpdate({ root, owner, pid: 999, now: 1000 }); + updateUpdateState({ root, owner, patch: { phase: "rolling_back", changed: true, candidateError: "missing dependency" }, now: 2000 }); + const state = readUpdateStatus({ root, now: 40000, pidAlive: () => false }); + assert.equal(state.status, "terminal"); + assert.equal(state.interrupted, true); + assert.equal(state.candidateError, "missing dependency"); + assert.match(state.reason, /rolling_back/); + assert.equal(readUpdateState({ root }).status, "running", "projection does not mutate evidence or owner state"); +}); + +test("live owner never times out; queued reservation has startup grace", () => { + const root = tempDir("update-live-"); + const { owner } = reserveUpdate({ root, now: 1000 }); + assert.equal(readUpdateStatus({ root, now: 2000, pidAlive: () => false }).status, "running"); + claimUpdate({ root, owner, pid: 999, now: 1000 }); + assert.equal(readUpdateStatus({ root, now: 999999999, pidAlive: () => true }).status, "running"); + releaseUpdate({ root, owner }); + assert.equal(readUpdateStatus({ root, now: 999999999 }).interrupted, true); +}); diff --git a/test/update-ui.test.js b/test/update-ui.test.js new file mode 100644 index 0000000..5bebd78 --- /dev/null +++ b/test/update-ui.test.js @@ -0,0 +1,82 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import { runInNewContext } from "node:vm"; + +const source = readFileSync(new URL("../public/app.js", import.meta.url), "utf8"); +const code = source.slice(source.indexOf("const UPDATE_PHASES ="), source.indexOf("// Resolve the initial path")); +function fixture({ entitlement = false, checked = true, transaction = null, fetchError = false } = {}) { + const el = { innerHTML: "", addEventListener() {} }; + const timers = []; + const context = { + document: { getElementById: () => el }, + escapeHtml: (text) => String(text).replaceAll("<", "<"), + Date, Number, Math, JSON, + sessionStorage: { getItem: () => null, removeItem() {}, setItem() {} }, + location: { reload() {} }, + api: async (url) => url.includes("check") ? { current: "abc", behind: checked ? 76 : 0, checked, automaticUpdates: entitlement } : { update: transaction }, + fetch: async () => { if (fetchError) throw new Error("offline"); return { ok: true, json: async () => ({ update: transaction }) }; }, + setTimeout: (fn) => timers.push(fn), + }; + runInNewContext(code, context); + return { el, timers, context }; +} + +test("only Enterprise sees update button; other editions see count and manual guidance", async () => { + for (const entitlement of [false, true, undefined]) { + const { el, context } = fixture({ entitlement }); + await context.loadUpdateStatus(); + assert.match(el.innerHTML, /76 commits? behind/); + assert.equal(el.innerHTML.includes('` : 'Included'}`; + const row = (s, action) => { + const status = s.unavailable ? "Not in the current catalog · saved selection kept" + : s.deleted || s.enabled === false ? "Disabled · saved selection kept" + : s.currentRevisionId == null && s.pinnedRevisionId == null ? "Awaiting approval" : sourceLabel(s); + return `
+
+ ${esc(s.name || s.slug)}${s.name && s.name !== s.slug ? `${esc(s.slug)}` : ""}${esc(status)} +

${esc(s.description || "No description available.")}

Name
${esc(s.name || s.slug)}
Slug
${esc(s.slug)}
Source
${esc(status)}
${s.version ? `
Version
${esc(s.version)}
` : ""}
+
+ ${action ? `` : 'Included'} +
`; + }; const group = (g, action) => { const visible = g.skills.filter(matches); return `

${esc(g.label)} ${visible.length === g.skills.length ? g.skills.length : `${visible.length} / ${g.skills.length}`}${g.locked ? ' Locked' : ""}

${g.note ? `

${esc(g.note)}

` : ""}
${visible.map((s) => row(s, action)).join("") || `

${g.skills.length ? "No matching active skills." : "No skills in this group."}

`}
`; @@ -57,9 +71,16 @@ export function mountSkillAssignmentPicker(root, options) { const available = model.available.filter(matches); root.querySelector("[data-available-count]").textContent = `${available.length} / ${model.available.length}`; root.querySelector("[data-picker-available]").innerHTML = `
${available.map((s) => row(s, "add")).join("") || `

${model.available.length ? "No skills match these filters." : "All available skills are already included."}

`}
`; + scrollAreas.forEach((el, i) => { el.scrollTop = scrollPositions[i]; }); } - root.addEventListener("input", (event) => { if (event.target === query) paint(); }); - root.addEventListener("change", (event) => { if (event.target === source) paint(); }); + root.addEventListener("toggle", (event) => { + const details = event.target; + if (!root.contains(details) || !details.matches("[data-picker-details]")) return; + if (details.open) expanded.add(key(details.dataset.pickerDetails)); + else expanded.delete(key(details.dataset.pickerDetails)); + }, true); + root.addEventListener("input", (event) => { if (event.target === query) paint(true); }); + root.addEventListener("change", (event) => { if (event.target === source) paint(true); }); root.addEventListener("click", (event) => { const button = event.target.closest("[data-picker-action]"); if (!button || !root.contains(button)) return; diff --git a/public/styles.css b/public/styles.css index 79cc6d0..691c29b 100644 --- a/public/styles.css +++ b/public/styles.css @@ -986,22 +986,35 @@ button.clear-tok.armed { background: rgba(229, 96, 77, .14); border-color: rgba( .skill-assignment-filters { display: grid; grid-template-columns: minmax(0, 2fr) minmax(0, 1fr); gap: 12px; margin-bottom: 18px; } .skill-assignment-filters .field { min-width: 0; margin: 0; } .skill-assignment-filters input, .skill-assignment-filters select { width: 100%; min-width: 0; } -.skill-assignment-columns { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); gap: 18px; align-items: start; } -.skill-assignment-panel { min-width: 0; background: var(--panel-dark); border: 1px solid var(--line-soft); border-radius: 12px; padding: 16px; } -.skill-assignment-panel > h3 { font-size: 15px; margin: 0 0 6px; } +.skill-assignment-columns { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); gap: 18px; align-items: stretch; } +.skill-assignment-panel { min-width: 0; height: clamp(360px, 65vh, 600px); box-sizing: border-box; display: flex; flex-direction: column; background: var(--panel-dark); border: 1px solid var(--line-soft); border-radius: 12px; padding: 16px; } +.skill-assignment-panel > h3 { flex-shrink: 0; font-size: 15px; margin: 0 0 6px; } +.skill-assignment-panel > .skills-note { flex-shrink: 0; } .skill-assignment-panel h3 > span, .skill-assignment-group h4 > span { font-size: 11px; font-weight: 500; color: var(--muted); } -.skill-assignment-group { margin-top: 18px; } +.skill-assignment-panel > [data-picker-active], .skill-assignment-panel > [data-picker-available] { flex: 1; min-height: 0; overflow-y: auto; scrollbar-gutter: stable; margin-top: 12px; } +.skill-assignment-group { margin-top: 16px; } +.skill-assignment-group:first-child { margin-top: 0; } .skill-assignment-group h4 { display: flex; align-items: center; flex-wrap: wrap; gap: 8px; margin: 0 0 6px; font-size: 12px; } -.skill-assignment-list { max-height: 320px; overflow-y: auto; scrollbar-gutter: stable; } -.skill-assignment-catalog { max-height: 620px; margin-top: 16px; } -.skill-assignment-row { display: flex; align-items: center; gap: 12px; padding: 12px 0; border-bottom: 1px solid var(--line-soft); } +.skill-assignment-row { display: flex; align-items: flex-start; gap: 8px; padding: 4px 0; border-bottom: 1px solid var(--line-soft); } .skill-assignment-row:last-child { border-bottom: 0; } -.skill-assignment-row > div { flex: 1; min-width: 0; } -.skill-assignment-row strong, .skill-assignment-row code { display: block; overflow-wrap: anywhere; font-size: 12px; } -.skill-assignment-row code { color: var(--muted); font-size: 11px; margin-top: 4px; } -.skill-assignment-row p { color: var(--muted); font-size: 12px; line-height: 1.5; margin: 6px 0 0; overflow-wrap: anywhere; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; } -.skill-assignment-source { display: block; color: var(--faint); font-size: 11px; margin-top: 4px; overflow-wrap: anywhere; } +.skill-assignment-row > details { flex: 1; min-width: 0; } +.skill-assignment-row summary { display: flex; align-items: center; gap: 6px; min-height: 30px; cursor: pointer; list-style: none; } +.skill-assignment-row summary::-webkit-details-marker { display: none; } +.skill-assignment-row summary::before { content: "▸"; flex: 0 0 10px; color: var(--faint); } +.skill-assignment-row details[open] > summary::before { content: "▾"; } +.skill-assignment-identity { display: flex; align-items: baseline; gap: 8px; min-width: 0; flex: 1; } +.skill-assignment-identity strong, .skill-assignment-identity code, .skill-assignment-source { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; min-width: 0; } +.skill-assignment-identity strong { font-size: 12px; flex: 0 1 auto; } +.skill-assignment-identity code { font-size: 11px; color: var(--muted); flex: 0 2 auto; } +.skill-assignment-source { color: var(--faint); font-size: 11px; flex: 1 3 auto; } +.skill-assignment-description { padding: 4px 0 8px 16px; font-size: 12px; color: var(--muted); overflow-wrap: anywhere; } +.skill-assignment-description p { margin: 0 0 8px; line-height: 1.6; white-space: pre-wrap; } +.skill-assignment-description dl { display: grid; grid-template-columns: auto minmax(0, 1fr); gap: 4px 10px; margin: 0; } +.skill-assignment-description dt { color: var(--faint); } +.skill-assignment-description dd { margin: 0; } .skill-assignment-row > button { flex-shrink: 0; padding: 6px 10px; font-size: 12px; } +.skill-assignment-row > .skill-assignment-locked { line-height: 30px; } +.skill-assignment-row summary:focus-visible { outline: 2px solid var(--orange); outline-offset: -2px; } .skill-assignment-locked { color: var(--faint); font-size: 11px; white-space: nowrap; } .skill-assignment-row > button:focus-visible { outline: 2px solid var(--orange); outline-offset: 2px; } .skill-assignment-empty { color: var(--faint); font-size: 12px; padding: 14px 0; margin: 0; } @@ -1010,6 +1023,7 @@ button.clear-tok.armed { background: rgba(229, 96, 77, .14); border-color: rgba( @media (max-width: 540px) { .skill-assignment-filters { grid-template-columns: minmax(0, 1fr); } .skill-assignment-panel { padding: 12px; } + .skill-assignment-identity > .skill-assignment-source { display: none; } .skills-tabs { overflow-x: auto; } .skills-tab { white-space: nowrap; } } diff --git a/test/skill-assignment-browser.test.js b/test/skill-assignment-browser.test.js index 8305ead..dc04439 100644 --- a/test/skill-assignment-browser.test.js +++ b/test/skill-assignment-browser.test.js @@ -176,3 +176,57 @@ test("conversation and template skill pickers preserve grants, drafts, filtering await screenshot("skill-template-mobile.png"); assert.deepEqual(errors, []); }); + +test("compact skill lists match heights and reveal details without changing selection", { skip: !process.env.CG_BROWSER_MODULE }, async (t) => { + const { chromium } = await import(process.env.CG_BROWSER_MODULE); + const app = express(); + app.use(express.static(fileURLToPath(new URL("../public", import.meta.url)))); + app.get("/picker-fixture", (_req, res) => res.send('
')); + const server = await new Promise((resolve) => { const s = app.listen(0, "127.0.0.1", () => resolve(s)); }); + t.after(() => new Promise((resolve) => { server.closeAllConnections(); server.close(resolve); })); + const browser = await chromium.launch({ headless: true, args: ["--no-sandbox"] }); + t.after(() => browser.close()); + const page = await browser.newPage({ viewport: { width: 1440, height: 900 } }); + await page.goto(`http://127.0.0.1:${server.address().port}/picker-fixture`); + await page.evaluate(async () => { + const { mountSkillAssignmentPicker } = await import('/skill-assignment-picker.js'); + const skills = Array.from({ length: 80 }, (_, i) => ({ slug: `skill-${String(i).padStart(2, "0")}`, name: `Skill ${String(i).padStart(2, "0")}`, sourceId: 1, currentRevisionId: 1, enabled: true, description: 'Full description with and enough detail to span multiple lines. '.repeat(5) })); + globalThis.pickerChanges = 0; + mountSkillAssignmentPicker(globalThis.document.getElementById('picker'), { skills, sources: [{ id: 1, label: 'Example source' }], selected: skills.slice(0, 30).map((s) => s.slug), onChange: () => { globalThis.pickerChanges++; } }); + }); + const picker = page.locator('#picker'); + const panels = picker.locator('.skill-assignment-panel'); + const sameHeight = async () => { + const heights = await panels.evaluateAll((els) => els.map((el) => el.getBoundingClientRect().height)); + assert.ok(Math.abs(heights[0] - heights[1]) < 1, 'both panels have equal fixed heights'); + return heights[0]; + }; + const height = await sameHeight(); + const row = picker.locator('[data-skill="skill-00"]'); + assert.ok((await row.boundingBox()).height <= 42, 'collapsed row remains compact'); + assert.equal(await row.locator('.skill-assignment-description').isVisible(), false); + const inline = await row.locator('.skill-assignment-identity').evaluate((el) => [...el.children].map((child) => child.getBoundingClientRect().top)); + assert.ok(Math.max(...inline) - Math.min(...inline) < 5, 'name, slug and source share one line'); + await row.locator('summary').click(); + assert.equal(await row.locator('.skill-assignment-description').isVisible(), true); + assert.match(await row.locator('.skill-assignment-description').textContent(), /Full description with /); + assert.equal(await row.locator('.skill-assignment-description literal').count(), 0, 'description is escaped'); + assert.equal(await page.evaluate(() => globalThis.pickerChanges), 0, 'opening details never changes grants'); + assert.equal(await sameHeight(), height, 'expanded details cannot grow either panel'); + await row.locator('summary').press('Enter'); + assert.equal(await row.locator('.skill-assignment-description').isVisible(), false); + const available = picker.locator('[data-picker-available]'); + await available.evaluate((el) => { el.scrollTop = 450; }); + const scrolled = await available.evaluate((el) => el.scrollTop); + await picker.getByRole('button', { name: 'Add skill-45', exact: true }).click(); + assert.equal(await page.evaluate(() => globalThis.pickerChanges), 1); + assert.ok(Math.abs(await available.evaluate((el) => el.scrollTop) - scrolled) < 2, 'adding from a long list retains its scroll position'); + assert.equal(await picker.locator('[data-skill="skill-45"] details').getAttribute('open'), null, 'Add does not open details'); + if (process.env.CG_UI_SCREENSHOTS) await page.screenshot({ path: path.join(process.env.CG_UI_SCREENSHOTS, 'skill-compact-desktop.png'), fullPage: true }); + await page.setViewportSize({ width: 390, height: 844 }); + await sameHeight(); + assert.equal(await picker.evaluate((el) => el.scrollWidth <= el.clientWidth), true); + await picker.locator('[data-skill="skill-00"] summary').click(); + assert.equal(await picker.locator('[data-skill="skill-00"] .skill-assignment-description').isVisible(), true); + if (process.env.CG_UI_SCREENSHOTS) await page.screenshot({ path: path.join(process.env.CG_UI_SCREENSHOTS, 'skill-compact-mobile.png'), fullPage: true }); +}); From 501c5626065374fe3e502f8acc33273869c002b1 Mon Sep 17 00:00:00 2001 From: Tiberiu Socaci Date: Tue, 8 Sep 2026 16:19:45 +0300 Subject: [PATCH 11/44] Offer wait or force for daemon restarts Signed-off-by: Tiberiu Socaci --- FEATURES.md | 7 +- TEST-PLAN.md | 16 ++++ public/admin-view.js | 19 ++++- public/app.js | 20 ++--- public/index.html | 3 +- .../references/administration.md | 4 + src/gateway/restart.js | 50 ++++++++++-- src/gateway/shutdown.js | 9 ++- src/server.js | 4 +- src/web/routes/settings.js | 13 ++-- test/admin-password-dialog.test.js | 2 +- test/restart-choice.test.js | 78 +++++++++++++++++++ test/restart-coordinator.test.js | 64 +++++++++++++++ test/run-api.test.js | 16 ++++ test/runtime-lifecycle.test.js | 42 ++++++++++ 15 files changed, 313 insertions(+), 34 deletions(-) create mode 100644 test/restart-choice.test.js diff --git a/FEATURES.md b/FEATURES.md index 1765260..528c606 100644 --- a/FEATURES.md +++ b/FEATURES.md @@ -2241,8 +2241,11 @@ are retired, bullet by bullet; everything else stands. Settings toggle (`agentsFile`) still governs the whole feature. → TEST-PLAN: Admin UI. - Optional admin **password login** for the UI/API (httpOnly session cookie); set, change, or remove it from Settings → System (or `ADMIN_PASSWORD`). `/api/health` and the login routes stay open. -- Daemon controls (Settings → System): **Restart daemon** (polls health, reloads) and Slack - disconnect/reconnect. The old "Stop daemon" button was removed as a footgun. +- Daemon controls (Settings → System): **Restart daemon** asks for **Wait until idle**, + **Force restart**, or Cancel. Wait preserves the five-minute idle check; Force interrupts active + turns/jobs and skips the drain, preserving recovery markers and process cleanup. A pending wait + can be upgraded to Force. The UI polls health and reloads on the new daemon instance; systemd + receives the restart exit code even with `Restart=on-failure`. Slack supports disconnect/reconnect. The old "Stop daemon" button was removed as a footgun. - **Update regression checks isolate runtime paths**: the aggregate test runner removes inherited production runtime/database/workspace selectors before launching fixtures. Backup, restore, and maintenance tests pin both current and legacy path variables, including direct `node --test` diff --git a/TEST-PLAN.md b/TEST-PLAN.md index 54fceec..6e825f3 100644 --- a/TEST-PLAN.md +++ b/TEST-PLAN.md @@ -1535,6 +1535,22 @@ structural invariants are automated; rendered navigation and feature claims also restarts only after a clear observation, cancels at the five-minute deadline, coalesces concurrent restart requests, and exposes waiting/cancelled state to the Admin UI. → `restart-coordinator.test.js`, `run-api.test.js`. +- [x] Restart choice regression (engine-independent UI/API/coordinator fixtures): Settings opens + Wait until idle / Force restart / Cancel; Enter defaults to Wait, focused Force works, Escape + cancels without a request. API requires authentication and CSRF and rejects nonboolean force. + Busy force skips waiting; upgrading an existing wait keeps the same request id and wakes its + poll, including a slow notification. Forced shutdown marks interrupted work, sweeps engines, + and uses the systemd restart exit code. → `restart-choice.test.js`, `run-api.test.js`, + `restart-coordinator.test.js`, `runtime-lifecycle.test.js`. +- [ ] Live restart choice acceptance (repeat separately with Claude and Codex in a disposable + container-backed gateway managed by a user systemd unit with `Restart=on-failure`): start an + engine turn with prompt “Run sleep 120, then reply finished”; while its process is active, + open Settings → System → Restart daemon. Cancel must leave the daemon instance unchanged. + Choose Wait: status must show waiting and the same instance; choose Restart again then Force: + the instance must change promptly, old engine processes must disappear, and the interrupted + turn must recover on boot in the same thread. Repeat Wait alone: completion precedes restart. + Pass only if both engines meet every observation. Not executed for this change; private QA + registry records also pending because the personal Airtable connection is unavailable. - [ ] When the job finishes, the thread shows a "🔔 … finished — continuing…" notice and then the agent's continuation, with prior context intact (same session resumed). → bg_start/bg_finish in `logs/`. - [ ] A failing/non-zero-exit job still continues, with the output tail handed to the agent. diff --git a/public/admin-view.js b/public/admin-view.js index c60fae0..59d910a 100644 --- a/public/admin-view.js +++ b/public/admin-view.js @@ -3,13 +3,14 @@ import { api } from "./admin-api.js"; export const escapeHtml = (s) => String(s ?? "").replace(/[&<>"']/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[c])); -export function openDialog({ title, body, confirmLabel, cancelLabel, danger, confirmOnly, password = false }) { +export function openDialog({ title, body, confirmLabel, cancelLabel, danger, confirmOnly, password = false, alternativeLabel = "", alternativeDanger = false }) { return new Promise((resolve) => { const modal = document.getElementById("confirm-modal"); const titleEl = document.getElementById("confirm-title"); const bodyEl = document.getElementById("confirm-body"); const okBtn = document.getElementById("confirm-ok"); const cancelBtn = document.getElementById("confirm-cancel"); + const alternativeBtn = document.getElementById("confirm-alternative"); const passwordField = document.getElementById("confirm-password-field"); const passwordInput = document.getElementById("confirm-password"); const previousFocus = document.activeElement; @@ -22,6 +23,9 @@ export function openDialog({ title, body, confirmLabel, cancelLabel, danger, con okBtn.classList.toggle("danger-btn", !!danger); cancelBtn.textContent = cancelLabel || "Cancel"; cancelBtn.hidden = !!confirmOnly; + alternativeBtn.hidden = !alternativeLabel; + alternativeBtn.textContent = alternativeLabel; + alternativeBtn.classList.toggle("danger-btn", !!alternativeDanger); modal.hidden = false; const done = (value) => { modal.hidden = true; @@ -30,6 +34,8 @@ export function openDialog({ title, body, confirmLabel, cancelLabel, danger, con previousFocus?.focus?.(); okBtn.removeEventListener("click", onOk); cancelBtn.removeEventListener("click", onCancel); + alternativeBtn.removeEventListener("click", onAlternative); + alternativeBtn.hidden = true; modal.removeEventListener("click", onBackdrop); document.removeEventListener("keydown", onKey); resolve(value); @@ -39,12 +45,18 @@ export function openDialog({ title, body, confirmLabel, cancelLabel, danger, con done(password ? passwordInput.value : true); }; const onCancel = () => done(password ? "" : false); + const onAlternative = () => done("alternative"); const onBackdrop = (event) => { if (event.target === modal) onCancel(); }; const onKey = (event) => { if (event.key === "Escape") onCancel(); - else if (event.key === "Enter") { event.preventDefault(); onOk(); } + else if (event.key === "Enter") { + event.preventDefault(); + if (document.activeElement === alternativeBtn && alternativeLabel) onAlternative(); + else if (document.activeElement === cancelBtn && !confirmOnly) onCancel(); + else onOk(); + } else if (event.key === "Tab") { - const controls = [password ? passwordInput : null, confirmOnly ? null : cancelBtn, okBtn].filter(Boolean); + const controls = [password ? passwordInput : null, confirmOnly ? null : cancelBtn, alternativeLabel ? alternativeBtn : null, okBtn].filter(Boolean); const current = controls.indexOf(document.activeElement); const next = (current + (event.shiftKey ? -1 : 1) + controls.length) % controls.length; event.preventDefault(); @@ -53,6 +65,7 @@ export function openDialog({ title, body, confirmLabel, cancelLabel, danger, con }; okBtn.addEventListener("click", onOk); cancelBtn.addEventListener("click", onCancel); + alternativeBtn.addEventListener("click", onAlternative); modal.addEventListener("click", onBackdrop); document.addEventListener("keydown", onKey); (password ? passwordInput : okBtn).focus(); diff --git a/public/app.js b/public/app.js index b27dea1..7fafd8c 100644 --- a/public/app.js +++ b/public/app.js @@ -3550,25 +3550,27 @@ function bindSettings() { }); document.getElementById("restart-daemon").addEventListener("click", async () => { - const ok = await confirmDialog({ + const choice = await confirmDialog({ title: "Restart the daemon?", - body: "The gateway checks ongoing turns and jobs first, waits up to five minutes, and restarts only after it becomes idle.", - confirmLabel: "Restart when idle", - danger: true, + body: "Wait until idle allows ongoing work to finish (up to five minutes). Force restart skips the wait and interrupts active turns and jobs; an update in progress may fail.", + confirmLabel: "Wait until idle", + alternativeLabel: "Force restart", + alternativeDanger: true, }); - if (!ok) return; + if (!choice) return; + const force = choice === "alternative"; const msg = document.getElementById("daemon-msg"); let before; let started; try { before = await api("/api/health"); - started = await api("/api/daemon/restart", { method: "POST" }); + started = await api("/api/daemon/restart", { method: "POST", body: JSON.stringify({ force }) }); } catch (e) { - msg.textContent = "✗ " + e.message; + msg.textContent = "✗ " + (e.body?.message || e.message); return; } - msg.textContent = "Checking ongoing work…"; - const deadline = Date.now() + Number(started.waitMs || 300_000) + 60_000; + msg.textContent = force ? "Force restart requested — active work will be interrupted…" : "Checking ongoing work…"; + const deadline = Date.now() + (force ? 0 : Number(started.waitMs || 300_000)) + 60_000; const timer = setInterval(async () => { try { const health = await api("/api/health"); diff --git a/public/index.html b/public/index.html index 46c822c..ee55a3c 100644 --- a/public/index.html +++ b/public/index.html @@ -926,7 +926,7 @@

Danger zone

- Restart daemonWaits up to five minutes for ongoing turns and jobs, then relaunches only when the gateway is idle. + Restart daemonChoose to wait for ongoing work or force a restart and interrupt active turns and jobs.
@@ -1042,6 +1042,7 @@

diff --git a/src/gateway/gateway-usage/references/administration.md b/src/gateway/gateway-usage/references/administration.md index 301811c..f4119ab 100644 --- a/src/gateway/gateway-usage/references/administration.md +++ b/src/gateway/gateway-usage/references/administration.md @@ -237,6 +237,10 @@ admin rights or change the permissions required by separate gateway control tool `~/.channelgate/update-backups/`. ## Restarting the gateway safely (admin) +- Settings → System → Restart daemon offers **Wait until idle**, **Force restart**, and Cancel. + Wait checks for idle for up to five minutes. Force interrupts active turns/jobs and can disrupt + an update; it also upgrades a pending wait immediately. The UI reloads after the new daemon starts. + The chat tool below retains its safe wait behavior. - Use `restart_gateway`; never run `systemctl`, `kill`, or another restart command via `run_in_background`. - The daemon lets the requesting turn finish, then checks foreground/queued engine turns, diff --git a/src/gateway/restart.js b/src/gateway/restart.js index 7095b03..107a4d5 100644 --- a/src/gateway/restart.js +++ b/src/gateway/restart.js @@ -66,15 +66,23 @@ export class RestartCoordinator { this.current = null; this.latest = null; this.pending = null; + this.forceSignal = null; + this.resolveForce = null; } - request({ channelId = "", threadKey = "", requestedBy = "", reason = "gateway restart" } = {}) { + request({ channelId = "", threadKey = "", requestedBy = "", reason = "gateway restart", force = false } = {}) { if (this.current) { + if (force === true && !this.current.force && ["settling", "waiting"].includes(this.current.phase)) { + this.current.force = true; + this.current.message = "Force restart requested. Ongoing work will be interrupted."; + this.resolveForce?.(); + return { ok: true, id: this.current.id, force: true, upgraded: true, waitMs: 0, pollMs: this.pollMs, message: this.current.message }; + } return { ok: false, conflict: true, id: this.current.id, - message: "A safe gateway restart is already waiting for ongoing work to finish.", + message: this.current.force ? "A force gateway restart is already pending." : "A safe gateway restart is already waiting for ongoing work to finish.", }; } const record = { @@ -84,10 +92,13 @@ export class RestartCoordinator { requestedBy: String(requestedBy || ""), reason: String(reason || "gateway restart"), requestedAt: this.now(), + force: force === true, phase: "settling", activity: null, - message: "Waiting for the requesting turn to finish before checking gateway activity.", + message: force === true ? "Force restart queued. Ongoing work will be interrupted." : "Waiting for the requesting turn to finish before checking gateway activity.", }; + this.forceSignal = new Promise((resolve) => { this.resolveForce = resolve; }); + if (record.force) this.resolveForce(); this.current = record; this.latest = record; this.pending = Promise.resolve() @@ -99,9 +110,10 @@ export class RestartCoordinator { return { ok: true, id: record.id, - waitMs: this.waitMs, + force: record.force, + waitMs: record.force ? 0 : this.waitMs, pollMs: this.pollMs, - message: "Safe restart queued. The gateway will wait for ongoing work to finish before restarting.", + message: record.force ? "Force restart queued. Ongoing work will be interrupted." : "Safe restart queued. The gateway will wait for ongoing work to finish before restarting.", }; } @@ -116,7 +128,8 @@ export class RestartCoordinator { phase: record.phase, activity: record.activity, message: record.message, - waitMs: this.waitMs, + force: record.force, + waitMs: record.force ? 0 : this.waitMs, pollMs: this.pollMs, }; } @@ -127,12 +140,21 @@ export class RestartCoordinator { async _notify(record, text) { try { - await this.notify({ ...record, text }); + await Promise.race([this.notify({ ...record, text }), this.forceSignal]); } catch { // Visibility is best-effort; a Slack outage must not turn the lifecycle guard into a crash. } } + async _waitForPoll(ms) { + const controller = new AbortController(); + try { + await Promise.race([this.sleep(ms, undefined, { signal: controller.signal }), this.forceSignal]); + } finally { + controller.abort(); // retire the losing timer when a force upgrade wakes the poll + } + } + async _run(record) { if (this.settleMs > 0) await this.sleep(this.settleMs); const startedAt = this.now(); @@ -142,6 +164,17 @@ export class RestartCoordinator { for (;;) { let activity = this.getActivity(); record.activity = activity; + if (record.force) { + record.phase = "restarting"; + record.message = "Force restarting the gateway; ongoing work will be interrupted."; + // Status is already visible to the HTTP caller. A slow chat notification must not hold + // an explicitly forced restart behind the activity it was requested to interrupt. + void this._notify(record, "🔄 Force restarting the gateway now. Ongoing work will be interrupted."); + await this.restart({ reason: record.reason, force: true }); + record.phase = "restarted"; + record.message = "Gateway force restart started."; + return { restarted: true, force: true, activity }; + } if (activity.total === 0) { record.phase = "restarting"; record.message = wasBusy @@ -177,6 +210,7 @@ export class RestartCoordinator { } } + if (record.force) continue; // upgraded while the busy notification was in flight const elapsed = this.now() - startedAt; if (elapsed >= this.waitMs) { record.phase = "cancelled"; @@ -187,7 +221,7 @@ export class RestartCoordinator { ); return { restarted: false, reason: "busy", activity }; } - await this.sleep(Math.min(this.pollMs, Math.max(1, this.waitMs - elapsed))); + await this._waitForPoll(Math.min(this.pollMs, Math.max(1, this.waitMs - elapsed))); } } } diff --git a/src/gateway/shutdown.js b/src/gateway/shutdown.js index dbb6a23..7411a7f 100644 --- a/src/gateway/shutdown.js +++ b/src/gateway/shutdown.js @@ -91,6 +91,7 @@ export async function performShutdown({ slack = null, code = 0, reason = "shutdown", + force = false, drainTimeoutMs = configuredDrainMs(), pollMs = DEFAULT_POLL_MS, killAfterMs = DEFAULT_KILL_AFTER_MS, @@ -105,6 +106,8 @@ export async function performShutdown({ logger = console, sleep = delay, } = {}) { + if (force === true) drainTimeoutMs = 0; + // Stop accepting new Socket Mode envelopes first. The Web API client remains usable by turns // already finishing, so their progress/final delivery can complete during the drain window. const disconnecting = Promise.resolve(slack?.disconnect?.()).catch((error) => { @@ -167,10 +170,10 @@ export function restartExitCode({ platform, env } = {}) { return detectServiceManager({ platform, env }) === "systemd" ? 1 : 0; } -export function requestShutdown({ slack = null, code = 0, reason = "shutdown" } = {}) { +export function requestShutdown({ slack = null, code = 0, reason = "shutdown", force = false } = {}) { if (shutdownPromise) return shutdownPromise; shuttingDown = true; - const drainTimeoutMs = configuredDrainMs(); + const drainTimeoutMs = force === true ? 0 : configuredDrainMs(); // Absolute fallback covers a stuck drain dependency. It performs synchronous SIGKILL sweeps // before exiting, so detached children cannot survive merely because an escalation timer was @@ -185,7 +188,7 @@ export function requestShutdown({ slack = null, code = 0, reason = "shutdown" } }, hardDeadlineMs); hardExit.unref?.(); - shutdownPromise = performShutdown({ slack, code, reason, drainTimeoutMs }) + shutdownPromise = performShutdown({ slack, code, reason, force, drainTimeoutMs }) .catch((error) => { forceStopping = true; forceKillEngineChildren(); diff --git a/src/server.js b/src/server.js index cd0ee5a..e13b5dc 100644 --- a/src/server.js +++ b/src/server.js @@ -38,7 +38,7 @@ import { startDriveSync } from "./gateway/drivesync.js"; import { configDir } from "./config/paths.js"; import { hardenRuntimeFiles, ensureAdminPasswordOnFirstBoot, isOperatorConfigured, assertRuntimeHardening } from "./config/harden.js"; import { acquireSingletonLock } from "./util/singleton.js"; -import { requestShutdown } from "./gateway/shutdown.js"; +import { requestShutdown, restartExitCode } from "./gateway/shutdown.js"; import { RestartCoordinator } from "./gateway/restart.js"; import { randomUUID, randomBytes } from "node:crypto"; import { mkdirSync, rmSync, writeFileSync } from "node:fs"; @@ -263,7 +263,7 @@ async function main() { } setActiveBackgroundJobs(backgroundJobs); // expose to the Slack /status command const restartCoordinator = new RestartCoordinator({ - restart: ({ reason }) => requestShutdown({ slack, code: 0, reason }), + restart: ({ reason, force = false }) => requestShutdown({ slack, code: restartExitCode(), reason, force }), notify: async ({ channelId, threadKey, text }) => { if (!channelId || !threadKey || !slack.snapshot?.().connected) return; await postNotice(slack.getClient?.(), { conversationId: channelId, threadKey, text }); diff --git a/src/web/routes/settings.js b/src/web/routes/settings.js index 33d937a..3cfc36a 100644 --- a/src/web/routes/settings.js +++ b/src/web/routes/settings.js @@ -553,16 +553,19 @@ export function createSettingsRouter({ } }); - // Restart the whole daemon only after the safe-restart coordinator observes an idle window. - // The coordinator leaves Slack connected while active work drains, rechecks for up to five - // minutes, and cancels instead of interrupting anything still running. The unit's + // Default to waiting for idle. An explicit admin force choice skips the activity wait + // and interrupts ongoing work through the normal shutdown cleanup. The unit's // Restart=on-failure relaunches after the nonzero restart exit (only when installed as the // systemd service — see shutdown.js restartExitCode). - router.post("/daemon/restart", (_req, res) => { + router.post("/daemon/restart", (req, res) => { if (!restartCoordinator) { return res.status(503).json({ ok: false, error: "Safe restart is unavailable." }); } - const result = restartCoordinator.request({ reason: "admin restart" }); + if (Object.hasOwn(req.body || {}, "force") && typeof req.body.force !== "boolean") { + return res.status(400).json({ ok: false, error: "force must be a boolean." }); + } + const force = req.body?.force === true; + const result = restartCoordinator.request({ reason: force ? "admin force restart" : "admin restart", force }); res.status(result.conflict ? 409 : 202).json(result); }); diff --git a/test/admin-password-dialog.test.js b/test/admin-password-dialog.test.js index b7c82e1..dfa6aab 100644 --- a/test/admin-password-dialog.test.js +++ b/test/admin-password-dialog.test.js @@ -11,7 +11,7 @@ test("admin reauthentication uses a masked dialog that clears secrets on accept const savedDocument = globalThis.document; const document = new EventTarget(); const controls = new Map(); - for (const id of ["confirm-modal", "confirm-title", "confirm-body", "confirm-ok", "confirm-cancel", "confirm-password-field", "confirm-password", "previous"]) { + for (const id of ["confirm-modal", "confirm-title", "confirm-body", "confirm-ok", "confirm-cancel", "confirm-alternative", "confirm-password-field", "confirm-password", "previous"]) { const control = new EventTarget(); Object.assign(control, { style: {}, classList: { toggle() {} }, hidden: true, value: "", focus() { document.activeElement = control; } }); controls.set(id, control); diff --git a/test/restart-choice.test.js b/test/restart-choice.test.js new file mode 100644 index 0000000..0f0f9a7 --- /dev/null +++ b/test/restart-choice.test.js @@ -0,0 +1,78 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import { runInNewContext } from "node:vm"; +import { confirmDialog } from "../public/admin-view.js"; + +function dialogFixture(t) { + const previous = globalThis.document; + const document = new EventTarget(); + const controls = new Map(); + for (const id of ["confirm-modal", "confirm-title", "confirm-body", "confirm-ok", "confirm-cancel", "confirm-alternative", "confirm-password-field", "confirm-password"]) { + const control = new EventTarget(); + Object.assign(control, { style: {}, hidden: false, value: "", classList: { toggle() {} }, focus() { document.activeElement = control; } }); + controls.set(id, control); + } + document.getElementById = (id) => controls.get(id); + globalThis.document = document; + t.after(() => { globalThis.document = previous; }); + const key = (key) => { + const event = new Event("keydown", { cancelable: true }); + Object.assign(event, { key }); + document.dispatchEvent(event); + }; + return { document, controls, key }; +} + +test("restart dialog defaults to wait, supports explicit force, and keeps cancellation separate", async (t) => { + const { document, controls, key } = dialogFixture(t); + const options = { title: "Restart?", confirmLabel: "Wait until idle", alternativeLabel: "Force restart", alternativeDanger: true }; + const wait = confirmDialog(options); + assert.equal(document.activeElement, controls.get("confirm-ok")); + assert.equal(controls.get("confirm-alternative").hidden, false); + key("Enter"); + assert.equal(await wait, true); + const force = confirmDialog(options); + controls.get("confirm-alternative").focus(); + key("Enter"); + assert.equal(await force, "alternative"); + assert.equal(controls.get("confirm-alternative").hidden, true); + const cancelled = confirmDialog(options); + key("Escape"); + assert.equal(await cancelled, false); + const ordinary = confirmDialog({ title: "Ordinary confirmation" }); + assert.equal(controls.get("confirm-alternative").hidden, true, "force action never leaks into another dialog"); + controls.get("confirm-cancel").dispatchEvent(new Event("click")); + assert.equal(await ordinary, false); +}); + +const source = readFileSync(new URL("../public/app.js", import.meta.url), "utf8"); +const start = source.indexOf(' document.getElementById("restart-daemon").addEventListener'); +const endMarker = "\n });"; +const listener = source.slice(start, source.indexOf(endMarker, start) + endMarker.length); + +test("Settings sends the selected restart policy and sends nothing on cancellation", async () => { + for (const [choice, expected] of [[true, false], ["alternative", true], [false, null]]) { + let handler; + let dialog; + const calls = []; + const msg = { textContent: "" }; + const context = { + document: { getElementById: (id) => id === "restart-daemon" ? { addEventListener: (_event, fn) => { handler = fn; } } : msg }, + confirmDialog: async (options) => { dialog = options; return choice; }, + api: async (url, options) => { calls.push({ url, options }); return url.includes("health") ? { instanceId: "before" } : { id: "restart", waitMs: 300_000 }; }, + setInterval() {}, clearInterval() {}, Date, Number, JSON, + }; + runInNewContext(listener, context); + await handler(); + assert.match(dialog.body, /interrupts active turns and jobs/); + assert.equal(dialog.confirmLabel, "Wait until idle"); + assert.equal(dialog.alternativeLabel, "Force restart"); + const mutation = calls.find((call) => call.options?.method === "POST"); + if (expected === null) assert.equal(calls.length, 0); + else { + assert.equal(mutation.url, "/api/daemon/restart"); + assert.deepEqual(JSON.parse(mutation.options.body), { force: expected }); + } + } +}); diff --git a/test/restart-coordinator.test.js b/test/restart-coordinator.test.js index 85c1a7e..418e2b8 100644 --- a/test/restart-coordinator.test.js +++ b/test/restart-coordinator.test.js @@ -105,3 +105,67 @@ test("safe restart cancels after its deadline while work remains active", async assert.match(coordinator.status().message, /API run/i); assert.equal(coordinator.status("wrong-id").ok, false); }); + +test("force restart bypasses busy engine, background, API, and updater activity", async () => { + const restarts = []; + const sleeps = []; + const coordinator = new RestartCoordinator({ + getActivity: () => ({ total: 4, engine: 1, background: 1, api: 1, update: 1 }), + restart: async (input) => restarts.push(input), + sleep: async (ms) => sleeps.push(ms), + settleMs: 2, + }); + const request = coordinator.request({ force: true, reason: "explicit force" }); + assert.equal(request.force, true); + assert.equal(request.waitMs, 0); + assert.equal((await coordinator.whenSettled()).restarted, true); + assert.deepEqual(sleeps, [2], "only the HTTP response settle remains; no activity polling"); + assert.deepEqual(restarts, [{ reason: "explicit force", force: true }]); + assert.equal(coordinator.status(request.id).force, true); +}); + +test("force upgrades a pending safe restart promptly and aborts its poll without duplicate shutdown", async () => { + let pollStarted; + const polling = new Promise((resolve) => { pollStarted = resolve; }); + let signal; + const restarts = []; + const coordinator = new RestartCoordinator({ + getActivity: () => ({ total: 1, engine: 1 }), + restart: async (input) => restarts.push(input), + sleep: (_ms, _value, options) => { + signal = options.signal; + pollStarted(); + return new Promise(() => {}); // advancing the long poll is deliberately impossible + }, + settleMs: 0, + pollMs: 30_000, + }); + const first = coordinator.request({ reason: "pending wait" }); + await polling; + const forced = coordinator.request({ force: true }); + const duplicate = coordinator.request({ force: true }); + assert.equal(forced.ok, true); + assert.equal(forced.id, first.id); + assert.equal(forced.upgraded, true); + assert.equal(duplicate.conflict, true); + assert.equal((await coordinator.whenSettled()).restarted, true); + assert.equal(signal.aborted, true); + assert.deepEqual(restarts, [{ reason: "pending wait", force: true }]); +}); + +test("force upgrade is not delayed by a pending busy notification", async () => { + let notified; + const noticeStarted = new Promise((resolve) => { notified = resolve; }); + let restarts = 0; + const coordinator = new RestartCoordinator({ + getActivity: () => ({ total: 1, api: 1 }), + restart: async ({ force }) => { assert.equal(force, true); restarts++; }, + notify: () => { notified(); return new Promise(() => {}); }, + settleMs: 0, + }); + coordinator.request(); + await noticeStarted; + coordinator.request({ force: true }); + await coordinator.whenSettled(); + assert.equal(restarts, 1); +}); diff --git a/test/run-api.test.js b/test/run-api.test.js index 443eb92..ecc6f63 100644 --- a/test/run-api.test.js +++ b/test/run-api.test.js @@ -198,6 +198,22 @@ test("POST /api/update/run refuses non-Enterprise before invoking the updater", } finally { testLicenseEnv(); } }); +test("admin restart accepts only explicit boolean force behind session and CSRF checks", async () => { + const before = restartRequests.length; + assert.equal((await post("/api/daemon/restart", { force: true })).status, 401); + const login = await post("/api/login", { password: "test-admin-pw" }); + const cookie = login.headers.get("set-cookie").split(";", 1)[0]; + assert.equal((await post("/api/daemon/restart", { force: true }, { cookie })).status, 403); + for (const force of ["true", 1, null, {}, []]) { + assert.equal((await post("/api/daemon/restart", { force }, { cookie, "x-cg-request": "1" })).status, 400); + } + assert.equal(restartRequests.length, before); + const result = await post("/api/daemon/restart", { force: true }, { cookie, "x-cg-request": "1" }); + assert.equal(result.status, 202); + assert.deepEqual(restartRequests.at(-1), { reason: "admin force restart", force: true }); + restartRequests.length = before; +}); + test("POST /internal/update-smoke requires loopback IPC auth", async () => { const missing = await post("/internal/update-smoke", {}); assert.equal(missing.status, 403); diff --git a/test/runtime-lifecycle.test.js b/test/runtime-lifecycle.test.js index a74fcf0..68ddcdb 100644 --- a/test/runtime-lifecycle.test.js +++ b/test/runtime-lifecycle.test.js @@ -1,6 +1,7 @@ import test from "node:test"; import assert from "node:assert/strict"; import { setImmediate as nextTurn } from "node:timers/promises"; +import { readFileSync } from "node:fs"; import { ensureTestEnv } from "./helpers.js"; ensureTestEnv(); @@ -597,3 +598,44 @@ test("an admin restart exits with the code its service manager treats as 'relaun assert.equal(detectServiceManager({ platform: "linux", env: {} }), "none"); assert.equal(restartExitCode({ platform: "linux", env: {} }), 0); }); + +test("force shutdown skips the drain while preserving durable interruptions and both sweep passes", async () => { + const events = []; + const result = await performShutdown({ + force: true, + drainTimeoutMs: 60_000, + killAfterMs: 0, + getActivity: () => ({ total: 1, queued: 1, cold: 1, warmPending: 0 }), + sleep: () => assert.fail("force restart must not enter the configured drain"), + markForce: () => { + events.push("mark-interrupted"); + assert.equal(shouldClearActiveRun({ terminal: false, forceStopping: true }), false); + }, + sweepCold: () => { events.push("cold"); return 1; }, + sweepWarm: () => { events.push("warm"); return 1; }, + sweepColdFinal: () => { events.push("final-cold"); return 0; }, + sweepWarmFinal: () => { events.push("final-warm"); return 0; }, + exit: () => events.push("exit"), + logger: { log() {}, warn() {} }, + }); + assert.equal(result.drained, false); + assert.deepEqual(events, ["mark-interrupted", "cold", "warm", "final-cold", "final-warm", "exit"]); +}); + +test("the server restart callback passes force and the systemd relaunch exit code to shutdown", () => { + const server = readFileSync(new URL("../src/server.js", import.meta.url), "utf8"); + const callback = server.match(/restart: (\(\{ reason[^\n]+?=> requestShutdown\([^\n]+?\)),\n/); + assert.ok(callback, "server must provide its restart shutdown callback"); + const observed = []; + // Execute the actual callback without importing/booting the server or restarting a service. + const restart = new Function("slack", "requestShutdown", "restartExitCode", `return ${callback[1]};`)( + "fixture-slack", (input) => observed.push(input), + () => restartExitCode({ platform: "linux", env: { INVOCATION_ID: "fixture-systemd" } }), + ); + restart({ reason: "wait" }); + restart({ reason: "force", force: true }); + assert.deepEqual(observed, [ + { slack: "fixture-slack", code: 1, reason: "wait", force: false }, + { slack: "fixture-slack", code: 1, reason: "force", force: true }, + ]); +}); From ecc4ac8476dce7b0f35fc2f9dce5f3c9da08078a Mon Sep 17 00:00:00 2001 From: Tiberiu Socaci Date: Tue, 8 Sep 2026 16:20:15 +0300 Subject: [PATCH 12/44] docs: clarify Teams CLI onboarding procedure Signed-off-by: Tiberiu Socaci --- FEATURES.md | 4 ++- TEST-PLAN.md | 12 +++++++- docs/PLATFORMS.md | 55 +++++++++++++++++++++++++++++------ public/index.html | 4 +-- test/teams-onboarding.test.js | 4 +-- 5 files changed, 64 insertions(+), 15 deletions(-) diff --git a/FEATURES.md b/FEATURES.md index 1765260..c7fc8e5 100644 --- a/FEATURES.md +++ b/FEATURES.md @@ -305,7 +305,9 @@ A categorized catalog of what's shipped. Cross-linked to `TEST-PLAN.md` checks. - **Teams onboarding uses Microsoft's CLI end to end**: the Admin UI and operator guide give the install/login commands, generate a copy-ready `teams app create` command from ChannelGate's exact public `/api/teams/messages` event endpoint, map the emitted credentials to Settings, and show - how to obtain the generated app's Teams install link. + how to obtain the generated app's Teams install link. The procedure covers device-code login, + verifying the target tenant, private credential storage, and a real inbound/reply check; CLI + commands are checked against stable 3.0.3. → TEST-PLAN: Google Chat and Teams transports. Setup: `docs/PLATFORMS.md`. ## Engine adapter kernel diff --git a/TEST-PLAN.md b/TEST-PLAN.md index 54fceec..b229daf 100644 --- a/TEST-PLAN.md +++ b/TEST-PLAN.md @@ -785,7 +785,17 @@ Google Workspace / Azure tenant and are unchecked until that drill runs. connector still THROWS on write. - [x] Teams setup in the Admin UI and operator guide names the official Teams CLI install/login flow, the exact `/api/teams/messages` event endpoint, the app-creation command, emitted - credential mapping, and the generated Teams install link. + credential mapping, and the generated Teams install link. Documentation checked against CLI + 3.0.3: `login --device-code`, `status` (no `--verbose`), and positional app ID for `app update`. +- [ ] MANUAL (Teams CLI onboarding; Claude AND Codex): use a tenant with custom-app upload enabled, + a target gateway with public HTTPS, one approved Teams test user, a personal chat and a test + team/channel. Follow `docs/PLATFORMS.md`: device login completes in the same live process, + status identifies the intended tenant, register/install the app, and save credentials only + in the target gateway's Settings. Select Claude for the test conversations and send + `Reply with TEAMS_OK` in the personal chat and `@ Reply with TEAMS_OK` in the channel; + repeat with Codex selected in admin Settings. Pass only if both engines return `TEAMS_OK`, + channel answers thread correctly, and no client secret appears in chat. Record CLI version, + target endpoint, engine and observed replies privately. This live case remains unexecuted. - [x] The shared name directory drops a name two people answer to, and a single-word key that is several people's first name, while keeping the full names — on every platform. - [ ] MANUAL (Google Workspace): a Chat app with a Pub/Sub connection delivers a mentioned space diff --git a/docs/PLATFORMS.md b/docs/PLATFORMS.md index 45ffa56..cd866d2 100644 --- a/docs/PLATFORMS.md +++ b/docs/PLATFORMS.md @@ -68,23 +68,50 @@ before anything else happens; that check is the entire authentication boundary f ### Setup -ChannelGate follows the same low-friction setup used by Hermes: Microsoft's Teams CLI creates the -bot registration and Teams app together, so the normal path does not require hand-building an app -manifest in Azure Portal. +Use Microsoft's [Teams Developer CLI](https://learn.microsoft.com/en-us/microsoftteams/platform/teams-sdk/get-started/quickstart-register) +to create the bot registration and Teams app together. These commands were checked against CLI +3.0.3; install the stable package below. The normal Teams-managed bot path does not need an Azure +subscription or a hand-built manifest. + +Before starting, identify the target ChannelGate installation and its public HTTPS origin. If +multiple gateways exist, do not use the current checkout's URL or settings for another bot. +You need a Microsoft 365 account permitted to register the app and install custom Teams apps. 1. **Install and sign in to the Teams CLI:** ```sh - npm install -g @microsoft/teams.cli@preview - teams login - teams status --verbose + npm install -g @microsoft/teams.cli + teams --version + teams login --device-code + teams status ``` + Open the URL printed by the CLI, enter its short-lived code, and sign in to the intended + organization. Keep the login process alive until it confirms success, then check the account, + tenant and custom-app upload status with `teams status`. A browser saying "done" alone is not + proof the CLI authenticated. On a desktop, `teams login` also supports browser sign-in. + If `teams` is not found after installation, add the npm global prefix's `bin` directory to + your shell's PATH (`npm prefix -g` prints the prefix). + + When an assistant runs this flow, it must keep the same interactive process alive, provide + the code in a progress message, and poll until success or expiry before ending the turn. + An expired code needs a fresh login. Do not request passwords or authentication tokens in chat. + 2. **Create the public event URL.** Teams cannot deliver events to `localhost`. For production, set Settings → Connection → *Public URL* to the daemon's public HTTPS origin. For local use, expose the daemon with a persistent HTTPS tunnel and use that origin. The Admin UI shows the resulting endpoint: `/api/teams/messages`. -3. **Generate the bot and Teams app with that endpoint:** +3. **Generate the bot and Teams app with that endpoint.** Run this in a private operator terminal; + creation can print the client secret. Change the display name to your bot's name. To capture + credentials, add `--env /absolute/private/path/teams.env` with a protected destination outside + the repository and channel work folders; use `umask 077` before creation. Do not stream the + creation output into chat or commit the credentials. + + ```sh + umask 077 + ``` + + Then create the app: ```sh teams app create \ @@ -93,7 +120,10 @@ manifest in Azure Portal. ``` Save the emitted `CLIENT_ID`, `CLIENT_SECRET`, and `TENANT_ID`; the secret is shown only once. - Also retain the emitted Teams app ID for installation. + Also retain the emitted Teams app ID for installation; it is distinct from the Application + (client) ID. Keep personal, team and groupChat scopes for the conversations you intend to use. + For an existing registration, use `teams app list` and `teams app get ` to inspect + it before creating a duplicate. 4. **Configure ChannelGate** — Settings → Connection → *Microsoft Teams*: paste `CLIENT_ID` as the Application ID, `CLIENT_SECRET` as the client secret, and `TENANT_ID` as the tenant, Save, then **Connect**. @@ -105,7 +135,14 @@ manifest in Azure Portal. Open the printed link in a browser or Teams client and install the app. If the public URL later changes, update the registered event endpoint with - `teams app update --id --endpoint "https:///api/teams/messages"`. + `teams app update --endpoint "https:///api/teams/messages"`. + +6. **Verify the connection with a real conversation.** Approve the test user's Teams identity in + the target ChannelGate installation; a Slack approval does not grant a separate Teams identity. + Send `Reply with TEAMS_OK` in a personal chat, then `@ Reply with TEAMS_OK` in a test + team/channel. Confirm a real answer in the personal chat and a threaded channel reply. A + "Connected" badge alone does not prove inbound delivery. See the live acceptance case in + `TEST-PLAN.md`; Teams remains beta until tenant verification is completed. ### Notes and limits diff --git a/public/index.html b/public/index.html index 46c822c..33eec52 100644 --- a/public/index.html +++ b/public/index.html @@ -464,10 +464,10 @@

Microsoft Teams (Beta)

Not configured

Teams pushes events to ChannelGate. Use Microsoft's Teams CLI to create the bot and app against the exact public endpoint below:

    -
  1. Install and sign in: npm install -g @microsoft/teams.cli@preview, then teams login.
  2. +
  3. Install and sign in: npm install -g @microsoft/teams.cli, then teams login --device-code. Complete Microsoft sign-in and verify the account, tenant and custom-app upload status with teams status.
  4. Set the Public URL below. For a local daemon, first expose it through a persistent public HTTPS tunnel.
  5. Create the bot/app: teams app create --name "ChannelGate" --endpoint "set-a-Public-URL-first/api/teams/messages"
  6. -
  7. Save the emitted CLIENT_ID, CLIENT_SECRET, and TENANT_ID here. Keep the emitted Teams app ID for installation.
  8. +
  9. Create in a private terminal: the command can print a secret. Save the emitted CLIENT_ID, CLIENT_SECRET, and TENANT_ID here. Keep the separate Teams app ID for installation. Do not paste the client secret into chat.
  10. Generate and open its Teams install link: teams app get <teamsAppId> --install-link.
+ +

Off by default. Requires application RSC consent for ChatMessage.Read.Chat and ChannelMessage.Read.Group in the Teams app manifest, then app update/reinstallation in each intended conversation. Graph subscriptions start after an installed conversation sends an activity; this is not a tenant-wide message feed. Chat subscriptions use Microsoft Graph beta. Keep both /api/teams/messages and /api/teams/notifications reachable at the Public URL. Save, then reconnect Teams to apply. Normal messages still require a mention outside personal chats; only the robot reaction activates a message without one.

Microsoft event endpoint: set a Public URL first

diff --git a/src/config/settings.js b/src/config/settings.js index 8a49211..0515645 100644 --- a/src/config/settings.js +++ b/src/config/settings.js @@ -188,6 +188,8 @@ export function resolveTeamsConfig() { appId: String(s.teamsAppId || process.env.TEAMS_APP_ID || "").trim(), appPassword: s.teamsAppPassword || process.env.TEAMS_APP_PASSWORD || "", tenantId: String(s.teamsTenantId || process.env.TEAMS_TENANT_ID || "").trim(), + allMessageEvents: s.teamsAllMessageEvents === true, + publicUrl: getPublicUrl(), }; } @@ -833,6 +835,7 @@ export function settingsForApi() { hasAppPassword: Boolean(resolveTeamsConfig().appPassword), appPasswordLast4: last4(resolveTeamsConfig().appPassword), tenantId: resolveTeamsConfig().tenantId, + allMessageEvents: resolveTeamsConfig().allMessageEvents, configured: hasTeamsConfig(), // What the operator must paste into the Azure bot registration. Empty when no public URL is // set, which is exactly when Teams cannot receive anything. diff --git a/src/db/migrations.js b/src/db/migrations.js index ebaa375..5e86f43 100644 --- a/src/db/migrations.js +++ b/src/db/migrations.js @@ -773,4 +773,17 @@ export const migrations = [ `); }, }, + { + version: 24, + up(db) { + db.exec(` + CREATE TABLE teams_graph_subscriptions ( + app_id TEXT NOT NULL, + conversation_id TEXT NOT NULL, + data TEXT NOT NULL, + PRIMARY KEY(app_id, conversation_id) + ); + `); + }, + }, ]; diff --git a/src/platforms/contract.js b/src/platforms/contract.js index bf5b27c..1f16b46 100644 --- a/src/platforms/contract.js +++ b/src/platforms/contract.js @@ -34,6 +34,7 @@ export const CAPABILITY_SPEC = Object.freeze({ ephemeral: { default: false }, deleteMessage: { default: false }, reactions: { default: false }, + reactionTriggers: { default: false }, maxMessageChars: { default: 4_000, type: "number" }, // ── Formatting ──────────────────────────────────────────────────────────── diff --git a/src/platforms/conversation-controls.js b/src/platforms/conversation-controls.js new file mode 100644 index 0000000..51fe3b3 --- /dev/null +++ b/src/platforms/conversation-controls.js @@ -0,0 +1,92 @@ +// Authorized text controls and a process-local lane per conversation/session. Engine/session +// persistence stays in the existing gateway stores; the lane only owns live work and cancellation. +import { clearSession } from '../gateway/sessions.js'; +import { resolveThreadEngine, getThreadModel, getThreadEffort, setThreadEngine, setThreadModel, setThreadEffort } from '../gateway/thread-engine.js'; +import { ENGINE_IDS, isEngineId, modelBelongsToEngine, effortBelongsToModel, effortsForModel, modelsForEngine } from '../engines/registry.js'; +import { canChangeChannelRuntime, getDefaultModel } from '../config/settings.js'; + +export function createConversationControls() { + const lanes = new Map(); + const keyFor = (message, sessionKey) => JSON.stringify([message.conversationId, sessionKey]); + const laneFor = (key) => { + if (!lanes.has(key)) lanes.set(key, { tail: Promise.resolve(), jobs: new Set(), clearing: false }); + return lanes.get(key); + }; + return { + async command({ message, sessionKey, slug, meta, authorIsAdmin, reply }) { + // Reaction target text is content, never a second user's administrative command. + if (message.trigger === 'reaction') return false; + const match = /^\/(help|status|clear|stop|cancel|model|effort)(?:\s+(.*))?$/is.exec(message.text.trim()); + if (!match) return false; + const [, name, raw = ''] = match; + const command = name.toLowerCase(); + const arg = raw.trim(); + const key = keyFor(message, sessionKey); + const lane = laneFor(key); + const respond = async (text) => { await reply(text); return true; }; + try { + if (command === 'help') return await respond('Commands: /help, /status, /stop (or /cancel), /clear, /model [engine] [model|default], /effort [level|default]. In a group chat, quote the message or bot reply to control its session and include the bot mention. A new unquoted group message starts a new session.'); + if (['stop', 'cancel', 'clear'].includes(command)) { + if ([...lane.jobs].some((job) => job.author !== message.userId && !authorIsAdmin)) return await respond('Only the run author or an administrator may stop or clear another person’s active or queued work.'); + if (lane.clearing) return await respond('This session is already being cleared.'); + for (const job of lane.jobs) job.controller.abort(); + if (command !== 'clear') return await respond(lane.jobs.size ? 'Stop requested for this session’s active and queued work.' : 'No active work in this session.'); + lane.clearing = true; + try { + await reply('Clearing this session; waiting for its active work to stop…'); + // Bump before waiting as well as after: a late engine result must not resurrect it. + await clearSession(slug, sessionKey); + await lane.tail; + await clearSession(slug, sessionKey); + return await respond('Session cleared. Your next message in this session starts fresh.'); + } finally { lane.clearing = false; } + } + const engine = await resolveThreadEngine(slug, sessionKey, meta); + const model = await getThreadModel(slug, sessionKey) || meta.model || getDefaultModel(engine); + const effort = await getThreadEffort(slug, sessionKey) || meta.effort || ''; + if (command === 'status') return await respond(`Engine: ${engine}; model: ${model || 'engine default'}; effort: ${effort || 'engine default'}. ${lane.jobs.size ? `${lane.jobs.size} active/queued request(s).` : 'Idle.'}`); + if (!arg) return await respond(command === 'model' ? `Engine: ${engine}; model: ${model || 'engine default'}. Engines: ${ENGINE_IDS.join(', ')}. Models: ${modelsForEngine(engine).map((item) => item.value).join(', ')}.` : `Effort: ${effort || 'engine default'}. Available: ${effortsForModel(engine, model).join(', ')}.`); + if (!message.isDM && !canChangeChannelRuntime(authorIsAdmin)) return await respond('Runtime changes in this conversation are restricted to administrators.'); + if (lane.jobs.size || lane.clearing) return await respond('Wait for this session’s work to finish, or stop it, before changing its runtime.'); + if (command === 'effort') { + const selected = arg === 'default' ? '' : arg.toLowerCase(); + if (!effortBelongsToModel(selected, engine, model)) return await respond(`Choose an effort from: ${effortsForModel(engine, model).join(', ')}, default.`); + await setThreadEffort(slug, sessionKey, selected); + return await respond(`Session effort: ${selected || 'inherited default'}.`); + } + const parts = arg.split(/\s+/); + const selectedEngine = isEngineId(parts[0]) ? parts.shift() : engine; + const selectedModel = parts.join(' ') === 'default' ? '' : parts.join(' '); + if (parts.length > 1 || !modelBelongsToEngine(selectedModel, selectedEngine)) return await respond('Use /model [engine] [model|default] with a model belonging to that engine.'); + await setThreadEngine(slug, sessionKey, selectedEngine); + await setThreadModel(slug, sessionKey, selectedModel); + if (selectedEngine !== engine || !effortBelongsToModel(effort, selectedEngine, selectedModel)) await setThreadEffort(slug, sessionKey, ''); + return await respond(`Session engine: ${selectedEngine}; model: ${selectedModel || 'inherited default'}.`); + } finally { + if (!lane.jobs.size && !lane.clearing) lanes.delete(key); + } + }, + async execute({ message, sessionKey, queued, work }) { + const key = keyFor(message, sessionKey); + const lane = laneFor(key); + if (lane.clearing) { await queued('This session is being cleared; send the message again after it finishes.'); return { skipped: 'clearing' }; } + const controller = new AbortController(); + const job = { author: message.userId, controller }; + const predecessor = lane.tail; + const position = lane.jobs.size; + lane.jobs.add(job); + let release; + lane.tail = new Promise((resolve) => { release = resolve; }); + try { + if (position) await queued(`Queued in this session (position ${position}).`); + await predecessor; + if (controller.signal.aborted) { await queued('Cancelled before starting.'); return { skipped: 'cancelled' }; } + return await work(controller.signal); + } finally { + lane.jobs.delete(job); + release(); + if (!lane.jobs.size && !lane.clearing) lanes.delete(key); + } + }, + }; +} diff --git a/src/platforms/inbound.js b/src/platforms/inbound.js index 42a7075..23d945d 100644 --- a/src/platforms/inbound.js +++ b/src/platforms/inbound.js @@ -30,6 +30,7 @@ export function makeInbound({ userEmail = "", text = "", mentionsBot = false, + trigger = "message", attachments = [], raw = null, } = {}) { @@ -56,6 +57,7 @@ export function makeInbound({ text: String(text || ""), // Whether the bot was explicitly addressed. Outside a DM this is the gate: no mention, no run. mentionsBot: Boolean(mentionsBot), + trigger: ["message", "edit", "reaction"].includes(trigger) ? trigger : "message", // [{ name, contentType, size, download() -> Promise }] — lazy, because most inbound // messages carry none and a download is a network call we should not make until the message has // passed authorization. diff --git a/src/platforms/ingest.js b/src/platforms/ingest.js index 213f96e..0c11b41 100644 --- a/src/platforms/ingest.js +++ b/src/platforms/ingest.js @@ -1,17 +1,6 @@ // From a normalized inbound message to an answered turn, for platforms that are not Slack. -// -// The Slack path (src/slack/message-pipeline.js) does a great deal more than this: Block Kit -// progress cards, approval buttons, busy-thread steering, the file explorer, in-thread slash -// commands. None of that is portable, and pretending otherwise would mean either a Slack-shaped -// façade over surfaces that cannot honour it, or a rewrite of 1400 lines before either new platform -// could say a word. So this is the HONEST subset — gate, authorize, register, run, answer — built -// on the same platform-neutral pieces the Slack pipeline uses (`runMessage`, the channel store, -// `ensureChannelFolder`, the usage ledger), so nothing here is a second implementation of a rule. -// -// What is deliberately NOT here yet, and must not be silently faked: interactive approvals (a -// non-admin channel's permission prompt has no button to press on these surfaces), live progress -// rendering, and stop/steer controls. Each is a capability question the adapter already answers, and -// each gets its own slice. +// Text controls, per-session serialization and conservative progress edits use the same gateway +// policy/stores as Slack. Interactive approval escalation remains deliberately unavailable here. import { upsertChannelEntry, getChannelMeta, saveChannelMeta, defaultChannelMeta, getUser, setUser, isAdmin, isApproved } from "../config/store.js"; import { getDefaultChannelAccess, applyChannelTemplate, getDefaultNudges } from "../config/settings.js"; import { ensureChannelFolder } from "../gateway/folders.js"; @@ -19,7 +8,7 @@ import { isAuthorized } from "../gateway/modes.js"; import { runMessage } from "../gateway/run.js"; import { createUsageBank } from "../gateway/usage.js"; import { logEvent } from "../util/logger.js"; -import { platformOr } from "./registry.js"; +import { platformOr, platformSupports } from "./registry.js"; import { postFormatted } from "./connector.js"; import { sessionKeyForMessage, rememberReplySession } from "./reply-sessions.js"; import { saveInboundAttachments } from "./attachments.js"; @@ -27,6 +16,8 @@ import { saveInboundAttachments } from "./attachments.js"; // Conversation kinds as the channel store spells them. The store's vocabulary is Slack's, and it is // a SECURITY value there (it decides whether a private channel's name may appear in App Home), so // the mapping is explicit rather than a passthrough of whatever a platform calls things. +import { createConversationControls } from "./conversation-controls.js"; + const STORE_TYPE = { dm: "im", group: "mpim", channel: "channel" }; // Register (or refresh) the conversation and make sure its gated folder exists. The platform is @@ -62,6 +53,7 @@ async function ensureUserKnown(message) { export function createIngest({ connector, log = console, run = runMessage } = {}) { const adapter = platformOr(connector?.platform); const bankUsage = createUsageBank(); + const controls = createConversationControls(); return async function ingest(message) { if (message.platform !== adapter.id) throw new Error(`${adapter.id} ingest received a ${message.platform} message`); @@ -74,7 +66,7 @@ export function createIngest({ connector, log = console, run = runMessage } = {} // else must say so. Both surfaces additionally only DELIVER mentioned messages by default // (`seesUnmentionedMessages: false`), so this is belt and braces — and it stays correct if an // operator grants Teams RSC or Chat's space-wide events later. - if (!message.isDM && !message.mentionsBot) return { skipped: "not-mentioned" }; + if (!message.isDM && !message.mentionsBot && !(message.trigger === "reaction" && platformSupports(adapter.id, "reactionTriggers"))) return { skipped: "not-mentioned" }; const { entry, meta } = await ensureConversation(message); await ensureUserKnown(message); @@ -99,6 +91,9 @@ export function createIngest({ connector, log = console, run = runMessage } = {} } }; + const reply = async (text) => deliver(connector, message, null, text, rememberReply); + if (await controls.command({ message, sessionKey, slug: entry.slug, meta, authorIsAdmin, reply })) return { command: true }; + return controls.execute({ message, sessionKey, queued: reply, work: async (signal) => { // Attachments land in the channel folder, exactly where the Slack path puts them, so the model // reads them with the same tool and the same confinement. const { paths, skipped } = await saveInboundAttachments(message, { slug: entry.slug, meta, log }); @@ -118,6 +113,7 @@ export function createIngest({ connector, log = console, run = runMessage } = {} log.warn?.(`[${adapter.id}] placeholder post failed: ${err?.message || err}`); } + const progress = createConversationProgress({ connector, message, placeholder, adapter, log }); let result; try { result = await run({ @@ -127,6 +123,9 @@ export function createIngest({ connector, log = console, run = runMessage } = {} // Session roots for flat chats must never become native reply addresses. threadKey: sessionKey, attachments: paths, + signal, + onDelta: progress.activity, + onEvent: progress.event, progressReport: false, // Not `slack_foreground`: that origin is what permits escalation to dangerous permissions, // and it means "a watched, Slack-authenticated turn". These turns are watched and @@ -136,10 +135,13 @@ export function createIngest({ connector, log = console, run = runMessage } = {} }); } catch (err) { log.error?.(`[${adapter.id}] run failed in ${entry.slug}: ${err?.message || err}`); - await deliver(connector, message, placeholder, `⚠️ ${err?.message || err}`, rememberReply); + await progress.stop(); + await deliver(connector, message, placeholder, signal.aborted ? "Stopped." : `⚠️ ${err?.message || err}`, rememberReply); return { error: err }; } + await progress.stop(); + if (signal.aborted) { await deliver(connector, message, placeholder, "Stopped.", rememberReply); return { skipped: "cancelled" }; } await bankUsage({ channelId: message.conversationId, slug: entry.slug, authorId: message.userId, engine: result.engine, taskKind: "interactive", result }); let text = String(result.content || "").trim() || "_(no output)_"; @@ -148,6 +150,7 @@ export function createIngest({ connector, log = console, run = runMessage } = {} } await deliver(connector, message, placeholder, text, rememberReply); return { result }; + } }); }; } @@ -189,3 +192,40 @@ async function deliver(connector, message, placeholder, text, rememberReply = () onPosted: rememberReply, }); } + +// Progress contains state only, never model/tool payloads. At most one periodic update per +// 30 seconds, with in-flight edits drained before the final answer to prevent stale overwrites. +export function createConversationProgress({ connector, message, placeholder, adapter, log = console, intervalMs = 30000, now = Date.now }) { + const started = now(); + let lastActivity = started; + let state = 'Working'; + let pending = Promise.resolve(); + let updating = false; + let stopped = false; + const agents = new Set(); + const tick = () => { + if (stopped || updating) return; + const text = `${state} — ${Math.floor((now() - started) / 1000)}s elapsed; last activity ${Math.floor((now() - lastActivity) / 1000)}s ago; ${agents.size} subagent(s) running. Still connected.`; + updating = true; + pending = Promise.resolve().then(() => placeholder?.messageId && adapter.capabilities.messageEdit + ? connector.edit({ conversationId: placeholder.conversationId || message.rawConversationId, messageId: placeholder.messageId, text }) + : connector.post({ conversationId: message.rawConversationId, threadKey: message.threadKey, text })) + .catch((err) => log.warn?.(`[${adapter.id}] progress update failed: ${err?.message || err}`)) + .finally(() => { updating = false; }); + }; + const timer = setInterval(tick, Math.max(30000, intervalMs)); + timer.unref?.(); + return { + activity() { lastActivity = now(); state = 'Working'; }, + event(event) { + if (event?.kind === 'agent_activity') { + const key = String(event.id || event.name || 'agent'); + if (event.status === 'running') agents.add(key); else agents.delete(key); + } + if (event?.kind === 'run_queued') state = `Waiting for a gateway run slot (position ${Number(event.position) || 1})`; + else if (event?.kind === 'notice') state = 'Working; waiting for the engine'; + else { lastActivity = now(); state = 'Working'; } + }, + async stop() { stopped = true; clearInterval(timer); await pending; }, + }; +} diff --git a/src/platforms/msteams.js b/src/platforms/msteams.js index dafe317..cd33017 100644 --- a/src/platforms/msteams.js +++ b/src/platforms/msteams.js @@ -43,6 +43,7 @@ export const teamsAdapter = validatePlatformAdapter({ ephemeral: false, deleteMessage: true, reactions: true, + reactionTriggers: true, maxMessageChars: MAX_TEAMS_MESSAGE_CHARS, markdown: "subset", markdownTables: false, diff --git a/src/platforms/msteams/activity.js b/src/platforms/msteams/activity.js index a2832f0..cff3c80 100644 --- a/src/platforms/msteams/activity.js +++ b/src/platforms/msteams/activity.js @@ -69,7 +69,11 @@ export function quotedReplyId(activity) { } export function normalizeActivity(activity, { botId = "", fetchImpl = fetch } = {}) { - if (String(activity?.type || "").toLowerCase() !== "message") return null; + const type = String(activity?.type || "").toLowerCase(); + const edit = type === "messageupdate" && activity.channelData?.eventType === "editMessage"; + const reaction = type === "messagereaction" && (activity.reactionsAdded || []).some(r => isRobotReaction(r?.type)); + if (type !== "message" && !edit && !reaction) return null; + if (reaction && !activity.replyToId) return null; const from = activity.from || {}; // Our own echo. Bot Service delivers the bot's own messages back on some configurations, and // answering one is an infinite loop with a bill attached. @@ -89,11 +93,12 @@ export function normalizeActivity(activity, { botId = "", fetchImpl = fetch } = kind, // In a channel the user's own message is the root a reply must thread under; a 1:1 or group // chat is flat, so nothing is carried and replies land in the chat itself. - threadKey: threadKey || (kind === "channel" ? String(activity.id || "") : ""), + threadKey: threadKey || (kind === "channel" ? String((reaction ? activity.replyToId : activity.id) || "") : ""), messageId: String(activity.id || ""), + trigger: reaction ? "reaction" : edit ? "edit" : "message", // Teams SDK quoted replies carry an entity; text markup alone is not a trustworthy // reference. Ignore ambiguous multiple quotes and quotes explicitly marked deleted. - replyToId: quotedReplyId(activity), + replyToId: reaction ? String(activity.replyToId) : quotedReplyId(activity), // The Bot Framework id (`29:…`) is the one that can address a message or open a 1:1; the Entra // object id is the one an operator recognizes. Both are kept — `userId` is the addressable one. userId: String(from.id || ""), @@ -101,7 +106,7 @@ export function normalizeActivity(activity, { botId = "", fetchImpl = fetch } = // Teams does not put an email on the `from` account; a UPN needs a roster read, which the bot // may not be consented for. Left empty rather than guessed. userEmail: "", - text: stripMentionTags(activity.text), + text: reaction ? "Continue the task from this message." : stripMentionTags(activity.text), mentionsBot, attachments: normalizeAttachments(activity.attachments, fetchImpl), raw: { @@ -151,3 +156,7 @@ async function fetchBytes(url, fetchImpl) { if (!res.ok) throw new Error(`Teams attachment download failed (${res.status})`); return res; } + +export function isRobotReaction(value) { + return ["🤖", "robot", "robot_face"].includes(String(value || "").replace(/\uFE0F/g, "")); +} diff --git a/src/platforms/msteams/api.js b/src/platforms/msteams/api.js index 3562e94..5c220fa 100644 --- a/src/platforms/msteams/api.js +++ b/src/platforms/msteams/api.js @@ -129,6 +129,12 @@ export function createTeamsApi({ auth, serviceUrl = DEFAULT_SERVICE_URL, fetchIm return res?.id || ""; }, + // Resolve the Microsoft 365 group GUID needed for Graph from an authenticated team activity. + async teamInfo(teamId) { + if (!isConversationId(teamId)) throw new Error("Teams team id contains invalid characters"); + return call(`v3/teams/${encodeURIComponent(teamId)}`, { method: "GET" }); + }, + // Roster for mention resolution. In a channel this needs RSC consent; without it Teams answers // 403 and the caller keeps its previous (possibly empty) directory. async listMembers(conversationId) { diff --git a/src/platforms/msteams/auth.js b/src/platforms/msteams/auth.js index b338b4b..c4fc076 100644 --- a/src/platforms/msteams/auth.js +++ b/src/platforms/msteams/auth.js @@ -7,6 +7,7 @@ const LOGIN_HOST = "https://login.microsoftonline.com"; // The audience every Bot Framework connector call is issued against. export const BOT_SCOPE = "https://api.botframework.com/.default"; +export const GRAPH_SCOPE = "https://graph.microsoft.com/.default"; // A multi-tenant bot authenticates against the shared Bot Framework tenant; a single-tenant bot // against its own directory. Operators paste whichever their app registration uses. export const MULTI_TENANT = "botframework.com"; @@ -16,7 +17,8 @@ const EXPIRY_MARGIN_MS = 60_000; // typo or an attempt to redirect our client credentials at an attacker-controlled STS. const TENANT_RE = /^[A-Za-z0-9][A-Za-z0-9.-]{0,120}$/; -export function createTeamsAuth({ clientId, clientSecret, tenantId = MULTI_TENANT, fetchImpl = fetch, now = Date.now } = {}) { +export function createTeamsAuth({ clientId, clientSecret, tenantId = MULTI_TENANT, scope = BOT_SCOPE, fetchImpl = fetch, now = Date.now } = {}) { + if (![BOT_SCOPE, GRAPH_SCOPE].includes(scope)) throw new Error("Unsupported Teams token scope"); const id = String(clientId || "").trim(); const secret = String(clientSecret || ""); const tenant = String(tenantId || MULTI_TENANT).trim() || MULTI_TENANT; @@ -34,7 +36,7 @@ export function createTeamsAuth({ clientId, clientSecret, tenantId = MULTI_TENAN grant_type: "client_credentials", client_id: id, client_secret: secret, - scope: BOT_SCOPE, + scope, }).toString(), }); const text = await res.text(); diff --git a/src/platforms/msteams/connector.js b/src/platforms/msteams/connector.js index 4979cf5..54407af 100644 --- a/src/platforms/msteams/connector.js +++ b/src/platforms/msteams/connector.js @@ -94,7 +94,7 @@ export function createTeamsConnector({ auth, capabilities, api = null, botId = " // into a ";messageid=" suffix. threadFor, // Only channels have reply chains; 1:1 and group chats are flat. - supportsThreads: (conversationId) => String(toConversationId(conversationId)).includes("@thread."), + supportsThreads: (conversationId) => /@thread\.(?:tacv2|skype)(?:;|$)/.test(String(toConversationId(conversationId))), directory: (conversationId) => directoryFor(conversationId), }); } diff --git a/src/platforms/msteams/event-store.js b/src/platforms/msteams/event-store.js new file mode 100644 index 0000000..1e021d0 --- /dev/null +++ b/src/platforms/msteams/event-store.js @@ -0,0 +1,20 @@ +// Subscription credentials are private operational data, never part of settings/listing output. +import { getDb, fromJson, toJson } from "../../db/index.js"; + +export function createTeamsEventStore({ appId, db = getDb() } = {}) { + if (!appId) throw new Error("Teams event store requires an app identity"); + return { + list() { + return db.prepare("SELECT data FROM teams_graph_subscriptions WHERE app_id = ?") + .all(appId).map(row => fromJson(row.data, {})); + }, + put(row) { + if (!row?.conversationId) throw new Error("Teams subscription requires a conversation id"); + db.prepare("INSERT INTO teams_graph_subscriptions(app_id, conversation_id, data) VALUES (?, ?, ?) ON CONFLICT(app_id, conversation_id) DO UPDATE SET data = excluded.data") + .run(appId, row.conversationId, toJson(row)); + }, + remove(conversationId) { + db.prepare("DELETE FROM teams_graph_subscriptions WHERE app_id = ? AND conversation_id = ?").run(appId, conversationId); + }, + }; +} diff --git a/src/platforms/msteams/graph-activity.js b/src/platforms/msteams/graph-activity.js new file mode 100644 index 0000000..6872c30 --- /dev/null +++ b/src/platforms/msteams/graph-activity.js @@ -0,0 +1,77 @@ +// Graph supplies Entra IDs; the Bot Framework roster is the authority for the addressable +// reactor identity. Never run under the original author's permissions on a reaction. +import { createHash } from "node:crypto"; +import { makeInbound } from "../inbound.js"; +import { isRobotReaction, quotedReplyId, stripMentionTags } from "./activity.js"; + +const digest = parts => createHash("sha256").update(JSON.stringify(parts)).digest("hex"); +const userId = identity => String(identity?.user?.id || ""); +function plainBody(body) { + let text = stripMentionTags(body?.content); + if (String(body?.contentType).toLowerCase() === "html") { + text = text.replace(//gi, "\n").replace(/<\/(?:p|div)>/gi, "\n").replace(/<[^>]*>/g, ""); + text = text.replace(/&(lt|gt|amp|quot|apos|nbsp);/g, (_, entity) => ({ lt: "<", gt: ">", amp: "&", quot: '"', apos: "'", nbsp: " " })[entity]); + } + return text.trim(); +} + +export async function normalizeGraphEvents(message, row, { botId, resolveMember, now = Date.now } = {}) { + if (!message?.id || message.deletedDateTime || message.messageType !== "message") return []; + const started = Date.parse(row.startedAt); + if (!Number.isFinite(started)) return []; + // The inbox retains tombstones for seven days. Only fresh provider events can enter it, + // so fetching a long-lived message never replays history whose tombstone was collected. + const since = Math.max(started, now() - 24 * 60 * 60_000); + const context = row.context || {}; + const conversation = context.conversation || {}; + const kind = ({ personal: "dm", groupchat: "group", channel: "channel" })[String(conversation.conversationType).toLowerCase()]; + if (!kind || !conversation.id) return []; + const appId = String(botId || "").replace(/^28:/, ""); + const application = message.from?.application?.id; + const ownBot = Boolean(appId) && [appId, botId].includes(application); + // Other agents are never silently invoked through their output. + if (application && !ownBot) return []; + const mentionsBot = Boolean(appId) && (message.mentions || []).some(mention => + [appId, botId].includes(mention?.mentioned?.application?.id) || + [appId, botId].includes(mention?.mentioned?.user?.id)); + const body = plainBody(message.body); + const result = []; + const members = new Map(); + async function emit(actorId, trigger, stamp) { + if (!actorId) return; + if (!members.has(actorId)) members.set(actorId, await resolveMember(actorId)); + const member = members.get(actorId); + if (!member?.id || member.id === botId) return; + const eventId = digest([row.conversationId, message.id, trigger, actorId, stamp]); + const reaction = trigger === "reaction"; + result.push(makeInbound({ + platform: "msteams", conversationId: conversation.id, conversationName: conversation.name, + kind, threadKey: kind === "channel" ? String(message.replyToId || message.id) : "", + messageId: reaction ? `reaction:${eventId}` : String(message.id), + replyToId: reaction ? String(message.id) : quotedReplyId({ text: message.body?.content }), + trigger, userId: member.id, userName: member.name, userEmail: member.email, + text: reaction && ownBot ? "Continue the task from this message." : body || ((message.attachments || []).length ? "Handle the attached message." : ""), + mentionsBot, + // Graph attachment retrieval has a separate permission path. Preserve descriptors so the + // shared attachment sink reports unavailable files instead of silently dropping them. + attachments: (message.attachments || []).map(file => ({ name: String(file.name || "attachment"), contentType: String(file.contentType || "application/octet-stream"), download: null })), + raw: { eventId, aadObjectId: actorId, tenantId: context.channelData?.tenant?.id || "", + serviceUrl: context.serviceUrl || "", teamId: context.channelData?.team?.aadGroupId || "" }, + })); + } + // lastModifiedDateTime changes for reactions too; only lastEditedDateTime represents an edit. + const edited = Date.parse(message.lastEditedDateTime); + if (!application && (kind === "dm" || mentionsBot) && edited > since) { + await emit(userId(message.from), "edit", message.lastEditedDateTime); + } + for (const item of message.messageHistory || []) { + if (item.actions !== "reactionAdded" || !isRobotReaction(item.reaction?.reactionType)) continue; + const stamp = item.modifiedDateTime; + if (!(Date.parse(stamp) > since)) continue; + const actor = userId(item.reaction?.user); + // A removed reaction must not start a new run when a delayed notification is fetched. + if (!(message.reactions || []).some(reaction => isRobotReaction(reaction.reactionType) && userId(reaction.user) === actor)) continue; + await emit(actor, "reaction", stamp); + } + return result; +} diff --git a/src/platforms/msteams/graph-events.js b/src/platforms/msteams/graph-events.js new file mode 100644 index 0000000..6b09b78 --- /dev/null +++ b/src/platforms/msteams/graph-events.js @@ -0,0 +1,185 @@ +// Microsoft Graph basic notifications. Credentials and subscription state stay daemon-side. +import { randomBytes, timingSafeEqual } from "node:crypto"; + +const GRAPH = "https://graph.microsoft.com"; +const LIFETIME = 55 * 60_000; +const RENEW_AFTER = 20 * 60_000; +const SAFE_ID = /^[A-Za-z0-9:@._-]+$/; +const safeId = value => typeof value === "string" && value !== "." && value !== ".." && value.length <= 1024 && SAFE_ID.test(value); +function segments(resource) { + if (typeof resource !== "string" || resource.length > 8192) return null; + try { + const path = resource.replace(/^\//, "").replace(/\('([^']+)'\)/g, "/$1"); + const parts = path.split("/").map(decodeURIComponent); + return parts.every(safeId) ? parts : null; + } catch { return null; } +} +function baseResource(resource) { + const parts = segments(resource); + if (!parts) throw new Error("Invalid Teams Graph subscription resource"); + const chat = parts.length === 3 && parts[0] === "chats" && parts[2] === "messages"; + const channel = parts.length === 5 && parts[0] === "teams" && parts[2] === "channels" && parts[4] === "messages"; + if (!chat && !channel) throw new Error("Invalid Teams Graph subscription resource"); + return { parts, apiVersion: chat ? "beta" : "v1.0", resource: `/${parts.map(encodeURIComponent).join("/")}` }; +} +function messagePath(notification, row) { + const base = baseResource(row.resource).parts; + const parts = segments(notification.resource); + if (!parts || base.some((part, i) => parts[i] !== part)) return null; + const suffix = parts.slice(base.length); + const root = suffix.length === 1; + const reply = base[0] === "teams" && suffix.length === 3 && suffix[1] === "replies"; + if (!root && !reply) return null; + if (notification.resourceData?.id !== suffix.at(-1)) return null; + return `/${parts.map(encodeURIComponent).join("/")}`; +} +function sameSecret(a, b) { + if (typeof a !== "string" || typeof b !== "string" || !a || a.length > 128 || b.length > 128) return false; + const aa = Buffer.from(a), bb = Buffer.from(b); + return aa.length === bb.length && timingSafeEqual(aa, bb); +} + +export function createTeamsGraphEvents({ auth, notificationUrl, tenantId, store, onMessage, enqueueNotifications = null, fetchImpl = fetch, now = Date.now, log = () => {}, intervalMs = 60_000 } = {}) { + const endpoint = new URL(notificationUrl); + if (endpoint.protocol !== "https:" || endpoint.username || endpoint.password || endpoint.hash) throw new Error("Teams Graph notifications need a public HTTPS URL"); + if (!tenantId || !auth?.token || !store?.list || !store?.put || !onMessage) throw new Error("Teams Graph event dependencies are incomplete"); + let timer = null; + let chain = Promise.resolve(); + const serialized = action => { + const next = chain.then(action); + chain = next.catch(() => {}); + return next; + }; + async function request(version, path, method = "GET", body) { + const res = await fetchImpl(`${GRAPH}/${version}${path}`, { + method, + headers: { authorization: `Bearer ${await auth.token()}`, "content-type": "application/json" }, + ...(body ? { body: JSON.stringify(body) } : {}), + signal: AbortSignal.timeout(8_000), + redirect: "error", + }); + if (!res.ok) { + const error = new Error(`Teams Graph request failed (${res.status})`); + error.status = res.status; + throw error; + } + return res.status === 204 ? null : res.json(); + } + async function maintain(row) { + const { resource, apiVersion } = baseResource(row.resource); + const current = now(); + const sameEndpoint = row.notificationUrl === endpoint.href; + if (sameEndpoint && row.subscriptionId && Date.parse(row.expiresAt) > current && Number(row.renewedAt || 0) + RENEW_AFTER > current) return row; + const expiresAt = new Date(current + LIFETIME).toISOString(); + let result; + if (sameEndpoint && row.subscriptionId && Date.parse(row.expiresAt) > current) { + try { result = await request(apiVersion, `/subscriptions/${encodeURIComponent(row.subscriptionId)}`, "PATCH", { expirationDateTime: expiresAt }); } + catch (error) { if (error.status !== 404) throw error; } + } + if (!result) { + const retiredId = !sameEndpoint && row.subscriptionId || row.retiredSubscriptionId; + if (retiredId) { + row = { ...row, retiredSubscriptionId: retiredId }; + try { + await request(apiVersion, `/subscriptions/${encodeURIComponent(retiredId)}`, "DELETE"); + delete row.retiredSubscriptionId; + } catch (error) { + if (error.status === 404) delete row.retiredSubscriptionId; + else log("Teams Graph prior subscription cleanup failed; retrying", { status: error.status || null }); + } + } + const clientState = randomBytes(32).toString("hex"); + // Persist the secret before creation; Graph validates the endpoint during this request. + row = { ...row, resource, apiVersion, clientState, notificationUrl: endpoint.href, subscriptionId: null, startedAt: row.startedAt || new Date(current).toISOString() }; + await store.put(row); + result = await request(apiVersion, "/subscriptions", "POST", { + changeType: "created,updated,deleted", notificationUrl: endpoint.href, + resource, includeResourceData: false, expirationDateTime: expiresAt, clientState, + }); + } + if (!safeId(result?.id)) throw new Error("Teams Graph subscription response has no valid id"); + row = { ...row, resource, apiVersion, subscriptionId: result.id, expiresAt: result.expirationDateTime || expiresAt, renewedAt: current }; + await store.put(row); + return row; + } + async function ensure(contextRow) { + return serialized(async () => { + const prior = (await store.list()).find(row => row.conversationId === contextRow.conversationId); + const base = baseResource(contextRow.resource); + // A conversation never silently switches subscription scope. + if (prior && baseResource(prior.resource).resource !== base.resource) throw new Error("Teams Graph conversation scope changed"); + const row = { ...prior, ...contextRow, ...base }; + delete row.parts; + await store.put(row); + try { return await maintain(row); } + catch (error) { log("Teams Graph subscription unavailable; retrying automatically", { status: error.status || null }); return row; } + }); + } + async function remove(conversationId) { + return serialized(async () => { + const row = (await store.list()).find(item => item.conversationId === conversationId); + await store.remove(conversationId); + if (!row?.subscriptionId) return; + try { await request(row.apiVersion || baseResource(row.resource).apiVersion, `/subscriptions/${encodeURIComponent(row.subscriptionId)}`, "DELETE"); } + catch (error) { log("Teams Graph subscription revoked locally; remote expiry remains bounded", { status: error.status || null }); } + }); + } + async function renew() { + return serialized(async () => { + for (const row of await store.list()) { + try { await maintain(row); } + catch (error) { log("Teams Graph subscription renewal failed; retrying automatically", { status: error.status || null }); } + } + }); + } + async function processNotifications(accepted) { + for (const { event } of accepted) { + const row = (await store.list()).find(item => item.subscriptionId && item.subscriptionId === event?.subscriptionId); + const path = row && messagePath(event, row); + // Uninstall or rotation may revoke an envelope after durable acceptance, before its GET. + if (!row || !path || event.tenantId !== tenantId || !sameSecret(event.clientState, row.clientState) + || !["created", "updated"].includes(event.changeType)) continue; + let message; + try { message = await request(row.apiVersion || baseResource(row.resource).apiVersion, path); } + catch (error) { if (error.status === 404) continue; throw error; } + if (message?.id !== event.resourceData.id) throw new Error("Teams Graph response message identity mismatch"); + await onMessage(message, row); + } + } + async function handle(req, res) { + const validation = req.query?.validationToken; + if (typeof validation === "string" && validation.length > 0 && validation.length <= 4096) { + res.status(200).type("text/plain").send(validation); + return; + } + const batch = req.body?.value; + if (!Array.isArray(batch) || !batch.length || batch.length > 100) { res.status(400).end(); return; } + try { + const rows = await store.list(); + const accepted = []; + for (const event of batch) { + const row = rows.find(item => item.subscriptionId && item.subscriptionId === event?.subscriptionId); + const path = row && messagePath(event, row); + if (!row || !path || event.tenantId !== tenantId || !sameSecret(event.clientState, row.clientState) || !["created", "updated", "deleted"].includes(event.changeType)) { + res.status(403).end(); return; + } + accepted.push({ event, row, path }); + } + if (enqueueNotifications) { + await enqueueNotifications(accepted); + res.status(202).end(); + return; + } + await processNotifications(accepted); + res.status(200).end(); + } catch (error) { + log("Teams Graph notification failed; delivery will be retried", { status: error.status || null }); + res.status(503).end(); + } + } + return { + ensure, handle, renew, remove, processNotifications, + start() { if (!timer) { timer = setInterval(() => { void renew().catch(() => {}); }, Math.max(10, intervalMs)); timer.unref?.(); void renew().catch(() => {}); } }, + async stop() { if (timer) clearInterval(timer); timer = null; await chain; }, + }; +} diff --git a/src/platforms/msteams/transport.js b/src/platforms/msteams/transport.js index ff1d80d..f47a79f 100644 --- a/src/platforms/msteams/transport.js +++ b/src/platforms/msteams/transport.js @@ -3,8 +3,14 @@ // Note the asymmetry with Google Chat: there is nothing to "start" on the inbound side. Teams pushes // to us, so the transport's job is to hold a verified handler that the Express app can route to and // to prove the outbound credentials work before reporting itself connected. -import { createTeamsAuth } from "./auth.js"; -import { createTeamsApi, DEFAULT_SERVICE_URL } from "./api.js"; +import { createHash, randomUUID } from "node:crypto"; +import { createDurableInbox } from "../durable-inbox.js"; +import { createTeamsGraphEvents } from "./graph-events.js"; +import { createTeamsEventStore } from "./event-store.js"; +import { normalizeGraphEvents } from "./graph-activity.js"; +import { splitConversationId } from "./activity.js"; +import { createTeamsAuth, GRAPH_SCOPE } from "./auth.js"; +import { createTeamsApi, DEFAULT_SERVICE_URL, validateServiceUrl, isConversationId } from "./api.js"; import { createTeamsConnector } from "./connector.js"; import { createTeamsWebhook } from "./webhook.js"; import { createJwksCache } from "./verify.js"; @@ -18,6 +24,8 @@ export async function startTeams({ appPassword, tenantId = "", serviceUrl = DEFAULT_SERVICE_URL, + allMessageEvents = false, + publicUrl = "", capabilities, onMessage, log = console, @@ -33,14 +41,143 @@ export async function startTeams({ const botId = botIdFor(appId); const connector = deps.connector || createTeamsConnector({ auth, capabilities, api, botId, tenantId, serviceUrl, log }); const jwks = deps.jwks || createJwksCache(); - const handler = createTeamsWebhook({ appId, botId, onMessage, jwks, log }); + let graph = null; + let eventStore = null; + let inbox = null; + let notificationInbox = null; + let dispatchInbox = null; + const graphEventsEnabled = allMessageEvents === true; + if (graphEventsEnabled) { + if (!publicUrl || !tenantId) throw new Error("Teams all-message events require Public URL and tenant ID"); + const graphAuth = deps.graphAuth || createTeamsAuth({ clientId: appId, clientSecret: appPassword, tenantId, scope: GRAPH_SCOPE }); + const normalizer = deps.normalizeGraphEvents || normalizeGraphEvents; + eventStore = deps.eventStore || createTeamsEventStore({ appId }); + const activeSubscription = async row => (await eventStore.list()).some(current => + current.conversationId === row.conversationId && current.startedAt === row.startedAt); + dispatchInbox = deps.graphDispatchInbox || (deps.createInbox || createDurableInbox)({ + namespace: `msteams-graph-dispatch:${appId}`, + handle: async ({ inbound, serviceUrl: sourceUrl, subscription }) => { + if (!await activeSubscription(subscription)) return; + await onMessage(inbound, { serviceUrl: sourceUrl }); + }, + interrupted: async ({ inbound }) => { + await connector.post({ conversationId: inbound.conversationId, threadKey: inbound.threadKey, + text: "A Teams edit or reaction request was interrupted before its outcome could be confirmed. Check the conversation before retrying; it was not run again automatically." }); + }, + log, + }); + inbox = deps.graphInbox || (deps.createInbox || createDurableInbox)({ + namespace: `msteams-graph:${appId}`, + handle: async ({ message, row }) => { + if (!await activeSubscription(row)) return; + const nativeId = row.context.conversation.id; + const rosterApi = deps.apiForServiceUrl?.(row.context.serviceUrl) || createTeamsApi({ auth, serviceUrl: row.context.serviceUrl }); + let roster; + const resolveMember = async aadId => { + roster ||= await rosterApi.listMembers(nativeId); + return roster.find(member => member.aadObjectId === aadId) || null; + }; + for (const inbound of await normalizer(message, row, { botId, resolveMember })) { + if (!inbound.raw?.eventId) throw new Error("Teams Graph event requires stable identity"); + dispatchInbox.accept({ id: inbound.raw.eventId, conversationId: inbound.raw.eventId, + payload: { inbound, serviceUrl: row.context.serviceUrl, subscription: { conversationId: row.conversationId, startedAt: row.startedAt } } }); + } + }, + interrupted: async ({ row }) => { + await connector.post({ conversationId: row.conversationId, + text: "A Teams edit or reaction request was interrupted before its outcome could be confirmed. Check the conversation before retrying; it was not run again automatically." }); + }, + log, + }); + notificationInbox = deps.graphNotificationInbox || (deps.createInbox || createDurableInbox)({ + namespace: `msteams-graph-notifications:${appId}`, + handle: async ({ accepted }) => graph.processNotifications(accepted), + // Only reads and durable downstream accepts have happened. Retrying these is safe; + // snapshot/event identities prevent repeating already accepted engine work. + interrupted: async ({ accepted }) => graph.processNotifications(accepted), + log, + }); + graph = (deps.createGraphEvents || createTeamsGraphEvents)({ + auth: graphAuth, notificationUrl: `${publicUrl.replace(/\/$/, "")}/api/teams/notifications`, tenantId, + store: eventStore, + enqueueNotifications: async accepted => { + for (const envelope of accepted) { + notificationInbox.accept({ id: randomUUID(), conversationId: envelope.row.conversationId, + payload: { accepted: [envelope] } }); + } + }, + log: (message, detail) => log.warn?.(`[msteams] ${message}`, detail), + onMessage: async (message, row) => { + const id = createHash("sha256").update(JSON.stringify([row.conversationId, message.id, message.etag, message.lastModifiedDateTime, message])).digest("hex"); + inbox.accept({ id, conversationId: row.conversationId, payload: { message, row } }); + }, + }); + dispatchInbox.start(); + inbox.start(); + notificationInbox.start(); + graph.start(); + } + async function onActivity(activity) { + if (!graph || !["message", "messageUpdate", "conversationUpdate", "installationUpdate"].includes(activity?.type)) return; + const nativeId = splitConversationId(activity.conversation?.id).conversationId; + const trustedService = validateServiceUrl(activity.serviceUrl); + const activityTenant = activity.channelData?.tenant?.id || activity.conversation?.tenantId; + if (!nativeId || !isConversationId(nativeId) || !trustedService || activityTenant !== tenantId) return; + const removed = activity.type === "installationUpdate" && ["remove", "remove-upgrade"].includes(activity.action) + || activity.type === "conversationUpdate" && activity.membersRemoved?.some(member => member.id === botId); + if (removed) { + const removedTeam = activity.channelData?.team || {}; + const affected = new Set([`teams:${nativeId}`]); + if (activity.conversation?.conversationType === "channel" && (removedTeam.id || removedTeam.aadGroupId)) { + for (const row of await eventStore.list()) { + const savedTeam = row.context?.channelData?.team; + if (savedTeam && (removedTeam.id && savedTeam.id === removedTeam.id + || removedTeam.aadGroupId && savedTeam.aadGroupId === removedTeam.aadGroupId)) affected.add(row.conversationId); + } + } + for (const conversationId of affected) await graph.remove(conversationId); + return; + } + const kind = activity.conversation?.conversationType; + let resource; + let teamGuid = ""; + if (kind === "channel") { + let team = activity.channelData?.team?.aadGroupId; + if (!team && activity.channelData?.team?.id) { + try { + const rosterApi = deps.apiForServiceUrl?.(trustedService) || createTeamsApi({ auth, serviceUrl: trustedService }); + const info = await rosterApi.teamInfo(activity.channelData.team.id); + team = info?.aadGroupId || info?.groupId; + } catch { log.warn?.("[msteams] team identity unavailable for Graph event subscription"); return; } + } + const channel = activity.channelData?.channel?.id || nativeId; + if (!team || !/^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$/i.test(team) || !isConversationId(channel)) return; + teamGuid = team; + resource = `/teams/${encodeURIComponent(team)}/channels/${encodeURIComponent(channel)}/messages`; + } else if (kind === "groupChat" || kind === "groupchat") { + resource = `/chats/${encodeURIComponent(nativeId)}/messages`; + } else return; + const row = { + conversationId: `teams:${nativeId}`, resource, + context: { conversation: { id: nativeId, conversationType: kind, name: activity.conversation?.name || "" }, + serviceUrl: trustedService, recipient: { id: botId }, + channelData: { tenant: { id: tenantId }, + ...(kind === "channel" ? { team: { aadGroupId: teamGuid, ...(activity.channelData?.team?.id ? { id: activity.channelData.team.id } : {}) }, channel: { id: activity.channelData?.channel?.id || nativeId } } : {}) } }, + }; + // Subscription errors are retried by Graph maintenance and never block a normal bot turn. + void graph.ensure(row).catch(() => log.warn?.("[msteams] could not register conversation event subscription")); + } + const handler = createTeamsWebhook({ appId, botId, onMessage, onActivity, graphEventsEnabled, jwks, log }); return { platform: "msteams", connector, handler, + graphHandler: graph?.handle || null, + graphEventsEnabled, + onActivity, botId, detail: `bot ${appId}`, - async stop() { /* nothing to unwind: the route asks the manager for the live handler */ }, + async stop() { notificationInbox?.stop(); inbox?.stop(); dispatchInbox?.stop(); await graph?.stop(); }, }; } diff --git a/src/platforms/msteams/verify.js b/src/platforms/msteams/verify.js index 3b55ad1..7ee1e77 100644 --- a/src/platforms/msteams/verify.js +++ b/src/platforms/msteams/verify.js @@ -129,6 +129,6 @@ export async function verifyTeamsRequest({ authorization, appId, serviceUrl = "" // carry a list of message identifiers. export function activityFingerprint(activity) { return createHash("sha256") - .update(`${activity?.id || ""}|${activity?.conversation?.id || ""}|${activity?.timestamp || ""}`) + .update(JSON.stringify([activity?.type, activity?.id, activity?.conversation?.id, activity?.timestamp, activity?.from?.id, activity?.text, activity?.entities, activity?.reactionsAdded, activity?.reactionsRemoved, activity?.channelData?.eventType, activity?.replyToId, activity?.attachments])) .digest("hex"); } diff --git a/src/platforms/msteams/webhook.js b/src/platforms/msteams/webhook.js index 896f662..b00fb7d 100644 --- a/src/platforms/msteams/webhook.js +++ b/src/platforms/msteams/webhook.js @@ -15,7 +15,7 @@ import { normalizeActivity } from "./activity.js"; import { validateServiceUrl } from "./api.js"; import { createDedupe } from "../googlechat/pubsub.js"; -export function createTeamsWebhook({ appId, botId = "", onMessage, jwks = null, log = console, dedupe = null } = {}) { +export function createTeamsWebhook({ appId, botId = "", onMessage, jwks = null, log = console, dedupe = null, onActivity = null, graphEventsEnabled = false } = {}) { if (!appId) throw new Error("Teams webhook requires the bot app id"); if (typeof onMessage !== "function") throw new TypeError("Teams webhook requires an onMessage handler"); const keys = jwks || createJwksCache(); @@ -49,6 +49,10 @@ export function createTeamsWebhook({ appId, botId = "", onMessage, jwks = null, try { if (seen.isDuplicate(activityFingerprint(activity))) return; + if (onActivity) await onActivity(activity); + // Graph is the sole owner of revisions/reactions when enabled; two transports must not + // dispatch the same action twice. New Bot Framework messages retain their attachment path. + if (graphEventsEnabled && String(activity.conversation?.conversationType).toLowerCase() !== "personal" && ["messageupdate", "messagereaction"].includes(String(activity.type).toLowerCase())) return; const message = normalizeActivity(activity, { botId }); if (!message) return; // not a message activity, or our own echo await onMessage(message, { serviceUrl }); diff --git a/src/web/app.js b/src/web/app.js index 4dabf72..357c1a3 100644 --- a/src/web/app.js +++ b/src/web/app.js @@ -165,6 +165,15 @@ export function createWebApp({ } }); + // Graph basic notifications authenticate using persisted subscription identity and clientState. + // No admin cookie is consumed; the handler also handles Graph's endpoint validation challenge. + app.post("/api/teams/notifications", async (req, res) => { + const handler = transports?.msteams?.getTransport?.()?.graphHandler; + if (!handler) return res.status(503).json({ error: "Teams message events are not enabled" }); + try { await handler(req, res); } + catch { if (!res.headersSent) res.status(503).json({ error: "Teams event delivery failed" }); } + }); + // Short-lived, single-file browser editor links originate from an authorized Slack file modal. // This router sits outside the admin login because ordinary approved channel members are allowed // to edit in writable modes; it has its own one-time grant → HttpOnly cookie exchange and repeats diff --git a/src/web/routes/settings.js b/src/web/routes/settings.js index 3cfc36a..83e6d7e 100644 --- a/src/web/routes/settings.js +++ b/src/web/routes/settings.js @@ -191,6 +191,7 @@ export function createSettingsRouter({ if (typeof body.teamsAppPassword === "string" && body.teamsAppPassword) patch.teamsAppPassword = body.teamsAppPassword.trim(); if (body.clearTeamsAppPassword === true) patch.teamsAppPassword = ""; if (typeof body.teamsTenantId === "string") patch.teamsTenantId = body.teamsTenantId.trim(); + if (typeof body.teamsAllMessageEvents === "boolean") patch.teamsAllMessageEvents = body.teamsAllMessageEvents; if (typeof body.sessionKeepalive === "string") patch.sessionKeepalive = body.sessionKeepalive.trim(); // Mode selection and credentials are deliberately independent: changing the mode never // clears either the existing personal/channel/org tokens or this organization SDK key. diff --git a/test/platform-controls.test.js b/test/platform-controls.test.js new file mode 100644 index 0000000..cb4cb05 --- /dev/null +++ b/test/platform-controls.test.js @@ -0,0 +1,86 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { ensureTestEnv } from './helpers.js'; +ensureTestEnv(); +const { createConversationControls } = await import('../src/platforms/conversation-controls.js'); +const { createIngest, createConversationProgress } = await import('../src/platforms/ingest.js'); +const { makeInbound } = await import('../src/platforms/inbound.js'); +const { setUser } = await import('../src/config/store.js'); +const { saveSession, getSession, sessionGeneration } = await import('../src/gateway/sessions.js'); +const { getThreadModel } = await import('../src/gateway/thread-engine.js'); +const { platformOr } = await import('../src/platforms/registry.js'); +const deferred = () => { let resolve; const promise = new Promise((r) => { resolve = r; }); return { promise, resolve }; }; +const message = (text = 'hello', userId = 'controls-owner') => ({ conversationId: 'teams:controls', text, userId, isDM: false }); +const args = (controls, text, extra = {}) => controls.command({ message: message(text), sessionKey: 'root', slug: 'controls', meta: { engine: 'claude' }, authorIsAdmin: true, reply: async () => {}, ...extra }); + +test('same-session work serializes; a different session runs immediately', async () => { + const controls = createConversationControls(); + const started = deferred(); const release = deferred(); const seen = []; const notices = []; + const first = controls.execute({ message: message(), sessionKey: 'root', queued: async (v) => notices.push(v), work: async () => { seen.push('first'); started.resolve(); await release.promise; } }); + await started.promise; + const second = controls.execute({ message: message(), sessionKey: 'root', queued: async (v) => notices.push(v), work: async () => seen.push('second') }); + await controls.execute({ message: message(), sessionKey: 'other', queued: async () => {}, work: async () => seen.push('other') }); + assert.deepEqual(seen, ['first', 'other']); assert.match(notices[0], /position 1/); + release.resolve(); await Promise.all([first, second]); assert.deepEqual(seen, ['first', 'other', 'second']); +}); + +test('only owner/admin can stop; cancellation prevents queued engine execution', async () => { + const controls = createConversationControls(); const started = deferred(); const stopped = deferred(); const notices = []; + let signal; let queuedRan = false; + const first = controls.execute({ message: message(), sessionKey: 'root', queued: async () => {}, work: async (s) => { signal = s; started.resolve(); await new Promise((r) => s.addEventListener('abort', r, { once: true })); stopped.resolve(); } }); + await started.promise; + const second = controls.execute({ message: message(), sessionKey: 'root', queued: async (v) => notices.push(v), work: async () => { queuedRan = true; } }); + await args(controls, '/stop', { message: message('/stop', 'another-user'), authorIsAdmin: false, reply: async (v) => notices.push(v) }); + assert.equal(signal.aborted, false); assert.match(notices.at(-1), /Only the run author/); + await args(controls, '/stop', { authorIsAdmin: false }); await stopped.promise; await Promise.all([first, second]); + assert.equal(queuedRan, false); assert.ok(notices.includes('Cancelled before starting.')); +}); + +test('clear waits for aborted work and fences late session saves', async () => { + const controls = createConversationControls(); const started = deferred(); const finish = deferred(); const notices = []; + const generation = sessionGeneration('controls', 'root'); + await saveSession('controls', 'root', 'before', 'claude', generation); + const running = controls.execute({ message: message(), sessionKey: 'root', queued: async () => {}, work: async (signal) => { started.resolve(); await finish.promise; assert.equal(signal.aborted, true); await saveSession('controls', 'root', 'late', 'claude', generation); } }); + await started.promise; + const clearing = args(controls, '/clear', { reply: async (v) => notices.push(v) }); + await new Promise((r) => setImmediate(r)); + assert.match(notices[0], /waiting/); assert.equal(notices.length, 1); + const blocked = await controls.execute({ message: message(), sessionKey: 'root', queued: async () => {}, work: async () => assert.fail('must not run while clearing') }); + assert.equal(blocked.skipped, 'clearing'); + finish.resolve(); await Promise.all([running, clearing]); + assert.equal(await getSession('controls', 'root'), ''); assert.match(notices.at(-1), /Session cleared/); +}); + +test('runtime policy is enforced and reactions never execute quoted slash commands', async () => { + const controls = createConversationControls(); const replies = []; + const reply = async (v) => replies.push(v); + await args(controls, '/model sonnet', { authorIsAdmin: false, reply }); + assert.match(replies.at(-1), /restricted/); + assert.equal(await args(controls, '/clear', { message: { ...message('/clear'), trigger: 'reaction' }, reply }), false); + await args(controls, '/model sonnet', { message: { ...message('/model sonnet'), isDM: true }, authorIsAdmin: false, reply }); + assert.equal(await getThreadModel('controls', 'root'), 'sonnet'); + await args(controls, '/effort impossible', { reply }); assert.match(replies.at(-1), /Choose an effort/); +}); + +test('authorized ingest handles controls without invoking engine and preserves group quote root', async () => { + await setUser('teams:controls-ingest-user', { approved: true, admin: true }); + const posts = []; let calls = 0; + const connector = { platform: 'msteams', async post(p) { posts.push(p); return { messageId: `reply${posts.length}`, conversationId: p.conversationId }; }, async edit() {}, async directory() { return null; } }; + const ingest = createIngest({ connector, run: async () => { calls += 1; return { content: 'done' }; }, log: { info() {}, warn() {} } }); + const base = { platform: 'msteams', conversationId: '19:controls@thread.v2', kind: 'group', userId: 'teams:controls-ingest-user', mentionsBot: true }; + await ingest(makeInbound({ ...base, messageId: 'first', text: '/help' })); + await ingest(makeInbound({ ...base, messageId: 'next', replyToId: 'reply1', text: '/status' })); + assert.equal(calls, 0); assert.match(posts[0].text, /Commands/); assert.match(posts[1].text, /Idle/); +}); + +test('progress respects 30s budget and drains pending edit before stop', async (t) => { + t.mock.timers.enable({ apis: ['setInterval', 'Date'], now: 1000 }); + const edits = []; const wait = deferred(); + const progress = createConversationProgress({ connector: { edit: async (v) => { edits.push(v); await wait.promise; } }, message: { rawConversationId: 'wire' }, placeholder: { messageId: 'p' }, adapter: platformOr('msteams') }); + t.mock.timers.tick(29999); await Promise.resolve(); assert.equal(edits.length, 0); + progress.event({ kind: 'run_queued', position: 2 }); + t.mock.timers.tick(1); await Promise.resolve(); assert.equal(edits.length, 1); assert.match(edits[0].text, /position 2/); + t.mock.timers.tick(60000); await Promise.resolve(); assert.equal(edits.length, 1, 'no overlapping edits'); + let stopped = false; const stop = progress.stop().then(() => { stopped = true; }); await Promise.resolve(); assert.equal(stopped, false); + wait.resolve(); await stop; t.mock.timers.tick(60000); assert.equal(edits.length, 1); +}); diff --git a/test/platform-teams-event-transport.test.js b/test/platform-teams-event-transport.test.js new file mode 100644 index 0000000..6ad4301 --- /dev/null +++ b/test/platform-teams-event-transport.test.js @@ -0,0 +1,176 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { createTeamsApi } from "../src/platforms/msteams/api.js"; +import { startTeams } from "../src/platforms/msteams/transport.js"; +import { createTeamsAuth, GRAPH_SCOPE } from "../src/platforms/msteams/auth.js"; +import { createTeamsEventStore } from "../src/platforms/msteams/event-store.js"; +import { DatabaseSync } from "node:sqlite"; +import { migrations } from "../src/db/migrations.js"; + +function fixture() { + const subscriptions = [], accepted = [], dispatched = []; + let options; + const inbox = { start() { this.started = true; }, stop() { this.stopped = true; }, accept(item) { accepted.push(item); } }; + const dispatch = { start() { this.started = true; }, stop() { this.stopped = true; }, accept(item) { dispatched.push(item); } }; + const graph = { start() { this.started = true; }, async stop() { this.stopped = true; }, async ensure(row) { subscriptions.push(row); }, handle() {} }; + return { subscriptions, accepted, dispatched, inbox, dispatch, graph, get options() { return options; }, + deps: { auth: { token: async () => "bot-token" }, api: {}, connector: {}, jwks: {}, graphAuth: { token: async () => "graph-token" }, eventStore: { list: () => subscriptions }, + graphInbox: inbox, graphDispatchInbox: dispatch, graphNotificationInbox: { start() {}, stop() {}, accept() {} }, createGraphEvents: opts => { options = opts; return graph; } } }; +} +const activity = { type: "message", conversation: { id: "19:chat@thread.v2", conversationType: "groupChat" }, + channelData: { tenant: { id: "tenant" } }, serviceUrl: "https://smba.trafficmanager.net/teams/" }; + +test("Teams events are opt-in and ordinary transport remains available", async () => { + const f = fixture(); + const transport = await startTeams({ appId: "app", onMessage: async () => {}, deps: f.deps }); + assert.equal(transport.graphHandler, null); + assert.equal(transport.graphEventsEnabled, false); + await transport.onActivity(activity); + assert.equal(f.subscriptions.length, 0); + await transport.stop(); +}); + +test("Teams events register only authenticated-context tenant/scoped resources", async () => { + const f = fixture(); + const transport = await startTeams({ appId: "app", tenantId: "tenant", allMessageEvents: true, publicUrl: "https://gateway.example/", onMessage: async () => {}, deps: f.deps }); + assert.equal(f.options.notificationUrl, "https://gateway.example/api/teams/notifications"); + assert.equal(transport.graphHandler, f.graph.handle); + await transport.onActivity(activity); + assert.equal(f.subscriptions[0].resource, "/chats/19%3Achat%40thread.v2/messages"); + assert.equal(f.subscriptions[0].conversationId, "teams:19:chat@thread.v2"); + assert.equal(f.subscriptions[0].context.recipient.id, "28:app"); + await transport.onActivity({ ...activity, channelData: { tenant: { id: "wrong" } } }); + await transport.onActivity({ ...activity, serviceUrl: "https://evil.example/" }); + assert.equal(f.subscriptions.length, 1); + await transport.onActivity({ ...activity, type: "conversationUpdate", conversation: { id: "19:channel", conversationType: "channel" }, + channelData: { tenant: { id: "tenant" }, channel: { id: "19:channel" }, team: { aadGroupId: "11111111-2222-3333-4444-555555555555" } } }); + assert.equal(f.subscriptions[1].resource, "/teams/11111111-2222-3333-4444-555555555555/channels/19%3Achannel/messages"); + assert.equal(f.graph.started, true); + await transport.stop(); + assert.equal(f.graph.stopped, true); + assert.equal(f.inbox.stopped, true); + assert.equal(f.dispatch.stopped, true); +}); + +test("Graph callback persists snapshots before acknowledgement with stable identity", async () => { + const f = fixture(); + const transport = await startTeams({ appId: "app", tenantId: "tenant", allMessageEvents: true, publicUrl: "https://gateway.example", onMessage: async () => {}, deps: f.deps }); + const row = { conversationId: "teams:chat", context: {} }, message = { id: "123", etag: "v1" }; + await f.options.onMessage(message, row); + await f.options.onMessage(message, row); + assert.equal(f.accepted.length, 2); + assert.equal(f.accepted[0].id, f.accepted[1].id); + assert.deepEqual(f.accepted[0].payload, { message, row }); + await f.options.onMessage({ ...message, etag: "v2" }, row); + assert.notEqual(f.accepted[2].id, f.accepted[0].id); + await transport.stop(); +}); + +test("Graph auth requests its own audience and rejects arbitrary audiences", async () => { + let request; + const auth = createTeamsAuth({ clientId: "app", clientSecret: "secret", tenantId: "tenant", scope: GRAPH_SCOPE, + fetchImpl: async (_url, init) => { request = init; return { ok: true, text: async () => JSON.stringify({ access_token: "graph-token", expires_in: 3600 }) }; } }); + assert.equal(await auth.token(), "graph-token"); + assert.equal(new URLSearchParams(request.body).get("scope"), GRAPH_SCOPE); + assert.throws(() => createTeamsAuth({ clientId: "app", clientSecret: "secret", scope: "https://evil.example/.default" }), /scope/); +}); + +test("Graph subscription store survives recreation and isolates apps", () => { + const db = new DatabaseSync(":memory:"); + migrations.find(m => m.version === 24).up(db); + const one = createTeamsEventStore({ appId: "one", db }); + const two = createTeamsEventStore({ appId: "two", db }); + one.put({ conversationId: "teams:chat", clientState: "private", subscriptionId: "sub" }); + assert.deepEqual(two.list(), []); + assert.equal(createTeamsEventStore({ appId: "one", db }).list()[0].subscriptionId, "sub"); + one.put({ conversationId: "teams:chat", subscriptionId: "renewed" }); + assert.equal(one.list().length, 1); + one.remove("teams:chat"); + assert.deepEqual(one.list(), []); + db.close(); +}); + +test("Graph snapshot processing resolves the reactor in its conversation and deduplicates each event", async () => { + const handlers = new Map(), queued = new Map(), delivered = [], memberReads = []; + const f = fixture(); + delete f.deps.graphInbox; delete f.deps.graphDispatchInbox; + f.deps.createInbox = options => { + handlers.set(options.namespace, options.handle); + return { start() {}, stop() {}, accept(item) { queued.set(`${options.namespace}:${item.id}`, item); } }; + }; + f.deps.apiForServiceUrl = url => ({ listMembers: async id => { + memberReads.push([url, id]); return [{ id: "29:reactor", aadObjectId: "aad-reactor", name: "Reactor" }]; + } }); + f.deps.normalizeGraphEvents = async (_message, row, { resolveMember }) => { + const member = await resolveMember("aad-reactor"); + assert.equal(member.id, "29:reactor"); + return [{ conversationId: row.conversationId, senderId: member.id, raw: { eventId: "reaction:123:reactor:time" } }]; + }; + const transport = await startTeams({ appId: "app", tenantId: "tenant", allMessageEvents: true, publicUrl: "https://gateway.example", onMessage: async message => delivered.push(message), deps: f.deps }); + const row = { conversationId: "teams:chat", startedAt: "start", context: { conversation: { id: "19:chat" }, serviceUrl: activity.serviceUrl } }; + f.subscriptions.push(row); + await handlers.get("msteams-graph:app")({ message: { id: "123", etag: "1" }, row }); + await handlers.get("msteams-graph:app")({ message: { id: "123", etag: "2" }, row }); + assert.equal(queued.size, 1); + assert.equal([...queued.values()][0].conversationId, "reaction:123:reactor:time"); + assert.deepEqual(memberReads[0], [activity.serviceUrl, "19:chat"]); + await handlers.get("msteams-graph-dispatch:app")([...queued.values()][0].payload); + assert.equal(delivered[0].senderId, "29:reactor"); + await transport.stop(); +}); + +test("Graph skips personal chats, resolves missing team GUID, and revokes uninstall", async () => { + const f = fixture(), removed = []; + f.graph.remove = async id => removed.push(id); + f.deps.apiForServiceUrl = () => ({ teamInfo: async id => { + assert.equal(id, "19:team"); return { aadGroupId: "11111111-2222-3333-4444-555555555555" }; + } }); + const transport = await startTeams({ appId: "app", tenantId: "tenant", allMessageEvents: true, publicUrl: "https://gateway.example", onMessage: async () => {}, deps: f.deps }); + await transport.onActivity({ ...activity, conversation: { id: "a:personal", conversationType: "personal" } }); + assert.equal(f.subscriptions.length, 0); + await transport.onActivity({ ...activity, conversation: { id: "19:channel", conversationType: "channel" }, + channelData: { tenant: { id: "tenant" }, team: { id: "19:team" } } }); + assert.equal(f.subscriptions.length, 1); + assert.match(f.subscriptions[0].resource, /11111111-2222-3333-4444-555555555555/); + await transport.onActivity({ ...activity, type: "installationUpdate", action: "remove" }); + await transport.onActivity({ ...activity, type: "conversationUpdate", membersRemoved: [{ id: "28:app" }] }); + assert.deepEqual(removed, ["teams:19:chat@thread.v2", "teams:19:chat@thread.v2"]); + assert.equal(f.subscriptions.length, 1); + await transport.stop(); +}); + + +test("Teams teamInfo requests only the scoped Bot Framework team endpoint", async () => { + const requests = []; + const api = createTeamsApi({ auth: { token: async () => "bot-token" }, fetchImpl: async (url, init) => { + requests.push({ url, init }); return { ok: true, json: async () => ({ aadGroupId: "group-id" }) }; + } }); + assert.equal((await api.teamInfo("19:team")).aadGroupId, "group-id"); + assert.equal(requests[0].url, "https://smba.trafficmanager.net/teams/v3/teams/19%3Ateam"); + assert.equal(requests[0].init.method, "GET"); + await assert.rejects(api.teamInfo("../users"), /invalid/); +}); + +test("Team uninstall revokes every subscribed channel of that team only", async () => { + const f = fixture(), removed = []; + f.graph.remove = async id => removed.push(id); + const saved = (conversationId, id, aadGroupId) => ({ conversationId, context: { channelData: { team: { id, aadGroupId } } } }); + f.subscriptions.push(saved("teams:channel-a", "19:team", "guid"), saved("teams:channel-b", "19:team", "guid"), saved("teams:other", "19:other", "different")); + const transport = await startTeams({ appId: "app", tenantId: "tenant", allMessageEvents: true, publicUrl: "https://gateway.example", onMessage: async () => {}, deps: f.deps }); + await transport.onActivity({ ...activity, type: "installationUpdate", action: "remove", conversation: { id: "19:general", conversationType: "channel" }, channelData: { tenant: { id: "tenant" }, team: { id: "19:team" } } }); + assert.deepEqual(removed.sort(), ["teams:19:general", "teams:channel-a", "teams:channel-b"].sort()); + await transport.stop(); +}); + +test("Graph identical unversioned notifications each enter durable intake for later snapshot dedup", async () => { + const f = fixture(), notifications = []; + f.deps.graphNotificationInbox = { start() {}, stop() {}, accept: item => notifications.push(item) }; + const transport = await startTeams({ appId: "app", tenantId: "tenant", allMessageEvents: true, publicUrl: "https://gateway.example", onMessage: async () => {}, deps: f.deps }); + const envelope = { event: { subscriptionId: "sub", resourceData: { id: "123" } }, row: { conversationId: "teams:chat" } }; + await f.options.enqueueNotifications([envelope]); + await f.options.enqueueNotifications([envelope]); + assert.equal(notifications.length, 2); + assert.notEqual(notifications[0].id, notifications[1].id); + assert.deepEqual(notifications[0].payload, { accepted: [envelope] }); + await transport.stop(); +}); diff --git a/test/platform-teams-events.test.js b/test/platform-teams-events.test.js new file mode 100644 index 0000000..851dd6a --- /dev/null +++ b/test/platform-teams-events.test.js @@ -0,0 +1,63 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { generateKeyPairSync, createSign } from 'node:crypto'; +import { normalizeActivity } from '../src/platforms/msteams/activity.js'; +import { activityFingerprint } from '../src/platforms/msteams/verify.js'; +import { createTeamsWebhook } from '../src/platforms/msteams/webhook.js'; +const botId = '28:events-bot'; +const appId = 'events-app'; +const serviceUrl = 'https://smba.trafficmanager.net/emea/'; +const base = { type: 'message', id: 'message-1', serviceUrl, timestamp: '2026-09-09T09:00:00Z', conversation: { id: '19:events@thread.v2', conversationType: 'groupchat' }, from: { id: '29:author', name: 'Fixture Author' }, text: 'Xavier hello', entities: [{ type: 'mention', mentioned: { id: botId } }] }; +const normalize = (changes = {}) => normalizeActivity({ ...base, ...changes }, { botId }); + +test('edit event subtype is explicit; real mention entity is required for the mention gate', () => { + assert.equal(normalize({ type: 'messageUpdate', channelData: { eventType: 'editMessage' } }).trigger, 'edit'); + for (const eventType of ['softDeleteMessage', 'undeleteMessage', '', 'EditMessage']) assert.equal(normalize({ type: 'messageUpdate', channelData: { eventType } }), null); + assert.equal(normalize({ type: 'messageDelete' }), null); + assert.equal(normalize({ text: '@Xavier hello', entities: [] }).mentionsBot, false); + assert.equal(normalize({ entities: [{ type: 'mention', mentioned: { id: 'another-bot' } }] }).mentionsBot, false); + assert.equal(normalize({ from: { id: botId } }), null, 'bot echo cannot become a turn'); +}); + +test('robot additions carry reactor identity and target; other reactions and removals do not run', () => { + for (const reaction of ['🤖', 'robot', 'robot_face', '🤖\uFE0F']) { + const value = normalize({ type: 'messageReaction', replyToId: 'bot-answer', text: '', entities: [], reactionsAdded: [{ type: reaction }] }); + assert.equal(value.trigger, 'reaction'); assert.equal(value.replyToId, 'bot-answer'); assert.equal(value.userId, '29:author'); assert.equal(value.mentionsBot, false); + } + assert.equal(normalize({ type: 'messageReaction', replyToId: 'bot-answer', reactionsRemoved: [{ type: 'robot' }] }), null); + assert.equal(normalize({ type: 'messageReaction', replyToId: 'bot-answer', reactionsAdded: [{ type: 'like' }] }), null); + assert.equal(normalize({ type: 'messageReaction', reactionsAdded: [{ type: 'robot' }] }), null, 'no target cannot run'); +}); + +test('fingerprint retries collapse but different authors, revisions and event classes are distinct', () => { + const original = activityFingerprint(base); + assert.equal(original, activityFingerprint(structuredClone(base))); + for (const change of [{ from: { id: '29:other' } }, { text: 'changed' }, { type: 'messageUpdate', channelData: { eventType: 'editMessage' } }, { reactionsAdded: [{ type: 'robot' }] }, { timestamp: '2026-09-09T09:00:01Z' }, { conversation: { id: 'another-chat' } }]) assert.notEqual(original, activityFingerprint({ ...base, ...change })); +}); + +const { privateKey, publicKey } = generateKeyPairSync('rsa', { modulusLength: 2048 }); +const jwk = { ...publicKey.export({ format: 'jwk' }), kid: 'events-key' }; +function token() { + const header = Buffer.from(JSON.stringify({ alg: 'RS256', typ: 'JWT', kid: 'events-key' })).toString('base64url'); + const body = Buffer.from(JSON.stringify({ iss: 'https://api.botframework.com', aud: appId, exp: Math.floor(Date.now() / 1000) + 600, serviceurl: serviceUrl })).toString('base64url'); + const sign = createSign('RSA-SHA256'); sign.update(`${header}.${body}`); + return `${header}.${body}.${sign.sign(privateKey, 'base64url')}`; +} +function response() { return { code: 0, status(code) { this.code = code; return this; }, json() { this.acked = true; } }; } + +test('signed webhook accepts distinct edits once, authenticates before dispatch and acknowledges before run', async () => { + const received = []; + let current; + const handle = createTeamsWebhook({ appId, botId, jwks: { get: async () => jwk }, log: { warn() {}, error() {} }, onMessage: async (m) => { assert.equal(current.acked, true); received.push(m); } }); + const edit = { ...base, type: 'messageUpdate', channelData: { eventType: 'editMessage' } }; + for (const body of [base, edit, edit, { ...edit, text: 'Xavier revised' }]) { current = response(); await handle({ body, headers: { authorization: `Bearer ${token()}` } }, current); assert.equal(current.code, 200); } + assert.deepEqual(received.map((m) => m.trigger), ['message', 'edit', 'edit']); + current = response(); await handle({ body: { ...base, id: 'unauth' }, headers: {} }, current); assert.equal(current.code, 401); assert.equal(received.length, 3); +}); + +test('Graph mode owns edit/reaction events; native new messages remain enabled', async () => { + const received = []; const observed = []; + const handle = createTeamsWebhook({ appId, botId, graphEventsEnabled: true, jwks: { get: async () => jwk }, onActivity: async (a) => observed.push(a.type), onMessage: async (m) => received.push(m), log: { error() {}, warn() {} } }); + for (const body of [base, { ...base, type: 'messageUpdate', channelData: { eventType: 'editMessage' } }, { ...base, type: 'messageReaction', replyToId: 'bot-answer', reactionsAdded: [{ type: 'robot' }] }]) await handle({ body, headers: { authorization: `Bearer ${token()}` } }, response()); + assert.equal(received.length, 1); assert.equal(received[0].trigger, 'message'); assert.equal(observed.length, 3); +}); diff --git a/test/platform-teams-graph-activity.test.js b/test/platform-teams-graph-activity.test.js new file mode 100644 index 0000000..cba3e4c --- /dev/null +++ b/test/platform-teams-graph-activity.test.js @@ -0,0 +1,58 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { normalizeGraphEvents } from '../src/platforms/msteams/graph-activity.js'; +const row = { conversationId: 'teams:19:test@thread.v2', startedAt: '2026-09-09T10:00:00Z', context: { conversation: { id: '19:test@thread.v2', conversationType: 'groupchat' }, serviceUrl: 'https://smba.trafficmanager.net/teams/', channelData: { tenant: { id: 'tenant' } } } }; +const reaction = { reactionType: '🤖', user: { user: { id: 'reactor' } } }; +const fixture = () => ({ id: 'message1', messageType: 'message', from: { user: { id: 'author' } }, body: { contentType: 'html', content: '

Handle this & that

' }, reactions: [reaction], messageHistory: [{ actions: 'reactionAdded', modifiedDateTime: '2026-09-09T10:01:00Z', reaction }] }); +const opts = { now: () => Date.parse('2026-09-09T11:00:00Z'), botId: '28:bot', resolveMember: async id => ({ id: `29:${id}`, name: id }) }; +test('robot reaction runs as reactor and anchors the original message', async () => { + const [event] = await normalizeGraphEvents(fixture(), row, opts); + assert.equal(event.userId, '29:reactor'); assert.equal(event.trigger, 'reaction'); + assert.equal(event.replyToId, 'message1'); assert.equal(event.text, 'Handle this & that'); + assert.equal(event.mentionsBot, false); assert.equal(event.threadKey, ''); + assert.match(event.messageId, /^reaction:/); +}); +test('history dedup key survives later snapshots but remove/readd gets a distinct key', async () => { + const message = fixture(); const [first] = await normalizeGraphEvents(message, row, opts); + message.lastModifiedDateTime = '2026-09-09T10:02:00Z'; + const [repeat] = await normalizeGraphEvents(message, row, opts); + assert.equal(first.raw.eventId, repeat.raw.eventId); + message.messageHistory[0].modifiedDateTime = '2026-09-09T10:03:00Z'; + const [added] = await normalizeGraphEvents(message, row, opts); + assert.notEqual(first.raw.eventId, added.raw.eventId); +}); +test('removed, historical, nonrobot, missing roster and deleted messages do not trigger', async () => { + for (const change of [m => { m.reactions = []; }, m => { m.messageHistory[0].modifiedDateTime = row.startedAt; }, m => { m.messageHistory = [{ actions: 'reactionRemoved', reaction }]; }, m => { m.deletedDateTime = 'now'; }, m => { m.messageHistory[0].reaction = { ...reaction, reactionType: '👍' }; }]) { + const message = fixture(); change(message); assert.deepEqual(await normalizeGraphEvents(message, row, opts), []); + } + assert.deepEqual(await normalizeGraphEvents(fixture(), row, { ...opts, resolveMember: async () => null }), []); +}); +test('edits require genuine bot mentions in groups and use edit time not modification time', async () => { + const message = fixture(); message.messageHistory = []; message.lastModifiedDateTime = '2026-09-09T10:02:00Z'; + message.body.content = 'Xavier process'; + assert.deepEqual(await normalizeGraphEvents(message, row, opts), []); + message.lastEditedDateTime = '2026-09-09T10:02:00Z'; + assert.deepEqual(await normalizeGraphEvents(message, row, opts), []); + message.mentions = [{ mentioned: { application: { id: 'bot' } } }]; + const [event] = await normalizeGraphEvents(message, row, opts); + assert.equal(event.trigger, 'edit'); assert.equal(event.userId, '29:author'); assert.equal(event.messageId, 'message1'); +}); +test('own bot reactions continue while other bots and bot edits are ignored', async () => { + const message = fixture(); message.from = { application: { id: 'bot' } }; message.lastEditedDateTime = '2026-09-09T10:02:00Z'; + const events = await normalizeGraphEvents(message, row, opts); + assert.equal(events.length, 1); assert.equal(events[0].text, 'Continue the task from this message.'); + message.from.application.id = 'another-bot'; + assert.deepEqual(await normalizeGraphEvents(message, row, opts), []); +}); +test('channel reply reactions stay in native thread and quoted edits keep the reference', async () => { + const channel = structuredClone(row); channel.context.conversation.conversationType = 'channel'; + const message = fixture(); message.replyToId = 'native-root'; + const [event] = await normalizeGraphEvents(message, channel, opts); assert.equal(event.threadKey, 'native-root'); + message.lastEditedDateTime = '2026-09-09T10:02:00Z'; message.mentions = [{ mentioned: { application: { id: 'bot' } } }]; + message.body.content = '
old
new'; + const [edit] = await normalizeGraphEvents(message, row, opts); assert.equal(edit.replyToId, 'quoted'); +}); + +test('old history never replays after inbox tombstone retention', async () => { + assert.deepEqual(await normalizeGraphEvents(fixture(), row, { ...opts, now: () => Date.parse('2026-09-20T11:00:00Z') }), []); +}); diff --git a/test/platform-teams-graph-events.test.js b/test/platform-teams-graph-events.test.js new file mode 100644 index 0000000..c6d3a7b --- /dev/null +++ b/test/platform-teams-graph-events.test.js @@ -0,0 +1,180 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { createTeamsGraphEvents } from "../src/platforms/msteams/graph-events.js"; + +function fixture(options = {}) { + const rows = new Map(), requests = [], messages = [], logs = []; + let time = Date.parse("2026-09-09T10:00:00Z"), fail = null; + const service = createTeamsGraphEvents({ + auth: { token: async () => "test-token" }, tenantId: "tenant", + notificationUrl: "https://example.org/api/teams/graph", now: () => time, + intervalMs: options.intervalMs || 60_000, + enqueueNotifications: options.enqueueNotifications || null, + log: (...args) => logs.push(args), + store: { list: async () => [...rows.values()].map(row => ({ ...row })), put: async row => rows.set(row.conversationId, { ...row }), remove: async id => rows.delete(id) }, + onMessage: async (...args) => { if (options.failCallback) throw new Error("queue unavailable"); messages.push(args); }, + fetchImpl: async (url, init) => { + requests.push({ url, ...init }); + if (fail) return { ok: false, status: fail }; + if (init.method === "DELETE") return { ok: true, status: 204 }; + if (url.includes("/subscriptions")) return { ok: true, status: 200, json: async () => ({ id: "sub-1", expirationDateTime: JSON.parse(init.body).expirationDateTime }) }; + return { ok: true, status: 200, json: async () => ({ id: "123", body: { content: "hello" } }) }; + }, + }); + const row = { conversationId: "teams:19:chat@thread.v2", resource: "/chats/19:chat@thread.v2/messages", context: { conversation: { id: "19:chat@thread.v2" } } }; + const notification = () => ({ subscriptionId: "sub-1", clientState: rows.get(row.conversationId)?.clientState, tenantId: "tenant", changeType: "updated", resource: "chats('19:chat@thread.v2')/messages('123')", resourceData: { id: "123" } }); + async function handle(body, query) { + const response = { code: null, mime: null, body: null, status(code) { this.code = code; return this; }, type(mime) { this.mime = mime; return this; }, send(body) { this.body = body; return this; }, end() { return this; } }; + await service.handle({ body, query }, response); + return response; + } + return { service, rows, row, notification, requests, messages, logs, handle, advance: ms => { time += ms; }, fail: status => { fail = status; } }; +} + +test("Graph creates scoped beta chat subscriptions, no encrypted data or lifecycle needed", async () => { + const f = fixture(); + const row = await f.service.ensure(f.row); + assert.equal(row.apiVersion, "beta"); + assert.match(row.clientState, /^[a-f0-9]{64}$/); + const request = f.requests[0], body = JSON.parse(request.body); + assert.equal(request.url, "https://graph.microsoft.com/beta/subscriptions"); + assert.equal(request.redirect, "error"); + assert.equal(body.includeResourceData, false); + assert.equal(body.expirationDateTime, "2026-09-09T10:55:00.000Z"); + assert.equal(body.resource, "/chats/19%3Achat%40thread.v2/messages"); + await f.service.ensure(f.row); + assert.equal(f.requests.length, 1); + f.advance(21 * 60_000); + await f.service.renew(); + assert.equal(f.requests[1].method, "PATCH"); + assert.equal(f.rows.get(f.row.conversationId).clientState, row.clientState); +}); + +test("Graph uses stable channel API and permits only replies inside subscribed channel", async () => { + const f = fixture(); + await f.service.ensure({ ...f.row, resource: "/teams/team-1/channels/channel-1/messages" }); + assert.match(f.requests[0].url, /\/v1\.0\/subscriptions$/); + const notification = { ...f.notification(), resource: "teams('team-1')/channels('channel-1')/messages('root')/replies('123')" }; + assert.equal((await f.handle({ value: [notification] })).code, 200); + assert.match(f.requests[1].url, /\/teams\/team-1\/channels\/channel-1\/messages\/root\/replies\/123$/); + assert.equal(f.messages.length, 1); +}); + +test("Graph validation token is echoed plain text without authenticating or fetching", async () => { + const f = fixture(); + const response = await f.handle(null, { validationToken: "opaque decoded + token" }); + assert.equal(response.code, 200); + assert.equal(response.mime, "text/plain"); + assert.equal(response.body, "opaque decoded + token"); + assert.equal(f.requests.length, 0); +}); + +test("Graph rejects forged notifications, wrong scopes and URL traversal before GET", async () => { + const f = fixture(); + await f.service.ensure(f.row); + for (const patch of [ + { clientState: "wrong" }, { tenantId: "other" }, { subscriptionId: "unknown" }, + { resource: "chats('another')/messages('123')" }, + { resource: "https://evil.example/chats/19:chat@thread.v2/messages/123" }, + { resource: "chats('19:chat@thread.v2')/messages('%2e%2e%2fusers')" }, + { resource: "chats('19:chat@thread.v2')/messages('%2e%2e')", resourceData: { id: ".." } }, + { resourceData: { id: "different" } }, { changeType: "unknown" }, + ]) assert.equal((await f.handle({ value: [{ ...f.notification(), ...patch }] })).code, 403); + assert.equal(f.requests.length, 1); + assert.equal(f.messages.length, 0); +}); + +test("Graph validates entire batch before processing and retries GET/queue failure", async () => { + const f = fixture(); + await f.service.ensure(f.row); + assert.equal((await f.handle({ value: [f.notification(), { ...f.notification(), tenantId: "wrong" }] })).code, 403); + assert.equal(f.requests.length, 1); + f.fail(429); + assert.equal((await f.handle({ value: [f.notification()] })).code, 503); + f.fail(null); + assert.equal((await f.handle({ value: [f.notification()] })).code, 200); + assert.equal(f.messages.length, 1); + const unavailable = fixture({ failCallback: true }); + await unavailable.service.ensure(unavailable.row); + assert.equal((await unavailable.handle({ value: [unavailable.notification()] })).code, 503); +}); + +test("Graph preserves retryable state on create or renewal errors without logging secrets", async () => { + const f = fixture(); + f.fail(403); + await f.service.ensure(f.row); + assert.equal(f.rows.size, 1); + assert.equal(f.rows.get(f.row.conversationId).subscriptionId, null); + f.fail(null); + await f.service.renew(); + assert.equal(f.rows.get(f.row.conversationId).subscriptionId, "sub-1"); + f.advance(21 * 60_000); + f.fail(500); + await f.service.renew(); + assert.equal(f.rows.get(f.row.conversationId).renewedAt, Date.parse("2026-09-09T10:00:00Z")); + f.fail(null); + await f.service.renew(); + assert.equal(f.rows.get(f.row.conversationId).renewedAt, Date.parse("2026-09-09T10:21:00Z")); + assert.ok(!JSON.stringify(f.logs).includes(f.rows.get(f.row.conversationId).clientState)); + assert.ok(!JSON.stringify(f.logs).includes("test-token")); +}); + +test("Graph stop clears repeated maintenance and start is idempotent", async () => { + const f = fixture({ intervalMs: 10 }); + await f.service.ensure(f.row); + f.advance(21 * 60_000); + f.service.start(); f.service.start(); + await new Promise(resolve => setTimeout(resolve, 25)); + await f.service.stop(); + const count = f.requests.length; + f.advance(21 * 60_000); + await new Promise(resolve => setTimeout(resolve, 25)); + assert.equal(f.requests.length, count); + assert.equal(count, 2); +}); + +test("Graph scope changes are rejected and deleted messages never trigger", async () => { + const f = fixture(); + await f.service.ensure(f.row); + await assert.rejects(f.service.ensure({ ...f.row, resource: "/chats/other/messages" }), /scope changed/); + assert.equal((await f.handle({ value: [{ ...f.notification(), changeType: "deleted" }] })).code, 200); + assert.equal(f.requests.length, 1); + assert.equal((await f.handle({ value: [] })).code, 400); +}); + +test("Graph notification URL change recreates subscription and uninstall revokes local delivery", async () => { + const f = fixture(); + await f.service.ensure(f.row); + const stored = f.rows.get(f.row.conversationId); + stored.notificationUrl = "https://old.example/notifications"; + const previousSecret = stored.clientState; + await f.service.renew(); + assert.equal(f.requests[1].method, "DELETE"); + assert.equal(f.requests[2].method, "POST"); + assert.notEqual(f.rows.get(f.row.conversationId).clientState, previousSecret); + const priorEvent = f.notification(); + await f.service.remove(f.row.conversationId); + assert.equal(f.rows.size, 0); + assert.equal((await f.handle({ value: [priorEvent] })).code, 403); + f.advance(1000); + const installed = await f.service.ensure(f.row); + assert.equal(installed.startedAt, "2026-09-09T10:00:01.000Z"); +}); + + +test("Graph acknowledges durable acceptance without waiting for message fetch and validates before enqueue", async () => { + const queued = []; + const f = fixture({ enqueueNotifications: async accepted => queued.push(accepted) }); + await f.service.ensure(f.row); + assert.equal((await f.handle({ value: [f.notification()] })).code, 202); + assert.equal(f.requests.length, 1); // no GET, even if the API would hang + assert.equal(queued.length, 1); + assert.equal((await f.handle({ value: [{ ...f.notification(), clientState: "forged" }] })).code, 403); + assert.equal(queued.length, 1); + await f.service.processNotifications(queued[0]); + assert.equal(f.messages.length, 1); + const count = f.requests.length; + await f.service.remove(f.row.conversationId); + await f.service.processNotifications(queued[0]); + assert.equal(f.requests.length, count + 1); // DELETE only, revoked snapshot never fetched +}); diff --git a/test/platform-teams.test.js b/test/platform-teams.test.js index bb4ce3c..98ce396 100644 --- a/test/platform-teams.test.js +++ b/test/platform-teams.test.js @@ -323,6 +323,7 @@ test("only a real activity id is used as a thread", () => { assert.equal(connector.threadFor("spaces/AAA/threads/T"), null); assert.equal(connector.supportsThreads("teams:19:abc@thread.tacv2"), true); assert.equal(connector.supportsThreads("teams:a:personal-chat"), false); + assert.equal(connector.supportsThreads("teams:19:group@thread.v2"), false); }); test("an ephemeral-only notice becomes a 1:1 chat, because Teams has no ephemeral message", async () => { diff --git a/test/teams-event-settings.test.js b/test/teams-event-settings.test.js new file mode 100644 index 0000000..68a175d --- /dev/null +++ b/test/teams-event-settings.test.js @@ -0,0 +1,51 @@ +import test, { after } from "node:test"; +import assert from "node:assert/strict"; +import express from "express"; +import { ensureTestEnv } from "./helpers.js"; + +ensureTestEnv(); +const settings = await import("../src/config/settings.js"); +const { createAdminRouter } = await import("../src/web/routes/admin.js"); + +const app = express(); +app.use(express.json()); +app.use(createAdminRouter({ slack: { snapshot: () => ({ connected: false }) } })); +const server = await new Promise(resolve => { + const listening = app.listen(0, "127.0.0.1", () => resolve(listening)); +}); +const base = `http://127.0.0.1:${server.address().port}`; +after(() => server.close()); + +async function patch(body) { + const response = await fetch(`${base}/settings`, { + method: "PUT", headers: { "content-type": "application/json" }, body: JSON.stringify(body), + }); + assert.equal(response.status, 200); + return response.json(); +} + +test("Teams all-message observation is opt-in and never exposes its credential", async () => { + assert.equal(settings.resolveTeamsConfig().allMessageEvents, false); + assert.equal(settings.settingsForApi().teams.allMessageEvents, false); + settings.saveSettings({ teamsAppPassword: "fixture-teams-private-value" }); + await patch({ teamsAllMessageEvents: true, publicUrl: "events.example.test/" }); + assert.equal(settings.resolveTeamsConfig().allMessageEvents, true); + assert.equal(settings.resolveTeamsConfig().publicUrl, "https://events.example.test"); + const masked = settings.settingsForApi().teams; + assert.equal(masked.allMessageEvents, true); + assert.equal(masked.hasAppPassword, true); + assert.equal(JSON.stringify(masked).includes("fixture-teams-private-value"), false); + await patch({ teamsAllMessageEvents: false }); + assert.equal(settings.resolveTeamsConfig().allMessageEvents, false); + assert.equal(settings.resolveTeamsConfig().appPassword, "fixture-teams-private-value"); +}); + +test("nonboolean settings payloads cannot enable all-message observation", async () => { + await patch({ teamsAllMessageEvents: false }); + for (const value of ["true", 1, {}, null]) { + await patch({ teamsAllMessageEvents: value }); + assert.equal(settings.resolveTeamsConfig().allMessageEvents, false); + } + settings.saveSettings({ teamsAllMessageEvents: "true" }); + assert.equal(settings.resolveTeamsConfig().allMessageEvents, false); +}); From 229d6771248b9d91a12eb0d7ca39fb9a37918a33 Mon Sep 17 00:00:00 2001 From: Tiberiu Socaci Date: Wed, 9 Sep 2026 00:36:14 +0300 Subject: [PATCH 34/44] fix: explain automatic shared skill summaries Signed-off-by: Tiberiu Socaci --- CHANGELOG.md | 3 +++ FEATURES.md | 5 +++-- TEST-PLAN.md | 3 ++- src/slack/channel-settings.js | 2 +- test/channel-settings-modal.test.js | 5 +++++ 5 files changed, 14 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f61219b..107bd96 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,8 @@ # Changelog — ChannelGate +- Explain in the All Shared Skills hint that its summary updates automatically when its source + groups are configured, matching the other combined skill summary. + - Include the current advisory network policy in every engine attempt, including resumed and Clean turns, so a changed switch supersedes stale context and cached request results. diff --git a/FEATURES.md b/FEATURES.md index f743ed7..cf1bd75 100644 --- a/FEATURES.md +++ b/FEATURES.md @@ -226,8 +226,9 @@ A categorized catalog of what's shipped. Cross-linked to `TEST-PLAN.md` checks. Only admins can see or manage Cloud MCP capabilities, independently for Claude and Codex; Skills labels each source as Skill Template, Channel Skills, Channel Skills Including Template, Org Skills, or All Shared Skills, with directions to the corresponding controls. Org Skills - explicitly require admins to add/remove them in the admin UI; combined lists are automatic - summaries. Skills supports direct grants and live template assignment; and Secrets opens the established + explicitly require admins to add/remove them in the admin UI; both combined lists explicitly + explain that their summaries update automatically when their source groups change. Skills + supports direct grants and live template assignment; and Secrets opens the established add/update/remove manager. Inherited and template grants are labelled and cannot be removed from the wrong tier. Credential forms never prefill stored values, and all views expose only configured/masked state. Opening it, navigating, submitting, and every individual mutation diff --git a/TEST-PLAN.md b/TEST-PLAN.md index 66d751b..3b7efb0 100644 --- a/TEST-PLAN.md +++ b/TEST-PLAN.md @@ -776,7 +776,8 @@ a pass. Verify Skill Template, Channel Skills, Channel Skills Including Template, Org Skills, and All Shared Skills are shown with configuration guidance. Manage Channel Skills and Change Template open the existing editors. Org Skills says only admins can add/remove them in - admin UI → Skills, and combined groups explain they update automatically. Duplicate skills + admin UI → Skills, and both combined groups each explain they update automatically. + All Shared Skills also directs users to configure the source groups above. Duplicate skills appear once in All Shared Skills. This is a copy-only change; grants and permissions remain unchanged. Live Slack rendering pending. diff --git a/src/slack/channel-settings.js b/src/slack/channel-settings.js index f9ddf0f..05d86dc 100644 --- a/src/slack/channel-settings.js +++ b/src/slack/channel-settings.js @@ -299,7 +299,7 @@ function skillsBlocks(snapshot = {}, state = {}) { fieldBlock("Org Skills", listLabel(skills.organization)), { type: "context", elements: [mrkdwn("Inherited from the organization. Only admins can add or remove Org Skills in the admin UI → Skills. They cannot be removed for just this channel.")] }, fieldBlock("All Shared Skills", listLabel(skills.effective)), - { type: "context", elements: [mrkdwn("Org Skills plus Channel Skills Including Template, with duplicates listed once. Configure the groups above to change this summary.")] }, + { type: "context", elements: [mrkdwn("Org Skills plus Channel Skills Including Template, with duplicates listed once. This summary updates automatically when you configure the groups above.")] }, { type: "actions", elements: [ diff --git a/test/channel-settings-modal.test.js b/test/channel-settings-modal.test.js index 134faa5..a9c592e 100644 --- a/test/channel-settings-modal.test.js +++ b/test/channel-settings-modal.test.js @@ -145,6 +145,11 @@ test("each Settings tab renders its channel setup snapshot", () => { const skills = buildChannelSettingsView(snapshot, state, { tab: "skills" }); assert.match(rendered(skills), /Development/); assert.ok(allButtons(skills).some((button) => button.action_id === CHANNEL_SETTINGS_SKILLS_MANAGE_ACTION_ID)); + for (const heading of ["Channel Skills Including Template", "All Shared Skills"]) { + const index = skills.blocks.findIndex((block) => block.text?.text.startsWith(`*${heading}*\n`)); + assert.ok(index >= 0, `${heading} is shown`); + assert.match(rendered(skills.blocks[index + 1]), /updates automatically/i, `${heading} explains its automatic summary`); + } const secretsView = buildChannelSettingsView(snapshot, state, { tab: "secrets", canEditSecrets: true }); const secrets = rendered(secretsView); assert.match(secrets, /SUPABASE_ACCESS_TOKEN/); From 36a6cfe6508faeeadd04befa52f80b44d4851cb5 Mon Sep 17 00:00:00 2001 From: Tiberiu Socaci Date: Wed, 9 Sep 2026 01:14:34 +0300 Subject: [PATCH 35/44] Remove files command in favor of existing browser controls Signed-off-by: Tiberiu Socaci --- CHANGELOG.md | 3 +++ FEATURES.md | 13 +++++------ README.md | 2 +- TEST-PLAN.md | 15 +++++++----- slack-app-manifest.json | 5 ---- src/slack/app.js | 35 +++++++--------------------- src/slack/file-explorer.js | 4 ++-- src/slack/help.js | 5 ++-- src/slack/message-pipeline.js | 25 +------------------- test/file-explorer.test.js | 6 +++-- test/help-text.test.js | 6 ++++- test/inherited-file-commands.test.js | 9 +++---- 12 files changed, 47 insertions(+), 81 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 107bd96..7bd8a00 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,8 @@ # Changelog — ChannelGate +- Remove the `/files` slash command and typed file-browser entry. The reply folder button and + Browse channel files shortcut remain available; legacy typed requests explain the replacement. + - Explain in the All Shared Skills hint that its summary updates automatically when its source groups are configured, matching the other combined skill summary. diff --git a/FEATURES.md b/FEATURES.md index cf1bd75..22e43de 100644 --- a/FEATURES.md +++ b/FEATURES.md @@ -29,7 +29,7 @@ - **Text commands remain controls after file sharing:** canonical current-message attachments are recovered even when Slack omits them from a trigger. Historical thread files cannot turn - text-only `/files`, stop/cancel, pending or other gateway controls into an agent request. + text-only retired-command notices, stop/cancel, pending or other gateway controls into an agent request. Ordinary followups, unknown commands, queued tasks and engine-native `/compact` retain their normal attachment context; bare `/next` gives its usage hint. → TEST-PLAN: Commands after file sharing. @@ -502,11 +502,10 @@ A categorized catalog of what's shipped. Cross-linked to `TEST-PLAN.md` checks. transcript** and re-trigger. Fresh installers ask whether to provision Whisper, updates honor the stored setting, and disabled mode never downloads raw audio. Typed text remains instructions and raw audio is excluded from Claude/Codex. → TEST-PLAN: Voice prompts. -- Native Slack **channel file explorer**: `/files` opens a Block Kit modal rooted at the channel's +- Native Slack **channel file explorer**: the 📂 reply button opens a Block Kit modal rooted at the channel's effective working folder. Its title identifies the authoritative stored Slack channel name, and its subtitle shows the full absolute current directory, refreshed on every navigation. The - *Browse channel files* message shortcut opens it for a selected thread, typed `@bot /files` posts - an ephemeral *Open files* button for thread-aware use, and every interactive run footer carries a + *Browse channel files* message shortcut opens it for a selected thread, and every interactive run footer carries a requester-bound `📂` button beside `💻` for one-click access; managers also receive the requester-bound **⚙️ Settings** snapshot button described above, and gateway admins may open a control attached to another user's bot reply. When an agent names up to five @@ -568,8 +567,8 @@ A categorized catalog of what's shipped. Cross-linked to `TEST-PLAN.md` checks. hash, atomically replaces the confined file, and records an audit event. Without a public URL the Slack controls its native modal dimensions; apps cannot request a larger popup. Without a public URL, the 3,000-character Slack modal editor remains available on its own. Existing Slack apps must - apply the latest manifest to activate the registered command + shortcut; typed `@bot /files` uses - normal messages. + apply the latest manifest to activate the message shortcut. `/files` is no longer registered or + advertised; a typed legacy request gets a retirement notice without starting an engine. → TEST-PLAN: Channel file explorer. - Slack Agent app (`agent_view`): native status animation (`assistant.threads.setStatus`) with progress-tracking phrases in agent threads. As soon as the spawn runtime resolves, the prominent @@ -753,7 +752,7 @@ A categorized catalog of what's shipped. Cross-linked to `TEST-PLAN.md` checks. channel/DM addressing, 🤖 engagement, thread stopping/steering, files, personal Composio setup, the skill catalog, channel memory/rules, the automatic gateway skills, reminders/schedules, long-running background work, useful status checks, and the complete command reference), - `/files` (native explorer; thread-aware open button), `/clear` (drop the thread's session — next + `/clear` (drop the thread's session — next message starts fresh), `/context` (token usage + % of the context window from the last turn), `/resume` (the copyable `cd "…" && claude --resume ` terminal command for this thread's session — kept out of reply footers; also behind the 💻 button on "🛑 Stopped." messages → modal. diff --git a/README.md b/README.md index 813eaea..02cfff6 100644 --- a/README.md +++ b/README.md @@ -155,7 +155,7 @@ catalog, including edge cases and links to regression coverage. - **Attachments:** download supported images and documents into the channel's uploads folder; retrieve earlier Slack attachments on demand. Large downloads stream to disk, with a 500 MB ceiling. -- **File explorer in Slack:** `/files` opens the channel workspace with bounded previews, +- **File explorer in Slack:** the 📂 reply button opens the channel workspace with bounded previews, folder navigation and permission-checked editing, creation and sharing. - **Browser editor and uploads:** with a configured public URL, open larger text files in a browser editor with Markdown preview and conflict checks, or upload nested folders directly diff --git a/TEST-PLAN.md b/TEST-PLAN.md index 3b7efb0..42ab0c0 100644 --- a/TEST-PLAN.md +++ b/TEST-PLAN.md @@ -95,7 +95,7 @@ - [x] Automated: `node --test test/inherited-file-commands.test.js test/slack-attachment-recovery.test.js test/message-normalize.test.js test/message-to-reply-e2e.test.js test/codex-message-to-reply-e2e.test.js`. The actual message pipeline handles current text commands after a preceding bot file, a root - attachment and no attachment. Require control responses and zero engine starts for `/files`, + attachment and no attachment. Require retirement guidance and zero engine starts for legacy `/files`, stop, pending, `/help` and bare `/next`. Canonical text overrides incomplete trigger text; current canonical files omitted from the trigger and trigger files retained after a lookup failure must keep attachment semantics. Ordinary/unknown commands, queued tasks and Claude's @@ -2672,7 +2672,7 @@ release, no egress cut-off — so the network entry has no container equivalent settings, and every skill directory. `..` and symlinks escaping the channel root are refused; escaping/broken links remain visible without open controls, contained symlinks work, text and binary previews are distinguished, modal state/actions are built, and the manifest registers - `/files` plus the message shortcut. + the 📂 reply button plus the message shortcut. - [x] Unit (`test/file-explorer.test.js`): the authoritative stored channel name reaches the modal title with Slack-safe ASCII and Unicode truncation; the subtitle shows the full absolute root/nested directory and refreshes on navigation, while @@ -2776,7 +2776,7 @@ release, no egress cut-off — so the network entry has no container equivalent directories, escaping symlinks, and absent run/requester context do not. The direct action opens the existing preview with its parent as Back state, while the click path retains the explorer's authorization, membership, and realpath checks. -- [ ] `/files` at conversation top-level opens the native file explorer without starting a +- [ ] The 📂 reply button opens the native file explorer without starting a Claude/Codex run. At root, its title is the stored Slack channel name and its subtitle is the full absolute effective-root path; after nested navigation, the subtitle updates to that absolute directory, and Up/Root return only within the effective root. More than 18 items @@ -2784,9 +2784,12 @@ release, no egress cut-off — so the network entry has no container equivalent confinement settings, instructions, memory, and complete skill trees—appear and can be previewed. Broken/escaping symlinks appear without an open control, and protected/internal paths expose no write controls. -- [ ] Use *Browse channel files* on a thread message, and typed `@bot /files` in that thread: both - open the same explorer with the thread retained. (Slack custom slash commands cannot run in a - thread, so the shortcut/button are the thread-aware entries.) +- [ ] Use *Browse channel files* on a thread message and the 📂 reply button: both open the same + explorer with the thread retained. `/files` is absent from the exported manifest and native + command handlers. In both Claude and Codex fixtures, send `@bot /files` without a current + attachment: require a removal notice pointing to the button/shortcut, no Open files action, + and zero engine starts, including when an older thread message contains a file. Help and + expired-control messages must not recommend the removed command. - [ ] An unauthorized user is refused; after an authorized user leaves the channel, a click in an already-open modal is refused. Forged state cannot change owner/channel, traverse `..`, open a path outside the confined root, follow an escaping symlink, or access a sibling channel. diff --git a/slack-app-manifest.json b/slack-app-manifest.json index f54b860..75ef465 100644 --- a/slack-app-manifest.json +++ b/slack-app-manifest.json @@ -29,11 +29,6 @@ "description": "Show what this channel is working on (jobs, schedules, sessions)", "should_escape": false }, - { - "command": "/files", - "description": "Browse this channel's files and share a selected file", - "should_escape": false - }, { "command": "/secrets", "description": "See and set this channel's environment secrets (values are never shown)", diff --git a/src/slack/app.js b/src/slack/app.js index fc6faba..e9acbb5 100644 --- a/src/slack/app.js +++ b/src/slack/app.js @@ -279,7 +279,7 @@ export async function authorizedControlEntry(channelId, userId) { // Shared by the file explorer and the secrets manager: same channel resolution, same // authorization gate, same "are you still a member" re-check on a modal click. `purpose` only // shapes the message a user reads — never the checks. -export async function fileExplorerContext(client, { channelId, userId, expectedSlug = "", verifyMembership = false, purpose = { expired: "This channel file explorer expired. Open it again with `/files`.", denied: "You're not authorized to browse files in this channel." } } = {}) { +export async function fileExplorerContext(client, { channelId, userId, expectedSlug = "", verifyMembership = false, purpose = { expired: "This channel file explorer expired. Open it again with the 📂 button on a reply.", denied: "You're not authorized to browse files in this channel." } } = {}) { const entry = await getChannelEntry(channelId); if (!entry || (expectedSlug && entry.slug !== expectedSlug)) throw new Error(purpose.expired); const meta = await getChannelMeta(entry.slug); @@ -888,7 +888,7 @@ async function connectAndWire(app) { return; } const state = parseExplorerMetadata(body?.view?.private_metadata); - if (!clicker || state.ownerId !== clicker) throw new Error("This file explorer isn't yours. Open your own with `/files`."); + if (!clicker || state.ownerId !== clicker) throw new Error("This file explorer isn't yours. Open your own with the 📂 button on a reply."); const { entry, meta, userIsAdmin, root } = await fileExplorerContext(client, { channelId: state.channelId, userId: clicker, @@ -982,7 +982,7 @@ async function connectAndWire(app) { }); return; } else { - throw new Error("This file explorer control expired. Open it again with `/files`."); + throw new Error("This file explorer control expired. Open it again with the 📂 button on a reply."); } await updateFileExplorerView(client, body, view); } catch (e) { @@ -1464,7 +1464,7 @@ async function connectAndWire(app) { const navigation = createFileFormNavigation({ ack, client, view }); try { const state = parseExplorerMetadata(view?.private_metadata); - if (!clicker || state.ownerId !== clicker) throw new Error("This file editor isn't yours. Open your own with `/files`."); + if (!clicker || state.ownerId !== clicker) throw new Error("This file editor isn't yours. Open your own with the 📂 button on a reply."); if (!state.editRelative || !state.editHash) throw new Error("This file editor expired. Reopen the file and try again."); const { entry, meta, userIsAdmin, root } = await fileExplorerContext(client, { channelId: state.channelId, @@ -1508,7 +1508,7 @@ async function connectAndWire(app) { const navigation = createFileFormNavigation({ ack, client, view }); try { const state = parseExplorerMetadata(view?.private_metadata); - if (!clicker || state.ownerId !== clicker) throw new Error("This file dialog isn't yours. Open your own with `/files`."); + if (!clicker || state.ownerId !== clicker) throw new Error("This file dialog isn't yours. Open your own with the 📂 button on a reply."); const submittedName = view?.state?.values?.[FILES_NEW_FILE_NAME_BLOCK_ID]?.[FILES_NEW_FILE_NAME_INPUT_ACTION_ID]?.value; const submittedContent = view?.state?.values?.[FILES_NEW_FILE_CONTENT_BLOCK_ID]?.[FILES_NEW_FILE_CONTENT_INPUT_ACTION_ID]?.value; const fileName = normalizeNewFileName(submittedName); @@ -1516,7 +1516,7 @@ async function connectAndWire(app) { if (typeof initialContent !== "string") throw new Error("Slack didn't return the initial file contents."); const loadingEntry = await getChannelEntry(state.channelId); - if (!loadingEntry || loadingEntry.slug !== state.slug) throw new Error("This channel file explorer expired. Open it again with `/files`."); + if (!loadingEntry || loadingEntry.slug !== state.slug) throw new Error("This channel file explorer expired. Open it again with the 📂 button on a reply."); await navigation.show(buildFilesLoadingView(state, { channelName: loadingEntry.name })); const { entry, meta, userIsAdmin, root } = await fileExplorerContext(client, { channelId: state.channelId, @@ -1557,12 +1557,12 @@ async function connectAndWire(app) { const navigation = createFileFormNavigation({ ack, client, view }); try { const state = parseExplorerMetadata(view?.private_metadata); - if (!clicker || state.ownerId !== clicker) throw new Error("This folder dialog isn't yours. Open your own with `/files`."); + if (!clicker || state.ownerId !== clicker) throw new Error("This folder dialog isn't yours. Open your own with the 📂 button on a reply."); const submittedName = view?.state?.values?.[FILES_NEW_FOLDER_BLOCK_ID]?.[FILES_NEW_FOLDER_INPUT_ACTION_ID]?.value; const folderName = normalizeNewFolderName(submittedName); const loadingEntry = await getChannelEntry(state.channelId); - if (!loadingEntry || loadingEntry.slug !== state.slug) throw new Error("This channel file explorer expired. Open it again with `/files`."); + if (!loadingEntry || loadingEntry.slug !== state.slug) throw new Error("This channel file explorer expired. Open it again with the 📂 button on a reply."); await navigation.show(buildFilesLoadingView(state, { channelName: loadingEntry.name })); const { entry, meta, userIsAdmin, root } = await fileExplorerContext(client, { channelId: state.channelId, @@ -1854,23 +1854,6 @@ async function connectAndWire(app) { } }); - // Native Block Kit file browser. Slack slash commands only run at conversation top-level, so - // this shares selected files into the channel; the message shortcut / `@bot /files` path carries - // a thread_ts when users want the selected file posted inside a particular thread. - app.command("/files", async ({ command, ack, respond, client }) => { - await ack(); - try { - await openFileExplorer(client, command.trigger_id, { - channelId: command.channel_id, - userId: command.user_id, - threadTs: command.thread_ts || "", - }); - } catch (e) { - console.error("[slack] /files error:", e.message); - await respond({ response_type: "ephemeral", text: e.message || "Couldn't open this channel's files." }); - } - }); - // Per-channel environment secrets. Lists what exists (names + last 4), and lets anyone who can // run commands here add or replace one. No path in or out of this modal reveals a value. app.command("/secrets", async ({ command, ack, respond, client }) => { @@ -2080,7 +2063,7 @@ async function connectAndWire(app) { if (orgSkills.length) favLines += `\n_Organization-wide: ${orgSkills.length} skill(s) every conversation gets._`; // In-thread commands + the active engine / how to switch models. - const commands = "`/help` · `/status` · `/files` · `/clear` · `/context` · `/mode` · `/model` · `/compact` · `/stop` · `/update` _(admin)_"; + const commands = "`/help` · `/status` · `/clear` · `/context` · `/mode` · `/model` · `/compact` · `/stop` · `/update` _(admin)_"; const engineInfo = `• Default engine: *${getEngine()}* · context window ~${Math.round(getContextWindow() / 1000)}k tokens\n` + "• Switch runtime: `/model` — channel or one thread → harness (Claude/Codex) → model → effort _(channel access set in Settings)_"; diff --git a/src/slack/file-explorer.js b/src/slack/file-explorer.js index b830be5..2562c34 100644 --- a/src/slack/file-explorer.js +++ b/src/slack/file-explorer.js @@ -255,10 +255,10 @@ export function parseExplorerMetadata(raw) { try { value = JSON.parse(String(raw || "")); } catch { - throw new Error("This file explorer expired. Open it again with `/files`."); + throw new Error("This file explorer expired. Open it again with the 📂 button on a reply."); } if (!value || typeof value !== "object" || !value.c || !value.s || !value.u) { - throw new Error("This file explorer expired. Open it again with `/files`."); + throw new Error("This file explorer expired. Open it again with the 📂 button on a reply."); } const state = { channelId: String(value.c), diff --git a/src/slack/help.js b/src/slack/help.js index 9cd0be2..d264e99 100644 --- a/src/slack/help.js +++ b/src/slack/help.js @@ -2,11 +2,11 @@ // from app.js so its important workflows can be regression-tested without wiring a Slack app. export const HELP_TEXT = "*How to use me*\n" + - "In a DM, just send your request. In a channel, write `@agent your request`; keep follow-ups in the thread and @mention me again there. For typed commands in a channel, use forms such as `@agent /help`. The registered `/files`, `/secrets`, `/status`, and `/stop` Slack commands run at conversation top level without a mention.\n\n" + + "In a DM, just send your request. In a channel, write `@agent your request`; keep follow-ups in the thread and @mention me again there. For typed commands in a channel, use forms such as `@agent /help`. The registered `/secrets`, `/status`, and `/stop` Slack commands run at conversation top level without a mention.\n\n" + "• *Act on a message:* react 🤖 to a new top-level message or a message in one of my threads. In another bot's thread, @mention me instead.\n" + "• *Voice prompts:* attach a voice clip. In channels, @mention me or react 🤖 to start; DMs keep their normal behavior. When enabled and installed, audio is transcribed locally with Whisper large-v3-turbo; otherwise I use a completed Slack transcript. If none exists, click *Generate transcript* and trigger me again. Typed text stays as instructions, and raw audio is never sent to Claude or Codex.\n" + "• *Control a live run:* in a channel thread send `@agent stop` (a bare `stop` needs no mention only in a DM) or react 🛑 to stop just that run. `/stop` at top level stops every active run in this channel. If you send another message while I work, choose *Steer Conversation*, *Add to Queue*, or *Cancel Request*; the choice card disappears after a valid selection. `/next ` queues directly without asking.\n" + - "• *Open files:* attach a file/image and ask me to read it, or use `/files` to browse this channel's folder, create new files/folders, and upload multiple files or a folder directly through the secured browser flow when configured—without storing the upload in Slack. With a Public URL, an opened file can be downloaded directly. In Worker/Auto mode, valid UTF-8 text files, including `.env`, JSON, configs, and scripts, can be edited regardless of extension; protected managed/credential/key paths stay read-only. You can also preview and share a file into the conversation. From a thread, send `@agent /files`.\n" + + "• *Open files:* attach a file/image and ask me to read it, or use the 📂 button on a reply to browse this channel's folder, create new files/folders, and upload multiple files or a folder directly through the secured browser flow when configured—without storing the upload in Slack. With a Public URL, an opened file can be downloaded directly. In Worker/Auto mode, valid UTF-8 text files, including `.env`, JSON, configs, and scripts, can be edited regardless of extension; protected managed/credential/key paths stay read-only. You can also preview and share a file into the conversation. The Browse channel files message shortcut also keeps the selected thread.\n" + "• *Add Composio:* In Personal mode, DM me `set my Composio token to …`, then delete the token message. In SDK mode, the admin key creates stable personal/channel identities automatically; you can manage your personal connections, while channel managers manage shared connections. Existing tokens remain saved when modes change. Say whose account to use when it matters.\n" + "• *Skills:* say `list skills` to browse the gateway's catalog, `add the skill for me` to carry one in your own runs, or (managers) `add the skill to this channel` / `apply the Development skills template`. Say `use the skill` when you want one explicitly.\n" + "• *Memory and rules:* say `remember that …` for a durable channel fact, or `always …` for a standing behavior rule. Memory is channel-scoped; never put secrets in it.\n" + @@ -18,7 +18,6 @@ export const HELP_TEXT = "• `/help` — show this guide\n" + "• `/clear` — start a fresh session in this thread\n" + "• `/delete` — delete this thread's messages (admin; irreversible—everyone's if an admin user token is set in Settings, otherwise mine only)\n" + - "• `/files` — browse, create files/folders, upload, edit, and share this channel's files\n" + "• `/secrets` — see which environment variables this channel has (name + last 4 only) and add or replace one. Values are never shown again, to anyone: this is how a channel gets its OWN CLI login (its own Supabase or Vercel account) instead of sharing the host's\n" + "• `/context` — token usage of the last turn\n" + "• `/resume` — terminal command to open this thread's session locally; `/resume ` continues an existing session from this channel's folder in this thread\n" + diff --git a/src/slack/message-pipeline.js b/src/slack/message-pipeline.js index 430d631..8a83cea 100644 --- a/src/slack/message-pipeline.js +++ b/src/slack/message-pipeline.js @@ -44,7 +44,6 @@ import { modelBelongsToEngine, effortBelongsToEngine } from "../engines/registry import { getDirectory } from "./directory.js"; import { listConversationMemberIds } from "./members.js"; -import { actionValue as fileActionValue, FILES_ACTION_ID } from "./file-explorer.js"; import { HELP_TEXT } from "./help.js"; import { formatAppContextProvenance } from "./app-context.js"; import { isIgnorable, isPendingCommand, isStopCommand, mentionsBot, parseNextCommand, parseSlashCommand, SLACK_MENTION_RE, stripMentions } from "./message-normalize.js"; @@ -737,29 +736,7 @@ export async function processMessageEvent(event, client, { botUserId = "", teamI await client.chat.postMessage({ channel: event.channel, ...(res.parentDeleted ? {} : { thread_ts: threadKey }), text: summary }).catch(() => {}); } } else if (sc.cmd === "files") { - // Message events have no trigger_id, so they cannot open a Slack modal directly. Post an - // ephemeral button; its click supplies the short-lived trigger and preserves this thread. - const value = fileActionValue("open", { c: event.channel, t: threadKey, u: event.user }); - const message = { - channel: event.channel, - user: event.user, - thread_ts: threadKey, - text: "Browse this channel's files", - blocks: [ - { - type: "section", - text: { type: "mrkdwn", text: "📂 Browse every file and folder contained in this channel's workspace." }, - accessory: { type: "button", style: "primary", action_id: FILES_ACTION_ID, text: { type: "plain_text", text: "Open files" }, value }, - }, - ], - }; - try { - await client.chat.postEphemeral(message); - } catch { - // Some Slack surfaces do not support threaded ephemerals; keep the control usable. - const { user: _user, ...publicMessage } = message; - await client.chat.postMessage(publicMessage); - } + await reply("The /files command has been removed. Use the 📂 button on a reply or the Browse channel files message shortcut."); } else if (sc.cmd === "context") { const c = lastCtx.get(runKey); await reply( diff --git a/test/file-explorer.test.js b/test/file-explorer.test.js index e2f0a2d..f567dac 100644 --- a/test/file-explorer.test.js +++ b/test/file-explorer.test.js @@ -595,9 +595,11 @@ test("binary preview is identified without dumping bytes into Slack", async (t) assert.equal(preview.shareable, true); }); -test("manifest registers the native /files command and message shortcut", () => { +test("manifest omits the removed files command and retains the message shortcut", () => { const manifest = JSON.parse(readFileSync(new URL("../slack-app-manifest.json", import.meta.url), "utf8")); - assert.ok(manifest.features.slash_commands.some((c) => c.command === "/files")); + assert.ok(!manifest.features.slash_commands.some((c) => c.command === "/files")); + const appSource = readFileSync(new URL("../src/slack/app.js", import.meta.url), "utf8"); + assert.doesNotMatch(appSource, /app\.command\("\/files"/); assert.ok(manifest.features.shortcuts.some((s) => s.callback_id === FILES_SHORTCUT_ID && s.type === "message")); assert.equal(MAX_SHARED_FILE_BYTES, 25 * 1024 * 1024); }); diff --git a/test/help-text.test.js b/test/help-text.test.js index d59e271..6935453 100644 --- a/test/help-text.test.js +++ b/test/help-text.test.js @@ -9,7 +9,7 @@ test("/help explains the gateway's essential user workflows", () => { "react 🤖", "`@agent stop`", "react 🛑", - "`@agent /files`", + "📂 button", "set my Composio token", "list skills", "`remember that …`", @@ -56,3 +56,7 @@ test("/help describes creating files and broad UTF-8 text editing", () => { assert.match(HELP_TEXT, /UTF-8 text files.*including `\.env`/i); assert.match(HELP_TEXT, /Worker\/Auto/i); }); + +test("help does not advertise the removed files command", () => { + assert.doesNotMatch(HELP_TEXT, /\/files/); +}); diff --git a/test/inherited-file-commands.test.js b/test/inherited-file-commands.test.js index f052f52..313f90f 100644 --- a/test/inherited-file-commands.test.js +++ b/test/inherited-file-commands.test.js @@ -44,27 +44,28 @@ async function execute(text, variant, opts) { } for (const variant of ["previous", "root", "none"]) { test(`current controls bypass the engine with ${variant} historical attachments`, async () => { - for (const [command, expected] of [["/files", /Browse this channel's files/], ["stop", /Nothing is running/], ["pending", /follow|pending|waiting/i], ["/help", /ChannelGate|Commands|commands/], ["/next", /Add the task after/]]) { + for (const [command, expected] of [["/files", /The \/files command has been removed/], ["stop", /Nothing is running/], ["pending", /follow|pending|waiting/i], ["/help", /ChannelGate|Commands|commands/], ["/next", /Add the task after/]]) { const result = await execute(`<@${BOT}> ${command}`, variant); assert.equal(result.starts.length, 0, `${command} must not start an engine`); assert.match(result.text, expected); + if (command === "/files") assert.ok(result.client.posted.every(m => !m.blocks?.some(b => b.accessory?.text?.text === "Open files"))); } }); } test("canonical current attachments omitted from a trigger retain attachment semantics", async () => { const result = await execute("/files", "canonical-current"); assert.equal(result.starts.length, 1); - assert.doesNotMatch(result.text, /Browse this channel's files/); + assert.doesNotMatch(result.text, /The \/files command has been removed/); }); test("failed canonical reads preserve current trigger attachments", async () => { const result = await execute("/files", "trigger-current", { lookupFails: true }); assert.equal(result.starts.length, 1); - assert.doesNotMatch(result.text, /Browse this channel's files/); + assert.doesNotMatch(result.text, /The \/files command has been removed/); }); test("canonical text controls routing even when the trigger text is incomplete", async () => { const result = await execute("earlier envelope text", "previous", { canonicalText: `<@${BOT}> /files` }); assert.equal(result.starts.length, 0); - assert.match(result.text, /Browse this channel's files/); + assert.match(result.text, /The \/files command has been removed/); }); test("unknown commands and ordinary followups retain preceding attachment recovery", async () => { for (const prompt of ["/unknown-command", "read that file", "/next read that file", "/compact"]) { From 4a32d39752d8048961101b3346d5cec0e0898624 Mon Sep 17 00:00:00 2001 From: Tiberiu Socaci Date: Wed, 9 Sep 2026 01:15:18 +0300 Subject: [PATCH 36/44] feat: add a standalone Slack menu command Signed-off-by: Tiberiu Socaci --- FEATURES.md | 14 ++++ INSTALL.md | 4 + TEST-PLAN.md | 26 ++++++ slack-app-manifest.json | 5 ++ src/slack/app.js | 38 ++++++++- src/slack/help.js | 3 +- src/slack/menu.js | 46 +++++++++++ src/slack/message-normalize.js | 2 +- src/slack/message-pipeline.js | 3 + test/help-text.test.js | 1 + test/inherited-file-commands.test.js | 2 +- test/menu.test.js | 118 +++++++++++++++++++++++++++ 12 files changed, 258 insertions(+), 4 deletions(-) create mode 100644 src/slack/menu.js create mode 100644 test/menu.test.js diff --git a/FEATURES.md b/FEATURES.md index cf1bd75..0588233 100644 --- a/FEATURES.md +++ b/FEATURES.md @@ -1,5 +1,19 @@ # ChannelGate — Features +## Standalone Slack menu + +- `/menu` returns only one card with **Resume, Files, Secrets, Settings**, directly from the + daemon without starting Claude or Codex. The registered slash command is private to its caller; + `@agent /menu` inside a channel thread (or `/menu` in a DM message) posts the card in that thread. +- All four controls remain available before a session exists. Resume reads the current thread's + session when clicked, uses its owning harness and container workspace (including per-thread + Clean mode), and explains when there is no session. Top-level slash commands do not guess a + thread. Existing file, secret and settings controls retain their authorization checks; Resume + is bound to the requester/channel and rechecks access and membership on click. +- Existing Slack installations must add `/menu` from `slack-app-manifest.json` to their installed + app configuration. The typed `@agent /menu` route needs no Slack app reconfiguration. + + - **Current network policy accompanies every attempt:** fresh, resumed, recovered and fallback prompts state the resolved network switch, including Clean runs. An off switch instructs the engine to explain the current restriction rather than present a cached response as a new request. diff --git a/INSTALL.md b/INSTALL.md index 9eb6450..978f99c 100644 --- a/INSTALL.md +++ b/INSTALL.md @@ -136,6 +136,10 @@ and rollback unverified. Check the log and service before retrying an interrupte ## 4. Create the Slack app (from the manifest) +For an existing app, add `/menu` from the updated `slack-app-manifest.json` to its App Manifest +(or Slash Commands configuration) to enable the native command. After restarting the gateway, +`@agent /menu` also works as a normal channel message without an app configuration update. + 1. Go to → **Create New App** → **From a manifest**. 2. Pick your workspace, paste the contents of [`slack-app-manifest.json`](./slack-app-manifest.json), and create. It preconfigures Socket Mode, all scopes, events, the Assistant feature, and the diff --git a/TEST-PLAN.md b/TEST-PLAN.md index 3b7efb0..56f1cfa 100644 --- a/TEST-PLAN.md +++ b/TEST-PLAN.md @@ -1,5 +1,31 @@ # ChannelGate — Test Plan +## Standalone Slack menu + +- [x] Automated: `node --test test/menu.test.js test/inherited-file-commands.test.js test/help-text.test.js`. + Fresh approved channel/DM fixtures receive exactly one actions block with four controls and no + session creation; typed channel commands retain mention gating and the current thread. Root or + previous attachments do not send `/menu` to an engine. Unapproved users receive no card. Resume + reads only the selected thread, honors the stored Claude/Codex session owner despite a changed + thread pin, wraps the command for the container and uses per-thread Clean cwd. Empty/top-level + and cleared sessions do not select another thread; foreign/revoked/nonmember clicks fail closed. +- [ ] Live (Claude + Codex session fixtures): register `/menu` from the shipped Slack manifest and + restart the candidate daemon. Use an approved test author, one owned Worker channel for each + harness, and a DM. Create an existing session with `@agent Reply MENU_READY`; also create a + per-thread Clean session with `@agent /clean Reply MENU_CLEAN`. Keep a fresh thread with no + session and one thread with a harmless uploaded `menu-fixture.txt`. + Invoke native `/menu` at top level, `@agent /menu` in each channel thread and `/menu` in a DM + thread. Pass: exactly the four-button card, no visible intro/stats/progress and no new + `run_start`. Click Files, Secrets and Settings: the existing channel-scoped modals open without + changing data. Click Resume: existing sessions show their owning harness and container cwd, + including Clean cwd; fresh/top-level cases show guidance. Clear an existing thread and click its + old Resume button: no old session command. A channel message `/menu` without mention is ignored; + a revoked/nonmember or different author cannot open an old menu's controls. Capture Slack + payloads/screenshots, session identity and event evidence. Card delivery is engine-independent; + resume ownership/cwd requires both harness fixtures. Live cases remain unexecuted at development + check time and are required before stable release. + + ## Network policy on resumed turns - [x] Automated: `node --test test/runtime-identity-preamble.test.js test/runtime-access-facts.test.js` diff --git a/slack-app-manifest.json b/slack-app-manifest.json index f54b860..1d66830 100644 --- a/slack-app-manifest.json +++ b/slack-app-manifest.json @@ -19,6 +19,11 @@ "agent_description": "Chat with Claude Code in threads and DMs \u2014 sandboxed per channel." }, "slash_commands": [ + { + "command": "/menu", + "description": "Show Resume, Files, Secrets, and Settings buttons", + "should_escape": false + }, { "command": "/stop", "description": "Stop the bot's in-progress answer in this conversation", diff --git a/src/slack/app.js b/src/slack/app.js index fc6faba..91d7e2b 100644 --- a/src/slack/app.js +++ b/src/slack/app.js @@ -96,6 +96,7 @@ import { appContextForMessage, appContextObservedAt, appContextUserId, createApp import { registerBusyThreadChoiceActions } from "./busy-thread-choice.js"; import { registerEngineSwitchChoiceActions } from "./engine-switch-choice.js"; import { composioHomeButtons, registerComposioHomeActions } from "./home-composio.js"; +import { buildMenuCard, buildMenuResumeView, MENU_RESUME_ACTION_ID } from "./menu.js"; import { buildStatusReport } from "./status-controller.js"; // Re-exported for existing importers (tests) — moved to slack/message-pipeline.js. export { stripMentions, isIgnorable, fetchThreadContext, deleteThreadMessages }; @@ -298,6 +299,37 @@ export async function fileExplorerContext(client, { channelId, userId, expectedS return { entry, meta, userIsAdmin, userIsApproved, root: effectiveWorkDir(entry.slug, meta) }; } +export async function handleMenuCommand({ command, ack, respond, client }) { + await ack(); + try { + if (!(await getChannelEntry(command.channel_id))) { + await ensureRegistered(client, { channel: command.channel_id, user: command.user_id, channel_type: syntheticChannelType(command.channel_id) }); + } + await ensureUserKnown(client, command.user_id); + await fileExplorerContext(client, { channelId: command.channel_id, userId: command.user_id }); + await respond({ response_type: "ephemeral", ...buildMenuCard(command.channel_id, command.thread_ts || "", command.user_id) }); + } catch (error) { + await respond({ response_type: "ephemeral", text: error.message || "Couldn't open the channel menu." }); + } +} + +export async function handleMenuResumeAction({ ack, body, action, client }) { + await ack(); + const channelId = body?.channel?.id || body?.container?.channel_id; + const userId = body?.user?.id; + try { + const value = JSON.parse(action?.value || "{}"); + if (!userId || value.u !== userId || value.c !== channelId || typeof value.t !== "string") { + throw new Error("This menu isn't yours. Open your own with `/menu`."); + } + const context = await fileExplorerContext(client, { channelId, userId, verifyMembership: true }); + const view = await buildMenuResumeView({ ...context, meta: effectiveMeta(context.meta) }, value.t); + await client.views.open({ trigger_id: body.trigger_id, view }); + } catch (error) { + if (channelId && userId) await client.chat.postEphemeral({ channel: channelId, user: userId, text: error.message || "Couldn't open Resume." }); + } +} + async function openFileExplorer(client, triggerId, { channelId, userId, threadTs = "", file = "" } = {}) { if (!(await getChannelEntry(channelId))) { await ensureRegistered(client, { @@ -1840,6 +1872,10 @@ async function connectAndWire(app) { } }); + // Standalone controls, available even before the first engine session. + app.command("/menu", handleMenuCommand); + app.action(MENU_RESUME_ACTION_ID, handleMenuResumeAction); + // /status slash command — reports what the current channel is working on. app.command("/status", async ({ command, ack, respond, client }) => { await ack(); @@ -2080,7 +2116,7 @@ async function connectAndWire(app) { if (orgSkills.length) favLines += `\n_Organization-wide: ${orgSkills.length} skill(s) every conversation gets._`; // In-thread commands + the active engine / how to switch models. - const commands = "`/help` · `/status` · `/files` · `/clear` · `/context` · `/mode` · `/model` · `/compact` · `/stop` · `/update` _(admin)_"; + const commands = "`/menu` · `/help` · `/status` · `/files` · `/clear` · `/context` · `/mode` · `/model` · `/compact` · `/stop` · `/update` _(admin)_"; const engineInfo = `• Default engine: *${getEngine()}* · context window ~${Math.round(getContextWindow() / 1000)}k tokens\n` + "• Switch runtime: `/model` — channel or one thread → harness (Claude/Codex) → model → effort _(channel access set in Settings)_"; diff --git a/src/slack/help.js b/src/slack/help.js index 9cd0be2..8c07328 100644 --- a/src/slack/help.js +++ b/src/slack/help.js @@ -2,7 +2,7 @@ // from app.js so its important workflows can be regression-tested without wiring a Slack app. export const HELP_TEXT = "*How to use me*\n" + - "In a DM, just send your request. In a channel, write `@agent your request`; keep follow-ups in the thread and @mention me again there. For typed commands in a channel, use forms such as `@agent /help`. The registered `/files`, `/secrets`, `/status`, and `/stop` Slack commands run at conversation top level without a mention.\n\n" + + "In a DM, just send your request. In a channel, write `@agent your request`; keep follow-ups in the thread and @mention me again there. For typed commands in a channel, use forms such as `@agent /help`. The registered `/menu`, `/files`, `/secrets`, `/status`, and `/stop` Slack commands run at conversation top level without a mention.\n\n" + "• *Act on a message:* react 🤖 to a new top-level message or a message in one of my threads. In another bot's thread, @mention me instead.\n" + "• *Voice prompts:* attach a voice clip. In channels, @mention me or react 🤖 to start; DMs keep their normal behavior. When enabled and installed, audio is transcribed locally with Whisper large-v3-turbo; otherwise I use a completed Slack transcript. If none exists, click *Generate transcript* and trigger me again. Typed text stays as instructions, and raw audio is never sent to Claude or Codex.\n" + "• *Control a live run:* in a channel thread send `@agent stop` (a bare `stop` needs no mention only in a DM) or react 🛑 to stop just that run. `/stop` at top level stops every active run in this channel. If you send another message while I work, choose *Steer Conversation*, *Add to Queue*, or *Cancel Request*; the choice card disappears after a valid selection. `/next ` queues directly without asking.\n" + @@ -15,6 +15,7 @@ export const HELP_TEXT = "• *Long-running work:* ask me to run it in the background. In an Auto/Full-access channel, the gateway keeps the job alive after the current turn and reports back in this thread when it finishes.\n" + "• *Useful checks:* `/status` shows active runs, background jobs, and schedules; `/pending` shows threads waiting on your decision; `/model` changes Claude/Codex, model, and effort; `/mode` shows the channel's tool-access mode.\n\n" + "*Commands* (this thread/channel)\n" + + "• `/menu` — show only the Resume, Files, Secrets, and Settings buttons; use `@agent /menu` inside a channel thread\n" + "• `/help` — show this guide\n" + "• `/clear` — start a fresh session in this thread\n" + "• `/delete` — delete this thread's messages (admin; irreversible—everyone's if an admin user token is set in Settings, otherwise mine only)\n" + diff --git a/src/slack/menu.js b/src/slack/menu.js new file mode 100644 index 0000000..a265989 --- /dev/null +++ b/src/slack/menu.js @@ -0,0 +1,46 @@ +// A standalone controls card: no run, usage footer, or session creation. +import { filesButton, secretsButton, settingsButton, buildResumeCommand } from "./footer.js"; +import { getSession, getSessionEngine } from "../gateway/sessions.js"; +import { effectiveWorkDir } from "../gateway/folders.js"; +import { getThreadClean, resolveThreadEngine } from "../gateway/thread-engine.js"; +import { resolveRuntime } from "../runtimes/resolve.js"; + +export const MENU_RESUME_ACTION_ID = "cg_menu_resume"; + +export function buildMenuCard(channelId, threadTs, authorId) { + return { + text: "Channel menu: Resume, Files, Secrets, Settings", + blocks: [{ type: "actions", elements: [ + { + type: "button", action_id: MENU_RESUME_ACTION_ID, + text: { type: "plain_text", text: "💻 Resume", emoji: true }, + accessibility_label: "Resume this thread in a terminal", + value: JSON.stringify({ c: channelId, t: threadTs || "", u: authorId }), + }, + filesButton(channelId, threadTs, authorId, "📂 Files"), + secretsButton(channelId, threadTs, authorId, "🔑 Secrets"), + settingsButton(channelId, threadTs, authorId, true), + ] }], + }; +} + +// Read the current session on click so an old card cannot resurrect a cleared session or select +// another thread. No container needs to start just to display these controls. +export async function buildMenuResumeView({ entry, meta }, threadTs) { + if (threadTs && await getThreadClean(entry.slug, threadTs)) meta = { ...meta, cleanMode: true }; + const sessionId = threadTs ? await getSession(entry.slug, threadTs) : null; + let text = threadTs + ? "No session in this thread yet. Send a message first, then open Resume again." + : "Open a conversation thread and send `@agent /menu` there to resume its session. In a DM thread, no mention is needed."; + if (sessionId) { + const engine = await getSessionEngine(entry.slug, threadTs) || await resolveThreadEngine(entry.slug, threadTs, meta); + const command = buildResumeCommand(effectiveWorkDir(entry.slug, meta), sessionId, engine, resolveRuntime(entry.slug, meta)); + text = "Run this on the gateway machine to open this thread’s session:\n```" + command + "```"; + } + return { + type: "modal", + title: { type: "plain_text", text: "Resume in terminal" }, + close: { type: "plain_text", text: "Close" }, + blocks: [{ type: "section", text: { type: "mrkdwn", text } }], + }; +} diff --git a/src/slack/message-normalize.js b/src/slack/message-normalize.js index 7afcb47..b3aec41 100644 --- a/src/slack/message-normalize.js +++ b/src/slack/message-normalize.js @@ -3,7 +3,7 @@ export const SLACK_MENTION_RE = /<@([A-Z0-9]+)(?:\|[^>]+)?>/g; const STOP_WORDS = new Set(["stop", "cancel", "abort", "halt", "stop it", "please stop", "stop please", "nevermind", "never mind"]); const PENDING_WORDS = new Set(["pending", "my followups", "my follow-ups", "followups", "follow-ups"]); -const SLASH_COMMANDS = new Set(["help", "clear", "context", "model", "effort", "engine", "compact", "update", "mode", "pending", "followups", "resume", "delete", "files"]); +const SLASH_COMMANDS = new Set(["help", "menu", "clear", "context", "model", "effort", "engine", "compact", "update", "mode", "pending", "followups", "resume", "delete", "files"]); function normalizedControlText(text, punctuation = /[!.…]+$/) { return (text || "").trim().toLowerCase().replace(punctuation, "").replace(/\s+/g, " "); diff --git a/src/slack/message-pipeline.js b/src/slack/message-pipeline.js index 430d631..b143486 100644 --- a/src/slack/message-pipeline.js +++ b/src/slack/message-pipeline.js @@ -45,6 +45,7 @@ import { getDirectory } from "./directory.js"; import { listConversationMemberIds } from "./members.js"; import { actionValue as fileActionValue, FILES_ACTION_ID } from "./file-explorer.js"; +import { buildMenuCard } from "./menu.js"; import { HELP_TEXT } from "./help.js"; import { formatAppContextProvenance } from "./app-context.js"; import { isIgnorable, isPendingCommand, isStopCommand, mentionsBot, parseNextCommand, parseSlashCommand, SLACK_MENTION_RE, stripMentions } from "./message-normalize.js"; @@ -674,6 +675,8 @@ export async function processMessageEvent(event, client, { botUserId = "", teamI const reply = (t) => client.chat.postMessage({ channel: event.channel, thread_ts: threadKey, text: t }); if (sc.cmd === "help") { await reply(HELP_TEXT); + } else if (sc.cmd === "menu") { + await client.chat.postMessage({ channel: event.channel, thread_ts: threadKey, ...buildMenuCard(event.channel, threadKey, event.user) }); } else if (sc.cmd === "clear") { // Kill the thread's LIVE run first, through the same terminal path as `stop` — otherwise // a late-finishing run would post its answer after the clear, auto-continue a warm death, diff --git a/test/help-text.test.js b/test/help-text.test.js index d59e271..324db50 100644 --- a/test/help-text.test.js +++ b/test/help-text.test.js @@ -24,6 +24,7 @@ test("/help explains the gateway's essential user workflows", () => { "Slack transcript", "Generate transcript", "`/status`", + "`/menu`", "`/pending`", ]; diff --git a/test/inherited-file-commands.test.js b/test/inherited-file-commands.test.js index f052f52..2199935 100644 --- a/test/inherited-file-commands.test.js +++ b/test/inherited-file-commands.test.js @@ -44,7 +44,7 @@ async function execute(text, variant, opts) { } for (const variant of ["previous", "root", "none"]) { test(`current controls bypass the engine with ${variant} historical attachments`, async () => { - for (const [command, expected] of [["/files", /Browse this channel's files/], ["stop", /Nothing is running/], ["pending", /follow|pending|waiting/i], ["/help", /ChannelGate|Commands|commands/], ["/next", /Add the task after/]]) { + for (const [command, expected] of [["/menu", /Channel menu/], ["/files", /Browse this channel's files/], ["stop", /Nothing is running/], ["pending", /follow|pending|waiting/i], ["/help", /ChannelGate|Commands|commands/], ["/next", /Add the task after/]]) { const result = await execute(`<@${BOT}> ${command}`, variant); assert.equal(result.starts.length, 0, `${command} must not start an engine`); assert.match(result.text, expected); diff --git a/test/menu.test.js b/test/menu.test.js new file mode 100644 index 0000000..80b9103 --- /dev/null +++ b/test/menu.test.js @@ -0,0 +1,118 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import { ensureTestEnv } from "./helpers.js"; +ensureTestEnv(); +const { useFakeRuntime } = await import("./runtime-fake.js"); +const runtime = await useFakeRuntime(); +const { setUser, upsertChannelEntry, saveChannelMeta, getChannelMeta } = await import("../src/config/store.js"); +const { saveSession, getSessionMap, clearSession } = await import("../src/gateway/sessions.js"); +const { setThreadEngine, setThreadClean } = await import("../src/gateway/thread-engine.js"); +const { handleMenuCommand, handleMenuResumeAction } = await import("../src/slack/app.js"); +const { processMessageEvent } = await import("../src/slack/message-pipeline.js"); +const { buildMenuCard } = await import("../src/slack/menu.js"); +const { readEvents } = await import("../src/util/logger.js"); +let sequence = 0; +async function fixture({ approved = true, dm = false } = {}) { + const channel = `${dm ? "D" : "C"}MENU${++sequence}`; + const user = `UMENU${sequence}`; + const entry = await upsertChannelEntry(channel, { name: `menu-${sequence}`, type: dm ? "im" : "channel" }); + await saveChannelMeta(entry.slug, { ...await getChannelMeta(entry.slug), isDM: dm, allowedUsers: [] }); + await setUser(user, { name: "Menu tester", approved }); + const sent = [], opened = []; + let acked = false; + const client = { + chat: { postMessage: async m => { sent.push(m); return { ok: true, ts: "900.1" }; }, postEphemeral: async m => { sent.push(m); return { ok: true }; } }, + views: { open: async m => { opened.push(m); return { ok: true }; } }, + conversations: { members: async () => ({ members: [user], response_metadata: {} }), replies: async () => ({ messages: [] }), history: async () => ({ messages: [] }) }, + users: { info: async () => ({ user: { real_name: "Menu tester" } }) }, + }; + return { channel, user, entry, client, sent, opened, + args: { command: { channel_id: channel, user_id: user }, client, ack: async () => { acked = true; }, respond: async m => { assert.ok(acked); sent.push(m); } } }; +} +function assertCard(message, channel, user, thread = "") { + assert.equal(message.blocks.length, 1); + assert.equal(message.blocks[0].type, "actions"); + assert.deepEqual(message.blocks[0].elements.map(b => b.text.text), ["💻 Resume", "📂 Files", "🔑 Secrets", "⚙️ Settings"]); + for (const button of message.blocks[0].elements) { + const value = JSON.parse(button.value); + assert.equal(value.c, channel); assert.equal(value.u, user); assert.equal(value.t, thread); + } +} +for (const dm of [false, true]) test(`/menu returns only four buttons in ${dm ? "DM" : "channel"} without a session`, async () => { + const f = await fixture({ dm }); + await handleMenuCommand(f.args); + assert.equal(f.sent.length, 1); + assert.equal(f.sent[0].response_type, "ephemeral"); + assertCard(f.sent[0], f.channel, f.user); + assert.deepEqual(await getSessionMap(f.entry.slug), {}); +}); +test("unapproved users receive no menu in channels or DMs", async () => { + for (const dm of [false, true]) { + const f = await fixture({ approved: false, dm }); + await handleMenuCommand(f.args); + assert.equal(f.sent.length, 1); assert.equal(f.sent[0].blocks, undefined); + assert.match(f.sent[0].text, /not authorized/); + } +}); +test("typed menu preserves thread, mention gate, and bypasses the engine", async () => { + for (const dm of [false, true]) { + const f = await fixture({ dm }); + const event = { channel: f.channel, channel_type: dm ? "im" : "channel", user: f.user, ts: "102.1", thread_ts: "100.1", text: "/menu" }; + if (!dm) { await processMessageEvent(event, f.client, { botUserId: "BMENU" }); assert.equal(f.sent.length, 0); } + await processMessageEvent({ ...event, text: dm ? "/menu" : "<@BMENU> /menu" }, f.client, { botUserId: "BMENU" }); + assert.equal(f.sent.length, 1); assertCard(f.sent[0], f.channel, f.user, "100.1"); + assert.equal(f.sent[0].thread_ts, "100.1"); + assert.equal(readEvents({ limit: 1000 }).filter(e => e.event === "run_start" && e.channel === f.channel).length, 0); + } +}); +async function click(f, { thread = "100.1", user = f.user, channel = f.channel } = {}) { + await handleMenuResumeAction({ ack: async () => {}, client: f.client, + body: { channel: { id: channel }, user: { id: user }, trigger_id: "trigger" }, + action: buildMenuCard(f.channel, thread, f.user).blocks[0].elements[0] }); +} +test("Resume never selects another thread and reads cleared state on click", async () => { + const f = await fixture(); + await saveSession(f.entry.slug, "other", "other-session", "claude"); + await click(f, { thread: "" }); assert.match(JSON.stringify(f.opened.at(-1)), /Open a conversation thread/); + await click(f); assert.match(JSON.stringify(f.opened.at(-1)), /No session/); + await saveSession(f.entry.slug, "100.1", "old-session", "claude"); + await clearSession(f.entry.slug, "100.1"); + await click(f); assert.match(JSON.stringify(f.opened.at(-1)), /No session/); + assert.equal(JSON.stringify(f.opened).includes("other-session"), false); +}); +for (const engine of ["claude", "codex"]) test(`Resume uses stored ${engine} owner and container command`, async () => { + const f = await fixture(); + await saveSession(f.entry.slug, "100.1", "menu-session", engine); + await setThreadEngine(f.entry.slug, "100.1", engine === "claude" ? "codex" : "claude"); + await click(f); + const view = JSON.stringify(f.opened[0]); + assert.match(view, /menu-session/); assert.match(view, new RegExp(engine)); assert.match(view, /exec/); +}); +test("Resume uses the per-thread clean workspace", async () => { + const f = await fixture(); + await saveSession(f.entry.slug, "100.1", "clean-session", "claude"); + await setThreadClean(f.entry.slug, "100.1", true); + await click(f); + const { effectiveWorkDir } = await import("../src/gateway/folders.js"); + const cleanDir = effectiveWorkDir(f.entry.slug, { ...await getChannelMeta(f.entry.slug), cleanMode: true }); + assert.ok(f.opened[0].view.blocks[0].text.text.includes(cleanDir)); +}); +test("Resume rechecks requester, channel, authorization and membership", async () => { + for (const kind of ["user", "channel", "revoked", "membership"]) { + const f = await fixture(); + if (kind === "revoked") await setUser(f.user, { approved: false }); + if (kind === "membership") f.client.conversations.members = async () => ({ members: [], response_metadata: {} }); + await click(f, kind === "user" ? { user: "FOREIGN" } : kind === "channel" ? { channel: "FOREIGN" } : {}); + assert.equal(f.opened.length, 0, kind); assert.equal(f.sent.length, 1, kind); + } +}); +test("manifest and Bolt expose native menu controls", () => { + const manifest = JSON.parse(readFileSync(new URL("../slack-app-manifest.json", import.meta.url))); + assert.ok(manifest.features.slash_commands.some(c => c.command === "/menu")); + const source = readFileSync(new URL("../src/slack/app.js", import.meta.url), "utf8"); + assert.match(source, /app.command\("\/menu", handleMenuCommand\)/); + assert.match(source, /app.action\(MENU_RESUME_ACTION_ID, handleMenuResumeAction\)/); + assert.equal(runtime.calls.spawn.length, 0); + assert.equal(runtime.calls.ensureUp.length, 0); +}); From fca377494ecca5d615ecdee634bad52abdd3c340 Mon Sep 17 00:00:00 2001 From: Tiberiu Socaci Date: Wed, 9 Sep 2026 01:24:59 +0300 Subject: [PATCH 37/44] Add native Teams controls approvals files and local voice Signed-off-by: Tiberiu Socaci --- FEATURES.md | 28 +++++ TEST-PLAN.md | 76 +++++++++++ docs/PLATFORMS.md | 68 ++++++++-- docs/TEAMS-PARITY.md | 38 +++--- public/app.js | 4 + public/index.html | 5 +- src/config/settings.js | 9 ++ src/gateway/transcribe.js | 27 +++- src/platforms/approval-delivery.js | 39 ++++++ src/platforms/attachments.js | 9 +- src/platforms/boot.js | 6 +- src/platforms/ingest.js | 30 +++-- src/platforms/msteams.js | 8 +- src/platforms/msteams/activity.js | 13 +- src/platforms/msteams/api.js | 4 +- src/platforms/msteams/cards.js | 67 ++++++++++ src/platforms/msteams/connector.js | 19 +++ src/platforms/msteams/controls.js | 136 ++++++++++++++++++++ src/platforms/msteams/file-consent.js | 146 ++++++++++++++++++++++ src/platforms/msteams/files.js | 100 +++++++++++++++ src/platforms/msteams/graph-activity.js | 2 +- src/platforms/msteams/interactions.js | 54 ++++++++ src/platforms/msteams/transport.js | 14 ++- src/platforms/msteams/webhook.js | 31 ++++- src/platforms/msteams/workspace-access.js | 20 +++ src/platforms/voice.js | 53 ++++++++ src/slack/approvals.js | 82 +++++++++--- src/web/app.js | 16 +++ src/web/routes/settings.js | 9 ++ test/platform-approvals.test.js | 77 ++++++++++++ test/platform-attachments.test.js | 32 +++++ test/platform-teams-files.test.js | 100 +++++++++++++++ test/platform-voice.test.js | 69 ++++++++++ test/teams-controls.test.js | 81 ++++++++++++ test/teams-file-consent.test.js | 128 +++++++++++++++++++ test/teams-native-cards.test.js | 126 +++++++++++++++++++ 36 files changed, 1654 insertions(+), 72 deletions(-) create mode 100644 src/platforms/approval-delivery.js create mode 100644 src/platforms/msteams/cards.js create mode 100644 src/platforms/msteams/controls.js create mode 100644 src/platforms/msteams/file-consent.js create mode 100644 src/platforms/msteams/files.js create mode 100644 src/platforms/msteams/interactions.js create mode 100644 src/platforms/msteams/workspace-access.js create mode 100644 src/platforms/voice.js create mode 100644 test/platform-approvals.test.js create mode 100644 test/platform-attachments.test.js create mode 100644 test/platform-teams-files.test.js create mode 100644 test/platform-voice.test.js create mode 100644 test/teams-controls.test.js create mode 100644 test/teams-file-consent.test.js create mode 100644 test/teams-native-cards.test.js diff --git a/FEATURES.md b/FEATURES.md index 1d307fd..badd23a 100644 --- a/FEATURES.md +++ b/FEATURES.md @@ -339,6 +339,34 @@ A categorized catalog of what's shipped. Cross-linked to `TEST-PLAN.md` checks. updates at most every 30 seconds with elapsed time, last activity, and running subagents; pending progress edits finish before the final answer replaces the placeholder. Interactive permission escalation is not enabled by these text controls. +- Native Adaptive Cards provide Approve/Deny/Request changes actions and an inline session engine/model/effort + form. `/settings` opens that private session form; `/secrets` opens the same form with a link + to the existing authenticated administration website. This does not introduce a secret input + card or expose secret values in Teams. Signed Bot Framework invoke envelopes establish the + actor; opaque expiring card state retains the source conversation and session. An optional + changes comment refuses the current action even if Approve was clicked. Task-module dialogs + and broadcast mentions remain unimplemented. Current approval/channel access is rechecked + when deciding, including for the original requester using a private fallback link. +- `/files [folder]` opens a private, paginated workspace browser. Download, upload and eligible + text-edit links use the existing signed browser grants, filesystem confinement and edit policy, + with current Teams membership and gateway authorization checked again at use. A failed private + delivery never posts the file list or bearer links to a group. `/sendfile ` + offers a native file-consent card in the requester's personal chat: nonempty files up to 10 MB, + ten-minute consent expiry, a bounded pending snapshot pool, and explicit Accept/Decline. + Revocation, expiry, actor mismatch or an uncertain prior upload cannot become another upload. +- Optional group/channel file reading (`teamsFilesEnabled`, default false) uses at most 32 + explicitly allowed drive IDs (`teamsFileDriveIds`). A canonical SharePoint path is resolved + inside an allowed drive and its returned identity is checked before downloading. Selected-site + application read grants must be configured externally; the gateway grants nothing automatically + and does not use the broad-permission Graph `/shares` route. Sharing shortlinks are unsupported. + Metadata and byte streams are bounded, redirects are refused, and Graph tokens never accompany + the file-host request. Native preauthenticated personal uploads remain available independently. +- Downloadable audio is transcribed locally with the shared Whisper setting and cancellation; + the portable path never calls Slack transcript APIs. Raw audio paths are withheld from the + engine, ordinary files remain available, failed audio alone produces an explanation without an + engine turn, and typed text can continue with a visible missing-transcript note. Progress starts + before download/transcription. Every attachment intake has a unique storage directory so + simultaneous same-name files or later edits cannot overwrite bytes another turn is reading. - The branch's Slack parity inventory and remaining surface-specific work live in `docs/TEAMS-PARITY.md`. These additions have mocked regression coverage; actual Microsoft delivery, tenant consent, federated chats, and Claude/Codex live acceptance remain unexecuted. diff --git a/TEST-PLAN.md b/TEST-PLAN.md index 037298f..ebb2c2d 100644 --- a/TEST-PLAN.md +++ b/TEST-PLAN.md @@ -1,7 +1,83 @@ # ChannelGate — Test Plan +Latest `teams-ms` verification (2026-09-09): 2,448 passed, 10 skipped under +`npm run test:coverage`; coverage floors, static checks and security coverage passed. +The dependency audit passed the high-severity threshold with one existing moderate +transitive Hono advisory group reported; no dependencies changed in this feature. +Live Microsoft/Claude/Codex gates remain unexecuted. + ## Teams events and portable controls — teams-ms branch only +### Native controls, scoped files and voice acceptance + +Automated fixtures to include in the final candidate rerun (mocked Microsoft transport; therefore +engine-independent): + +```sh +node --test test/teams-native-cards.test.js test/teams-controls.test.js test/teams-file-consent.test.js test/platform-teams-files.test.js test/platform-voice.test.js test/platform-attachments.test.js test/file-download.test.js test/file-upload.test.js test/file-editor.test.js test/whisper-transcribe.test.js test/slack-voice-prompts.test.js +``` + +Native controls use signed RSA/JWKS activity fixtures, source group `teams:19:group@thread.v2`, +private conversation `a:personal`/`a:private`, actor `29:user`, and a scratch `report.txt` containing +`snapshot`. Require private delivery, actor/conversation/expiry rejection, current membership and +approval checks, compatible runtime values, safe Execute/Submit behavior, scope validation and +single-outcome replay. File consent must upload the original snapshot only on valid acceptance, +reject empty/oversized/outside-root files, expire at ten minutes, cap pending snapshots, and never +retry an uncertain upload. Graph file fixtures use drive `b!fixture_drive`, canonical host +`fixture.sharepoint.com`, and a synthetic bearer; require lazy authorization-before-fetch, +allowlisted drive identity, no `/shares` request, no bearer on the byte request, no redirects, +and bounded streams. Voice fixtures inject transcripts/failures, plus a cancellable local Node +child; require no raw audio engine attachments, no engine on audio-only failure and preserved +text/file fallback. Simultaneous flat messages named `audio.wav` plus a revision must retain +three different storage paths and each original byte sequence. + +- [ ] UNEXECUTED live native-card gate, separately with Claude and Codex pinned: install the + reviewed branch manifest in an owned personal chat, channel and external-member group. It must + declare `supportsFiles: true` on the bot for file consent. Card/form use requires no additional + Graph RSC permission. Request `/settings` by quoting an existing root and mentioning the bot; + choose a compatible engine/model/effort and verify that root's next run uses the choice. Reject + another actor's submission, an expired state, a revoked member and a prohibited non-admin + runtime change. Trigger a harmless approval fixture; Approve must allow once, Deny must not, + Request changes must refuse and preserve the comment. A typed changes comment with Approve + must also refuse. Repeat Execute and a client using Submit fallback. Replaying a decision + must not repeat its effect. Revoke the original requester's current approval/channel access + before deciding by native card or private fallback link: neither path may retain authority + merely because they created the request. Record actor, anonymized root, scope and result. +- [ ] UNEXECUTED live settings/secrets gate: `/secrets` and the card's settings link must open the + existing authenticated admin website; verify login and existing reveal/re-auth policy still + apply. No secret value or new secret-entry form may appear in the Teams chat/card. A failed + private delivery must explain failure without publishing private control links to the group. +- [ ] UNEXECUTED live workspace gate, both engines: create owned `fixtures/report.txt` containing + `ORIGINAL` and `fixtures/note.txt` containing `EDIT_ME`. Invoke `/files fixtures` from the group; + its private browser must remain scoped to that group's workspace. Download report and verify + bytes; upload `new.txt`, then edit note where current write policy permits. A read-only user + cannot upload/edit. Revoke gateway approval or Teams membership before opening a link and + require denial; an escaped path/symlink cannot disclose another workspace. Confirm conflicting + text edits preserve the existing stale-write safeguard. +- [ ] UNEXECUTED live native-send gate, both engines: `/sendfile fixtures/report.txt` from the + group must deliver file consent only in the requester's personal chat. Accept must upload the + prepared bytes and return a native file attachment. Decline, a different actor, revoked access, + expiry after ten minutes and duplicate invocation must not upload. Try a zero-byte and an + over-10-MB file; require refusal and browser-download guidance. Simulate interrupted upload; + the uncertain outcome must not retry automatically. No group/channel direct native upload is + claimed by this personal-consent flow. +- [ ] UNEXECUTED live scoped-read gate, both engines: configure `teamsFilesEnabled: true` with + one owned drive in `teamsFileDriveIds` and externally grant selected-site application read + access. Send a canonical SharePoint file reference inside that drive, then an unlisted drive + and a sharing shortlink. Only the allowed canonical file may download. Native personal uploads + must continue with the opt-in off. Record tenant consent and exact sanitized resource paths; + metadata access alone is not evidence that content was delivered. Test revoked consent and + blocked redirect behavior. No broad sharing-link permission may be silently added to pass. +- [ ] UNEXECUTED live voice/collision gate, both engines: attach a short owned WAV saying + `Reply VOICE_NATIVE_OK`. Verify a preparing/transcribing status and the correct text response. + Disable Whisper and repeat audio-only: clear explanation, zero engine runs. Add typed + `Reply TEXT_FALLBACK_OK` with unavailable audio: text must run and the failure remain visible. + Stop a long local transcription and verify child exit and no later engine start. Send two + simultaneous group messages each attaching `audio.wav` with distinct spoken markers, then + edit/retrigger one; require independent stored bytes, transcripts and group session roots. + Preserve ordinary attached files. There is no Slack transcript fallback on Teams. + + Verification on 2026-09-09: full coverage suite passed (2,404 passed, 10 skipped); static checks, secret scan, security coverage and production dependency audit passed. The skipped/live cases below remain unverified; this branch is not a release candidate. diff --git a/docs/PLATFORMS.md b/docs/PLATFORMS.md index 40427b1..73a7229 100644 --- a/docs/PLATFORMS.md +++ b/docs/PLATFORMS.md @@ -206,13 +206,61 @@ acceptance has not been performed as part of this branch's local implementation. See [the full Slack-to-Teams parity audit](TEAMS-PARITY.md) for remaining UI and integration gaps. +### Native cards, workspace files and voice (teams-ms branch) + +These additions are branch-only; they do not imply a deployment or completed live acceptance. +Update the installed app's reviewed manifest so the bot entry has `supportsFiles: true` for native +personal-chat file consent, then upload/install that app revision with the Teams CLI as described +above. Adaptive Cards and their inline forms do not require additional Graph RSC permissions. +Task-module dialogs and broadcast mentions remain unavailable. + +- `/settings` opens a private session engine/model/effort form. `/secrets` opens the same card with + a link to the existing authenticated admin website. Enter secrets there, never in Teams cards. + Native approvals provide Approve/Deny/Request changes and supported scope choices. An optional + changes comment refuses the current action, including when Approve was clicked. Card submissions + take identity from the verified Microsoft envelope. +- `/files [folder]` privately browses the current conversation workspace. Open a file to download + it or edit eligible text; users with file-write access can open the uploader. Browser links are + short-lived grants and recheck current Teams membership and gateway policy. A group request + keeps the original group workspace even though its controls arrive in a personal chat. +- `/sendfile ` sends a personal-chat file-consent card. Accept uploads + the prepared snapshot; Decline does not upload. The native limit is a nonempty file of at most + 10 MB, consent expires after ten minutes, and uncertain upload outcomes are not replayed. + Larger files use the private browser download path. Install/open a personal chat first if + Microsoft cannot deliver private controls or file-consent cards. +- Downloadable audio uses local Whisper only, controlled by the existing Whisper setting. No + Slack-generated transcript is requested. A failed audio-only request explains the missing + transcript without invoking an engine; accompanying typed text can continue. Stop cancels + local transcription as well as engine work. Separate intake directories keep simultaneous + uploads and edits from overwriting another request's audio or files. + +For optional **group/channel file reading**, enable **Read group and channel files from allowed +drives** under Microsoft Teams settings and enter up to 32 exact Microsoft drive IDs, one per +line (`teamsFilesEnabled`, `teamsFileDriveIds`). Configure application read access to those sites +externally using [Microsoft's selected permissions](https://learn.microsoft.com/en-us/graph/permissions-selected-overview), +then save and reconnect Teams. The deployment's configured Graph identity must have access to +those drives; neither the checkbox nor the allowlist grants Microsoft permissions. This is a +gateway-wide allowlist for authorized conversations, not a per-user Microsoft file entitlement. + +Use canonical SharePoint file URLs whose paths lie inside an allowed drive root. The resolver +reads only configured drive roots, addresses the matching item within that drive, verifies its +returned identity, and downloads with no Graph bearer on the file-host request. Redirects and +oversized responses are refused. Sharing shortlinks are unsupported: use a canonical file link +or upload directly in a personal chat. The implementation deliberately does not use Graph's +[sharing-link endpoint](https://learn.microsoft.com/en-us/graph/api/shares-get?view=graph-rest-1.0), +whose documented application permissions include broad write access. Selected-site consent and +real SharePoint download compatibility remain live acceptance gates. + +See `TEST-PLAN.md` for exact native-card, file, voice and both-engine fixtures. Native file flow +reference: [Microsoft bot file consent](https://learn.microsoft.com/en-us/microsoftteams/platform/bots/how-to/bots-filesv4). + ### Notes and limits - **No public URL ⇒ no inbound.** The bot will connect and can send, but Azure has nowhere to deliver to. ChannelGate says so at boot and on the health check rather than looking merely quiet. - **Attachments in:** 1:1 uploads arrive with a pre-authenticated download URL and are fetched (only - from Microsoft-owned hosts). Channel files live in SharePoint and need Graph application - permissions with tenant admin consent — not requested, so those are reported as skipped. + from Microsoft-owned hosts). Group/channel references require the optional scoped drive + configuration above; unsupported or unconsented files are explicitly reported as skipped. - **Threads and sessions:** channel replies thread under the user's message. Personal chats keep one continuous session. Group chats remain visually flat, but every new message to the bot starts a separate session. Quote an earlier user message or bot reply to continue that session; quote @@ -237,10 +285,10 @@ See [the full Slack-to-Teams parity audit](TEAMS-PARITY.md) for remaining UI and | Channel folder confinement, authorization, MCP allowlist | ✅ | ✅ | ✅ | | Attachments in | ✅ | partial | partial | | Threads | ✅ | spaces only | channels only | -| Live progress rendering | ✅ | placeholder → answer | placeholder → answer | -| Interactive approval buttons | ✅ | ❌ (actions named in text) | ❌ (actions named in text) | +| Live progress rendering | ✅ | bounded progress → answer | bounded progress → answer (branch) | +| Interactive approval buttons | ✅ | ❌ (actions named in text) | native cards (branch) | | Approvals by signed link | ✅ (in addition to the buttons) | ✅ (the mechanism) | ✅ (the mechanism) | -| In-thread commands (`/model`, `/clear`, stop, steer) | ✅ | ❌ | ❌ | +| Session commands | full Slack controls | portable text subset (branch) | text subset + native settings/files (branch) | | Native tables / charts / Lists / canvases | ✅ | ❌ | ❌ | | Escalation to full-access in an admin-mode channel | ✅ | ❌ | ❌ | @@ -258,11 +306,11 @@ a button click runs. The gateway needs a **Public URL** for a link to be reachab host, and the behaviour is Settings → Connection → **Approval links** (`auto` / `always` / `off`). See `FEATURES.md` → Modes & approvals for the security properties. -This is what makes approvals *possible* on Teams and Google Chat rather than *already wired* there: -the link mechanism, its private delivery and its confirmation page are platform-neutral and honour -each adapter's declared `ephemeral` capability, but a turn on those surfaces still does not RAISE a -permission card (see the header of `src/platforms/ingest.js` — interactive approvals are a slice of -their own). When it does, the answer arrives by link with no further work. +On `teams-ms`, native Teams approval cards call the shared actor-checked decision path. Inline +cards do not automatically widen the engine permission policy: escalation remains separately +gated, and its live acceptance must pass before release. Google Chat retains its existing +surface limitations. Task-module dialogs and Slack's full busy-thread interaction flow are not +supplied by the Teams card implementation. --- diff --git a/docs/TEAMS-PARITY.md b/docs/TEAMS-PARITY.md index 2bcf608..21e261e 100644 --- a/docs/TEAMS-PARITY.md +++ b/docs/TEAMS-PARITY.md @@ -45,19 +45,29 @@ A Teams event subscription can make an event observable; it does not authorize a The requester remains the person editing or reacting, not the target message's original author. The current channel policy, bot installation scope and selected-account permissions still apply. +## Native additions implemented on this branch + +These paths have automated fixture coverage; Microsoft tenant/client and both-engine live +acceptance remain unexecuted. They are not deployed by keeping work on `teams-ms`. + +| Feature | Implemented behavior | Boundary | +| --- | --- | --- | +| Approval cards | Native Approve/Deny/Request changes with optional comment, supported scope choices, Execute and Submit fallback, verified actor identity | A changes comment refuses the current action even with Approve; escalation policy is separate | +| Session form | `/settings` opens private engine/model/effort choices through existing controls | Runtime-change authorization and active-session safeguards still apply | +| Settings and secrets | `/secrets` and the session card link to the existing authenticated admin website | No secret values or new secret-entry form in Teams | +| Workspace browser | `/files [folder]` provides private pagination and browser download/upload/text-edit links | Source workspace and current Teams membership are rechecked; no public fallback | +| Native file sending | `/sendfile ` asks for personal-chat Accept/Decline and sends the approved file snapshot | Nonempty files up to 10 MB; ten-minute consent; bounded pending pool; manifest `supportsFiles: true` | +| Group/channel file reading | Optional Graph resolution of canonical SharePoint paths inside explicitly allowed drives | External selected-site read grants; no `/shares` route or shortlinks; redirects blocked and token isolated | +| Voice | Local Whisper transcription, cancellable work, text fallback with explicit failure notes | No Slack transcript service; unavailable downloads/Whisper do not become raw-audio engine requests | +| Concurrent attachments | A unique intake directory preserves each message/revision's bytes | Storage IDs do not change reply/session identity | + ## Remaining feasible adaptations | Slack feature | Teams gap | Next implementation slice | | --- | --- | --- | -| Explicit tool/action approvals | Shared signed-link and durable-approval primitives exist, but `slack/approvals.js` still posts a Slack card first and validates a Slack timestamp; Teams delivery is not wired merely because the helper mentions Teams | Extract a connector-backed approval prompt, private single-use decision links, decision replay protection and private-delivery failure reporting; leave escalation disabled until acceptance proves it | -| File explorer and file links in reply footer | Native Slack modal and requester-bound footer actions do not have a Teams entry point | Provide authorized browser entry links using existing file access checks; never post bearer file/editor links publicly | -| Channel settings and secret explorer | Browser admin settings work; Slack's per-message settings buttons, secret forms and modals are not a Teams UI | Authenticated settings deep links first; later Adaptive Card/dialog entry points with actor-bound state and unchanged secret policy | -| Interactive model picker | Slack wizard uses Block Kit actions | Text commands using the shared model catalog first; optional card picker later | | Busy-thread steer/queue choice | Slack posts authenticated decision controls and handles active-run steering | Port explicit choices to text/private links or cards; preserve author checks, session identity and queue ordering | | Background status button | Job execution/delivery is shared; Slack button callback is not | Add text status access or safe authenticated browser status links | | Restart recovery presentation | Durable run state exists; some recovery orchestration and notification hooks remain Slack-owned | Audit and route recovery through the connector while preserving queue reservations and uncertain-outcome rules | -| Instruction approval comments | Durable approval decisions exist, but freeform Comment is a Slack interaction | Add an actor-bound text/dialog comment path after approval transport | -| Local voice transcription | Local transcription backend is reusable; Slack pipeline currently performs audio classification, transcription and Slack VTT fallback | Use common downloaded-file transcription; explain unavailable transcript without claiming Slack's VTT feature exists on Teams | | On-demand history/thread reads | Built-in MCP history tools are Slack-specific | Add conversation-scoped Teams reads through a separately authorized Graph route; expose safe metadata and respect explicit message scope | | Re-download a historical file | `slack_download_file` requires Slack file descriptors and channel membership proof | Teams-specific descriptor lookup and scoped Graph/SharePoint retrieval, using the existing confined streaming sink | | Follow-up digests, done/reopen reactions and nudges | Delivery plumbing is shared but Slack reaction and history ingestion drive parts of tracking | Audit the tracking inputs and map explicit Teams reactions; avoid treating robot activation as digest acknowledgement | @@ -73,7 +83,7 @@ The current channel policy, bot installation scope and selected-account permissi | --- | --- | | All-message observation | Default bot delivery is not equivalent to Slack history/reaction coverage. Declare exact installation/RSC/Graph permission prerequisites and subscription scope. No tenant-wide feed by default | | Group/channel files | Files may live in SharePoint/OneDrive and require a distinct Graph permission path; a bot message credential is not proof of file access | -| Native file sending | Teams platform file possibilities do not mean this connector implements outgoing upload or file-consent flow | +| Native file sending | Implemented personal-chat consent path requires manifest `supportsFiles: true`; this does not provide direct arbitrary group/channel uploads | | Proactive DMs | Conversation creation can fail when installation, identity or tenant policy prevents it. Private approval links must never fall back into the group | | Reaction shape/coverage | Validate the robot reaction's real payload and availability on desktop/mobile and user/bot messages. Unknown reactions are ignored; reaction removal is not an implicit new request | | External/federated chats | Acceptance must include a group containing external members; successful same-tenant tests alone do not prove this works | @@ -98,14 +108,12 @@ independent API compatibility certification. ## Capability descriptor accuracy checks -In the baseline `src/platforms/msteams.js`, `buttons`, `modals`, `fileUpload`, `broadcast` and -`richCards: "adaptive-cards"` advertise platform possibilities more broadly than this gateway's -implemented surface. The connector turns button labels into text; it does not dispatch a card. -No task-module invocation route or native outgoing file uploader is established by those flags. -`reactions: true` similarly did not establish inbound trigger handling before this branch. -Consumers and operating guides must not confuse those declarations with shipped functionality. -Prefer separate explicit effective capability facts or conservative flags until the corresponding -end-to-end path exists. Test any flag change against guide materialization and UI behavior. +The branch declares native buttons/cards and file sending only alongside their implemented +handlers. `modals: false` is intentional: forms render inline in Adaptive Cards, not task-module +dialogs. `broadcast: false` remains intentional: no broad mention entity builder exists. +Reaction trigger support is separate from general reaction capability, and observing an event +never substitutes for gateway authorization. Validate capability changes against guide +materialization and the actual connector, not Microsoft platform possibilities alone. This branch also corrects `supportsThreads()` to recognize channel ID suffixes without treating flat `@thread.v2` group chats as native threads. Inbound normalization still uses conversation kind. diff --git a/public/app.js b/public/app.js index dd7552d..2ddd762 100644 --- a/public/app.js +++ b/public/app.js @@ -3013,6 +3013,8 @@ function readSettingsForm() { ...(tokenValue(document.getElementById("set-teams-secret")) ? { teamsAppPassword: tokenValue(document.getElementById("set-teams-secret")) } : {}), ...(document.getElementById("clear-teams-secret").classList.contains("armed") ? { clearTeamsAppPassword: true } : {}), teamsTenantId: document.getElementById("set-teams-tenant").value, + teamsFilesEnabled: document.getElementById("set-teams-files-enabled").checked, + teamsFileDriveIds: document.getElementById("set-teams-file-drives").value.split(/[\s,]+/).filter(Boolean), teamsAllMessageEvents: document.getElementById("set-teams-all-message-events").checked, ...(tokenValue(document.getElementById("set-license-key")) ? { licenseKey: tokenValue(document.getElementById("set-license-key")) } : {}), ...(document.getElementById("clear-license-key").classList.contains("armed") ? { clearLicenseKey: true } : {}), @@ -3122,6 +3124,8 @@ function paintSettings(s) { // value is still fetchable through /api/secrets/reveal for an admin who needs it back. document.getElementById("set-teams-app").value = s.teams?.appId || ""; document.getElementById("set-teams-tenant").value = s.teams?.tenantId || ""; + document.getElementById("set-teams-files-enabled").checked = Boolean(s.teams?.filesEnabled); + document.getElementById("set-teams-file-drives").value = (s.teams?.fileDriveIds || []).join("\n"); document.getElementById("set-teams-all-message-events").checked = s.teams?.allMessageEvents === true; document.getElementById("state-teams-secret").textContent = tokenState(s.teams?.hasAppPassword, s.teams?.appPasswordLast4); attachReveal(document.getElementById("set-teams-secret"), { has: Boolean(s.teams?.hasAppPassword), last4: s.teams?.appPasswordLast4 || "", fetch: revealSecret("settings", "teamsAppPassword") }); diff --git a/public/index.html b/public/index.html index d9b9065..e7904a7 100644 --- a/public/index.html +++ b/public/index.html @@ -486,6 +486,9 @@

Microsoft Teams (Beta)

Off by default. Requires application RSC consent for ChatMessage.Read.Chat and ChannelMessage.Read.Group in the Teams app manifest, then app update/reinstallation in each intended conversation. Graph subscriptions start after an installed conversation sends an activity; this is not a tenant-wide message feed. Chat subscriptions use Microsoft Graph beta. Keep both /api/teams/messages and /api/teams/notifications reachable at the Public URL. Save, then reconnect Teams to apply. Normal messages still require a mention outside personal chats; only the robot reaction activates a message without one.

+ + +

Off by default. Requires separately configured selected-site application read access. Only files within these drives can download; no permissions are granted automatically. Use canonical SharePoint file links; sharing shortlinks are not supported. Personal-chat file uploads keep their existing download path. Save, then reconnect Teams to apply.

Microsoft event endpoint: set a Public URL first

@@ -564,7 +567,7 @@

Engine & runtime

"Ask" posts a message with buttons in the Slack thread and runs nothing until someone clicks; the click re-runs the same message on the chosen harness. Scheduled runs, background agents and API runs cannot be asked and always switch automatically. - +
${state.newSkill ? renderNewSkillForm() : ""} - + ${rows || ``}
SkillDescriptionCategoryOwnerVersionEnabledDiscoverableMandatoryUsage 30d
Skill or pluginDescriptionCategoryOwnerVersionEnabledDiscoverableMandatoryUsage 30d
No skills yet. Add a GitHub source under Sources, re-import the host folders, or create one here.
${state.detail && state.detail.skill.slug === state.selected ? renderDetail() : ""}`; @@ -250,8 +251,9 @@ function renderDetail() { const usage = d.usage ? `${d.usage.total} use(s) in 90 days, last ${fmtWhen(d.usage.lastTs)}` : "no use recorded in 90 days"; return `
-

${esc(s.slug)} ${esc(s.name !== s.slug ? s.name : "")}

${esc(s.owner)}${s.excluded ? 'excluded' : s.deleted ? 'removed' : ""}
+

${esc(s.slug)} ${esc(s.name !== s.slug ? s.name : "")}${pluginBadge(s)}

${esc(s.owner)}${s.excluded ? 'excluded' : s.deleted ? 'removed' : ""}

${esc(s.description)}

+ ${pluginSummary(s)}

Category: ${esc(s.category || "—")} · Version: ${esc(s.version || "—")} · Tags: ${esc((s.tags || []).join(", ") || "—")} · Requires: ${esc((s.requires || []).join(", ") || "—")}${s.createdBy ? ` · Author: ${esc(s.createdBy)}` : ""}

${esc(usage)}

Files (effective revision)
@@ -282,7 +284,7 @@ function renderReview() { const proposals = state.overview?.proposals || []; const stagedRows = staged.map((r) => ` - ${esc(r.slug)} ${esc(r.skillName || "")} + ${esc(r.slug)}${pluginBadge(r)} ${esc(r.skillName || "")}${pluginSummary(r)} #${r.revisionNo}${r.version ? ` v${esc(r.version)}` : ""} · ${r.fileCount} file(s) ${esc(r.ownerKind === "git" ? `git source #${r.sourceId}` : r.ownerKind)} · ${esc(r.sourceRef.slice(0, 12))} ${fmtWhen(r.createdAt)} @@ -297,8 +299,8 @@ function renderReview() { `).join(""); return `

Staged source revisions

-

Skills a review-mode source delivered. Approving makes the revision active for every conversation that grants the skill; rejecting keeps the current one.

- ${stagedRows || ''}
SkillRevisionFromReceived
Nothing staged.
+

Skills and plugins delivered by a source. Approving a plugin approves its whole package, including executable components. Review the files and engine requirements before approval. The revision becomes active for every conversation that grants it; rejecting keeps the current one.

+ ${stagedRows || ''}
Skill or pluginRevisionFromReceived
Nothing staged.
${state.fileView?.kind === "revision" ? `

Revision #${state.fileView.revisionNo} files

${state.fileView.files.map((f) => `

${esc(f.path)} ${f.size} B

${f.content != null ? `
${esc(f.content)}
` : ""}`).join("")}
` : ""}

Proposals

Changes members proposed from chat. Approving a change publishes a revision (pinned as a local override when the skill comes from a source); approving a promotion grants the skill organization-wide.

@@ -336,8 +338,8 @@ function renderSourceSkills(source) { ${renderSourceSettings(source)}
${skills.length} of ${all.length} skills

Discoverable applies across the organization. Mandatory loads a skill in every conversation and also enables discovery. Disabled skills stay listed here so you can enable them again.

-
- ${skills.map((skill) => ``).join("") || ``} +
SkillEnabledDiscoverable · org-wideMandatory
${esc(skill.slug)}

${esc(skill.description)}

${skill.deleted ? 'disabled' : ""}${skill.currentRevisionId == null && !skill.deleted ? 'awaiting approval' : ""}
${toggle(skill, "enabled", "Enabled")}${toggle(skill, "discoverable", "Discoverable organization-wide")}${toggle(skill, "mandatory", "Mandatory")}
${all.length ? "No skills match your search." : "No catalog skills from this source yet. Sync the source to import them."}
+ ${skills.map((skill) => ``).join("") || ``}
Skill or pluginEnabledDiscoverable · org-wideMandatory
${pluginBadge(skill)}${esc(skill.slug)}

${esc(skill.description)}

${skill.deleted ? 'disabled' : ""}${skill.currentRevisionId == null && !skill.deleted ? 'awaiting approval' : ""}
${toggle(skill, "enabled", "Enabled")}${toggle(skill, "discoverable", "Discoverable organization-wide")}${toggle(skill, "mandatory", "Mandatory")}
${all.length ? "No skills match your search." : "No catalog skills from this source yet. Sync the source to import them."}
${state.detail && all.some((skill) => skill.slug === state.selected) ? renderDetail() : ""}`; } @@ -356,9 +358,10 @@ function renderSources() { `; }).join(""); return ` - ${selected ? renderSourceSkills(selected) : `

Sources

Choose a source to browse its skills and manage how they are used.

${cards || '

No sources yet. Add a repository or another ChannelGate.

'}
`} + ${selected ? renderSourceSkills(selected) : `

Sources

Choose a source to browse its skills and plugins and manage how they are used.

${cards || '

No sources yet. Add a repository or another ChannelGate.

'}
`} ${state.sourceModal ? `

Active skills and plugins

+

Add skills and plugins

Search the catalog and add skills or whole plugins to your selection.

`; const query = root.querySelector("[data-picker-query]"); const source = root.querySelector("[data-picker-source]"); const sourceLabel = (skill) => config.sources.find((s) => String(s.id) === String(skill.sourceId))?.label @@ -55,8 +56,8 @@ export function mountSkillAssignmentPicker(root, options) { : s.currentRevisionId == null && s.pinnedRevisionId == null ? "Awaiting approval" : sourceLabel(s); return `
- ${esc(s.name || s.slug)}${s.name && s.name !== s.slug ? `${esc(s.slug)}` : ""}${esc(status)} -

${esc(s.description || "No description available.")}

Name
${esc(s.name || s.slug)}
Slug
${esc(s.slug)}
Source
${esc(status)}
${s.version ? `
Version
${esc(s.version)}
` : ""}
+ ${esc(s.name || s.slug)}${pluginBadge(s)}${s.name && s.name !== s.slug ? `${esc(s.slug)}` : ""}${esc(status)} +

${esc(s.description || "No description available.")}

${pluginSummary(s)}
Name
${esc(s.name || s.slug)}
Slug
${esc(s.slug)}
Source
${esc(status)}
${s.version ? `
Version
${esc(s.version)}
` : ""}
${action ? `` : 'Included'}
`; diff --git a/scripts/security-coverage.mjs b/scripts/security-coverage.mjs index c0162a8..8750f38 100644 --- a/scripts/security-coverage.mjs +++ b/scripts/security-coverage.mjs @@ -10,6 +10,11 @@ const root = path.dirname(path.dirname(fileURLToPath(import.meta.url))); const help = spawnSync(process.execPath, ["--help"], { encoding: "utf8" }).stdout || ""; const branchFlag = help.includes("--test-coverage-branches") ? "--test-coverage-branches" : "--test-coverage-branch"; const areas = [ + { + name: "plugin-packages", floor: [95, 85, 90], + include: ["src/gateway/plugin-runtime.js", "src/gateway/skills/plugin-package.js"], + tests: ["test/plugin-runtime.test.js", "test/plugin-grant-integration.test.js", "test/skills-plugin-import.test.js"], + }, { name: "authorization", floor: [95, 95, 95], include: ["src/gateway/modes.js"], tests: ["test/authorization.test.js", "test/modes.test.js"] }, { name: "access-grants", @@ -25,6 +30,8 @@ const areas = [ "test/mcp-capability.test.js", "test/run-engine-mcp.test.js", "test/run-grant-isolation.test.js", + "test/plugin-grant-integration.test.js", + "test/plugin-runtime.test.js", ], }, { name: "modes", floor: [90, 90, 80], include: ["src/gateway/modes.js", "src/engines/child-env.js"], tests: ["test/modes.test.js", "test/folders-settings.test.js", "test/child-env.test.js"] }, diff --git a/src/engines/adapters.js b/src/engines/adapters.js index f4922d0..fbdfe6e 100644 --- a/src/engines/adapters.js +++ b/src/engines/adapters.js @@ -11,6 +11,7 @@ import { runtimeTargetOr } from "./runtime-target.js"; import { readCodexAuthState } from "./codex-auth.js"; import { claudeEngineHome, codexEngineHome } from "../config/paths.js"; import { discoverCodexModels } from "./model-discovery.js"; +import { requirePluginRuntime } from "../gateway/plugin-runtime.js"; // Which Claude login the gateway is using (src/gateway/claude-login.js). Imported LAZILY: that // module reads a setting, src/config/settings.js imports the engine registry, and the registry @@ -51,6 +52,7 @@ const baseCompile = (engine, request = {}, supportedModes = ["off"]) => { }; const claude = validateEngineAdapter({ + pluginCapabilities: { manifest: "claude", components: ["skills", "commands", "agents", "hooks", "mcpServers"] }, resolveOptionalMcpConfig: resolveClaudeMcpConfig, id: "claude", label: "Claude", cli: "claude", defaultModelKey: "defaultClaudeModel", mcpMetaKey: "allowedMcps", instructionFile: "CLAUDE.md", skillsDir: ".claude/skills", mcpTransport: "file", contextWindow: 200_000, @@ -91,6 +93,7 @@ const claude = validateEngineAdapter({ compileConfinement: (request) => baseCompile("claude", request, FULL_NETWORK_MODES), async run(ctx) { const r = ctx.runtime; + requirePluginRuntime(r.pluginRuntime, this.id); // WHERE this turn runs (src/runtimes/): the channel's container, resolved once per turn by // run.js and put on the context. const target = runtimeTargetOr(ctx.target, ctx.cwd); @@ -183,6 +186,16 @@ const claude = validateEngineAdapter({ }); const codex = validateEngineAdapter({ + pluginCapabilities: { manifest: "", components: ["skills", "mcpServers"] }, + async resolveOptionalMcpConfig(allowed) { + if (!Array.isArray(allowed) || !allowed.length) return {}; + const policy = codexMcpPolicyFor(await listEngineMcps("codex"), allowed); + return Object.fromEntries(policy.servers.filter((server) => server.enabled).map((server) => { + const definition = server.definition; + if (!definition) throw new Error(`Optional MCP ${server.name} has no complete credential-safe definition; refusing Codex run`); + return [server.name, definition.transport === "http" ? { type: "http", url: definition.url } : { command: definition.command, args: definition.args || [] }]; + })); + }, id: "codex", label: "Codex", cli: "codex", defaultModelKey: "defaultCodexModel", mcpMetaKey: "allowedCodexMcps", instructionFile: "AGENTS.md", skillsDir: ".agents/skills", mcpTransport: "argv", contextWindow: 272_000, efforts: ["none", "low", "medium", "high", "xhigh", "max", "ultra"], models: [ @@ -205,12 +218,14 @@ const codex = validateEngineAdapter({ compileConfinement: (request) => baseCompile("codex", request, FULL_NETWORK_MODES), async run(ctx) { const r = ctx.runtime; + requirePluginRuntime(r.pluginRuntime, this.id); const target = runtimeTargetOr(ctx.target, ctx.cwd); const catalog = await listEngineMcps("codex").catch(() => []); const codexMcpPolicy = codexMcpPolicyFor(catalog, r.allowedMcps || []); + codexMcpPolicy.servers.push(...(r.pluginMcpServers || [])); const unsafe = codexMcpPolicy.servers.find((server) => server.enabled && !server.definition); if (unsafe) throw new Error(`Optional MCP ${unsafe.name} has no complete credential-safe definition; refusing Codex run`); - return runCodex({ cwd: ctx.cwd, prompt: ctx.prompt, extraEnv: r.channelEnv, browserNamespace: r.browserNamespace, sessionId: ctx.session.id, isNewSession: ctx.session.fresh, dangerouslySkip: r.dangerouslySkip, writable: r.writable, networkMode: ctx.policy.network.mode, clean: r.clean, autoApprove: r.autoApprove, composioUserEndpoint: r.composioUserEndpoint, composioEndpoint: r.composioEndpoint, composioUserToken: r.composioUserToken, composioToken: r.composioToken, toolboxToken: r.toolboxToken, makeToolboxUrl: r.makeToolboxUrl, makeToolboxKey: r.makeToolboxKey, codexMcpPolicy, gatewayCapability: r.gatewayCapability, gatewayFsRoot: r.gatewayFsRoot, gatewayWorkspaceRoot: r.gatewayWorkspaceRoot, progressReport: r.progressReport, model: r.model, effort: r.effort, codexStateDir: r.codexStateDir, personalSkills: r.personalSkillCatalog, attachments: r.attachments, target, artifactDir: ctx.artifactDir ?? target.artifactDir ?? null, signal: r.signal, timeoutMs: r.timeoutMs, maxSilenceMs: r.maxSilenceMs, onDelta: r.onDelta, onEvent: r.onEvent, onSessionResolved: r.onSessionResolved }); + return runCodex({ cwd: ctx.cwd, prompt: ctx.prompt, extraEnv: r.channelEnv, browserNamespace: r.browserNamespace, sessionId: ctx.session.id, isNewSession: ctx.session.fresh, dangerouslySkip: r.dangerouslySkip, writable: r.writable, networkMode: ctx.policy.network.mode, clean: r.clean, autoApprove: r.autoApprove, composioUserEndpoint: r.composioUserEndpoint, composioEndpoint: r.composioEndpoint, composioUserToken: r.composioUserToken, composioToken: r.composioToken, toolboxToken: r.toolboxToken, makeToolboxUrl: r.makeToolboxUrl, makeToolboxKey: r.makeToolboxKey, codexMcpPolicy, gatewayCapability: r.gatewayCapability, gatewayFsRoot: r.gatewayFsRoot, gatewayWorkspaceRoot: r.gatewayWorkspaceRoot, progressReport: r.progressReport, model: r.model, effort: r.effort, codexStateDir: r.codexStateDir, personalSkills: r.personalSkillCatalog, pluginSkills: requirePluginRuntime(r.pluginRuntime, this.id).skills, attachments: r.attachments, target, artifactDir: ctx.artifactDir ?? target.artifactDir ?? null, signal: r.signal, timeoutMs: r.timeoutMs, maxSilenceMs: r.maxSilenceMs, onDelta: r.onDelta, onEvent: r.onEvent, onSessionResolved: r.onSessionResolved }); }, interrupt: () => false, discoverMcps: () => listEngineMcps("codex"), diff --git a/src/engines/codex.js b/src/engines/codex.js index 59cbc53..6ca132d 100644 --- a/src/engines/codex.js +++ b/src/engines/codex.js @@ -602,8 +602,17 @@ export function codexPersonalSkillPrefix(skills) { + JSON.stringify(skills) + "\n[End current personal skill grants]\n\n"; } +// Plugin package skills are scoped independently from personal grants. An empty catalog revokes +// the previous turn's plugin paths, including when a resumed thread enters clean mode. +export function codexPluginSkillPrefix(skills) { + if (!Array.isArray(skills)) return ""; + return "[Current approved plugin skills — this run only]\n" + + "Only the following approved plugin skill catalog applies now; earlier plugin catalogs and paths have expired. An empty catalog means no plugin skills are available this turn. When a listed skill matches the task, read its SKILL.md and resolve references and scripts relative to that file's directory. These are prompt-delivered skills, not native plugin commands. Do not copy them into shared project skill folders.\n" + + JSON.stringify(skills) + "\n[End current approved plugin skills]\n\n"; +} + // Build `codex exec` argv. `outFile` receives the final agent message (authoritative content). -export function buildCodexArgs({ prompt, sessionId, isNewSession, cwd, dangerouslySkip, writable = false, networkMode = "off", clean = false, autoApprove = false, composioUserEndpoint = null, composioEndpoint = null, composioUserToken = "", composioToken = "", toolboxToken = "", makeToolboxUrl = "", makeToolboxKey = "", secretBundlePath = "", codexMcpPolicy = null, gatewayCapability = "", gatewayFsRoot = "", gatewayWorkspaceRoot = "", progressReport = false, model = "", effort = "", personalSkills = null, attachments = [], target = null, outFile, headerHelpers = [] }) { +export function buildCodexArgs({ prompt, sessionId, isNewSession, cwd, dangerouslySkip, writable = false, networkMode = "off", clean = false, autoApprove = false, composioUserEndpoint = null, composioEndpoint = null, composioUserToken = "", composioToken = "", toolboxToken = "", makeToolboxUrl = "", makeToolboxKey = "", secretBundlePath = "", codexMcpPolicy = null, gatewayCapability = "", gatewayFsRoot = "", gatewayWorkspaceRoot = "", progressReport = false, model = "", effort = "", personalSkills = null, pluginSkills = null, attachments = [], target = null, outFile, headerHelpers = [] }) { const runtimeTarget = runtimeTargetOr(target, cwd); // The CONTAINER is the confinement boundary, so Codex's own sandbox is switched off: no // permission profiles, no network_proxy — egress is the container's network mode. @@ -773,7 +782,8 @@ export function buildCodexArgs({ prompt, sessionId, isNewSession, cwd, dangerous // Prompt must come before image flags: Codex's `-i/--image ...` option is variadic, so any // positional after the last `-i` is consumed as another image and the CLI exits with no prompt. - args.push(argvSafePrompt((clean ? "" : codexPersonalSkillPrefix(personalSkills)) + prompt)); + args.push(argvSafePrompt((clean ? "" : codexPersonalSkillPrefix(personalSkills)) + + codexPluginSkillPrefix(clean && Array.isArray(pluginSkills) ? [] : pluginSkills) + prompt)); // Attached images via native -i (Codex's vision path); non-image files are referenced in the // prompt text instead (the gateway already lists their paths there). @@ -846,6 +856,7 @@ export async function runCodex({ effort = "", codexStateDir = "", personalSkills = null, + pluginSkills = null, attachments = [], // Where this turn runs (src/runtimes/): the channel's container. target = null, @@ -919,7 +930,7 @@ export async function runCodex({ // pure argv builder. They carry no credential of their own — each one reads its entry out of the // 0600 bundle above — but they are still per-run files, created and removed with it. const headerHelpers = []; - const args = buildCodexArgs({ prompt, sessionId, isNewSession, cwd, dangerouslySkip, writable, networkMode, clean, autoApprove, composioUserEndpoint, composioEndpoint, composioUserToken, composioToken, toolboxToken, makeToolboxUrl, makeToolboxKey, secretBundlePath, codexMcpPolicy, gatewayCapability, gatewayFsRoot, gatewayWorkspaceRoot, progressReport, model, effort, codexStateDir, personalSkills, attachments, target: runtime, outFile, headerHelpers }); + const args = buildCodexArgs({ prompt, sessionId, isNewSession, cwd, dangerouslySkip, writable, networkMode, clean, autoApprove, composioUserEndpoint, composioEndpoint, composioUserToken, composioToken, toolboxToken, makeToolboxUrl, makeToolboxKey, secretBundlePath, codexMcpPolicy, gatewayCapability, gatewayFsRoot, gatewayWorkspaceRoot, progressReport, model, effort, codexStateDir, personalSkills, pluginSkills, attachments, target: runtime, outFile, headerHelpers }); for (const spec of headerHelpers) { await writeFile(spec.path, headerHelperSource({ ...spec, bundlePath: secretBundlePath }), { mode: 0o700 }); } diff --git a/src/gateway/plugin-runtime.js b/src/gateway/plugin-runtime.js new file mode 100644 index 0000000..2362db8 --- /dev/null +++ b/src/gateway/plugin-runtime.js @@ -0,0 +1,163 @@ +// Approved packages reuse skill revisions and grants. Engine-specific components are compiled +// explicitly; package MCP settings never enter ambient engine discovery or replace identities. +import path from "node:path"; +import { createHash } from "node:crypto"; +import { parsePluginPackage } from "./skills/plugin-package.js"; +import { skillBundle } from "./skills/catalog.js"; +import { resolveSkillProfile } from "./skills/resolve.js"; +import { parseFrontmatter, skillMetadata } from "./skills/frontmatter.js"; + +export function grantedPluginPackages(names, { profileFor = resolveSkillProfile, bundleFor = skillBundle } = {}) { + return profileFor(names).active.flatMap(({ skill }) => { + const bundle = bundleFor(skill); + const descriptor = bundle && parsePluginPackage(bundle.files); + return descriptor ? [{ slug: skill.slug, revision: bundle.revision, descriptor }] : []; + }); +} + +export function pluginServerName(slug, name) { + const digest = createHash("sha256").update(JSON.stringify([slug, name])).digest("hex").slice(0, 12); + return `cg_plugin_${slug.replace(/[^a-z0-9_]/gi, "_").slice(0, 48)}_${digest}`; +} + +function readJson(descriptor, file) { + const entry = descriptor.files.find((f) => f.path === file); + if (!entry) throw new Error(`Plugin ${descriptor.name} is missing ${file}`); + try { return JSON.parse(entry.content.toString("utf8")); } + catch { throw new Error(`Plugin ${descriptor.name} has invalid JSON in ${file}`); } +} + +function componentConfigs(descriptor, key, manifestEngine) { + const out = []; + for (const file of descriptor.components[key] || []) { + const owner = Object.entries(descriptor.manifests).find(([engine]) => file === `.${engine}-plugin/plugin.json`); + if (owner) { + if (owner[0] === manifestEngine) out.push(owner[1][key]); + } else out.push(readJson(descriptor, file)); + } + return out; +} + +// Plugin sources are reviewed code, not credential stores. Reconstruct only explicit transports; +// every unsupported field or authentication dependency requires a separately selected connection. +function publicPluginMcpDefinition(source) { + if (!source || typeof source !== "object" || Array.isArray(source)) return null; + const allowed = new Set(["type", "transport", "url", "command", "args"]); + if (Object.keys(source).some((key) => !allowed.has(key))) return null; + if (source.url != null) { + if (typeof source.url !== "string" || source.command != null || source.args != null + || (source.type != null && !["http", "streamable-http"].includes(source.type)) + || (source.transport != null && !["http", "streamable-http"].includes(source.transport))) return null; + let url; + try { url = new URL(source.url); } catch { return null; } + if (!["http:", "https:"].includes(url.protocol) || url.username || url.password || url.search || url.hash + || /[\s$]/.test(source.url)) return null; + return { transport: "http", url: source.url }; + } + if (typeof source.command !== "string" || !source.command.trim() + || (source.type != null && source.type !== "stdio") + || (source.transport != null && source.transport !== "stdio") + || (source.args != null && (!Array.isArray(source.args) || source.args.some((arg) => typeof arg !== "string")))) return null; + const args = source.args || []; + // Literal command bodies are reviewed source code. Known authentication flags and unresolved + // environment substitutions are instead connection configuration and must not reach argv. + const values = [source.command, ...args]; + if (values.some((value) => /[\0\r\n]/.test(value) + || /(?:^|[\s=])--?(?:[a-z]+-)*(?:token|password|passwd|secret|api-?key|auth|authorization|credential)(?:[s-]|=|$)/i.test(value) + || /\$(?:[A-Za-z_]|\{)/.test(value.replace(/\$\{(?:CLAUDE_PLUGIN_ROOT|CODEX_PLUGIN_ROOT|PLUGIN_ROOT)\}/g, "")))) return null; + return { transport: "stdio", command: source.command, args }; +} + +// `capabilities` is an engine adapter fact, not an engine-name branch in the orchestrator. +export function compilePluginPackage(pkg, { capabilities, allowBypass = false, writable = false } = {}) { + const { descriptor: d, slug } = pkg; + if (["gateway-shared-skills", "gateway-user-grants"].includes(d.name.toLowerCase())) throw new Error(`Plugin ${slug}: the plugin name is reserved by the gateway`); + if (!capabilities) throw new Error(`Plugin ${slug}: this engine does not support plugin packages`); + for (const key of ["agents", "commands", "hooks", "apps", "lspServers"]) { + if (d.components[key]?.length && !capabilities.components.includes(key)) { + throw new Error(`Plugin ${slug}: ${key} are unsupported by this engine; select a compatible engine or remove this plugin grant`); + } + } + if (d.components.hooks.length && !allowBypass) throw new Error(`Plugin ${slug}: hooks require an authorized live admin turn in a Full-access conversation`); + const manifestEngine = d.manifests[capabilities.manifest] ? capabilities.manifest : d.engines[0]; + const manifest = d.manifests[manifestEngine]; + // Only known component paths are forwarded. In particular settings, MCP, apps, and LSP + // declarations cannot silently expand permissions or activate an unselected service. + const native = { name: d.name, version: d.version || "1.0.0", description: d.description }; + for (const key of ["skills", "agents", "commands"]) { + if (!d.components[key].length) continue; + const paths = key === "agents" + ? d.files.filter((f) => /\.md$/i.test(f.path) && d.components.agents.some((p) => f.path === p || f.path.startsWith(`${p}/`))).map((f) => f.path) + : d.components[key]; + native[key] = paths.map((p) => `./${p}`); + } + const hooks = componentConfigs(d, "hooks", manifestEngine); + if (hooks.length) { + const merged = {}; + for (const config of hooks) { + const events = config?.hooks || config; + if (!events || typeof events !== "object" || Array.isArray(events)) throw new Error(`Plugin ${slug}: invalid hooks configuration`); + for (const [event, entries] of Object.entries(events)) { + if (!Array.isArray(entries)) throw new Error(`Plugin ${slug}: invalid hook event ${event}`); + merged[event] = [...(merged[event] || []), ...entries]; + } + } + native.hooks = { hooks: merged }; + } + const servers = []; + const seen = new Set(); + for (const config of componentConfigs(d, "mcpServers", manifestEngine)) { + const entries = config?.mcpServers || config; + if (!entries || typeof entries !== "object" || Array.isArray(entries)) throw new Error(`Plugin ${slug}: invalid MCP configuration`); + for (const [name, definition] of Object.entries(entries)) { + if (!/^[a-z0-9][a-z0-9_-]{0,99}$/i.test(name) || seen.has(name)) throw new Error(`Plugin ${slug}: invalid or duplicate MCP server name`); + seen.add(name); + const safe = publicPluginMcpDefinition(definition); + if (safe?.transport === "stdio" && !writable) throw new Error(`Plugin ${slug}: MCP server commands require a Worker or Full-access conversation`); + // Unsupported auth/transport must be satisfied by an explicitly selected connection; + // never copy source credentials into argv, resolve host env, or silently discard them. + servers.push({ name: pluginServerName(slug, name), sourceName: name, plugin: slug, definition: safe }); + } + } + const controlFiles = new Set([".claude-plugin/plugin.json", ".codex-plugin/plugin.json", ".mcp.json", "hooks/hooks.json", "hooks.json", ...d.components.mcpServers, ...d.components.hooks]); + const files = d.files.filter((f) => !controlFiles.has(f.path)); + if (capabilities.manifest) files.push({ path: `.${capabilities.manifest}-plugin/plugin.json`, content: Buffer.from(JSON.stringify(native)), executable: false }); + // Disable implicit hook discovery: hooks are declared once above, after policy checks. + const skillFiles = d.files.filter((f) => /(?:^|\/)SKILL\.md$/i.test(f.path) && d.components.skills.some((p) => f.path === p || f.path.startsWith(`${p}/`))).map((f) => { + const md = skillMetadata(parseFrontmatter(f.content.toString("utf8")).data); + if (!md.name || !md.description) throw new Error(`Plugin ${slug}: ${f.path} needs a skill name and description`); + return { name: `${d.name}:${md.name}`, description: md.description, path: f.path }; + }); + // Explicitly reject declarative engine settings which cannot be carried without replacing + // gateway policy. The raw file remains in the reviewed catalog revision. + if (manifest.settings || d.files.some((f) => ["settings.json", ".claude/settings.json", ".codex/config.toml"].includes(f.path))) throw new Error(`Plugin ${slug}: plugin settings overrides are unsupported`); + return { files, native: Boolean(capabilities.manifest), skillFiles, servers }; +} + +// Replace only package-root placeholders. Other environment expansion needs a separately +// configured connection; expansion on the daemon would leak the operator's own credentials. +export function relocatePluginServers(servers, root) { + return servers.map((server) => { + if (!server.definition) return server; + const replace = (s) => { + const value = s.replace(/\$\{(?:CLAUDE_PLUGIN_ROOT|CODEX_PLUGIN_ROOT|PLUGIN_ROOT)\}/g, () => root); + if (/\$\{|\$[A-Za-z_]/.test(value)) throw new Error(`Plugin ${server.plugin}: MCP ${server.sourceName} requires a separately configured connection for environment variables`); + return value; + }; + const definition = { ...server.definition }; + if (definition.command) definition.command = replace(definition.command); + if (definition.args) definition.args = definition.args.map(replace); + if (definition.url) definition.url = replace(definition.url); + return { ...server, definition }; + }); +} + +export function pluginSkillCatalog(compiled, root) { + return compiled.skillFiles.map((skill) => ({ ...skill, path: path.join(root, skill.path) })); +} + +export function requirePluginRuntime(runtime, engine) { + const selected = runtime?.[engine]; + if (selected?.error) throw new Error(selected.error); + return selected || { dirs: [], skills: [], servers: [] }; +} diff --git a/src/gateway/run-engine-mcp.js b/src/gateway/run-engine-mcp.js index 4a7142d..8b8f2e3 100644 --- a/src/gateway/run-engine-mcp.js +++ b/src/gateway/run-engine-mcp.js @@ -3,6 +3,8 @@ // authorized from this claim, so Claude's capability must never be reused by a Codex fallback. import { buildMcpConfig } from "./mcp.js"; import { requireAdapter } from "../engines/registry.js"; +import { requirePluginRuntime } from "./plugin-runtime.js"; +import { safeCodexMcpDefinition } from "./mcp-discovery.js"; // Capabilities live for six hours; a persistent Claude process idles out after ten minutes, but a // continuously active thread can keep it alive much longer. Rotate the warm fingerprint at least @@ -13,7 +15,7 @@ const CAPABILITY_FINGERPRINT_BUCKET_MS = 5 * 60 * 60 * 1000; // engine reaches the gateway control server: a host run spawns the stdio server from this checkout, // an isolated run gets the in-container bridge instead (there is no DB and no checkout on that // side). Passed through explicitly rather than riding in `identity`, so the dependency is visible. -export async function buildEngineMcpRuntime({ clean = false, engine = "claude", target = null, allowedMcps = [], fingerprintNow = Date.now(), ...identity } = {}) { +export async function buildEngineMcpRuntime({ clean = false, engine = "claude", target = null, allowedMcps = [], pluginRuntime = null, fingerprintNow = Date.now(), ...identity } = {}) { if (clean) { const mcpConfigJson = JSON.stringify({ mcpServers: {} }); return { mcpConfigJson, mcpConfigFingerprint: mcpConfigJson, gatewayCapability: "" }; @@ -24,6 +26,16 @@ export async function buildEngineMcpRuntime({ clean = false, engine = "claude", if (Object.hasOwn(parsed.mcpServers, name)) throw new Error("Selected MCP server conflicts with a built-in identity."); parsed.mcpServers[name] = definition; } + const pluginServers = []; + for (const server of requirePluginRuntime(pluginRuntime, engine).servers) { + if (Object.hasOwn(parsed.mcpServers, server.name)) throw new Error("Plugin MCP server conflicts with a selected connection"); + const definition = server.definition || safeCodexMcpDefinition(optional[server.sourceName]); + if (!definition) throw new Error(`Plugin ${server.plugin}: MCP ${server.sourceName} needs a separately selected, supported connection; source credentials are not imported`); + parsed.mcpServers[server.name] = definition.transport === "http" + ? { type: "http", url: definition.url } + : { command: definition.command, args: definition.args }; + pluginServers.push({ name: server.name, enabled: true, definition }); + } const mcpConfigJson = JSON.stringify(parsed); const gatewayCapability = parsed.mcpServers.gateway.env.CG_GATEWAY_CAPABILITY; @@ -46,5 +58,6 @@ export async function buildEngineMcpRuntime({ clean = false, engine = "claude", mcpConfigJson, mcpConfigFingerprint: JSON.stringify(fingerprintView), gatewayCapability, + pluginServers, }; } diff --git a/src/gateway/run-grant-artifacts.js b/src/gateway/run-grant-artifacts.js index 2999a25..d3f7e9d 100644 --- a/src/gateway/run-grant-artifacts.js +++ b/src/gateway/run-grant-artifacts.js @@ -7,6 +7,11 @@ import path from "node:path"; import { buildSettings, enableSkills } from "./folders.js"; import { ensureRealDir } from "./safe-fs.js"; import { parseFrontmatter, skillMetadata } from "./skills/frontmatter.js"; +import { grantedPluginPackages, compilePluginPackage, relocatePluginServers, pluginSkillCatalog } from "./plugin-runtime.js"; +import { ENGINE_IDS, requireAdapter } from "../engines/registry.js"; +import { materializeSkill } from "./skills/materialize.js"; +import { hashSkillFiles, normalizeSkillFiles } from "./skills/files.js"; +import { openWorkspaceDirectory } from "./skills/workspace-backup.js"; // ── Isolated-runtime engine homes ───────────────────────────────────────────────────────────── // Inside a container the engine's HOME is the channel's own persistent volume (plan §5/§8), so @@ -131,6 +136,9 @@ async function materializePlugin({ pluginDir, name, description, skillNames = [] // Omit only that generated observation field here; ordinary workspace materialization keeps // its real timestamp, and all revision facts, skill bytes and grants remain fingerprinted. const copied = await enableSkills(skillsDir, skillNames, { recordMaterializationTime: false }); + // A package is delivered through its own explicit native plugin or per-run catalog. Leaving + // its wrapper here would let recursive engine discovery activate the unsanitized source tree. + for (const pkg of grantedPluginPackages(skillNames)) await rm(path.join(skillsDir, pkg.slug), { recursive: true, force: true }); // Gateway-generated rather than source-library entries. Import fixed names only, never an // arbitrary project skill. `cp` retains references/, scripts/, and assets. // Same no-follow rule as copyWorkspaceAgents: both the container and the named skill folder must @@ -192,6 +200,49 @@ export async function createRunGrantArtifacts({ const codexSkillsDir = ""; const personalSkillCatalog = []; const claudePluginDirs = []; + const pluginRuntime = {}; + const sharedPackages = grantedPluginPackages(sharedSkills); + const sharedPackageNames = new Set(sharedPackages.map((pkg) => pkg.slug)); + const personalPackages = grantedPluginPackages(userSkills).filter((pkg) => !sharedPackageNames.has(pkg.slug)); + for (const engine of ENGINE_IDS) { + const output = { dirs: [], skills: [], servers: [] }; + pluginRuntime[engine] = output; + try { + const pluginNames = new Set(); + for (const [packages, personal] of [[sharedPackages, false], [personalPackages, true]]) { + for (const pkg of packages) { + const pluginName = pkg.descriptor.name.toLowerCase(); + if (pluginNames.has(pluginName)) throw new Error(`Plugin ${pkg.slug}: two granted packages use the same plugin name`); + pluginNames.add(pluginName); + const compiled = compilePluginPackage(pkg, { + capabilities: requireAdapter(engine).pluginCapabilities, + allowBypass, + writable: Boolean(meta.allowBash || meta.autoMode || meta.adminMode || allowBypass), + }); + const files = normalizeSkillFiles([ + { path: "SKILL.md", content: `---\nname: package-runtime\ndescription: Approved package runtime files\n---\n` }, + ...compiled.files.map((f) => ({ ...f, path: `package/${f.path}` })), + ]); + const digest = hashSkillFiles(files); + const parent = path.join(personal ? root : artifactRoot, "plugin-packages", engine); + const handle = await openWorkspaceDirectory(parent, { create: true }); + await handle.close(); + const name = `${pkg.slug}-${digest.slice(0, 24)}`; + const revision = { ...pkg.revision, contentHash: digest }; + const materialized = await materializeSkill(parent, name, { + lookup: () => ({ slug: name }), bundleFor: () => ({ revision, files }), recordMaterializationTime: false, + }); + if (materialized.state === "project") throw new Error(`Plugin ${pkg.slug}: runtime package path has untrusted files; refusing to load it`); + const packageRoot = path.join(parent, name, "package"); + if (compiled.native) output.dirs.push(packageRoot); + output.skills.push(...pluginSkillCatalog(compiled, packageRoot)); + output.servers.push(...relocatePluginServers(compiled.servers, packageRoot)); + } + } + } catch (error) { + pluginRuntime[engine] = { dirs: [], skills: [], servers: [], error: String(error.message || error) }; + } + } let missingSkills = []; // The two content-addressed roots a warm process keeps reading between turns live under the // artifact dir, mounted at the same absolute path inside the container so the digest path the @@ -230,7 +281,8 @@ export async function createRunGrantArtifacts({ // User grants differ by author, so they remain per-run and make only those Claude turns cold. // They are deleted after the process exits. - let claudePluginEphemeral = false; + claudePluginDirs.push(...(pluginRuntime.claude?.dirs || [])); + let claudePluginEphemeral = personalPackages.length > 0; if (userSkills.length > 0) { const pluginDir = path.join(root, "user-grants-plugin"); const personal = await materializePlugin({ @@ -256,8 +308,12 @@ export async function createRunGrantArtifacts({ } let settingsFile = ""; - if (needsClaudeSettings) { + if (needsClaudeSettings || sharedPackages.length || personalPackages.length) { const settings = await buildSettings({ ...meta, _slug: slug }, { allowBypass, target }); + for (const server of pluginRuntime.claude?.servers || []) { + settings.allowedMcpServers.push({ serverName: server.name }); + settings.permissions.allow.push(`mcp__${server.name}`); + } const content = `${JSON.stringify(settings, null, 2)}\n`; const digest = createHash("sha256").update(content).digest("hex").slice(0, 24); const settingsRoot = path.join(stableArtifactRoot, "claude-settings"); @@ -282,6 +338,7 @@ export async function createRunGrantArtifacts({ settingsFile, claudePluginDirs, claudePluginEphemeral, + pluginRuntime, claudeHome, claudeConfigDir, claudeStateDir, diff --git a/src/gateway/run.js b/src/gateway/run.js index ba9b778..29f3909 100644 --- a/src/gateway/run.js +++ b/src/gateway/run.js @@ -1191,8 +1191,9 @@ export async function runMessage({ channelId, authorId, workspaceId = "", text, let mcpConfigJson = ""; let mcpConfigFingerprint = ""; let gatewayCapability = ""; + let pluginMcpServers = []; const mintGatewayMcpRuntime = async () => { - ({ mcpConfigJson, mcpConfigFingerprint, gatewayCapability } = await buildEngineMcpRuntime({ ...mcpRuntimeInput, engine, target, allowedMcps: meta[adapter.mcpMetaKey] || [] })); + ({ mcpConfigJson, mcpConfigFingerprint, gatewayCapability, pluginServers: pluginMcpServers = [] } = await buildEngineMcpRuntime({ ...mcpRuntimeInput, pluginRuntime: grantArtifacts.pluginRuntime, engine, target, allowedMcps: meta[adapter.mcpMetaKey] || [] })); }; // Every granted definition is explicit in the per-run payload. Keep ambient MCPs disabled @@ -1387,6 +1388,8 @@ export async function runMessage({ channelId, authorId, workspaceId = "", text, claudeConfigDir: grantArtifacts.claudeConfigDir, codexStateDir: grantArtifacts.codexStateDir, personalSkillCatalog: grantArtifacts.personalSkillCatalog, + pluginRuntime: grantArtifacts.pluginRuntime, + pluginMcpServers, grantFingerprint, attachments, }, @@ -1540,7 +1543,7 @@ export async function runMessage({ channelId, authorId, workspaceId = "", text, // Remint for the engine that will actually execute. The gateway MCP uses this signed engine // claim to choose allowedCodexMcps vs allowedMcps for mutations; reusing the failed engine's // token would cross that authority boundary even though a different runner executes. - const fallbackMcpRuntime = await buildEngineMcpRuntime({ ...mcpRuntimeInput, engine: fallbackEngine, target, allowedMcps: meta[fallbackAdapter.mcpMetaKey] || [] }); + const fallbackMcpRuntime = await buildEngineMcpRuntime({ ...mcpRuntimeInput, pluginRuntime: grantArtifacts.pluginRuntime, engine: fallbackEngine, target, allowedMcps: meta[fallbackAdapter.mcpMetaKey] || [] }); const fbKey = `${threadKey}::${fallbackEngine}-fallback`; const prior = await getSession(entry.slug, fbKey); // A FRESH fallback session can't resume the failed engine's conversation, so without help it @@ -1601,6 +1604,8 @@ export async function runMessage({ channelId, authorId, workspaceId = "", text, claudeConfigDir: grantArtifacts.claudeConfigDir, codexStateDir: grantArtifacts.codexStateDir, personalSkillCatalog: grantArtifacts.personalSkillCatalog, + pluginRuntime: grantArtifacts.pluginRuntime, + pluginMcpServers: fallbackMcpRuntime.pluginServers || [], grantFingerprint }, })); }; diff --git a/src/gateway/skills/catalog.js b/src/gateway/skills/catalog.js index a00999f..b7fa26a 100644 --- a/src/gateway/skills/catalog.js +++ b/src/gateway/skills/catalog.js @@ -16,6 +16,7 @@ import path from "node:path"; import { getDb, fromJson, toJson } from "../../db/index.js"; import { parseFrontmatter, skillMetadata, slugFromName } from "./frontmatter.js"; +import { parsePluginPackage, buildPluginSkill } from "./plugin-package.js"; import { normalizeSkillFiles, hashSkillFiles, isSkillManifestPath, classifyBytes, sha256, MAX_FILE_BYTES } from "./files.js"; export const OWNER_KINDS = Object.freeze(["bundled", "local", "folder", "git"]); @@ -369,7 +370,9 @@ export function putSkillRevision({ if (!OWNER_KINDS.includes(ownerKind)) throw new SkillCatalogError(`unknown owner kind "${ownerKind}"`); if (!VISIBILITIES.includes(visibility)) throw new SkillCatalogError(`unknown visibility "${visibility}"`); if (!["active", "staged"].includes(status)) throw new SkillCatalogError(`a new revision is active or staged, not "${status}"`); - const normalized = normalizeSkillFiles(files); + let normalized = normalizeSkillFiles(files); + const plugin = parsePluginPackage(normalized); + if (plugin) normalized = buildPluginSkill(plugin.files); const manifest = normalized.find((f) => isSkillManifestPath(f.path)); const parsed = parseFrontmatter(manifest.content.toString("utf8")); const md = skillMetadata(parsed.data); @@ -385,6 +388,7 @@ export function putSkillRevision({ return tx((db) => { const existing = db.prepare("SELECT * FROM skills WHERE slug = ? COLLATE NOCASE").get(resolvedSlug); + if (existing && !plugin && fromJson(existing.meta, {})?.plugin?.kind === "plugin") throw new SkillCatalogError("an existing plugin revision must retain its package manifest", { code: "plugin" }); if (existing && (existing.owner_kind !== ownerKind || (existing.source_id ?? null) !== sid)) { return { changed: false, created: false, conflict: true, skill: rowToSkill(existing), revision: null, reason: `slug "${resolvedSlug}" is owned by ${existing.owner_kind}${existing.source_id ? ` source #${existing.source_id}` : ""}` }; } diff --git a/src/gateway/skills/files.js b/src/gateway/skills/files.js index fc488b604a8c410a01116ee2e29ed84cbd78a5d4..32b2870e7e0b88450ea9f5f719d1eab439dc56fa 100644 GIT binary patch delta 46 tcmZp)-ekRDo0MXjUTQ^Za%o9oQckLZy@HYgf~%xprJ$s=d70F0CIE}b5EK9a delta 12 TcmdmF-DtgGo7Cp#Qm2^!CLsl@ diff --git a/src/gateway/skills/git-sync.js b/src/gateway/skills/git-sync.js index 190e540..03a3f5b 100644 --- a/src/gateway/skills/git-sync.js +++ b/src/gateway/skills/git-sync.js @@ -12,6 +12,7 @@ // • A source in `review` mode stages every new or changed skill for an admin; `auto` activates. // • A source can be pinned to one commit; removed skills tombstone, never vanish. // • Ingest limits (file count/size, path rules) come from files.js and reject before storage. +import { buildPluginSkill, PLUGIN_MANIFESTS } from "./plugin-package.js"; import { gunzipSync } from "node:zlib"; import { putSkillRevision, tombstoneMissingSourceSkills, recordSourceSync, getSource, listSources, SkillCatalogError } from "./catalog.js"; import { normalizeSkillPath, isSkillManifestPath, MAX_FILE_BYTES, MAX_FILES } from "./files.js"; @@ -134,7 +135,7 @@ function parsePax(buf) { } // Yield every regular file in a (gunzipped) tar buffer as { path, content, mode }. -export function* readTar(tar) { +export function* readTar(tar, { includeLinks = false } = {}) { let offset = 0; let longName = ""; let pax = {}; @@ -161,6 +162,7 @@ export function* readTar(tar) { const entryPath = pax.path || longName || (prefix ? `${prefix}/${name}` : name); longName = ""; pax = {}; + if (includeLinks && (type === "1" || type === "2")) yield { path: entryPath, content: Buffer.alloc(0), mode, unsafeLink: true }; if (type !== "0" && type !== "\0" && type !== "7") continue; // directories, links, devices yield { path: entryPath, content: Buffer.from(data), mode }; } @@ -185,11 +187,14 @@ export async function fetchRepoFiles({ owner, repo, sha, token = "", fetchImpl = // Map with the tarball's top-level `owner-repo-sha/` stripped. export function tarToFiles(tar) { const files = new Map(); - for (const entry of readTar(tar)) { + const unsafeLinks = []; + for (const entry of readTar(tar, { includeLinks: true })) { const rel = entry.path.replace(/^\.\//, "").replace(/^[^/]+\//, ""); if (!rel) continue; + if (entry.unsafeLink) { unsafeLinks.push(rel); continue; } files.set(rel, { content: entry.content, mode: entry.mode }); } + files.unsafeLinks = unsafeLinks; return files; } @@ -200,13 +205,22 @@ export function tarToFiles(tar) { export function discoverSkills(files, subpath = "") { const scope = String(subpath || "").replace(/^\/+|\/+$/g, ""); const inScope = (p) => !scope || p === scope || p.startsWith(`${scope}/`); + const pluginDirs = [...new Set([...files.keys(), ...(files.unsafeLinks || [])].filter((p) => inScope(p) && Object.values(PLUGIN_MANIFESTS).some((m) => p === m || p.endsWith(`/${m}`))).map((p) => p.split("/").slice(0, -2).join("/")))].sort(); + const outerPlugins = pluginDirs.filter((d) => !pluginDirs.some((parent) => parent !== d && (!parent || d.startsWith(`${parent}/`)))); + const pluginSkills = outerPlugins.map((dir) => { + const prefix = dir ? `${dir}/` : ""; + if (files.unsafeLinks?.some((p) => p.startsWith(prefix))) throw new Error(`plugin package cannot contain symlinks: ${dir || "."}`); + const bundle = [...files].filter(([p]) => p.startsWith(prefix) && !p.split("/").includes(".git")).map(([p, f]) => ({ path: p.slice(prefix.length), content: f.content, executable: (f.mode & 0o111) !== 0 })); + // A failed package must fail discovery before sync can tombstone the last approved copy. + return { dir, files: buildPluginSkill(bundle), oversized: [] }; + }); const skillDirs = []; for (const p of files.keys()) { - if (!isSkillManifestPath(p.split("/").pop()) || !inScope(p)) continue; + if (!isSkillManifestPath(p.split("/").pop()) || !inScope(p) || outerPlugins.some((d) => !d || p.startsWith(`${d}/`))) continue; skillDirs.push(p.split("/").slice(0, -1).join("/")); } skillDirs.sort(); - const out = []; + const out = [...pluginSkills]; for (const dir of skillDirs) { const prefix = dir ? `${dir}/` : ""; const bundle = []; @@ -215,6 +229,7 @@ export function discoverSkills(files, subpath = "") { if (!p.startsWith(prefix)) continue; const rel = p.slice(prefix.length); if (!rel) continue; + if (outerPlugins.some((d) => !d || p.startsWith(`${d}/`))) continue; // A deeper skill directory owns this file. if (skillDirs.some((d) => d.length > dir.length && d.startsWith(prefix) && p.startsWith(`${d}/`))) continue; if (/(^|\/)\.git\//.test(`${rel}/`) || rel.split("/").some((seg) => seg === ".git")) continue; @@ -297,7 +312,7 @@ export async function syncGitSource(source, { token = "", fetchImpl = fetch, log else if (r.created) stats.created++; else stats.updated++; } - stats.tombstoned = tombstoneMissingSourceSkills(src.id, present); + if (!stats.skipped.length && !stats.conflicts.length) stats.tombstoned = tombstoneMissingSourceSkills(src.id, present); recordSourceSync(src.id, { ok: true, ref: sha, stats }); return { ok: true, ref: sha, ...stats }; } catch (err) { diff --git a/src/gateway/skills/import-folder.js b/src/gateway/skills/import-folder.js index 7e1f418..4643e6a 100644 --- a/src/gateway/skills/import-folder.js +++ b/src/gateway/skills/import-folder.js @@ -5,6 +5,7 @@ // grant still resolves, and it is re-imported by content hash on each boot: the directory stays // authoritative for that skill, the catalog mirrors it. A directory that disappears tombstones // its skill; one that comes back restores it. +import { buildPluginSkill, PLUGIN_MANIFESTS } from "./plugin-package.js"; import { readdir, readFile, stat, lstat } from "node:fs/promises"; import path from "node:path"; import { fileURLToPath } from "node:url"; @@ -24,6 +25,13 @@ async function isDirLike(p, { dereference }) { } } +async function hasPluginManifest(dir) { + for (const manifest of Object.values(PLUGIN_MANIFESTS)) { + try { const info = await lstat(path.join(dir, manifest)); if (info.isFile() || info.isSymbolicLink()) return true; } catch { /* absent */ } + } + return false; +} + async function hasManifest(dir) { try { const entries = await readdir(dir); @@ -37,7 +45,7 @@ async function hasManifest(dir) { // folders (a subdirectory with its own SKILL.md) belong to that skill and are skipped, as are // gateway marker files and VCS/system noise. Symlinks are followed when `dereference` is set — // the operator's own folders are often links into a store of available skills. -export async function readSkillDirectory(dir, { dereference = true } = {}) { +export async function readSkillDirectory(dir, { dereference = true, plugin = false } = {}) { const files = []; const walk = async (current, rel) => { const entries = await readdir(current, { withFileTypes: true }); @@ -48,13 +56,14 @@ export async function readSkillDirectory(dir, { dereference = true } = {}) { const relPath = rel ? `${rel}/${entry.name}` : entry.name; let info; try { - info = dereference ? await stat(abs) : await lstat(abs); + info = dereference && !plugin ? await stat(abs) : await lstat(abs); } catch { continue; // dangling link or vanished entry } + if (plugin && info.isSymbolicLink()) throw new Error(`plugin packages cannot contain symlinks: ${relPath}`); if (info.isDirectory()) { // A nested skill is its own skill; never fold it into this one. - if (await hasManifest(abs)) continue; + if (!plugin && (await hasManifest(abs) || await hasPluginManifest(abs))) continue; await walk(abs, relPath); continue; } @@ -69,7 +78,10 @@ export async function readSkillDirectory(dir, { dereference = true } = {}) { // Import one skill directory as a revision. Returns the catalog result plus the slug used. export async function importSkillDirectory(dir, { slug = "", ownerKind = "folder", sourceId = null, sourcePath = dir, sourceRef = "", status = "active", createdBy = "", dereference = true } = {}) { - const files = await readSkillDirectory(dir, { dereference }); + const plugin = await hasPluginManifest(dir); + if (plugin && (await lstat(dir)).isSymbolicLink()) throw new Error("plugin source cannot be a symlink"); + const raw = await readSkillDirectory(dir, { dereference, plugin }); + const files = plugin ? buildPluginSkill(raw) : raw; return putSkillRevision({ slug, files, ownerKind, sourceId, sourcePath, sourceRef, status, createdBy }); } @@ -86,17 +98,20 @@ export async function importSkillTree(root, { ownerKind = "folder", sourceId = n if (requireRoot) result.errors.push({ slug: "(source root)", error: `Cannot read skill source directory: ${err?.message || String(err)}` }); return result; } + const rootPlugin = await hasPluginManifest(root); + if (rootPlugin) entries = [{ name: path.basename(root), pluginRoot: true }]; for (const entry of entries.sort((a, b) => a.name.localeCompare(b.name))) { if (SKIP_NAMES.has(entry.name) || entry.name.startsWith(".")) continue; - const dir = path.join(root, entry.name); + const dir = entry.pluginRoot ? root : path.join(root, entry.name); if (!(await isDirLike(dir, { dereference }))) continue; - if (!(await hasManifest(dir))) continue; - if (!isValidSlug(entry.name)) { + const plugin = await hasPluginManifest(dir); + if (!(await hasManifest(dir)) && !plugin) continue; + if (!plugin && !isValidSlug(entry.name)) { result.errors.push({ slug: entry.name, error: "folder name is not a valid skill slug" }); continue; } try { - const r = await importSkillDirectory(dir, { slug: entry.name, ownerKind, sourceId, sourcePath: dir, sourceRef, status, createdBy, dereference }); + const r = await importSkillDirectory(dir, { slug: plugin ? "" : entry.name, ownerKind, sourceId, sourcePath: dir, sourceRef, status, createdBy, dereference }); if (r.conflict) result.conflicts.push({ slug: entry.name, reason: r.reason }); else { result.presentSlugs.push(r.skill.slug); @@ -129,7 +144,7 @@ export async function importHostSkillFolders(dirs = []) { if (skill.sourceId != null) continue; const key = skill.slug.toLowerCase(); const here = present.has(key); - if (!here && !skill.deleted) { + if (!here && !skill.deleted && !results.some((r) => r.errors.length)) { tombstoneSkill(skill.slug); tombstoned++; } else if (here && skill.deleted && !skill.excluded) { diff --git a/src/gateway/skills/plugin-package.js b/src/gateway/skills/plugin-package.js new file mode 100644 index 0000000..9e064a5 --- /dev/null +++ b/src/gateway/skills/plugin-package.js @@ -0,0 +1,70 @@ +// Plugins use the existing revision, approval and grant model. Their original tree is stored +// below package/ and a derived SKILL.md makes the package one selectable catalog entry. +import { normalizeSkillFiles, normalizeSkillPath } from "./files.js"; +import { parseFrontmatter } from "./frontmatter.js"; + +export const PLUGIN_MANIFESTS = { claude: ".claude-plugin/plugin.json", codex: ".codex-plugin/plugin.json" }; +const componentDefaults = { skills: "skills", commands: "commands", agents: "agents", hooks: "hooks/hooks.json", mcpServers: ".mcp.json", lspServers: ".lsp.json", apps: ".apps.json" }; + +function componentPath(value, files) { + if (typeof value !== "string" || !value || value.includes(":") || value.includes("$") || value.includes("~")) throw new Error("plugin component must be a local package path"); + const p = normalizeSkillPath(value); + if (!files.some((f) => f.path === p || f.path.startsWith(`${p}/`))) throw new Error(`plugin component path is missing: ${p}`); + return p; +} + +function describe(files) { + const manifests = {}; + const components = Object.fromEntries(Object.keys(componentDefaults).map((k) => [k, []])); + let name = "", description = "", version = "", manifestPath = ""; + for (const [engine, p] of Object.entries(PLUGIN_MANIFESTS)) { + const file = files.find((f) => f.path === p); + if (!file) continue; + let manifest; + try { manifest = JSON.parse(file.content.toString("utf8")); } catch { throw new Error(`invalid plugin manifest JSON: ${p}`); } + if (!manifest || typeof manifest !== "object" || Array.isArray(manifest) || typeof manifest.name !== "string" || !/^[a-z0-9][a-z0-9._-]{0,119}$/i.test(manifest.name)) throw new Error(`invalid plugin name in ${p}`); + if (name && name !== manifest.name) throw new Error("plugin manifests must use the same name"); + for (const key of ["description", "version"]) if (manifest[key] != null && typeof manifest[key] !== "string") throw new Error(`invalid plugin ${key} in ${p}`); + name = manifest.name; + description ||= manifest.description || `Plugin package ${name}`; + version ||= manifest.version || ""; + manifestPath ||= p; + manifests[engine] = manifest; + for (const [key, defaultPath] of Object.entries(componentDefaults)) { + const value = manifest[key]; + if (value != null) { + if ((["hooks", "mcpServers", "lspServers", "apps"].includes(key)) && typeof value === "object" && !Array.isArray(value)) components[key].push(p); + else for (const entry of Array.isArray(value) ? value : [value]) components[key].push(componentPath(entry, files)); + } + // Claude's conventional component directories remain enabled alongside custom paths. + if (files.some((f) => f.path === defaultPath || f.path.startsWith(`${defaultPath}/`))) components[key].push(defaultPath); + } + } + if (!name) throw new Error("plugin package needs a .claude-plugin/plugin.json or .codex-plugin/plugin.json manifest"); + for (const [key, extras] of Object.entries({ apps: [".app.json"], hooks: ["hooks.json"] })) { + for (const p of extras) if (files.some((f) => f.path === p)) components[key].push(p); + } + for (const key of Object.keys(components)) components[key] = [...new Set(components[key])]; + return { kind: "plugin", name, description, version, engines: Object.keys(manifests), manifestPath, manifests, components, files }; +} + +export function buildPluginSkill(input) { + const files = normalizeSkillFiles(input, { requireManifest: false }); + const descriptor = describe(files); + const { name, description, version, engines, manifestPath, components } = descriptor; + const metadata = { kind: "plugin", name, engines, manifestPath, components }; + // JSON values are also valid YAML scalars; nested metadata is emitted as a YAML mapping. + const skillLinks = files.filter((f) => /(^|\/)SKILL\.md$/i.test(f.path)).map((f) => `- [${f.path}](package/${f.path})`).join("\n"); + const componentLinks = [...new Set(Object.values(components).flat())].map((p) => `- [${p}](package/${p})`).join("\n"); + const header = `---\nname: ${JSON.stringify(name)}\ndescription: ${JSON.stringify(description)}\nversion: ${JSON.stringify(version)}\nplugin:\n kind: plugin\n name: ${JSON.stringify(name)}\n engines: ${JSON.stringify(engines)}\n manifestPath: ${JSON.stringify(manifestPath)}\n components:\n${Object.entries(metadata.components).map(([k, v]) => ` ${k}: ${JSON.stringify(v)}`).join("\n")}\n---\n\nThis entry contains the complete ${name} plugin package. Review all files under package/ before approving this revision. Enable it through the existing channel skills or templates. This file is a catalog index; native plugin execution is managed separately by the gateway.\n\nBundled skill instructions:\n${skillLinks || "No bundled skills."}\n\nSupporting components:\n${componentLinks || "No additional components."}\n`; + return normalizeSkillFiles([{ path: "SKILL.md", content: header }, ...files.map((f) => ({ ...f, path: `package/${f.path}` }))]); +} + +export function parsePluginPackage(input) { + const files = normalizeSkillFiles(input); + const main = files.find((f) => /^SKILL\.md$/i.test(f.path)); + const hasPackageManifest = files.some((f) => Object.values(PLUGIN_MANIFESTS).includes(f.path.slice("package/".length)) && f.path.startsWith("package/")); + if (!hasPackageManifest && parseFrontmatter(main.content.toString("utf8")).data.plugin?.kind !== "plugin") return null; + if (files.some((f) => f !== main && !f.path.startsWith("package/"))) throw new Error("plugin revision contains files outside package/"); + return describe(normalizeSkillFiles(files.filter((f) => f.path.startsWith("package/")).map((f) => ({ ...f, path: f.path.slice(8) })), { requireManifest: false })); +} diff --git a/src/gateway/skills/plugin-summary.js b/src/gateway/skills/plugin-summary.js new file mode 100644 index 0000000..44cd380 --- /dev/null +++ b/src/gateway/skills/plugin-summary.js @@ -0,0 +1,11 @@ +// The public package summary is an allowlist, never a raw manifest or connection object. +export function pluginSummaryFromMetadata(metadata) { + const plugin = metadata?.plugin; + if (plugin?.kind !== "plugin") return undefined; + const text = (value) => typeof value === "string" ? value : ""; + const components = {}; + for (const key of ["skills", "commands", "agents", "hooks", "mcpServers", "apps", "lspServers"]) { + components[key] = Array.isArray(plugin.components?.[key]) ? plugin.components[key].filter((value) => typeof value === "string") : []; + } + return { kind: "plugin", name: text(plugin.name), engines: Array.isArray(plugin.engines) ? plugin.engines.filter((value) => value === "claude" || value === "codex") : [], manifestPath: text(plugin.manifestPath), components }; +} diff --git a/src/gateway/skills/publish.js b/src/gateway/skills/publish.js index 96a11e6..5b93404 100644 --- a/src/gateway/skills/publish.js +++ b/src/gateway/skills/publish.js @@ -8,7 +8,8 @@ // that source (owner git, same source id) so the next sync recognizes its own files instead of // reporting a conflict — that is how "authored in chat → pushed to GitHub → part of the library" // closes the loop. -import { getSkill, getRevision, revisionFiles, listSources, adoptSkillIntoSource, markRevisionPublished, SkillCatalogError } from "./catalog.js"; +import { parsePluginPackage } from "./plugin-package.js"; +import { getSkill, getRevision, revisionFiles, listRevisions, listSources, adoptSkillIntoSource, markRevisionPublished, SkillCatalogError } from "./catalog.js"; import { parseRepoUrl } from "./git-sync.js"; import { getSkillsPublishGithubToken, getSkillsPublish } from "../../config/settings.js"; import { logEvent } from "../../util/logger.js"; @@ -17,6 +18,25 @@ import { getChannelEntry } from "../../config/store.js"; const USER_AGENT = "channelgate-skill-publish"; +function publishFiles(revision) { + const stored = revisionFiles(revision.id); + const plugin = parsePluginPackage(stored); + if (plugin?.files.some((f) => f.executable)) throw new SkillCatalogError("Publishing plugins with executable files requires Git mode-preserving publishing; use the source repository directly", { status: 409 }); + return plugin?.files || stored; +} + +// Packages may coexist with files added after the reviewed snapshot. Only +// paths already recorded in this package's revision history are eligible for removal. +function ownedPluginPaths(skill) { + const paths = new Set(); + for (const revision of listRevisions(skill.id)) { + if (revision.status !== "active" && !revision.publishedAt) continue; + const plugin = parsePluginPackage(revisionFiles(revision.id)); + for (const file of plugin?.files || []) paths.add(file.path); + } + return paths; +} + function ghHeaders(token) { return { "User-Agent": USER_AGENT, Accept: "application/vnd.github+json", "X-GitHub-Api-Version": "2022-11-28", Authorization: `Bearer ${token}`, "Content-Type": "application/json" }; } @@ -98,6 +118,7 @@ export function skillDir(target, { slug, channelScope = "" } = {}) { // A skill the publish repository already owns is written back to the folder it was synced from // (moving it is an explicit step, moveSkillFiles); anything else goes where its scope says. function publishDirFor(target, skill, source) { + if (source && skill.ownerKind === "git" && skill.sourceId === source.id && skill.sourcePath === "." && skill.meta?.plugin?.kind === "plugin") return ""; if (source && skill.ownerKind === "git" && skill.sourceId === source.id && skill.sourcePath && skill.sourcePath !== ".") { const rel = skill.sourcePath.replace(/^\/+|\/+$/g, ""); const base = (source.subpath || "").replace(/^\/+|\/+$/g, ""); @@ -128,21 +149,23 @@ export async function publishRevision({ slug, revisionId = null, message = "", a if (!skill) throw new SkillCatalogError("skill not found", { status: 404 }); const revision = getRevision(revisionId ?? skill.pinnedRevisionId ?? skill.currentRevisionId); if (!revision || revision.skillId !== skill.id) throw new SkillCatalogError("revision not found", { status: 404 }); - const files = revisionFiles(revision.id); + const files = publishFiles(revision); const source = publishSource(target); const dir = publishDirFor(target, skill, source); const remote = await listRemoteFiles(fetchImpl, token, target, dir); + const owned = skill.meta?.plugin?.kind === "plugin" ? ownedPluginPaths(skill) : null; const note = message || `skill(${skill.slug}): revision ${revision.revisionNo}${revision.version ? ` v${revision.version}` : ""}${revision.note ? ` — ${revision.note}` : ""}`; let lastCommit = ""; const written = []; for (const f of files) { - const p = `${dir}/${f.path}`; + const p = dir ? `${dir}/${f.path}` : f.path; lastCommit = (await putFile(fetchImpl, token, target, p, f.content, note, remote.get(p))) || lastCommit; written.push(p); remote.delete(p); } const deleted = []; for (const [p, sha] of remote) { + if (owned && !owned.has(dir ? p.slice(dir.length + 1) : p)) continue; lastCommit = (await deleteFile(fetchImpl, token, target, p, sha, `${note} (remove ${p.slice(dir.length + 1)})`)) || lastCommit; deleted.push(p); } @@ -174,17 +197,19 @@ export async function moveSkillFiles({ slug, channelId = "", actor = "", fetchIm const fromDir = publishDirFor(target, skill, source); const toDir = skillDir(target, { slug: skill.slug, channelScope: scope }); if (fromDir === toDir) return { moved: false, path: toDir, commit: "" }; - const files = revisionFiles(revision.id); + const files = publishFiles(revision); const note = `skill(${skill.slug}): move to ${scope ? `the ${scope} channel section` : "the shared library"}`; const stale = await listRemoteFiles(fetchImpl, token, target, toDir); + const previous = await listRemoteFiles(fetchImpl, token, target, fromDir); + const owned = skill.meta?.plugin?.kind === "plugin" ? ownedPluginPaths(skill) : null; let lastCommit = ""; for (const f of files) { const p = `${toDir}/${f.path}`; lastCommit = (await putFile(fetchImpl, token, target, p, f.content, note, stale.get(p))) || lastCommit; stale.delete(p); } - for (const [p, sha] of stale) lastCommit = (await deleteFile(fetchImpl, token, target, p, sha, `${note} (replace ${p})`)) || lastCommit; - for (const [p, sha] of await listRemoteFiles(fetchImpl, token, target, fromDir)) lastCommit = (await deleteFile(fetchImpl, token, target, p, sha, `${note} (remove ${p})`)) || lastCommit; + for (const [p, sha] of stale) if (!owned || owned.has(p.slice(toDir.length + 1))) lastCommit = (await deleteFile(fetchImpl, token, target, p, sha, `${note} (replace ${p})`)) || lastCommit; + for (const [p, sha] of previous) if (!p.startsWith(`${toDir}/`) && (!owned || owned.has(fromDir ? p.slice(fromDir.length + 1) : p))) lastCommit = (await deleteFile(fetchImpl, token, target, p, sha, `${note} (remove ${p})`)) || lastCommit; if (scope) lastCommit = (await ensureSectionReadme(fetchImpl, token, target, scope, note)) || lastCommit; markRevisionPublished(revision.id, { ref: lastCommit }); if (source) adoptSkillIntoSource(skill.slug, source.id, { sourcePath: toDir, sourceRef: lastCommit }); diff --git a/src/gateway/skills/usage.js b/src/gateway/skills/usage.js index 8df619f..889e2d9 100644 --- a/src/gateway/skills/usage.js +++ b/src/gateway/skills/usage.js @@ -10,7 +10,14 @@ import { resolveSkillProfile } from "./resolve.js"; // `…/skills//SKILL.md` — inside a path, a shell command, or a quoted argument. The // lookbehind is what keeps `myskills/…` and `agent-skills/…` out while still accepting every // separator a shell puts in front of a path (`/`, a space, a quote, `=`, `(`, `;`, `&&`, `|`, `,`). -const SKILL_FILE_RE = /(?/ segment. This keeps +// a bundled skill from being attributed to an unrelated standalone catalog entry of that name. +const SAFE_SLUG = "[A-Za-z0-9][A-Za-z0-9._-]{0,119}"; +const PACKAGE_SKILL_PATH = "(?:(?!\\.\\.?/)[A-Za-z0-9._-]{1,120}/){0,32}SKILL\\.md(?![A-Za-z0-9._/-])"; +const SKILL_FILE_RE = new RegExp(`(?:` (e.g. -// `gateway-shared-skills:code-review`) in its Skill tool call; the catalog only knows the bare -// slug, so strip the prefix before looking it up — without it every Claude run recorded an -// unmatched name with no skill/revision id. +// `gateway-shared-skills:code-review`) in its Skill tool call. Managed packages attribute to +// their catalog parent; synthetic shared-skill plugins still attribute to the bare skill slug. const PLUGIN_QUALIFIED_RE = /^[^:\s]+:([A-Za-z0-9][A-Za-z0-9._-]{0,119})$/; export function unqualifySkillName(name) { @@ -67,7 +74,16 @@ export function createSkillUsageRecorder({ if (!raw) return; const bare = unqualifySkillName(raw); let skill = null; - for (const candidate of bare ? [raw, bare] : [raw]) { + if (bare) { + const prefix = raw.slice(0, raw.indexOf(":")); + if (new RegExp(`^${SAFE_SLUG}$`).test(prefix)) { + try { + const parent = lookup(prefix); + if (parent?.meta?.plugin?.kind === "plugin") skill = parent; + } catch { /* usage capture must never break a run */ } + } + } + for (const candidate of skill ? [] : bare ? [raw, bare] : [raw]) { try { skill = lookup(candidate); } catch { diff --git a/src/web/routes/skills.js b/src/web/routes/skills.js index c1d94b5..aa613c0 100644 --- a/src/web/routes/skills.js +++ b/src/web/routes/skills.js @@ -38,6 +38,8 @@ import { SkillCatalogError, } from "../../gateway/skills/catalog.js"; import { fileToApi, SkillFileError } from "../../gateway/skills/files.js"; +import { parseFrontmatter } from "../../gateway/skills/frontmatter.js"; +import { pluginSummaryFromMetadata } from "../../gateway/skills/plugin-summary.js"; import { resolveSkillProfile, skillGrantContextChange } from "../../gateway/skills/resolve.js"; import { listTemplateSummaries, previewTemplate, assignTemplateToChannel, templateSummary, templateAssignments, withTemplateSkills, templateOfMeta, channelScopedSkills } from "../../gateway/skills/templates.js"; import { skillUsageReport } from "../../gateway/skills/usage.js"; @@ -80,6 +82,7 @@ function skillToApi(skill, usage = null) { return { ...skill, meta: undefined, + plugin: pluginSummaryFromMetadata(skill.meta), owner: describeOwner(skill), enabled: !skill.deleted, mandatory, @@ -87,6 +90,12 @@ function skillToApi(skill, usage = null) { }; } +function revisionToApi(revision) { + const file = revisionFile(revision.id, "SKILL.md"); + const metadata = file ? parseFrontmatter(file.content.toString("utf8")).data : {}; + return { ...revision, plugin: pluginSummaryFromMetadata(metadata) }; +} + function queryBoolean(value) { return value === "1" ? true : value === "0" ? false : null; } @@ -116,7 +125,7 @@ export function createSkillsRouter() { stats: catalogStats(), sources: listSources(), templates: listTemplateSummaries(), - staged: listStagedRevisions(), + staged: listStagedRevisions().map(revisionToApi), proposals: listProposals({ status: "pending" }), hostFolders: skillSourceDirs(), settings: { @@ -255,13 +264,13 @@ export function createSkillsRouter() { })); // ── Revisions (staged review) ───────────────────────────────────────────────────────────── - router.get("/skills/staged", guard(async (_req, res) => res.json({ staged: listStagedRevisions() }))); + router.get("/skills/staged", guard(async (_req, res) => res.json({ staged: listStagedRevisions().map(revisionToApi) }))); router.get("/skills/revisions/:id/files", guard(async (req, res) => { const rev = getRevision(Number(req.params.id)); if (!rev) return res.status(404).json({ error: "revision not found" }); const includeContent = req.query.content === "1"; - res.json({ revision: rev, files: revisionFiles(rev.id).map((f) => fileToApi(f, { includeContent: includeContent && f.content.length <= 256 * 1024 })) }); + res.json({ revision: revisionToApi(rev), files: revisionFiles(rev.id).map((f) => fileToApi(f, { includeContent: includeContent && f.content.length <= 256 * 1024 })) }); })); router.post("/skills/revisions/:id/approve", guard(async (req, res) => { diff --git a/test/codex-args.test.js b/test/codex-args.test.js index db62aea..40e6bd3 100644 --- a/test/codex-args.test.js +++ b/test/codex-args.test.js @@ -832,3 +832,26 @@ test("personal skill catalogs supplement fresh/resumed prompts, replace old gran assert.ok(clean.includes("Raw clean prompt")); assert.ok(!clean.join("\n").includes("private-proof")); }); + + +test("plugin skill catalogs replace grants independently on fresh, resumed, and clean turns", () => { + const pluginSkills = [{ name: "approved-package:proof", description: "Package instructions", path: "/artifact/plugins/package/skills/proof/SKILL.md" }]; + const personalSkills = [{ name: "private-proof", path: "/artifact/personal/SKILL.md" }]; + for (const isNewSession of [true, false]) { + const args = argsFor({ prompt: "Use package proof", isNewSession, pluginSkills, personalSkills }); + const prompt = args.at(-1); + assert.match(prompt, /Current approved plugin skills/); + assert.match(prompt, /earlier plugin catalogs and paths have expired/); + assert.ok(prompt.includes(JSON.stringify(pluginSkills))); + assert.ok(prompt.includes(JSON.stringify(personalSkills))); + assert.ok(prompt.endsWith("Use package proof")); + for (const overrides of [{ pluginSkills: [] }, { clean: true }]) { + const revoked = argsFor({ prompt: "Next turn", isNewSession, pluginSkills, ...overrides }).at(-1); + assert.match(revoked, /earlier plugin catalogs and paths have expired/); + assert.match(revoked, /empty catalog means no plugin skills are available/); + assert.ok(revoked.includes("\n[]\n")); + assert.ok(!revoked.includes("approved-package")); + assert.ok(!revoked.includes("/artifact/plugins")); + } + } +}); diff --git a/test/fixtures/plugins/native/.claude-plugin/plugin.json b/test/fixtures/plugins/native/.claude-plugin/plugin.json new file mode 100644 index 0000000..2e10dc4 --- /dev/null +++ b/test/fixtures/plugins/native/.claude-plugin/plugin.json @@ -0,0 +1 @@ +{"name":"fixture-native-plugin","version":"1.0.0","description":"Disposable native Claude plugin acceptance fixture"} diff --git a/test/fixtures/plugins/native/agents/inspector.md b/test/fixtures/plugins/native/agents/inspector.md new file mode 100644 index 0000000..b321cf9 --- /dev/null +++ b/test/fixtures/plugins/native/agents/inspector.md @@ -0,0 +1,6 @@ +--- +name: plugin-inspector +description: Answer the disposable native plugin agent acceptance check. +--- + +Reply with PLUGIN-AGENT. diff --git a/test/fixtures/plugins/native/commands/marker.md b/test/fixtures/plugins/native/commands/marker.md new file mode 100644 index 0000000..56ba1f1 --- /dev/null +++ b/test/fixtures/plugins/native/commands/marker.md @@ -0,0 +1,5 @@ +--- +description: Return the disposable native plugin command marker. +--- + +Reply with PLUGIN-COMMAND. diff --git a/test/fixtures/plugins/native/hooks/hooks.json b/test/fixtures/plugins/native/hooks/hooks.json new file mode 100644 index 0000000..9ccc0bd --- /dev/null +++ b/test/fixtures/plugins/native/hooks/hooks.json @@ -0,0 +1 @@ +{"hooks":{"SessionStart":[{"hooks":[{"type":"command","command":"node \"${CLAUDE_PLUGIN_ROOT}/scripts/marker.mjs\""}]}]}} diff --git a/test/fixtures/plugins/native/scripts/marker.mjs b/test/fixtures/plugins/native/scripts/marker.mjs new file mode 100644 index 0000000..6ec4ff4 --- /dev/null +++ b/test/fixtures/plugins/native/scripts/marker.mjs @@ -0,0 +1,2 @@ +import { appendFileSync } from "node:fs"; +appendFileSync("plugin-hook-marker.txt", "PLUGIN-HOOK\n"); diff --git a/test/fixtures/plugins/portable/.claude-plugin/plugin.json b/test/fixtures/plugins/portable/.claude-plugin/plugin.json new file mode 100644 index 0000000..11559f8 --- /dev/null +++ b/test/fixtures/plugins/portable/.claude-plugin/plugin.json @@ -0,0 +1 @@ +{"name":"fixture-portable-plugin","version":"1.0.0","description":"Disposable plugin acceptance fixture"} diff --git a/test/fixtures/plugins/portable/.codex-plugin/plugin.json b/test/fixtures/plugins/portable/.codex-plugin/plugin.json new file mode 100644 index 0000000..5e7e06d --- /dev/null +++ b/test/fixtures/plugins/portable/.codex-plugin/plugin.json @@ -0,0 +1 @@ +{"name":"fixture-portable-plugin","version":"1.0.0","description":"Disposable plugin acceptance fixture","skills":"./skills","mcpServers":"./.mcp.json"} diff --git a/test/fixtures/plugins/portable/.mcp.json b/test/fixtures/plugins/portable/.mcp.json new file mode 100644 index 0000000..79f52d2 --- /dev/null +++ b/test/fixtures/plugins/portable/.mcp.json @@ -0,0 +1 @@ +{"mcpServers":{"fixture":{"command":"node","args":["${PLUGIN_ROOT}/server.mjs"]}}} diff --git a/test/fixtures/plugins/portable/server.mjs b/test/fixtures/plugins/portable/server.mjs new file mode 100644 index 0000000..66be7b6 --- /dev/null +++ b/test/fixtures/plugins/portable/server.mjs @@ -0,0 +1,17 @@ +// Disposable, credential-free MCP fixture. No imports from the daemon or network calls. +import { createInterface } from "node:readline"; +const input = createInterface({ input: process.stdin }); +input.on("line", (line) => { + const request = JSON.parse(line); + if (request.id == null) return; + let result; + if (request.method === "initialize") result = { protocolVersion: request.params.protocolVersion, capabilities: { tools: {} }, serverInfo: { name: "plugin-fixture", version: "1.0.0" } }; + else if (request.method === "tools/list") result = { tools: [{ name: "plugin_echo", description: "Echo a disposable plugin acceptance marker", inputSchema: { type: "object", properties: { value: { type: "string" } }, required: ["value"] } }] }; + else if (request.method === "tools/call") result = { content: [{ type: "text", text: `PLUGIN-ECHO:${request.params.arguments.value}` }] }; + else if (request.method === "ping") result = {}; + else { + process.stdout.write(`${JSON.stringify({ jsonrpc: "2.0", id: request.id, error: { code: -32601, message: "Method not found" } })}\n`); + return; + } + process.stdout.write(`${JSON.stringify({ jsonrpc: "2.0", id: request.id, result })}\n`); +}); diff --git a/test/fixtures/plugins/portable/skills/marker/SKILL.md b/test/fixtures/plugins/portable/skills/marker/SKILL.md new file mode 100644 index 0000000..58c2462 --- /dev/null +++ b/test/fixtures/plugins/portable/skills/marker/SKILL.md @@ -0,0 +1,7 @@ +--- +name: plugin-marker +description: Use when asked for the plugin acceptance marker or to run plugin echo. +--- + +Read `marker.txt` beside this file and quote its exact contents. When asked to run plugin echo, +call the granted `plugin_echo` MCP tool with the user's supplied value and report its result. diff --git a/test/fixtures/plugins/portable/skills/marker/marker.txt b/test/fixtures/plugins/portable/skills/marker/marker.txt new file mode 100644 index 0000000..8025b93 --- /dev/null +++ b/test/fixtures/plugins/portable/skills/marker/marker.txt @@ -0,0 +1 @@ +PLUGIN-REVISION-ONE diff --git a/test/plugin-container.live.test.js b/test/plugin-container.live.test.js new file mode 100644 index 0000000..0664f57 --- /dev/null +++ b/test/plugin-container.live.test.js @@ -0,0 +1,53 @@ +// Real image validation, without provider credentials or a model call. Live conversational +// acceptance remains separate in TEST-PLAN.md. All CLI execution occurs inside a test container. +import test from "node:test"; +import assert from "node:assert/strict"; +import { randomBytes } from "node:crypto"; +import { ensureTestEnv } from "./helpers.js"; +ensureTestEnv(); +const live = process.env.CG_LIVE_PLUGIN_CONTAINER === "1"; + +test("live: compiled plugins validate and their MCP server works inside the channel image", { skip: !live, timeout: 120_000 }, async () => { + const { __setContainerRuntime, __resetContainerRuntime, bootContainerRuntime, stopContainerRuntime, containerBackend } = await import("../src/runtimes/container/index.js"); + const { resolveRuntime } = await import("../src/runtimes/resolve.js"); + const { importSkillTree } = await import("../src/gateway/skills/import-folder.js"); + const { createRunGrantArtifacts } = await import("../src/gateway/run-grant-artifacts.js"); + const settings = { enabled: true, defaultBackend: "container", cli: "auto", image: process.env.CG_LIVE_IMAGE || "channelgate/runtime:latest", idleMinutes: 10, maxRunning: 8, pidsLimit: 1024, memory: "", cpus: "", hasClaudeOauthToken: true }; + const runtime = __setContainerRuntime({ log: () => {} }); + const boot = await bootContainerRuntime({ settings, log: () => {} }); + stopContainerRuntime(); + assert.equal(boot.cli.ok, true, "container CLI required for opted-in test"); + assert.equal(boot.image.present, true, "built channel image required for opted-in test"); + const slug = `cg-plugin-${randomBytes(4).toString("hex")}`; + const meta = { platform: "slack", channelId: "C-PLUGIN-FIXTURE", allowBash: true, adminMode: true }; + const target = resolveRuntime(slug, meta, { settings }); + let artifacts; + try { + const imported = await importSkillTree(new URL("./fixtures/plugins/", import.meta.url).pathname); + assert.deepEqual(imported.errors, []); + artifacts = await createRunGrantArtifacts({ slug, meta, sharedSkills: ["fixture-portable-plugin", "fixture-native-plugin"], allowBypass: true, target }); + assert.equal(artifacts.pluginRuntime.claude.error, undefined); + await containerBackend.ensureUp(target, {}); + const exec = async (args) => { + const result = await runtime.exec.runExec(target, [target.container.name, ...args], { retry: false, timeoutMs: 60_000 }); + assert.equal(result.code, 0, String(result.stderr || result.stdout)); + return String(result.stdout || ""); + }; + for (const directory of artifacts.pluginRuntime.claude.dirs) { + const out = await exec(["claude", "plugin", "validate", directory]); + assert.match(out, /valid|passed/i); + } + const server = artifacts.pluginRuntime.claude.servers[0].definition; + const requests = [ + { jsonrpc: "2.0", id: 1, method: "initialize", params: { protocolVersion: "2024-11-05", capabilities: {}, clientInfo: { name: "fixture", version: "1" } } }, + { jsonrpc: "2.0", id: 2, method: "tools/list", params: {} }, + { jsonrpc: "2.0", id: 3, method: "tools/call", params: { name: "plugin_echo", arguments: { value: "CONTAINER-42" } } }, + ]; + const code = `const {execFileSync}=require('node:child_process');process.stdout.write(execFileSync(${JSON.stringify(server.command)},${JSON.stringify(server.args)},{input:${JSON.stringify(requests.map((r) => JSON.stringify(r)).join("\n") + "\n")}}))`; + assert.match(await exec(["node", "-e", code]), /PLUGIN-ECHO:CONTAINER-42/); + } finally { + await artifacts?.cleanup(); + await containerBackend.destroy(target, { volumes: true }); + __resetContainerRuntime(); + } +}); diff --git a/test/plugin-grant-integration.test.js b/test/plugin-grant-integration.test.js new file mode 100644 index 0000000..7dd044f --- /dev/null +++ b/test/plugin-grant-integration.test.js @@ -0,0 +1,96 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { access, readFile, writeFile } from "node:fs/promises"; +import path from "node:path"; +import { ensureTestEnv } from "./helpers.js"; +ensureTestEnv(); +const { createRunGrantArtifacts } = await import("../src/gateway/run-grant-artifacts.js"); +const { buildEngineMcpRuntime } = await import("../src/gateway/run-engine-mcp.js"); +const { putSkillRevision, approveRevision, pinSkill } = await import("../src/gateway/skills/catalog.js"); +const { buildPluginSkill } = await import("../src/gateway/skills/plugin-package.js"); +const { createFakeRuntimeBackend, fakeTarget } = await import("./runtime-fake.js"); +const backend = createFakeRuntimeBackend(); +const meta = { platform: "slack", allowBash: true }; +const artifacts = (slug, sharedSkills = [], userSkills = []) => createRunGrantArtifacts({ slug, meta, sharedSkills, userSkills, target: fakeTarget(backend, slug, meta) }); +const packageFiles = (name, version = "one") => buildPluginSkill([ + { path: ".claude-plugin/plugin.json", content: JSON.stringify({ name, description: "Granted integration package" }) }, + { path: "skills/example/SKILL.md", content: `---\nname: example\ndescription: Package ${version}\n---\n${version}\n` }, + { path: ".mcp.json", content: JSON.stringify({ mcpServers: { lookup: { type: "http", url: "https://example.com/mcp" } } }) }, +]); + +test("approval, package revision updates, pin rollback and revocation control both engine artifacts", async (t) => { + const slug = "pkg-lifecycle"; + const first = putSkillRevision({ slug, files: packageFiles(slug), status: "staged" }); + const runs = []; + t.after(async () => { for (const run of runs) await run.cleanup(); }); + const launch = async (grants = [slug]) => { const run = await artifacts("pkg-channel", grants); runs.push(run); return run; }; + const staged = await launch(); + assert.deepEqual(staged.pluginRuntime.claude.dirs, []); + assert.deepEqual(staged.pluginRuntime.codex.skills, []); + approveRevision(first.revision.id); + const approved = await launch(); + assert.equal(approved.pluginRuntime.claude.dirs.length, 1); + assert.equal(approved.pluginRuntime.codex.skills.length, 1); + assert.equal(approved.pluginRuntime.codex.servers.length, 1); + assert.match(await readFile(approved.pluginRuntime.codex.skills[0].path, "utf8"), /one/); + const native = JSON.parse(await readFile(path.join(approved.pluginRuntime.claude.dirs[0], ".claude-plugin/plugin.json"), "utf8")); + assert.equal(native.mcpServers, undefined, "native discovery cannot bypass the selected MCP runtime"); + const settings = JSON.parse(await readFile(approved.settingsFile, "utf8")); + assert.ok(settings.allowedMcpServers.some((s) => s.serverName === approved.pluginRuntime.claude.servers[0].name)); + const mcp = await buildEngineMcpRuntime({ engine: "claude", target: fakeTarget(backend, "pkg-channel", meta), pluginRuntime: approved.pluginRuntime, slug: "pkg-channel", channelId: "C_PACKAGE", authorId: "U_PACKAGE", threadKey: "C_PACKAGE:123", origin: "slack_foreground" }); + assert.equal(JSON.parse(mcp.mcpConfigJson).mcpServers[approved.pluginRuntime.claude.servers[0].name].url, "https://example.com/mcp"); + putSkillRevision({ slug, files: packageFiles(slug, "two") }); + const updated = await launch(); + assert.notEqual(updated.pluginRuntime.claude.dirs[0], approved.pluginRuntime.claude.dirs[0]); + assert.match(await readFile(updated.pluginRuntime.codex.skills[0].path, "utf8"), /two/); + pinSkill(slug, first.revision.revisionNo); + const rollback = await launch(); + assert.equal(rollback.pluginRuntime.claude.dirs[0], approved.pluginRuntime.claude.dirs[0]); + const revoked = await launch([]); + assert.deepEqual(revoked.pluginRuntime.claude.dirs, []); + assert.deepEqual(revoked.pluginRuntime.codex.skills, []); + assert.deepEqual(revoked.pluginRuntime.codex.servers, []); +}); + +test("personal packages stay in ephemeral run artifacts and channel targets use separate paths", async (t) => { + const slug = "pkg-private"; + putSkillRevision({ slug, files: packageFiles(slug), visibility: "personal", createdBy: "U_PRIVATE" }); + const personal = await artifacts("pkg-author-channel", [], [slug]); + const other = await artifacts("pkg-author-channel"); + const channelB = await artifacts("pkg-other-channel", [], [slug]); + t.after(async () => { await personal.cleanup(); await other.cleanup(); await channelB.cleanup(); }); + assert.equal(personal.claudePluginEphemeral, true); + assert.match(personal.pluginRuntime.claude.dirs[0], /\/runs\/grants-/); + assert.deepEqual(other.pluginRuntime.claude.dirs, []); + assert.deepEqual(other.pluginRuntime.codex.skills, []); + assert.notEqual(personal.artifactRoot, channelB.artifactRoot); + for (const run of [personal, channelB]) for (const engine of ["claude", "codex"]) { + for (const skill of run.pluginRuntime[engine].skills) assert.ok(skill.path.startsWith(`${run.artifactRoot}/`)); + } + const personalPath = personal.pluginRuntime.codex.skills[0].path; + await personal.cleanup(); + await assert.rejects(access(personalPath), { code: "ENOENT" }); +}); + +test("tampered compiled package bytes are restored from the approved revision", async (t) => { + const slug = "pkg-tamper"; + putSkillRevision({ slug, files: packageFiles(slug) }); + const original = await artifacts("pkg-tamper-channel", [slug]); + t.after(() => original.cleanup()); + const file = original.pluginRuntime.codex.skills[0].path; + const expected = await readFile(file, "utf8"); + await writeFile(file, "tampered instructions"); + const next = await artifacts("pkg-tamper-channel", [slug]); + t.after(() => next.cleanup()); + assert.equal(next.pluginRuntime.codex.error, undefined); + assert.equal(await readFile(next.pluginRuntime.codex.skills[0].path, "utf8"), expected); +}); + +test("two catalog packages cannot collide in the native plugin namespace", async (t) => { + putSkillRevision({ slug: "collision-one", files: packageFiles("shared-native-name") }); + putSkillRevision({ slug: "collision-two", files: packageFiles("shared-native-name") }); + const run = await artifacts("pkg-collision-channel", ["collision-one", "collision-two"]); + t.after(() => run.cleanup()); + assert.match(run.pluginRuntime.claude.error, /same plugin name/); + assert.deepEqual(run.pluginRuntime.claude.dirs, []); +}); diff --git a/test/plugin-runtime.test.js b/test/plugin-runtime.test.js new file mode 100644 index 0000000..97f5433 --- /dev/null +++ b/test/plugin-runtime.test.js @@ -0,0 +1,122 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { ensureTestEnv } from "./helpers.js"; +ensureTestEnv(); +const { buildPluginSkill, parsePluginPackage } = await import("../src/gateway/skills/plugin-package.js"); +const { compilePluginPackage, pluginServerName, relocatePluginServers, pluginSkillCatalog, requirePluginRuntime } = await import("../src/gateway/plugin-runtime.js"); + +const claude = { manifest: "claude", components: ["skills", "commands", "agents", "hooks", "mcpServers"] }; +const codex = { manifest: "", components: ["skills", "mcpServers"] }; +const skill = "---\nname: proof\ndescription: Synthetic package proof\n---\nUse the proof fixture."; +function pkg(manifest = {}, extra = []) { + return { slug: "package-proof", descriptor: parsePluginPackage(buildPluginSkill([ + { path: ".claude-plugin/plugin.json", content: JSON.stringify({ name: "package-proof", ...manifest }) }, + { path: "skills/proof/SKILL.md", content: skill }, ...extra, + ])) }; +} +function compile(manifest = {}, extra = [], options = {}) { + return compilePluginPackage(pkg(manifest, extra), { capabilities: claude, writable: true, ...options }); +} +const file = (path, value) => ({ path, content: JSON.stringify(value) }); + +test("approved package compiler keeps native components and restricts skill catalogs to declared roots", () => { + const compiled = compile({}, [{ path: "docs/SKILL.md", content: skill }]); + assert.equal(compiled.native, true); + const native = JSON.parse(compiled.files.find((f) => f.path === ".claude-plugin/plugin.json").content); + assert.deepEqual(native.skills, ["./skills"]); + assert.equal(native.mcpServers, undefined); + assert.deepEqual(pluginSkillCatalog(compiled, "/artifact/package").map((s) => s.path), ["/artifact/package/skills/proof/SKILL.md"]); + const portable = compile({}, [], { capabilities: codex }); + assert.equal(portable.native, false); + assert.equal(portable.files.some((f) => f.path.endsWith("plugin.json")), false); + assert.equal(portable.skillFiles[0].name, "package-proof:proof"); +}); + +test("native agents enumerate Markdown files and cannot shadow the gateway plugin namespace", () => { + const compiled = compile({}, [{ path: "agents/inspector.md", content: "Inspect the fixture." }]); + const native = JSON.parse(compiled.files.find((f) => f.path === ".claude-plugin/plugin.json").content); + assert.deepEqual(native.agents, ["./agents/inspector.md"]); + assert.throws(() => compile({ name: "gateway-shared-skills" }), /reserved by the gateway/); + const custom = compile({ mcpServers: "./config/tools.json" }, [file("config/tools.json", { mcpServers: { fixture: { url: "https://example.test/mcp" } } })]); + assert.equal(custom.files.some((f) => f.path === "config/tools.json"), false, "source control files cannot activate ambient MCPs"); +}); + +test("hooks require live admin bypass and are declared once without implicit hook files", () => { + const hooks = { hooks: { Stop: [{ hooks: [{ type: "command", command: "node proof.js" }] }] } }; + const files = [file("hooks/hooks.json", hooks)]; + assert.throws(() => compile({}, files), /hooks require/); + assert.throws(() => compile({}, files, { capabilities: codex, allowBypass: true }), /hooks are unsupported/); + const compiled = compile({}, files, { allowBypass: true }); + assert.equal(compiled.files.some((f) => f.path === "hooks/hooks.json"), false); + const native = JSON.parse(compiled.files.find((f) => f.path === ".claude-plugin/plugin.json").content); + assert.deepEqual(native.hooks, hooks); + assert.throws(() => compile({ hooks: { Stop: "bad" } }, [], { allowBypass: true }), /invalid hook event/); +}); + +test("unsupported components and ambient settings fail closed", () => { + for (const [component, path] of [["commands", "commands/proof.md"], ["agents", "agents/proof.md"]]) { + assert.throws(() => compile({}, [{ path, content: "proof" }], { capabilities: codex }), new RegExp(`${component} are unsupported`)); + } + for (const [component, path] of [["apps", ".apps.json"], ["lspServers", ".lsp.json"]]) { + assert.throws(() => compile({}, [file(path, {})]), new RegExp(`${component} are unsupported`)); + } + for (const path of ["settings.json", ".claude/settings.json", ".codex/config.toml"]) { + assert.throws(() => compile({}, [{ path, content: "{}" }]), /settings overrides are unsupported/); + } + assert.throws(() => compile({ settings: { permissions: {} } }), /settings overrides are unsupported/); +}); + +test("MCP servers are namespaced, removed from native discovery, and refuse duplicate names", () => { + const result = compile({ mcpServers: { proof: { type: "http", url: "https://example.test/mcp" } } }); + assert.equal(result.servers[0].name, pluginServerName("package-proof", "proof")); + assert.notEqual(pluginServerName("a-b", "proof"), pluginServerName("a_b", "proof")); + assert.notEqual(pluginServerName("package-proof", "proof"), pluginServerName("package-proof", "other")); + const native = JSON.parse(result.files.find((f) => f.path === ".claude-plugin/plugin.json").content); + assert.equal(native.mcpServers, undefined); + const external = compile({}, [file(".mcp.json", { mcpServers: { proof: { url: "https://example.test/mcp" } } })]); + assert.equal(external.files.some((f) => f.path === ".mcp.json"), false); + assert.throws(() => compile({ mcpServers: { proof: { url: "https://example.test/mcp" } } }, + [file(".mcp.json", { proof: { url: "https://example.test/other" } })]), /duplicate MCP/); +}); + +test("MCP executable transports require writable mode and expand only package root placeholders", () => { + const definition = { command: "node", args: ["${CLAUDE_PLUGIN_ROOT}/server.js", "--label", "reviewed fixture"] }; + assert.throws(() => compile({ mcpServers: { proof: definition } }, [], { writable: false }), /Worker or Full-access/); + const compiled = compile({ mcpServers: { proof: definition } }); + const relocated = relocatePluginServers(compiled.servers, "/artifact/package"); + assert.deepEqual(relocated[0].definition.args, ["/artifact/package/server.js", "--label", "reviewed fixture"]); + assert.equal(compiled.servers[0].definition.args[0], "${CLAUDE_PLUGIN_ROOT}/server.js"); + assert.throws(() => relocatePluginServers([{ plugin: "proof", sourceName: "proof", definition: { command: "$HOME/server" } }], "/artifact/package"), /separately configured connection/); +}); + +test("source authentication, invalid transports, and ambiguous definitions require selected connections", () => { + const definitions = [ + { url: "https://example.test/mcp", headers: { Authorization: "Bearer fixture" } }, + { url: "https://example.test/mcp", bearer_token_env_var: "TOKEN" }, + { url: "https://example.test/mcp?token=fixture" }, + { url: "https://example.test/mcp#fixture" }, + { url: "https://fixture:fixture@example.test/mcp" }, + { url: "file:///tmp/server" }, + { url: "https://example.test/${TOKEN}" }, + { url: "https://example.test/mcp", type: "sse" }, + { url: "https://example.test/mcp", command: "node" }, + { command: "node", env: { TOKEN: "fixture" } }, + { command: "node", args: ["server.js", "--api-key=fixture"] }, + { command: "node", args: ["server.js", "--token", "fixture"] }, + { command: "node", args: ["${TOKEN}"] }, + { command: "node", args: [123] }, + { command: "node", args: "server.js" }, + { command: "node", args: ["bad\nvalue"] }, + { command: "node", transport: { command: "other" } }, + null, [], "https://example.test/mcp", + ]; + for (const definition of definitions) { + const compiled = compile({ mcpServers: { proof: definition } }); + assert.equal(compiled.servers[0].definition, null, JSON.stringify(definition)); + } +}); + +test("runtime compiler failures propagate rather than silently loading partial packages", () => { + assert.throws(() => requirePluginRuntime({ codex: { error: "unsupported package" } }, "codex"), /unsupported package/); + assert.deepEqual(requirePluginRuntime(null, "codex"), { dirs: [], skills: [], servers: [] }); +}); diff --git a/test/plugin-summary.test.js b/test/plugin-summary.test.js new file mode 100644 index 0000000..e2c42f0 --- /dev/null +++ b/test/plugin-summary.test.js @@ -0,0 +1,32 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { pluginBadge, pluginSummary } from "../public/plugin-summary.js"; + +test("plugin review shows component paths and declared engines without exposing raw connection config", () => { + const item = { plugin: { kind: "plugin", engines: ["claude", "codex", "