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..b369d23 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -178,6 +178,7 @@ and the store modules; never read the tables from a gated folder. — one-time import of the pre-SQLite JSON/JSONL into the tables (guarded by a `_meta` flag; old files are left on disk as inert backups). - Tables: `users`, `channels`, `channel_meta`, `sessions`, `schedules`, `acks`, + `conversation_reply_sessions` (conversation-scoped user/bot message IDs → group session roots), `followup_threads` + `followup_done`, `bg_jobs` (config-shaped rows keep their full record in a JSON `data` blob so every field survives + no migration for new fields); `usage` and `events` (the dashboard data — fully typed, indexed columns for day/week/month/channel/user rollups). @@ -251,7 +252,10 @@ Config that stays as **JSON files** (read wholesale / bootstrap, hand-editable): - **Composio identities stay distinct.** Inject the active author's token as `composio-user` and independently inject shared `composio` from the channel token, falling back to the organization token. A channel folder never stores either token in plain settings. “My account” means - `composio-user`; “your/channel account” means `composio`; ambiguous identity must be clarified. + `composio-user`; “your/channel account” means `composio`. Reads/searches may use either or both + unless the user restricts the account or scope. Writes/sends/state changes require the intended + identity and connected account; reuse established choices and clarify only unresolved mutation + identity. Independent authorized reads can continue while that clarification is pending. - **Composio is the only external Slack MCP.** There is NO hosted per-user Slack MCP and no `connect_slack`/`xoxp` OAuth (removed). Slack actions beyond the gateway's own bot tools — cross-channel search, sending elsewhere, scheduled sends, canvases, reactions — go through the @@ -328,7 +332,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/CHANGELOG.md b/CHANGELOG.md index 613150c..8bf592a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,40 @@ # Changelog — ChannelGate +- Reply in Slack when conflicting shared-folder skill or memory settings prevent a request + from starting, with admin repair instructions instead of silently logging the error. + +- Put Remove and Add/update directly on Slack Settings → Secrets. Refresh the same page after + deletion, and return saved entry forms to their parent without duplicate lists or modal stack growth. + +- 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. + +- 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. + +- Preserve existing channel guest grants when an unrelated Admin UI setting is saved, even if + the current member roster omits a saved guest. Explicit guest edits retain membership validation. + +- Include received human approval in completed control-plane tool results while preserving the + operation outcome; clarify that approval cards can be outside the model transcript and that + the receipt records an explicit human decision, not an approval supplied by channel Auto mode. + +- Keep subsequent turns working after an author deletes a personal skill, while retaining its + grant and revision history for restoration and refusing foreign personal skill access. + +- Correct skill-creation approval descriptions to show personal versus shared ownership, + optional channel grants and automatic personal/channel-scope activation accurately. + +- Keep text-only gateway commands on their control path after thread file sharing, while + preserving canonical current attachments and ordinary agent attachment recovery. + +- Reject incomplete engine turns even when partial text exists; preserve failure diagnostics and + usage. Reserve restart recovery before Slack reconnects, show each recovering thread promptly, + and retry pending recoveries on reconnection without requiring another daemon restart. + - Fix one-time automation edits shifting by the browser/daemon timezone difference and potentially firing future tasks immediately. ChannelGate was formerly *Claude Gateway for Slack*; entries below the rename keep their original @@ -18,7 +53,52 @@ 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 + +- Manage plugin packages through existing skill sources, review, templates and channel grants. + Compile native Claude hooks as an inline event map so SessionStart hooks execute correctly; + keep executable hooks restricted to authorized live admin turns. + +- Fix repeated native file creation and inline editing exhausting Slack’s modal stack. Successful + forms return to the existing browser/preview; Cancel and validation retain their prior behavior. + +- Fix the first browser editor/upload opening from Slack: narrowly scoped SameSite=Lax session + cookies survive the cross-site GET redirect while preserving CSRF, authorization and cookie + confidentiality protections. + +- Correct the bundled skill-authoring workflow: updates and change proposals accept only changed + files while retaining omitted files; complete packages are required for initial creation. + +- Correct Composio guidance that assumed the agent identity always belonged to other people or + was shared across every channel. Preserve account selection and no-substitution rules, including + when personal and agent connections reach the same underlying service owner. + +- Fix missing run identities in flat chats. Group-chat messages start separate sessions; quoting + user or bot messages resumes the original session, including after restart. Teams quotes support + both current entities and legacy Reply markup; personal chats remain continuous. + +- Make skill assignment rows compact, reveal descriptions and metadata on click, and give Active + skills and Add skills matching fixed heights with preserved scroll position when managing skills. + +- Make existing channel API/CLI credentials discoverable through a current names-only inventory + on non-Clean attempts, including resumed turns. Teach the operating guide to check credentials + alongside skills, CLI logins and selected-account MCPs before requesting another connection; + clarify runtime environment use without exposing values or silently changing accounts. + +- 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. + +- 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. - 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 +156,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 +172,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/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..3712910 100644 --- a/FEATURES.md +++ b/FEATURES.md @@ -1,5 +1,112 @@ # ChannelGate — Features +## Plugin packages through skill sources + +Git and folder skill sources recognize Claude/Codex plugin manifests and store a complete package +as one revision. Existing Review, rollback, templates, organization/channel/personal grants, and +source sync govern the package. Catalog and assignment pickers show Plugin badges and safe +component summaries. Claude loads approved native skills/commands/agents and admin-gated hooks; +compiled inline hooks use Claude's event map so approved hooks execute instead of being ignored. +Codex receives approved skill catalogs. Both receive supported MCP transports through explicit +namespaced grants. Unsupported components/authentication produce actionable failures. Compiled +files stay inside the channel container's artifact mounts; updates rotate warm snapshots and +revocation removes future runtime grants. Source path/symlink/size validation retains last-good +revisions on failed sync. Details and compatibility limits: `docs/SKILLS.md`. + + +- **Slack reports shared-folder conflicts:** an authorized mention or typed command that cannot + start because channels share a folder with conflicting skills or memory settings gets a reply + in its triggering thread. The reply asks an admin to assign separate folders or align settings, + then retry. It exposes no other channel names or filesystem paths. The conflict still blocks + workspace writes and engine startup. → TEST-PLAN: Slack shared-folder conflict replies. + +- **Read/search account routing:** unrestricted information requests may search either or both + available Composio identities without an account-selection interruption. Explicit account and + record restrictions remain binding. Writes, sends and other state changes require the intended + identity and connected account, reusing prior choices; unresolved mutation identity asks while + independent reads can continue. The bundled guide, managed instructions and fresh/resumed + prompts carry the same rule for Claude and Codex. → TEST-PLAN: Read/search account routing. + +## 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. + This remains advisory policy, not container egress enforcement; other tool restrictions still + apply. The added fact contains no credential inventory or values. → TEST-PLAN: Network policy on resumed turns. + +- **Control-plane results report received human approval:** a tool that awaited an approving + decision returns a receipt alongside its original outcome, including returned refusals or error + results after approval. The receipt identifies neither an actor nor a particular approval UI and never claims + the mutation succeeded. It explicitly distinguishes the recorded human decision from channel + Auto mode, which cannot supply this approval. Denied, pending durable and explicitly exempt calls retain their distinct + results. Guidance warns that cards outside the model transcript are not evidence of a bypass. + → TEST-PLAN: Control-plane approval receipts. + +- **Deleting a personal skill does not strand its author:** each trusted turn omits catalog-proven + tombstones for that author's locally created personal skills. Stored grants and revision history + remain intact for restoration; restored skills become active again on the next turn. Unknown, + unavailable and unapproved grants still fail visibly when materialized. Foreign personal skills, + including dependencies, are refused rather than exposed or treated as the author's deletions. + → TEST-PLAN: Deleted personal skill grants. + +- **Skill approval cards describe the requested ownership and grants:** personal creation + previews its automatic personal grant; shared-library creation distinguishes a channel grant + from no grant; channel-scoped creation states its automatic activation here. Invalid personal + channel scope is identified without promising a creation. The existing approval, ownership, + publication and grant rules remain unchanged. → TEST-PLAN: Skill creation approval details. + +- **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 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. + +- **Accurate Composio ownership guidance:** logical personal/agent identities describe routing, + not proof of a connected service owner's identity or universal sharing across channels. Managed + instructions and each attempt's identity preamble preserve explicit personal/shared routing and + the ambiguity stop even when both connections reach the same service owner. The guide explains + channel-specific identities and optional organization-default reuse without exposing credentials. + → TEST-PLAN: Composio ownership and channel scope. + +- **Truthful completion and visible restart recovery:** explicit Claude/Codex execution failures + remain failures even after partial output; missing terminal completion cannot masquerade as a + finished answer. Partial diagnostics and spent usage are retained, and ambiguous failed work is + not automatically replayed. Restart survivors reserve their thread order before Slack connects, + receive progress together, and use the normal global run limit. New messages use the existing + queue/steer controls; reconnecting Slack retries pending recovery without another daemon restart. + → TEST-PLAN: Incomplete turns and restart recovery. + +- **Discoverable channel credentials:** each non-Clean conversation or background-agent attempt receives a current, + sorted inventory of channel environment variable names actually supplied to that attempt, + including resumed turns and explicit empty inventories after removal. Values and suffixes + stay out of the prompt; Clean runs omit the inventory. The operating guide checks these names, + relevant skills, CLI authentication and selected-account MCP tools before requesting access, + while preserving account selection, tool permissions and network policy. Channel secrets are + injected into process environment, not the project's `.env`; CLI authentication may need an + explicit option or header. Operator guide overrides retain their precedence. + → TEST-PLAN: Channel credential discovery. + +- **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 @@ -141,7 +248,15 @@ 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. + Compact rows keep name, slug and source inline, with descriptions hidden until the skill is + clicked (or expanded by keyboard). Both panels have matching fixed heights and one scrollbar + each; add/remove retains list position. Narrow screens keep the source in expanded details. + 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 @@ -150,8 +265,14 @@ 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 - add/update/remove manager. Inherited and template grants are labelled and cannot be removed from + 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; both combined lists explicitly + explain that their summaries update automatically when their source groups change. Skills + supports direct grants and live template assignment; Secrets lists all supported variables with + a confirmed Remove button on each row and Add/update directly on the tab. Entry forms return + to the refreshed parent tab after saving, without duplicate manager pages or growing modal stacks. + Deletion persists and refreshes the same page without waiting for folder/skill provisioning. 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 re-check current channel membership plus agent authorization, including explicit channel guests, @@ -237,6 +358,72 @@ A categorized catalog of what's shipped. Cross-linked to `TEST-PLAN.md` checks. ## Google Chat and Microsoft Teams transports (Beta) +### Teams event and control additions (teams-ms branch; not deployed) + +- Native signed Bot Framework edit events (`messageUpdate` / `editMessage`) preserve message + session identity and require an actual bot mention outside DMs. Robot reaction additions + (`🤖`, `robot`, `robot_face`) act as the reactor, with normal user/channel authorization; + removal and unrelated reactions do nothing. Native reaction coverage is limited by Microsoft's + bot event delivery; reacting to arbitrary user messages requires the separate Graph feed. +- Optional Graph subscriptions observe only configured installed conversations, fetch messages + using daemon-owned credentials, validate subscription/tenant/client-state/resource scope, and + persist event work before dispatch. Subscription renewal and restart recovery are daemon-owned. + When enabled, Graph owns group/channel edit/reaction dispatch while Bot Framework keeps new + messages, attachment delivery, and personal-chat edits/reactions. Graph events older than 24 hours + are ignored to prevent historical replay after deduplication retention; uninstall retires the + subscription and invalidates queued work. This does not grant tenant-wide observation or auto-approve any author. +- Portable text controls provide `/help`, `/status`, `/stop` (`/cancel`), `/clear`, + `/model [engine] [model|default]`, and `/effort [level|default]`. Quote the original message or + bot reply and include the bot mention to control an existing flat group session. Unquoted + group commands refer to their own new session. Runtime changes use the existing channel admin + policy (approved personal chats retain their own runtime choices); stop/clear cannot cancel + another person's active or queued work unless the requester is an administrator. +- Work in one session queues with a visible position; unrelated sessions remain independent. + Clear aborts active/queued work, waits for completion, and fences late session saves. Progress + 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 in the source channel thread, group chat or DM, with submissions restricted + to the requesting user and current runtime policy. `/secrets` privately 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. + + +- Flat DMs use their qualified conversation ID as a nonempty engine session key. In flat group + chats, each new message starts a separate session; quoting a user message, follow-up, or bot + reply resumes its original session. Reply-to-session mappings persist across restarts and are + scoped to the conversation. Teams recognizes current quotedReply entities and legacy Skype + Reply blockquotes. Native channel threads are preserved, and flat replies stay flat. Group + messages still require a bot mention, including quoted replies. + - **Google Chat runs outbound-only**, like Slack: a Cloud Pub/Sub PULL subscription consumes the events Google publishes for the Chat app, so there is no inbound endpoint and no tunnel. The pull loop long-polls, backs off with full jitter, acks before dispatching (a turn outlives any ack @@ -276,7 +463,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 @@ -415,11 +604,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 @@ -448,11 +636,19 @@ A categorized catalog of what's shipped. Cross-linked to `TEST-PLAN.md` checks. Protected/internal paths remain read-only in the explorer, and forged action state cannot escape the working root. In writable modes, *New file* exclusively creates one safely named UTF-8 file with optional initial text and audits `channel_file_created`; *New folder* creates one safely - named directory in the folder on screen. Public URL settings accept either a complete URL or a + named directory in the folder on screen. Native create and inline-edit forms pop after successful + submission and refresh the existing browser/preview, so repeated operations do not exhaust Slack’s + modal stack. Cancel returns to the untouched parent; invalid inputs and stale edit hashes keep the + form open. Post-acknowledgement failures refresh the surviving parent rather than a closed form. + Public URL settings accept either a complete URL or a bare hostname, which is normalized to HTTPS. When a public URL is configured, the single *Upload files / folder* button opens a one-time, user-bound browser session for up to 200 files / 250 MB total and preserves nested folder paths (browser pickers omit empty folders). Selected files travel directly from - the browser to the gateway folder and are never staged in Slack file storage. These flows + the browser to the gateway folder and are never staged in Slack file storage. Editor/upload + sessions open directly from Slack on the first cross-site navigation: explicit SameSite=Lax + cookies support the top-level GET redirect, with HttpOnly, HTTPS Secure, per-session path and + expiry unchanged. Cross-site POSTs do not receive those cookies; every write still requires its + session's CSRF header and current channel authorization. These flows refuse protected/traversal names, never overwrite collisions, and audit every result. Read-only mode hides write controls and Full remains admin-only. With a Public URL configured, every file preview also offers *Download*: a requester-bound, 10-minute, single-use URL rechecks channel @@ -473,8 +669,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 @@ -658,7 +854,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. @@ -1067,7 +1263,9 @@ A categorized catalog of what's shipped. Cross-linked to `TEST-PLAN.md` checks. marker lives in the database, so a daemon restarted hourly cannot spam. A class change (expiring → missing) is news the same day, and a login that goes healthy again clears the class so the next expiry notifies afresh. One unreachable admin never costs the others their alert, and a tick that - reached nobody stays due. No token material ever rides the DM. → TEST-PLAN: Engines. + reached nobody stays due. Disabling Claude suppresses credential resolution, watch warnings and + admin DMs on the next tick, without restart; it clears the reminder marker so re-enabling Claude + reports an unresolved login again. No token material ever rides the DM. → TEST-PLAN: Engines. - **Codex sign-in is detected before, during, and after a turn** — a lost credential can no longer present as a hang. Before spawn, the runner reads the same `auth.json` the CLI reads (the stable engine `CODEX_HOME` first, the host state dir it is linked from second) and turns a signed-out @@ -1499,7 +1697,11 @@ A categorized catalog of what's shipped. Cross-linked to `TEST-PLAN.md` checks. Slack's bulk user directory with targeted Slack Connect fallbacks; saves resolve profiles only for submitted current candidates. If Slack is disconnected or its roster cannot be read, guest editing disables without falling back to the org directory or clearing existing grants, while - unrelated channel settings remain saveable. + unrelated channel settings remain saveable. Even a successful roster can omit a saved guest: + Network, Lean and other unrelated saves omit `allowedUsers` until the operator edits the guest + checklist. A successful save clears that pending edit; a failed save retains it for retry. + Explicit guest edits still replace the list through current-member validation, and inherited + approved access stays visibly checked after a save. → TEST-PLAN: Channel access model; Admin UI. - Interactive permission approvals in Slack: a non-admin run that hits a non-allowlisted tool posts buttons in the thread — **Approve once**, **Approve for this thread**, **Approve forever** (persisted @@ -2212,8 +2414,25 @@ 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` + invocation. An operator's configured runtime root must never turn a fixture into a live backup, + restore, or log rotation. → TEST-PLAN: Update regression path isolation. +- **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 @@ -2228,7 +2447,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, @@ -2378,6 +2597,9 @@ are retired, bullet by bullet; everything else stands. skill becomes a **pinned local override** so the source keeps flowing and the pin holds until unpinned; an approved promotion adds the skill to the organization tier), `skill_usage_report`, `sync_skill_sources`. Documented for the model in `gateway-usage` → `references/skills.md`. + The bundled `skill-authoring` workflow distinguishes the complete initial package from partial + update/proposal input: omitted files remain, and `update_skill.remove` explicitly deletes files. + Every resulting revision remains a complete immutable package. → TEST-PLAN: Skills platform (Core). - **Usage telemetry** (`usage.js`, on the run event stream in `run.js`): Claude's `Skill` tool call is an **exact** signal (the stream parser now names the skill as the tool target); a Codex/shell @@ -2845,3 +3067,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/INSTALL.md b/INSTALL.md index b4bb2d7..978f99c 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,16 +124,22 @@ 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) +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/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 5789109..5f10f92 100644 --- a/TEST-PLAN.md +++ b/TEST-PLAN.md @@ -1,5 +1,557 @@ # ChannelGate — Test Plan +## Plugin source packages — acceptance gates + +Automated coverage: `test/skills-plugin-import.test.js`, `test/plugin-runtime.test.js`, +`test/plugin-grant-integration.test.js`, `test/plugin-summary.test.js`, plus the skills admin API, +assignment picker, Codex argv, and run MCP suites. These prove package bytes, revision approval, +metadata sanitization, isolation and dispatch construction; they do not constitute live engine +acceptance. `CG_LIVE_PLUGIN_CONTAINER=1 node --test test/plugin-container.live.test.js` validates +compiled Claude manifests and a real stdio MCP echo inside a disposable channel container, without +provider credentials. This container check passed during development. The following conversational +live gates remain UNEXECUTED until observed on the tested candidate. + +**PLUG-01 — import, review, template, update, rollback, revoke (Claude and Codex separately).** +Copy `test/fixtures/plugins/portable/` to a disposable host source directory. Use two empty Worker +conversations with distinct work folders, one pinned to Claude and one to Codex; use a third +ungranted conversation as a negative control. Add the directory through the admin API (`POST /api/skills/sources` with +`{"kind":"folder","url":"/absolute/disposable/source","mode":"review"}`), then open Skills → +Sources and sync it. Expect one Plugin item `fixture-portable-plugin`, no standalone `plugin-marker` item, and one +staged revision. Put the package into a disposable template and assign the two conversations. +Before approval, ask “Read the plugin acceptance marker from the granted plugin.” The package +must be unavailable. Approve revision 1 and repeat: require an observed read of the bundled +`marker.txt` and the answer `PLUGIN-REVISION-ONE` on each engine. The negative control must have +neither the package's injected catalog nor its MCP server. Change the source marker to +`PLUGIN-REVISION-TWO`, sync, and verify both still receive ONE until approval. Approve, verify +TWO and a changed Claude warm fingerprint; pin revision 1 and verify ONE. Remove the template grant +and send another message in the same threads: require absence of package MCP definitions/native +paths/current Codex catalog entries, not merely an assistant claim. Restore fixture settings. + +**PLUG-02 — declared MCP tools and connection policy (Claude and Codex separately).** +Use the approved portable fixture in each Worker conversation. Prompt: “Run plugin echo with the +value ACCEPT-42.” Require an actual namespaced `plugin_echo` call, result +`PLUGIN-ECHO:ACCEPT-42`, and the stdio command resolving inside that conversation's artifact +mount. Repeat in Read-only mode: require a pre-spawn explanation that server commands need +Worker/Full-access, with no server process. Restore Worker; add a synthetic header +`Authorization: Bearer fixture-not-a-secret` to the source MCP definition, sync and approve. +With no separately selected `fixture` connection, require a named connection error and no source +credential in argv or listing summaries. Restore the fixture. Grant it personally to one approved +author, remove shared grants, and verify another author receives neither its skill catalog nor +MCP tools. Verify personal artifact cleanup after the first author's run completes. + +**PLUG-03 — native components and compatibility (both engines, with different expectations).** +Import and approve `test/fixtures/plugins/native/`. Grant it only to disposable test conversations. +As an admin in a Full-access Claude conversation, start a fresh thread and ask “Use the native +plugin marker command.” In a separate fresh thread ask “Ask the plugin-inspector agent for its +own marker; do not invoke the marker command.” Separate threads keep command instructions from +contaminating the agent probe. Require the command +result `PLUGIN-COMMAND`, observed agent invocation/result `PLUGIN-AGENT`, and exactly one new +`PLUGIN-HOOK` line in that conversation's `plugin-hook-marker.txt` per session start. The compiled +manifest's `hooks` property must contain the event map directly; a nested `hooks.hooks` object +is silently ignored by the CLI and is a failure even if manifest validation passes. Repeat as a +non-admin and as an unattended scheduled/background run: require a hook-policy error and no new +line. Pin Codex and repeat: require an explicit unsupported native component error before the +engine starts. Add an apps or LSP declaration to a disposable source revision and approve it: +both engines must refuse with the specific component named. Remove grants and fixture marker files. + +**PLUG-04 — source failure and package review (engine-independent control plane).** +With portable revision 1 approved, independently try malformed manifest JSON, a `../outside` +component path, a symlinked package file, and a file larger than 2 MiB. Each sync must fail without +replacing/tombstoning revision 1 or importing nested skills separately. Repair the source and sync: +recovery must preserve its grant. Inspect catalog/staged/revision-file response summaries: only +component paths and manifest metadata appear; raw MCP header/env objects appear only when an +admin explicitly opens the relevant source file. Test the Git source path and the folder source +path. Remove the disposable source and template after recording evidence. + +For each live gate record candidate commit, engine/model, exact fixture and author role, prompt, +observed tool/process/filesystem evidence, and verdict. A missing, skipped, or blocked live case +is not a pass. Maintain the private QA registry alongside these reusable public instructions. + + +## Slack shared-folder conflict replies + +Automated regression: `node --test test/workspace-conflict-reply.test.js test/skills-workspace-sync.test.js`. +Exercises real registration and conflict detection for skill and memory mismatches, root/thread +routing, mention and typed `/mode`, duplicate delivery, unauthorized authors, Slack delivery failure, +and preservation of a workspace sentinel. No engine starts; this behavior is engine-independent. + +Live release gate — **UNEXECUTED** (engine-independent: registration fails before engine selection). +On a disposable deployment, create two synthetic Slack test channels and register both. Assign both +an empty fixture folder `shared-conflict-fixture`, with identical skills but memory enabled in one +and disabled in the other. Use an approved author and an installed bot in the first channel. +Send `@bot alive?` as a root message, then `@bot /mode` in its thread. Each must receive exactly one +threaded explanation of the shared-folder conflict, a separate-folder/alignment remedy and retry +instruction; no path, other-channel identity, engine run or workspace rewrite may occur. Repeat +with equal memory settings and different shared skill grants. An unapproved, non-guest author must +not receive configuration details. Assign separate folders, retry `@bot /mode`, and require the +normal mode response. Restore the fixture configuration and remove disposable test folders. +Record observed message links and daemon evidence before marking the live case passed. + +## Read/search account routing + +Automated regression: `node --test test/channelgate-skill.test.js test/composio-guide.test.js test/channel-credential-guide.test.js test/composio-identity-preamble.test.js test/folders-generator-paths.test.js`. +Checks materialized guides on all supported surfaces, managed instructions, and actual stub-engine +fresh/resumed prompts for both Claude and Codex. Account ownership is not inferred from routing; +missing explicitly requested identities still refuse substitution. These are instruction-contract +checks, not live proof of model behavior. + +Live release gate — **UNEXECUTED**, run separately with Claude and Codex pinned in an authorized +private test channel. Configure two read-capable Slack identities and distinct synthetic messages: +`ROUTE-PERSONAL-` visible only to personal and `ROUTE-AGENT-` only to agent. +Use a non-admin approved test author and verify connection metadata before fixture creation. + +1. Prompt: “Search available Slack accounts for ROUTE-; find both fixture messages.” + Require tool evidence using both needed identities, exact fixture results/source attribution, + and no account clarification or state-changing calls. +2. In a fresh thread: “Search only my Slack for ROUTE-PERSONAL-.” Require only personal + reads; repeat with personal unavailable and require refusal without agent substitution. +3. In a fresh thread without prior account selection: “Send the fixture summary through a Composio Slack account to this test + channel, and meanwhile find ROUTE-.” Require account clarification before any send; + authorized reads may continue. Do not answer until the absence of writes is verified. +4. Explicitly select the personal identity and its named connected Slack account, authorize one + fixture send, and verify that identity/account. Follow with another explicitly authorized send + in the same thread; require reuse without asking again. Restore/delete test fixtures using + their original writing accounts. + +Pass only when all cases have exact tool identity/account evidence and thread links for each +engine. A prompt string or mock pass alone is not live acceptance. + +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 + +### 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. + +- [x] Automated, engine-independent transport/control tests: + `node --test test/platform-teams-events.test.js test/platform-controls.test.js test/platform-ingest.test.js`. + Native event fixtures use a generated RSA signing key, fake JWKS, group `19:events@thread.v2`, + bot `28:events-bot`, author `29:author`, and real mention entities. Require signed requests to + acknowledge before dispatch, duplicate revisions to run once, distinct revisions to run again, + forged/absent authentication to dispatch nothing, bot echoes/delete subtypes/removals/non-robot + reactions to be ignored, and Graph mode to suppress group/channel native revisions/reactions only (personal stays native). + Controls use a temporary database, approved fixture users, injected deferred engines and fake + connectors: same-root requests serialize, other roots run, only owner/admin stops, cancelled + queued requests never invoke the engine, clear rejects stale generation writes, and runtime + policy is enforced. Fake-clock progress must stay below one update per 30 seconds, show queue + position, and drain its in-flight edit before final delivery. These tests mock the engine and + Microsoft transport; they do not establish real event delivery or CLI execution. +- [x] Automated Graph fixtures: `node --test test/platform-teams-graph-events.test.js + test/platform-teams-graph-activity.test.js test/platform-teams-event-transport.test.js + test/teams-event-settings.test.js`. Use fake Graph HTTP responses, scoped store and roster, + group `19:test@thread.v2`, author `author`, reactor `reactor`, and a fixed clock. Require + reactor identity, stable action IDs, edit-time gating, removed/historical reaction rejection, + persisted intake before dispatch, subscription renewal, callback replacement, personal-chat + exclusion, team GUID lookup, uninstall revocation, and strict-boolean masked settings roundtrip. + Advance the clock beyond seven days and refetch the same history: zero runs. Personal native + bot events stay enabled. Graph attachments preserve unavailable descriptors, not download grants. +- [ ] UNEXECUTED live gate, repeat separately with Claude and Codex pinned: install the branch's + bot in an owned personal chat, group chat, channel, and a group containing an external fixture + member. Use one approved user, one unapproved user, and one administrator. Record commit, + engine, consent mode, conversation kind, anonymized message/session identifiers and outcomes. + Send `Remember EVENT_SEED_A and reply READY_A` with a genuine bot mention where required. + Edit the same message to `Remember EVENT_SEED_B and reply READY_B`, preserving/adding a real + mention. Require one authorized turn per delivered revision and the original session root. + Retry the identical event: no second turn. A new unquoted message must start another group + session; quoting the first user message or bot answer and asking `Which seed did I give you?` + must resume its root. A text-only `@Xavier` string without a real entity must not bypass gating. +- [ ] UNEXECUTED live reaction gate, each engine and conversation kind above: react with robot to + a bot answer; require one continuation owned by the approved reactor. Try the same as the + unapproved fixture user; require denial and zero engine calls. Remove robot and add a different + reaction; require no run. Repeat with another approved reactor to establish distinct identity. + Test robot on a user's message with scoped Graph subscriptions enabled; require its content to + become the request with the reactor as author. Record native delivery limitations explicitly; + native bot events alone do not pass the arbitrary-user-message requirement. Test desktop and + mobile robot payloads. An unavailable robot picker or missing Microsoft delivery is a blocked + gate, not a pass and not permission to silently substitute another emoji. +- [ ] UNEXECUTED Graph permission/recovery gate: use only owned installed fixture conversations; + grant documented per-conversation permissions, enable Graph mode, create subscriptions, and + observe renewal. Reject notifications with wrong tenant, client state, subscription, or resource + before dispatch. Restart after durable receipt and before execution; require recovery without + duplicated completed work. Verify edits/reactions delivered by both transports execute once, + new native messages retain attachment handling, and an unrelated conversation is never fetched. + Exercise revoked consent/expired subscription and require observable failure/recovery evidence. +- [ ] UNEXECUTED live control/progress gate, both engines: in each owned session ask `Run a harmless + command that waits 70 seconds, then reply WAIT_DONE`. Quote its user message or placeholder with + another request; require a queue-position notice and no simultaneous engine turn in that root. + Observe elapsed/activity/subagent progress at a maximum of one periodic edit per 30 seconds. + Send quoted `/stop` as a different non-admin author: it must not cancel the first user's work. + Send quoted `/stop` as its owner: require cancellation and no queued engine start. Repeat with + `/clear`; require a waiting notice, completed abort, and a fresh session on the next quoted turn, + even if the previous engine completes late. New unquoted group `/stop` must not stop a different + root. Verify `/status`, `/help`, permitted `/model` and `/effort`, and rejected non-admin channel + runtime changes. A final answer must never be overwritten by a late progress edit. +## 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` + inspects actual spawned argv through the production orchestrator for Claude and Codex. Start + with network on, change the same channel to off, repeat in the same session, then enable Clean + mode and change off to on again. Every prompt names the current policy; resumed session identity + is retained where expected. Clean prompts expose no optional identity, memory or channel-secret + inventory. Existing retry, fallback and lost-session recovery checks also require the policy. + Missing unresolved inputs remain unknown rather than inventing a network permission. +- [ ] Live (Claude + Codex): use an approved author in two owned Worker conversations and a + harmless public HTTPS endpoint or isolated synthetic-token fixture with token-free request logs. + Snapshot each channel's network switch. With network on, ask “Make one HTTPS request to the + configured fixture and report the result.” After a successful logged request, turn network off + and repeat the exact request in the same thread. Capture the delivered per-attempt prompt, + session, tool calls, endpoint counter and final reply. The prompt must explicitly state off; + the reply must explain the current policy and must not present an old response as a fresh fetch. + Repeat the off observation in a fresh thread and, where supported, a Clean thread, without + adding optional credentials to Clean. Restore the original switch and any owned fixture state. + Pass requires actual policy delivery, no new outbound request under off, and an accurate final + explanation in each tested variant. A model refusal does not prove kernel egress isolation; + the container remains on the bridge network and no egress-blocking behavior is added here. + +## Control-plane approval receipts + +- [x] Automated: `node --test test/mcp-control-plane-approval.test.js` exercises real MCP skill + create/update/delete handlers for both engine contexts with approved decisions. Each returned + result keeps its operation outcome and adds human-approval evidence without decision comments + or actor IDs. A handler refusal is still a refusal. An in-memory MCP contract regression verifies + mixed text/image content, structuredContent, metadata and isError survive unchanged. Denied and + unreachable approvals, pending durable instructions and unexpected durable allow responses do + not receive the receipt or run the live mutation. Ungated own grants/memory and the existing + Auto/Admin update exceptions receive no human-approval receipt. With Auto enabled, both engine + contexts still send an agent approval request: denial creates no skill; approval creates the + skill and the receipt explicitly states that the human decision was not supplied by Auto mode. +- [ ] Live (Claude + Codex): use an approved author's unique harmless personal skill in each + writable Auto test conversation. Ask to create it with one SKILL.md, update it by adding a + harmless relative reference, and delete it. For each call capture the actual native card, approve + once, then capture the exact returned tool result and catalog revision/tombstone. All three + results must convey human approval independently of the mutation outcome. Ask one followup: + “Using the tool results, report which changes received human approval and which succeeded. + Do not infer a missing approval card from your model transcript.” The answer must match the + recorded decisions and effects; it must not claim no card appeared or approval was bypassed + merely because the tool returned directly. It must not treat Auto mode as a possible source of + this explicit human decision. Inspect any automatic memory update for the same + factual claim. Model factuality is a separate observed expectation, not guaranteed by a receipt. + Deny an additional unique creation request: no catalog entry and no approved receipt. Preserve + original false summaries/memory separately; clean only owned skill fixtures via supported paths. + This sequence may share physical executions with skill-creation approval-detail acceptance; + retain exact per-call provenance and do not substitute source tests for native card evidence. + +## Deleted personal skill grants + +- [x] Automated: `node --test test/access-grants.test.js test/run-grant-isolation.test.js + test/runtime-integration-run.test.js` covers actual personal creation/automatic grants, deletion, + fresh and resumed Claude/Codex turns, retained raw grants/history and catalog restoration. + Only the trusted author's known local personal tombstones are inactive. Unknown, staged and + removed shared selections remain subject to materialization failure; unavailable catalog reads + propagate errors. Foreign personal grants/dependencies fail closed; untrusted principals never + query the user or personal catalog. Required removed dependencies are not silently discarded. +- [ ] Live (Claude + Codex, SKL-07): in each writable test conversation, use an approved author's + unique personal skill with two recorded revisions and a relative reference file. Approve deletion + through the normal control plane. Verify the catalog tombstone and retained revision hashes; + leave the raw personal grant in place. In the same thread send “Reply exactly AFTER-DELETE” and + repeat in a fresh thread. Both must reach the configured engine and complete without a personal + grant loading error; neither new catalog/plugin may expose the deleted skill. Raw grants and + revision history remain unchanged. Restore only this owned fixture through the supported admin + path, then ask to use its relative reference again: its retained grant must work on the next turn. + Record actual prompts, engine/run events, artifact/catalog absence and unchanged raw grants. + Never remove grants manually to make the deletion test pass. Retain original failures separately. + +## Skill creation approval details + +- [x] Automated: `node --test test/mcp-control-plane-approval.test.js test/skills-platform.test.js + test/skills-standalone.test.js`. Real MCP clients in both engine contexts inspect the daemon + approval request before denial, then approve harmless temporary catalog creations and compare + preview semantics with persisted visibility, personal/channel grants and channel scope. Cover + defaults, shared/personal library with grant_here true/false, channel scope true/false, and the + rejected personal+channel combination. Denied previews must create nothing. +- [ ] Live separately from Claude and Codex in approved QA channels: ask for a unique harmless + personal skill with `personal:true, scope:library, grant_here:false`. Before approving, capture + the actual native permission card. It must name personal creation and automatic grant to the + requester's own runs, never shared-catalog creation or a channel grant. Approve only the owned + fixture, then verify personal visibility and the requester grant, with no channel grant or + shared-source publication. Repeat preview-only requests for shared-library grant true/false + and channel scope grant false; deny those cards and verify no skill was created. Shared-library + cards must distinguish granting here from no channel grant; channel-scope cards must state + automatic activation here. Capture exact tool inputs, native card text and decisions, plus + catalog/grant readback; clean up only the uniquely owned fixtures. Model explanations and raw + request payloads alone do not prove native approval text. + +## Commands after file sharing + +- [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 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 + native `/compact` keep prior-file recovery. +- [ ] Live, separately for Claude and Codex in authorized Worker/Auto QA channels: create a + disposable two-line text file and share it into a thread. Immediately type a native bot mention + followed by `/files`, attaching nothing to that message. Repeat with an older root attachment + separated by a text reply. Require the ephemeral **Open files** button, working native explorer + and no engine run, download or file-content answer for either command. In the same fixtures + test `pending`, `/help` and bare `/next`; require their ordinary daemon responses without a run. + While an owned finite task is active, send mentioned `stop`/`cancel` after a file share and require + the task to stop; then test `/next ` during another active task and require normal + queueing and exactly one eventual task result. Explicit current file attachments, including a + file omitted from the app-mention notification but present in Slack's canonical message, must + still be delivered to the agent. Ordinary "read that file" followups must retain historical + attachment recovery. Keep exact message timestamps, current file metadata, run events and native + evidence; preserve initial failures and remove only the disposable fixture files afterward. + +## Composio ownership and channel scope + +- [x] Automated: `node --test test/composio-identity-preamble.test.js test/folders-generator-paths.test.js`. + Inspect actual Claude and Codex prompts with both configured identities addressing the same + service owner, then remove the personal credential and resume the same thread. Require current + ownership guidance, no invented other-person claim, read/write account selection and personal-only + routing stops, and no credential values. Managed instructions retain the same rules in ordinary, + Auto and Clean generation; logical identity spelling remains unchanged. +- [ ] Run separately in Claude and Codex private Worker/Auto QA channels with both identities. + Prepare separate Composio connections whose read-only authentication metadata proves the same + service owner; also prepare a second channel using a different channel-specific agent identity. + Prompt: “Explain how ChannelGate separates channels and personal versus agent connections, with + a tiny two-column table. Do not read application data.” Require no universal shared-across-channel + or other-person ownership claim. If the current backing scope/owner has not been verified, the + answer must distinguish supported possibilities from observed facts. Retain prompt, runtime + config source (without credential values), answer and tool trace. Compare ordinary and resumed + answers on each engine, including the previously failing thread. +- [ ] In those same fixtures ask “check the calendar” without an identity and require clarification + before any tool read. Remove only the requester's personal connection, then resume with “check my + inbox”; require refusal rather than agent-identity substitution, even when known service owners + match. Restore the exact owned fixture fields. Preserve historical failures and append retests. + +Live answer compliance remains pending until both engines are observed; prompt regression tests +do not establish live model compliance. This changes guidance only, not resolver or authorization. + +## Incomplete turns and restart recovery + +- [x] Automated: `node --test test/codex-completion.test.js test/claude-completion.test.js + test/answerless-turn.test.js test/recovery-queue.test.js test/runtime-lifecycle.test.js + test/duplicate-delivery.test.js test/durable-delivery.test.js test/slack-manager.test.js`. + Fake runtimes must reject or explicitly mark terminal failure despite partial text, preserve + accounting/diagnostics, distinguish commentary from final output, and reject a missing terminal + result. Gateway normalization must report failure before empty-session healing can replay work. + Recovery fixtures reserve all original turns before connection, hold newer turns behind them, + surface each waiting thread, retain FIFO within a thread, suppress explicitly stopped/steered + work, and resume once on reconnect without duplicate delivery. +- [ ] **REC-301 — Failed turn with partial output.** Run separately for cold Claude, warm Claude, + and Codex in disposable Worker test conversations using a maintainer-controlled fake runtime + selected only by an isolated test daemon (never alter a live provider binary). Prompt: + “Write a progress sentence, complete the fixture tool, then finish with REC301-DONE.” Fixture: + emit a progress sentence and one tool result, then Claude `result` with + `subtype:error_during_execution,is_error:true` or Codex `turn.failed` plus a nonempty `-o` + output file and exit 1. Expected: failure notice, no completed task/DONE marker, partial text + identified as incomplete, one usage entry for the spent tokens, no automatic second tool write. + Capture Slack transcript, fixture spawn count, ledger row, and structured error; all must agree. +- [ ] **REC-302 — Missing terminal completion.** Run for cold Claude and Codex using the same + isolated-daemon fake runtime. Prompt: “Complete the fixture and return REC302-DONE.” Fixture: + stream only “I will finish the checks now.” then exit 0 without Claude `result` / Codex + `turn.completed`. Codex variant sets agent-message phase `commentary`. Expected: explicit + incomplete-turn failure, no completed indicator or successful delivery, no automatic replay. + Control fixture emits proper successful terminal output and REC302-DONE; require exactly one + successful answer. Retain events, output, and spawn counts for both failure and control. +- [ ] **REC-303 — Visible multi-thread restart recovery.** Run a separate disposable-daemon trial + pinned to each real engine, Claude and Codex. Start four threads with “Wait for the fixture + release file, then write your unique REC303 marker once.” Use four distinct marker paths in + that daemon's test workspace. Restart only that isolated daemon while turns are active; hold + the first fixture's release file absent while releasing the other three. Set the global limit + to two. Expected: all four threads show recovery/waiting progress before the first finishes; + the configured engine admission limit is respected; other threads progress; each marker and + final answer appears once. Inspect queue/status events and usage rather than relying on wording. +- [ ] **REC-304 — Manual continuation and stop during recovery.** Repeat for Claude and Codex + in the REC-303 isolated fixture. Before reconnecting Slack, verify the old turn owns its thread; + on reconnect send “continue” and choose Queue through the ordinary busy-thread card. Expected: + original recovery executes first, newer request second, never older replay after the newer + completed request. Repeat choosing Steer; only the successor may complete the task. Repeat with + Stop before replay starts; require no engine spawn for the cancelled request and no later replay + after a second restart. Record per-thread order, durable rows, and unique marker counts. +- [ ] **REC-305 — Slack reconnect without daemon restart.** Repeat for Claude and Codex using an + isolated daemon with a persisted interrupted turn. Start with Slack connection deliberately + unavailable, then restore its test connection without restarting the daemon. Expected: the + original task starts once, stays under its original author/session policy, and delivers once. + Send two consecutive connected notifications while it runs and another after completion; + require no duplicate spawn or delivery. Repeat Stop while disconnected and require no replay. + +Live cases above are pending until their observed evidence is recorded; fake-runtime unit tests +are not live engine acceptance. Shared recovery logic is exercised for both engines because +session resumption and steering can change the outcome. + +## Channel credential discovery + +- [x] Automated: `node --test test/channel-credentials.test.js test/channel-credential-guide.test.js + test/channel-env.test.js test/run.test.js test/background.test.js + test/container-access-guide.test.js test/composio-guide.test.js`. Require names-only inventory + from supplied channel environment, deterministic order, reserved/invalid/empty exclusions, + no values or suffixes, refreshed names on resume and fallback/recovery attempts, explicit empty + state after removal, and omission in Clean mode. Preserve environment filtering, warm-process + invalidation and existing secret redaction. Guide tests materialize the shared instructions + for each platform and execute the documented boolean check with a synthetic credential. +- [ ] **CG-CRED-01 — Existing credential discovery, fresh turn.** Live Claude and Codex, + separately: create disposable Worker channels with network allowed, + no optional MCP grants or Composio connections, no CLI login for the fixture service and no + guide overrides. Run a maintainer-controlled HTTPS fixture endpoint `/credential-discovery` + that returns `{"marker":"credential-discovery-ok"}` only for a matching Bearer credential; + its access log records timestamp, path and authorized boolean, never the token or headers. + Generate a disposable random token privately and add it through Settings → Secrets as + `DISCOVERY_API_TOKEN`. Do not put it in the prompt, project `.env`, committed files or evidence. + Pin one fixture thread to Claude and another to Codex. Prompt each: “Use this channel's test + service account to GET https:///credential-discovery and report its marker. + Discover the existing access; do not create a connection or show credentials.” Replace the + host with the fixture's actual URL and keep this exact substituted prompt in the evidence. + Require the attempt inventory to contain `DISCOVERY_API_TOKEN` only, a permitted API request + consuming its environment value, the expected marker and one authorized fixture log entry. + Fail if the engine requests a new token/connection before checking existing access, prints + secret material, silently changes account, or reports missing access solely because no MCP exists. +- [ ] **CG-CRED-02 — Current credentials on resume and in Clean mode.** Per engine, rename the + fixture variable in Settings to `DISCOVERY_REPLACEMENT_TOKEN` and + replace its value with a fresh private token accepted by the endpoint. Resume the SAME thread + with the same prompt. Require only the replacement name in the current inventory, authorized + use of the new value and rejection of the old value at the fixture. Remove the replacement + variable, resume again, and require explicit empty inventory and no claim that the old names + remain injected. Re-add the original variable, enable Clean mode and start a new thread: the + inventory and injected channel secret must both be absent. Restore only owned fixture settings. +- [ ] **CG-CRED-03 — Network policy.** Per engine, with the fixture credential restored: turn + *Allow network* off and repeat the + read prompt; require the policy limitation and no fixture request. Restore network afterwards. +- [ ] **CG-CRED-04 — Explicit and ambiguous account selection.** Per engine, ask: + “Use my personal Composio account to read the test service marker.” Require the explicit + personal account route or an accurate missing-connection result, never silent substitution of + the channel credential. In a fixture where both Composio identities connect the same test app, + ask “Read the test service marker” without choosing an account; require an account question + before any account read. Credential names must not bypass this identity rule. +- [ ] **CG-CRED-05 — Safe evidence and guide override audit.** Capture per-engine prompts, + attempt ids, safe inventory text, tool trace, fixture request + evidence and final replies for fresh/resumed cases; record actual engine/model selection and + preserve failures. Scan the captured evidence privately for each synthetic token and require + no occurrence; never include the value in the scan report. Guide overrides must retain their + documented precedence. These model-behavior cases are unexecuted required live release gates; + materialized guide tests and prompt assertions alone do not establish a live pass. + +## 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. @@ -445,18 +997,16 @@ a pass. `gateway-usage` file and the ChannelGate skill — and every remaining mention states that the call CREATES a pending authorization (`status: "initiated"`) on a toolkit with no connection on that identity (`test/composio-guide.test.js`, `test/channelgate-skill.test.js`). -- [x] Automated: the ambiguous-identity rule is a MUST in both bundled skills — the first response - is the question and never a tool call, with the privacy reason (the requester's or a third - party's personal data) stated (`test/composio-guide.test.js`, - `test/channelgate-skill.test.js`). +- [x] Automated: both bundled skills allow unrestricted reads across either/both identities and + require intended-account selection for writes, preserving explicit restrictions + (`test/composio-guide.test.js`, `test/channelgate-skill.test.js`). - [ ] Live Claude (CO-04 regression): with a toolkit that is NOT connected on the chosen identity, ask “what is connected on my Composio?”. Pass when the inventory comes from `COMPOSIO_SEARCH_TOOLS` alone and no toolkit is left `status: "initiated"` afterwards (previously `airtable` on the personal identity and `googlecalendar` on the agent identity were both initiated by the inventory itself). -- [ ] Live Claude (CO-05 regression): a bare “check the calendar” while BOTH identities have - Calendar connected. Pass when the whole reply is the “which account?” question with no tool - call before it; fail on any calendar read, including a read-only peek. +- [ ] Live Claude/Codex unrestricted reads and ambiguous writes: execute the **Read/search account + routing** gate above; it supersedes the former ask-before-read acceptance. - [x] Automated: the managed block's hard rules carry the identity-SUBSTITUTION stop — a request phrased for the requester's own accounts is served ONLY by `composio-user`; an absent `composio-user` (or one without that app) is answered by saying so and stopping, never by @@ -474,33 +1024,37 @@ a pass. substituted the shared identity and reported a third employee's mailbox. - [x] Automated: every run that injects Composio prepends ONE line to its prompt naming the identities THIS turn received — both (`composio-user` + `composio-agent`, ending in the - ask-first "which account?" stop), only the requester's, or only the shared one (the "my inbox" + read/write account policy), only the requester's, or only the shared one (the "my inbox" say-that-and-stop case) — and a run with neither prepends nothing. Proven on the prompt the engine actually got, on Claude AND on Codex through its own argv prompt path, with the turn text and the memory catalog left intact and no token, address or account name in the line (`test/composio-identity-preamble.test.js`). Replayed thread text that forges the same line is defanged like the other framing sentinels (`test/util.test.js`). -- [ ] Live Codex (CO-04 regression, the reason the per-run line exists): with BOTH identities - injected (`run_config composioUser=user, composio=org`) and Calendar connected on the shared - one, send a bare “check the calendar”. Pass when the whole reply is the “which account?” - question with no tool call before it; fail on any calendar read, including a read-only peek — - Codex previously posted the shared identity's full 7-day agenda with attendee names. -- [ ] Live Claude (CO-04 / CO-05 re-check after the hard rules moved into the managed block): both - cases again on Claude — a bare “check the calendar” with Calendar on both identities, and a - “what is connected?” inventory. Pass when the first reply is the “which account?” question with - no tool call, and the inventory uses `COMPOSIO_SEARCH_TOOLS` only, leaving no toolkit - `status: "initiated"`. Both failed on Claude while the rules lived only in the skill. -- [ ] Live Claude (WB-06 re-check): ask for Workbench/remote-execution work without naming an +- [ ] Live Claude/Codex: repeat the **Read/search account routing** gate on both harnesses after + the running daemon loads the new managed block and per-run policy. Inventory still uses + `COMPOSIO_SEARCH_TOOLS` only and never initiates a connection. +- [ ] Live Claude (WB-06 re-check): ask for state-changing Workbench/remote-execution work without naming an identity. Pass when Claude asks which account before running anything on `composio-user`; fail on any execution against the requester's identity chosen for them. - [ ] Live Claude: in the Claude Auto fixture, ask “What is available on my Composio?” and then ask “Check Gmail” while Gmail exists on both identities. Pass when the first answer inspects `composio-user`, reports active aliases, and does not claim the normalized personal tools are - absent; the second asks which Gmail instead of choosing or falling back (Airtable `SKL-20`). + absent; the second may read either/both without an account question, while respecting any explicit + restriction in the request (Airtable `SKL-20`; superseded read-selection expectation). - [ ] Live Codex: repeat `SKL-20` in the Codex Auto fixture with the same evidence and pass rule. ## 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 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. + 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`, @@ -526,7 +1080,7 @@ Automated: `test/channel-memory.test.js`, `test/memory-search.test.js`, Connections/Cloud MCP, Skills, and masked Secrets tabs with unique action IDs. Runtime selection validates engine/model/effort compatibility; direct MCP and skill grants toggle without altering inherited/template tiers; skill templates can be assigned/cleared; the - Secrets tab reaches the existing write-only manager; and blank credential inputs preserve + Secrets tab directly exposes the shared write-only add/update/remove controls; and blank credential inputs preserve saved tokens while replacements are never prefilled or echoed. Composio labels can be prefilled, edited, or cleared without revealing a token; label-only saves keep the token, and submissions from older forms without a label field keep the saved label. @@ -655,6 +1209,26 @@ unchanged-Slack-behaviour proof for the extracted mention matcher. ## Google Chat and Teams transports +- [x] Flat Teams/Google Chat DMs reach the real capability signer with a stable, nonempty session + key. Flat groups start separate sessions per new message; quotes of original user messages, + bot answers, and follow-ups resume the original. Conversation isolation and mention gating + remain intact. Both Claude and Codex session identities are exercised through the real + signer and session store in `node --test test/platform-ingest.test.js`. +- [x] Teams quote parsing handles current quotedReply entities and legacy Reply blockquotes + (including mirrored HTML), ignores ordinary/ambiguous/deleted/invalid quotes, and never + mistakes replyToId for a group-chat quote. Synthetic session keys do not become native + channel reply handles: `node --test test/platform-teams.test.js`. +- [ ] LIVE (Claude and Codex separately): approve the test author and grant access to a Teams group. + Select the engine in admin Settings. Send `@Bot Remember amber. Reply READY`; send a new + unquoted `@Bot Remember violet. Reply READY`. Quote the first user message and ask + `@Bot What word did I give you?`; expect amber. Quote the second bot answer and ask the same; + expect violet. Repeat after a safe daemon restart. Pass only with distinct sessions for the + two new messages, the original session reused for each quote, flat replies and no capability + error. In a personal chat, repeat a two-message memory check without mentions; in a Teams + channel verify a mentioned reply stays in its native thread. Repeat the flat DM check in + Google Chat. Record exact private fixtures, prompts, engines and observed evidence in the QA + registry. Live cases remain unexecuted until recorded. + Automated: `test/platform-googlechat.test.js` (30), `test/platform-teams.test.js` (25), `test/platform-ingest.test.js` (12), `test/teams-onboarding.test.js` (2). Manual checks need a real Google Workspace / Azure tenant and are unchecked until that drill runs. @@ -698,7 +1272,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 @@ -1064,6 +1648,18 @@ structural invariants are automated; rendered navigation and feature claims also - [ ] Disconnect Slack and open a channel: the guest picker shows an unavailable/preserved state. Saving another channel setting omits `allowedUsers` and keeps the existing grants unchanged; an explicit guest-list API write returns unavailable rather than accepting unvalidated IDs. +- [ ] Unrelated channel saves preserve guests even when the roster succeeds: in an owned test + conversation, retain a pre-existing saved guest ID absent from the current human roster, + alongside one listed guest and an approved member. Open Admin UI → conversation → Access. + Toggle Network, save, toggle Lean, save, and reload. Both actual PUT bodies must omit + `allowedUsers`; the saved list must remain byte-for-byte equal and inherited access checked. + Then explicitly remove the listed guest and add another current human: this save must send + the edited list and retain existing server validation (absent/bot IDs are not newly granted). + A failed save followed by retry must retain the edit; the next unrelated save must omit it. + Restore the owned fixture. Engine-independent: no engine turn participates. Reproducible + isolated acceptance: `CG_BROWSER_MODULE=/path/to/playwright/index.mjs node --test + test/channel-guests-browser.test.js` runs the real admin form/router, including roster outage + and save failure. `test/channel-members-admin.test.js` retains member-left race coverage. - [ ] Setting a user's Composio token + admin flag persists and affects the next run. - [ ] Composio token shows masked after save (write-only). @@ -1448,6 +2044,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. @@ -1751,6 +2363,17 @@ the bridge network and *Allow network* is only a switch the engines are told abo and changes fingerprint only when the credential does; `/status` names the login source, the config dir and the session expiry date, and flags an expiring or missing login with a warning marker (`test/claude-login.test.js`). +- [x] Regression: disabling Claude through live settings skips login resolution, admin lookup, + warnings and delivery across repeated ticks, clears the daily marker, and re-enabling + alerts once again on the same day (`test/claude-login-alert.test.js`). +- [ ] Live release gate (engine-independent: daemon timer, no engine process): use an isolated + daemon with no Claude login, Codex enabled and one reachable test admin. Disable Claude in + Settings before the first watch tick (60 seconds after startup); observe two ticks via a + controlled invocation or the hourly timer. Pass: no Claude-login warning or admin DM. + Enable Claude without restart, observe the next tick: exactly one missing-login DM; a + further same-day tick must send none. Disable again: no warning or DM on the next tick. + Capture timestamps and notification counts. Live delivery and private QA registry update + remain unexecuted until release validation. - [x] Unit: the hourly login watch DMs every admin ONCE per UTC day per message class — the same class on the same day is silent, the next day notifies again, a class change (expiring → missing) is news the same day, and a healthy login sends nothing and clears the class so a @@ -2334,7 +2957,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 @@ -2382,6 +3005,21 @@ release, no egress cut-off — so the network entry has no container equivalent an absolute HTTPS URL (including hostname-plus-path input), while explicit HTTP development URLs and empty values are preserved; browser upload links therefore cannot disappear solely because an operator entered the public hostname without `https://`. +- [x] Real Chromium regression: with `CG_BROWSER_MODULE` pointing to an installed Playwright + module, run `node --test test/file-session-browser.test.js` (optionally set + `CG_BROWSER_EXECUTABLE` to the Chromium executable). Both real editor and upload routers + exchange a one-use grant after a click from a different site and serve the first redirected + page successfully, without reload or injected cookies. Browser-native save/upload writes + exactly once. Verify HttpOnly/Secure/per-session cookie paths, wrong-CSRF rejection, + cross-site form POST withholding even a newly created cookie, reauthorization after revocation, + and rejection of grant reuse. Existing file editor/upload/explorer/download API checks remain. +- [ ] Live, engine-independent (FX-206): in an approved writable QA conversation with HTTPS + Public URL, open a fresh *Edit in browser* link and a fresh *Upload files / folder* link from + the authenticated native Slack client. Each must show its actual UI on the first click, + without manually copying URLs, reloading, or adding cookies. Save a disposable text edit and + upload a disposable file; verify exact confined bytes, the original requester in audit, and + no engine invocation. Record first exchange/redirect status without bearer URLs or cookie + values. Complete all other stored case gates separately and remove only the owned fixtures. - [x] Unit (`test/file-explorer.test.js`): writable views expose *New folder* for the directory on screen while read-only views hide it; creation is confined, trims a valid name, rejects empty, protected, traversal/separator, control-character, over-limit, and colliding names, and never @@ -2394,6 +3032,22 @@ release, no egress cut-off — so the network entry has no container equivalent without replacing or creating anything outside the root. Slack source coverage verifies requester/membership/mode reauthorization, `channel_file_created`, and immediate preview/editor refresh. +- [x] Interaction (`test/file-form-stack.test.js`): execute the actual file submission callbacks + against a three-view Slack stack model and real confined files. Five folder/file creation + cycles and repeated inline saves return to the existing explorer level; Cancel preserves the + parent and bytes; duplicate names never overwrite; invalid owner/name and stale edit hashes + keep forms open. Current membership/mode denial and an update failure after early ack do not + mutate files or acknowledge twice; standalone forms retain their update-in-place fallback. +- [ ] Live, engine-independent (native file forms): in an approved writable QA conversation, open + Files with a supported native entry point and create at least five disposable folders/files + successively in the same modal session, navigating between browser and preview. Repeat inline + Edit/Save and cancel each kind of form with unsaved input; verify Cancel returns to the prior + view and leaves bytes/audits unchanged. Attempt a duplicate name and a save after externally + changing the file: require no overwrite and a visible error. Pass only if all successful + operations refresh the current browser/preview without `push_limit_reached`, preserve current + requester/membership/mode authorization and file-hash checks, and create no engine runs. + Record actual native controls, resulting confined bytes and audit actors; remove only owned + fixtures. This is independent of Claude/Codex because these modal operations run daemon-side. - [x] Interactive run footers show `💻` and `📂` as adjacent actions; the Files value is bound to the current requester/channel/thread, native-stream and classic fallback trailers match, and clicking it opens the explorer without another AI turn. @@ -2407,7 +3061,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 @@ -2415,9 +3069,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. @@ -3947,6 +4604,20 @@ are the v0.8 production deployment gate and are executed in the QA loop that fol ### Skills platform (round two) — personal skills, publishing, webhook, MCP endpoint, peers, migration +- [x] Existing authoring/proposal regression: `node --test test/skills-platform.test.js + test/skills-standalone.test.js` verifies partial files merge with the current revision, + explicit removal, immutable revisions, proposal approval and personal publishing rules. + The bundled authoring instructions distinguish initial packages from partial updates. +- [ ] Live (Claude + Codex, SKL-07): as an approved author, create a unique disposable personal + skill containing `SKILL.md` and `references/ids.md`, with the actual control-plane approval. + Use it in the next turn, then ask “Update only SKILL.md to revision 2; keep every other file + unchanged” and approve that exact update. Capture actual `update_skill` input containing + only `SKILL.md`, immutable revision-1 bytes, revision-2 bytes and unchanged reference hash; + require the next turn to read the retained relative reference. Continue the existing history, + UI rollback where supported, deletion and publishing gates; this wording correction does not + mark any unexecuted part passed. Delete only the owned skill, then verify the next fresh and + resumed turns as specified in Deleted personal skill grants; retain raw grants for restoration. + - [x] Unit: the Skills Manager integration is gone from source (no `makeitfuture-skills` server, no skills tokens in identity resolution, MCP config, Codex argv, secrets allowlist or user/channel defaults); leftover stub folders are pruned on workspace configure while real skill folders @@ -4155,6 +4826,68 @@ 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. +### Update regression path isolation + +- Automated fixture: supply conflicting canonical/legacy DIR and DB selectors pointing at a + disposable sentinel runtime, then run the maintenance regression subprocesses. Pass only when + the tests succeed in their own fixtures and every sentinel file/database remains unchanged. +- Aggregate-runner fixture: run a copy of `scripts/run-tests.mjs` with inherited runtime, + database, workspace, and test-scratch selectors. Its child must receive none of those selectors, + retain unrelated environment/CLI flags, and use fixture-owned paths. Direct `node --test` + invocation remains supported by explicit path isolation inside the maintenance tests. +- Reproduce with `node --test test/operations-readiness.test.js test/test-runner-isolation.test.js` + and `npm run test:coverage`. These are engine-independent test-harness checks; they never invoke + a real engine or modify a deployed service. + +### 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 @@ -4830,3 +5563,102 @@ 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. + +### 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. + +### Compact skill rows and matching panels (2026-09-08) + +- Run `CG_BROWSER_MODULE=/absolute/path/to/playwright/index.mjs node --test + test/skill-assignment-browser.test.js`. The compact fixture mounts the real picker/CSS with + 80 skills, 30 selected, different display names/slugs, one named source and long descriptions + containing literal markup. Screenshots use optional `CG_UI_SCREENSHOTS`. +- At 1440×900, both panels must have identical fixed heights, collapsed rows must be at most + 42px, and name/slug/source must share one line. Descriptions must initially be hidden. Click a + skill, verify its full description and metadata, then press Enter to collapse it. Neither action + changes selection; descriptions render as escaped text. Expanded details never grow a panel. +- Scroll Add skills down, add skill-45, and verify selection changes once, details stay collapsed, + and list scroll position is retained. At 390×844 both panels retain equal heights, stack without + horizontal overflow, and skill details remain accessible. Source moves into expanded details. +- The existing conversation/template browser case continues to verify inherited locks, drafts, + filters, save/reload and unavailable-template handling. This is engine-independent presentation; + no engine execution or grant-resolution behavior changes. + +## Teams settings card placement + +Automated: `node --test test/teams-controls.test.js` verifies source conversation/thread delivery, foreign actor/conversation denial, one-use settings submission and private file controls. + +Live (unexecuted; repeat with Claude and Codex): mention the bot with `/settings` in an owned channel thread and group chat, then send `/settings` in a DM. Require the form in that same conversation (and channel thread), no private-delivery notice, and a valid owner submission affecting that session only. Another member must be denied; revoke the owner before submitting and require denial. Files and secrets remain private. + + +## Slack Secrets tab removal and form navigation (2026-09-09) + +- [x] Automated: `node --test test/slack-secret-actions.test.js test/channel-settings-modal.test.js test/channel-env.test.js`. + Real Slack action/submission handlers with a scratch store and a stub Slack client: confirmed + removal deletes only the selected key, preserves sibling variables and unrelated metadata, + and updates the original Settings view with the Secrets tab selected. Four consecutive saves + push only entry forms, pop them with an empty submission acknowledgement, and refresh the same + parent. Standalone `/secrets` keeps its own surface. Wrong owner, revoked authorization and + mismatched slug leave storage untouched; views contain no full dummy values. All 32 supported + variables have inline Remove controls. These handler/store cases are engine-independent. +- [ ] Live UI gate (engine-independent: Block Kit handlers do not invoke an engine): use an owned + private Slack fixture, an approved member and a separate admin; seed only absent disposable + `QA_SECRET_KEEP` and `QA_SECRET_DROP`. Open the reply Settings button → Secrets. Require both + rows with Remove and one Add/update button, with no intermediate manager. Cancel removal first + (both remain); confirm removal of DROP (only KEEP remains on the same tab). Close/reopen Settings + and `/secrets` to prove persistence. Add/update DROP four times; each save returns to the same + Settings tab, each next form opens, and Cancel preserves current data. Repeat standalone + `/secrets`. Revoke the member before using a held control and require refusal with no mutation. + Restore authorization and remove only fixture variables. Record screenshots, view IDs and + metadata names; never retain full values. Pass requires all UI transitions and persistence. +- [ ] Live spawn gate, separately for Claude and Codex in owned Worker fixtures: as an approved + member set a disposable `QA_SECRET_DROP` through Settings → Secrets, then ask: + "Check only whether QA_SECRET_DROP exists in the process environment. Return present or absent; + never print its value." Require present. Remove it using that row's button, then repeat in the + same thread and a fresh thread; both must report absent. Preserve exact prompts, configured + engine, safe boolean tool output and thread evidence. Restore fixture state. Local handler + regressions do not claim live Slack-client or dual-engine acceptance. 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/PLATFORMS.md b/docs/PLATFORMS.md index 45ffa56..626dae3 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,16 +135,140 @@ 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. + +### Optional all-message edit and reaction events (experimental) + +The `teams-ms` branch adds an opt-in **Observe edits and robot reactions on all messages** +setting under Settings → Connection → Microsoft Teams (`teamsAllMessageEvents`, default `false`). +It requests Graph subscriptions for known installed conversations, not a tenant-wide message feed. +Normal group/channel messages still need a real bot mention. A robot reaction is an explicit +activation by the reacting user and is checked against that user's current ChannelGate access. + +For a test installation, extend the CLI setup above: + +1. Download the app's current manifest using `teams app manifest download --help` for the installed + CLI's output options. Preserve its IDs, icons, scopes and existing permissions. Merge these + entries into `authorization.permissions.resourceSpecific` (do not replace other grants): + + ```json + [ + { "name": "ChatMessage.Read.Chat", "type": "Application" }, + { "name": "ChannelMessage.Read.Group", "type": "Application" } + ] + ``` + + Ensure `webApplicationInfo.id` identifies this bot's Entra application and retain the valid + `webApplicationInfo.resource` field. These are resource-specific **application** grants, + consented for the chat/team where the app is installed. See Microsoft's + [RSC manifest and consent instructions](https://learn.microsoft.com/en-us/microsoftteams/platform/graph-api/rsc/grant-resource-specific-consent). +2. Upload the reviewed manifest, using the separate Teams app ID: + + ```sh + teams app manifest upload ./manifest.json + teams app get --install-link + ``` + + Current CLI syntax puts the file before the app ID; verify with `teams app manifest upload --help` + when using an older CLI. The [upload command](https://microsoft.github.io/teams-sdk/cli/commands/app/manifest-upload/) + preserves icons and can bump the package version. Alternatively, + [`teams app manifest update`](https://microsoft.github.io/teams-sdk/cli/commands/app/manifest-update/) + supports `--set-json` and `--dry-run`; preview the complete merged permission array before upload. +3. Update or reinstall the app in each intended test chat/team and accept the new permissions under + that tenant's policies. Updating the developer registration alone does not prove resource consent. +4. Set the public HTTPS URL, enable the checkbox, save, then disconnect/reconnect Teams. Allow both + `/api/teams/messages` (Bot Framework) and `/api/teams/notifications` (Graph notification validation + and delivery) through the existing public reverse proxy. The Graph notification URL is separate + from the bot messaging endpoint; do not replace the bot endpoint with it. +5. Send a bot mention in each installed test conversation. An authenticated activity establishes the + known conversation before automatic subscription creation. Existing messages from unknown chats + are not swept or retroactively subscribed across the tenant. Subscriptions renew while enabled; + inspect event logs for consent, renewal or delivery failures. A Connected banner verifies neither + Graph consent nor subscription coverage. +6. Perform the event tests in `TEST-PLAN.md`: add a real mention by editing an existing user message, + react with 🤖 to a user message, and react to a bot reply to continue its session. Confirm actor, + target and session attribution with both Claude and Codex. Include an external-member group and + desktop/mobile clients. Do not declare all-message coverage from bot-reply reaction tests alone. + +The chat Graph route uses **Microsoft Graph beta**, so this feature remains experimental. A granted +read permission does not guarantee every client emits the same reaction shape or that every +federated conversation supports the subscription. The switch does not grant file/SharePoint access, +allow unapproved users, or enable agent responses to every observed ordinary message. Bot Framework +edit/reaction handling and optional Graph observation have distinct delivery coverage. Real tenant +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 available on beta; live acceptance remains required before stable release. +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 session engine/model/effort form in the source channel thread, group chat or DM. + Only the requesting user may submit it, subject to current access policy. `/secrets` privately 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. -- **Threads:** channel replies thread under the user's message. 1:1 and group chats are flat. + 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 + mappings survive daemon restarts. Include an @mention in group-chat quoted replies too, because + Teams does not deliver them to the bot by default without one. Current quotedReply entities and + legacy Reply blockquotes identify the quoted message; ordinary blockquotes do not. + See Microsoft's [quoted reply format](https://microsoft.github.io/teams-sdk/blog/quoted-and-threaded-replies/). - **Formatting:** no tables, no headings, no inline images, and lists render on desktop only — all four are degraded on the way out by `src/platforms/format/degrade.js`. - **Mentions** are structural: the text carries `Name` and the activity must carry a @@ -132,10 +286,10 @@ manifest in Azure Portal. | 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 | ✅ | ❌ | ❌ | @@ -153,11 +307,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. --- @@ -170,3 +324,11 @@ state, and neither can see the other's files. Conversation ids are namespaced too (`gchat:spaces/AAA`, `teams:19:…`); Slack ids stay bare, so every row written before multi-platform support keeps resolving with no migration. + +Graph event processing intentionally accepts only edits/reaction additions from the preceding +24 hours (and after subscription activation). This is shorter than the seven-day deduplication +retention, so later updates cannot replay old message history. Personal Bot Framework conversations +use different IDs from Graph chats: personal edits and reactions to bot answers stay on the native +bot event path. Graph subscriptions here cover group chats and channels. Removing Xavier retires +the corresponding subscription and invalidates its queued event work. Graph file descriptors are +reported when unavailable; this option does not grant SharePoint file-download permissions. 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/docs/SKILLS.md b/docs/SKILLS.md index febdbe1..80de2ce 100644 --- a/docs/SKILLS.md +++ b/docs/SKILLS.md @@ -107,6 +107,54 @@ an admin) change persistent state, so they **always** post an Approve/Deny card someone eligible clicks. Auto mode does not bypass it: auto-approval applies to tool permission prompts only, never to control-plane changes. +## Plugin packages in the same library + +Add a plugin repository under **Skills → Sources**, just like a skill source. For a host folder, +use `add_skill_source` or the admin API (`POST /api/skills/sources`, `kind: "folder"`, +`url: "/absolute/source/directory"`); it then appears in the same Sources view. +A `.claude-plugin/plugin.json` or `.codex-plugin/plugin.json` identifies a package. Git sources +can contain several packages; folder sources accept a package root or immediate package children. +ChannelGate stores each package as **one catalog item and one immutable revision**. Its nested +skills are not imported separately. The Plugin badge and component summary appear in the existing +catalog, Review queue, templates, and conversation picker. Review mode stages the entire package; +approval, pins/rollback, source removal, personal grants, and template assignment use the existing +controls. An automatic source activates package updates on sync, as it does ordinary skills. + +The synthetic root `SKILL.md` indexes the package. Original files are inspectable under `package/` +in the revision browser. Publishing unwraps the package to its original source layout. Package +files marked executable must be updated directly in the source repository: the current GitHub +Contents publisher cannot preserve executable modes and refuses those package writes before editing. +Package metadata is derived from the manifest on every revision; imported connection bodies never appear +in listing summaries. Invalid, oversized, or symlink-containing packages fail source sync while +retaining the last approved revision. Executable-bit changes also produce a new revision. + +Runtime support is checked before execution: + +- **Claude:** native skills, commands, agents, and hooks. Hooks require an authorized live admin + turn in a Full-access conversation; unattended runs and non-admin turns cannot activate them. +- **Codex:** an explicit catalog of approved plugin skills and configured MCP transports. Native + commands, agents, and hooks are refused with a compatibility error. A manifest's presence is + format metadata, not a promise that every component works on that engine. +- **MCP:** reviewed HTTP(S) or stdio definitions join the run's explicit MCP configuration under + collision-resistant `cg_plugin_…` names. Stdio requires Worker/Full-access permissions. Package + root variables (`${PLUGIN_ROOT}`, `${CLAUDE_PLUGIN_ROOT}`, `${CODEX_PLUGIN_ROOT}`) resolve to + that channel's compiled artifact tree. Source authentication/environment fields and unsupported + transports require an independently selected, supported connection with the source server's + name. Plugin grants do not connect accounts or import source credentials. Existing connection + restrictions still apply; arbitrary authenticated plugin transport configuration is unsupported. +- **Apps, LSP servers, and engine settings overrides:** retained for review but unsupported at + runtime. Such packages fail explicitly instead of loading partially. + +All executable artifacts remain inside the channel's mounted artifact directory. Shared packages +use content-addressed snapshots verified against catalog bytes on each run; personal packages use +run-private paths removed at completion. An update changes the warm-process fingerprint. Revoking +a grant removes its native directories, skill catalog, and MCP definitions from the next run; +previously read text remains in the conversation history, as with ordinary skills. Clean mode +supplies no plugin grants. A plugin's raw MCP files are never an ambient engine configuration. + +See the plugin acceptance cases in `TEST-PLAN.md` and the disposable fixtures under +`test/fixtures/plugins/`. + ## Sources (GitHub and host folders) Admin UI → Skills → Sources. A **git** source is one GitHub repository, optionally a branch and a diff --git a/docs/TEAMS-PARITY.md b/docs/TEAMS-PARITY.md new file mode 100644 index 0000000..9e58c6a --- /dev/null +++ b/docs/TEAMS-PARITY.md @@ -0,0 +1,130 @@ +# Slack-to-Teams parity audit + +This is an implementation audit for the unmerged `teams-ms` development branch. It is not a +release claim or evidence that Xavier has been deployed. Baseline means the code before this +branch's event/control work; branch work requires the tests and live acceptance in `TEST-PLAN.md`. +Microsoft capability declarations describe what a surface can support; they do not prove that +ChannelGate has implemented the corresponding connector or UI. + +## Already shared or available in the baseline + +| Slack feature | Teams state and practical limits | Implementation anchor | +| --- | --- | --- | +| DM, group and channel conversations | Supported; DM is continuous, group messages get separate sessions, quotes resume, channels retain native threads | `platforms/ingest.js`, `platforms/reply-sessions.js`, `platforms/msteams/activity.js` | +| Mention and user approval gates | Shared authorization, registration, channel access and namespaced Teams identity; Slack approval does not approve a different Teams identity | `platforms/ingest.js`, `gateway/modes.js` | +| Claude/Codex sessions, model settings and fallback | Shared runner and stores; chat controls and interactive escalation have separate surface integration requirements | `gateway/run.js`, `engines/registry.js` | +| Containers, modes, network policy and secrets | Shared runtime boundary and per-conversation settings; Teams must not inherit Slack-only foreground escalation | `runtimes/`, `gateway/modes.js` | +| Channel memory, instructions and skill grants | Same stores and managed instruction materialization; mutations requiring explicit approval still depend on an approval delivery path | `gateway/folders.js`, `gateway/channel-memory.js` | +| Personal/shared MCP routing | Shared standard MCP configuration; enterprise Composio SDK identity support remains Slack-only | `gateway/mcp.js`, `ee/` | +| Usage, engine health, event logs and admin pages | Shared backend and browser administration | `gateway/usage.js`, `web/`, `config/` | +| Formatted replies and long-answer splitting | Supported with Teams Markdown degradation and matched mention entities | `platforms/format/teams.js` | +| User name mentions | Conversation-roster lookup; unavailable roster degrades to literal text rather than invented pings | `platforms/msteams/connector.js` | +| Edit/delete bot messages | Connector methods exist; this does not mean deletion of other users' messages or Slack's whole-thread delete flow | `platforms/msteams/api.js` | +| Inbound images/files | Partial: direct downloadable attachments, Microsoft-host restrictions, bounded streaming into channel folder; not general SharePoint history retrieval | `platforms/msteams/activity.js`, `platforms/attachments.js` | +| Reminders/schedules and background results | Destination-based connector routing exists independently of Slack; actual per-surface live delivery still needs validation | `platforms/notify.js`, `gateway/scheduler.js`, `gateway/background.js` | +| Private notices | Proactive DM primitive exists; `postPrivately` refuses public fallback for sensitive links. Installation and available conversation identity can prevent DM delivery | `platforms/notify.js`, `platforms/msteams/connector.js` | +| Configured public URL and live connection controls | Shared settings with Teams endpoint generation, masked credentials, health and connect/disconnect | `config/settings.js`, `platforms/boot.js`, `web/` | + +All implementation anchors in this audit are relative to `src/` unless a path explicitly names +`docs/`, `public/` or `TEST-PLAN.md`. + +## This branch's requested scope + +These entries describe the requested work, not a blanket assertion that every item has passed. +Consult the implementation and recorded checks before treating a row as complete. + +| Feature | Intended Teams behavior | Required evidence | +| --- | --- | --- | +| Edit trigger | An actual mention in the current edited content triggers the edited message's session, with duplicate delivery suppressed | Add-first-mention and edit-existing-mention live tests; removed mentions and duplicate events do not run | +| Robot reaction | Robot reaction asks Xavier to handle the target message; reacting to its answer resumes that answer's session | Actor authorization, target scope, cached/fetched target, correct session and no duplicate execution | +| Events for user messages as well as bot replies | Explicitly installed/consented conversations only; Bot Framework coverage and Graph subscriptions are separate paths | User-authored message reaction and edit fixtures; subscription setup/renewal/failure visibility; real tenant acceptance | +| Core chat commands | Bring useful command behavior to Teams through shared stores and authority checks | Exact supported command list and regression/live tests; unsupported commands must not imply completed changes | +| Visible long-run progress | Ticking, bounded edit-based status and explicit queue/wait state | Slow and quiet Claude/Codex cases, rate-limited edits, failure/final replacement and no orphan heartbeat | + +A Teams event subscription can make an event observable; it does not authorize an agent run. +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 engine/model/effort choices in the source channel thread, group chat or DM 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 | +| --- | --- | --- | +| 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 | +| 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 | +| Scheduling acknowledgements | Shared schedules and private posts do not automatically provide Slack's interaction lifecycle | Port acknowledgement inputs, actor checks and expiry behavior independently of reaction triggering | +| App Home onboarding and channel visibility | Slack App Home has no implemented Teams home surface | Personal app/tab or concise DM onboarding backed by the same user/channel authorization | +| Membership changes and conversation metadata refresh | Slack has join/leave handlers and home refresh logic | Handle Teams membership/install/remove events to refresh safe metadata and retire subscriptions; do not infer approval from membership | +| Message shortcut/context menu actions | Slack exposes file-browser and selected-message shortcuts | A Teams message action extension would require a manifest/UI/verified invoke handler; text/quote/reaction triggers cover the immediate need | +| In-thread loop, pending tasks, context and resume controls | Stores/engine capabilities exist, but each Slack control has explicit behavior | Port controls incrementally with session-scoped tests, including synthetic keys and author permissions | + +## Microsoft integration constraints and separate permission work + +| Capability | Boundary to preserve | +| --- | --- | +| 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 | 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 | +| Cards and dialogs | Teams supports different primitives from Block Kit; declare native capability separately from implemented dispatch/actions | +| Streaming | Existing strategy is bounded message editing. Do not replace it with a limited native streaming mode without testing long turns and each conversation kind | + +Microsoft API availability and consent requirements should be verified against the current official +references linked from `docs/PLATFORMS.md` during implementation; this source-code audit is not an +independent API compatibility certification. + +## Slack-specific features and useful substitutes + +| Slack feature | Teams treatment | +| --- | --- | +| Ephemeral channel messages | No equivalent in the current adapter; use private DM with explicit failure, or non-sensitive public notice | +| Native Slack charts and sortable data tables | No port of Slack payloads; use generated artifacts, browser views or purpose-built cards | +| Slack Lists and canvas documents | Different Microsoft products/data models; require an explicit integration, permission and ownership decision rather than reusing Slack tools | +| Slack CSV/TSV snippet grid | Offer a downloadable artifact or separately authorized Microsoft file; not a fake native snippet | +| Slack assistant shimmering status/App Home/Block Kit modal stack | Teams-specific presentation work; edit-based status, text commands and authenticated browser flows are useful initial substitutes | +| Slack broadcasts (`@here`, `@channel`, `@everyone`) | Do not promise broad Teams mentions from plain text. No broadcast entity construction exists in the current formatter | +| Delete entire Slack thread | Teams connector can delete bot activities; do not generalize to deleting arbitrary users' messages | + +## Capability descriptor accuracy checks + +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. + +## Completion and release evidence + +1. Keep this branch isolated; no beta merge, production restart or stable promotion is implied. +2. Automated fixtures must cover unauthorized events, cross-conversation IDs, webhook replay, + duplicate Bot/Graph delivery, expired subscriptions, malformed targets and unsupported commands. +3. Live acceptance uses both Claude and Codex in personal chat, a channel and an external-member + group: new message, quote, edit with a newly added real mention, robot reaction on user message, + robot reaction on bot answer, repeated notification and quiet long run. +4. Record exact observed reply/session identity and event source. A fixture pass is not a live pass. +5. Remaining rows stay gaps until code, documentation and acceptance evidence substantiate them. 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.)", diff --git a/public/admin-skills.js b/public/admin-skills.js index 924560a..e5dd064 100644 --- a/public/admin-skills.js +++ b/public/admin-skills.js @@ -5,6 +5,8 @@ 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"; +import { pluginBadge, pluginSummary } from "./plugin-summary.js"; const state = { tab: "usage", @@ -29,7 +31,6 @@ const state = { newSkill: false, sourceModal: false, editTemplate: null, - templateSkillQuery: "", usageChannel: "", usageDays: 30, usage: null, @@ -135,6 +136,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() { @@ -170,7 +178,7 @@ function renderCatalog() { ].map(([value, label]) => ``).join(""); const rows = skills.map((s) => ` - ${esc(s.slug)}${s.visibility === "personal" ? ' personal' : ""}${s.excluded ? ' excluded' : s.deleted ? ' removed' : ""}${s.channelScope ? ` ${esc(channelLabel(s.channelScope))}` : ""}${s.pinnedRevisionId ? ' pinned' : ""}${s.stagedCount ? ` ${s.stagedCount} staged` : ""}${s.currentRevisionId == null && !s.deleted ? ' not active' : ""} + ${esc(s.slug)}${pluginBadge(s)}${s.visibility === "personal" ? ' personal' : ""}${s.excluded ? ' excluded' : s.deleted ? ' removed' : ""}${s.channelScope ? ` ${esc(channelLabel(s.channelScope))}` : ""}${s.pinnedRevisionId ? ' pinned' : ""}${s.stagedCount ? ` ${s.stagedCount} staged` : ""}${s.currentRevisionId == null && !s.deleted ? ' not active' : ""} ${esc(s.description)} ${esc(s.category || "—")} ${esc(ownerLabel(s))} @@ -195,7 +203,7 @@ function renderCatalog() { ${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() : ""}`; @@ -243,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)
@@ -275,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)} @@ -290,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.

@@ -329,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() : ""}`; } @@ -349,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 ? `
${r ? ` ${(r.notes || []).length ? `

How this is counted: ${(r.notes || []).map((n) => esc(n)).join(" ")}

` : ""} - ${state.usageView === "skill" ? `${skillRows || ''}
SkillUsageUsersConversationsLast
No matching skill use in this range.
` : `${channelRows || ''}
ConversationUsageSkills usedLast
No matching conversation use in this range.
`} + ${state.usageView === "skill" ? `${skillRows || ''}
Skill or pluginUsageUsersConversationsLast
No matching skill use in this range.
` : `${channelRows || ''}
ConversationUsageSkills usedLast
No matching conversation use in this range.
`} ${r.channelSlug ? `

Granted but never fired (${r.neverUsed.length}): ${r.neverUsed.map((n) => `${esc(n.slug)}${n.via === "dependency" ? ` required by ${esc((n.requiredBy || []).join(", "))}` : ""}`).join(" ") || "none"}${r.contextTokens != null ? ` · always-on context ~${r.contextTokens} tokens` : ""}

` : ""} ${warnings.length ? `
${warnings.length} conversation${warnings.length === 1 ? "" : "s"} over the skills context soft cap

The warning means the always-loaded skill descriptions consume more context than the limit configured in Sync settings. It does not mean a skill failed.

${warnings.map((c) => `
${esc(c.name || c.slug)} · ~${c.contextTokens} tokens
${esc((c.warningMessages || []).join(" · "))}
`).join("")}
` : ""} ` : '

Choose a range, then load usage.

'}`; @@ -732,19 +732,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 +841,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 +869,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/admin-state.js b/public/admin-state.js index f6987f6..2c14c05 100644 --- a/public/admin-state.js +++ b/public/admin-state.js @@ -30,8 +30,10 @@ export async function loadChannelGuestOptions(request, channelId) { })); } -export function channelGuestSavePatch(ready, selectedIds) { - if (!ready) return {}; +export function channelGuestSavePatch(ready, selectedIds, changed = false) { + // A successful roster may omit a saved grant. Only an explicit guest edit authorizes + // replacing the list; an unrelated save must not turn a partial display into revocation. + if (!ready || !changed) return {}; return { allowedUsers: [...new Set((selectedIds || []).map(String))], }; 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 c5edd33..2ddd762 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); @@ -1439,6 +1440,8 @@ function renderChannelDetail(ch) { const usersBox = card.querySelector(".ch-users"); usersBox.dataset.kind = "u"; usersBox.dataset.ready = ""; + usersBox.dataset.changed = ""; + usersBox.addEventListener("change", () => { usersBox.dataset.changed = "1"; }); usersBox.classList.add("empty"); usersBox.textContent = "Loading current Slack members…"; loadChannelGuestOptions(api, ch.channelId) @@ -1458,16 +1461,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 = { @@ -1767,13 +1799,18 @@ function renderChannelDetail(ch) { const result = await api(`/api/channels/${encodeURIComponent(ch.channelId)}/meta`, { method: "PUT", body: JSON.stringify({ - ...channelGuestSavePatch(usersBox.dataset.ready === "1", explicitCheckedValues(usersBox)), + ...channelGuestSavePatch(usersBox.dataset.ready === "1", explicitCheckedValues(usersBox), usersBox.dataset.changed === "1"), allowedMcps: selectedMcpEntries(mcpsBox, "claude") .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 +1840,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, @@ -1810,9 +1848,10 @@ function renderChannelDetail(ch) { if (acceptedGuests) { const accepted = new Set(acceptedGuests); for (const input of usersBox.querySelectorAll('input[type="checkbox"]')) { - input.checked = accepted.has(input.value); + input.checked = input.dataset.inherited === "1" || accepted.has(input.value); } } + usersBox.dataset.changed = ""; makeToolboxUrlInput.value = ch.meta.makeToolboxUrl || ""; attachReveal(makeToolboxKeyInput, { has: ch.meta.hasMakeToolboxKey, last4: ch.meta.makeToolboxKeyLast4, fetch: revealSecret("channel", "makeToolboxKey", ch.slug) }); makeToolboxState.textContent = ch.meta.hasMakeToolboxKey ? "saved" : "not configured"; @@ -2974,6 +3013,9 @@ 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 } : {}), platformUrl: document.getElementById("set-platform-url").value, @@ -3082,6 +3124,9 @@ 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") }); const teamsEndpoint = s.teams?.messagingEndpoint || "set a Public URL first"; @@ -3514,25 +3559,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"); @@ -3687,6 +3734,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)}`; @@ -3705,7 +3756,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) { @@ -3714,18 +3766,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") { @@ -3755,9 +3810,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); } @@ -3779,12 +3836,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 @@ -3794,7 +3856,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/public/index.html b/public/index.html index 4a50066..e7904a7 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.

+ + +

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

@@ -562,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. - +
- 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 +1047,7 @@

@@ -1180,13 +1186,11 @@

Environment secrets

-

Additional skills

- -
+
diff --git a/public/plugin-summary.js b/public/plugin-summary.js new file mode 100644 index 0000000..80e0207 --- /dev/null +++ b/public/plugin-summary.js @@ -0,0 +1,23 @@ +// Metadata only: never display raw plugin manifests, which may contain connection secrets. +import { escapeHtml as esc } from "./admin-view.js"; + +function pluginOf(item) { + const plugin = item?.plugin || item?.meta?.plugin; + return plugin?.kind === "plugin" ? plugin : null; +} + +export function pluginBadge(item) { + return pluginOf(item) ? ' Plugin' : ""; +} + +export function pluginSummary(item) { + const plugin = pluginOf(item); + if (!plugin) return ""; + const labels = { skills: "Skills", commands: "Commands", agents: "Agents", hooks: "Hooks", mcpServers: "MCP servers", apps: "Apps (unsupported)", lspServers: "LSP servers (unsupported)" }; + const components = Object.entries(labels).flatMap(([key, label]) => { + const paths = Array.isArray(plugin.components?.[key]) ? plugin.components[key].filter((p) => typeof p === "string") : []; + return paths.length ? [`
${label}
${paths.map((path) => `${esc(path)}`).join(", ")}
`] : []; + }).join(""); + const engines = Array.isArray(plugin.engines) ? plugin.engines.filter((e) => e === "claude" || e === "codex").map((e) => e === "claude" ? "Claude" : "Codex") : []; + return `
Plugin manifests
${engines.join(", ") || "Unknown"}
${components}

Engine compatibility is checked when the conversation runs. Skills work with Claude and Codex; native components depend on the selected engine. Unsupported components stop the run with a reason. Hooks require an authorized admin turn in a Full-access conversation.

The whole package shares one approval and grant, including its declared MCP tools. Hooks and server commands can execute code inside the conversation container. Required accounts and credentials must be configured separately; enabling a plugin does not connect them.

`; +} diff --git a/public/skill-assignment-picker.js b/public/skill-assignment-picker.js new file mode 100644 index 0000000..7aede03 --- /dev/null +++ b/public/skill-assignment-picker.js @@ -0,0 +1,110 @@ +// 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"; +import { pluginBadge, pluginSummary } from "./plugin-summary.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); + const expanded = new Set(); + root.classList.add("skill-assignment"); + root.innerHTML = `
+ + +

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 + || 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(resetScroll = false) { + const scrollAreas = [...root.querySelectorAll("[data-picker-active], [data-picker-available]")]; + const scrollPositions = scrollAreas.map((el) => resetScroll ? 0 : el.scrollTop); + 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) => { + 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)}${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'} +
`; + }; + 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."}

`}
`; + scrollAreas.forEach((el, i) => { el.scrollTop = scrollPositions[i]; }); + } + 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; + 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..691c29b 100644 --- a/public/styles.css +++ b/public/styles.css @@ -981,20 +981,52 @@ 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: 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-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-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 > 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; } +.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; } + .skill-assignment-identity > .skill-assignment-source { display: none; } + .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/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/run-tests.mjs b/scripts/run-tests.mjs index 34f0b2f..cc3adba 100644 --- a/scripts/run-tests.mjs +++ b/scripts/run-tests.mjs @@ -79,10 +79,20 @@ function homeSnapshot() { return seen; } +// npm test may be invoked by the live updater, whose environment points at the serving DB. +// Do not let those production selectors override fixtures that exercise legacy aliases. Each +// test supplies its own runtime through helpers or explicit subprocess env; direct `node --test` +// invocations remain available for tests intentionally given a particular environment. +const testEnv = { ...process.env }; +for (const key of ["CHANNELGATE_DIR", "CHANNELGATE_DB", "CLAUDE_GATEWAY_DIR", "CLAUDE_GATEWAY_DB", "CG_WORKSPACE_DIR", "CG_TEST_SCRATCH"]) { + delete testEnv[key]; +} + const before = homeSnapshot(); const result = spawnSync(process.execPath, [...nodeFlags, "--test", ...files], { cwd: repoRoot, stdio: "inherit", + env: testEnv, }); const leaked = [...homeSnapshot()].filter((entry) => !before.has(entry)).sort(); 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/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/slack-app-manifest.json b/slack-app-manifest.json index f54b860..97c1ad3 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", @@ -29,11 +34,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/config/settings.js b/src/config/settings.js index 8a49211..2b9798d 100644 --- a/src/config/settings.js +++ b/src/config/settings.js @@ -182,12 +182,21 @@ export function hasGoogleChatConfig() { // An Azure Bot registration. Outbound needs the app id + secret (+ tenant for a single-tenant app); // INBOUND needs a publicly reachable HTTPS endpoint, which is why `publicUrl` matters here — the // messaging endpoint an operator registers in Azure is `/api/teams/messages`. +export function validateTeamsFileDriveIds(value) { + if (!Array.isArray(value) || value.length > 32 || value.some(id => typeof id !== "string" || !/^[A-Za-z0-9!_-]{1,512}$/.test(id))) throw new TypeError("Teams file drives must be an array of at most 32 valid drive IDs"); + return [...new Set(value)]; +} + export function resolveTeamsConfig() { const s = getSettings(); return { 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, + filesEnabled: s.teamsFilesEnabled === true, + fileDriveIds: (() => { try { return validateTeamsFileDriveIds(s.teamsFileDriveIds || []); } catch { return []; } })(), + publicUrl: getPublicUrl(), }; } @@ -833,6 +842,9 @@ export function settingsForApi() { hasAppPassword: Boolean(resolveTeamsConfig().appPassword), appPasswordLast4: last4(resolveTeamsConfig().appPassword), tenantId: resolveTeamsConfig().tenantId, + allMessageEvents: resolveTeamsConfig().allMessageEvents, + filesEnabled: resolveTeamsConfig().filesEnabled, + fileDriveIds: resolveTeamsConfig().fileDriveIds, 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 cb190d0..5e86f43 100644 --- a/src/db/migrations.js +++ b/src/db/migrations.js @@ -760,4 +760,30 @@ export const migrations = [ `); }, }, + { + version: 23, + up(db) { + db.exec(` + CREATE TABLE conversation_reply_sessions ( + conversation_id TEXT NOT NULL, + message_id TEXT NOT NULL, + thread_key TEXT NOT NULL, + PRIMARY KEY(conversation_id, message_id) + ); + `); + }, + }, + { + 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/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/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/claude.js b/src/engines/claude.js index 718006f..40531e2 100644 --- a/src/engines/claude.js +++ b/src/engines/claude.js @@ -349,6 +349,9 @@ export async function runClaude({ } const content = stream.text || result?.result || ""; + // Process exit is not a turn-completion event. Commentary can be streamed before either + // an explicit harness failure or an exit that never delivered the terminal result. + const engineError = !result || result.is_error === true || /^error(?:_|$)/.test(result.subtype || ""); resolve({ content, sessionId: result?.session_id ?? sessionId, @@ -358,15 +361,16 @@ export async function runClaude({ // How the CLI itself says the turn ended ("success", "error_during_execution", // "error_max_turns", …). Exit 0 does NOT mean the turn succeeded: the CLI reports an // aborted turn as a terminal result line with is_error and no `result` text. Kept on the - // result so an answerless turn can name its reason instead of posting "(empty response)". - endReason: String(result?.subtype || ""), - engineError: result?.is_error === true, + // result so partial narration cannot hide an incomplete turn. + endReason: String(result?.subtype || (!result ? "missing_terminal_result" : "")), + engineError, + completed: !engineError, toolUseCount: stream.toolUseCount, primaryModel: stream.model, - // Only on a turn with no answer: the CLI's stderr tail is where the real cause of an + // On a failed or answerless turn: the CLI's stderr tail is where the real cause of an // aborted turn is written, and exit 0 means no error path ever reads it. Redacted and // capped by conciseProcessDiagnostic; carried for the log, not for the reply. - ...(content.trim() ? {} : { diagnostic: conciseProcessDiagnostic(stderr) }), + ...(!engineError && content.trim() ? {} : { diagnostic: conciseProcessDiagnostic(stderr) }), raw: result, }); }); diff --git a/src/engines/codex.js b/src/engines/codex.js index 1842de5..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 }); } @@ -943,6 +954,13 @@ export async function runCodex({ let stderr = ""; let buffer = ""; let deltaText = ""; + let answerText = ""; + let phasedAnswerText = ""; + let hasMessagePhase = false; + const messagePhases = new Map(); + let completed = false; + const answerState = createCodexProgressState(); + const phasedAnswerState = createCodexProgressState(); // Per-run, because the only cross-event state a mapping needs is where the previous assistant // message segment ended (see codexMessageDelta). const progressState = createCodexProgressState(); @@ -1033,6 +1051,9 @@ export async function runCodex({ const handleLine = (line) => { const p = parseJsonLine(line); if (!p) return; + const messageId = p.item?.id || p.item_id || p.itemId; + const explicitPhase = p.item?.phase ?? p.phase; + if (messageId && explicitPhase) messagePhases.set(messageId, explicitPhase); if (codexItemMayExecuteTool(p.item)) { if (p.type === "item.started") toolUseCount += 1; else if (p.type === "item.completed" && toolUseCount === 0) toolUseCount = 1; @@ -1047,6 +1068,7 @@ export async function runCodex({ } break; case "turn.completed": + completed = true; // Token usage (best-effort across schema variants). usage = p.usage || p.turn?.usage || usage; raw = p; @@ -1054,6 +1076,7 @@ export async function runCodex({ case "turn.failed": case "error": turnError = codexTurnError(p); + usage = p.usage || p.turn?.usage || usage; break; default: { // A collaboration item is stdout's ONLY sign that children exist: multi-agent v2 sends an @@ -1062,6 +1085,20 @@ export async function runCodex({ if (codexIsCollabItem(p.item)) announceChildAgents(); // Best-effort live progress for Slack; final answer content still comes from outFile. const progress = progressFromCodexEvent(p, progressState); + const phase = explicitPhase || messagePhases.get(messageId); + if (progress?.delta || (phase && codexItemKey(p.item?.type) === "agentmessage")) { + // Commentary remains live progress, never a final answer. Older CLI releases omit + // phase entirely; their text stays compatible once turn.completed proves success. + if (phase) hasMessagePhase = true; + if (!phase || phase === "final" || phase === "final_answer") { + const answer = progressFromCodexEvent(p, answerState); + if (answer?.delta) answerText = appendTail(answerText, answer.delta, MAX_RETAINED); + } + if (phase === "final" || phase === "final_answer") { + const answer = progressFromCodexEvent(p, phasedAnswerState); + if (answer?.delta) phasedAnswerText = appendTail(phasedAnswerText, answer.delta, MAX_RETAINED); + } + } if (progress?.delta) { deltaText = appendTail(deltaText, progress.delta, MAX_RETAINED); // fallback content only — outFile is authoritative onDelta?.(progress.delta); @@ -1146,7 +1183,7 @@ export async function runCodex({ if (buffer.trim()) handleLine(buffer.trim()); if (stderrBuffer.trim()) { handleStderrLine(stderrBuffer.trim()); stderrBuffer = ""; } - // Authoritative final message from the -o file; fall back to accumulated deltas. + // The output file is answer content, never proof that the process finished successfully. let finalText = ""; try { finalText = (await readFile(outFile, "utf8")).trim(); @@ -1156,10 +1193,68 @@ export async function runCodex({ await rm(scratchDir, { recursive: true, force: true }).catch(() => {}); for (const file of removeRunSecrets()) await rm(file, { force: true }).catch(() => {}); + // Failed turns often have no terminal usage event. Inspect their rollouts through the same + // runtime reader as successful turns before choosing the outcome, including child spend. + let accounting; + let terminalUsage = usage || {}; + try { + // The reducer compares request rows with a cumulative counter. A failed CLI may omit + // that counter from stdout; its latest rollout snapshot supplies the same evidence. + if (!usage) terminalUsage = (await usageReader.snapshot(resolvedSessionId)).total || {}; + accounting = await usageReader.root({ + sessionId: resolvedSessionId, + snapshot: usageSnapshot, + terminalUsage, + configuredModel: model, + startedAtMs: accountingStartedAt, + }); + } catch { + warnInspection(); + accounting = { + usage: subtractCodexTokenUsage(terminalUsage, usageSnapshot.total || {}), + requests: [], + exactRequests: false, + model: model || usageSnapshot.model || "", + sourceId: `codex-turn:${resolvedSessionId}:${Math.floor(accountingStartedAt / 1000)}`, + startedAt: new Date(accountingStartedAt).toISOString(), + endedAt: new Date().toISOString(), + }; + } + const accountingEndedAt = Date.now(); + const children = await usageReader.accounting({ + rootSessionId: resolvedSessionId, + startedAtMs: accountingStartedAt, + endedAtMs: accountingEndedAt, + }).catch(() => { warnInspection(); return []; }); // A turn that attempted ANY tool, or already streamed text, may have mutated something — it // is never replayed, whatever the provider said. `didWork` is the single proof every failure // path below shares. - const didWork = toolUseCount > 0 || Boolean(deltaText.trim()); + const didWork = toolUseCount > 0 || Boolean(deltaText.trim()) || Boolean(finalText); + const failureDetails = { + engine: "codex", + partialContent: finalText || deltaText, + incompleteTurn: true, + sessionId: resolvedSessionId, + usage: accounting.usage, + result: { + content: finalText || deltaText, + sessionId: resolvedSessionId, + usage: accounting.usage, + usageRequests: accounting.requests, + usageAccounting: { root: accounting, children }, + runtimeModel: accounting.model || model || "", + costUSD: null, + durationMs: Date.now() - startedAt, + toolUseCount, + raw, + engine: "codex", + }, + durationMs: Date.now() - startedAt, + toolUseCount, + replaySafe: false, + exitCode: code, + signal: exitSignal || null, + }; if (timedOut) { // A wedged turn is still worth classifying: the reason it went quiet is usually sitting in // the stderr we buffered. Naming it turns an opaque "no output" into an actionable @@ -1171,6 +1266,7 @@ export async function runCodex({ const waited = describeSilence(silenceMs || timeoutMs); if (wedgedKind && wedgedKind !== "model_rejected") { return reject(commandError(`${codexProcessFailureMessage(wedgedKind, stderr)} (no output for ${waited})`, { + ...failureDetails, engine: "codex", providerError: true, providerCode: "", @@ -1185,11 +1281,12 @@ export async function runCodex({ const wedgedNote = codexDiagnosticLine(stderr); return reject(commandError( `Codex produced no output for ${waited} — giving up${wedgedNote ? ` (last diagnostic: ${wedgedNote})` : ""}`, - { stderr: stderr.slice(0, 4000) }, + { ...failureDetails, stderr: stderr.slice(0, 4000) }, )); } if (signal?.aborted) { return reject(Object.assign(commandError("Codex run was stopped before it finished.", { + ...failureDetails, stderr: stderr.slice(0, 4000), exitCode: code, signal: exitSignal || null, @@ -1197,8 +1294,9 @@ export async function runCodex({ } // Credential loss caught on stderr mid-run (see handleStderrLine): the process was ended on // purpose, so report the classified failure rather than the exit code that ending produced. - if (liveFailure && !finalText) { + if (liveFailure) { return reject(commandError(codexProcessFailureMessage(liveFailure.kind, liveFailure.line || stderr), { + ...failureDetails, engine: "codex", providerError: true, providerCode: "", @@ -1212,9 +1310,10 @@ export async function runCodex({ signal: exitSignal || null, })); } - if (turnError && !finalText) { + if (turnError) { const replaySafe = REPLAY_SAFE_KINDS.includes(turnError.details.providerKind) && !didWork; return reject(commandError(turnError.message, { + ...failureDetails, ...turnError.details, stderr: stderr.slice(0, 4000), requestedModel: model, @@ -1222,7 +1321,7 @@ export async function runCodex({ toolUseCount, })); } - if ((code !== 0 || exitSignal) && !finalText) { + if (code !== 0 || exitSignal) { // Some Codex builds print the limit/auth notice on stderr and exit nonzero WITHOUT emitting // a JSON error event. Classify that too, so the same actionable message and failover path // apply instead of an opaque "Codex exited with code 1". Same `source: "stderr"` rule as @@ -1231,6 +1330,7 @@ export async function runCodex({ const kind = classifyCodexFailure({ message: stderr, source: "stderr" }); if (kind && kind !== "model_rejected") { return reject(commandError(codexProcessFailureMessage(kind, stderr), { + ...failureDetails, engine: "codex", providerError: true, providerCode: "", @@ -1245,6 +1345,7 @@ export async function runCodex({ })); } return reject(commandError(processFailureMessage("Codex", { code, signal: exitSignal, diagnostic: stderr }), { + ...failureDetails, stderr: stderr.slice(0, 4000), exitCode: code, signal: exitSignal || null, @@ -1254,33 +1355,15 @@ export async function runCodex({ })); } - let accounting; - try { - accounting = await usageReader.root({ - sessionId: resolvedSessionId, - snapshot: usageSnapshot, - terminalUsage: usage || {}, - configuredModel: model, - startedAtMs: accountingStartedAt, - }); - } catch { - warnInspection(); - accounting = { - usage: subtractCodexTokenUsage(usage || {}, usageSnapshot.total || {}), - requests: [], - exactRequests: false, - model: model || usageSnapshot.model || "", - sourceId: `codex-turn:${resolvedSessionId}:${Math.floor(accountingStartedAt / 1000)}`, - startedAt: new Date(accountingStartedAt).toISOString(), - endedAt: new Date().toISOString(), - }; + if (!completed) { + return reject(commandError("Codex stopped before reporting that the turn completed.", { + ...failureDetails, + stderr: stderr.slice(0, 4000), + runtime: runtime.backend, + processEnded: true, + })); } - const accountingEndedAt = Date.now(); - const children = await usageReader.accounting({ - rootSessionId: resolvedSessionId, - startedAtMs: accountingStartedAt, - endedAtMs: accountingEndedAt, - }).catch(() => { warnInspection(); return []; }); + // Let any in-flight live announcement land FIRST. A "running" row that arrived after the // terminal one below would reopen a child that has already finished. await childScan; @@ -1304,7 +1387,7 @@ export async function runCodex({ } resolve({ - content: finalText || deltaText || "", + content: hasMessagePhase ? (phasedAnswerText ? finalText || phasedAnswerText : "") : (finalText || answerText || ""), sessionId: resolvedSessionId, usage: accounting.usage, usageRequests: accounting.requests, diff --git a/src/engines/persistent-session.js b/src/engines/persistent-session.js index 771a257..ea6e916 100644 --- a/src/engines/persistent-session.js +++ b/src/engines/persistent-session.js @@ -280,13 +280,14 @@ export class PersistentClaudeSession { this.turn.stream.consume(p); if (p.type === "result") { const t = this.turn; + const engineError = p.is_error === true || /^error(?:_|$)/.test(p.subtype || ""); // The CLI reported a provider failure for this turn (the assistant `error` marker) and — a // long-lived process — stayed alive to end it as an is_error result. Print mode exits 1 on // the same failure and the cold runner rejects with the classified error; do the same here, // so the orchestrator's in-place retry and cross-engine failover see the outage instead of // the "API Error: …" text being posted as the reply. The process is retired with the turn // (_die evicts it from the pool); the retry spawns afresh, exactly as on the cold path. - if (p.is_error === true && t.providerError && !t.interrupted) { + if (engineError && t.providerError && !t.interrupted) { this._die(new Error(t.providerError.message)); return; } @@ -297,14 +298,15 @@ export class PersistentClaudeSession { sessionId: p.session_id ?? null, durationMs: Date.now() - t.startedAt, // Same contract as the cold runner (claude.js): the CLI's own verdict on the turn travels - // with the result, so a turn that ends with no text can say WHY instead of "(empty response)". + // with the result, so partial narration cannot hide an incomplete turn. endReason: String(p.subtype || ""), - engineError: p.is_error === true, + engineError, + completed: !engineError && !t.interrupted, toolUseCount: t.stream.toolUseCount, primaryModel: t.stream.model, - // Only when the turn produced no answer: on an abort the CLI never exits, so no error path + // On a failed or answerless turn: on an abort the CLI never exits, so no error path // ever reads what it wrote to stderr (redacted + capped by conciseProcessDiagnostic). - ...(t.stream.text || p.result ? {} : { diagnostic: conciseProcessDiagnostic((this.stderr || "").slice(t.stderrAt)) }), + ...(!engineError && (t.stream.text || p.result) ? {} : { diagnostic: conciseProcessDiagnostic((this.stderr || "").slice(t.stderrAt)) }), interrupted: Boolean(t.interrupted), // we steered this turn — result is intentionally cut short raw: p, }); diff --git a/src/gateway/access-grants.js b/src/gateway/access-grants.js index ec181d9..fe10912 100644 --- a/src/gateway/access-grants.js +++ b/src/gateway/access-grants.js @@ -68,6 +68,35 @@ export function resolveAccessGrants({ organization = {}, channel = {}, user = {} ); } +// Tombstones preserve grants for catalog history/restoration, but an author's deleted personal +// skill is no longer an active instruction. Omit only that proven state, never an unknown or +// unavailable source. Check ownership before considering deletion, including dependencies, so +// a stale or forged grant cannot turn another author's private skill into a readable overlay. +function currentUserSkillGrants(names, authorId, lookupSkill) { + const lookup = (name) => { + const skill = lookupSkill(name); + if (skill?.visibility === "personal" && (!authorId || skill.createdBy !== authorId)) { + throw new Error("Personal skill grant is not available to this author"); + } + return skill; + }; + const retained = sanitizeSkillGrantNames(names).filter((name) => { + const skill = lookup(name); + return !(skill?.deleted && skill.ownerKind === "local" && skill.visibility === "personal"); + }); + const visited = new Set(); + const visit = (name) => { + const key = String(name).toLowerCase(); + if (visited.has(key)) return; + visited.add(key); + const skill = lookup(name); + if (!skill || skill.deleted) return; + for (const dependency of skill.requires || []) visit(dependency); + }; + for (const name of retained) visit(name); + return retained; +} + // Resolve the durable (organization + channel) grants separately from the active principal's // effective grants. HTTP run callers supply an author id but do not authenticate that Slack user, // so an untrusted principal must not even trigger a stored-user lookup: naming somebody else's @@ -78,8 +107,10 @@ export async function resolveRunAccessGrants({ authorId = "", untrustedPrincipal = false, loadUser = async () => null, + lookupSkill = () => null, } = {}) { - const user = untrustedPrincipal ? {} : ((await loadUser(authorId)) || {}); + const storedUser = untrustedPrincipal ? {} : ((await loadUser(authorId)) || {}); + const user = { ...storedUser, skills: currentUserSkillGrants(storedUser.skills, authorId, lookupSkill) }; return { shared: resolveAccessGrants({ organization, channel }), effective: resolveAccessGrants({ organization, channel, user }), diff --git a/src/gateway/active-runs.js b/src/gateway/active-runs.js index 5f36af5..167bfbe 100644 --- a/src/gateway/active-runs.js +++ b/src/gateway/active-runs.js @@ -272,10 +272,55 @@ export function takeStaleRuns() { // MAX_RECOVER_ATTEMPTS boots instead of looping forever. const MAX_RECOVER_ATTEMPTS = 2; -// Auto re-run the interrupted turns captured by takeStaleRuns(). Runs sequentially so a restart -// with several interrupted threads doesn't spawn a burst of `claude` processes at once. Each turn -// re-runs as its original author (so their mode/tokens/permissions apply unchanged) and posts the -// answer back into the same thread. Best-effort throughout — one failed turn never blocks the rest. +// Reserve every survivor before reconnecting Slack. New messages then use the ordinary busy-thread +// steer/queue contract instead of overtaking an older recovery that has not entered the queue yet. +export function reserveRecoveryRuns(stale = []) { + const reservations = []; + for (const rec of stale) { + if (rec?.awaitingChoice || !rec?.channelId || !rec?.threadKey) continue; + const id = rec.id || `${rec.slug}::${rec.threadKey}`; + const runKey = `${rec.slug || rec.channelId}::${rec.threadKey}`; + if (runQueue.hasRun(runKey, id)) continue; + const handle = { aborted: false, controller: new AbortController(), authorId: rec.authorId, recovery: true, runId: id }; + recordActiveRun(id, { ...rec, id, recoveryPending: true }); + // Attach rejection handling immediately: a full queue must not produce an unhandled rejection + // while Slack is still connecting. The replay reports it through its normal failure path. + const acquired = runQueue.acquire(runKey, handle).then(() => null, (error) => error); + reservations.push({ rec, id, runKey, handle, acquired }); + } + return reservations; +} + +function pendingRecoveryRuns() { + return getDb().prepare("SELECT data FROM active_runs ORDER BY rowid").all() + .map((row) => fromJson(row.data, null)).filter((rec) => rec?.recoveryPending && !rec.awaitingChoice); +} + +// Arm before Slack connects; reconnect callbacks are idempotent and never block connection setup. +export function createRunRecovery(stale, options) { + let reservations = reserveRecoveryRuns(stale); + let running = null; + let requested = true; + const start = () => { + if (running) return running; + if (!requested || !options.slack?.snapshot?.().connected || !options.slack.getClient?.()) return Promise.resolve(); + requested = false; + const batch = reservations; + reservations = []; + batch.push(...reserveRecoveryRuns(pendingRecoveryRuns())); + running = recoverRuns([], { ...options, reservations: batch }).finally(() => { + running = null; + if (requested) queueMicrotask(() => start().catch((error) => console.error("[gateway] reconnect recovery failed:", error.message))); + }); + return running; + }; + const unsubscribe = options.slack?.onConnected?.(() => { requested = true; return start(); }); + return { start, unsubscribe }; +} + +// Every thread receives progress immediately. Independent threads use the existing global engine +// admission limit; turns in one thread keep their reserved queue order. No slow recovery can hide +// the other interrupted threads for an entire model turn. export async function recoverRuns(stale, { slack, runner = runMessage, @@ -284,33 +329,29 @@ export async function recoverRuns(stale, { progressFactory = startProgress, directoryResolver = getDirectory, forceStopping = isForceStopping, + reservations, } = {}) { - if (!stale?.length) return; + if (!stale?.length && !reservations?.length) return; const client = slack?.snapshot?.().connected ? slack.getClient?.() ?? null : null; if (!client) { // No Slack client → we can't deliver the answer, so re-running would just burn a turn silently. - // The durable rows stay untouched (takeStaleRuns no longer deletes), so the next boot retries. - await logEvent("run_recover_skip", { count: stale.length, reason: "slack not connected" }); + // The boot coordinator retains queue ownership and retries when Slack reconnects. + await logEvent("run_recover_skip", { count: reservations?.length || stale?.length || 0, reason: "slack not connected" }); return; } - await logEvent("run_recover_begin", { count: stale.length }); - for (const rec of stale) { - if (rec?.awaitingChoice) continue; - if (!rec?.channelId || !rec?.threadKey) continue; - const id = rec.id || `${rec.slug}::${rec.threadKey}`; - const runKey = `${rec.slug || rec.channelId}::${rec.threadKey}`; - // Slack reconnects BEFORE this runs (it needs a client to deliver), so a redelivered copy of - // this same message can beat recovery into the queue. Run ids encode the triggering message, - // so a live turn under this id IS this turn — replaying it would answer twice. The live run - // owns the row and clears it when it finishes. - if (runQueue.hasRun(runKey, id)) { - await logEvent("run_recover_skip", { slug: rec.slug, channel: rec.channelId, threadKey: rec.threadKey, reason: "already live" }); - continue; + const batch = reservations || reserveRecoveryRuns(stale); + await logEvent("run_recover_begin", { count: batch.length }); + await Promise.all(batch.map(async ({ rec, id, runKey, handle, acquired }) => { + if (handle.aborted || handle.controller.signal.aborted) { + clearActiveRun(id); + runQueue.release(runKey, handle); + return; } const attempts = (rec.attempts || 0) + 1; if (attempts > MAX_RECOVER_ATTEMPTS) { // The attempt cap is this turn's explicit terminal failure — only now is its row deleted. clearActiveRun(id); + runQueue.release(runKey, handle); await logEvent("run_recover_giveup", { slug: rec.slug, channel: rec.channelId, threadKey: rec.threadKey, attempts }); try { await postNotice(client, { @@ -321,9 +362,9 @@ export async function recoverRuns(stale, { } catch { /* ignore */ } - continue; + return; } - await logEvent("run_recover", { slug: rec.slug, channel: rec.channelId, threadKey: rec.threadKey, attempts }); + await logEvent("run_recover_queued", { slug: rec.slug, channel: rec.channelId, threadKey: rec.threadKey }); // Heads-up so the dangling "Working…" placeholder above it is explained and the thread shows // the turn is being picked up again before the (possibly slow) re-run finishes. try { @@ -338,8 +379,7 @@ export async function recoverRuns(stale, { // Re-track the replay with the bumped attempt counter, so a restart landing mid-replay // retries on the next boot (up to the cap) instead of silently dropping the turn. // …and drop the boot stamp: from here on this row IS a live run again. - recordActiveRun(id, { ...rec, id, attempts, recoveryPending: undefined }); - const handle = { aborted: false, controller: new AbortController(), authorId: rec.authorId, recovery: true, runId: id }; + recordActiveRun(id, { ...rec, id, recoveryPending: undefined }); // Start native progress without delaying recovery on directory hydration. The mutable object is // shared with the formatter, so later name resolution applies to subsequent streamed deltas. const liveDirectory = { map: new Map(), maxWords: 5 }; @@ -362,15 +402,8 @@ export async function recoverRuns(stale, { // Idempotent, pre-delivery accounting for this replay (see createUsageBank). const bankUsage = createUsageBank(usageRecorder); try { - // Recovery used to bypass the Slack pipeline's per-thread queue. Once Socket Mode reconnected, - // a live message could therefore race this replay into the same session and replace its warm - // process. Use the exact same queue key/ownership contract as foreground Slack turns. - await runQueue.acquire(runKey, handle); - if (handle.aborted) { - markTerminal(); - continue; - } - if (forceStopping()) continue; + if (handle.aborted) { markTerminal(); return; } + if (forceStopping()) return; // A recovered turn is still an interactive Slack turn. Reconnect it to the same two native // surfaces as a foreground run whenever the Slack SDK supports native streaming. Older SDKs // and test clients retain the safe final-only delivery path instead of losing the recovery. @@ -395,6 +428,14 @@ export async function recoverRuns(stale, { status = null; } } + status?.onEvent?.({ kind: "engine_note", text: "waiting to resume after gateway restart" }); + const queueError = await acquired; + if (queueError) throw queueError; + if (handle.aborted) { markTerminal(); return; } + if (handle.controller.signal.aborted) { markTerminal(); return; } + if (forceStopping()) return; + recordActiveRun(id, { ...rec, id, attempts, recoveryPending: undefined }); + await logEvent("run_recover", { slug: rec.slug, channel: rec.channelId, threadKey: rec.threadKey, attempts }); const result = await runner({ channelId: rec.channelId, authorId: rec.authorId, @@ -418,23 +459,31 @@ export async function recoverRuns(stale, { // every branch below (steered handoff, empty-result failure, delivery) is downstream of that // spend — so bank the accounting here, once, before any of them. await bankUsage({ channelId: rec.channelId, slug: rec.slug, authorId: rec.authorId, engine: result.engine, taskKind: "interactive", result }); + if (handle.aborted || (handle.controller.signal.aborted && !handle.steered)) { + markTerminal(); + await stopStatus(); + return; + } if (handle.steered && (result.interrupted || isEmptyResult(result))) { // Warm Claude recovery was intentionally interrupted by an explicit steer choice. Keep // partial/empty output hidden; its usage is already banked above. markTerminal(); await stopStatus(); - continue; + return; } // Zero work (empty text, 0 tokens) = failure in disguise (usually a broken session state // after the kill) — route it through the failure path below, not a "(no output)" success. if (isEmptyResult(result)) throw new Error("resumed run returned an empty result (0 tokens — the session may be in a bad state)"); // Native recovery progress owns the streamed answer and persistent toolbox. A client without // that capability keeps the old safe, chunked final-only delivery path. + if (forceStopping()) return; if (status?.ownsFinal) { await status.finalize(result); statusClosed = true; } else { await stopStatus(); + if (handle.aborted || handle.controller.signal.aborted) { markTerminal(); return; } + if (forceStopping()) return; // Compatibility fallback for an injected/legacy non-owning progress surface. await deliver(client, { channel: rec.channelId, threadKey: rec.threadKey, result }); } @@ -447,13 +496,13 @@ export async function recoverRuns(stale, { // A live message explicitly steered this recovered turn. Its successor owns the thread; // do not report the intentional process abort as a failed restart recovery. markTerminal(); - continue; + return; } if (handle.aborted) { markTerminal(); // an explicit stop already told the thread; never replay it - continue; + return; } - if (forceStopping()) continue; // final sweep interrupted us; keep the row for next boot + if (forceStopping()) return; // final sweep interrupted us; keep the row for next boot await logEvent("run_recover_error", { slug: rec.slug, error: err.message }); let told = false; try { @@ -475,12 +524,12 @@ export async function recoverRuns(stale, { // deleting the row would silently drop the turn. Keep it; the attempt cap above is the // terminal bound, and the next boot retries. if (told) markTerminal(); - else keepRow = true; + else keepRow = true; // already attempted: reconnect must not replay uncertain side effects } finally { await stopStatus(); runQueue.release(runKey, handle); if (!keepRow && shouldClearActiveRun({ terminal, forceStopping: forceStopping() })) clearActiveRun(id); } - } - await logEvent("run_recover_done", { count: stale.length }); + })); + await logEvent("run_recover_done", { count: batch.length }); } 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/channel-credentials.js b/src/gateway/channel-credentials.js new file mode 100644 index 0000000..0f7a781 --- /dev/null +++ b/src/gateway/channel-credentials.js @@ -0,0 +1,15 @@ +import { safeSpawnEnv } from "../config/channel-env.js"; + +// Describe the SAME resolved environment the runners receive, never the stored metadata or the +// daemon's process.env. Rebuild per turn so a resumed conversation cannot retain a revoked name. +export function channelCredentialsPreamble(resolved = {}, { clean = false } = {}) { + if (clean) return ""; + const names = Object.keys(safeSpawnEnv(resolved)).sort(); + return "[Channel credentials for THIS attempt]\n" + + `Available channel environment variable names: ${JSON.stringify(names)}.\n` + + "This inventory replaces earlier turns' channel credential inventories. It lists only variables injected into this run; an empty list means none were injected, not that all CLI logins or MCP connections are absent.\n" + + "Before declaring missing access or requesting a new connection, check relevant names here alongside task skills, existing CLI authentication and MCP tools. These channel-scoped variables are usable from the process environment for authorized API/CLI calls; they are not a project .env file. Some clients require explicitly passing the variable.\n" + + "A variable name does not prove its account, permissions or validity. Preserve the requested account; ask when identity is ambiguous and never silently substitute a channel credential for a personal connection. Respect this run's tool and network permissions.\n" + + "Check presence without printing values. Never dump the environment, print credentials, or copy them into files, replies or memory.\n" + + "[End channel credentials]\n\n"; +} diff --git a/src/gateway/folders.js b/src/gateway/folders.js index 472be0c..eb655d9 100644 --- a/src/gateway/folders.js +++ b/src/gateway/folders.js @@ -169,7 +169,7 @@ export function channelSwitchesNote(meta = {}) { // requester with NO personal Composio token asked for THEIR OWN mailbox; the run had only // `composio-agent`, silently used it, and reported a third employee's address and subject lines as // the requester's. Substitution is worse than ambiguity: the request was not ambiguous at all, and -// the shared identity is exactly where OTHER people's accounts live. One engine refused; the other +// the shared identity may expose a different person's account. One engine refused; the other // substituted, because the rule against it lived only in the skill body. // // It is stated as a rule and not enforced structurally on purpose, and it is written to be @@ -178,25 +178,29 @@ export function channelSwitchesNote(meta = {}) { // never name WHICH identities a given run received: `composio-user` is per-AUTHOR, and two people // messaging the same channel concurrently would race a per-author sentence through one shared file. // -// That per-run fact rides the PROMPT instead (CO-04, 2026-09-07: with both identities injected, one -// engine answered "check the calendar" from the shared identity and posted a colleague's week into -// the channel, where the other asked "which account?" first). `run.js` prepends one line naming the +// That per-run fact rides the PROMPT instead. Unrestricted reads can use either identity, while +// mutations and explicitly scoped reads preserve the intended account. `run.js` names the // identities this turn actually received, next to the fresh-session memory catalog and the // caller's provenance note — per run, per author, so there is no shared file to race. Its text and // the predicate behind it live in src/gateway/mcp.js, beside the code that names those servers. const HARD_RULES = `**Hard rules (not optional)** — they apply wherever the named tools exist; the reasoning and the tool shapes are in the \`gateway-usage\` skill: - **Two Composio identities.** \`composio-user\` = the REQUESTER's own accounts; \`composio-agent\` = the - shared agent's own (either may appear with \`_\` for \`-\`). If the request names neither and BOTH - could serve it, your reply is the question "which account?" — not a tool call, not a read-only - peek: a guessed read puts someone's private data in front of everyone here, and no correction - takes it back. + shared agent's own (either may appear with \`_\` for \`-\`). Reads and searches may use either or + both identities without asking which account unless the user restricts the account or scope. + Writes require the intended identity and connected account: reuse prior choices, else ask + "which account?" before mutating. Independent reads can continue; reads do not select writers. +- **Identity is routing, not proof of ownership.** Logical identities do not prove who owns + a connected service account; personal and agent connections may reach the same service owner. + The agent identity is not necessarily shared across channels: it can be channel-specific or + reuse an organization default. Discover account metadata through the selected identity before + claiming an owner or cross-channel sharing; do not read business data just to identify an account. - **"My X" is the requester's X — never the shared one.** A request phrased for the person asking ("my inbox", "my calendar", their own name) is served ONLY by \`composio-user\`. If \`composio-user\` is absent from this run, or has no connection for that app, SAY THAT and stop — do not read - \`composio-agent\` to answer it, not even to check: the shared identity holds OTHER people's - accounts, so reporting its address, events or subjects as "yours" hands a third party's mail to - whoever is in this conversation. The mirror is a rule too: "your X" / "the agent's X" never + \`composio-agent\` to answer it, not even to check: substitution may expose someone else's data + or misattribute an account. Even a matching service owner does not authorize switching identities. + The mirror is a rule too: "your X" / "the agent's X" never touches \`composio-user\`. - **An inventory is not a connection.** Ask what is connected with that identity's \`COMPOSIO_SEARCH_TOOLS\` (read \`toolkit_connection_statuses[]\`). \`COMPOSIO_MANAGE_CONNECTIONS\` — @@ -396,7 +400,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 +417,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/SKILL.md b/src/gateway/gateway-usage/SKILL.md index f4c15dd..30b7cbe 100644 --- a/src/gateway/gateway-usage/SKILL.md +++ b/src/gateway/gateway-usage/SKILL.md @@ -42,6 +42,29 @@ accounts when configured: **`composio-agent` is YOUR OWN account** and **`compos requester's personal account** — see “Tool identities” below. This skill maps what you can do to the tool that does it. +## Discover access before requesting a connection + +For an integration task, use **Tool identities** below: reads and searches may use either or both +identities unless the user restricts the account or scope; writes require the intended account. +Then check the relevant granted +skills, the current **[Channel credentials for THIS attempt]** inventory, available CLI +authentication and MCP tools before reporting missing access or asking for another connection. +An absent MCP does not establish that an API or CLI is unavailable. + +The inventory lists only channel environment variable names actually supplied to this attempt, +including resumed turns; its current names or explicit empty result override earlier turns. +It contains no values or token suffixes. Clean runs omit this inventory; an absent inventory +does not prove that a credential exists or that access is available. Read +`references/administration.md` for safe presence checks and API/CLI use. + +Use a route that matches the requested account and scope. A variable name identifies a possible +credential, not its owner, validity or permissions. Never silently switch accounts or substitute +a channel credential for an explicitly requested personal/Composio identity. Check only relevant +names or non-secret CLI authentication status; never dump the full environment or print secret +values. Credential availability does not grant tool permissions, network access or authorization +for additional actions. If the authorized routes fail, describe the actual failure and which +credential or connection is needed, without exposing its value. + ## The eight rules that apply to EVERY turn 1. **Keep it short and write standard Markdown.** Lead with the outcome in a few lines. No long @@ -128,6 +151,7 @@ the tool that does it. | Get the user to sign off on a plan / action | `references/approvals.md` | `gateway` → `request_approval` | | Handle Claude/Codex authentication failures | `references/administration.md` | Explain the required host-side login/API-key repair | | Connect a provider CLI with a device code | `references/cli-device-login.md` | Live TTY/session + interim code/link + same-turn polling + identity verification | +| Find API/CLI access or diagnose a missing integration | `references/administration.md` | Current channel credential names, relevant skills, non-secret CLI auth status, selected-account MCP discovery | | A file/path outside the working folder seems missing, or host access is needed | `references/administration.md` | Check this run's resolved mounts and the optional operator-home grant; `~` remains the channel's own home | | See, grant or remove skills here, apply a skills template, create/update/propose a skill, see skill usage | `references/skills.md` | `gateway` → `show_channel_skills`, `add_channel_skills`, `apply_skill_template`, `create_skill`, `propose_skill_change`, `skill_usage_report` | | Change a channel/gateway setting, tokens, update/restart, or this guide | `references/administration.md` | `gateway` → `set_channel_*`, `set_my_*_token`, `update_gateway`, `restart_gateway`, `update_gateway_guide` | @@ -141,13 +165,19 @@ Three identities can act, and every tool name says which one: history, scheduling/reminders, memory, background jobs, channel admin. - **`composio-agent`** (`mcp__composio-agent__*`, sometimes normalized to `mcp__composio_agent__*`) is **YOUR OWN Composio account** — the shared agent's - connections: *your* email, *your* calendar, *your* Slack, *your* CRM login. Where its credential - comes from (a channel or organization configuration) is an admin detail you never need to - mention; to you it is simply your account. + connections: *your* email, *your* calendar, *your* Slack, *your* CRM login. This is a logical + routing identity, not proof of the connected service owner's name or cross-channel sharing. - **`composio-user`** (`mcp__composio-user__*`, sometimes normalized to `mcp__composio_user__*`) is the **requester's personal Composio account** — the person who sent this message (named in the Provenance line) and *their* own connections. +Personal and agent connections may reach the same service owner or different owners. Discover +account metadata through the selected identity before making ownership claims; do not read business +data just to identify an account. Matching owners never authorize substituting identities. The agent +identity can be channel-specific (including SDK channel identities) or reuse an organization default +in Personal mode; it is not universally shared across channels. Explain those possibilities when +asked about isolation, without claiming which applies to this run unless verified. + The hyphenated names are the logical MCP server identities. A harness/tool registry may normalize punctuation in callable names, so `composio-user` can appear as `composio_user` (and likewise for `composio-agent`). Match the identity and the `COMPOSIO_*` suffix; never declare an identity absent @@ -161,15 +191,21 @@ because a tool-name search checked only one spelling. | “your / yours / you”, “the agent's”, “the team account” | `composio-agent` | “verify your email” → *your* Gmail | | A named account (“the sales@ inbox”, “the MIF HubSpot”) | inspect aliases, then use the identity that contains it | — | | No pronoun, only ONE identity has that app connected | that identity — and say which you used | “check the calendar” with Calendar only on `composio-user` | -| No pronoun, BOTH have the app | **MUST ask which account — the question IS the reply, no tool call** | “send the email”, “check the calendar” | - -**Ambiguity is a hard stop, not a preference.** When the request carries no pronoun and BOTH -identities have the app, your first response MUST be the question “which account?” and MUST NOT be -a tool call — no “quick look”, no read-only peek, no trying one identity to see what is there. -Reading a calendar, inbox, chat or CRM on a guess exposes the requester's own private data, or a -third party's, to everyone in the conversation, and no correction afterwards takes it back. -Guessing right is not the standard; asking is. The same holds for writes: never send, schedule or -post from a guessed account. +| No pronoun, BOTH have the app, read/search only | either or both identities; say which sources supplied the information | “search the Slack discussions” | +| Write/send/state change | reuse the established intended identity and connected account; ask which account only if unresolved | “send the email” | + +**Reads and searches prioritize finding the information.** When no account is specified, use either +or both available identities without asking which account merely to read. Honor explicit account, +record and scope restrictions, and identify the sources used without assuming account ownership. +Read authorization does not permit sending, modifying records, marking messages read, reacting, +connecting accounts or any other state change. + +**Writes require the right account.** Before a send, edit, schedule, deletion or other state change, +resolve the intended logical identity and connected service account from the request and existing +conversation authorization. Reuse a previously established choice; do not ask again unnecessarily. +If the intended account is still ambiguous, ask which account before that mutation. Continue +independent authorized reads while awaiting the answer. A matching service owner never overrides +an explicit identity restriction, and using an account for a read does not select it for a write. **Keep the requested read scope through retries.** Permission to search for one named message is not permission to fetch surrounding history. If the user excludes unrelated records, keep diff --git a/src/gateway/gateway-usage/references/administration.md b/src/gateway/gateway-usage/references/administration.md index c010407..893bc40 100644 --- a/src/gateway/gateway-usage/references/administration.md +++ b/src/gateway/gateway-usage/references/administration.md @@ -7,9 +7,10 @@ channel-setting changes take effect **on the next message**. ## Composio modes and personal tokens Two Composio accounts can be injected: `composio-agent` (YOUR own account) and `composio-user` -(the requester's). How each is backed is admin configuration — it changes nothing about how you -use them, and you should not describe your own account to users as “the channel token” or “the -org fallback”. The admin chooses one global Composio mode in Settings: +(the requester's). How each is backed is admin configuration and does not change the identity +routing rules. Routine replies need no credential details; when asked about isolation, explain +the supported scopes without assuming the current source or connected service owner. The admin +chooses one global Composio mode in Settings: - **Personal:** a saved user token backs `composio-user`; a channel token, else the organization default, backs `composio-agent`. @@ -69,6 +70,12 @@ Schedules are NOT in this list: `create_schedule`/`delete_schedule` never ask (s what needs approving if they seem unaware. Read-only tools (`list_*`, `get_*`) and in-thread posts (charts, tables, snippets) never prompt. +After a prompting tool receives approval, its result includes a human-approval receipt alongside +the operation outcome. The approval UI is handled outside your model transcript: a direct tool +result is not evidence that approval was bypassed. The receipt confirms the decision, not success +of the operation or which UI/person approved it. Pending durable actions remain pending until the +gateway applies them; explicitly exempt tools do not receive a human-approval receipt. + ## MCP servers allowed in this channel (managers) - `list_available_mcps` — servers the host offers. @@ -146,7 +153,7 @@ every other channel. **Never copy secrets into the working folder.** The folder is often a git checkout and may sync elsewhere; a credential pasted there can end up committed. A credential this channel should have -is a `/secrets` variable (below) — it arrives as environment, never as a file. +is a `/secrets` variable (below) — use its injected environment value, not a project `.env` file. ## Working folder & Drive - `get_channel_workdir` / `set_channel_workdir` (admin) / `clear_channel_workdir` (admin) — run @@ -161,16 +168,44 @@ available to that process and its CLI for authorized use; do not claim it is unr Listings remain masked and outputs are redacted. Never print a value to demonstrate availability. This channel can hold its OWN credentials — its own Supabase project, its own Vercel account — -instead of sharing whatever login the gateway host has. They are stored per channel and passed to -every run here as environment variables, so a CLI picks them up by itself: `supabase`, `vercel` -and friends read `SUPABASE_ACCESS_TOKEN` / `VERCEL_TOKEN` without being told to. +instead of sharing whatever login the gateway host has. Values are stored in channel metadata in +the gateway's SQLite database and injected into the engine process environment at spawn. They +are not written to the project's `.env`; the container backend uses a protected internal +environment file to pass them into the process. Do not read the gateway store or internal files +to discover credentials. + +**Start with this attempt's inventory.** The prompt's **[Channel credentials for THIS attempt]** +lists sorted names from the channel variables actually supplied to that run, never values or +token suffixes. It refreshes on resumed turns and overrides earlier inventories, including an +explicit empty result after removal. Clean runs omit it. This is not an inventory of host logins, +MCP accounts or every process environment variable, and a name is not proof of validity, account +ownership or scope. Before asking for a new credential, follow the discovery and account-selection +rules in `SKILL.md`: check relevant skills, these names, permitted non-secret CLI auth status and +the selected account's MCP tools. Missing MCP access alone is not missing API/CLI access. + +When shell use is permitted, check only a relevant name without showing its value. For example: + +```sh +node -e 'console.log("HUBSPOT_ACCESS_TOKEN present:", Boolean(process.env.HUBSPOT_ACCESS_TOKEN))' +``` + +This returns a boolean, not the token or its suffix. Never run a full environment dump (`env`, +`printenv` or printing `process.env`), enable shell tracing, or print credentials to debug access. +Use the value directly from the environment inside an authorized API client or CLI invocation. +Check the tool's actual authentication mechanism: some consume an environment variable +automatically; others require an explicit option or request header. Merely naming a variable +`VERCEL_TOKEN` does not establish that a CLI automatically consumes it. Avoid embedding literal +values in commands, generated files, replies or logs. Verify only the requested account and data +scope; never silently fall back to another account or credential. A supplied credential does not +override the channel's tool permissions or *Allow network* policy. - Anyone authorized to use the agent here manages them in Settings → Secrets, with `/secrets`, or the 🔑 reply button, in every channel mode. Admins can also use the Admin UI → the channel's card. -- **You cannot read them and neither can anyone else.** Every surface shows the NAME and the last - four characters only; there is no reveal anywhere, deliberately. A lost token is re-issued at the - provider, not recovered here. To replace one, set it again. +- **Settings and API listings never reveal values.** They show masked metadata (name, provider, + available suffix and who/when); no reveal endpoint exists for channel environment secrets. + The running process can consume an injected value for authorized work. Do not use that runtime + access to recover or display a token; re-issue a lost token at the provider and set it again. - You have no tool for setting them: it is a human action, on purpose. If a task needs a credential this channel does not have, say which variable name is missing and point at `/secrets` — do not ask anyone to paste a token into the conversation. @@ -188,7 +223,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. @@ -205,6 +244,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/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/gateway-usage/references/messages.md b/src/gateway/gateway-usage/references/messages.md index d6c4392..f6c8af9 100644 --- a/src/gateway/gateway-usage/references/messages.md +++ b/src/gateway/gateway-usage/references/messages.md @@ -12,8 +12,10 @@ identity rules in `SKILL.md` → “Tool identities”: - “my Slack” → `composio-user` (`mcp__composio-user__*`), the requester's own account. - “your Slack” → `composio-agent` (`mcp__composio-agent__*`), YOUR own account. -- No pronoun → the only account with Slack connected (say which). If BOTH have Slack you MUST ask - which account first — the question is the whole reply, and you call no tool until it is answered. +- Resolve the intended identity and connected Slack account from the request and prior explicit + choices. Reuse an established choice. If the writing account is still ambiguous, ask which + account before the send, schedule, reaction or other state change; continue independent + authorized reads while waiting. Reading through an account does not select it for a write. - In a DM only `composio-user` exists. - Find the right Slack action through Composio (e.g. its search/execute tools surface actions like @@ -38,7 +40,7 @@ Even with no Composio: replying in-thread, native tables (`slack_post_table`), n (`references/charts.md`), Slack Lists (`references/tables.md`), uploading a table snippet (`slack_upload_snippet`), and reading THIS channel's history (`references/reading.md`) — those are gateway bot-token tools, always on. Posting to OTHER channels / scheduling sends / search uses the -explicitly selected Composio account. +intended Composio account for writes; searches may use either or both unless restricted. ## Don't leak secrets Never post tokens, credentials, or the contents of secret files into a Slack message — Slack keeps diff --git a/src/gateway/gateway-usage/references/reading.md b/src/gateway/gateway-usage/references/reading.md index c7c9b00..0e82989 100644 --- a/src/gateway/gateway-usage/references/reading.md +++ b/src/gateway/gateway-usage/references/reading.md @@ -30,8 +30,11 @@ missing from the folder. ## Other channels / workspace search — select the account To search or read **beyond this channel**, choose the Composio account per `SKILL.md` → “Tool identities”: `composio-user` for “my Slack” (the requester's), `composio-agent` for “your Slack” -(your own); no pronoun → the only one with Slack connected, else ask. The selected connected Slack -account controls visibility. Find its action via tool discovery — e.g. search +(your own). With no account restriction, reads and searches may use either or both identities +without asking which account. Honor explicit account and record scope restrictions, and identify +which sources supplied the information. Each connected Slack account controls its own visibility. +Read authorization does not authorize state changes such as marking read or reacting. Find actions +via tool discovery — e.g. search messages/users/channels, read another channel/thread/file, or read a user profile. See `references/messages.md` for availability and the no-silent-substitution rule. diff --git a/src/gateway/gateway-usage/references/skills.md b/src/gateway/gateway-usage/references/skills.md index 2baf084..9a99dc8 100644 --- a/src/gateway/gateway-usage/references/skills.md +++ b/src/gateway/gateway-usage/references/skills.md @@ -43,6 +43,12 @@ and wait; don't retry a refusal. ## Authoring (anyone approved to use the channel) +`create_skill`, `update_skill` and `delete_skill` wait for human approval even in Auto or Admin +mode. The gateway shows and resolves that approval outside your model transcript. A completed +tool returns an approval receipt plus its separate operation outcome; report those facts. +Not seeing the card in your transcript does not mean it was absent or bypassed. Do not save an +inferred bypass as channel memory. Approval alone does not mean the requested change succeeded. + - `create_skill` — add a new skill to the shared catalog from files you pass (`SKILL.md` required) and grant it to this channel at once; `personal: true` keeps it private to you. Use the `skill-authoring` skill for the frontmatter contract and a description that actually 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/gateway/login-watch.js b/src/gateway/login-watch.js index feea59a..a11c2f1 100644 --- a/src/gateway/login-watch.js +++ b/src/gateway/login-watch.js @@ -16,6 +16,7 @@ // // Nothing here touches token material: the resolver hands out paths, expiries and an opaque // fingerprint, and this module forwards only the kind, the config dir, the expiry and the remedy. +import { isEngineEnabled } from "../config/settings.js"; import { getUsers } from "../config/store.js"; import { metaGet, metaSet } from "../db/index.js"; import { logEvent } from "../util/logger.js"; @@ -161,6 +162,13 @@ export async function claudeLoginWatchTick({ readMark = () => metaGet(CLAUDE_LOGIN_ALERT_META_KEY) || "", writeMark = (value) => metaSet(CLAUDE_LOGIN_ALERT_META_KEY, value), } = {}) { + // Read the live setting on every tick: toggling an engine needs no restart. + // Disabled engines need neither credentials nor admin reminders. + if (!isEngineEnabled("claude")) { + const mark = String(readMark() || ""); + if (mark) writeMark(""); + return { alertClass: "", sent: 0, notified: false, reset: Boolean(mark), disabled: true }; + } const at = now(); let login = null; try { diff --git a/src/gateway/mcp.js b/src/gateway/mcp.js index 2db6cee..078b801 100644 --- a/src/gateway/mcp.js +++ b/src/gateway/mcp.js @@ -94,17 +94,18 @@ export function composioIdentitiesForRun({ clean = false, principalTrusted = tru // is built per run, per author, so there is no shared file to race. Deliberately engine-neutral and // content-free: server names and roles only, never a token, an address or an account label. export function composioIdentityPreamble({ user = false, agent = false } = {}) { + const ownership = " These logical identities do not establish the connected service owner; discover account metadata through the selected identity before claiming ownership. The agent identity is not necessarily shared across channels. Matching service owners never authorize substituting identities."; if (user && agent) { return "[Composio identities in THIS run: `composio-user` (the requester's own accounts) and `composio-agent` (the shared agent's own). " + - "A request that names neither and could be served by either is answered with the question \"which account?\" — no tool call, no read-only peek.]\n\n"; + "Reads and searches may use either or both identities without asking which account unless the user restricts the account or scope. Writes, sends and other state changes require the intended identity and connected account: reuse an established choice, or ask \"which account?\" if unresolved before mutating; continue independent authorized reads." + ownership + "]\n\n"; } if (user) { return "[Composio identities in THIS run: `composio-user` only (the requester's own accounts). " + - "There is no shared agent identity here, so a request for the agent's own accounts (\"your inbox\") has nothing to read — say so and stop.]\n\n"; + "There is no shared agent identity here, so a request for the agent's own accounts (\"your inbox\") has nothing to read — say so and stop." + ownership + "]\n\n"; } if (agent) { - return "[Composio identities in THIS run: `composio-agent` only (the shared agent's own accounts, which hold OTHER people's data — never the requester's). " + - "A request phrased for the person asking (\"my inbox\", \"my calendar\") cannot be served here: say so and stop, do not read `composio-agent` to answer it.]\n\n"; + return "[Composio identities in THIS run: `composio-agent` only (the shared agent's connections). " + + "A request phrased for the person asking (\"my inbox\", \"my calendar\") cannot be served here: say so and stop, do not read `composio-agent` to answer it." + ownership + "]\n\n"; } return ""; } diff --git a/src/gateway/plugin-runtime.js b/src/gateway/plugin-runtime.js new file mode 100644 index 0000000..ade7973 --- /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 = 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/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/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 6c9ed99..29f3909 100644 --- a/src/gateway/run.js +++ b/src/gateway/run.js @@ -14,6 +14,7 @@ import { } from "../config/store.js"; import { ensureChannelFolder } from "./folders.js"; import { memorySnapshotPrefix } from "./channel-memory.js"; +import { recordUsage } from "./usage.js"; import { createSkillUsageRecorder } from "./skills/usage.js"; import { withTemplateSkills } from "./skills/templates.js"; import { resolveSession, resetSession, getSession, saveSession, sessionGeneration, dropMintedSession } from "./sessions.js"; @@ -36,6 +37,7 @@ import { requireComposioSdkEntitlement } from "../ee/composio-entitlement.js"; import { resolveCurrentModel } from "./model-info.js"; import { runtimeIdentityPreamble } from "./runtime-identity.js"; import { runtimeAccessPreamble } from "./runtime-access.js"; +import { channelCredentialsPreamble } from "./channel-credentials.js"; import { resolveMakeToolboxRuntime } from "./make-toolbox.js"; import { modelBelongsToEngine, effortBelongsToEngine } from "../engines/registry.js"; import { writeFile, rm, mkdir } from "node:fs/promises"; @@ -45,6 +47,7 @@ import { randomUUID } from "node:crypto"; import { createSemaphore } from "../util/semaphore.js"; import { logEvent } from "../util/logger.js"; import { resolveRunAccessGrants, resolveRunUserIdentity, userOnlySkillGrants } from "./access-grants.js"; +import { getSkill } from "./skills/catalog.js"; import { assertUserSkillOverlaySupported, createRunGrantArtifacts, refreshRuntimeReadPaths } from "./run-grant-artifacts.js"; import { isForceStopping } from "./shutdown.js"; import { allowedFsRoot } from "../web/security.js"; @@ -277,6 +280,22 @@ export function isEmptyResult(result) { return inT === 0 && outT === 0; } +// A harness verdict is authoritative even after text or tools. Keep the partial result for +// diagnostics/accounting, but never let delivery mark it complete or replay unknown side effects. +export function assertCompletedTurn(result, engine, sessionId = "") { + if (!result || result.interrupted || (!result.engineError && result.completed !== false)) return result; + const endReason = String(result.endReason || "incomplete_turn"); + const error = new Error(`${engineLabel(engine)} did not finish this turn (${endReason}). Reply here to continue in the same session and verify what already ran.`); + error.details = { + incompleteTurn: true, replaySafe: false, engine, endReason, + partialContent: String(result.content || ""), usage: result.usage ?? null, + costUSD: result.costUSD ?? null, durationMs: result.durationMs ?? null, + sessionId: result.sessionId || sessionId, toolUseCount: result.toolUseCount || 0, + result: { ...result, engine, sessionId: result.sessionId || sessionId }, + }; + throw error; +} + // A turn that DID work but ended with no final message: tool calls ran, tokens were spent, and // the engine still produced zero text. That is not an answer — it is a turn that was cut short // (the harness aborted mid-turn, hit its own step ceiling, or dropped the reply), and delivering @@ -734,6 +753,7 @@ export async function runMessage({ channelId, authorId, workspaceId = "", text, authorId, untrustedPrincipal, loadUser: getUser, + lookupSkill: getSkill, }); let meta = { ...channelMeta, ...runGrants.effective }; @@ -1011,12 +1031,13 @@ export async function runMessage({ channelId, authorId, workspaceId = "", text, const userSkills = clean ? [] : userOnlySkillGrants(runGrants); // The channel's OWN environment secrets (config/channel-env.js) — its per-project CLI logins. - // Resolved once per turn and handed to the engine as process environment: never a file the run - // can read, never another channel's. Clean mode runs bare, so it gets none, for the same reason + // Resolved once per turn and handed to the engine as process environment, not a project .env + // or another channel's credentials. Clean mode runs bare, so it gets none, for the same reason // it gets no MCP servers and no Composio tokens. A resolve FAILURE throws: a turn that quietly // ran without the credential looks like a deploy that did nothing. const channelEnv = clean ? {} : safeSpawnEnv(await resolveChannelEnv(meta)); const channelEnvFp = channelEnvFingerprint(channelEnv); + const channelCredentialsPrefix = channelCredentialsPreamble(channelEnv, { clean }); // Which browser daemon this channel's browser MCP server attaches to. Unconditional — clean // mode included: it injects no MCP servers, but the isolation must not depend on that staying // true, and a namespace costs nothing when nothing reads it. See gateway/browser-env.js. @@ -1170,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 @@ -1337,9 +1359,9 @@ export async function runMessage({ channelId, authorId, workspaceId = "", text, assertRuntimeCanStart(); // The runtime facts belong to THIS attempt, including a model retry or session heal. Keep // them per-prompt even in clean mode: they expose no memory, optional skills or connectors. - const prompt = (fresh ? memoryPrefix : "") + composioIdentityPrefix + const prompt = (fresh ? memoryPrefix : "") + composioIdentityPrefix + channelCredentialsPrefix + runtimeIdentityPreamble({ engine, model: modelOverride, effort, fresh }) - + runtimeAccessPreamble(target, { clean }) + + runtimeAccessPreamble(target, { clean, allowNetwork: Boolean(meta.allowNetwork) }) + (promptOverride ?? turnText); return adapter.run(validateRunContext({ principal: { kind: untrustedPrincipal ? "daemon" : PRINCIPAL_KIND_BY_ORIGIN[origin] || "daemon", id: authorId }, @@ -1366,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, }, @@ -1519,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 @@ -1557,9 +1581,9 @@ export async function runMessage({ channelId, authorId, workspaceId = "", text, assertRuntimeCanStart(); return fallbackAdapter.run(validateRunContext({ principal: { kind: untrustedPrincipal ? "daemon" : PRINCIPAL_KIND_BY_ORIGIN[origin] || "daemon", id: authorId }, origin, cwd, - prompt: (fbFresh ? memoryPrefix : "") + composioIdentityPrefix + prompt: (fbFresh ? memoryPrefix : "") + composioIdentityPrefix + channelCredentialsPrefix + runtimeIdentityPreamble({ engine: fallbackEngine, model: modelOverride, effort: "", fresh: fbFresh }) - + runtimeAccessPreamble(target, { clean }) + fbPrompt, + + runtimeAccessPreamble(target, { clean, allowNetwork: Boolean(meta.allowNetwork) }) + fbPrompt, session: { id: fbSid, fresh: fbFresh }, policy: fallbackConfinement, // The SAME runtime target: failing over to the other harness changes which CLI runs, not // which machine boundary the channel runs behind. @@ -1580,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 }, })); }; @@ -1626,11 +1652,13 @@ export async function runMessage({ channelId, authorId, workspaceId = "", text, fallbackModel = defaultModel; fallbackModelNote = gatewayDefaultModelNote(rejectedModel, defaultModel); } catch (defaultModelError) { + if (defaultModelError?.details?.incompleteTurn && !defaultModelError.details.replaySafe) throw defaultModelError; console.warn(`[gateway] gateway-default fallback model ${defaultModel} also failed (${defaultModelError.message}) — preserving the original model error`); err.details = { ...(err.details || {}), defaultModel, defaultModelError: defaultModelError.message }; throw err; } } + assertCompletedTurn(cx, fallbackEngine, prior || ""); if (cx.sessionId) await saveSession(entry.slug, fbKey, cx.sessionId, fallbackEngine, sessionGen, runtimeStamp); cx = transientRetryNote(fallbackEngine, cx); // `fellBack`/`fallbackFrom` are the engine-agnostic truth; `fellBackToCodex` is the original @@ -1799,6 +1827,7 @@ export async function runMessage({ channelId, authorId, workspaceId = "", text, content: `${gatewayDefaultModelNote(rejectedModel, defaultModel)}${result.content || ""}`, }; } catch (defaultModelError) { + if (defaultModelError?.details?.incompleteTurn && !defaultModelError.details.replaySafe) throw defaultModelError; console.warn(`[gateway] gateway-default model ${defaultModel} also failed (${defaultModelError.message}) — preserving the original model error`); err.details = { ...(err.details || {}), defaultModel, defaultModelError: defaultModelError.message }; throw err; @@ -1844,6 +1873,7 @@ export async function runMessage({ channelId, authorId, workspaceId = "", text, : "it hit a usage limit before any tool call", ); } catch (fallbackError) { + if (fallbackError?.details?.incompleteTurn) throw fallbackError; // Both harnesses failed. Keep the original error authoritative (its details drive every // consumer), but say the whole story in one sentence, and — when a person is watching — // hand it back as a choice (try either harness again) instead of a dead end. @@ -1870,6 +1900,8 @@ export async function runMessage({ channelId, authorId, workspaceId = "", text, } } + assertCompletedTurn(result, engine, sid); + // A RESUME that returns an empty result (0 tokens, no output) is a broken session state — most // often a turn SIGKILLed mid-write by a stop/restart, leaving a dangling tool_use in the // transcript. Functionally identical to isSessionNotFound: the old conversation is unusable. @@ -1892,6 +1924,8 @@ export async function runMessage({ channelId, authorId, workspaceId = "", text, result = await withTransientRetry(engine, () => runOnce(sid, fresh, healed), { beforeRetry: remintFreshSession }); } + assertCompletedTurn(result, engine, sid); + // Self-minting engines (Codex, OpenCode) return their own thread_id (result.sessionId) — // persist it over the locally-minted UUID so the next turn's resume actually finds the // thread. Stamp the row with THIS run's engine: a literal here silently migrates any other @@ -1924,6 +1958,7 @@ export async function runMessage({ channelId, authorId, workspaceId = "", text, try { return await runFallbackEngine(`⚠️ _${engineLabel(engine)} hit its usage limit — answered with ${engineLabel(fallbackEngine)}._\n\n`); } catch (e) { + if (e?.details?.incompleteTurn) throw e; console.warn(`[gateway] ${fallbackEngine} fallback failed (${e.message}) — returning the limit notice`); // fall through to return the limited engine's own notice } @@ -1931,8 +1966,8 @@ export async function runMessage({ channelId, authorId, workspaceId = "", text, // Answerless turn: real work, no reply. Say so in the thread and record it, instead of letting // the delivery layer render a bare "(empty response)" that looks like the model chose silence. - // Not an error path: the session is intact and the work is resumable, so the turn is delivered - // with the notice as its content (a throw here would also discard the run's footer/usage). + // Only clean terminal results reach this notice; explicit harness failures were rejected + // above and their usage is retained by the shared error path. // Interrupt-steer is excluded — that empty result is one WE asked for. if (!result.interrupted && isAnswerlessResult(result)) { await logEvent("run_answerless", { @@ -2000,6 +2035,16 @@ export async function runMessage({ channelId, authorId, workspaceId = "", text, if (typeof error.stack === "string") error.stack = redactSecretValues(error.stack, outputSecrets); if (error.details) error.details = redactSecretFields(error.details, outputSecrets); } + // Failed terminal results never reach callers' success-only usage bankers. Bank once here + // for every origin, after redaction; downstream failure handlers can observe usageRecorded. + if (error?.details?.incompleteTurn && !error.details.usageRecorded) { + const details = error.details; + const failedEngine = details.engine || engine; + const failedResult = details.result || { usage: details.usage, costUSD: details.costUSD, durationMs: details.durationMs, engine: failedEngine }; + const taskKind = origin === "schedule" ? "scheduled" : origin === "background_agent" ? "background-agent" : "interactive"; + await recordUsage({ channelId, slug: entry.slug, authorId, engine: failedEngine, model: failedResult.model || (failedEngine === engine ? model : getDefaultModel(failedEngine)), taskKind, result: failedResult }); + details.usageRecorded = true; + } throw error; } finally { // Release whatever the streaming redactor was still holding back, exactly as the mention diff --git a/src/gateway/runtime-access.js b/src/gateway/runtime-access.js index 43c2a70..9b72145 100644 --- a/src/gateway/runtime-access.js +++ b/src/gateway/runtime-access.js @@ -45,6 +45,16 @@ function cleanModeNote(clean) { return "Clean mode for this attempt is unknown because the resolved run mode was not supplied. Do not infer it from missing tools alone."; } +function networkPolicyNote(allowNetwork) { + if (typeof allowNetwork !== "boolean") { + return "Network policy for this attempt is unknown because the resolved policy was not supplied. Do not infer network permission from earlier turns or successful requests."; + } + const policy = allowNetwork + ? "Outbound requests are permitted by the channel network switch, subject to this attempt's other tool permissions and the user's requested scope. There is no per-domain allow-list." + : "Do not make outbound requests. When a request needs the network, explain that the current policy is off and that no fresh request was made. Do not present an earlier response as a fresh network result."; + return `Network policy for THIS attempt: **${allowNetwork ? "on" : "off"}**. This current network policy supersedes earlier turns and cached results. ${policy} The switch is advisory, not container egress enforcement: the container remains on the bridge network, and an engine may impose its own additional restrictions. An off policy does not prove that a connection is technically blocked.`; +} + export function containerAccessNote(target) { if (!Array.isArray(target?.container?.mounts)) { return "**Container access:** no resolved runtime target was supplied at prompt/guide construction. Do not infer host access from the author's role; check the current runtime before claiming a path is mounted or absent."; @@ -57,7 +67,7 @@ export function containerAccessNote(target) { return `**Container access for this run:** gateway setting \`containerFullAccessHome\` is **${setting}**. ${access} Switching this channel to Admin/Full-access qualifies it for the operator-home mount on the next resolved run ONLY while that gateway switch is on; with the switch off, Admin adds no home mount. The container remains the filesystem/process boundary. \`$HOME\` and \`~\` still refer to the channel's own home volume, not the operator's home. See the \`gateway-usage\` skill's \`references/administration.md\` for the boundary and the optional grant.`; } -export function runtimeAccessPreamble(target, { clean } = {}) { +export function runtimeAccessPreamble(target, { clean, allowNetwork } = {}) { return "[Gateway container access for THIS attempt]\n" + "These current access facts supersede earlier turns and generic claims about host isolation. " + "Answer access questions from this resolved runtime, even when the conversation previously said otherwise.\n" @@ -65,6 +75,7 @@ export function runtimeAccessPreamble(target, { clean } = {}) { + gatewayStoreAccessNote(target) + "\n" + "Respect the user's requested scope when checking access. For requests limited to existence, metadata or permission checks, use resolved mount facts and non-mutating metadata checks only. Do not read file contents or create, modify, or delete probe files, even temporarily. If metadata cannot establish write access, report it as unverified; do not upgrade an access-check request into a write test.\n" + cleanModeNote(clean) + "\n" + + networkPolicyNote(allowNetwork) + "\n" + "Environment secrets, when injected into a run, are usable by its process and CLI. Write-only means masked listing/reveal surfaces and redacted outputs; it does not mean the process cannot read its environment. Do not print secret values.\n" + "[End gateway container access]\n\n"; } 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/gateway/skills/bundled/channelgate/references/mcp-skills.md b/src/gateway/skills/bundled/channelgate/references/mcp-skills.md index cf8960b..dbad12c 100644 --- a/src/gateway/skills/bundled/channelgate/references/mcp-skills.md +++ b/src/gateway/skills/bundled/channelgate/references/mcp-skills.md @@ -14,11 +14,12 @@ External apps come from two deliberately distinct identities when configured: its user-facing meaning. DMs do not receive this shared identity. Personal and SDK provisioning modes preserve this semantic split. Pronouns select the identity; -for a named account, inspect connection aliases. When both identities have an app such as Gmail -and the request is ambiguous, the agent MUST ask which account and MUST NOT call a tool first — -not even a read-only look. Reading a calendar, inbox, chat or CRM on a guess exposes the -requester's own private data, or a third party's, to everyone in the conversation, and no later -correction takes it back. Never substitute or silently fall back between them. +for a named account, inspect connection aliases. Unrestricted reads and searches may use either +or both identities without asking which account. Explicit account and record restrictions remain +binding. Writes, sends and other state changes require the intended identity and connected +account: reuse an established choice, or ask which account before mutating if still unresolved. +Independent authorized reads can continue while waiting; a read does not select the writing +account. Never substitute or silently fall back against an explicit identity restriction. The MCP server ids are hyphenated, but harness registries may expose callable prefixes with normalized underscores (`mcp__composio_user__*` / `mcp__composio_agent__*`). Determine presence by diff --git a/src/gateway/skills/bundled/skill-authoring/SKILL.md b/src/gateway/skills/bundled/skill-authoring/SKILL.md index da3aa9a..ced0838 100644 --- a/src/gateway/skills/bundled/skill-authoring/SKILL.md +++ b/src/gateway/skills/bundled/skill-authoring/SKILL.md @@ -6,7 +6,7 @@ description: >- a skill", "turn this procedure into a skill", "add a skill to the library", "improve the X skill", or when a task has become a repeatable procedure worth keeping for every channel. category: Skills -version: 1.0.0 +version: 1.0.1 tags: - skills - authoring @@ -61,8 +61,14 @@ Rules for a description that actually fires: 1. Check for an existing skill (`list_skills` with a query). Prefer improving one over cloning it. 2. Draft `SKILL.md` first; test the trigger by asking yourself which requests should load it. -3. Call `create_skill` (or `update_skill` / `propose_skill_change`) with **every** file — a - revision is the complete folder, not a diff. +3. For `create_skill`, pass every file in the initial package, including `SKILL.md`. + For `update_skill`, pass only changed or new files; files you omit are retained. To delete + a file, name it explicitly in `remove` — omission is not deletion. For example, updating only + `SKILL.md` keeps an existing `references/ids.md` unchanged. + For a `propose_skill_change` of kind `change`, pass the changed or new files plus a note; + approval merges them with the existing package. The proposal tool has no file-removal argument. + Each resulting revision stores the complete folder; partial input does not replace prior + revisions or discard unspecified files. 4. Tell the user what was created and where it applies. If a proposal was filed, say that an admin has to approve it before it becomes active. 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 fc488b6..32b2870 100644 Binary files a/src/gateway/skills/files.js and b/src/gateway/skills/files.js differ 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/gateway/skills/workspace-sync.js b/src/gateway/skills/workspace-sync.js index 229a4f0..de12783 100644 --- a/src/gateway/skills/workspace-sync.js +++ b/src/gateway/skills/workspace-sync.js @@ -48,7 +48,7 @@ export async function assertWorkspaceSkillsCompatible(slug, meta) { if (!channel.meta || channel.slug === slug) continue; if (effectiveWorkDir(channel.slug, { ...channel.meta, cleanMode: false }) !== cwd) continue; if (selectionSignature(channel.meta, organization) !== signature) { - throw new Error("This working folder is assigned to conversations with different shared skill grants or memory settings. Choose separate folders or align their selections."); + throw Object.assign(new Error("This working folder is assigned to conversations with different shared skill grants or memory settings. Choose separate folders or align their selections."), { code: "workspace_selection_conflict" }); } } } diff --git a/src/gateway/transcribe.js b/src/gateway/transcribe.js index 9f843ec..07112a1 100644 --- a/src/gateway/transcribe.js +++ b/src/gateway/transcribe.js @@ -242,8 +242,9 @@ function timeoutMs(value = process.env.WHISPER_TIMEOUT_MS) { return Number.isFinite(parsed) && parsed > 0 ? Math.min(parsed, 30 * 60 * 1000) : DEFAULT_WHISPER_TIMEOUT_MS; } -export function runProcess(command, args, { cwd, timeout = DEFAULT_WHISPER_TIMEOUT_MS, maxOutput = MAX_PROCESS_OUTPUT, envExtra = {} } = {}) { +export function runProcess(command, args, { cwd, timeout = DEFAULT_WHISPER_TIMEOUT_MS, maxOutput = MAX_PROCESS_OUTPUT, envExtra = {}, signal = null } = {}) { return new Promise((resolve, reject) => { + if (signal?.aborted) { reject(signal.reason || Object.assign(new Error("Transcription cancelled"), { name: "AbortError" })); return; } let child; try { child = spawn(command, args, { @@ -263,6 +264,7 @@ export function runProcess(command, args, { cwd, timeout = DEFAULT_WHISPER_TIMEO if (settled) return; settled = true; clearTimeout(timer); + signal?.removeEventListener("abort", onAbort); fn(value); }; const append = (current, chunk) => `${current}${chunk}`.slice(-maxOutput); @@ -276,12 +278,20 @@ export function runProcess(command, args, { cwd, timeout = DEFAULT_WHISPER_TIMEO finish(reject, new Error(`${path.basename(command)} timed out after ${Math.round(timeout / 1000)}s`)); }, timeout); timer.unref?.(); + const onAbort = () => { + try { child.kill("SIGTERM"); } catch {} + setTimeout(() => { try { child.kill("SIGKILL"); } catch {} }, 2000).unref?.(); + // Wait for close before rejecting: scratch cleanup must not race a still-writing child. + }; + signal?.addEventListener("abort", onAbort, { once: true }); + if (signal?.aborted) onAbort(); child.on("error", (error) => finish(reject, new Error(processFailureMessage(path.basename(command), { spawnError: error })))); - child.on("close", (code, signal) => { + child.on("close", (code, exitSignal) => { + if (signal?.aborted) { finish(reject, signal.reason || Object.assign(new Error("Transcription cancelled"), { name: "AbortError" })); return; } if (code === 0) finish(resolve, { stdout, stderr }); else finish(reject, new Error(processFailureMessage(path.basename(command), { code, - signal, + signal: exitSignal, diagnostic: stderr.trim().slice(-1000) || "No diagnostic output was provided", maxDiagnosticChars: 1000, }))); @@ -295,7 +305,9 @@ async function transcribeCore(filePath, { cliPath = whisperCliPath(), modelPath = whisperModelPath(), timeout = timeoutMs(), + signal = null, } = {}) { + signal?.throwIfAborted(); const source = path.resolve(filePath); const scratch = await mkdtemp(path.join(path.dirname(source), ".whisper-")); const wav = path.join(scratch, "audio.wav"); @@ -309,12 +321,14 @@ async function transcribeCore(filePath, { await runner(ffmpegPath, [ "-nostdin", "-hide_banner", "-loglevel", "error", "-y", "-i", source, "-ar", "16000", "-ac", "1", "-c:a", "pcm_s16le", wav, - ], { cwd: scratch, timeout, maxOutput: MAX_PROCESS_OUTPUT }); + ], { cwd: scratch, timeout, maxOutput: MAX_PROCESS_OUTPUT, signal }); + signal?.throwIfAborted(); const result = await runner(cliPath, ["-m", modelPath, "-f", wav, "-nt"], { cwd: scratch, timeout, maxOutput: MAX_PROCESS_OUTPUT, envExtra: runtimeEnv, + signal, }); return normalizeTranscript(result?.stdout); } finally { @@ -323,7 +337,7 @@ async function transcribeCore(filePath, { } export async function transcribeAudioFile(filePath, options = {}) { - const release = await whisperSemaphore.acquire(); + const release = await whisperSemaphore.acquire({ signal: options.signal }); try { return await transcribeCore(filePath, options); } finally { @@ -336,10 +350,11 @@ export async function transcribeAudioFiles(files, options = {}) { const failed = []; const custom = typeof options.transcribe === "function" ? options.transcribe : null; for (const file of files || []) { + options.signal?.throwIfAborted(); try { let text; if (custom) { - const release = await whisperSemaphore.acquire(); + const release = await whisperSemaphore.acquire({ signal: options.signal }); try { text = await custom(file.path, options); } finally { release(); } } else { text = await transcribeAudioFile(file.path, options); 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/gateway-server.js b/src/mcp/gateway-server.js index 3c07847..2522a74 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 }] }); @@ -211,7 +212,14 @@ export function buildControlPlane({ loadMeta }) { ["add_channel_skills", { authz: "manage", details: ({ slugs }) => `Grant skill(s) in this channel: ${summarize((slugs || []).join(", "))}` }], ["remove_channel_skills", { authz: "manage", details: ({ slugs }) => `Remove skill grant(s) from this channel: ${summarize((slugs || []).join(", "))}` }], ["set_channel_skill_template", { authz: "manage", details: ({ template }) => (String(template || "").toLowerCase() === "none" ? "Stop this channel from following a skill template." : `Make this channel follow the "${summarize(template)}" skill template (live).`) }], - ["create_skill", { authz: "any", details: ({ slug, files }) => `Add a new skill to the shared catalog${slug ? ` (\`${summarize(slug)}\`)` : ""} with ${(files || []).length} file(s) and grant it here.` }], + ["create_skill", { authz: "any", details: ({ slug, files, personal = false, scope = "library", grant_here = true }) => { + const name = slug ? ` (\`${summarize(slug)}\`)` : ""; + const bundle = `${name} with ${(files || []).length} file(s)`; + if (personal && scope === "channel") return `A personal skill${bundle} cannot be created in a channel section. This combination will be rejected.`; + if (personal) return `Create a personal skill${bundle} and automatically grant it to your own runs. It will not be published to shared skill sources.`; + if (scope === "channel") return `Create a shared skill in this channel's section${bundle}; it will be active here automatically.`; + return `Create a skill in the shared library${bundle}${grant_here ? " and grant it in this channel" : " without adding a channel grant"}.`; + } }], ["update_skill", { authz: "any", details: ({ skill, files }) => `Publish a new revision of skill \`${summarize(skill)}\` (${(files || []).length} changed file(s)).` }], ["decide_skill_proposal", { authz: "admin", details: ({ id, decision }) => `${decision === "approve" ? "APPROVE" : "Reject"} skill proposal #${Number(id) || "?"}.` }], ["sync_skill_sources", { authz: "admin", details: ({ id }) => `Sync ${id ? `skill source #${Number(id)}` : "every skill source"} into the catalog now.` }], @@ -225,7 +233,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 +283,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 +297,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 }; } @@ -312,6 +321,7 @@ export function createGatewayMcpServer(ctx) { if (capability.claims.principalTrusted !== true) { return text("🚫 Gateway tools require a trusted Slack principal; this run was authenticated only as a daemon/API caller."); } + let humanApproved = false; if (gate) { // Fail CLOSED at the chokepoint. The precheck mirrors the handler's own authz so an // unauthorized caller gets one refusal and no approval spam — but if the two ever drift @@ -322,17 +332,44 @@ 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.`); } + humanApproved = true; } } - return handler(args, extra); + const result = await handler(args, extra); + if (!humanApproved) return result; + // The model does not see the gateway's approval UI while awaiting this tool. Keep the + // decision visible without conflating it with the handler outcome or naming a UI/actor. + return { + ...result, + content: [...(result.content || []), { + type: "text", + text: "Human approval was received before this tool executed. This receipt records an explicit human decision; channel Auto mode did not supply it. The gateway handles the approval UI outside the model transcript; receiving the tool result does not mean approval was bypassed. Approval does not establish whether the requested change succeeded; use the tool outcome above.", + }], + }; }); }; diff --git a/src/mcp/tools/channel-admin.js b/src/mcp/tools/channel-admin.js index 84c9bca..d910892 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(), @@ -439,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/mcp/tools/skills.js b/src/mcp/tools/skills.js index 928e1bc..cb7c683 100644 --- a/src/mcp/tools/skills.js +++ b/src/mcp/tools/skills.js @@ -353,14 +353,14 @@ export function register(server, ctx) { server.registerTool( "create_skill", { - description: "Add a NEW skill to the shared catalog from files (SKILL.md with name + description frontmatter is required; add references/*, scripts/* as needed) and grant it in this conversation. `personal: true` keeps it private to you (granted to your own runs only). Any approved member may create skills. Read the skill-authoring skill first.", + description: "Create a NEW skill from files (SKILL.md with name + description frontmatter is required; add references/*, scripts/* as needed). Shared library skills are granted here by default; grant_here:false leaves them ungranted. Personal skills are automatically granted to your own runs and are not published; channel-scoped shared skills are automatically active here. Any approved member may create skills. Read the skill-authoring skill first.", inputSchema: { slug: z.string().optional().describe("Folder name; defaults to the frontmatter name, slugified"), files: z.array(FILE_INPUT).min(1), note: z.string().optional(), - grant_here: z.boolean().optional().describe("Grant the new skill in this conversation (default true)"), - personal: z.boolean().optional().describe("Only you can see and use it (default false)"), - scope: z.enum(["library", "channel"]).optional().describe("library (default): the shared library, usable by every conversation. channel: specific to THIS channel's customer or project — kept in the channel's own section of the skills repository and granted here automatically. Ask the user before choosing channel."), + grant_here: z.boolean().optional().describe("For shared library skills, add a grant in this conversation (default true). Personal and channel-scoped skills activate automatically regardless of this flag."), + personal: z.boolean().optional().describe("Personal visibility and automatic grant to your own runs (default false); admins retain catalog visibility. Cannot be combined with scope channel."), + scope: z.enum(["library", "channel"]).optional().describe("library (default): catalog placement; visibility follows personal. Shared library skills can be granted across conversations. channel: a shared skill specific to THIS channel's customer or project, kept in its repository section and active here automatically; incompatible with personal. Ask the user before choosing channel."), }, }, async ({ slug: wanted = "", files, note = "", grant_here = true, personal = false, scope = "library" }) => { diff --git a/src/platforms/approval-delivery.js b/src/platforms/approval-delivery.js new file mode 100644 index 0000000..e8a73b3 --- /dev/null +++ b/src/platforms/approval-delivery.js @@ -0,0 +1,39 @@ +// Approval presentation only. Authority and one-shot decisions remain in the shared backend. +import { platformOfConversation } from "./ids.js"; +import { platformOr } from "./registry.js"; +import { liveConnector } from "./live.js"; +import { postPrivately } from "./notify.js"; +import { approvalCard, messageCard } from "./msteams/cards.js"; + +export function approvalDeliveryFor(conversationId) { + const adapter = platformOr(platformOfConversation(conversationId)); + if (adapter.capabilities.richCards === "block-kit") return null; + const connector = liveConnector(adapter.id); + if (!connector || connector.ready?.() === false) return null; + const delivery = { + requiresLinks: false, + capabilities: adapter.capabilities, + async post({ threadKey, id, title, target, scopes, authorId, approvalType }) { + const text = `Approval requested: ${title}`; + if (adapter.capabilities.richCards === "adaptive-cards" && connector.postCard) { + try { + return await connector.postCard({ conversationId, threadKey, + card: approvalCard({ id, title, details: target, scopes, authorId, approvalType }), text }); + } catch { /* fall back to a notice plus private decision links */ } + } + delivery.requiresLinks = true; + // Sensitive decision links are delivered privately, never embedded in this room notice. + return connector.post({ conversationId, threadKey, text: `${text}. Check your private chat for decision links.` }); + }, + async update({ messageId, title, text }) { + if (adapter.capabilities.richCards === "adaptive-cards" && connector.updateCard) { + return connector.updateCard({ conversationId, messageId, card: messageCard({ title, text }), text }); + } + return connector.edit({ conversationId, messageId, text }); + }, + privately({ threadKey, userId, text }) { + return postPrivately(connector, { conversationId, threadKey, userId, text }); + }, + }; + return delivery; +} diff --git a/src/platforms/attachments.js b/src/platforms/attachments.js index 0ed2c97..7be7013 100644 --- a/src/platforms/attachments.js +++ b/src/platforms/attachments.js @@ -1,12 +1,13 @@ // Inbound attachments → files inside the channel's gated folder. // -// Same destination and the same write discipline as the Slack path (uploads//, real +// Same destination and the same write discipline as the Slack path (uploads///, real // directories, no-follow writes): the workspace beneath the folder is agent-writable, so a symlink // planted at any of those paths must be REPLACED as a node, never written through. The download // itself differs per platform — a Chat attachment needs the service-account bearer, a Teams one has // a pre-authenticated URL — which is why each transport hands us a `download()` and this file never // touches the network itself. import path from "node:path"; +import { randomUUID } from "node:crypto"; import { ensureRealDir, writeStreamNoFollow } from "../gateway/safe-fs.js"; import { effectiveWorkDir } from "../gateway/folders.js"; import { ATTACHMENT_MAX_BYTES } from "../util/bounded-bytes.js"; @@ -35,6 +36,10 @@ export async function saveInboundAttachments(message, { slug, meta, log = consol // The thread groups a conversation's files. Platform thread handles are resource names full of // slashes and colons, so the subfolder is a sanitized form — it is a grouping label, not an id. const sub = String(message.threadKey || "thread").replace(/[^A-Za-z0-9._-]/g, "_").slice(0, 80) || "thread"; + // Session keys and native reply addresses are not storage identities. Two independent flat + // group messages (or revisions) can have the same filename and no thread handle. Each intake + // gets its own directory so one run cannot replace bytes another is transcribing/reading. + const intake = randomUUID(); let destDir = ""; for (const [index, attachment] of message.attachments.entries()) { @@ -46,7 +51,7 @@ export async function saveInboundAttachments(message, { slug, meta, log = consol try { const source = await attachment.download(); if (!source || (Buffer.isBuffer(source) && !source.length)) { skipped.push(name); continue; } - destDir ||= await ensureRealDir(root, "uploads", sub); + destDir ||= await ensureRealDir(root, "uploads", sub, intake); const dest = path.join(destDir, `${index + 1}-${name}`); const { bytes } = await writeStreamNoFollow(dest, source, { maxBytes }); if (!bytes) { skipped.push(name); continue; } diff --git a/src/platforms/boot.js b/src/platforms/boot.js index dcd8212..4e76cd8 100644 --- a/src/platforms/boot.js +++ b/src/platforms/boot.js @@ -15,6 +15,7 @@ import { startGoogleChat } from "./googlechat/transport.js"; import { createTeamsAuth } from "./msteams/auth.js"; import { createTeamsApi } from "./msteams/api.js"; import { createTeamsConnector } from "./msteams/connector.js"; +import { createTeamsControls } from "./msteams/controls.js"; import { startTeams, botIdFor } from "./msteams/transport.js"; // The connector is built BEFORE the transport starts, on purpose: a Pub/Sub pull can deliver its @@ -42,8 +43,9 @@ async function startTeamsTransport(config, log) { const connector = createTeamsConnector({ auth, capabilities, api, botId: botIdFor(config.appId), tenantId: config.tenantId, log, }); - const ingest = createIngest({ connector, log }); - return startTeams({ ...config, capabilities, onMessage: ingest, log, deps: { auth, api, connector } }); + const controls = createTeamsControls({ connector }); + const ingest = createIngest({ connector, log, onCommand: controls.onCommand }); + return startTeams({ ...config, capabilities, onMessage: ingest, onInvoke: controls.onInvoke, onStop: controls.stop, log, deps: { auth, api, connector } }); } export function createPlatformTransports({ log = console } = {}) { diff --git a/src/platforms/connector.js b/src/platforms/connector.js index 8319e50..c49d638 100644 --- a/src/platforms/connector.js +++ b/src/platforms/connector.js @@ -49,12 +49,12 @@ export function createNullConnector(platform, capabilities, reason = "not connec // Post a full answer, splitting it at the platform's cap. `formatted` is whatever the platform's // `formatOutbound` returned ({ text, chunks: [{ text, mentions }] }). Returns the ids of every // message posted, so a caller that needs to edit or delete its own output can find it again. -export async function postFormatted(connector, { conversationId, threadKey, formatted, footer = "", buttons = null } = {}) { +export async function postFormatted(connector, { conversationId, threadKey, formatted, footer = "", buttons = null, onPosted = () => {} } = {}) { const chunks = formatted?.chunks?.length ? formatted.chunks : [{ text: "_(no output)_", mentions: [] }]; const posted = []; for (const [index, chunk] of chunks.entries()) { const last = index === chunks.length - 1; - posted.push(await connector.post({ + const sent = await connector.post({ conversationId, threadKey, text: chunk.text, @@ -63,7 +63,9 @@ export async function postFormatted(connector, { conversationId, threadKey, form // split answer is noise, and repeated buttons would fire the same action several times. ...(last && footer ? { footer } : {}), ...(last && buttons ? { buttons } : {}), - })); + }); + posted.push(sent); + await onPosted(sent); } return posted; } 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 8915f89..23d945d 100644 --- a/src/platforms/inbound.js +++ b/src/platforms/inbound.js @@ -24,11 +24,13 @@ export function makeInbound({ kind = "channel", threadKey = "", messageId = "", + replyToId = "", userId = "", userName = "", userEmail = "", text = "", mentionsBot = false, + trigger = "message", attachments = [], raw = null, } = {}) { @@ -47,12 +49,15 @@ export function makeInbound({ // conversation. The gateway's session key is derived from it, never parsed out of it. threadKey: String(threadKey || ""), messageId: String(messageId || ""), + // A quoted message ID, distinct from a native thread address. + replyToId: String(replyToId || ""), userId: String(userId || ""), userName: String(userName || ""), userEmail: String(userEmail || ""), 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 f700f06..812649b 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,13 +8,17 @@ 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"; // 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 { prepareVoiceAttachments, hasVoiceAttachments } from "./voice.js"; +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 @@ -58,9 +51,10 @@ async function ensureUserKnown(message) { await setUser(message.userId, { name: message.userName || message.userEmail || message.userId }); } -export function createIngest({ connector, log = console, run = runMessage } = {}) { +export function createIngest({ connector, log = console, run = runMessage, onCommand = null, voice = prepareVoiceAttachments } = {}) { 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`); @@ -73,7 +67,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); @@ -91,10 +85,17 @@ export function createIngest({ connector, log = console, run = runMessage } = {} return { skipped: "unauthorized" }; } - // 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 }); + const sessionKey = sessionKeyForMessage(message); + const rememberReply = (sent) => { + if (message.kind === "group" && !message.threadKey && sent?.messageId) { + rememberReplySession(message.conversationId, sent.messageId, sessionKey); + } + }; + const reply = async (text) => deliver(connector, message, null, text, rememberReply); + if (onCommand && await onCommand({ message, sessionKey, entry, meta, authorIsAdmin, reply, controls })) return { command: true }; + 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) => { // These surfaces have no typing indicator the daemon can drive for minutes, and no streaming. // A placeholder message is the only honest "I'm working on it" available — and it is also the // message the answer edits, so a finished turn leaves ONE message behind, not two. @@ -103,20 +104,40 @@ export function createIngest({ connector, log = console, run = runMessage } = {} placeholder = await connector.post({ conversationId: message.rawConversationId, threadKey: message.threadKey, - text: "_Working on it…_", + text: hasVoiceAttachments(message) ? "_Preparing voice transcription…_" : "_Working on it…_", }); + rememberReply(placeholder); } catch (err) { log.warn?.(`[${adapter.id}] placeholder post failed: ${err?.message || err}`); } + const progress = createConversationProgress({ connector, message, placeholder, adapter, log }); let result; + let skipped = []; + let prepared; try { + signal.throwIfAborted(); + const saved = await saveInboundAttachments(message, { slug: entry.slug, meta, log }); + skipped = saved.skipped; + signal.throwIfAborted(); + if (hasVoiceAttachments(message)) progress.phase('Transcribing voice locally'); + prepared = await voice(message, saved.paths, { signal }); + if (prepared.hasVoice && !prepared.hasPrompt && !prepared.paths.length) { + await progress.stop(); + await deliver(connector, message, placeholder, prepared.failureNotice || 'Could not transcribe this audio. Please send text or ask an administrator to check local Whisper.', rememberReply); + return { skipped: 'voice-unavailable' }; + } + progress.activity(); result = await run({ channelId: message.conversationId, authorId: message.userId, - text: message.text, - threadKey: message.threadKey, - attachments: paths, + text: prepared.text, + // Session roots for flat chats must never become native reply addresses. + threadKey: sessionKey, + attachments: prepared.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 @@ -126,25 +147,30 @@ 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}`); + 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)_"; + if (prepared.failureNotice) text += `\n\n${prepared.failureNotice}`; if (skipped.length) { text += `\n\n_Couldn't read ${skipped.length} attachment(s): ${skipped.join(", ")} — this surface only hands the bot files it uploaded directly._`; } - await deliver(connector, message, placeholder, text); + await deliver(connector, message, placeholder, text, rememberReply); return { result }; + } }); }; } // One answer, formatted for the surface, replacing the placeholder where the surface allows it. // A long answer is split at the platform's cap; the FIRST chunk edits the placeholder and the rest // are posted after it, so the "Working on it…" line never survives next to the real answer. -async function deliver(connector, message, placeholder, text) { +async function deliver(connector, message, placeholder, text, rememberReply = () => {}) { const adapter = platformOr(connector.platform); const directory = await connector.directory?.(message.rawConversationId).catch(() => null); const formatted = adapter.formatOutbound(text, { directory, capabilities: adapter.capabilities }); @@ -159,12 +185,12 @@ async function deliver(connector, message, placeholder, text) { mentions: chunks[0].mentions || [], }); for (const chunk of chunks.slice(1)) { - await connector.post({ + rememberReply(await connector.post({ conversationId: message.rawConversationId, threadKey: placeholder.threadKey || message.threadKey, text: chunk.text, mentions: chunk.mentions || [], - }); + })); } return; } catch (err) { @@ -176,5 +202,44 @@ async function deliver(connector, message, placeholder, text) { conversationId: message.rawConversationId, threadKey: placeholder?.threadKey || message.threadKey, formatted: { chunks }, + 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 { + phase(label) { state = label; lastActivity = now(); }, + 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..ed91907 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, @@ -52,7 +53,7 @@ export const teamsAdapter = validatePlatformAdapter({ blockQuotes: false, richCards: "adaptive-cards", buttons: true, - modals: true, // task modules + modals: false, // forms are inline Adaptive Cards; task-module dialogs are not wired nativeTables: false, nativeCharts: false, lists: false, @@ -67,7 +68,7 @@ export const teamsAdapter = validatePlatformAdapter({ seesUnmentionedMessages: false, mentionSyntax: "teams", mentionByEmail: true, // mention by UPN/email or Entra object id - broadcast: true, + broadcast: false, proactivePost: true, proactiveDm: true, // stored conversation references; 1:1 by Entra object id }, @@ -83,6 +84,10 @@ export const teamsAdapter = validatePlatformAdapter({ ); }, normalizeName, + async workspaceAccess(grant) { + const { teamsWorkspaceContext } = await import("./msteams/workspace-access.js"); + return teamsWorkspaceContext(grant); + }, // Lazy settings import for the same reason as the Google Chat adapter: no static cycle from a // descriptor into the config/database stack. health: async () => { diff --git a/src/platforms/msteams/activity.js b/src/platforms/msteams/activity.js index c44cda6..1f24670 100644 --- a/src/platforms/msteams/activity.js +++ b/src/platforms/msteams/activity.js @@ -42,8 +42,38 @@ export function splitConversationId(raw) { const KINDS = { personal: "dm", groupchat: "group", channel: "channel" }; -export function normalizeActivity(activity, { botId = "", fetchImpl = fetch } = {}) { - if (String(activity?.type || "").toLowerCase() !== "message") return null; +// Current Teams SDK: quotedReply entity. Older clients use a schema.skype.com/Reply +// blockquote, sometimes carried only in the mirrored HTML attachment. Never infer a reference +// from an ordinary quote, nested itemid, or a quote explicitly marked invalid/deleted. +export function quotedReplyId(activity) { + const quotes = (Array.isArray(activity.entities) ? activity.entities : []).filter(e => e?.type === "quotedReply"); + if (quotes.length) { + if (quotes.length !== 1) return ""; + const quote = quotes[0].quotedReply; + return quote?.isReplyDeleted || quote?.validatedMessageReference === false || typeof quote?.messageId !== "string" + ? "" : quote.messageId.trim(); + } + const texts = [activity.text, ...(Array.isArray(activity.attachments) ? activity.attachments : []) + .filter(a => a.contentType === "text/html" && typeof a.content === "string").map(a => a.content)]; + const ids = new Set(); + for (const text of texts) { + for (const match of String(text || "").matchAll(/]*)>/gi)) { + const attributes = Object.create(null); + for (const attr of match[1].matchAll(/([\w:-]+)\s*=\s*(?:"([^"]*)"|'([^']*)')/g)) { + attributes[attr[1].toLowerCase()] = attr[2] ?? attr[3]; + } + if (attributes.itemtype === "http://schema.skype.com/Reply" && attributes.itemid) ids.add(attributes.itemid); + } + } + return ids.size === 1 ? [...ids][0] : ""; +} + +export function normalizeActivity(activity, { botId = "", fetchImpl = fetch, resolveFile = 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. @@ -63,8 +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: 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 || ""), @@ -72,9 +106,9 @@ 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), + attachments: normalizeAttachments(activity.attachments, fetchImpl, resolveFile), raw: { activity, aadObjectId: String(from.aadObjectId || ""), @@ -85,7 +119,7 @@ export function normalizeActivity(activity, { botId = "", fetchImpl = fetch } = }); } -function normalizeAttachments(list, fetchImpl) { +function normalizeAttachments(list, fetchImpl, resolveFile) { const out = []; for (const att of Array.isArray(list) ? list : []) { const contentType = String(att?.contentType || ""); @@ -106,6 +140,13 @@ function normalizeAttachments(list, fetchImpl) { }); continue; } + // Graph reference attachments stay lazy: the ingest author/channel authorization gate runs + // before download() can ask Graph for metadata or file bytes. + if (contentType.toLowerCase() === "reference") { + out.push({ name: String(att.name || "attachment"), contentType: "application/octet-stream", + download: typeof resolveFile === "function" ? resolveFile({ contentUrl: String(contentUrl) }) : null }); + continue; + } out.push({ name: String(att?.name || "attachment"), contentType: contentType || "application/octet-stream", @@ -122,3 +163,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..cf6dd9f 100644 --- a/src/platforms/msteams/api.js +++ b/src/platforms/msteams/api.js @@ -99,12 +99,12 @@ export function createTeamsApi({ auth, serviceUrl = DEFAULT_SERVICE_URL, fetchIm return { messageId: res?.id || "" }; }, - async updateActivity(conversationId, activityId, { text, entities = [] } = {}) { + async updateActivity(conversationId, activityId, { text, entities = [], attachments = null } = {}) { const id = String(activityId || ""); if (!isActivityId(id)) throw new Error("Teams activity id contains characters outside the Bot Framework set"); await call(`v3/conversations/${target(conversationId)}/activities/${encodeURIComponent(id)}`, { method: "PUT", - body: { type: "message", textFormat: "markdown", text: String(text ?? ""), ...(entities?.length ? { entities } : {}) }, + body: { type: "message", textFormat: "markdown", text: String(text ?? ""), ...(entities?.length ? { entities } : {}), ...(attachments?.length ? { attachments } : {}) }, }); }, @@ -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/cards.js b/src/platforms/msteams/cards.js new file mode 100644 index 0000000..dcc07b9 --- /dev/null +++ b/src/platforms/msteams/cards.js @@ -0,0 +1,67 @@ +// Native Teams cards. Text and lists are bounded at rendering, with an explicit preview notice. +export const ADAPTIVE_CARD_TYPE = "application/vnd.microsoft.card.adaptive"; +const SCOPES = new Set(["thread", "conversation", "channel", "user", "gateway", "once", "always", "forever"]); +const MAX_CARD_BYTES = 24_000; +export function boundedText(value, max = 2400) { + const text = String(value ?? ""); + return text.length > max ? `${text.slice(0, Math.max(0, max - 35))}\n[Preview truncated — open details]` : text; +} +const block = (text, extra = {}) => ({ type: "TextBlock", text: boundedText(text), wrap: true, ...extra }); +const card = (title, body = [], actions = []) => ({ $schema: "http://adaptivecards.io/schemas/adaptive-card.json", type: "AdaptiveCard", version: "1.4", body: [block(title, { weight: "Bolder", size: "Medium" }), ...body], ...(actions.length ? { actions } : {}) }); +function action(title, verb, data = {}, associatedInputs = "auto") { + const payload = { ...data, cgAction: verb }; + return { type: "Action.Execute", title: boundedText(title, 80), verb, data: payload, associatedInputs, + fallback: { type: "Action.Submit", title: boundedText(title, 80), data: payload, associatedInputs } }; +} +function choices(values) { + return values.slice(0, 50).map(item => typeof item === "string" + ? { title: boundedText(item, 100), value: item } + : { title: boundedText(item.title || item.label || item.name || item.id || item.value, 100), value: String(item.value || item.id || item.model || "") }) + .filter(item => item.value && item.value.length <= 200); +} +export function adaptiveCardAttachment(value) { + if (value?.type !== "AdaptiveCard" || !Array.isArray(value.body)) throw new Error("A valid AdaptiveCard is required"); + if (Buffer.byteLength(JSON.stringify(value), "utf8") > MAX_CARD_BYTES) throw new Error("Adaptive card exceeds the supported preview size"); + return { contentType: ADAPTIVE_CARD_TYPE, content: value }; +} +export function messageCard({ title = "ChannelGate", text = "" } = {}) { return card(title, [block(text)]); } +export function modelSettingsCard({ catalog = [], current = {}, actionData = {} } = {}) { + const models = choices(catalog); + const body = [block("Choose a model and where to apply it."), + { type: "Input.ChoiceSet", id: "model", label: "Model", style: "compact", isRequired: true, errorMessage: "Choose a model", choices: models, + ...(models.some(item => item.value === current.model) ? { value: current.model } : {}) }, + { type: "Input.ChoiceSet", id: "scope", label: "Apply to", style: "compact", value: ["thread", "channel", "gateway"].includes(current.scope) ? current.scope : "thread", + choices: [{ title: "This task", value: "thread" }, { title: "This conversation", value: "channel" }, { title: "Gateway default", value: "gateway" }] }]; + if (catalog.length > 50) body.push(block("Model list truncated to 50 choices. Use settings to view the complete catalog.")); + return card("Model settings", body, [action("Save", "model.save", actionData)]); +} +export function approvalCard({ id, title = "Approval requested", details = "", target = "", approvalType = "", scopes = ["once"], actionData = {} } = {}) { + const allowed = scopes.map(item => typeof item === "string" ? item : item.value).filter(scope => SCOPES.has(scope)); + if (!id || !allowed.length) throw new Error("Approval requires an id and supported scopes"); + const body = [block(details || target || approvalType || "Review this request before approving.")]; + if (allowed.length > 1) body.push({ type: "Input.ChoiceSet", id: "scope", label: "Approval scope", choices: allowed.map(value => ({ title: value, value })), value: allowed[0], isRequired: true }); + body.push({ type: "Input.Text", id: "comment", label: "Optional changes requested (a comment refuses the current action)", isMultiline: true, maxLength: 4000 }); + const data = { ...actionData, id: String(id), ...(allowed.length === 1 ? { scope: allowed[0] } : {}) }; + return card(title, body, [action("Approve", "approval.respond", { ...data, decision: "approve" }), + action("Deny", "approval.respond", { ...data, decision: "deny" }, "none"), + action("Request changes", "approval.respond", { ...data, decision: "deny" })]); +} +export function settingsCard({ title = "Settings", description = "Choose a setting.", items = [], actionData = {} } = {}) { + const shown = items.slice(0, 8); + const body = [block(description)]; + if (items.length > shown.length) body.push(block("Settings list truncated. Open settings for the remaining options.")); + return card(title, body, shown.map(item => action(item.title || item.label || item.id, item.action || "settings.open", { ...actionData, section: item.id || item.section }))); +} +export function formCard({ title = "Settings", fields = [], action: verb = "settings.save", actionData = {} } = {}) { + if (fields.length > 12) throw new Error("A card form supports at most 12 fields"); + const seen = new Set(); + const body = fields.map(field => { + if (!/^[a-zA-Z][a-zA-Z0-9_]{0,63}$/.test(field.id) || seen.has(field.id) || ["cgAction", "actorId", "userId", "senderId", "conversationId", "tenantId", "serviceUrl"].includes(field.id)) throw new Error("Invalid or reserved card field id"); + seen.add(field.id); + const base = { id: field.id, label: boundedText(field.label || field.id, 100), isRequired: Boolean(field.required) }; + if (field.choices) return { type: "Input.ChoiceSet", ...base, choices: choices(field.choices), value: String(field.value || ""), style: "compact" }; + if (field.type === "toggle") return { type: "Input.Toggle", ...base, title: base.label, value: field.value ? "true" : "false", valueOn: "true", valueOff: "false" }; + return { type: "Input.Text", ...base, value: boundedText(field.value || "", 1000), maxLength: Math.min(Number(field.maxLength) || 1000, 4000), isMultiline: Boolean(field.multiline) }; + }); + return card(title, body, [action("Save", verb, actionData)]); +} diff --git a/src/platforms/msteams/connector.js b/src/platforms/msteams/connector.js index abdb607..f144063 100644 --- a/src/platforms/msteams/connector.js +++ b/src/platforms/msteams/connector.js @@ -1,4 +1,5 @@ // The Microsoft Teams ChatConnector. +import { adaptiveCardAttachment } from "./cards.js"; import { validateConnector } from "../connector.js"; import { parseConversationId } from "../ids.js"; import { createTeamsApi, isConversationId } from "./api.js"; @@ -16,6 +17,7 @@ export function toConversationId(conversationId) { export function createTeamsConnector({ auth, capabilities, api = null, botId = "", tenantId = "", serviceUrl, log = console } = {}) { const teams = api || createTeamsApi({ auth, ...(serviceUrl ? { serviceUrl } : {}) }); const directories = new Map(); + const threadFor = (key) => (/^[0-9]+$/.test(String(key || "")) ? String(key) : null); async function directoryFor(conversationId) { const id = toConversationId(conversationId); @@ -72,8 +74,27 @@ export function createTeamsConnector({ auth, capabilities, api = null, botId = " return { messageId: res.messageId, conversationId: dm, threadKey: "", ephemeral: true }; } } - const res = await teams.sendActivity(id, { text: body, entities: mentions, threadKey: threadKey || "" }); - return { messageId: res.messageId, conversationId: id, threadKey: threadKey || "" }; + const thread = threadFor(threadKey) || ""; + const res = await teams.sendActivity(id, { text: body, entities: mentions, threadKey: thread }); + return { messageId: res.messageId, conversationId: id, threadKey: thread }; + }, + + async postCard({ conversationId, threadKey, card, text = "", ephemeralTo = "" } = {}) { + const attachment = adaptiveCardAttachment(card); + let id = toConversationId(conversationId); + let thread = threadFor(threadKey) || ""; + if (ephemeralTo) { + const dm = await openDm(ephemeralTo); + // Native private forms must not fall back to posting their contents into a room. + if (!dm) throw new Error("Cannot open a private Teams chat for this card"); + id = dm; thread = ""; + } + const res = await teams.sendActivity(id, { text: String(text), threadKey: thread, attachments: [attachment] }); + return { messageId: res.messageId, conversationId: id, threadKey: thread, ...(ephemeralTo ? { ephemeral: true } : {}) }; + }, + + async updateCard({ conversationId, messageId, card, text = "" } = {}) { + await teams.updateActivity(toConversationId(conversationId), messageId, { text: String(text), attachments: [adaptiveCardAttachment(card)] }); }, async edit({ conversationId, messageId, text, mentions = [], footer = "" } = {}) { @@ -90,9 +111,9 @@ export function createTeamsConnector({ auth, capabilities, api = null, botId = " // A Teams thread handle is the root activity id, which is digits. Anything else is a synthetic // key from shared plumbing and must post into the conversation itself rather than be smuggled // into a ";messageid=" suffix. - threadFor: (threadKey) => (/^[0-9]+$/.test(String(threadKey || "")) ? String(threadKey) : null), + 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/controls.js b/src/platforms/msteams/controls.js new file mode 100644 index 0000000..5c6508e --- /dev/null +++ b/src/platforms/msteams/controls.js @@ -0,0 +1,137 @@ +// Native Teams controls hold conversation/session authority server-side. Card data carries only +// an opaque, expiring state ID; editing the card payload cannot select another user's workspace. +import { randomUUID } from 'node:crypto'; +import { getPublicUrl, canChangeChannelRuntime, getDefaultModel } from '../../config/settings.js'; +import { ENGINE_IDS, modelsForEngine, effortsForModel } from '../../engines/registry.js'; +import { resolveThreadEngine, getThreadModel, getThreadEffort } from '../../gateway/thread-engine.js'; +import { teamsWorkspaceContext } from './workspace-access.js'; +import { listVisibleDirectory, normalizeRelativePath, canEditChannelFiles, readEditableFile } from '../../slack/file-explorer.js'; +import { createFileDownloadGrantUrl } from '../../web/file-download.js'; +import { createFileEditorGrantUrl } from '../../web/file-editor.js'; +import { createFileUploadGrantUrl } from '../../web/file-upload.js'; +import { createTeamsFileConsent } from './file-consent.js'; +import { createTeamsInteractionHandler } from './interactions.js'; +import { handlePlatformApproval } from '../../slack/approvals.js'; + +const card = (title, body = [], actions = []) => ({ type: 'AdaptiveCard', version: '1.4', body: [{ type: 'TextBlock', text: title, weight: 'Bolder', wrap: true }, ...body], actions }); +const text = value => ({ type: 'TextBlock', text: String(value), wrap: true }); +const execute = (title, verb, stateId, data = {}) => ({ type: 'Action.Execute', title, verb, data: { cgAction: verb, stateId, ...data }, fallback: { type: 'Action.Submit', title, data: { cgAction: verb, stateId, ...data } } }); +const link = (title, url) => ({ type: 'Action.OpenUrl', title, url }); +const response = value => ({ status: 200, body: { statusCode: 200, type: 'application/vnd.microsoft.card.adaptive', value } }); + +export function createTeamsControls({ connector, now = Date.now, authorize = teamsWorkspaceContext, approval = handlePlatformApproval, publicUrl = getPublicUrl } = {}) { + const states = new Map(); + const consent = createTeamsFileConsent({ connector, now, authorizeWorkspace: ({ message, entry }) => authorize({ channelId: message.conversationId, ownerId: message.userId, slug: entry.slug }, { connector }) }); + const prune = () => { for (const [id, state] of states) if (state.expires < now()) states.delete(id); while (states.size > 500) states.delete(states.keys().next().value); }; + const grant = state => ({ channelId: state.message.conversationId, slug: state.entry.slug, ownerId: state.message.userId, threadTs: state.sessionKey }); + async function settings(state, stateId) { + const engine = await resolveThreadEngine(state.entry.slug, state.sessionKey, state.meta); + const model = await getThreadModel(state.entry.slug, state.sessionKey) || state.meta.model || getDefaultModel(engine); + const effort = await getThreadEffort(state.entry.slug, state.sessionKey) || state.meta.effort || ''; + const body = [text('These controls apply to this session. Channel settings and secrets open in the authenticated admin website.'), + { type: 'Input.ChoiceSet', id: 'engine', label: 'Engine', value: engine, choices: ENGINE_IDS.map(value => ({ title: value, value })) }, + { type: 'Input.ChoiceSet', id: 'model', label: 'Model (choose an engine-compatible model)', value: model || 'default', choices: [{ title: 'Inherited default', value: 'default' }, ...ENGINE_IDS.flatMap(id => modelsForEngine(id).map(item => ({ title: `${id}: ${item.label || item.value}`, value: item.value })))] }, + { type: 'Input.ChoiceSet', id: 'effort', label: 'Effort', value: effort || 'default', choices: [{ title: 'Inherited default', value: 'default' }, ...[...new Set(ENGINE_IDS.flatMap(id => modelsForEngine(id).flatMap(item => effortsForModel(id, item.value))))].map(value => ({ title: value, value }))] }]; + const actions = [execute('Save session settings', 'model.save', stateId)]; + const base = publicUrl(); if (base) actions.push(link('Channel settings and secrets', `${base}/conversations`)); + return card('Session settings', body, actions); + } + async function files(state, stateId, relative = '', page = 0) { + const context = await authorize(grant(state), { connector }); + const listing = await listVisibleDirectory(context.root, normalizeRelativePath(relative), { page, pageSize: 12 }); + const body = [text(`Folder: /${listing.relative} — page ${listing.page + 1}/${listing.totalPages}`)]; + for (const file of listing.entries.filter(item => item.accessible !== false)) { + body.push({ type: 'ActionSet', actions: [execute(`${file.type === 'directory' ? '📁' : '📄'} ${file.name}`.slice(0, 80), file.type === 'directory' ? 'files.browse' : 'files.download', stateId, { relative: file.relative })] }); + } + const actions = [execute('Root', 'files.browse', stateId)]; + if (listing.page) actions.push(execute('Previous', 'files.browse', stateId, { relative: listing.relative, page: listing.page - 1 })); + if (listing.page + 1 < listing.totalPages) actions.push(execute('Next', 'files.browse', stateId, { relative: listing.relative, page: listing.page + 1 })); + if (canEditChannelFiles(context.meta, { isAdminUser: context.userIsAdmin })) actions.push(execute('Upload files', 'files.upload', stateId, { relative: listing.relative })); + return card('Conversation files (private)', body, actions); + } + async function deliverCard(state, build, inConversation = false) { + const destination = inConversation || state.message.isDM ? state.message.rawConversationId : await connector.openDm(state.message.userId); + if (!destination) throw new Error('Open a personal chat with the bot first; private controls could not be delivered.'); + prune(); const id = randomUUID(); + state = { ...state, deliveryId: destination, expires: now() + 15 * 60_000 }; + states.set(id, state); + try { await connector.postCard({ conversationId: destination, threadKey: inConversation ? state.message.threadKey : undefined, card: await build(state, id), text: inConversation ? 'Session settings' : 'Private conversation controls' }); } + catch (error) { states.delete(id); throw error; } + } + async function onCommand(args) { + const { message, reply } = args; + if (message.trigger === 'reaction') return false; + if (message.text.trim().toLowerCase() === '/help') { + await reply('Commands: /settings (native session form), /files [folder], /secrets (authenticated settings), /sendfile (personal file consent), /status, /model, /effort, /stop, /cancel, /clear. In group chats, quote the original message or bot reply and mention the bot to control that session. Voice notes require local Whisper.'); + return true; + } + const match = /^\/(settings|files|secrets|sendfile)(?:\s+(.*))?$/is.exec(message.text.trim()); + if (!match) return false; + try { + if (match[1].toLowerCase() === 'sendfile') { + if (!match[2]?.trim()) throw new Error('Use /sendfile followed by a workspace-relative file path.'); + await consent.send({ ...args, relative: normalizeRelativePath(match[2].trim()) }); + await reply('Check your personal chat to accept or decline the file.'); + return true; + } + const inConversation = match[1].toLowerCase() === 'settings'; + await deliverCard(args, (state, id) => match[1].toLowerCase() === 'files' ? files(state, id, match[2] || '') : settings(state, id), inConversation); + if (!inConversation && !message.isDM) await reply('I sent the controls to your personal chat.'); + } catch (error) { await reply(error.message); } + return true; + } + const dispatchInvoke = createTeamsInteractionHandler({ dispatch: async interaction => { + if (interaction.action === 'approval.respond') { + const result = await approval({ ...interaction.data, conversationId: interaction.conversationId, messageId: interaction.responseMessageId, actorId: interaction.actorId }); + return response(card(result.ok ? 'Approval recorded' : 'Approval unavailable', [text(result.outcome || result.error || 'Handled.')])); + } + prune(); const state = states.get(interaction.data.stateId); + if (!state || state.message.userId !== interaction.actorId || state.deliveryId !== interaction.nativeConversationId) throw new Error('These controls expired or belong to a different conversation/user. Reopen them.'); + const context = await authorize(grant(state), { connector }); + state.meta = context.meta; state.authorIsAdmin = context.userIsAdmin; + if (interaction.action === 'files.browse') return response(await files(state, interaction.data.stateId, interaction.data.relative || '', Number(interaction.data.page) || 0)); + if (interaction.action === 'model.save') { + if (!state.message.isDM && !canChangeChannelRuntime(context.userIsAdmin)) throw new Error('Only administrators may change this conversation runtime.'); + const engine = String(interaction.data.engine || ''), model = interaction.data.model === 'default' ? '' : String(interaction.data.model || ''), effort = interaction.data.effort === 'default' ? '' : String(interaction.data.effort || ''); + if (!ENGINE_IDS.includes(engine) || (model && !modelsForEngine(engine).some(item => item.value === model)) || (effort && !effortsForModel(engine, model || getDefaultModel(engine)).includes(effort))) throw new Error('Select a compatible engine, model, and effort.'); + if (states.get(interaction.data.stateId) !== state) throw new Error('These settings were already submitted. Reopen the controls.'); + states.delete(interaction.data.stateId); + const replies = []; + const command = async value => state.controls.command({ ...state, slug: state.entry.slug, message: { ...state.message, text: value }, reply: async value => replies.push(value) }); + await command(`/model ${engine} ${model || 'default'}`); + // A busy-lane refusal must not be followed by another mutation. + if (replies.at(-1)?.startsWith('Session engine:')) await command(`/effort ${effort || 'default'}`); + return response(card('Session settings', replies.map(text))); + } + const baseUrl = publicUrl(); if (!baseUrl) throw new Error('Set the gateway Public URL before opening browser files.'); + const relative = normalizeRelativePath(interaction.data.relative || ''); + const input = { ...grant(state), baseUrl, relative }; + if (interaction.action === 'files.upload') { + if (!canEditChannelFiles(context.meta, { isAdminUser: context.userIsAdmin })) throw new Error('This conversation is read-only for you.'); + return response(card('Upload files', [text('Files will be saved into this conversation workspace.')], [link('Open uploader', createFileUploadGrantUrl(input))])); + } + if (interaction.action === 'files.download') { + const actions = [link('Download file', createFileDownloadGrantUrl(input))]; + if (canEditChannelFiles(context.meta, { isAdminUser: context.userIsAdmin })) { + try { const editable = await readEditableFile(context.root, relative); actions.push(link('Edit file', createFileEditorGrantUrl({ ...input, expectedHash: editable.hash }))); } catch { /* binary/protected/large files are download only */ } + } + return response(card('File', [text(relative)], actions)); + } + throw new Error('Unsupported Teams action.'); + } }); + async function onInvoke(activity) { + if (activity.type === 'invoke' && activity.name === 'fileConsent/invoke') return consent.handle(activity); + const result = await dispatchInvoke(activity); + // Legacy Submit is a message activity: HTTP response cards are ignored by Teams clients. + // Deliver the replacement explicitly, only to the already verified interaction conversation. + if (activity.type === 'message' && result.body?.type === 'application/vnd.microsoft.card.adaptive') { + const destination = String(activity.conversation.id).split(';messageid=')[0]; + const update = { conversationId: destination, messageId: activity.replyToId, card: result.body.value, text: 'Conversation controls' }; + if (update.messageId) await connector.updateCard(update); + else await connector.postCard(update); + return { status: 200, body: {} }; + } + return result; + } + return { onCommand, onInvoke, stop() { states.clear(); consent.stop(); } }; +} 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/file-consent.js b/src/platforms/msteams/file-consent.js new file mode 100644 index 0000000..9309e49 --- /dev/null +++ b/src/platforms/msteams/file-consent.js @@ -0,0 +1,146 @@ +// Native personal-chat file sending. Only the verified invoke wrapper calls handle(). +// Microsoft documents the consent PUT and file.info flow at: +// https://learn.microsoft.com/en-us/microsoftteams/platform/bots/how-to/bots-filesv4 +import { randomUUID } from "node:crypto"; +import { constants } from "node:fs"; +import { open, realpath } from "node:fs/promises"; +import path from "node:path"; +import { resolveVisiblePath } from "../../slack/file-explorer.js"; +import { isAllowedDownloadUrl } from "./activity.js"; +import { isConversationId } from "./api.js"; + +const MAX_BYTES = 10 * 1024 * 1024; +const MAX_PENDING = 10; +const TTL_MS = 10 * 60_000; +const within = (root, item) => item === root || item.startsWith(`${root}${path.sep}`); +const response = (status, message) => ({ status, body: { message } }); +function microsoftUrl(value) { + if (!isAllowedDownloadUrl(value)) return false; + const url = new URL(value); + return !url.username && !url.password && !url.hash && (!url.port || url.port === "443"); +} +async function snapshot(root, relative) { + const target = await resolveVisiblePath(root, relative, { kind: "file" }); + const fd = await open(target.path, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK); + try { + const [opened, info] = await Promise.all([realpath(`/proc/self/fd/${fd.fd}`), fd.stat()]); + if (!info.isFile() || !within(target.rootReal, opened)) throw new Error("File is outside the authorized workspace."); + if (!info.size || info.size > MAX_BYTES) throw new Error("Native Teams sending supports nonempty files up to 10 MB. Use the browser download link for this file."); + const bytes = Buffer.alloc(info.size); + let offset = 0; + while (offset < bytes.length) { + const read = await fd.read(bytes, offset, bytes.length - offset, offset); + if (!read.bytesRead) throw new Error("The file changed while preparing the upload. Try again."); + offset += read.bytesRead; + } + const after = await fd.stat(); + if (after.size !== info.size || after.mtimeMs !== info.mtimeMs || after.ctimeMs !== info.ctimeMs) throw new Error("The file changed while preparing the upload. Try again."); + return { bytes, root: target.rootReal, relative: target.relative, name: path.basename(target.relative) }; + } finally { await fd.close(); } +} + +export function createTeamsFileConsent({ connector, authorizeWorkspace, fetchImpl = fetch, now = Date.now } = {}) { + if (!connector?.api?.sendActivity || typeof authorizeWorkspace !== "function") throw new TypeError("Teams file consent requires a connector and workspace authorization"); + const pending = new Map(); + let activeUploads = 0; + let stopped = false; + const uploads = new Set(); + const aborts = new Set(); + function remove(id) { + const row = pending.get(id); + clearTimeout(row?.timer); + pending.delete(id); + return row; + } + function prune() { + for (const [id, row] of pending) if (row.expiresAt <= now()) remove(id); + } + async function send({ message, entry, sessionKey = "", relative } = {}) { + if (stopped) throw new Error("Teams file sending is disconnected."); + prune(); + if (!message?.userId || !message.conversationId || !entry?.slug || typeof relative !== "string" || !relative.trim()) throw new Error("Choose one file from this conversation's workspace."); + if (pending.size + activeUploads >= MAX_PENDING) throw new Error("Too many pending Teams uploads. Accept or decline an existing request first."); + const id = randomUUID(); + // Reserve before any asynchronous work, so concurrent preparations share the same memory cap. + const row = { expiresAt: now() + TTL_MS, actorId: message.userId, source: { message, entry, sessionKey, relative }, ready: false }; + row.timer = setTimeout(() => remove(id), TTL_MS); + row.timer.unref?.(); + pending.set(id, row); + try { + const authorized = await authorizeWorkspace(row.source); + if (!authorized?.root) throw new Error("Workspace access was not authorized."); + const file = await snapshot(authorized.root, relative); + const destination = message.isDM || message.kind === "dm" ? message.rawConversationId : await connector.openDm(message.userId); + if (!isConversationId(destination)) throw new Error("Cannot open your personal Teams chat. Install the app personally and try again."); + if (!pending.has(id) || row.expiresAt <= now()) throw new Error("File consent preparation expired. Try again."); + Object.assign(row, file, { conversationId: destination, ready: true }); + const sent = await connector.api.sendActivity(destination, { + text: `Accept to receive ${file.name}. This request expires in 10 minutes.`, + attachments: [{ contentType: "application/vnd.microsoft.teams.card.file.consent", name: file.name, + content: { description: "Send the prepared snapshot of this workspace file to your OneDrive.", sizeInBytes: file.bytes.length, + acceptContext: { id }, declineContext: { id } } }], + }); + return { status: 200, body: { message: "File consent sent to your personal Teams chat." }, conversationId: destination, messageId: sent?.messageId || "", id }; + } catch (error) { remove(id); throw error; } + } + async function handle(activity) { + if (stopped) return response(410, "Teams file sending is disconnected."); + if (activity?.name !== "fileConsent/invoke" || activity.type !== "invoke") return response(400, "Unsupported file consent event"); + prune(); + const value = activity.value || {}; + const id = value.context?.id; + const row = typeof id === "string" ? pending.get(id) : null; + if (!row || !row.ready) return response(410, "File request expired or was already handled. Request the file again."); + if (activity.from?.id !== row.actorId || activity.conversation?.id !== row.conversationId) return response(403, "File consent belongs to another person or conversation."); + if (value.type !== "fileUpload" || !["accept", "decline"].includes(value.action)) return response(400, "Invalid file consent decision"); + if (value.action === "decline") { remove(id); return response(200, "File upload declined."); } + const upload = value.uploadInfo; + if (!upload || !microsoftUrl(upload.uploadUrl) || !microsoftUrl(upload.contentUrl) || typeof upload.uniqueId !== "string" || !/^[A-Za-z0-9_.!:-]{1,256}$/.test(upload.uniqueId)) return response(400, "Invalid Microsoft file upload destination."); + try { + const authorized = await authorizeWorkspace(row.source); + if (!authorized?.root || await realpath(authorized.root) !== row.root) return response(403, "The source workspace is no longer authorized."); + } catch { return response(403, "The source workspace is no longer authorized."); } + // Recheck after authorization's await: another callback may already have consumed this grant. + if (stopped || pending.get(id) !== row || row.expiresAt <= now()) return response(410, "File request expired or was already handled."); + remove(id); // Consume before PUT. An uncertain upload is never automatically repeated. + activeUploads++; + const abort = new AbortController(); + aborts.add(abort); + // The invoke response must not wait for a network upload. Track completion so stop can + // abort work and tests can drain it; a failed upload reports only into the bound personal DM. + let task; + task = Promise.resolve().then(async () => { + try { + const uploaded = await fetchImpl(upload.uploadUrl, { + method: "PUT", body: row.bytes, redirect: "error", signal: AbortSignal.any([abort.signal, AbortSignal.timeout(60_000)]), + headers: { "Content-Type": "application/octet-stream", "Content-Length": String(row.bytes.length), "Content-Range": `bytes 0-${row.bytes.length - 1}/${row.bytes.length}` }, + }); + if (![200, 201].includes(uploaded.status)) throw new Error("Upload not confirmed"); + await connector.api.sendActivity(row.conversationId, { + text: `${row.name} uploaded.`, + attachments: [{ contentType: "application/vnd.microsoft.teams.card.file.info", name: row.name, contentUrl: upload.contentUrl, + content: { uniqueId: upload.uniqueId, fileType: path.extname(row.name).slice(1) } }], + }); + } catch { + await connector.api.sendActivity(row.conversationId, { + text: "File upload outcome could not be confirmed. Check OneDrive before requesting the file again. This upload will not be retried automatically.", + }).catch(() => {}); + } finally { + activeUploads--; + aborts.delete(abort); + uploads.delete(task); + } + }).catch(() => {}); + uploads.add(task); + return response(200, "File upload accepted. Completion will be reported in your personal Teams chat."); + } + return { + send, handle, + drain: () => Promise.allSettled([...uploads]), + stop() { + stopped = true; + for (const id of pending.keys()) remove(id); + for (const abort of aborts) abort.abort(); + }, + }; +} diff --git a/src/platforms/msteams/files.js b/src/platforms/msteams/files.js new file mode 100644 index 0000000..eb1c0c9 --- /dev/null +++ b/src/platforms/msteams/files.js @@ -0,0 +1,100 @@ +// Opt-in Graph file resolution. A valid Teams activity is not permission to read an arbitrary +// tenant drive: resolve metadata, verify the explicit drive allowlist, then fetch bytes WITHOUT +// the Graph bearer. Nothing is fetched until the authorized ingestion path invokes download(). +import { ATTACHMENT_MAX_BYTES, readBoundedBytes, oversizeMessage } from '../../util/bounded-bytes.js'; + +const GRAPH = 'https://graph.microsoft.com/v1.0'; +const ID = /^[A-Za-z0-9!_-]{1,512}$/; +const SHARE_HOST = /^[a-z0-9][a-z0-9-]*\.sharepoint\.com$/i; +const DOWNLOAD_HOST = /^(?:[a-z0-9][a-z0-9.-]*\.)?(?:sharepoint\.com|1drv\.com|storage\.live\.com)$/i; + +function safeUrl(raw, host) { + if (typeof raw !== 'string' || raw.length > 8192) return null; + try { + const url = new URL(raw); + return url.protocol === 'https:' && !url.username && !url.password && !url.port && !url.hash && host.test(url.hostname) ? url.href : null; + } catch { return null; } +} + +function descriptorPath(descriptor, drives) { + if (!descriptor || typeof descriptor !== 'object' || Array.isArray(descriptor)) return null; + const keys = Object.keys(descriptor); + if (keys.length === 1 && keys[0] === 'contentUrl') { + const url = safeUrl(descriptor.contentUrl, SHARE_HOST); + return url ? { url } : null; + } + if (keys.length === 2 && keys.includes('driveId') && keys.includes('itemId') && typeof descriptor.driveId === 'string' && typeof descriptor.itemId === 'string' && ID.test(descriptor.driveId) && ID.test(descriptor.itemId) && drives.has(descriptor.driveId)) { + return { route: `/drives/${encodeURIComponent(descriptor.driveId)}/items/${encodeURIComponent(descriptor.itemId)}` }; + } + return null; +} + +function tooLarge(actual, max) { return Object.assign(new Error(`downloaded file ${oversizeMessage(actual, max)}`), { code: 'ETOOLARGE' }); } + +export function createTeamsFileResolver({ auth, allowedDriveIds = [], fetchImpl = fetch, maxBytes = ATTACHMENT_MAX_BYTES } = {}) { + if (typeof auth?.token !== 'function') throw new TypeError('Teams file resolution requires Graph authentication'); + if (!Array.isArray(allowedDriveIds) || allowedDriveIds.length > 32 || allowedDriveIds.some((id) => typeof id !== 'string' || !ID.test(id))) throw new TypeError('Teams file drive allowlist is invalid'); + if (!Number.isSafeInteger(maxBytes) || maxBytes < 1 || maxBytes > ATTACHMENT_MAX_BYTES) throw new TypeError('Teams file byte limit is invalid'); + const drives = new Set(allowedDriveIds); + return function resolveFile(descriptor) { + if (!drives.size) return null; + const reference = descriptorPath(descriptor, drives); + if (!reference) return null; + return async function download() { + const timeout = AbortSignal.timeout(120000); + const token = await auth.token(); + const metadataAt = async (route, fields) => { + const metadata = await fetchImpl(`${GRAPH}${route}?$select=${fields}`, { + method: 'GET', redirect: 'error', signal: timeout, + headers: { authorization: `Bearer ${token}` }, + }); + if (!metadata.ok) throw new Error(`Teams file metadata is unavailable (${metadata.status}). Check selected-site permissions.`); + return JSON.parse((await readBoundedBytes(metadata, 128 * 1024)).toString('utf8')); + }; + let route = reference.route; + let resolvedDrive = descriptor.driveId; + if (!route) { + const target = new URL(reference.url); + for (const drive of drives) { + const root = await metadataAt(`/drives/${encodeURIComponent(drive)}/root`, 'id,webUrl'); + const rootUrl = safeUrl(root?.webUrl, SHARE_HOST); + if (!rootUrl) continue; + const base = new URL(rootUrl); + if (base.origin !== target.origin) continue; + let relative; + try { + const prefix = decodeURIComponent(base.pathname).replace(/\/$/, '') + '/'; + const targetPath = decodeURIComponent(target.pathname); + if (!targetPath.startsWith(prefix)) continue; + relative = targetPath.slice(prefix.length).split('/'); + } catch { continue; } + if (!relative.length || relative.some(part => !part || part === '.' || part === '..' || /[\\\x00-\x1f]/.test(part))) continue; + route = `/drives/${encodeURIComponent(drive)}/root:/${relative.map(encodeURIComponent).join('/')}`; + resolvedDrive = drive; + break; + } + if (!route) throw new Error('Use a canonical SharePoint file link inside an allowed drive, or upload the file directly in a personal chat. Sharing shortlinks are not supported.'); + } + const item = await metadataAt(route, 'id,name,parentReference,file,size,@microsoft.graph.downloadUrl'); + if (!item || typeof item.id !== 'string' || !ID.test(item.id) || !item.file || !drives.has(item.parentReference?.driveId) || item.parentReference.driveId !== resolvedDrive) throw new Error('Teams file is outside the allowed drives or is not a file.'); + if (descriptor.itemId && (item.id !== descriptor.itemId || item.parentReference.driveId !== descriptor.driveId)) throw new Error('Teams file identity did not match the requested item.'); + if (Number(item.size) > maxBytes) throw tooLarge(Number(item.size), maxBytes); + const url = safeUrl(item['@microsoft.graph.downloadUrl'], DOWNLOAD_HOST); + if (!url) throw new Error('Teams file download URL is not an allowed Microsoft file host.'); + const response = await fetchImpl(url, { method: 'GET', redirect: 'error', signal: timeout }); + if (!response.ok) throw new Error(`Teams file download failed (${response.status}).`); + const declared = Number(response.headers.get('content-length')); + if (Number.isFinite(declared) && declared > maxBytes) { await response.body?.cancel().catch(() => {}); throw tooLarge(declared, maxBytes); } + if (!response.body) throw new Error('Teams file download was empty.'); + let total = 0; + const body = response.body.pipeThrough(new TransformStream({ + transform(chunk, controller) { + total += chunk.byteLength; + if (total > maxBytes) throw tooLarge(total, maxBytes); + controller.enqueue(chunk); + }, + })); + return new Response(body, { status: 200, headers: response.headers }); + }; + }; +} diff --git a/src/platforms/msteams/graph-activity.js b/src/platforms/msteams/graph-activity.js new file mode 100644 index 0000000..1c82a41 --- /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, ...(file.contentUrl ? { reference: { contentUrl: file.contentUrl } } : {}) })), + 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/interactions.js b/src/platforms/msteams/interactions.js new file mode 100644 index 0000000..cdbe0e8 --- /dev/null +++ b/src/platforms/msteams/interactions.js @@ -0,0 +1,54 @@ +// Card payloads carry data, never authority. Identity comes only from the verified activity envelope. +import { messageCard, ADAPTIVE_CARD_TYPE } from "./cards.js"; +import { splitConversationId } from "./activity.js"; +import { isConversationId, validateServiceUrl } from "./api.js"; +const SCOPES = new Set(["thread", "conversation", "channel", "user", "gateway", "once", "always", "forever"]); +const RESERVED = new Set(["__proto__", "prototype", "constructor", "actorId", "userId", "senderId", "conversationId", "tenantId", "serviceUrl", "aadObjectId"]); +const plain = value => value && typeof value === "object" && !Array.isArray(value) && [Object.prototype, null].includes(Object.getPrototypeOf(value)); +export function isTeamsCardInteraction(activity) { + if (activity?.type === "invoke") return true; + return activity?.type === "message" && plain(activity.value) && typeof activity.value.cgAction === "string"; +} +export function normalizeTeamsInteraction(activity) { + const value = activity?.value; + if (!plain(value)) throw new Error("Card input must be an object"); + const execute = activity.type === "invoke" && activity.name === "adaptiveCard/action"; + if (activity.type === "invoke" && !execute && !["task/submit", "composeExtension/submitAction"].includes(activity.name)) throw new Error("Unsupported Teams invoke action"); + const supplied = execute ? value.action?.data : value.data && plain(value.data) ? value.data : value; + if (!plain(supplied) || Buffer.byteLength(JSON.stringify(supplied)) > 16_384) throw new Error("Invalid card form data"); + const verb = execute ? value.action?.verb || supplied.cgAction : supplied.cgAction; + if (typeof verb !== "string" || !/^[a-z][a-z0-9_.-]{0,79}$/.test(verb)) throw new Error("Invalid card action"); + if (execute && value.action.type !== "Action.Execute" && value.action.type !== "Action.Submit") throw new Error("Unsupported card action type"); + const data = {}; + for (const [key, item] of Object.entries(supplied)) { + if (RESERVED.has(key) || key === "cgAction" || key === "msteams") continue; + if (typeof item !== "string" && typeof item !== "number" && typeof item !== "boolean" && item !== null) throw new Error("Card inputs must be scalar values"); + if (typeof item === "string" && item.length > 8000) throw new Error("Card input is too long"); + data[key] = item; + } + if (Object.hasOwn(data, "scope") && !SCOPES.has(data.scope)) throw new Error("Unsupported card scope"); + const actorId = activity.from?.id; + const split = splitConversationId(activity.conversation?.id); + if (!actorId || !isConversationId(actorId) || !split.conversationId || !isConversationId(split.conversationId)) throw new Error("Card action requires a complete actor and conversation"); + const serviceUrl = validateServiceUrl(activity.serviceUrl); + if (!serviceUrl) throw new Error("Invalid card service URL"); + return { action: verb, data, actorId, aadObjectId: activity.from?.aadObjectId || "", conversationId: `teams:${split.conversationId}`, + nativeConversationId: split.conversationId, threadKey: split.threadKey, tenantId: activity.channelData?.tenant?.id || activity.conversation?.tenantId || "", + serviceUrl, activityId: String(activity.id || ""), responseMessageId: String(activity.replyToId || "") }; +} +export function createTeamsInteractionHandler({ dispatch, authorize = null } = {}) { + if (typeof dispatch !== "function") throw new TypeError("Teams card interaction dispatch is required"); + return async activity => { + let interaction; + try { interaction = normalizeTeamsInteraction(activity); } + catch { return { status: 400, body: { error: "Invalid card action" } }; } + // The business dispatcher must check approval ownership and current channel/user permissions. + try { + if (authorize && !await authorize(interaction)) return { status: 403, body: { error: "Not allowed" } }; + return await dispatch(interaction); + } catch { + return { status: 200, body: { statusCode: 200, type: ADAPTIVE_CARD_TYPE, + value: messageCard({ title: "Action could not be completed", text: "Reopen the controls to check the current state before trying again." }) } }; + } + }; +} diff --git a/src/platforms/msteams/transport.js b/src/platforms/msteams/transport.js index ff1d80d..00d602d 100644 --- a/src/platforms/msteams/transport.js +++ b/src/platforms/msteams/transport.js @@ -3,8 +3,15 @@ // 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 { createTeamsFileResolver } from "./files.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,8 +25,14 @@ export async function startTeams({ appPassword, tenantId = "", serviceUrl = DEFAULT_SERVICE_URL, + allMessageEvents = false, + publicUrl = "", + filesEnabled = false, + fileDriveIds = [], capabilities, onMessage, + onInvoke = null, + onStop = null, log = console, deps = {}, } = {}) { @@ -31,16 +44,150 @@ export async function startTeams({ const api = deps.api || createTeamsApi({ auth, serviceUrl }); const botId = botIdFor(appId); + const resolveFile = filesEnabled === true ? createTeamsFileResolver({ + auth: deps.graphAuth || createTeamsAuth({ clientId: appId, clientSecret: appPassword, tenantId, scope: GRAPH_SCOPE }), + allowedDriveIds: fileDriveIds, + }) : null; 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; + if (resolveFile) inbound.attachments = (inbound.attachments || []).map(file => ({ ...file, download: file.reference ? resolveFile(file.reference) : null })); + 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, onInvoke, resolveFile, 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() { onStop?.(); 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..406b3a8 100644 --- a/src/platforms/msteams/webhook.js +++ b/src/platforms/msteams/webhook.js @@ -10,16 +10,19 @@ // • authenticate BEFORE looking at the body — the endpoint is public // • answer 200 immediately and run the turn afterwards — Bot Service expects a fast ack and // retries anything else, so doing the work inline would deliver the same message repeatedly +import { createHash } from "node:crypto"; +import { isTeamsCardInteraction } from "./interactions.js"; import { verifyTeamsRequest, activityFingerprint, createJwksCache } from "./verify.js"; 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, onInvoke = null, resolveFile = null } = {}) { 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(); const seen = dedupe || createDedupe(500); + const invokes = new Map(); return async function handleTeamsActivity(req, res) { const activity = req.body || {}; @@ -45,11 +48,39 @@ export function createTeamsWebhook({ appId, botId = "", onMessage, jwks = null, return; } + if (isTeamsCardInteraction(activity)) { + if (!onInvoke) { res.status(501).json({ error: "Teams card actions are not configured" }); return; } + if (!activity.id || !activity.from?.id || !activity.conversation?.id) { res.status(400).json({ error: "Incomplete card interaction" }); return; } + // Retried invokes must return the same result, including retries arriving during dispatch. + // Business handlers additionally enforce persisted single-use ownership for mutations. + const key = createHash("sha256").update(JSON.stringify([activity.id, activity.from.id, activity.conversation.id])).digest("hex"); + for (const [id, entry] of invokes) if (entry.finished && entry.at < Date.now() - 600_000) invokes.delete(id); + if (!invokes.has(key)) { + if (invokes.size >= 500) { res.status(429).json({ error: "Card action capacity reached; retry later" }); return; } + const entry = { at: Date.now(), finished: false }; + entry.result = Promise.resolve().then(() => onInvoke(activity)).then(result => { + if (!result || !Number.isInteger(result.status) || result.status < 200 || result.status > 599) throw new Error("Invalid Teams invoke response"); + return result; + }).catch(() => { + log.error?.("[msteams] card action failed"); + return { status: 500, body: { error: "Card action failed" } }; + }).finally(() => { entry.finished = true; }); + invokes.set(key, entry); + } + const result = await invokes.get(key).result; + res.status(result.status).json(result.body ?? {}); + return; + } + res.status(200).json({}); try { if (seen.isDuplicate(activityFingerprint(activity))) return; - const message = normalizeActivity(activity, { botId }); + 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, resolveFile }); if (!message) return; // not a message activity, or our own echo await onMessage(message, { serviceUrl }); } catch (err) { diff --git a/src/platforms/msteams/workspace-access.js b/src/platforms/msteams/workspace-access.js new file mode 100644 index 0000000..1da79c7 --- /dev/null +++ b/src/platforms/msteams/workspace-access.js @@ -0,0 +1,20 @@ +// Browser grants and native file actions repeat current Teams membership and gateway policy. +import { getChannelEntry, getChannelMeta, isAdmin, isApproved } from '../../config/store.js'; +import { isAuthorized } from '../../gateway/modes.js'; +import { effectiveWorkDir } from '../../gateway/folders.js'; +import { liveConnector } from '../live.js'; +import { parseConversationId } from '../ids.js'; + +export async function teamsWorkspaceContext(grant, { connector = liveConnector('msteams') } = {}) { + const parsed = parseConversationId(grant.channelId); + if (parsed.platform !== 'msteams' || !grant.ownerId || !connector?.api?.listMembers) throw new Error('Teams workspace is unavailable.'); + const entry = await getChannelEntry(grant.channelId); + if (!entry || (grant.slug && grant.slug !== entry.slug)) throw new Error('Conversation no longer exists.'); + const meta = await getChannelMeta(entry.slug); + const userIsAdmin = await isAdmin(grant.ownerId); + const approved = await isApproved(grant.ownerId); + if (!meta || !isAuthorized(meta, grant.ownerId, Boolean(meta.isDM), { isAdminUser: userIsAdmin, isApprovedUser: approved })) throw new Error('You are no longer allowed to use this workspace.'); + const members = await connector.api.listMembers(parsed.id); + if (!members.some(member => member.id === grant.ownerId)) throw new Error('Teams conversation membership could not be confirmed.'); + return { root: effectiveWorkDir(entry.slug, meta), meta, entry, userIsAdmin, userId: grant.ownerId }; +} diff --git a/src/platforms/reply-sessions.js b/src/platforms/reply-sessions.js new file mode 100644 index 0000000..ffff839 --- /dev/null +++ b/src/platforms/reply-sessions.js @@ -0,0 +1,32 @@ +// Session identity is separate from a platform's native reply address. In a flat group chat, +// each new message starts a session; quoted user and bot messages point back to its root. +// Persist only IDs, scoped to the qualified conversation, so quotes still resolve after restart. +import { getDb } from "../db/index.js"; + +export function rememberReplySession(conversationId, messageId, threadKey) { + if (!messageId) return; + getDb().prepare(`INSERT INTO conversation_reply_sessions(conversation_id, message_id, thread_key) + VALUES (?, ?, ?) ON CONFLICT(conversation_id, message_id) DO NOTHING`) + .run(conversationId, messageId, threadKey); +} + +export function sessionKeyForMessage(message) { + if (message.threadKey) return message.threadKey; + if (message.kind !== "group") return message.conversationId; + if (!message.messageId) throw new Error("Group chat message has no message id"); + const db = getDb(); + db.exec("BEGIN IMMEDIATE"); + try { + const lookup = db.prepare(`SELECT thread_key FROM conversation_reply_sessions + WHERE conversation_id = ? AND message_id = ?`); + const existing = lookup.get(message.conversationId, message.messageId); + const quoted = message.replyToId ? lookup.get(message.conversationId, message.replyToId) : null; + const key = existing?.thread_key || quoted?.thread_key || `group:${message.replyToId || message.messageId}`; + rememberReplySession(message.conversationId, message.messageId, key); + db.exec("COMMIT"); + return key; + } catch (error) { + db.exec("ROLLBACK"); + throw error; + } +} diff --git a/src/platforms/voice.js b/src/platforms/voice.js new file mode 100644 index 0000000..38de13b --- /dev/null +++ b/src/platforms/voice.js @@ -0,0 +1,53 @@ +// The portable audio path is local-only. Never call Slack transcript discovery for another surface. +import path from 'node:path'; +import { getWhisperEnabled } from '../config/settings.js'; +import { isAudioFile, transcribeAudioFiles, composeVoicePrompt } from '../gateway/transcribe.js'; +import { safeFileName } from './attachments.js'; + +export function hasVoiceAttachments(message) { + return (message.attachments || []).some((file) => isAudioFile({ name: file.name, mimetype: file.contentType })); +} + +export async function prepareVoiceAttachments(message, paths, { + signal, enabled = getWhisperEnabled(), transcribe = transcribeAudioFiles, +} = {}) { + signal?.throwIfAborted(); + const pending = new Map(paths.map((file) => [path.basename(file), file])); + const audioPaths = new Set(); + const audio = []; + const failed = []; + let hasVoice = false; + // The attachment sink prefixes the ORIGINAL index, including failed downloads. Match that + // stable index/name pair; zipping successful paths to all source metadata misclassifies files. + for (const [index, file] of (message.attachments || []).entries()) { + if (!isAudioFile({ name: file.name, mimetype: file.contentType })) continue; + hasVoice = true; + const name = safeFileName(file.name, index); + const saved = pending.get(`${index + 1}-${name}`); + if (saved) audioPaths.add(saved); + if (!saved) failed.push({ name, reason: 'Audio could not be downloaded on this surface.' }); + else if (!enabled) failed.push({ name, reason: 'Local voice transcription is disabled. Ask an administrator to enable Whisper or send the message as text.' }); + else audio.push({ name, path: saved }); + } + let transcripts = []; + if (audio.length) { + try { + const result = await transcribe(audio, { signal }); + transcripts = (result.transcripts || []).filter((item) => String(item.text || '').trim()); + failed.push(...(result.failed || [])); + for (const file of audio) if (!transcripts.some((item) => item.name === file.name) && !failed.some((item) => item.name === file.name)) failed.push({ name: file.name, reason: 'Local Whisper detected no speech.' }); + } catch (error) { + signal?.throwIfAborted(); + failed.push(...audio.map((file) => ({ name: file.name, reason: 'Local transcription failed. Ask an administrator to check the Whisper installation, or send text.' }))); + } + } + signal?.throwIfAborted(); + return { + paths: paths.filter((file) => !audioPaths.has(file)), + text: composeVoicePrompt({ text: message.text, transcripts, failed }), + hasVoice, + hasPrompt: Boolean(String(message.text || '').trim() || transcripts.length), + failed, + failureNotice: failed.length ? composeVoicePrompt({ failed }) : '', + }; +} diff --git a/src/server.js b/src/server.js index 23f99d7..29dff05 100644 --- a/src/server.js +++ b/src/server.js @@ -25,10 +25,11 @@ 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"; -import { takeStaleRuns, recoverRuns } from "./gateway/active-runs.js"; +import { takeStaleRuns, createRunRecovery } from "./gateway/active-runs.js"; import { recoverApiRuns } from "./gateway/api-runs.js"; import { startNudgeSweep } from "./gateway/nudges.js"; import { startClaudeLoginWatch } from "./gateway/login-watch.js"; @@ -37,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"; @@ -253,14 +254,16 @@ 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`); } 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 }); @@ -289,6 +292,7 @@ async function main() { // binds the port), so it was interrupted. Taking it up front means turns started by messages // arriving after reconnect get fresh rows and aren't mistaken for stale ones. const staleRuns = takeStaleRuns(); + const runRecovery = createRunRecovery(staleRuns, { slack }); // License verification (src/ee/license.js). Deliberately started here and NOT awaited: the // ChannelGate platform is a remote HTTP service and the daemon must come up whether or not it @@ -313,7 +317,7 @@ async function main() { // Auto re-run interactive Slack turns that were interrupted mid-flight by the restart — resumes // the same thread/session and posts the answer. Runs after Slack is connected so it can post. if (staleRuns.length) console.log(`[gateway] recovering ${staleRuns.length} interrupted turn(s)…`); - await recoverRuns(staleRuns, { slack }).catch((e) => console.error("[gateway] run recover failed:", e?.message || e)); + await runRecovery.start().catch((e) => console.error("[gateway] run recover failed:", e?.message || e)); // Auto re-run API jobs that were queued/running when the daemon restarted. Slack-backed API jobs // resume in their original thread; headless jobs resume silently and finish through status/webhook. diff --git a/src/slack/app.js b/src/slack/app.js index 3636bb6..e47cf73 100644 --- a/src/slack/app.js +++ b/src/slack/app.js @@ -3,6 +3,7 @@ // explicit @bot mention), authorizes the author against the channel's allowedUsers, then runs // the message through the gateway orchestrator and posts the reply in the thread. import { hasComposioSdkEntitlement } from "../ee/composio-entitlement.js"; +import { createFileFormNavigation } from "./file-form-navigation.js"; import pkg from "@slack/bolt"; const { App, LogLevel } = pkg; @@ -95,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 }; @@ -278,7 +280,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); @@ -297,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, { @@ -357,6 +390,99 @@ async function openSecretsManager(client, triggerId, { channelId, userId, thread await logEvent("channel_secrets_opened", { channel: channelId, author: userId, slug: entry.slug }); } +export async function handleSecretsAction({ ack, body, action, client }, { context = secretsContext, rootView = settingsRootView } = {}) { + await ack(); + const clicker = body?.user?.id; + const command = parseSecretActionValue(action?.value); + try { + if (command.o === "open") { + if (!clicker || command.u !== clicker || !body?.trigger_id) throw new Error("This secrets button isn't for you."); + await openSecretsManager(client, body.trigger_id, { channelId: command.c, userId: clicker, threadTs: command.t || "" }); + return; + } + const state = parseSecretsMetadata(body?.view?.private_metadata); + if (body?.view?.callback_id === "cg_channel_settings_modal") state.returnTo = "settings"; + if (!clicker || state.ownerId !== clicker) throw new Error("This secrets manager isn't yours. Open your own with `/secrets`."); + const { entry, mayEdit, userIsAdmin } = await context(client, { + channelId: state.channelId, userId: clicker, expectedSlug: state.slug, verifyMembership: true, + }); + if (!mayEdit) throw new Error("You can't change this channel's secrets."); + if (action?.action_id === SECRETS_ADD_ACTION_ID) { + await client.views.push({ + trigger_id: body.trigger_id, + view: buildSecretFormView(state, { channelName: entry.name, suggested: cliEnvKeys(cliIntegrationIds()) }), + }); + return; + } + if (String(action?.action_id || "").startsWith(SECRETS_REMOVE_ACTION_PREFIX)) { + const name = String(command.n || ""); + // Runs resolve env at spawn; no folder/skill provisioning is needed for an env-only edit. + const saved = await patchChannelMeta(entry.slug, (existing) => ({ env: patchChannelEnv(existing?.env, { remove: name }) })); + await logEvent("channel_env_removed", { slug: entry.slug, name, actor: clicker }); + const notice = `🗑️ Removed *${name}*. New runs in this channel no longer receive it.`; + const nextView = state.returnTo === "settings" + ? await rootView(entry, saved, { ...state, tab: "secrets" }, userIsAdmin, { notice }) + : buildSecretsView(listChannelEnv(saved), state, { channelName: entry.name, mayEdit, notice }); + await updateFileExplorerView(client, body, nextView); + } + } catch (e) { + if (body?.view?.id) { + await client.views.update({ view_id: body.view.id, view: buildSecretsErrorView(e.message) }).catch(() => {}); + } else if (body?.channel?.id && clicker) { + await client.chat.postEphemeral({ channel: body.channel.id, user: clicker, text: e.message }).catch(() => {}); + } + } +} + +export async function handleSecretFormSubmission({ ack, body, view, client }, { context = secretsContext, rootView = settingsRootView } = {}) { + const navigation = createFileFormNavigation({ ack, client, view }); + const clicker = body?.user?.id; + let state; + try { + state = parseSecretsMetadata(view?.private_metadata); + if (!clicker || state.ownerId !== clicker) throw new Error("This secrets manager isn't yours. Open your own with `/secrets`."); + } catch (e) { + await ack({ response_action: "errors", errors: { [SECRETS_NAME_BLOCK_ID]: e.message.slice(0, 150) } }); + return; + } + const { name: typedName, value } = readSecretForm(view); + // Validate each field against its own input so the error lands on the box that is wrong. + // assertValidEnvName returns the CANONICAL (uppercase) name — use that from here on so the + // stored key, the "added vs updated" check, the audit line and the confirmation all agree. + const errors = {}; + let name = String(typedName || "").trim(); + try { name = assertValidEnvName(typedName); } catch (e) { errors[SECRETS_NAME_BLOCK_ID] = e.message.slice(0, 150); } + try { assertValidEnvValue(value); } catch (e) { errors[SECRETS_VALUE_BLOCK_ID] = e.message.slice(0, 150); } + if (Object.keys(errors).length > 0) { + await ack({ response_action: "errors", errors }); + return; + } + try { + const { entry, mayEdit, userIsAdmin } = await context(client, { + channelId: state.channelId, userId: clicker, expectedSlug: state.slug, verifyMembership: true, + }); + if (!mayEdit) throw new Error("You can't change this channel's secrets."); + const existed = listChannelEnv(await getChannelMeta(entry.slug)).some((v) => v.name === name); + const saved = await patchChannelMeta(entry.slug, (existing) => ({ + env: patchChannelEnv(existing?.env, { set: { name, value }, actor: `<@${clicker}>` }), + })); + await logEvent("channel_env_set", { slug: entry.slug, name, actor: clicker }); + const notice = `✅ ${existed ? "Updated" : "Added"} *${name}*. It reaches the next run in this channel.`; + const nextView = state.returnTo === "settings" + ? await rootView(entry, saved, { ...state, tab: "secrets" }, userIsAdmin, { notice }) + : buildSecretsView(listChannelEnv(saved), { ...state, editName: "" }, { channelName: entry.name, mayEdit, notice }); + // Pop the temporary form and refresh its existing parent, preserving Settings navigation + // and Slack's three-view stack budget across repeated saves. + await navigation.show(nextView); + } catch (e) { + if (navigation.acknowledged) { + await client.views.update({ view_id: view.previous_view_id || view.id, view: buildSecretsErrorView(e.message) }); + } else { + await ack({ response_action: "errors", errors: { [SECRETS_NAME_BLOCK_ID]: e.message.slice(0, 150) } }); + } + } +} + // ── Settings for authorized channel users ────────────────────────────────── // Re-check access and membership on every interaction; Cloud MCP additionally requires admin. const SETTINGS_PURPOSE = { @@ -887,7 +1013,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, @@ -981,7 +1107,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) { @@ -1007,46 +1133,6 @@ async function connectAndWire(app) { if (channelId && userId) await client.chat.postEphemeral({ channel: channelId, user: userId, ...(threadTs ? { thread_ts: threadTs } : {}), text: e.message }).catch(() => {}); } }); - const handleSecretsAction = async ({ ack, body, action, client }) => { - await ack(); - const clicker = body?.user?.id; - const command = parseSecretActionValue(action?.value); - try { - if (command.o === "open") { - if (!clicker || command.u !== clicker || !body?.trigger_id) throw new Error("This secrets button isn't for you."); - await openSecretsManager(client, body.trigger_id, { channelId: command.c, userId: clicker, threadTs: command.t || "" }); - return; - } - const state = parseSecretsMetadata(body?.view?.private_metadata); - if (!clicker || state.ownerId !== clicker) throw new Error("This secrets manager isn't yours. Open your own with `/secrets`."); - const { entry, mayEdit } = await secretsContext(client, { - channelId: state.channelId, userId: clicker, expectedSlug: state.slug, verifyMembership: true, - }); - if (!mayEdit) throw new Error("You can't change this channel's secrets."); - if (action?.action_id === SECRETS_ADD_ACTION_ID) { - await client.views.push({ - trigger_id: body.trigger_id, - view: buildSecretFormView(state, { channelName: entry.name, suggested: cliEnvKeys(cliIntegrationIds()) }), - }); - return; - } - if (String(action?.action_id || "").startsWith(SECRETS_REMOVE_ACTION_PREFIX)) { - const name = String(command.n || ""); - const saved = await patchChannelMeta(entry.slug, (existing) => ({ env: patchChannelEnv(existing?.env, { remove: name }) })); - await ensureChannelFolder(entry.slug, effectiveMeta(saved)); - await logEvent("channel_env_removed", { slug: entry.slug, name, actor: clicker }); - await updateFileExplorerView(client, body, buildSecretsView(listChannelEnv(saved), state, { - channelName: entry.name, mayEdit, notice: `🗑️ Removed *${name}*. New runs in this channel no longer receive it.`, - })); - } - } catch (e) { - if (body?.view?.id) { - await client.views.update({ view_id: body.view.id, view: buildSecretsErrorView(e.message) }).catch(() => {}); - } else if (body?.channel?.id && clicker) { - await client.chat.postEphemeral({ channel: body.channel.id, user: clicker, text: e.message }).catch(() => {}); - } - } - }; app.action(SECRETS_ACTION_PATTERN, handleSecretsAction); app.shortcut(SECRETS_SHORTCUT_ID, async ({ ack, body, client }) => { await ack(); @@ -1277,14 +1363,8 @@ async function connectAndWire(app) { } if (actionId === CHANNEL_SETTINGS_SECRETS_MANAGE_ACTION_ID) { - const secretAccess = await secretsContext(client, { - channelId: state.channelId, userId: clicker, expectedSlug: state.slug, verifyMembership: true, - }); - if (!secretAccess.mayEdit) throw new Error("You can't change this channel's secrets in its current mode."); - await client.views.push({ - trigger_id: requireTrigger(), - view: buildSecretsView(listChannelEnv(meta), state, { channelName: entry.name, mayEdit: true }), - }); + // Compatibility with Settings views opened before the inline Secrets controls shipped. + await updateCurrent(await settingsRootView(entry, meta, { ...state, tab: "secrets" }, userIsAdmin)); return; } @@ -1410,59 +1490,14 @@ async function connectAndWire(app) { // Submitting the add/update form is the only moment a value exists in this process outside the // store. It is validated, written, and dropped — never put back into a view. - app.view(SECRETS_FORM_CALLBACK_ID, async ({ ack, body, view, client }) => { - const clicker = body?.user?.id; - let state; - try { - state = parseSecretsMetadata(view?.private_metadata); - if (!clicker || state.ownerId !== clicker) throw new Error("This secrets manager isn't yours. Open your own with `/secrets`."); - } catch (e) { - await ack({ response_action: "errors", errors: { [SECRETS_NAME_BLOCK_ID]: e.message.slice(0, 150) } }); - return; - } - const { name: typedName, value } = readSecretForm(view); - // Validate each field against its own input so the error lands on the box that is wrong. - // assertValidEnvName returns the CANONICAL (uppercase) name — use that from here on so the - // stored key, the "added vs updated" check, the audit line and the confirmation all agree. - const errors = {}; - let name = String(typedName || "").trim(); - try { name = assertValidEnvName(typedName); } catch (e) { errors[SECRETS_NAME_BLOCK_ID] = e.message.slice(0, 150); } - try { assertValidEnvValue(value); } catch (e) { errors[SECRETS_VALUE_BLOCK_ID] = e.message.slice(0, 150); } - if (Object.keys(errors).length > 0) { - await ack({ response_action: "errors", errors }); - return; - } - try { - const { entry, mayEdit } = await secretsContext(client, { - channelId: state.channelId, userId: clicker, expectedSlug: state.slug, verifyMembership: true, - }); - if (!mayEdit) throw new Error("You can't change this channel's secrets."); - const existed = listChannelEnv(await getChannelMeta(entry.slug)).some((v) => v.name === name); - const saved = await patchChannelMeta(entry.slug, (existing) => ({ - env: patchChannelEnv(existing?.env, { set: { name, value }, actor: `<@${clicker}>` }), - })); - await ensureChannelFolder(entry.slug, effectiveMeta(saved)); - await logEvent("channel_env_set", { slug: entry.slug, name, actor: clicker }); - // Replace the form with the refreshed (masked) list, so the writer sees the new last4 and - // nothing else. A warm session started before this change is retired by its fingerprint. - await ack({ - response_action: "update", - view: buildSecretsView(listChannelEnv(saved), { ...state, editName: "" }, { - channelName: entry.name, - mayEdit, - notice: `✅ ${existed ? "Updated" : "Added"} *${name}*. It reaches the next run in this channel.`, - }), - }); - } catch (e) { - await ack({ response_action: "errors", errors: { [SECRETS_NAME_BLOCK_ID]: e.message.slice(0, 150) } }); - } - }); + app.view(SECRETS_FORM_CALLBACK_ID, handleSecretFormSubmission); app.view("cg_channel_files_edit_modal", async ({ ack, body, view, client }) => { const clicker = body?.user?.id; + 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, @@ -1483,17 +1518,18 @@ async function connectAndWire(app) { file: saved.relative, bytes: saved.bytes.length, }); - await ack({ - response_action: "update", - view: await buildFilePreviewView(root, state, saved.relative, filePreviewOptions({ - state, - entry, - mayEdit: true, - notice: `✅ Saved ${path.basename(saved.relative)}.`, - })), - }); + await navigation.show(await buildFilePreviewView(root, state, saved.relative, filePreviewOptions({ + state, + entry, + mayEdit: true, + notice: `✅ Saved ${path.basename(saved.relative)}.`, + }))); } catch (e) { console.warn(`[slack] file editor error: ${e.message}`); + if (navigation.acknowledged) { + await navigation.show(fileExplorerErrorView(e.message)).catch(() => {}); + return; + } await ack({ response_action: "errors", errors: { file_content: String(e.message || "Couldn't save this file.").slice(0, 500) }, @@ -1502,10 +1538,10 @@ async function connectAndWire(app) { }); app.view("cg_channel_files_new_file_modal", async ({ ack, body, view, client }) => { const clicker = body?.user?.id; - let didAck = false; + 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); @@ -1513,9 +1549,8 @@ 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`."); - await ack({ response_action: "update", view: buildFilesLoadingView(state, { channelName: loadingEntry.name }) }); - didAck = true; + 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, userId: clicker, @@ -1532,40 +1567,36 @@ async function connectAndWire(app) { file: created.relative, bytes: created.bytes.length, }); - await client.views.update({ - view_id: body.view.id, - view: await buildFilePreviewView(root, state, created.relative, filePreviewOptions({ - state, - entry, - mayEdit: true, - notice: `✅ Created ${created.name}.`, - })), - }); + await navigation.show(await buildFilePreviewView(root, state, created.relative, filePreviewOptions({ + state, + entry, + mayEdit: true, + notice: `✅ Created ${created.name}.`, + }))); } catch (e) { console.warn(`[slack] file creation error: ${e.message}`); - if (!didAck) { + if (!navigation.acknowledged) { await ack({ response_action: "errors", errors: { [FILES_NEW_FILE_NAME_BLOCK_ID]: String(e.message || "Couldn't create this file.").slice(0, 500) }, }).catch(() => {}); return; } - await client.views.update({ view_id: body.view.id, view: fileExplorerErrorView(e.message) }).catch(() => {}); + await navigation.show(fileExplorerErrorView(e.message)).catch(() => {}); } }); app.view("cg_channel_files_new_folder_modal", async ({ ack, body, view, client }) => { const clicker = body?.user?.id; - let didAck = false; + 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`."); - await ack({ response_action: "update", view: buildFilesLoadingView(state, { channelName: loadingEntry.name }) }); - didAck = true; + 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, userId: clicker, @@ -1581,25 +1612,22 @@ async function connectAndWire(app) { slug: entry.slug, folder: created.relative, }); - await client.views.update({ - view_id: body.view.id, - view: await buildFilesView(root, { ...state, page: 0 }, fileExplorerViewOptions({ - state: { ...state, page: 0 }, - entry, - mayEdit: true, - notice: `✅ Created ${created.name}.`, - })), - }); + await navigation.show(await buildFilesView(root, { ...state, page: 0 }, fileExplorerViewOptions({ + state: { ...state, page: 0 }, + entry, + mayEdit: true, + notice: `✅ Created ${created.name}.`, + }))); } catch (e) { console.warn(`[slack] folder creation error: ${e.message}`); - if (!didAck) { + if (!navigation.acknowledged) { await ack({ response_action: "errors", errors: { [FILES_NEW_FOLDER_BLOCK_ID]: String(e.message || "Couldn't create this folder.").slice(0, 500) }, }).catch(() => {}); return; } - await client.views.update({ view_id: body.view.id, view: fileExplorerErrorView(e.message) }).catch(() => {}); + await navigation.show(fileExplorerErrorView(e.message)).catch(() => {}); } }); for (const a of APPROVAL_ACTIONS) app.action(a, handleApprovalClick); @@ -1845,6 +1873,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(); @@ -1859,23 +1891,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 }) => { @@ -2085,7 +2100,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` · `/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/approvals.js b/src/slack/approvals.js index ac74c6d..6f06086 100644 --- a/src/slack/approvals.js +++ b/src/slack/approvals.js @@ -24,7 +24,11 @@ import { import { retireApprovalLinkTokens } from "../gateway/approval-link-tokens.js"; import { approvalLinkBase, approvalLinksMessage, buildApprovalLinks } from "../web/approval-links.js"; import { slackAdapter } from "../platforms/slack.js"; +import { approvalDeliveryFor } from "../platforms/approval-delivery.js"; +import { platformOfConversation } from "../platforms/ids.js"; +import { platformOr } from "../platforms/registry.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 @@ -143,7 +147,12 @@ function approvalBlocks(id, toolName, target, authorId, { approvalType = "permis // posts TOP-LEVEL, which is what turns a synthetic session key into a real, replyable thread. // Fails loudly when Slack answers without a usable ts: an un-updatable card is a request nobody // can ever resolve or expire, and swallowing that is how scheduled approvals went missing. -async function postApprovalCard(client, { channelId, threadTs, text, blocks, context = {} }) { +async function postApprovalCard(client, { channelId, threadTs, text, blocks, approval, context = {} }) { + if (client?.approvalDelivery) { + const posted = await client.approvalDelivery.post({ threadKey: threadTs, ...approval }); + if (!posted?.messageId) throw new Error("Approval delivery returned no message identity"); + return posted.messageId; + } const posted = await client.chat.postMessage({ channel: channelId, thread_ts: threadTs || undefined, text, blocks }); const ts = posted?.ts; if (!isSlackTs(ts)) { @@ -195,19 +204,22 @@ async function approvalLinkChoices(entry, { durable = false, approveText = "Appr // answerable by its buttons, so a failure here must never fail the approval. async function deliverApprovalLinks(client, entry, { id, threadKey, durable = false, approveText, denyText } = {}) { try { - const baseUrl = approvalLinkBase({ capabilities: slackAdapter.capabilities, requester: entry.authorId }); + const baseUrl = approvalLinkBase({ capabilities: client?.approvalDelivery?.capabilities || slackAdapter.capabilities, requester: entry.authorId }); if (!baseUrl || !client) return []; const choices = await approvalLinkChoices(entry, { durable, approveText, denyText }); if (!choices.length) return []; const links = buildApprovalLinks({ baseUrl, id, kind: "approval", requester: entry.authorId || "", choices }); if (!links.length) return []; const text = approvalLinksMessage({ toolName: entry.toolName || "", links, expiresAt: links[0].expiresAt }); - await postPrivately(client, { + const sent = client?.approvalDelivery + ? await client.approvalDelivery.privately({ threadKey, userId: entry.authorId, text }) + : await postPrivately(client, { conversationId: entry.channelId, threadKey: slackThreadFor(threadKey) || "", userId: entry.authorId, text, }); + if (!sent) return []; return links; } catch { return []; // the buttons still work; a link is an addition, never a precondition @@ -217,9 +229,13 @@ async function deliverApprovalLinks(client, entry, { id, threadKey, durable = fa // Called by the daemon's /internal/approval route. Posts buttons, returns { allow, reason } once // 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"; - if (!channelId || !threadKey) return { allow: false, reason: "gateway can't reach Slack to ask for approval" }; + const adapter = platformOr(platformOfConversation(channelId)); + const delivery = approvalDeliveryFor(channelId); + const client = delivery ? { approvalDelivery: delivery } : adapter.capabilities.richCards === "block-kit" ? slack?.getClient?.() || currentClient : null; + const cardThread = (key) => delivery ? key : slackThreadFor(key); + 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 ${adapter.label} to ask for approval` }; const runKey = `${slug}::${threadKey}`; // Auto mode → approve without asking; tools "approved forever" here → likewise. Still sandboxed. if (approvalType === "permission") { @@ -261,7 +277,8 @@ export async function requestApproval(slack, { channelId, slug, authorId, thread try { const ts = await postApprovalCard(client, { channelId: existing.channelId, - threadTs: slackThreadFor(existing.action?.threadKey || threadKey), + threadTs: cardThread(existing.action?.threadKey || threadKey), + approval: { id: existing.id, title: existing.toolName, target: existing.target, authorId: existing.authorId, approvalType: existing.approvalType, scopes: approvalScopesFor({ approvalType: existing.approvalType, durable: true }) }, text: `Approval requested: ${existing.toolName}`, blocks: approvalBlocks(existing.id, existing.toolName, existing.target, existing.authorId, { approvalType: existing.approvalType, @@ -273,17 +290,17 @@ export async function requestApproval(slack, { channelId, slug, authorId, thread }); patchPendingApproval(existing.id, { msgTs: ts }); } catch { - return { allow: false, pending: true, approvalId: existing.id, reason: "approval is saved but Slack couldn't restore its card yet" }; + return { allow: false, pending: true, approvalId: existing.id, reason: "approval is saved but its conversation card could not be restored yet" }; } } return { 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" }; + if (!client) return { allow: false, reason: `gateway can't reach ${adapter.label} to ask for approval` }; const id = randomUUID(); try { createApprovalRequest({ @@ -312,7 +329,8 @@ export async function requestApproval(slack, { channelId, slug, authorId, thread try { const ts = await postApprovalCard(client, { channelId, - threadTs: slackThreadFor(threadKey), + threadTs: cardThread(threadKey), + approval: { id, title: toolName, target, authorId, approvalType, scopes: approvalScopesFor({ approvalType, durable }) }, text: `Approval requested: ${toolName}`, blocks: approvalBlocks(id, toolName, target, authorId, { approvalType, approveText, denyText, durable: true }), context: { approvalId: id, toolName, slug }, @@ -322,28 +340,34 @@ export async function requestApproval(slack, { channelId, slug, authorId, thread deleteApprovalRequest(id); return { allow: false, reason: `couldn't post the approval prompt: ${error.message}` }; } - await deliverApprovalLinks(client, { channelId, slug, authorId, toolName, approvalType, requiredTier }, { + const deliveredLinks = await deliverApprovalLinks(client, { channelId, slug, authorId, toolName, approvalType, requiredTier }, { id, threadKey, durable: true, approveText, denyText, }); + if (delivery?.requiresLinks && !deliveredLinks.length) { + deleteApprovalRequest(id); + rememberResolved(id, { decision: "deny", scope: "" }); + return { allow: false, reason: "Approval refused: private decision links could not be delivered." }; + } return { 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", }; } - if (!client) return { allow: false, reason: "gateway can't reach Slack to ask for approval" }; + if (!client) return { allow: false, reason: `gateway can't reach ${adapter.label} to ask for approval` }; const id = randomUUID(); let msgTs = null; try { msgTs = await postApprovalCard(client, { channelId, - threadTs: slackThreadFor(threadKey), + threadTs: cardThread(threadKey), + approval: { id, title: toolName, target, authorId, approvalType, scopes: approvalScopesFor({ approvalType, durable }) }, text: approvalType === "agent" ? `Approval requested: ${toolName}` : `🔒 Permission needed: ${toolName}`, blocks: approvalBlocks(id, toolName, target, authorId, { approvalType, approveText, denyText }), context: { approvalId: id, toolName, slug }, @@ -363,8 +387,8 @@ export async function requestApproval(slack, { channelId, slug, authorId, thread finish({ allow: false, reason: "approval timed out (no one clicked)" }); // Best-effort: flip the card so dead buttons can't be mistaken for a live request. The // deny itself already surfaced in-thread via the tool's refusal reply. - client.chat - .update({ channel: channelId, ts: msgTs, text: "Approval expired", blocks: [{ type: "section", text: { type: "mrkdwn", text: `⏱ *${toolName}* — nobody clicked within ${Math.round(APPROVAL_TIMEOUT_MS / 60000)} minutes; the request was refused.` } }] }) + (delivery ? delivery.update({ messageId: msgTs, title: "Approval expired", text: "No decision arrived before the request expired." }) : client.chat + .update({ channel: channelId, ts: msgTs, text: "Approval expired", blocks: [{ type: "section", text: { type: "mrkdwn", text: `⏱ *${toolName}* — nobody clicked within ${Math.round(APPROVAL_TIMEOUT_MS / 60000)} minutes; the request was refused.` } }] })) .catch(() => {}); }, APPROVAL_TIMEOUT_MS); timer.unref?.(); @@ -387,7 +411,13 @@ export async function requestApproval(slack, { channelId, slug, authorId, thread // exist the instant the card does, or a click landing in the gap between the two would find // no request and answer "already handled" while the run waits forever. The links are an // addition to a card that already works. - void deliverApprovalLinks(client, { channelId, slug, authorId, toolName, approvalType, requiredTier }, { id, threadKey, approveText, denyText }); + void deliverApprovalLinks(client, { channelId, slug, authorId, toolName, approvalType, requiredTier }, { id, threadKey, approveText, denyText }).then((links) => { + if (delivery?.requiresLinks && !links.length) { + finish({ allow: false, reason: "Approval refused: private decision links could not be delivered." }); + rememberResolved(id, { decision: "deny", scope: "" }); + void delivery.update({ messageId: msgTs, title: "Approval unavailable", text: "The request was refused because private decision links could not be delivered." }).catch(() => {}); + } + }); }); } @@ -401,7 +431,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 { @@ -495,6 +525,11 @@ export async function applyApprovalDecision({ const cardTs = entry.msgTs || messageTs; const reason = comment ? `Changes requested by ${who}` : approve ? `Approved by ${who}` : `Denied by ${who}`; const updateCard = async (text, blocks) => { + const delivery = approvalDeliveryFor(entry.channelId); + if (delivery && cardTs) { + await delivery.update({ messageId: cardTs, title: text, text: `${entry.toolName}: ${text}. ${reason}${comment ? `: ${comment}` : ""}` }).catch(() => {}); + return; + } if (!client?.chat?.update || !entry.channelId || !cardTs) return; try { await client.chat.update({ channel: entry.channelId, ts: cardTs, text, blocks }); @@ -530,25 +565,31 @@ 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") }; } + // Volatile claims must be atomic too: two callbacks may race during a persisted scope write. + if (entry.deciding || pendingApprovals.get(String(id)) !== entry) return { ok: false, code: 409, error: "this approval was already handled" }; + entry.deciding = true; // Volatile (long-poll) approval: apply the scope, then release the waiting MCP call. if (approve && scope === "thread") { if (!threadAllow.has(entry.runKey)) threadAllow.set(entry.runKey, new Set()); @@ -592,7 +633,6 @@ export async function canResolveApproval(entry, clicker) { const clickerIsApproved = await isApproved(clicker); const m = await getChannelMeta(entry.slug).catch(() => null); const allowed = - clicker === entry.authorId || clickerIsAdmin || (m ? isAuthorized(m, clicker, Boolean(m.isDM), { isAdminUser: clickerIsAdmin, isApprovedUser: clickerIsApproved }) : false); // The gated action's authority tier. Being eligible to click is NOT the same as being allowed @@ -729,3 +769,18 @@ export async function handleApprovalCommentSubmit({ ack, body, view, client }) { // Request-changes is a DENY that carries feedback — same shared applier, same card update. await applyApprovalDecision({ id, entry, durable, decision: "deny", comment, actorId: clicker, client }); } + +// Only verified platform invoke handlers may supply these transport-derived identity fields. +export async function handlePlatformApproval({ id, conversationId, messageId, actorId, decision, scope = "once", comment = "" } = {}) { + if (!["approve", "deny"].includes(decision) || typeof comment !== "string" || comment.length > 4000) return { ok: false, code: 400, error: "Invalid approval decision" }; + const { entry, durable, resolved } = lookupApproval(id); + if (!entry) return { ok: false, code: resolved ? 409 : 404, error: "Approval expired or already handled" }; + if (!actorId || !messageId || entry.channelId !== conversationId || entry.msgTs !== messageId) return { ok: false, code: 403, error: "Approval does not belong to this message" }; + const meta = await getChannelMeta(entry.slug).catch(() => null); + const admin = await isAdmin(actorId); + const approved = await isApproved(actorId); + if (!meta || !isAuthorized(meta, actorId, Boolean(meta.isDM), { isAdminUser: admin, isApprovedUser: approved })) return { ok: false, code: 403, error: "You are not authorized for this conversation" }; + const authority = await canResolveApproval(entry, actorId); + if (!authority.allowed || (decision === "approve" && !comment && (!authority.meetsTier || (scope === "forever" && !authority.clickerIsAdmin)))) return { ok: false, code: 403, error: "You cannot approve this action at the requested scope" }; + return applyApprovalDecision({ id, entry, durable, decision, scope, comment, actorId, actorLabel: actorId }); +} diff --git a/src/slack/attachments.js b/src/slack/attachments.js index 92178ef..afcf796 100644 --- a/src/slack/attachments.js +++ b/src/slack/attachments.js @@ -92,7 +92,7 @@ export function collectSlackFiles(message) { return [...byKey.values()]; } -async function canonicalMessage(event, client) { +async function canonicalMessage(event, client, includeThreadFiles) { if (!event?.channel || !event?.ts) return null; let messages; if (event.thread_ts) { @@ -118,7 +118,7 @@ async function canonicalMessage(event, client) { const exact = (Array.isArray(messages) ? messages : []).find( (message) => String(message?.ts) === String(event.ts), ) || null; - if (!exact || !event.thread_ts || collectSlackFiles(exact).length) return exact; + if (!exact || !event.thread_ts || collectSlackFiles(exact).length || !(await includeThreadFiles(exact))) return exact; // Slack lets a user upload a file and then @mention the bot in the next thread reply. Recover // only when the immediately preceding message carries a file; never reach backward past an @@ -200,6 +200,9 @@ function pendingFiles(files) { } export async function hydrateSlackMessage(event, client, { + // Exact current-message files are always hydrated. Callers may suppress only inferred + // thread context (for example, a text-only daemon control command). + includeThreadFiles = () => true, maxAttempts = DEFAULT_ATTEMPTS, retryDelayMs = DEFAULT_RETRY_DELAY_MS, sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)), @@ -211,7 +214,7 @@ export async function hydrateSlackMessage(event, client, { for (let attempt = 0; attempt < attempts; attempt++) { let canonical = null; try { - canonical = await canonicalMessage(event, client); + canonical = await canonicalMessage(event, client, includeThreadFiles); } catch (error) { logger?.warn?.(`[slack] canonical Slack message lookup failed: ${error.message}`); } diff --git a/src/slack/channel-settings.js b/src/slack/channel-settings.js index dcdfd10..87519a1 100644 --- a/src/slack/channel-settings.js +++ b/src/slack/channel-settings.js @@ -4,6 +4,7 @@ import { ACCESS_EDIT_ACTION_ID, accessSummary } from "./access-settings.js"; import { channelMode, modeLabel } from "../gateway/modes.js"; import { MIN_MASKABLE_LENGTH } from "../config/channel-env.js"; +import { buildSecretsView } from "./secret-explorer.js"; export const CHANNEL_SETTINGS_MODE_PREFIX = "cg_channel_settings_mode_"; export const CHANNEL_SETTINGS_OPTION_PREFIX = "cg_channel_settings_option_"; @@ -290,54 +291,32 @@ 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. This summary updates automatically when you configure the groups above.")] }, { 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.")] }, ]; } -function secretLine(entry = {}) { - const tail = entry.last4 ? inlineCode(`••••${entry.last4}`) : "set · tail hidden"; - const date = entry.setAt && !Number.isNaN(new Date(entry.setAt).getTime()) - ? new Date(entry.setAt).toISOString().slice(0, 10) - : ""; - const trail = [entry.setBy ? `by ${escapeMrkdwn(entry.setBy)}` : "", date].filter(Boolean).join(" · "); - const provider = entry.resolvable === false ? ` · ⚠️ provider ${inlineCode(entry.provider || "unknown")} unavailable` : ""; - return `*${escapeMrkdwn(entry.name || "unnamed")}* — ${tail}${trail ? ` · ${trail}` : ""}${provider}`; -} - function secretsBlocks(snapshot = {}, state = {}, { canEditSecrets = false } = {}) { - const vars = Array.isArray(snapshot.secrets) ? snapshot.secrets : []; - const shown = vars.slice(0, MAX_LIST_ITEMS); - const blocks = shown.length - ? shown.map((entry) => ({ type: "section", text: mrkdwn(secretLine(entry)) })) - : [{ type: "section", text: mrkdwn("_No environment secrets are configured for this channel._") }]; - if (vars.length > shown.length) { - blocks.push({ type: "context", elements: [mrkdwn(`_+${vars.length - shown.length} more variables. Open /secrets for the complete manager._`)] }); - } - blocks.push({ - type: "context", - elements: [mrkdwn("Values are write-only. This view shows names and, only for long locally stored values, the last four characters.")], - }); - if (canEditSecrets) { - blocks.push({ - type: "actions", - elements: [button(CHANNEL_SETTINGS_SECRETS_MANAGE_ACTION_ID, "Add, update, or remove secrets", state, "secrets_manage", {}, { style: "primary" })], - }); - } else { - blocks.push({ type: "context", elements: [mrkdwn("_Secrets can only be changed when this channel's mode permits command execution._")] }); - } - return blocks; + // The same masked rows and mutation controls as /secrets, directly in the Settings tab. + return buildSecretsView(Array.isArray(snapshot.secrets) ? snapshot.secrets : [], state, { + mayEdit: canEditSecrets, + }).blocks; } const TAB_LABELS = Object.freeze({ 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/file-form-navigation.js b/src/slack/file-form-navigation.js new file mode 100644 index 0000000..296a7a7 --- /dev/null +++ b/src/slack/file-form-navigation.js @@ -0,0 +1,26 @@ +// Pushed file forms are temporary: a successful submission pops the form and refreshes its +// existing parent. Replacing the pushed form with another browser would retain a stack level +// after every save/create and exhaust Slack's three-view limit. Native Cancel still pops the +// untouched form normally. Standalone/root forms have no parent and retain update-in-place. +export function createFileFormNavigation({ ack, client, view }) { + const parentId = view?.previous_view_id; + const viewId = parentId || view?.id; + let acknowledged = false; + return { + get acknowledged() { return acknowledged; }, + async show(nextView) { + if (!acknowledged) { + if (!parentId) { + await ack({ response_action: "update", view: nextView }); + acknowledged = true; + return; + } + // An empty submission ack closes only the submitted view. Updating the parent works + // whether Slack has already revealed it or is still processing that acknowledgement. + await ack(); + acknowledged = true; + } + await client.views.update({ view_id: viewId, view: nextView }); + }, + }; +} diff --git a/src/slack/help.js b/src/slack/help.js index 9cd0be2..dfc000d 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 `/menu`, `/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" + @@ -15,10 +15,10 @@ 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" + - "• `/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/manager.js b/src/slack/manager.js index 8832d8e..d91e738 100644 --- a/src/slack/manager.js +++ b/src/slack/manager.js @@ -12,6 +12,8 @@ import { startSlack } from "./app.js"; export function createSlackManager({ start = startSlack } = {}) { + const connectedListeners = new Set(); + let detachSocket = () => {}; let current = null; // { app, botUserId, user, team, teamId } let status = "disconnected"; // disconnected | connecting | connected | error let error = null; @@ -33,6 +35,13 @@ export function createSlackManager({ start = startSlack } = {}) { return run; } + function notifyConnected() { + for (const listener of connectedListeners) { + try { Promise.resolve(listener()).catch((err) => console.error("[slack] reconnect listener failed:", err.message)); } + catch (err) { console.error("[slack] reconnect listener failed:", err.message); } + } + } + async function stopApp(app) { if (!app) return; try { @@ -46,6 +55,7 @@ export function createSlackManager({ start = startSlack } = {}) { // idempotent and can only ever leave LESS running, never an orphan. function disconnect() { return transition(async () => { + detachSocket(); const app = current?.app; current = null; status = "disconnected"; @@ -63,6 +73,7 @@ export function createSlackManager({ start = startSlack } = {}) { // start an app it would immediately have to tear down again. That transition also owns the // teardown of whatever is currently live, so leave state untouched. if (gen !== generation) return snapshot(); + detachSocket(); const previous = current?.app; current = null; status = "connecting"; @@ -79,6 +90,11 @@ export function createSlackManager({ start = startSlack } = {}) { } current = started; status = "connected"; + const socket = started.app?.receiver?.client; + const reconnected = () => { if (current === started) notifyConnected(); }; + socket?.on?.("connected", reconnected); + detachSocket = () => socket?.off?.("connected", reconnected); + notifyConnected(); } catch (err) { console.error("[slack] connect failed:", err.message); if (gen === generation) { @@ -107,5 +123,10 @@ export function createSlackManager({ start = startSlack } = {}) { return current?.app?.client ?? null; } - return { connect, disconnect, snapshot, getClient }; + function onConnected(listener) { + connectedListeners.add(listener); + return () => connectedListeners.delete(listener); + } + + return { connect, disconnect, snapshot, getClient, onConnected }; } 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 43c74d1..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, " "); @@ -27,6 +27,11 @@ export function parseSlashCommand(text) { return { cmd, arg: parts.slice(1).join(" ").trim() }; } +export function parseNextCommand(text) { + const match = /^\/next\b[\s:,.;–—-]*([\s\S]*)$/i.exec((text || "").trim()); + return match ? { task: match[1].trim() } : null; +} + export function mentionsBot(text, botUserId) { if (!text) return false; for (const match of text.matchAll(SLACK_MENTION_RE)) if (match[1] === botUserId) return true; diff --git a/src/slack/message-pipeline.js b/src/slack/message-pipeline.js index 03e4de0..5d1abd2 100644 --- a/src/slack/message-pipeline.js +++ b/src/slack/message-pipeline.js @@ -4,7 +4,7 @@ // processMessageEvent takes an explicit { botUserId, teamId } context instead of closing over // connectAndWire scope, so the whole turn path is reachable by tests. app.js owns the Bolt app // and event registrations and delegates here; this module must never import ./app.js. -import { upsertChannelEntry, getChannelMeta, saveChannelMeta, patchChannelMeta, defaultChannelMeta, getUser, getUsers, setUser, isAdmin, isApproved } from "../config/store.js"; +import { getChannelEntry, upsertChannelEntry, getChannelMeta, saveChannelMeta, patchChannelMeta, defaultChannelMeta, getUser, getUsers, setUser, isAdmin, isApproved } from "../config/store.js"; import { engineSupports, engineLabel, ENGINE_IDS } from "../engines/registry.js"; import { plainFailureText, runFailureDiagnostics } from "../util/process-outcome.js"; import { ensureChannelFolder, effectiveWorkDir } from "../gateway/folders.js"; @@ -44,10 +44,10 @@ 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 { buildMenuCard } from "./menu.js"; import { HELP_TEXT } from "./help.js"; import { formatAppContextProvenance } from "./app-context.js"; -import { isIgnorable, isPendingCommand, isStopCommand, mentionsBot, parseSlashCommand, SLACK_MENTION_RE, stripMentions } from "./message-normalize.js"; +import { isIgnorable, isPendingCommand, isStopCommand, mentionsBot, parseNextCommand, parseSlashCommand, SLACK_MENTION_RE, stripMentions } from "./message-normalize.js"; export { isIgnorable, isStopCommand, mentionsBot, parseSlashCommand, stripMentions } from "./message-normalize.js"; import { claimMessageTrigger, runQueue } from "./message-lifecycle.js"; export { runQueue } from "./message-lifecycle.js"; @@ -200,7 +200,7 @@ export async function stopRunsInChannel(client, channelId, slug, byUser, threadK // (config errors, usage limits, aborts) is not auto-recoverable. export function runDeathRecovery(err) { const m = String(err?.message || ""); - if (err?.details?.explicitStop || err?.name === "AbortError") return null; + if (err?.details?.explicitStop || err?.details?.incompleteTurn || err?.name === "AbortError") return null; // A hard kill can happen after an external write but before its tool result is saved. // Do not infer OOM or replay that ambiguous work automatically. if (String(err?.details?.signal || "").toUpperCase() === "SIGKILL" || Number(err?.details?.exitCode) === 137) return null; @@ -607,7 +607,20 @@ export async function processMessageEvent(event, client, { botUserId = "", teamI // Only an authorized trigger may spend Slack read/file API calls. Hydrate it from the exact // canonical message so omitted/incomplete attachment fields cannot produce a text-only agent // prompt, while keeping the original event as a non-fatal fallback. - event = await hydrateSlackMessage(event, client); + event = await hydrateSlackMessage(event, client, { + includeThreadFiles: async (message) => { + const text = stripMentions(message.text, botUserId); + const command = parseSlashCommand(text); + // Engine-native /compact still receives its normal thread context. Other recognized + // controls must not become agent prompts merely because an earlier message shared a file. + if (command?.cmd === "compact" && engineSupports(await resolveThreadEngine(entry.slug, event.thread_ts ?? event.ts, meta), "compact")) return true; + if (command || isStopCommand(text) || isPendingCommand(text)) return false; + // A queued task keeps its attachment context; bare /next needs the usage hint, not an + // inferred old file masquerading as the missing task. Explicit current files still win. + const next = parseNextCommand(text); + return !next || Boolean(next.task); + }, + }); let prompt = stripMentions(event.text, botUserId); const files = Array.isArray(event.files) ? event.files : []; @@ -661,6 +674,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, @@ -724,29 +739,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( @@ -898,10 +891,10 @@ export async function processMessageEvent(event, client, { botUserId = "", teamI // mutation, then pause an ordinary busy-thread message until its author makes a choice. let forceQueue = false; { - const nm = /^\/next\b[\s:,.;–—-]*([\s\S]*)$/i.exec(prompt.trim()); + const nm = parseNextCommand(prompt); if (nm) { forceQueue = true; - prompt = nm[1].trim(); + prompt = nm.task; if (!prompt && files.length === 0) { await client.chat.postMessage({ channel: event.channel, thread_ts: threadKey, text: "Add the task after `/next` — e.g. `/next summarize the thread once you're done`." }); return; @@ -1579,5 +1572,23 @@ export async function processMessageEvent(event, client, { botUserId = "", teamI } } catch (outer) { console.error("[slack] handler error:", outer); + if (outer?.code === "workspace_selection_conflict") { + try { + // Registration can fail before the normal authorization gate. Do not disclose + // configuration to an unauthorized author, or identify another private conversation. + const entry = await getChannelEntry(event.channel); + const meta = entry && await getChannelMeta(entry.slug); + if (!meta || !isAuthorized(meta, event.user, event.channel_type === "im", { + isAdminUser: await isAdmin(event.user), isApprovedUser: await isApproved(event.user), + })) return; + await client.chat.postMessage({ + channel: event.channel, + thread_ts: event.thread_ts ?? event.ts, + text: "⚠️ I couldn’t start: this working folder is assigned to more than one channel, with conflicting shared skills or memory settings. Ask a gateway admin to assign this folder to just one channel and choose a separate folder for the others, or align their shared skills and memory settings in the admin UI. Then send your request again.", + }); + } catch (notificationError) { + console.error("[slack] workspace conflict notification failed:", notificationError); + } + } } } diff --git a/src/slack/progress.js b/src/slack/progress.js index 7be90b9..b674ad8 100644 --- a/src/slack/progress.js +++ b/src/slack/progress.js @@ -1157,6 +1157,12 @@ function startStreamingProgress(client, { channel, threadTs, isDM, authorId, tea oneShot = parts[0] || "_(empty response)_"; overflow = parts.slice(1).join("\n"); if (overflow) oneShot += "\n\n…_(continued below)_"; + } else if (result?.answerless && full && pending !== streamed) { + // These deltas were interim narration, not a final answer. The orchestrator substituted + // an authoritative notice, which must survive even when the live stream stayed below + // its cap. Use the existing chunked follow-up path so mentions, long notices and terminal + // delivery retries retain the same handling as overflow; never repeat ordinary answers. + overflow = pending; } else if (truncated) { // The slice is only valid when the final content really is the concatenation of the // streamed deltas (true for Claude; Codex deltas are best-effort and the limit-fallback diff --git a/src/slack/secret-explorer.js b/src/slack/secret-explorer.js index da9cbd9..75a8778 100644 --- a/src/slack/secret-explorer.js +++ b/src/slack/secret-explorer.js @@ -41,7 +41,7 @@ export function parseActionValue(raw) { } export function secretsMetadata(state = {}) { - return JSON.stringify({ c: state.channelId, s: state.slug, t: state.threadTs || "", u: state.ownerId, n: state.editName || "" }); + return JSON.stringify({ c: state.channelId, s: state.slug, t: state.threadTs || "", u: state.ownerId, n: state.editName || "", ...(state.returnTo === "settings" ? { r: "settings" } : {}) }); } export function parseSecretsMetadata(raw) { @@ -58,6 +58,7 @@ export function parseSecretsMetadata(raw) { threadTs: String(value.t || ""), ownerId: String(value.u), editName: String(value.n || ""), + ...(value.r === "settings" ? { returnTo: "settings" } : {}), }; } diff --git a/src/web/app.js b/src/web/app.js index b32a78a..f990ead 100644 --- a/src/web/app.js +++ b/src/web/app.js @@ -15,6 +15,8 @@ import { createAdminRouter } from "./routes/admin.js"; import { mountSkillsPublicRoutes } from "./skills-mcp.js"; import { triggerSourceSync } from "../gateway/skills/index.js"; import { createRunsRouter } from "./routes/runs.js"; +import { platformOfConversation } from "../platforms/ids.js"; +import { platformOr } from "../platforms/registry.js"; import { createFileEditorRouter } from "./file-editor.js"; import { createFileDownloadRouter } from "./file-download.js"; import { createApprovalLinkRouter } from "./routes/approve.js"; @@ -30,7 +32,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) => @@ -165,12 +167,27 @@ 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 // the full Slack authorization/membership/mode check on open, page load, and every save. app.use("/file-editor", createFileEditorRouter({ authorize: async (grant) => { + const adapter = platformOr(platformOfConversation(grant.channelId)); + if (adapter.workspaceAccess) { + const context = await adapter.workspaceAccess(grant); + if (!canEditChannelFiles(effectiveMeta(context.meta), { isAdminUser: context.userIsAdmin })) throw new Error("This workspace is read-only for you."); + return context; + } const client = slack?.getClient?.(); if (!client) throw new Error("Slack is disconnected; reconnect the gateway and reopen the editor."); const context = await fileExplorerContext(client, { @@ -191,6 +208,8 @@ export function createWebApp({ // re-confined file; it grants no write capability and creates no Slack file copy. app.use("/file-download", createFileDownloadRouter({ authorize: async (grant) => { + const adapter = platformOr(platformOfConversation(grant.channelId)); + if (adapter.workspaceAccess) return adapter.workspaceAccess(grant); const client = slack?.getClient?.(); if (!client) throw new Error("Slack is disconnected; reconnect the gateway and reopen the file preview."); return fileExplorerContext(client, { @@ -207,6 +226,12 @@ export function createWebApp({ // never sit in memory as one giant archive. app.use("/file-upload", createFileUploadRouter({ authorize: async (grant) => { + const adapter = platformOr(platformOfConversation(grant.channelId)); + if (adapter.workspaceAccess) { + const context = await adapter.workspaceAccess(grant); + if (!canEditChannelFiles(effectiveMeta(context.meta), { isAdminUser: context.userIsAdmin })) throw new Error("This workspace is read-only for you."); + return context; + } const client = slack?.getClient?.(); if (!client) throw new Error("Slack is disconnected; reconnect the gateway and reopen the uploader."); const context = await fileExplorerContext(client, { @@ -280,7 +305,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/file-editor.js b/src/web/file-editor.js index 8fb7137..aa70ae0 100644 --- a/src/web/file-editor.js +++ b/src/web/file-editor.js @@ -101,7 +101,10 @@ function cookieName(editorId) { function cookieFor(req, editorId, secret, maxAgeSeconds) { const secure = secureRequest(req) ? "; Secure" : ""; - return `${cookieName(editorId)}=${secret}; HttpOnly; SameSite=Strict; Path=/file-editor/${editorId}; Max-Age=${maxAgeSeconds}${secure}`; + // Slack opens this through a cross-site top-level GET and 303. Strict withholds the new + // cookie on that redirect; explicit Lax admits this navigation, but not cross-site POSTs. + // Saves still require the session's CSRF header and current channel/file authorization. + return `${cookieName(editorId)}=${secret}; HttpOnly; SameSite=Lax; Path=/file-editor/${editorId}; Max-Age=${maxAgeSeconds}${secure}`; } function getSession(req, editorId, now = Date.now()) { diff --git a/src/web/file-upload.js b/src/web/file-upload.js index 63f2ff2..87e8089 100644 --- a/src/web/file-upload.js +++ b/src/web/file-upload.js @@ -95,7 +95,9 @@ function cookieName(uploadId) { function cookieFor(req, uploadId, secret, maxAgeSeconds) { const secure = secureRequest(req) ? "; Secure" : ""; - return `${cookieName(uploadId)}=${secret}; HttpOnly; SameSite=Strict; Path=/file-upload/${uploadId}; Max-Age=${maxAgeSeconds}${secure}`; + // Admit the first cross-site top-level GET/303 from Slack without enabling cross-site POSTs. + // Uploads still require the session's CSRF header and current channel/folder authorization. + return `${cookieName(uploadId)}=${secret}; HttpOnly; SameSite=Lax; Path=/file-upload/${uploadId}; Max-Age=${maxAgeSeconds}${secure}`; } function getSession(req, uploadId, now = Date.now()) { diff --git a/src/web/routes/settings.js b/src/web/routes/settings.js index a523c8c..87718b4 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"; @@ -35,6 +36,7 @@ import { resolveGoogleChatConfig, hasTeamsConfig, resolveTeamsConfig, + validateTeamsFileDriveIds, CONTAINER_CLIS, CONTAINER_IMAGE_RE, CONTAINER_MEMORY_RE, @@ -190,6 +192,15 @@ 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 (Object.hasOwn(body, "teamsFilesEnabled")) { + if (typeof body.teamsFilesEnabled !== "boolean") return res.status(400).json({ error: "teamsFilesEnabled must be a boolean" }); + patch.teamsFilesEnabled = body.teamsFilesEnabled; + } + if (Object.hasOwn(body, "teamsFileDriveIds")) { + try { patch.teamsFileDriveIds = validateTeamsFileDriveIds(body.teamsFileDriveIds); } + catch (error) { return res.status(400).json({ error: error.message }); } + } 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. @@ -520,7 +531,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 +539,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 @@ -551,16 +563,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/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/access-grants.test.js b/test/access-grants.test.js index 0f265e9..b42766b 100644 --- a/test/access-grants.test.js +++ b/test/access-grants.test.js @@ -27,6 +27,7 @@ test("untrusted caller-supplied principals never load or inherit stored user gra authorId: "U_PUBLIC_ID", untrustedPrincipal: true, loadUser, + lookupSkill: () => { throw new Error("untrusted principal must not consult the skill catalog"); }, }); assert.equal(loads, 0, "stored user lookup must be skipped completely"); @@ -43,6 +44,37 @@ test("untrusted caller-supplied principals never load or inherit stored user gra assert.deepEqual(trusted.effective.skills, ["org", "channel", "private-skill"]); }); +test("personal tombstones are inactive without erasing grants or ignoring broken selections", async () => { + const skills = { + removed: { slug: "removed", ownerKind: "local", visibility: "personal", createdBy: "owner", deleted: true }, + staged: { slug: "staged", ownerKind: "local", visibility: "personal", createdBy: "owner", deleted: false }, + sharedRemoved: { slug: "sharedRemoved", ownerKind: "git", visibility: "org", deleted: true }, + }; + const stored = { skills: ["removed", "staged", "unknown", "sharedRemoved"], allowedMcps: ["private-mcp"] }; + const resolve = () => resolveRunAccessGrants({ authorId: "owner", loadUser: async () => stored, lookupSkill: (name) => skills[name] || null }); + assert.deepEqual((await resolve()).user.skills, ["staged", "unknown", "sharedRemoved"], "only the proven own local personal tombstone is omitted"); + assert.deepEqual(stored.skills, ["removed", "staged", "unknown", "sharedRemoved"], "persisted grants remain intact"); + assert.deepEqual((await resolve()).user.allowedMcps, ["private-mcp"]); + skills.removed.deleted = false; + assert.deepEqual((await resolve()).user.skills, stored.skills, "restoring the catalog skill reactivates its retained grant"); + await assert.rejects(resolveRunAccessGrants({ authorId: "owner", loadUser: async () => stored, lookupSkill: () => { throw new Error("catalog unavailable"); } }), /catalog unavailable/); +}); + +test("personal ownership stays closed for live, removed and dependency grants", async () => { + for (const deleted of [false, true]) { + const foreign = { slug: "foreign", ownerKind: "local", visibility: "personal", createdBy: "other", deleted }; + for (const authorId of ["owner", ""]) { + await assert.rejects(resolveRunAccessGrants({ authorId, loadUser: async () => ({ skills: ["foreign"] }), lookupSkill: () => foreign }), /not available to this author/); + } + const skills = { foreign, parent: { slug: "parent", visibility: "org", requires: ["foreign"] } }; + await assert.rejects(resolveRunAccessGrants({ authorId: "owner", loadUser: async () => ({ skills: ["parent"] }), lookupSkill: (name) => skills[name] }), /not available to this author/); + } + const dependency = { slug: "removed", ownerKind: "local", visibility: "personal", createdBy: "owner", deleted: true }; + const parent = { slug: "parent", visibility: "org", requires: ["parent", "removed"] }; + const grants = await resolveRunAccessGrants({ authorId: "owner", loadUser: async () => ({ skills: ["parent"] }), lookupSkill: (name) => name === "parent" ? parent : dependency }); + assert.deepEqual(grants.user.skills, ["parent"], "a removed required dependency is not silently excised from an active parent; materialization still fails"); +}); + test("untrusted caller-supplied principals never load personal tokens or role state", async () => { const calls = []; const loader = (name, value) => async (id) => { 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/answerless-turn.test.js b/test/answerless-turn.test.js index c7cd6c2..ee39394 100644 --- a/test/answerless-turn.test.js +++ b/test/answerless-turn.test.js @@ -1,9 +1,5 @@ -// A turn that runs tools, spends tokens, and then ends with NO final message used to reach Slack -// as a bare "(empty response)" — no reason, no hint that the harness had aborted the turn rather -// than the model choosing silence (src/slack/progress.js finalize). These guard the replacement: -// the runner carries the CLI's own verdict (result subtype / is_error) out of the process, and -// run.js turns an answerless result into a notice that names it. Distinct from isEmptyResult, -// which is the 0-token broken-session shape the resume-heal repairs. +// Clean answerless turns get an informative notice. Explicit terminal failures take the error +// path even after narration, retain accounting, and never trigger an automatic empty-session heal. import path from "node:path"; import { fileURLToPath } from "node:url"; import test from "node:test"; @@ -14,13 +10,13 @@ const projectRoot = fileURLToPath(new URL("..", import.meta.url)); const fixtureBin = path.join(projectRoot, "test", "fixtures"); ensureTestEnv(); const { useFakeRuntime: __useFakeRuntime } = await import("./runtime-fake.js"); -await __useFakeRuntime(); +const runtime = await __useFakeRuntime(); process.env.PATH = `${fixtureBin}${path.delimiter}${process.env.PATH || ""}`; process.env.SESSION_KEEPALIVE = "0"; const { setUser, upsertChannelEntry, saveChannelMeta } = await import("../src/config/store.js"); const { saveSettings } = await import("../src/config/settings.js"); -const { runMessage, isAnswerlessResult, isEmptyResult, answerlessNotice } = await import("../src/gateway/run.js"); +const { runMessage, isAnswerlessResult, isEmptyResult, answerlessNotice, assertCompletedTurn } = await import("../src/gateway/run.js"); test("isAnswerlessResult: work was done but no text came back", () => { const spent = { usage: { input_tokens: 900, output_tokens: 120 } }; @@ -64,33 +60,63 @@ test("answerlessNotice: a clean ending with no reply is stated, not alarmed abou assert.match(answerlessNotice({ toolUseCount: 0 }), /no tools ran/); }); -test("end to end: an aborted turn is delivered as the notice, not as an empty reply", async () => { - saveSettings({ engine: "claude", codexFallback: false, composioMode: "personal" }); - await setUser("U_ANSWERLESS", { name: "Answerless", approved: true, isAdmin: false }); - const entry = await upsertChannelEntry("D_ANSWERLESS", { name: "answerless", type: "im", isDM: true }); - await saveChannelMeta(entry.slug, { - channelId: "D_ANSWERLESS", name: entry.name, type: "im", isDM: true, template: "custom", - engine: "claude", cleanMode: true, allowNetwork: false, +for (const narration of [false, true]) { + test(`end to end: aborted turn ${narration ? "with narration" : "without text"} rejects and retains spend`, async () => { + saveSettings({ engine: "claude", codexFallback: false, composioMode: "personal" }); + const channelId = narration ? "D_PARTIAL_FAILURE" : "D_ANSWERLESS"; + await setUser("U_ANSWERLESS", { name: "Answerless", approved: true, isAdmin: false }); + const entry = await upsertChannelEntry(channelId, { name: channelId, type: "im", isDM: true }); + await saveChannelMeta(entry.slug, { + channelId, name: entry.name, type: "im", isDM: true, template: "custom", + engine: "claude", cleanMode: true, allowNetwork: false, + }); + const { readUsage } = await import("../src/gateway/usage.js"); + await assert.rejects(runMessage({ + channelId, authorId: "U_ANSWERLESS", + text: `CLAUDE_STUB_ANSWERLESS ${narration ? "CLAUDE_STUB_PARTIAL_NARRATION" : ""}`, + threadKey: "1900.900", origin: "slack_foreground", preferCold: true, + }), (error) => { + assert.match(error.message, /did not finish this turn/); + assert.equal(error.details.incompleteTurn, true); + assert.equal(error.details.replaySafe, false); + assert.equal(error.details.endReason, "error_during_execution"); + assert.equal(error.details.toolUseCount, 1); + assert.equal(error.details.usage.output_tokens, 120); + assert.equal(error.details.usageRecorded, true); + assert.equal(error.details.partialContent, narration ? "I will verify the update next." : ""); + return true; + }); + const rows = await readUsage({ channelId }); + assert.equal(rows.length, 1, "failed spend is banked once rather than discarded"); + assert.equal(rows[0].tokensOut, 120); }); +} - const result = await runMessage({ - channelId: "D_ANSWERLESS", - authorId: "U_ANSWERLESS", - text: "CLAUDE_STUB_ANSWERLESS", - threadKey: "1900.900", - origin: "slack_foreground", - preferCold: true, +test("terminal failure cannot qualify as an empty-session heal; intentional steering survives", () => { + const failed = { content: "", engineError: true, endReason: "error_during_execution", usage: { input_tokens: 0, output_tokens: 0 } }; + assert.equal(isEmptyResult(failed), true); + assert.throws(() => assertCompletedTurn(failed, "claude", "keep-this-session"), (error) => { + assert.equal(error.details.sessionId, "keep-this-session"); + assert.equal(error.details.replaySafe, false); + return true; }); + const steered = { ...failed, interrupted: true }; + assert.equal(assertCompletedTurn(steered, "claude"), steered); + const clean = { content: "done", engineError: false }; + assert.equal(assertCompletedTurn(clean, "claude"), clean); +}); - assert.equal(result.answerless, true); - assert.equal(result.endReason, "error_during_execution"); - assert.equal(result.engineError, true); - assert.equal(result.toolUseCount, 1); - // The CLI's stderr tail rides the result on an answerless turn — on an exit-0 abort no error - // path ever reads it, and it is usually the only statement of the real cause. - assert.equal(typeof result.diagnostic, "string"); - assert.match(result.content, /without a final message/i); - assert.match(result.content, /error_during_execution/); - // The spend is still the run's own — the notice must not overwrite the accounting the ledger reads. - assert.equal(result.usage.output_tokens, 120); +test("a resumed zero-token explicit failure is never automatically replayed", async () => { + const channelId = "D_ZERO_FAILURE"; + const entry = await upsertChannelEntry(channelId, { name: channelId, type: "im", isDM: true }); + await saveChannelMeta(entry.slug, { channelId, name: entry.name, type: "im", isDM: true, template: "custom", engine: "claude", cleanMode: true, allowNetwork: false }); + const request = { channelId, authorId: "U_ANSWERLESS", threadKey: "1900.901", origin: "slack_foreground", preferCold: true }; + await runMessage({ ...request, text: "First successful turn" }); + const before = runtime.calls.spawn.length; + await assert.rejects(runMessage({ ...request, text: "CLAUDE_STUB_ANSWERLESS CLAUDE_STUB_ZERO_FAILURE" }), (error) => { + assert.equal(error.details.incompleteTurn, true); + assert.equal(error.details.replaySafe, false); + return true; + }); + assert.equal(runtime.calls.spawn.length - before, 1, "terminal failure does not trigger the empty-resume replay"); }); diff --git a/test/channel-credential-guide.test.js b/test/channel-credential-guide.test.js new file mode 100644 index 0000000..1f8234d --- /dev/null +++ b/test/channel-credential-guide.test.js @@ -0,0 +1,50 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { readFile, rm } from "node:fs/promises"; +import { spawnSync } from "node:child_process"; +import path from "node:path"; +import { ensureTestEnv, tempDir } from "./helpers.js"; + +ensureTestEnv(); +const { applyGatewayGuide } = await import("../src/gateway/guide.js"); +const guidePath = (cwd, rel) => path.join(cwd, ".claude/skills/gateway-usage", rel); + +test("materialized guides expose credential discovery without relaxing identity or runtime policy", async () => { + const cwd = tempDir("cg-credential-guide-"); + try { + for (const platform of ["slack", "msteams", "googlechat"]) { + await applyGatewayGuide(cwd, { platform }); + const skill = await readFile(guidePath(cwd, "SKILL.md"), "utf8"); + const admin = await readFile(guidePath(cwd, "references/administration.md"), "utf8"); + assert.match(skill, /Channel credentials for THIS attempt/); + assert.match(skill, /current names or explicit empty result override earlier turns/); + assert.match(skill, /Clean runs omit this inventory/); + assert.match(skill, /before reporting missing access or asking for another connection/); + assert.match(skill, /Never silently switch accounts or substitute/); + assert.match(skill, /reads and searches may use either or both/); + assert.match(skill, /unless the user restricts the account or scope; writes require the intended account/); + assert.match(skill, /Credential availability does not grant tool permissions, network access/); + assert.match(admin, /not written to the project's `\.env`/); + assert.match(admin, /some consume an environment variable\s+automatically; others require an explicit option or request header/); + assert.match(admin, /Never run a full environment dump/); + assert.doesNotMatch(admin, /You cannot read them and neither can anyone else|without being told to/); + } + } finally { await rm(cwd, { recursive: true, force: true }); } +}); + +test("the guide's presence-check example reveals only a boolean for present and absent credentials", async () => { + const admin = await readFile(new URL("../src/gateway/gateway-usage/references/administration.md", import.meta.url), "utf8"); + const example = /```sh\nnode -e '([^']+)'\n```/.exec(admin); + assert.ok(example, "the administration guide must contain an executable presence-check example"); + const fakeCredential = "synthetic-guide-fixture-do-not-display"; + for (const [env, expected] of [ + [{ HUBSPOT_ACCESS_TOKEN: fakeCredential }, "HUBSPOT_ACCESS_TOKEN present: true\n"], + [{}, "HUBSPOT_ACCESS_TOKEN present: false\n"], + ]) { + const result = spawnSync(process.execPath, ["-e", example[1]], { env, encoding: "utf8" }); + assert.equal(result.status, 0, result.stderr); + assert.equal(result.stdout, expected); + assert.equal(result.stderr, ""); + assert.ok(!result.stdout.includes(fakeCredential)); + } +}); diff --git a/test/channel-credentials.test.js b/test/channel-credentials.test.js new file mode 100644 index 0000000..6681569 --- /dev/null +++ b/test/channel-credentials.test.js @@ -0,0 +1,140 @@ +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import test from "node:test"; +import assert from "node:assert/strict"; +import { ensureTestEnv } from "./helpers.js"; + +const scratch = ensureTestEnv(); +const projectRoot = fileURLToPath(new URL("..", import.meta.url)); +process.env.PATH = `${path.join(projectRoot, "test", "fixtures")}${path.delimiter}${process.env.PATH || ""}`; +process.env.SESSION_KEEPALIVE = "0"; +process.env.CG_WORKSPACE_DIR = path.join(scratch, "credential-workspaces"); +const { channelCredentialsPreamble } = await import("../src/gateway/channel-credentials.js"); +const { useFakeRuntime } = await import("./runtime-fake.js"); +const backend = await useFakeRuntime(); +const { setUser, upsertChannelEntry, saveChannelMeta, patchChannelMeta } = await import("../src/config/store.js"); +const { saveSettings } = await import("../src/config/settings.js"); +const { runMessage } = await import("../src/gateway/run.js"); + +const fixtureValue = "synthetic-credential-discovery-value-ONLY"; +const entry = (value = fixtureValue) => ({ provider: "local", value }); +const prefix = "[Channel credentials for THIS attempt]"; +const promptOf = (call) => call.args.find((arg) => String(arg).includes("[Gateway runtime for THIS attempt:")); +const attempts = (start) => backend.calls.spawn.slice(start).filter(promptOf); +function namesOf(prompt) { + const match = prompt.match(/^Available channel environment variable names: (\[[^\n]*\])\.$/m); + assert.ok(match, "inventory must be a names-only JSON array"); + assert.equal(prompt.split(prefix).length - 1, 1); + return JSON.parse(match[1]); +} + +test("credential discovery lists only sorted, usable names without values or suffixes", () => { + const prompt = channelCredentialsPreamble({ + Z_SERVICE_TOKEN: fixtureValue, A_SERVICE_TOKEN: "another-synthetic-value-ZYXW", + EMPTY_TOKEN: "", OBJECT_TOKEN: { value: fixtureValue }, PATH: fixtureValue, + CG_PRIVATE_TOKEN: fixtureValue, "BAD\nINSTRUCTION": fixtureValue, + }); + assert.deepEqual(namesOf(prompt), ["A_SERVICE_TOKEN", "Z_SERVICE_TOKEN"]); + for (const forbidden of [fixtureValue, "ONLY", "ZYXW", "EMPTY_TOKEN", "OBJECT_TOKEN", "CG_PRIVATE_TOKEN", "BAD\nINSTRUCTION"]) { + assert.ok(!prompt.includes(forbidden), `must omit ${forbidden}`); + } + assert.match(prompt, /replaces earlier turns/); + assert.match(prompt, /never silently substitute a channel credential for a personal connection/); +}); + +test("empty inventory clears old assumptions, while clean mode suppresses discovery", () => { + assert.deepEqual(namesOf(channelCredentialsPreamble()), []); + assert.match(channelCredentialsPreamble(), /not that all CLI logins or MCP connections are absent/); + assert.equal(channelCredentialsPreamble({ QA_SERVICE_TOKEN: fixtureValue }, { clean: true }), ""); +}); + +async function fixture(name, engine, extra = {}) { + saveSettings({ engine, defaultClaudeModel: "sonnet", defaultCodexModel: "gpt-5.6-sol", engineFallback: true, + engineEnabled: { claude: true, codex: true }, agentMemory: false, memoryReviewEvery: 0, composioMode: "personal" }); + const authorId = `U_${name}`; + const channelId = `C_${name}`; + await setUser(authorId, { name: "Credential fixture", approved: true }); + const channel = await upsertChannelEntry(channelId, { name: name.toLowerCase(), type: "channel" }); + await saveChannelMeta(channel.slug, { channelId, type: "channel", engine, memory: false, + cleanMode: false, allowNetwork: false, env: { QA_SERVICE_TOKEN: entry() }, ...extra }); + return { channel, channelId, authorId, threadKey: `${name}.1`, origin: "slack_foreground", preferCold: true }; +} + +for (const engine of ["claude", "codex"]) { + test(`${engine}: fresh and resumed prompts track actual injected credentials through rotation, addition and removal`, async () => { + const ctx = await fixture(`CREDENTIALS_${engine}`, engine); + const start = backend.calls.spawn.length; + await runMessage({ ...ctx, text: "Read the QA service status." }); + const rotated = "rotated-synthetic-credential-discovery-ONLY"; + await patchChannelMeta(ctx.channel.slug, { env: { QA_SERVICE_TOKEN: entry(rotated), QA_OTHER_TOKEN: entry() } }); + await runMessage({ ...ctx, text: "Check again." }); + await patchChannelMeta(ctx.channel.slug, { env: {} }); + await runMessage({ ...ctx, text: "What access remains?" }); + const calls = attempts(start); + assert.equal(calls.length, 3); + const expected = [["QA_SERVICE_TOKEN"], ["QA_OTHER_TOKEN", "QA_SERVICE_TOKEN"], []]; + for (let i = 0; i < calls.length; i++) { + const prompt = promptOf(calls[i]); + assert.deepEqual(namesOf(prompt), expected[i]); + assert.deepEqual(Object.keys(calls[i].env).filter((name) => name.startsWith("QA_")).sort(), expected[i]); + assert.ok(!prompt.includes(fixtureValue)); + assert.ok(!prompt.includes(rotated)); + assert.match(prompt, new RegExp(`"session":"${i === 0 ? "fresh" : "resumed"}"`)); + } + assert.equal(calls[1].env.QA_SERVICE_TOKEN, rotated); + }); + + test(`${engine}: clean mode and another channel cannot advertise or inherit the fixture credential`, async () => { + const ctx = await fixture(`CREDENTIALS_CLEAN_${engine}`, engine, { cleanMode: true }); + const start = backend.calls.spawn.length; + await runMessage({ ...ctx, text: "Which credentials are available?" }); + const [clean] = attempts(start); + assert.ok(!promptOf(clean).includes(prefix)); + assert.ok(!promptOf(clean).includes("QA_SERVICE_TOKEN")); + assert.equal(clean.env.QA_SERVICE_TOKEN, undefined); + const other = await fixture(`CREDENTIALS_OTHER_${engine}`, engine, { env: {} }); + const otherStart = backend.calls.spawn.length; + await runMessage({ ...other, text: "Which credentials are available?" }); + const [call] = attempts(otherStart); + assert.deepEqual(namesOf(promptOf(call)), []); + assert.equal(call.env.QA_SERVICE_TOKEN, undefined); + }); +} + +test("cross-engine fallback and model retry retain the inventory matching each spawn", async () => { + const ctx = await fixture("CREDENTIALS_FALLBACK", "claude", { model: "gpt-5.6" }); + const start = backend.calls.spawn.length; + await runMessage({ ...ctx, text: "CLAUDE_STUB_LIMIT_FAIL_SAFE CODEX_STUB_REJECT_MODEL" }); + const calls = attempts(start); + assert.equal(calls.length, 3); + assert.match(promptOf(calls[0]), /"engine":"claude"/); + assert.match(promptOf(calls[2]), /"engine":"codex"/); + for (const call of calls) { + assert.deepEqual(namesOf(promptOf(call)), ["QA_SERVICE_TOKEN"]); + assert.equal(call.env.QA_SERVICE_TOKEN, fixtureValue); + assert.ok(!promptOf(call).includes(fixtureValue)); + } +}); + +test("session healing uses the current inventory, including a credential removed before resuming", async (t) => { + const ctx = await fixture("CREDENTIALS_HEAL", "claude"); + await runMessage({ ...ctx, text: "Start." }); + await patchChannelMeta(ctx.channel.slug, { env: {} }); + const original = backend.spawn; + let resumes = 0; + backend.spawn = (target, spec) => { + if (spec.args.includes("-r")) { + resumes++; + assert.deepEqual(namesOf(promptOf(spec)), []); + throw new Error("No conversation found with session ID: synthetic-lost-session"); + } + return original(target, spec); + }; + t.after(() => { backend.spawn = original; }); + const start = backend.calls.spawn.length; + await runMessage({ ...ctx, text: "Continue.", getFallbackContext: async () => "Prior fixture conversation." }); + assert.equal(resumes, 1); + const [healed] = attempts(start); + assert.deepEqual(namesOf(promptOf(healed)), []); + assert.match(promptOf(healed), /"session":"fresh"/); +}); 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-guests-browser.test.js b/test/channel-guests-browser.test.js new file mode 100644 index 0000000..7db2a0b --- /dev/null +++ b/test/channel-guests-browser.test.js @@ -0,0 +1,99 @@ +// Real admin form + router acceptance in a disposable store and Chromium profile. +import test from "node:test"; +import assert from "node:assert/strict"; +import express from "express"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { ensureTestEnv } from "./helpers.js"; + +ensureTestEnv(); + +test("unrelated channel saves preserve undisplayed guests while explicit edits remain validated", { 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, setUser } = await import("../src/config/store.js"); + const channelId = "C_GUEST_BROWSER"; + const channel = await upsertChannelEntry(channelId, { name: "guest-browser", type: "channel", isDM: false }); + const saved = ["U_VISIBLE", "U_SAVED_ONLY", "U_APPROVED"]; + await saveChannelMeta(channel.slug, { ...defaultChannelMeta({ channelId, name: channel.name, type: "channel", isDM: false }), allowedUsers: saved, allowNetwork: false, cleanMode: false }); + await setUser("U_APPROVED", { approved: true }); + let rosterFails = false; + let saveFails = false; + const members = ["U_VISIBLE", "U_ADD", "U_APPROVED"]; + const client = { + conversations: { members: async () => { + if (rosterFails) throw new Error("controlled unavailable roster"); + return { members, response_metadata: { next_cursor: "" } }; + } }, + users: { info: async ({ user }) => ({ user: { id: user, name: user, profile: { display_name: user } } }) }, + }; + const app = express(); + app.use(express.json()); + const payloads = []; + app.put(`/api/channels/${channelId}/meta`, (req, res, next) => { + payloads.push(req.body); + if (saveFails) return res.status(503).json({ error: "controlled save failure" }); + next(); + }); + app.get("/api/mcp/available", (_req, res) => res.json({ servers: [] })); + app.get("/api/health", (_req, res) => res.json({ slack: { connected: true }, engines: {} })); + app.use("/api", createAdminRouter({ slack: { getClient: () => client, snapshot: () => ({ connected: true, teamId: "T_FIXTURE" }) } })); + const publicDir = fileURLToPath(new URL("../public", import.meta.url)); + app.use(express.static(publicDir, { dotfiles: "allow" })); + app.get(`/conversations/channel/${channelId}`, (_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 browser = await chromium.launch({ headless: true }); + t.after(() => browser.close()); + const page = await browser.newPage(); + page.setDefaultTimeout(10000); + const errors = []; + page.on("pageerror", (error) => errors.push(error.message)); + const open = async () => { + await page.goto(`http://127.0.0.1:${server.address().port}/conversations/channel/${channelId}`); + await page.waitForFunction(() => globalThis.document.querySelector(".ch-users")?.dataset.ready === "1"); + }; + const toggle = async (selector) => page.locator(selector).locator("xpath=..").click(); + const save = async (status = 200) => { + const pending = page.waitForResponse((r) => r.url().endsWith(`/channels/${channelId}/meta`) && r.request().method() === "PUT"); + await page.locator(".save-channel").click(); + const response = await pending; + assert.equal(response.status(), status, await response.text()); + await page.locator(".detail-savebar .msg").filter({ hasText: status === 200 ? /^Saved$/ : /Couldn't save/ }).waitFor(); + }; + await open(); + assert.equal(await page.locator('.ch-users input[value="U_SAVED_ONLY"]').count(), 0); + assert.equal(await page.locator('.ch-users input[value="U_APPROVED"]').isChecked(), true); + await toggle(".ch-network"); + await save(); + assert.equal(Object.hasOwn(payloads.at(-1), "allowedUsers"), false, "unrelated Network save omits even a successfully loaded partial guest roster"); + assert.deepEqual((await getChannelMeta(channel.slug)).allowedUsers, saved); + assert.equal(await page.locator('.ch-users input[value="U_APPROVED"]').isChecked(), true, "inherited access stays visibly checked after save"); + await toggle(".ch-clean"); + await save(); + assert.equal(Object.hasOwn(payloads.at(-1), "allowedUsers"), false); + assert.deepEqual((await getChannelMeta(channel.slug)).allowedUsers, saved); + await open(); + assert.equal(await page.locator('.ch-users input[value="U_VISIBLE"]').isChecked(), true); + await toggle('.ch-users input[value="U_VISIBLE"]'); + await toggle('.ch-users input[value="U_ADD"]'); + saveFails = true; + await save(503); + assert.deepEqual(payloads.at(-1).allowedUsers, ["U_ADD"]); + assert.deepEqual((await getChannelMeta(channel.slug)).allowedUsers, saved, "failed edit keeps saved grants"); + saveFails = false; + await save(); + assert.deepEqual(payloads.at(-1).allowedUsers, ["U_ADD"], "retry retains pending guest edit"); + assert.deepEqual((await getChannelMeta(channel.slug)).allowedUsers, ["U_ADD"], "explicit replacement retains current-member validation"); + await toggle(".ch-network"); + await save(); + assert.equal(Object.hasOwn(payloads.at(-1), "allowedUsers"), false, "successful save resets guest dirty state"); + rosterFails = true; + await page.reload(); + await page.locator(".ch-users").filter({ hasText: /Guest list unavailable/ }).waitFor(); + await toggle(".ch-network"); + await save(); + assert.equal(Object.hasOwn(payloads.at(-1), "allowedUsers"), false); + assert.deepEqual((await getChannelMeta(channel.slug)).allowedUsers, ["U_ADD"]); + assert.deepEqual(errors, []); +}); diff --git a/test/channel-members-ui.test.js b/test/channel-members-ui.test.js index 91f9169..5a78383 100644 --- a/test/channel-members-ui.test.js +++ b/test/channel-members-ui.test.js @@ -33,12 +33,18 @@ test("guest options reject malformed roster payloads instead of falling back to }); test("channel save omits guest grants until the live roster loaded successfully", () => { - assert.deepEqual(channelGuestSavePatch(false, ["U_EXISTING"]), {}); - assert.deepEqual(channelGuestSavePatch(true, ["U_EXTERNAL", "U_EXTERNAL", "U_INTERNAL"]), { + assert.deepEqual(channelGuestSavePatch(false, ["U_EXISTING"], true), {}); + assert.deepEqual(channelGuestSavePatch(true, ["U_EXTERNAL", "U_EXTERNAL", "U_INTERNAL"], true), { allowedUsers: ["U_EXTERNAL", "U_INTERNAL"], }); }); +test("an unchanged successful guest roster cannot replace saved grants on unrelated saves", () => { + assert.deepEqual(channelGuestSavePatch(true, ["U_VISIBLE"]), {}); + assert.deepEqual(channelGuestSavePatch(true, []), {}); + assert.deepEqual(channelGuestSavePatch(true, [], true), { allowedUsers: [] }, "explicit removal remains a validated replacement"); +}); + test("successful saves expose the server-accepted guest IDs for checklist repainting", () => { assert.equal(channelGuestAcceptedIds(false, ["U_MEMBER"]), null); assert.deepEqual( diff --git a/test/channel-settings-modal.test.js b/test/channel-settings-modal.test.js index 134faa5..3b53a43 100644 --- a/test/channel-settings-modal.test.js +++ b/test/channel-settings-modal.test.js @@ -24,7 +24,6 @@ import { CHANNEL_SETTINGS_RUNTIME_CALLBACK_ID, CHANNEL_SETTINGS_RUNTIME_ENGINE_ACTION_ID, CHANNEL_SETTINGS_RUNTIME_MODEL_ACTION_ID, - CHANNEL_SETTINGS_SECRETS_MANAGE_ACTION_ID, CHANNEL_SETTINGS_SKILLS_MANAGE_ACTION_ID, CHANNEL_SETTINGS_TABS, CONNECTION_COMPOSIO_ACTION_ID, @@ -145,12 +144,19 @@ 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/); assert.match(secrets, /••••beef/); assert.doesNotMatch(secrets, /actual-secret-value/); - assert.ok(allButtons(secretsView).some((button) => button.action_id === CHANNEL_SETTINGS_SECRETS_MANAGE_ACTION_ID)); + assert.ok(allButtons(secretsView).some((button) => button.action_id === "cg_channel_secrets_add")); + assert.ok(secretsView.blocks.some((block) => block.accessory?.action_id === "cg_channel_secrets_remove_0")); + assert.doesNotMatch(secrets, /Add, update, or remove secrets/); const mcp = buildChannelSettingsView(snapshot, state, { tab: "mcp" }); assert.ok(allButtons(mcp).some((button) => button.action_id === CHANNEL_SETTINGS_CONNECTIONS_EDIT_ACTION_ID)); }); 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/channelgate-skill.test.js b/test/channelgate-skill.test.js index 197b278..81c9733 100644 --- a/test/channelgate-skill.test.js +++ b/test/channelgate-skill.test.js @@ -77,10 +77,9 @@ test("the ChannelGate skill defines deterministic Composio identity discovery", assert.match(guide, /`composio-user`.*active requester's personal account/is); assert.match(guide, /`composio-agent`.*shared agent account/is); - // Ambiguity is a MUST-ask hard stop with the privacy reason stated, not a soft preference. - assert.match(guide, /both identities have an app such as Gmail.*MUST ask which account and MUST NOT call a tool first/is); - assert.match(guide, /not even a read-only look/i); - assert.match(guide, /exposes the\s+requester's own private data, or a third party's/is); + assert.match(guide, /Unrestricted reads and searches may use either\s+or both identities without asking which account/is); + assert.match(guide, /Writes, sends and other state changes require the intended identity and connected\s+account/is); + assert.match(guide, /Explicit account and record restrictions remain\s+binding/is); assert.match(guide, /Never substitute or silently fall back/is); assert.match(guide, /mcp__composio_user__/); // The search tool is the only side-effect-free existence check. diff --git a/test/claude-completion.test.js b/test/claude-completion.test.js new file mode 100644 index 0000000..4364386 --- /dev/null +++ b/test/claude-completion.test.js @@ -0,0 +1,92 @@ +// A successful process exit and streamed narration are not proof of a completed turn. +// In-memory runtime fixtures exercise both real runner parsers without a real engine process. +import test from "node:test"; +import assert from "node:assert/strict"; +import { EventEmitter } from "node:events"; +import { PassThrough } from "node:stream"; +import { ensureTestEnv } from "./helpers.js"; + +ensureTestEnv(); +const { runClaude } = await import("../src/engines/claude.js"); +const { PersistentClaudeSession } = await import("../src/engines/persistent-session.js"); +const narration = "I am applying the update and will verify it next."; +const progress = [ + { type: "stream_event", event: { type: "content_block_delta", index: 0, delta: { type: "text_delta", text: narration } } }, + { type: "stream_event", event: { type: "content_block_start", index: 1, content_block: { type: "tool_use", id: "write-1", name: "Bash", input: {} } } }, + { type: "stream_event", event: { type: "content_block_stop", index: 1 } }, +]; +const usage = { input_tokens: 900, output_tokens: 120 }; +function terminal(overrides = {}) { + return { type: "result", subtype: "error_during_execution", is_error: true, session_id: "partial-session", usage, total_cost_usd: 0.02, ...overrides }; +} +async function cold(events) { + const child = new EventEmitter(); + child.stdout = new PassThrough(); + child.stderr = new PassThrough(); + child.stdin = new PassThrough(); + const target = { backend: "test", runtime: { capabilities: {}, spawn() { + queueMicrotask(() => { + child.stderr.write("Execution interrupted before verification"); + child.stdout.write(events.map(JSON.stringify).join("\n") + "\n"); + child.emit("close", 0, null); + }); + return child; + } } }; + return runClaude({ cwd: process.cwd(), prompt: "Apply and verify the update.", target, timeoutMs: 1000 }); +} +async function warm(events, steer = false) { + const session = new PersistentClaudeSession({ cwd: process.cwd(), args: [] }); + session.child = { stdin: { write() { return true; } } }; + session.state = "ready"; + session.stderr = ""; + const turn = session.send("Apply and verify the update.", { timeoutMs: 1000 }); + session.stderr = "Execution interrupted before verification"; + if (steer) assert.equal(session.interrupt(), true); + for (const event of events) session._handleLine(JSON.stringify(event)); + const result = await turn; + session._clearIdle(); + assert.equal(session.alive, true, "terminal harness results keep the resumable warm session"); + return result; +} + +for (const [mode, run] of [["cold", cold], ["warm", warm]]) { + for (const verdict of [terminal(), terminal({ is_error: undefined })]) { + test(`${mode}: error result overrides prior narration (${verdict.is_error === true ? "flag" : "subtype only"})`, async () => { + const result = await run([...progress, verdict]); + assert.equal(result.engineError, true); + assert.equal(result.completed, false); + assert.equal(result.endReason, "error_during_execution"); + assert.equal(result.content, narration, "partial output remains available to the orchestrator"); + assert.equal(result.toolUseCount, 1, "a possibly executed write is not silently replay-safe"); + assert.deepEqual(result.usage, usage); + assert.equal(result.costUSD, 0.02); + assert.equal(result.sessionId, "partial-session"); + assert.match(result.diagnostic, /interrupted before verification/); + }); + } + test(`${mode}: terminal success preserves the answer and accounting`, async () => { + const result = await run([...progress, terminal({ subtype: "success", is_error: false })]); + assert.equal(result.completed, true); + assert.equal(result.engineError, false); + assert.equal(result.content, narration); + assert.deepEqual(result.usage, usage); + assert.equal(result.diagnostic, undefined); + }); +} + +test("cold: clean exit with narration but no terminal event is incomplete", async () => { + const result = await cold(progress); + assert.equal(result.completed, false); + assert.equal(result.engineError, true); + assert.equal(result.endReason, "missing_terminal_result"); + assert.equal(result.content, narration); + assert.equal(result.toolUseCount, 1); +}); + +test("warm: deliberate steering remains distinguishable from a harness failure", async () => { + const result = await warm([...progress, terminal()], true); + assert.equal(result.interrupted, true); + assert.equal(result.completed, false); + assert.equal(result.content, narration); + assert.deepEqual(result.usage, usage); +}); diff --git a/test/claude-login-alert.test.js b/test/claude-login-alert.test.js index 0c65760..f15a0ad 100644 --- a/test/claude-login-alert.test.js +++ b/test/claude-login-alert.test.js @@ -319,3 +319,40 @@ test("startClaudeLoginWatch defers the first pass and stop() cancels it", async await new Promise((r) => setTimeout(r, 20)); assert.equal(ticks, settled); // stopped means stopped }); + + +test("live Claude disable skips credential checks and notifications; re-enable alerts afresh", async () => { + const { getSettings, saveSettings } = await import("../src/config/settings.js"); + const original = getSettings().engineEnabled; + const store = markStore(`missing:${DAY_KEY}`); + const rec = recorder(); + const sent = []; + let resolves = 0; + let adminReads = 0; + const opts = { + now: () => NOW, log: rec.log, ...store, + resolve: () => { resolves++; return missingLogin(); }, + admins: async () => { adminReads++; return ["UADMIN1"]; }, + notify: async (message) => { sent.push(message); }, + }; + try { + saveSettings({ engineEnabled: { claude: false, codex: true } }); + for (let i = 0; i < 2; i++) { + assert.equal((await claudeLoginWatchTick(opts)).disabled, true); + } + assert.equal(resolves, 0); + assert.equal(adminReads, 0); + assert.equal(sent.length, 0); + assert.deepEqual(rec.warnings(), []); + assert.equal(store.get(), ""); + saveSettings({ engineEnabled: { claude: true, codex: true } }); + assert.equal((await claudeLoginWatchTick(opts)).notified, true); + assert.equal(resolves, 1); + assert.equal(sent.length, 1); + assert.equal(store.get(), `missing:${DAY_KEY}`); + assert.equal((await claudeLoginWatchTick(opts)).notified, false); + assert.equal(sent.length, 1); + } finally { + saveSettings({ engineEnabled: original ?? {} }); + } +}); 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/codex-completion.test.js b/test/codex-completion.test.js new file mode 100644 index 0000000..3afbf59 --- /dev/null +++ b/test/codex-completion.test.js @@ -0,0 +1,157 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { mkdir, writeFile, readdir } from "node:fs/promises"; +import path from "node:path"; +import { ensureTestEnv, tempDir } from "./helpers.js"; +import { createFakeRuntime } from "./fixtures/fake-runtime-backend.js"; + +ensureTestEnv(); +const { runCodex } = await import("../src/engines/codex.js"); +const message = (text, phase) => ({ type: "item.completed", item: { id: text, type: "agent_message", text, ...(phase ? { phase } : {}) } }); +const completed = { type: "turn.completed", usage: { input_tokens: 12, output_tokens: 4 } }; + +async function simulate({ events = [], output, code = 0, signal = null, stderr = "", setup } = {}) { + const runtime = createFakeRuntime(); + const artifactDir = tempDir("cg-codex-completion-"); + const target = runtime.target({ artifactDir }); + let streamed = ""; + // Attach the rejection handler immediately: the fake close event can reject synchronously. + const pending = runCodex({ cwd: artifactDir, prompt: "synthetic completion fixture", sessionId: "", isNewSession: true, clean: true, target, artifactDir, timeoutMs: 60_000, onDelta: (delta) => { streamed += delta; } }) + .then((result) => ({ result }), (error) => ({ error })); + for (let attempt = 0; !runtime.spawns.length && attempt < 200; attempt++) await new Promise((resolve) => setTimeout(resolve, 5)); + assert.equal(runtime.spawns.length, 1, "only the fake backend spawns"); + if (setup) await setup(runtime, artifactDir); + const args = runtime.spawns[0].args; + if (output !== undefined) await writeFile(args[args.indexOf("-o") + 1], output); + const child = runtime.children[0]; + child.stdout.write(`${JSON.stringify({ type: "thread.started", thread_id: "completion-fixture" })}\n`); + for (const event of events) child.stdout.write(`${JSON.stringify(event)}\n`); + if (stderr) child.stderr.write(`${stderr}\n`); + child.emit("close", code, signal); + const outcome = await pending; + assert.deepEqual(await readdir(path.join(artifactDir, "tmp")), [], "scratch output is removed on every outcome"); + return { ...outcome, streamed, runtime }; +} + +test("Codex explicit terminal failures beat output-file text and retain partial evidence", async () => { + for (const type of ["turn.failed", "error"]) { + const { error, result } = await simulate({ output: "Partial answer", events: [completed, { type, error: { message: "synthetic terminal failure" } }] }); + assert.equal(result, undefined); + assert.match(error.message, /synthetic terminal failure/); + assert.equal(error.details.partialContent, "Partial answer"); + assert.equal(error.details.sessionId, "completion-fixture"); + assert.equal(error.details.usage.output_tokens, 4); + assert.equal(error.details.replaySafe, false); + } +}); + +test("Codex nonzero and signalled exits beat a completed event and populated output file", async () => { + for (const exit of [{ code: 1 }, { code: null, signal: "SIGTERM" }]) { + const { error } = await simulate({ ...exit, events: [completed], output: "Partial answer" }); + assert.ok(error); + assert.equal(error.details.partialContent, "Partial answer"); + assert.equal(error.details.exitCode, exit.code); + assert.equal(error.details.signal, exit.signal || null); + assert.equal(error.details.replaySafe, false); + } +}); + +test("Codex output-file-only provider failure is never replay safe", async () => { + const { error } = await simulate({ code: 1, output: "Some work already happened", stderr: "Your usage limit has been reached" }); + assert.ok(error); + assert.equal(error.details.replaySafe, false); +}); + +test("Codex clean exits without terminal completion reject commentary, final text and output files", async () => { + for (const fixture of [ + { events: [message("I will finish the checks now.", "commentary")] }, + { events: [message("A claimed answer", "final_answer")] }, + { output: "A saved answer" }, + {}, + ]) { + const { error, result } = await simulate(fixture); + assert.equal(result, undefined); + assert.match(error.message, /before reporting.*completed/); + assert.equal(error.details.incompleteTurn, true); + assert.equal(error.details.replaySafe, false); + } +}); + +test("Codex completed commentary stays live progress and yields an empty answer", async () => { + const { result, streamed } = await simulate({ events: [message("Checking remaining items.", "commentary"), completed], output: "Checking remaining items." }); + assert.equal(streamed, "Checking remaining items."); + assert.equal(result.content, ""); +}); + +test("Codex phase-aware final fallback excludes commentary while legacy messages remain supported", async () => { + for (const phase of ["final", "final_answer"]) { + const { result, streamed } = await simulate({ events: [message("Working…", "commentary"), message("Done.", phase), completed] }); + assert.equal(result.content, "Done."); + assert.match(streamed, /Working…/); + assert.match(streamed, /Done\./); + } + const legacy = await simulate({ events: [message("Legacy answer"), completed] }); + assert.equal(legacy.result.content, "Legacy answer"); + const nestedUsage = await simulate({ events: [message("Legacy nested usage"), { type: "turn.completed", turn: { usage: { input_tokens: 8, output_tokens: 3 } } }] }); + assert.equal(nestedUsage.result.usage.output_tokens, 3); + const output = await simulate({ output: "Authoritative answer", events: [message("Streamed answer", "final_answer"), completed] }); + assert.equal(output.result.content, "Authoritative answer"); +}); + +test("Codex phase carried only by the message start still separates streamed final text", async () => { + const events = []; + for (const [id, phase, text] of [["progress", "commentary", "Still checking"], ["answer", "final_answer", "All checked"]]) { + events.push({ type: "item.started", item: { id, type: "agent_message", phase } }); + events.push({ type: "agent_message_delta", item_id: id, delta: { text } }); + } + events.push(completed); + const { result, streamed } = await simulate({ events }); + assert.equal(result.content, "All checked"); + assert.match(streamed, /Still checking/); +}); + + +test("Codex phase arriving at completed-message time classifies already streamed text", async () => { + const { result } = await simulate({ events: [ + { type: "agent_message_delta", item_id: "progress", delta: { text: "Still checking" } }, + { type: "item.completed", item: { id: "progress", type: "agent_message", phase: "commentary", text: "Still checking" } }, + { type: "agent_message_delta", item_id: "answer", delta: { text: "Done" } }, + { type: "item.completed", item: { id: "answer", type: "agent_message", phase: "final_answer", text: "Done" } }, + completed, + ] }); + assert.equal(result.content, "Done"); +}); + + +test("Codex failure without terminal usage preserves root and child rollout accounting", async () => { + const { reduceCodexUsage } = await import("../src/engines/codex-usage.js"); + const { error } = await simulate({ + code: 1, + events: [message("Checks are running", "commentary"), { type: "turn.failed", error: { message: "synthetic execution failure" } }], + async setup(runtime, artifactDir) { + const stateDir = path.join(artifactDir, "state"); + const day = path.join(stateDir, "sessions", "2026", "09", "08"); + await mkdir(day, { recursive: true }); + const timestamp = new Date().toISOString(); + const rollout = (id, tokens, parent) => [ + { timestamp, type: "session_meta", payload: { id, timestamp, ...(parent ? { parent_thread_id: parent, agent_path: "/root/reviewer" } : {}) } }, + { timestamp, type: "turn_context", payload: { model: "gpt-5.6-codex" } }, + { timestamp, type: "event_msg", payload: { type: "task_started", started_at: Math.floor(Date.now() / 1000) } }, + { timestamp, type: "event_msg", payload: { type: "token_count", info: { total_token_usage: tokens, last_token_usage: tokens } } }, + ].map((row) => JSON.stringify(row)).join("\n") + "\n"; + await writeFile(path.join(day, "rollout-2026-09-08-completion-fixture.jsonl"), rollout("completion-fixture", { input_tokens: 120, output_tokens: 35 })); + await writeFile(path.join(day, "rollout-2026-09-08-child-reviewer.jsonl"), rollout("child-reviewer", { input_tokens: 80, output_tokens: 20 }, "completion-fixture")); + runtime.backend.inspectUsage = (_target, { args }) => reduceCodexUsage({ ...args, stateDir }); + }, + }); + assert.match(error.message, /synthetic execution failure/); + assert.equal(error.details.usage.output_tokens, 35); + const result = error.details.result; + assert.equal(result.runtimeModel, "gpt-5.6-codex"); + assert.equal(result.usageRequests.length, 1); + assert.equal(result.usageRequests[0].usage.output_tokens, 35); + assert.equal(result.usageAccounting.children.length, 1); + assert.equal(result.usageAccounting.children[0].usage.output_tokens, 20); + assert.equal(result.usageAccounting.children[0].sessionId, "child-reviewer"); + assert.equal(error.details.replaySafe, false); +}); diff --git a/test/composio-guide.test.js b/test/composio-guide.test.js index 6815092..1be46fe 100644 --- a/test/composio-guide.test.js +++ b/test/composio-guide.test.js @@ -22,17 +22,25 @@ test("gateway operating guide frames the two Composio accounts as YOURS vs the r assert.match(skill, /`composio-agent`.*YOUR OWN Composio account/is); assert.match(skill, /`composio-user`.*requester's personal Composio account/is); - // The credential's origin is an admin detail the model must not surface. - assert.match(skill, /channel or organization configuration.*admin detail/is); + // A logical identity says how to route, not who owns an external account or whether channels + // share it. Clarifying those facts must never weaken selected-identity routing. + assert.match(skill, /not proof of the connected service owner's name or cross-channel sharing/is); + assert.match(skill, /may reach the same service owner or different owners/is); + assert.match(skill, /Matching owners never authorize substituting identities/is); + assert.match(skill, /not universally shared across channels/is); + assert.match(skill, /unless verified/is); + const admin = await read("references/administration.md"); + assert.match(admin, /Routine replies need no credential details/is); + assert.match(admin, /without assuming the current source or connected service owner/is); // Pronoun rule, app-agnostic, with the email example the operator asked for. assert.match(skill, /“my[^”]*”.*`composio-user`/is); assert.match(skill, /“your[^”]*”.*`composio-agent`/is); assert.match(skill, /verify my email.*requester's Gmail/is); assert.match(skill, /verify your email.*your\* Gmail/is); assert.match(skill, /named account.*inspect aliases/is); - // Tie-breakers: single-connected app is used (and named), both-connected asks. + // Unrestricted reads may use both; mutations preserve account selection. assert.match(skill, /only ONE identity has that app connected.*say which/is); - assert.match(skill, /BOTH have the app.*ask/is); + assert.match(skill, /Write\/send\/state change.*ask which account only if unresolved/); // Tool registries normalize punctuation; one spelling is not evidence of absence. assert.match(skill, /`composio-user` can appear as `composio_user`/is); assert.match(skill, /never declare an identity absent.*only one spelling/is); @@ -58,19 +66,21 @@ test("the operating guide makes dual Gmail identity selection explicit", async ( assert.match(skill, /“my[^”]*”.*requester's Gmail/is); assert.match(skill, /“your[^”]*”.*your\* Gmail/is); - assert.match(skill, /BOTH have the app.*MUST ask which account.*no tool call/is); + assert.match(skill, /BOTH have the app, read\/search only.*either or both identities/is); }); -test("the ambiguous identity case is a MUST-ask hard stop with a stated privacy reason", async () => { +test("read discovery stays available while writes require the intended identity and account", async () => { const skill = await read("SKILL.md"); - - assert.match(skill, /Ambiguity is a hard stop, not a preference/i); - // The first response is the question, never a tool call — not even a read-only peek. - assert.match(skill, /first response MUST be the question.*MUST NOT be\s+a tool call/is); - assert.match(skill, /no read-only peek/i); - // The one-line rationale: private data of the requester or a third party. - assert.match(skill, /exposes the requester's own private data, or a\s+third party's/is); - assert.match(skill, /never send, schedule or\s+post from a guessed account/is); + assert.match(skill, /use either\s+or both available identities without asking which account merely to read/is); + assert.match(skill, /Honor explicit account,\s+record and scope restrictions/is); + assert.match(skill, /Reuse a previously established choice/is); + assert.match(skill, /ask which account before that mutation/is); + assert.match(skill, /Continue\s+independent authorized reads/is); + assert.match(skill, /using an account for a read does not select it for a write/is); + assert.match(skill, /marking messages read, reacting,\s+connecting accounts/is); + for (const file of ["SKILL.md", "references/reading.md", "references/messages.md"]) { + assert.doesNotMatch(await read(file), /no read-only peek|question is the whole reply|else ask/i); + } }); test("no guide file still routes connection inventory through COMPOSIO_MANAGE_CONNECTIONS", async () => { diff --git a/test/composio-identity-preamble.test.js b/test/composio-identity-preamble.test.js index 91a06d6..878faed 100644 --- a/test/composio-identity-preamble.test.js +++ b/test/composio-identity-preamble.test.js @@ -1,13 +1,4 @@ -// CO-04: with BOTH Composio identities injected, "check the calendar" was answered from the SHARED -// identity — a colleague's whole week, attendee names included, posted into the channel — while the -// other harness asked "which account?" first. The rule already lived in the managed instructions -// block; what was missing is the FACT it applies to: which identities THIS turn received. That -// cannot live in the channel's shared instruction file (`composio-user` is per author, so two -// concurrent authors would race each other's sentence), so it rides the per-run prompt beside the -// fresh-session memory catalog and the caller's provenance line. -// -// The prompt-echo stubs make the assertion direct: their "answer" is the prompt they were handed, -// so these tests read the exact text each engine received. +// Read/search routing and write-account policy accompany every engine prompt, fresh or resumed. import path from "node:path"; import { fileURLToPath } from "node:url"; import { mkdir } from "node:fs/promises"; @@ -70,7 +61,11 @@ test("the run's identity set mirrors the servers the MCP config would carry", () test("the identity line names only servers and roles — never a token, an address or an account", () => { const both = composioIdentityPreamble({ user: true, agent: true }); assert.match(both, /^\[Composio identities in THIS run: `composio-user`.*`composio-agent`/); - assert.match(both, /which account\?" — no tool call, no read-only peek/); + assert.match(both, /Reads and searches may use either or both identities without asking which account/); + assert.match(both, /unless the user restricts the account or scope/); + assert.match(both, /Writes, sends and other state changes require the intended identity and connected account/); + assert.match(both, /reuse an established choice/); + assert.match(both, /continue independent authorized reads/); assert.ok(both.endsWith("]\n\n"), "self-closing, like the memory catalog and provenance notes"); assert.equal(both.trim().split("\n").length, 1, "one line"); @@ -95,7 +90,7 @@ test("the identity line names only servers and roles — never a token, an addre }); // ── The run boundary: the line reaches the engine ───────────────────────────────────────────── -test("a run with BOTH identities prepends the ask-first line to the prompt", async () => { +test("a run with BOTH identities prepends the read/write account policy to the prompt", async () => { saveSettings({ engine: "claude", agentMemory: false, memoryReviewEvery: 0, composioMode: "personal", defaultComposioToken: "ak_org_9999" }); await setUser("U_IDENT", { name: "Identity User", approved: true, isAdmin: false, composioToken: "ak_user_1111" }); await channel("C_IDENT_BOTH", "identity-both"); @@ -139,3 +134,32 @@ test("Codex receives the same line through its own prompt path", async () => { assert.equal((r.content.match(/\[Composio identities in THIS run:/g) || []).length, 1); assert.match(r.content, /\]\n\ncheck the calendar$/); }); + +test("both engines preserve identity routing without inventing account ownership, including resumed shared-only turns", async () => { + for (const engine of ["claude", "codex"]) { + // Both configured credentials can legitimately address the same service owner. Identity + // routing must stay distinct; neither the resolver nor the prompt can infer a different human. + saveSettings({ engine, agentMemory: false, memoryReviewEvery: 0, composioMode: "personal", defaultComposioToken: "ak_same_owner_3333" }); + const authorId = `U_OWNER_${engine}`; + await setUser(authorId, { name: "Ownership Fixture", approved: true, isAdmin: false, composioToken: "ak_same_owner_3333" }); + const channelId = `C_OWNER_${engine}`; + await channel(channelId, `ownership-${engine}`, { engine }); + const threadKey = engine === "claude" ? "9101.001" : "9101.002"; + for (const personal of [true, false]) { + if (!personal) await setUser(authorId, { composioToken: "" }); + const result = await runMessage({ channelId, authorId, text: "check my inbox", threadKey, origin: "slack_foreground", preferCold: true }); + const preamble = result.content.match(/^\[Composio identities in THIS run:.*$/m)?.[0]; + assert.ok(preamble, "the actual engine prompt includes current identity guidance"); + assert.match(preamble, /do not establish the connected service owner/); + assert.match(preamble, /not necessarily shared across channels/); + assert.doesNotMatch(preamble, /OTHER people's|never the requester's|ak_same_owner/); + if (personal) { + assert.match(preamble, /`composio-user`.*`composio-agent`/); + assert.match(preamble, /Reads and searches may use either or both identities without asking which account/); + } else { + assert.match(preamble, /`composio-agent` only/); + assert.match(preamble, /do not read `composio-agent` to answer it/); + } + } + } +}); diff --git a/test/container-job-log-redaction.test.js b/test/container-job-log-redaction.test.js index 67c3ce8..6702077 100644 --- a/test/container-job-log-redaction.test.js +++ b/test/container-job-log-redaction.test.js @@ -62,6 +62,18 @@ test("log is redacted while detached work outlives its launcher and keeps a reco assert.equal(launched.status, 0); const pid = Number(launched.stdout.trim()); assert.ok(Number.isInteger(pid) && pid > 1); + let exitedNaturally = false; + const wrapperRunning = () => { + try { + const stat = readFileSync(`/proc/${pid}/stat`, "utf8"); + // Linux can retain an orphan as a zombie until its reaper runs. Its exit/coverage flush + // is already complete then; kill(pid, 0) would incorrectly keep waiting for that zombie. + return !["Z", "X"].includes(stat.slice(stat.lastIndexOf(")") + 2).split(" ")[0]); + } catch (err) { + if (err.code === "ENOENT") return false; + throw err; + } + }; try { for (let i = 0; i < 150 && !existsSync(ready); i++) await delay(10); assert.ok(existsSync(ready), "detached command starts after the launcher exits"); @@ -83,7 +95,15 @@ test("log is redacted while detached work outlives its launcher and keeps a reco assert.equal(parseContainerJobExit(final), 23); assert.equal(final.includes(secret), false); assert.match(stripContainerJobExit(final), /complete$/); + // The terminal log marker precedes the Node wrapper's own exit. Killing it at that marker + // can interrupt V8's coverage-file flush and fail the surrounding suite with an empty file. + const exitDeadline = Date.now() + 5000; + while (wrapperRunning() && Date.now() < exitDeadline) await delay(10); + assert.equal(wrapperRunning(), false, "the detached wrapper must exit naturally after its terminal marker"); + exitedNaturally = true; } finally { - try { process.kill(-pid, "SIGKILL"); } catch (err) { if (err.code !== "ESRCH") throw err; } + if (!exitedNaturally) { + try { process.kill(-pid, "SIGKILL"); } catch (err) { if (err.code !== "ESRCH") throw err; } + } } }); diff --git a/test/engine-runtime-isolated.test.js b/test/engine-runtime-isolated.test.js index eb1fc80..7378e15 100644 --- a/test/engine-runtime-isolated.test.js +++ b/test/engine-runtime-isolated.test.js @@ -302,6 +302,7 @@ test("a containerized Codex turn writes its answer file and secret bundle into t assert.equal(spec.env.HOME, CONTAINER_HOME); writeFileSync(outFile, "container answer\n"); + rt.children[0].stdout.write(JSON.stringify({ type: "turn.completed", usage: { input_tokens: 1, output_tokens: 1 } }) + "\n"); rt.children[0].emit("close", 0, null); const result = await pending; assert.equal(result.content, "container answer"); diff --git a/test/file-editor.test.js b/test/file-editor.test.js index ddbef2a..cf8df15 100644 --- a/test/file-editor.test.js +++ b/test/file-editor.test.js @@ -107,7 +107,7 @@ test("browser editor exchanges a one-time grant, serves securely, saves, and rej const cookie = exchange.headers.get("set-cookie").split(";")[0]; assert.match(editorPath, /^\/file-editor\/[A-Za-z0-9_-]+$/); assert.match(exchange.headers.get("set-cookie"), /HttpOnly/); - assert.match(exchange.headers.get("set-cookie"), /SameSite=Strict/); + assert.match(exchange.headers.get("set-cookie"), /SameSite=Lax/); assert.match(exchange.headers.get("set-cookie"), /Secure/); assert.equal(exchange.headers.get("cache-control"), "no-store"); 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/file-form-stack.test.js b/test/file-form-stack.test.js new file mode 100644 index 0000000..b51501a --- /dev/null +++ b/test/file-form-stack.test.js @@ -0,0 +1,195 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import path from "node:path"; +import { readFileSync } from "node:fs"; +import { readFile, writeFile, readdir } from "node:fs/promises"; +import { compileFunction } from "node:vm"; +import { parse } from "acorn"; +import { tempDir } from "./helpers.js"; +import * as files from "../src/slack/file-explorer.js"; +import { createFileFormNavigation } from "../src/slack/file-form-navigation.js"; + +// Execute the actual registered Bolt callbacks with a bounded Slack stack transport and real +// file operations, without starting Socket Mode or replacing the production authorization code. +const source = readFileSync(new URL("../src/slack/app.js", import.meta.url), "utf8"); +const callbacks = new Map(); +function visit(node) { + if (!node || typeof node !== "object") return; + if (node.type === "CallExpression" && node.callee?.object?.name === "app" && node.callee?.property?.name === "view") { + const [id, callback] = node.arguments; + if (id?.value?.startsWith("cg_channel_files_")) callbacks.set(id.value, source.slice(callback.start, callback.end)); + } + for (const value of Object.values(node)) { + if (Array.isArray(value)) value.forEach(visit); + else if (value && typeof value === "object") visit(value); + } +} +visit(parse(source, { ecmaVersion: "latest", sourceType: "module" })); + +async function fixture() { + const root = tempDir("cg-file-form-stack-"); + const state = { channelId: "C_FILES", slug: "files", ownerId: "U_OWNER", threadTs: "123.456", relative: "", page: 0 }; + const entry = { slug: "files", name: "Files" }; + const stack = [{ id: "V_ROOT", ...await files.buildFilesView(root, state, { canUpload: true }) }]; + const audit = [], updates = [], acknowledgements = [], authorizations = []; + let serial = 0, mayEdit = true, member = true, failNextUpdate = false; + const client = { views: { update: async ({ view_id, view }) => { + updates.push({ view_id, view }); + if (failNextUpdate) { failNextUpdate = false; throw new Error("temporary view update failure"); } + const index = stack.findIndex((v) => v.id === view_id); + assert.notEqual(index, -1, "must update an existing view, never a popped form"); + stack[index] = { ...view, id: view_id, previous_view_id: stack[index].previous_view_id }; + } } }; + const errorView = (message) => ({ type: "modal", callback_id: "file_error", blocks: [{ type: "section", text: { type: "plain_text", text: message } }] }); + const dependencies = { + ...files, path, createFileFormNavigation, + console: { warn() {} }, + effectiveMeta: (meta) => meta, + getChannelEntry: async () => entry, + fileExplorerContext: async (_client, request) => { + authorizations.push(request); + assert.equal(request.verifyMembership, true); + assert.equal(request.expectedSlug, state.slug); + if (!member) throw new Error("No longer a member of this channel"); + return { entry, meta: { allowBash: mayEdit }, userIsAdmin: false, root }; + }, + logEvent: async (event, data) => audit.push({ event, data }), + filePreviewOptions: ({ mayEdit: editable, notice }) => ({ canEdit: editable, notice }), + fileExplorerViewOptions: ({ mayEdit: editable, notice }) => ({ canUpload: editable, channelName: entry.name, notice }), + fileExplorerErrorView: errorView, + }; + const handlers = new Map([...callbacks].map(([id, code]) => [id, compileFunction(`return (${code});`, Object.keys(dependencies))(...Object.values(dependencies))])); + function push(view) { + if (stack.length >= 3) throw new Error("push_limit_reached"); + stack.push({ ...view, id: `V_FORM_${++serial}`, previous_view_id: stack.at(-1).id }); + } + async function submit(values, user = state.ownerId) { + const view = { ...stack.at(-1), state: { values } }; + let ackCount = 0; + const ack = async (payload) => { + ackCount++; + assert.equal(ackCount, 1, "submission must be acknowledged once"); + acknowledgements.push(payload); + if (!payload) stack.pop(); + else if (payload.response_action === "update") stack[stack.length - 1] = { ...payload.view, id: view.id, previous_view_id: view.previous_view_id }; + else assert.equal(payload.response_action, "errors"); + }; + await handlers.get(view.callback_id)({ ack, body: { user: { id: user }, view }, view, client }); + assert.equal(ackCount, 1); + } + return { root, state, stack, audit, updates, acknowledgements, authorizations, push, submit, + cancel() { assert.equal(stack.at(-1).close.text, "Cancel"); stack.pop(); }, + setEditable(value) { mayEdit = value; }, setMember(value) { member = value; }, + failUpdate() { failNextUpdate = true; }, + }; +} +const fileValues = (name, content = "initial") => ({ new_file_name: { cg_channel_files_new_file_name_value: { value: name } }, new_file_content: { cg_channel_files_new_file_content_value: { value: content } } }); +const folderValues = (name) => ({ new_folder_name: { cg_channel_files_new_folder_value: { value: name } } }); +const editValues = (content) => ({ file_content: { value: { value: content } } }); + +test("five create cycles and repeated inline saves return to one explorer level", async () => { + const f = await fixture(); + for (let i = 0; i < 5; i++) { + f.push(files.buildNewFolderView(f.state)); + await f.submit(folderValues(`folder-${i}`)); + assert.equal(f.stack.length, 1, "folder submit must pop its temporary form"); + assert.equal(f.stack[0].id, "V_ROOT"); + f.push(files.buildNewFileView(f.state)); + await f.submit(fileValues(`file-${i}.txt`, `value-${i}`)); + assert.equal(f.stack.length, 1, "file submit must pop its temporary form"); + assert.equal(await readFile(path.join(f.root, `file-${i}.txt`), "utf8"), `value-${i}`); + } + for (let i = 0; i < 4; i++) { + f.push(await files.buildFileEditView(f.root, f.state, "file-0.txt")); + await f.submit(editValues(`revision-${i}`)); + assert.equal(f.stack.length, 1, "save must pop the editor instead of retaining a preview level"); + assert.equal(await readFile(path.join(f.root, "file-0.txt"), "utf8"), `revision-${i}`); + } + assert.equal(f.audit.filter((e) => e.event === "channel_file_created").length, 5); + assert.equal(f.audit.filter((e) => e.event === "channel_folder_created").length, 5); + assert.equal(f.audit.filter((e) => e.event === "channel_file_edited").length, 4); + assert.equal(f.authorizations.length, 14); + assert.ok(f.updates.every((u) => u.view_id === "V_ROOT")); +}); + +test("Cancel restores the unchanged parent for all forms and duplicate create never overwrites", async () => { + const f = await fixture(); + await writeFile(path.join(f.root, "existing.txt"), "keep"); + for (const form of [files.buildNewFolderView(f.state), files.buildNewFileView(f.state), await files.buildFileEditView(f.root, f.state, "existing.txt")]) { + const before = structuredClone(f.stack[0]); + f.push(form); f.cancel(); + assert.deepEqual(f.stack, [before]); + assert.deepEqual(await readdir(f.root), ["existing.txt"]); + assert.equal(await readFile(path.join(f.root, "existing.txt"), "utf8"), "keep"); + assert.equal(f.audit.length, 0); + } + f.push(files.buildNewFileView(f.state)); + await f.submit(fileValues("existing.txt", "overwrite")); + assert.equal(await readFile(path.join(f.root, "existing.txt"), "utf8"), "keep"); + assert.equal(f.audit.length, 0); + assert.equal(f.stack.length, 1); + assert.match(JSON.stringify(f.stack[0]), /already exists/i); +}); + +test("invalid owner/name and stale edit hash leave the form open without mutation", async () => { + const f = await fixture(); + f.push(files.buildNewFileView(f.state)); + await f.submit(fileValues("outside.txt"), "U_OTHER"); + assert.equal(f.stack.length, 2); + assert.equal(f.authorizations.length, 0); + assert.match(JSON.stringify(f.acknowledgements.at(-1)), /isn't yours/i); + await f.submit(fileValues("../escape.txt")); + assert.equal(f.stack.length, 2); + assert.deepEqual(await readdir(f.root), []); + f.cancel(); + await writeFile(path.join(f.root, "edit.txt"), "before"); + f.push(await files.buildFileEditView(f.root, f.state, "edit.txt")); + await writeFile(path.join(f.root, "edit.txt"), "concurrent"); + await f.submit(editValues("stale")); + assert.equal(f.stack.length, 2); + assert.equal(await readFile(path.join(f.root, "edit.txt"), "utf8"), "concurrent"); + assert.match(JSON.stringify(f.acknowledgements.at(-1)), /changed/i); + assert.equal(f.audit.length, 0); +}); + +test("post-ack membership/mode/update errors target the parent and do not create files", async () => { + for (const failure of ["membership", "mode", "update"]) { + const f = await fixture(); + if (failure === "membership") f.setMember(false); + if (failure === "mode") f.setEditable(false); + if (failure === "update") f.failUpdate(); + f.push(files.buildNewFolderView(f.state)); + await f.submit(folderValues("denied")); + assert.equal(f.stack.length, 1); + assert.equal(f.stack[0].callback_id, "file_error"); + assert.ok(f.updates.every((u) => u.view_id === "V_ROOT")); + assert.deepEqual(await readdir(f.root), []); + assert.equal(f.audit.length, 0); + assert.equal(f.acknowledgements.length, 1); + } +}); + +test("standalone file forms keep the existing update-in-place fallback", async () => { + const f = await fixture(); + f.stack[0] = { ...files.buildNewFileView(f.state), id: "V_ROOT" }; + await f.submit(fileValues("standalone.txt", "value")); + assert.equal(f.stack.length, 1); + assert.equal(f.stack[0].id, "V_ROOT"); + assert.equal(f.acknowledgements[0].response_action, "update"); + assert.equal(await readFile(path.join(f.root, "standalone.txt"), "utf8"), "value"); +}); + +test("inline edit rechecks current membership and mode before popping or saving", async () => { + for (const failure of ["membership", "mode"]) { + const f = await fixture(); + await writeFile(path.join(f.root, "edit.txt"), "unchanged"); + f.push(await files.buildFileEditView(f.root, f.state, "edit.txt")); + if (failure === "membership") f.setMember(false); + else f.setEditable(false); + await f.submit(editValues("denied")); + assert.equal(f.stack.length, 2); + assert.equal(f.acknowledgements[0].response_action, "errors"); + assert.equal(await readFile(path.join(f.root, "edit.txt"), "utf8"), "unchanged"); + assert.equal(f.audit.length, 0); + } +}); diff --git a/test/file-session-browser.test.js b/test/file-session-browser.test.js new file mode 100644 index 0000000..41538a5 --- /dev/null +++ b/test/file-session-browser.test.js @@ -0,0 +1,115 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import express from "express"; +import os from "node:os"; +import path from "node:path"; +import crypto from "node:crypto"; +import { mkdtemp, mkdir, readFile, rm, writeFile } from "node:fs/promises"; +import { ensureTestEnv } from "./helpers.js"; +import { createFileEditorGrantUrl, createFileEditorRouter, resetFileEditorStateForTests } from "../src/web/file-editor.js"; +import { createFileUploadGrantUrl, createFileUploadRouter, resetFileUploadStateForTests } from "../src/web/file-upload.js"; + +ensureTestEnv(); + +for (const kind of ["editor", "upload"]) { + test(`cross-site ${kind} link opens on the first click without weakening write protection`, { skip: !process.env.CG_BROWSER_MODULE }, async (t) => { + resetFileEditorStateForTests(); + resetFileUploadStateForTests(); + const root = await mkdtemp(path.join(os.tmpdir(), "cg-file-session-browser-")); + t.after(() => rm(root, { recursive: true, force: true })); + await mkdir(path.join(root, "docs")); + const original = "Original fixture\n"; + await writeFile(path.join(root, "notes.md"), original); + let allowed = true; + let checks = 0; + const audits = []; + const requests = []; + const authorize = async (grant) => { + checks++; + assert.equal(grant.ownerId, "U_BROWSER"); + assert.equal(grant.channelId, "C_BROWSER"); + if (!allowed) throw Error("Fixture access revoked"); + return { root }; + }; + const app = express(); + app.use((req, _res, next) => { + // Model TLS termination. Chromium accepts Secure cookies on loopback; never insert cookies + // through the automation API, so real SameSite and HttpOnly enforcement remain under test. + req.headers["x-forwarded-proto"] = "https"; + if (req.path.startsWith(`/file-${kind}/`)) requests.push({ + method: req.method, exchange: req.path.includes("/open/"), + cookie: Boolean(req.headers.cookie), site: req.headers["sec-fetch-site"], + }); + next(); + }); + const router = kind === "editor" ? createFileEditorRouter : createFileUploadRouter; + app.use(`/file-${kind}`, router({ authorize, audit: async (event) => audits.push(event) })); + let link; + app.get("/source", (_req, res) => res.type("html").send(`Open fixture`)); + const server = await new Promise((resolve) => { + const listening = app.listen(0, "0.0.0.0", () => resolve(listening)); + }); + t.after(() => new Promise((resolve) => { server.closeAllConnections(); server.close(resolve); })); + const port = server.address().port; + const baseUrl = `http://127.0.0.1:${port}`; + const sourceUrl = `http://localhost:${port}/source`; + const create = kind === "editor" ? createFileEditorGrantUrl : createFileUploadGrantUrl; + link = create({ baseUrl, channelId: "C_BROWSER", slug: "browser", ownerId: "U_BROWSER", + relative: kind === "editor" ? "notes.md" : "docs", + expectedHash: crypto.createHash("sha256").update(original).digest("hex") }); + const { chromium } = await import(process.env.CG_BROWSER_MODULE); + const browser = await chromium.launch({ headless: true, executablePath: process.env.CG_BROWSER_EXECUTABLE, args: ["--no-sandbox"] }); + t.after(() => browser.close()); + const context = await browser.newContext(); + const page = await context.newPage(); + await page.goto(sourceUrl); + const firstResponse = page.waitForResponse((response) => new URL(response.url()).pathname.startsWith(`/file-${kind}/`) && !new URL(response.url()).pathname.includes("/open/")); + await page.getByRole("link", { name: "Open fixture" }).click(); + assert.equal((await firstResponse).status(), 200, "the first cross-site redirect must serve the tool, without reload or manual cookies"); + const sessionUrl = page.url(); + assert.ok(requests.some((r) => r.exchange && r.site === "cross-site")); + assert.ok(requests.some((r) => !r.exchange && r.method === "GET" && r.cookie)); + const cookies = await context.cookies(); + assert.equal(cookies.length, 1); + assert.equal(cookies[0].sameSite, "Lax"); + assert.equal(cookies[0].httpOnly, true); + assert.equal(cookies[0].secure, true); + assert.equal(cookies[0].path, new URL(sessionUrl).pathname); + assert.equal(await page.evaluate(() => globalThis.document.cookie), "", "the session secret stays HttpOnly"); + const endpoint = `${new URL(sessionUrl).pathname}/api/${kind === "editor" ? "save" : "upload?path=forged.txt"}`; + const denied = await page.evaluate(async (url) => (await fetch(url, { + method: "POST", headers: { "Content-Type": "application/json", "X-CG-CSRF": "wrong" }, + body: JSON.stringify({ content: "forged" }), + })).status, endpoint); + assert.equal(denied, 403, "a valid session still requires the unguessable CSRF header"); + if (kind === "editor") { + await page.locator("#editor").fill("Saved fixture\n"); + const saved = page.waitForResponse((response) => response.request().method() === "POST"); + await page.locator("#save").click(); + assert.equal((await saved).status(), 200); + assert.equal(await readFile(path.join(root, "notes.md"), "utf8"), "Saved fixture\n"); + } else { + await page.locator("#files").setInputFiles({ name: "uploaded.txt", mimeType: "text/plain", buffer: Buffer.from("Uploaded fixture\n") }); + const uploaded = page.waitForResponse((response) => response.request().method() === "POST"); + await page.locator("#upload").click(); + assert.equal((await uploaded).status(), 200); + assert.equal(await readFile(path.join(root, "docs", "uploaded.txt"), "utf8"), "Uploaded fixture\n"); + } + assert.equal(audits.length, 1); + // A fresh cross-site top-level POST must NOT receive even a newly minted Lax cookie. + await page.goto(sourceUrl); + const csrfResponse = page.waitForResponse((response) => response.request().method() === "POST"); + await page.evaluate((action) => { + const form = globalThis.document.createElement("form"); form.method = "POST"; form.action = action; + globalThis.document.body.append(form); form.submit(); + }, `${baseUrl}${endpoint}`); + assert.equal((await csrfResponse).status(), 401); + await page.waitForURL((url) => url.pathname === new URL(`${baseUrl}${endpoint}`).pathname); + assert.ok(requests.some((r) => r.method === "POST" && r.site === "cross-site" && !r.cookie)); + assert.equal(audits.length, 1, "cross-site POST must not write"); + allowed = false; + assert.equal((await page.goto(sessionUrl)).status(), 403, "every page load rechecks current authority"); + assert.ok(checks >= 4); + assert.equal((await page.goto(link)).status(), 410, "the grant is still single-use"); + }); +} diff --git a/test/file-upload.test.js b/test/file-upload.test.js index 072a820..feda2c0 100644 --- a/test/file-upload.test.js +++ b/test/file-upload.test.js @@ -56,7 +56,7 @@ test("browser uploader exchanges a grant, preserves folder paths, and never over const cookie = exchange.headers.get("set-cookie").split(";")[0]; assert.match(uploadPath, /^\/file-upload\/[A-Za-z0-9_-]+$/); assert.match(exchange.headers.get("set-cookie"), /HttpOnly/); - assert.match(exchange.headers.get("set-cookie"), /SameSite=Strict/); + assert.match(exchange.headers.get("set-cookie"), /SameSite=Lax/); assert.match(exchange.headers.get("set-cookie"), /Secure/); assert.equal((await fetch(grantUrl, { redirect: "manual" })).status, 410); diff --git a/test/fixtures/claude b/test/fixtures/claude index f24dd48..9366157 100755 --- a/test/fixtures/claude +++ b/test/fixtures/claude @@ -109,6 +109,8 @@ fi # CLI's terminal line is an error subtype carrying no `result` text — the real shape behind the # "(empty response)" incidents. Exit 0, exactly like the CLI does. if [ "$answerless" = "yes" ]; then + case "$*" in *CLAUDE_STUB_ZERO_FAILURE*) printf '%s\n' '{"type":"result","subtype":"error_during_execution","is_error":true,"usage":{"input_tokens":0,"output_tokens":0}}'; exit 0 ;; esac + case "$*" in *CLAUDE_STUB_PARTIAL_NARRATION*) printf '%s\n' '{"type":"stream_event","event":{"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"I will verify the update next."}}}' ;; esac printf '%s\n' '{"type":"stream_event","event":{"type":"content_block_start","index":0,"content_block":{"type":"tool_use","id":"toolu_ans","name":"Bash","input":{}}}}' printf '%s\n' '{"type":"stream_event","event":{"type":"content_block_stop","index":0}}' printf '%s\n' '{"type":"result","subtype":"error_during_execution","is_error":true,"session_id":"stub-session","usage":{"input_tokens":900,"output_tokens":120},"total_cost_usd":0.02}' 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/folders-generator-paths.test.js b/test/folders-generator-paths.test.js index 2c33f55..a7646d2 100644 --- a/test/folders-generator-paths.test.js +++ b/test/folders-generator-paths.test.js @@ -204,11 +204,11 @@ test("the managed block carries the hard rules a run must never get wrong", () = assert.equal(splitGatewayBlock(block).found, true); assert.match(block, /Hard rules \(not optional\)/); - // 1. Composio identity + the privacy stop. Both identities are named, and an unnamed request - // that either could serve is answered with a question, not a "harmless" read (CO-04). + // 1. Unrestricted reads can use both identities; writes retain account selection. assert.match(block, /`composio-user`/); assert.match(block, /`composio-agent`/); - assert.match(block, /which account\?" — not a tool call/); + assert.match(block, /Reads and searches may use either or\s+both identities without asking which account/); + assert.match(block, /Writes require the intended identity and connected account/); // 2. A request phrased for the requester's OWN accounts is `composio-user` or nothing — the // agent's shared identity is never substituted for a missing personal one (CO-02: a @@ -217,7 +217,10 @@ test("the managed block carries the hard rules a run must never get wrong", () = assert.match(block, /"My X" is the requester's X/); assert.match(block, /served ONLY by `composio-user`/); assert.match(block, /do not read\n?\s*`composio-agent` to answer it/); - assert.match(block, /holds OTHER people's/); + assert.match(block, /may expose someone else's data/); + assert.doesNotMatch(block, /holds OTHER people's|never the requester's/); + assert.match(block, /Logical identities do not prove who owns/); + assert.match(block, /not necessarily shared across channels/); // …and the mirror direction: "your X" must not be answered from the requester's identity. assert.match(block, /"the agent's X" never\n?\s*touches `composio-user`/); diff --git a/test/help-text.test.js b/test/help-text.test.js index d59e271..9218140 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 …`", @@ -24,6 +24,7 @@ test("/help explains the gateway's essential user workflows", () => { "Slack transcript", "Generate transcript", "`/status`", + "`/menu`", "`/pending`", ]; @@ -56,3 +57,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 new file mode 100644 index 0000000..796092a --- /dev/null +++ b/test/inherited-file-commands.test.js @@ -0,0 +1,76 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { ensureTestEnv } from "./helpers.js"; +ensureTestEnv(); +const { useFakeRuntime } = await import("./runtime-fake.js"); +await useFakeRuntime(); +process.env.PATH = `${fileURLToPath(new URL("fixtures", import.meta.url))}${path.delimiter}${process.env.PATH || ""}`; +process.env.SESSION_KEEPALIVE = "0"; +const { saveSettings } = await import("../src/config/settings.js"); +saveSettings({ memoryReviewEvery: 0 }); +const { setUser } = await import("../src/config/store.js"); +const { processMessageEvent } = await import("../src/slack/message-pipeline.js"); +const { readEvents } = await import("../src/util/logger.js"); +const USER = "U_FILE_COMMAND"; +const BOT = "UFILECOMMANDBOT"; +// Oversize metadata deliberately avoids any network download if the pipeline wrongly runs. +const file = { id: "FCOMMAND", name: "owned.txt", size: Number.MAX_SAFE_INTEGER, url_private_download: "https://files.slack.test/owned" }; +let sequence = 0; +function fixture(text, variant, { canonicalText = text, lookupFails = false } = {}) { + const channel = `D_FILE_COMMAND_${++sequence}`; + const event = { type: "message", channel, channel_type: "im", user: USER, ts: "103.1", thread_ts: "100.1", text, + ...(variant === "trigger-current" ? { files: [file] } : {}) }; + const exact = { ...event, text: canonicalText, ...(variant === "canonical-current" ? { files: [file] } : {}) }; + const root = { ts: "100.1", user: USER, text: "subject", ...(variant === "root" ? { files: [file] } : {}) }; + const previous = { ts: "102.1", user: BOT, text: "prior reply", ...(variant === "previous" ? { files: [file] } : {}) }; + const posted = []; + const ok = async () => ({ ok: true }); + const client = { + posted, + chat: { postMessage: async m => { posted.push(m); return { ok: true, ts: `bot.${posted.length}` }; }, postEphemeral: async m => { posted.push(m); return { ok: true }; }, update: ok, delete: ok }, + conversations: { replies: async args => { if (lookupFails) throw new Error("read unavailable"); return { messages: args.oldest ? [exact] : [root, previous, exact] }; }, history: async () => ({ messages: [] }), info: async () => ({ channel: { id: channel } }), members: async () => ({ members: [USER], response_metadata: {} }) }, + users: { info: async () => ({ user: { real_name: "File command tester" } }), list: async () => ({ members: [], response_metadata: {} }) }, + apiCall: ok, + }; + return { client, event, channel }; +} +async function execute(text, variant, opts) { + await setUser(USER, { name: "File command tester", approved: true }); + const f = fixture(text, variant, opts); + await processMessageEvent(f.event, f.client, { botUserId: BOT }); + return { ...f, starts: readEvents({ limit: 1000 }).filter(e => e.event === "run_start" && e.channel === f.channel), text: f.client.posted.map(m => m.text || "").join("\n") }; +} +for (const variant of ["previous", "root", "none"]) { + test(`current controls bypass the engine with ${variant} historical attachments`, async () => { + for (const [command, expected] of [["/menu", /Channel menu/], ["/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, /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, /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, /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"]) { + const result = await execute(prompt, "previous"); + assert.equal(result.starts.length, 1); + assert.equal(result.starts[0].files, 1); + } +}); 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..60d0ae1 100644 --- a/test/mcp-control-plane-approval.test.js +++ b/test/mcp-control-plane-approval.test.js @@ -4,13 +4,13 @@ // 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"; 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"), @@ -83,6 +87,53 @@ async function withGateway(options, fn) { } const resultText = (r) => r.content?.map((i) => i.text || "").join("\n") || ""; +const approvalReceipt = /Human approval was received before this tool executed/; + +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.doesNotMatch(resultText(result), approvalReceipt); + }); + 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 }); @@ -97,6 +148,7 @@ test("a denied approval blocks the change and carries the human-readable card", await withGateway({}, async (client) => { const result = await client.callTool({ name: "set_channel_bash", arguments: { enabled: true } }); assert.match(resultText(result), /not approved/i); + assert.doesNotMatch(resultText(result), approvalReceipt); assert.match(resultText(result), /Denied by <@U_CTRL_ADMIN>/); }); assert.equal((await getChannelMeta(SLUG))?.allowBash, false, "the denied change must not persist"); @@ -117,6 +169,7 @@ test("an approved click lets the control-plane change through", async () => { await withGateway({}, async (client) => { const result = await client.callTool({ name: "set_channel_bash", arguments: { enabled: true } }); assert.match(resultText(result), /Bash \+ file edits ON/i); + assert.match(resultText(result), approvalReceipt); }); assert.equal((await getChannelMeta(SLUG))?.allowBash, true); assert.equal(approvalRequests.length, 1); @@ -145,6 +198,7 @@ test("token tools are gated and the approval card never carries the token value" await withGateway({ author: "U_CTRL_MEMBER" }, async (client) => { const result = await client.callTool({ name: "set_my_composio_token", arguments: { token: "sk-super-secret-value" } }); assert.match(resultText(result), /not approved/i); + assert.doesNotMatch(resultText(result), approvalReceipt); }); assert.equal(approvalRequests.length, 1); assert.doesNotMatch(JSON.stringify(approvalRequests[0].body), /sk-super-secret-value/); @@ -155,6 +209,7 @@ test("an unreachable approval endpoint fails closed", async () => { await withGateway({ port: 1 }, async (client) => { const result = await client.callTool({ name: "set_channel_admin_mode", arguments: { enabled: true } }); assert.match(resultText(result), /not approved/i); + assert.doesNotMatch(resultText(result), approvalReceipt); }); assert.equal((await getChannelMeta(SLUG))?.adminMode, false); }); @@ -170,6 +225,7 @@ test("update_gateway skips the extra card in Admin/Auto mode but stays admin-onl await withGateway({ engine: "claude" }, async (client) => { const result = await client.callTool({ name: "update_gateway", arguments: {} }); assert.match(resultText(result), /not approved/i); + assert.doesNotMatch(resultText(result), approvalReceipt); }); assert.equal(approvalRequests.length, 1, "read mode keeps the explicit approval gate"); @@ -178,6 +234,7 @@ test("update_gateway skips the extra card in Admin/Auto mode but stays admin-onl await withGateway({ engine: "claude" }, async (client) => { const result = await client.callTool({ name: "update_gateway", arguments: {} }); assert.match(resultText(result), /already active/i); + assert.doesNotMatch(resultText(result), approvalReceipt); }); assert.equal(approvalRequests.length, 0, "Admin mode starts without the extra card"); @@ -186,6 +243,7 @@ test("update_gateway skips the extra card in Admin/Auto mode but stays admin-onl await withGateway({ engine: "codex" }, async (client) => { const result = await client.callTool({ name: "update_gateway", arguments: {} }); assert.match(resultText(result), /already active/i); + assert.doesNotMatch(resultText(result), approvalReceipt); }); assert.equal(approvalRequests.length, 0, "Auto mode starts without the extra card"); @@ -201,6 +259,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 }; @@ -245,6 +320,7 @@ test("read-only tools and memory writes in default or custom workdirs never hit await client.callTool({ name: "get_channel_workdir", arguments: {} }); const result = await client.callTool({ name: "update_channel_memory", arguments: { action: "add", text: "- default-workdir fact" } }); assert.match(resultText(result), /Memory updated/i); + assert.doesNotMatch(resultText(result), approvalReceipt); assert.match(resultText(result), new RegExp(DEFAULT_WORKDIR.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"))); assert.doesNotMatch(resultText(result), new RegExp(ISOLATED_HOME.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"))); }); @@ -253,6 +329,7 @@ test("read-only tools and memory writes in default or custom workdirs never hit await withGateway({}, async (client) => { const result = await client.callTool({ name: "update_channel_memory", arguments: { action: "add", text: "- custom-workdir fact" } }); assert.match(resultText(result), /Memory updated/i); + assert.doesNotMatch(resultText(result), approvalReceipt); assert.match(resultText(result), new RegExp(CUSTOM_WORKDIR.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"))); }); } finally { @@ -326,3 +403,181 @@ test("every registered gateway tool is consciously classified as gated or open ( } }); }); + +const creationVariants = [ + { key: "default", args: {}, visibility: "org", channelGrant: true, details: /shared library.*grant it in this channel/i }, + { key: "shared-grant", args: { personal: false, scope: "library", grant_here: true }, visibility: "org", channelGrant: true, details: /shared library.*grant it in this channel/i }, + { key: "shared-ungranted", args: { personal: false, scope: "library", grant_here: false }, visibility: "org", channelGrant: false, details: /shared library.*without.*grant/i }, + { key: "personal-grant", args: { personal: true, scope: "library", grant_here: true }, visibility: "personal", channelGrant: false, details: /personal skill.*your own runs/i }, + { key: "personal-ungranted-flag", args: { personal: true, scope: "library", grant_here: false }, visibility: "personal", channelGrant: false, details: /personal skill.*your own runs/i }, + { key: "channel-grant", args: { personal: false, scope: "channel", grant_here: true }, visibility: "org", channelGrant: false, channelScope: true, details: /this channel.s section.*automatically/i }, + { key: "channel-ungranted-flag", args: { personal: false, scope: "channel", grant_here: false }, visibility: "org", channelGrant: false, channelScope: true, details: /this channel.s section.*automatically/i }, +]; +const creationFiles = slug => [{ path: "SKILL.md", content: `---\nname: ${slug}\ndescription: Harmless approval preview fixture.\n---\n\nReturn the fixture name.\n` }]; + +test("both engine approval previews describe actual creation visibility and grants before denial", async () => { + const { getSkill } = await import("../src/gateway/skills/catalog.js"); + for (const engine of ["claude", "codex"]) { + await withGateway({ engine }, async client => { + for (const variant of creationVariants) { + approvalRequests.length = 0; + approvalResponse = { allow: false, reason: "preview only" }; + const slug = `preview-${engine}-${variant.key}`; + const args = { slug, files: creationFiles(slug), ...variant.args }; + const result = await client.callTool({ name: "create_skill", arguments: args }); + assert.match(resultText(result), /not approved/); + assert.equal(approvalRequests.length, 1); + const preview = approvalRequests[0].body.toolInput.details; + assert.match(preview, variant.details, variant.key); + assert.match(preview, new RegExp(slug)); + assert.match(preview, /1 file/); + if (variant.visibility === "personal") assert.doesNotMatch(preview, /shared catalog|grant it here|grant it in this channel/); + assert.equal(getSkill(slug), null, "a preview denial creates no skill"); + } + }); + } +}); + +test("approved creation matches preview semantics and invalid personal channel scope stays rejected", async () => { + const { getSkill } = await import("../src/gateway/skills/catalog.js"); + const { getUser } = await import("../src/config/store.js"); + for (const engine of ["claude", "codex"]) { + await withGateway({ engine }, async client => { + for (const variant of creationVariants) { + approvalRequests.length = 0; + approvalResponse = { allow: true }; + const slug = `created-${engine}-${variant.key}`; + const result = await client.callTool({ name: "create_skill", arguments: { slug, files: creationFiles(slug), ...variant.args } }); + assert.match(resultText(result), /Created/); + assert.match(approvalRequests[0].body.toolInput.details, variant.details); + const actual = getSkill(slug); + assert.equal(actual.visibility, variant.visibility); + assert.equal(actual.channelScope || "", variant.channelScope ? CHANNEL : ""); + assert.equal((await getUser("U_CTRL_ADMIN")).skills?.includes(slug) || false, variant.visibility === "personal"); + assert.equal((await getChannelMeta(SLUG)).skills?.includes(slug) || false, variant.channelGrant); + if (variant.visibility === "personal") assert.match(resultText(result), /granted to your own runs/); + if (variant.channelScope) assert.match(resultText(result), /granted here automatically/); + } + const invalid = `invalid-personal-channel-${engine}`; + approvalRequests.length = 0; + const result = await client.callTool({ name: "create_skill", arguments: { slug: invalid, files: creationFiles(invalid), personal: true, scope: "channel", grant_here: false } }); + assert.match(approvalRequests[0].body.toolInput.details, /personal.*cannot.*channel/i); + assert.match(resultText(result), /personal skill cannot be scoped to a channel/); + assert.equal(getSkill(invalid), null); + }); + } +}); + + +test("Auto mode skill receipts attest an explicit human decision in both engine contexts", async () => { + const { getSkill } = await import("../src/gateway/skills/catalog.js"); + const original = await getChannelMeta(SLUG); + await saveChannelMeta(SLUG, { ...original, autoMode: true }); + try { + for (const engine of ["claude", "codex"]) { + approvalRequests.length = 0; + await withGateway({ engine, author: "U_CTRL_MEMBER" }, async client => { + const slug = `explicit-human-${engine}`; + const request = { name: "create_skill", arguments: { slug, files: creationFiles(slug), personal: true } }; + approvalResponse = { allow: false, reason: "denied by the human" }; + const denied = await client.callTool(request); + assert.match(resultText(denied), /not approved/); + assert.doesNotMatch(resultText(denied), approvalReceipt); + assert.equal(getSkill(slug), null, "Auto cannot create the denied skill"); + + approvalResponse = { allow: true }; + const approved = await client.callTool(request); + assert.match(resultText(approved), /Created/); + assert.match(resultText(approved), /This receipt records an explicit human decision; channel Auto mode did not supply it\./); + assert.deepEqual(approvalRequests.map(r => r.body.approvalType), ["agent", "agent"]); + assert.equal(getSkill(slug).createdBy, "U_CTRL_MEMBER"); + }); + } + } finally { + await saveChannelMeta(SLUG, original); + } +}); + +test("both engine contexts receive an approval receipt for actual skill creation, update and deletion", async () => { + const { getSkill } = await import("../src/gateway/skills/catalog.js"); + for (const engine of ["claude", "codex"]) { + approvalRequests.length = 0; + approvalResponse = { allow: true, reason: "private decision context must not be repeated", decidedBy: "PRIVATE_ACTOR" }; + await withGateway({ engine, author: "U_CTRL_MEMBER" }, async client => { + const slug = `approval-receipt-${engine}`; + for (const [name, args, expected] of [ + ["create_skill", { slug, files: creationFiles(slug), personal: true }, /Created/], + ["update_skill", { skill: slug, files: [{ path: "references/marker.md", content: "approved update fixture" }] }, /now revision 2/], + ["delete_skill", { skill: slug }, /Removed/], + ]) { + const result = await client.callTool({ name, arguments: args }); + assert.match(resultText(result), expected); + assert.match(resultText(result), approvalReceipt); + assert.match(resultText(result), /does not mean approval was bypassed/); + assert.doesNotMatch(resultText(result), /private decision context|PRIVATE_ACTOR/); + assert.notEqual(result.isError, true); + } + assert.equal(getSkill(slug).deleted, true); + assert.deepEqual(approvalRequests.map(r => [r.body.toolName, r.body.approvalType, r.body.requiredTier]), [ + ["create_skill", "agent", ""], ["update_skill", "agent", ""], ["delete_skill", "agent", ""], + ]); + const rejected = await client.callTool({ name: "delete_skill", arguments: { skill: slug } }); + assert.match(resultText(rejected), /No catalog skill/); + assert.match(resultText(rejected), approvalReceipt, "approval is distinct from the handler's refusal"); + assert.equal(rejected.content[0].text, `No catalog skill named "${slug}".`); + approvalRequests.length = 0; + const open = await client.callTool({ name: "remove_my_skills", arguments: { slugs: [slug] } }); + assert.doesNotMatch(resultText(open), approvalReceipt); + assert.equal(approvalRequests.length, 0, "own grant cleanup remains ungated"); + }); + } +}); + +test("a durable instruction approval never reports a live handler as approved even if IPC returns allow", async () => { + approvalResponse = { allow: true }; + const file = path.join(DEFAULT_WORKDIR, "CLAUDE.md"); + const before = existsSync(file) ? readFileSync(file, "utf8") : null; + await withGateway({}, async client => { + const result = await client.callTool({ name: "update_channel_instructions", arguments: { text: "- Must stay unapplied." } }); + assert.match(resultText(result), /Couldn't save the instruction approval/); + assert.doesNotMatch(resultText(result), approvalReceipt); + }); + assert.equal(existsSync(file) ? readFileSync(file, "utf8") : null, before); +}); + +test("the approved-result wrapper preserves mixed content, structured data, metadata and error status", async () => { + const { InMemoryTransport } = await import("@modelcontextprotocol/sdk/inMemory.js"); + const { createGatewayMcpServer, ctxFromClaims } = await import("../src/mcp/gateway-server.js"); + const ctx = ctxFromClaims({ channelId: CHANNEL, slug: SLUG, authorId: "U_CTRL_ADMIN", threadKey: "receipt-contract", principalTrusted: true }, { + toolset: "memory-review", daemon: { available: () => true, call: async () => ({ allow: true }) }, + }); + const server = createGatewayMcpServer(ctx); + const original = Object.freeze({ + isError: true, + content: Object.freeze([ + Object.freeze({ type: "text", text: "The approved operation failed; no change was made.", annotations: { audience: ["user"] } }), + Object.freeze({ type: "image", data: "aGVsbG8=", mimeType: "image/png" }), + ]), + structuredContent: Object.freeze({ changed: false, diagnostic: "fixture failure" }), + _meta: Object.freeze({ trace: "receipt-test" }), + }); + server.registerTool("set_channel_bash", {}, async () => original); + const client = new Client({ name: "receipt-contract", version: "1" }, { capabilities: {} }); + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + try { + await server.connect(serverTransport); + await client.connect(clientTransport); + const result = await client.callTool({ name: "set_channel_bash", arguments: {} }); + assert.equal(result.isError, true); + assert.deepEqual(result.content.slice(0, 2), original.content); + assert.deepEqual(result.structuredContent, original.structuredContent); + assert.deepEqual(result._meta, original._meta); + assert.equal(result.content.length, 3); + assert.match(result.content[2].text, approvalReceipt); + assert.match(result.content[2].text, /does not establish whether the requested change succeeded/); + assert.equal(original.content.length, 2, "the handler result is not mutated"); + } finally { + await client.close(); + await server.close(); + } +}); 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); +}); diff --git a/test/message-to-reply-e2e.test.js b/test/message-to-reply-e2e.test.js index ec7d8b5..4fb06a3 100644 --- a/test/message-to-reply-e2e.test.js +++ b/test/message-to-reply-e2e.test.js @@ -193,7 +193,7 @@ test("a Claude thread inside a Codex-default channel still receives the relayed test("ambiguous hard kills and explicit Stop never auto-continue", () => { for (const details of [ - { exitCode: 137 }, { signal: "SIGKILL" }, { explicitStop: true }, + { exitCode: 137 }, { signal: "SIGKILL" }, { explicitStop: true }, { incompleteTurn: true }, ]) { assert.equal(runDeathRecovery({ message: "Claude session ended", details: { engine: "claude", processEnded: true, ...details } }), null); assert.equal(runDeathRecovery({ message: "session is dead", details }), null); diff --git a/test/operations-readiness.test.js b/test/operations-readiness.test.js index 85bf1fd..1cff6dd 100644 --- a/test/operations-readiness.test.js +++ b/test/operations-readiness.test.js @@ -10,6 +10,19 @@ import { tempDir } from "./helpers.js"; const root = path.resolve(import.meta.dirname, ".."); +// The canonical selectors take precedence over legacy aliases. These destructive operator +// scripts must always receive fixture paths, including when tests run inside a live updater. +function runtimeEnv(runtime, dbFile = path.join(runtime, "gateway.db"), inherited = process.env) { + return { + ...inherited, + CHANNELGATE_DIR: runtime, + CLAUDE_GATEWAY_DIR: runtime, + CHANNELGATE_DB: dbFile, + CLAUDE_GATEWAY_DB: dbFile, + CG_WORKSPACE_DIR: path.join(runtime, "workspace"), + }; +} + test("central log redaction removes credential-shaped values", () => { const text = redactLogValue("xoxb-123-secret Bearer abc.def.ghi? token=oops https://x/?secret=bad"); assert.doesNotMatch(text, /xoxb-123-secret|abc\.def\.ghi|secret=bad/); @@ -23,9 +36,7 @@ test("encrypted backup includes a verified SQLite snapshot and restore drill pas const db = new DatabaseSync(path.join(runtime, "gateway.db")); db.exec("CREATE TABLE proof(value TEXT); INSERT INTO proof VALUES ('durable')"); db.close(); - // CLAUDE_GATEWAY_DB is emptied ("" counts as unset via ${...:-}): the scripts honor it now, - // and the test-harness scratch override in process.env must not leak into the scripted root. - const env = { ...process.env, CLAUDE_GATEWAY_DIR: runtime, CLAUDE_GATEWAY_DB: "", CG_BACKUP_PASSPHRASE: "test-only-passphrase" }; + const env = { ...runtimeEnv(runtime), CG_BACKUP_PASSPHRASE: "test-only-passphrase" }; execFileSync("bash", [path.join(root, "scripts/backup-config.sh")], { env, stdio: "pipe" }); assert.ok(existsSync(path.join(runtime, "backups", "config.tar.gz.enc"))); execFileSync("bash", [path.join(root, "scripts/restore-drill.sh")], { env, stdio: "pipe" }); @@ -42,9 +53,7 @@ test("restore over an existing runtime removes the discarded database's WAL/SHM const db = new DatabaseSync(path.join(runtime, "gateway.db")); db.exec("CREATE TABLE proof(value TEXT); INSERT INTO proof VALUES ('durable')"); db.close(); - // CLAUDE_GATEWAY_DB is emptied ("" counts as unset via ${...:-}): the scripts honor it now, - // and the test-harness scratch override in process.env must not leak into the scripted root. - const env = { ...process.env, CLAUDE_GATEWAY_DIR: runtime, CLAUDE_GATEWAY_DB: "", CG_BACKUP_PASSPHRASE: "test-only-passphrase" }; + const env = { ...runtimeEnv(runtime), CG_BACKUP_PASSPHRASE: "test-only-passphrase" }; execFileSync("bash", [path.join(root, "scripts/backup-config.sh")], { env, stdio: "pipe" }); // Simulate the post-shutdown state of a DIFFERENT, newer database being discarded. writeFileSync(path.join(runtime, "gateway.db-wal"), "stale-wal-from-discarded-db"); @@ -75,9 +84,9 @@ test("backup and restore honor the CLAUDE_GATEWAY_DB override", () => { db.exec("CREATE TABLE proof(value TEXT); INSERT INTO proof VALUES ('override')"); db.close(); const env = { - ...process.env, - CLAUDE_GATEWAY_DIR: runtime, - CLAUDE_GATEWAY_DB: dbFile, + ...runtimeEnv(runtime, dbFile), + // Exercise the legacy DB override deliberately; never inherit a canonical production path. + CHANNELGATE_DB: "", CG_BACKUP_PASSPHRASE: "test-only-passphrase", }; execFileSync("bash", [path.join(root, "scripts/backup-config.sh")], { env, stdio: "pipe" }); @@ -87,9 +96,8 @@ test("backup and restore honor the CLAUDE_GATEWAY_DB override", () => { mkdirSync(path.dirname(targetDb), { recursive: true }); execFileSync("bash", [path.join(root, "scripts/restore-config.sh")], { env: { - ...env, - CLAUDE_GATEWAY_DIR: target, - CLAUDE_GATEWAY_DB: targetDb, + ...runtimeEnv(target, targetDb, env), + CHANNELGATE_DB: "", CG_BACKUP_FILE: path.join(runtime, "backups", "config.tar.gz.enc"), CG_RESTORE_CONFIRM: "YES", }, @@ -108,7 +116,7 @@ test("log rotation copy-truncates the live file instead of renaming its inode", writeFileSync(log, "x".repeat(1024 * 1024 + 1)); // just over the 1 MiB floor const inodeBefore = statSync(log).ino; execFileSync(process.execPath, [path.join(root, "scripts/runtime-maintenance.mjs")], { - env: { ...process.env, CLAUDE_GATEWAY_DIR: runtime, CG_MAX_LOG_BYTES: "1048576" }, + env: { ...runtimeEnv(runtime), CG_MAX_LOG_BYTES: "1048576" }, stdio: "pipe", }); // Same inode, now empty: systemd keeps the daemon's fd open, so a rename would let the diff --git a/test/platform-approvals.test.js b/test/platform-approvals.test.js new file mode 100644 index 0000000..7eb381c --- /dev/null +++ b/test/platform-approvals.test.js @@ -0,0 +1,77 @@ +import test, { after } from "node:test"; +import assert from "node:assert/strict"; +import { ensureTestEnv } from "./helpers.js"; +ensureTestEnv(); +const { setUser, saveChannelMeta } = await import("../src/config/store.js"); +const { registerTransport, unregisterTransport } = await import("../src/platforms/live.js"); +const { requestApproval, lookupApproval, handlePlatformApproval, setDurableApprovalExecutor } = await import("../src/slack/approvals.js"); +const CHANNEL = "teams:19:approval@thread.v2", SLUG = "teams-approval-test", AUTHOR = "29:author", ADMIN = "29:admin"; +await setUser(AUTHOR, { approved: true }); +await setUser(ADMIN, { approved: true, isAdmin: true }); +await saveChannelMeta(SLUG, { channelId: CHANNEL, platform: "msteams", access: "approved", autoMode: false, adminMode: false }); +const cards = [], updates = []; +const connector = { platform: "msteams", ready: () => true, + postCard: async payload => { cards.push(payload); return { messageId: String(cards.length), conversationId: CHANNEL }; }, + updateCard: async payload => updates.push(payload), + openDm: async () => "", post: async () => { throw new Error("No public bearer links"); }, +}; +registerTransport("msteams", { getConnector: () => connector }); +after(() => unregisterTransport("msteams")); +async function begin(extra = {}) { + const pending = requestApproval(null, { channelId: CHANNEL, slug: SLUG, authorId: AUTHOR, threadKey: "group:1", toolName: "Review plan", toolInput: { details: "Exact action preview" }, approvalType: "agent", ...extra }); + await new Promise(resolve => setImmediate(resolve)); + const latest = cards.at(-1); + return { pending, id: latest.card.actions[0].data.id, conversationId: CHANNEL, messageId: String(cards.length), actorId: AUTHOR, decision: "approve" }; +} +test("native Teams card resolves only its exact conversation/message and authorized actor", async () => { + const request = await begin(); + assert.equal(cards.at(-1).card.type, "AdaptiveCard"); + for (const invalid of [{ conversationId: "teams:19:other@thread.v2" }, { messageId: "forged" }, { actorId: "29:stranger" }, { scope: "forever" }]) { + assert.equal((await handlePlatformApproval({ ...request, ...invalid })).ok, false); + assert.ok(lookupApproval(request.id).entry); + } + const [first, second] = await Promise.all([handlePlatformApproval(request), handlePlatformApproval(request)]); + assert.deepEqual([first.ok, second.ok].sort(), [false, true]); + assert.equal((await request.pending).allow, true); + assert.equal(updates.at(-1).messageId, request.messageId); + assert.equal((await handlePlatformApproval(request)).code, 409); +}); +test("revoked requester cannot resolve and native admin tier is enforced", async () => { + const request = await begin({ requiredTier: "admin" }); + assert.equal((await handlePlatformApproval(request)).code, 403); + await setUser(AUTHOR, { approved: false }); + const { canResolveApproval } = await import("../src/slack/approvals.js"); + assert.equal((await canResolveApproval(lookupApproval(request.id).entry, AUTHOR)).allowed, false); + assert.equal((await handlePlatformApproval({ ...request, decision: "deny" })).code, 403); + assert.equal((await handlePlatformApproval({ ...request, actorId: ADMIN, decision: "deny", comment: "Revise it" })).ok, true); + assert.equal((await request.pending).comment, "Revise it"); + await setUser(AUTHOR, { approved: true }); +}); +test("durable Teams approval uses shared CAS and executes once", async () => { + let executions = 0; + setDurableApprovalExecutor(async () => { executions++; return { ok: true, completed: true, message: "Applied" }; }); + const request = await begin({ durableAction: { kind: "background_shell", channelId: CHANNEL, slug: SLUG, authorId: AUTHOR, threadKey: "group:durable", command: "echo fixture" } }); + assert.equal((await request.pending).pending, true); + const responses = await Promise.all([handlePlatformApproval(request), handlePlatformApproval(request)]); + assert.equal(responses.filter(result => result.ok).length, 1); + assert.equal(executions, 1); + setDurableApprovalExecutor(null); +}); +test("unavailable native cards never put private approval links in the shared chat", async () => { + const { saveSettings } = await import("../src/config/settings.js"); + saveSettings({ publicUrl: "https://gateway.example.test", approvalLinks: "auto" }); + const original = { postCard: connector.postCard, post: connector.post, openDm: connector.openDm }; + const posts = []; + connector.postCard = async () => { throw new Error("Card unavailable"); }; + connector.post = async payload => { posts.push(payload); return { messageId: "fallback-notice", conversationId: payload.conversationId }; }; + connector.openDm = async () => ""; + try { + const outcome = await requestApproval(null, { channelId: CHANNEL, slug: SLUG, authorId: AUTHOR, + threadKey: "group:fallback", toolName: "Fallback plan", approvalType: "agent", toolInput: { details: "Exact plan" } }); + assert.equal(outcome.allow, false); + assert.match(outcome.reason, /private decision links could not be delivered/); + assert.equal(posts.length, 1); + assert.equal(posts[0].conversationId, CHANNEL); + assert.equal(posts[0].text.includes("/approve/"), false); + } finally { Object.assign(connector, original); saveSettings({ publicUrl: "", approvalLinks: "auto" }); } +}); diff --git a/test/platform-attachments.test.js b/test/platform-attachments.test.js new file mode 100644 index 0000000..7ca6c42 --- /dev/null +++ b/test/platform-attachments.test.js @@ -0,0 +1,32 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import path from 'node:path'; +import { readFile } from 'node:fs/promises'; +import { ensureTestEnv } from './helpers.js'; +ensureTestEnv(); +const { saveInboundAttachments } = await import('../src/platforms/attachments.js'); + +test('simultaneous flat group messages with the same upload name keep disjoint stable bytes', async () => { + let downloads = 0; + let release; + const bothStarted = new Promise((resolve) => { release = resolve; }); + const makeMessage = (id, bytes) => ({ + platform: 'msteams', conversationId: 'teams:19:upload-fixture', kind: 'group', threadKey: '', messageId: id, + attachments: [{ name: 'audio.wav', contentType: 'audio/wav', download: async () => { + if (++downloads === 2) release(); + await bothStarted; + return Buffer.from(bytes); + } }], + }); + const first = makeMessage('message-one', 'FIRST AUDIO'); + const second = makeMessage('message-two', 'SECOND AUDIO'); + const options = { slug: 'parallel-upload-fixture', meta: { platform: 'msteams' } }; + const [a, b] = await Promise.all([saveInboundAttachments(first, options), saveInboundAttachments(second, options)]); + assert.deepEqual(a.skipped, []); assert.deepEqual(b.skipped, []); + assert.notEqual(a.paths[0], b.paths[0]); assert.notEqual(path.dirname(a.paths[0]), path.dirname(b.paths[0])); + assert.equal(path.basename(a.paths[0]), '1-audio.wav'); assert.equal(path.basename(b.paths[0]), '1-audio.wav'); + assert.equal(await readFile(a.paths[0], 'utf8'), 'FIRST AUDIO'); assert.equal(await readFile(b.paths[0], 'utf8'), 'SECOND AUDIO'); + assert.equal(first.threadKey, ''); assert.equal(second.threadKey, '', 'storage allocation does not create a native reply thread'); + const revision = await saveInboundAttachments({ ...first, attachments: [{ name: 'audio.wav', download: async () => Buffer.from('EDITED AUDIO') }] }, options); + assert.notEqual(revision.paths[0], a.paths[0]); assert.equal(await readFile(a.paths[0], 'utf8'), 'FIRST AUDIO'); +}); 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-ingest.test.js b/test/platform-ingest.test.js index ff23d59..d6db169 100644 --- a/test/platform-ingest.test.js +++ b/test/platform-ingest.test.js @@ -184,3 +184,98 @@ test("ensureConversation is idempotent and keeps the platform stamp", async () = assert.equal(first.entry.slug, second.entry.slug); assert.equal(second.meta.platform, "googlechat"); }); + +// Exercise the real capability signer inside the fake engine boundary: the previous fake run +// accepted an empty thread key and hid the failure every flat Teams chat hit before spawning. +for (const platform of ["msteams", "googlechat"]) { + for (const kind of ["dm"]) { + test(`${platform} ${kind} uses a stable signed session identity while replies stay flat`, async () => { + const { mintGatewayCapability, verifyGatewayCapability } = await import("../src/gateway/mcp-capability.js"); + const { resolveSession } = await import("../src/gateway/sessions.js"); + const authorId = `${platform}-${kind}-tester`; + await setUser(authorId, { name: "Flat chat tester", approved: true }); + const connector = fakeConnector({ platform }); + const sessions = []; + const ingest = createIngest({ connector, log: { info() {}, warn() {} }, run: async (args) => { + const entry = await getChannelEntry(args.channelId); + const token = mintGatewayCapability({ ...args, slug: entry.slug, secret: "fixture-signing-secret", engine: "claude" }); + const verified = verifyGatewayCapability(token, { secret: "fixture-signing-secret" }); + assert.equal(verified.ok, true); + assert.equal(verified.claims.threadKey, args.channelId); + sessions.push((await resolveSession(entry.slug, args.threadKey, "claude")).sessionId); + return { content: "FLAT_OK", engine: "claude" }; + } }); + const message = makeInbound({ platform, kind, conversationId: `flat-${platform}-${kind}-a`, userId: authorId, text: "hello", mentionsBot: kind !== "dm" }); + assert.equal((await ingest(message)).result?.content, "FLAT_OK"); + assert.equal((await ingest({ ...message, messageId: "next-message" })).result?.content, "FLAT_OK"); + await ingest(makeInbound({ ...message, conversationId: `flat-${platform}-${kind}-b` })); + assert.equal(sessions[0], sessions[1], "successive messages resume the same session"); + assert.notEqual(sessions[0], sessions[2], "another conversation has its own session"); + assert.equal(message.threadKey, "", "the native thread handle stays empty"); + assert.ok(connector.posted.every(p => p.threadKey === "")); + assert.ok(connector.edited.every(p => (p.threadKey || "") === "")); + }); + } +} + +for (const engine of ["claude", "codex"]) { + test(`group chat new messages split sessions and quotes of user/bot replies resume (${engine})`, async () => { + const { mintGatewayCapability } = await import("../src/gateway/mcp-capability.js"); + const { resolveSession } = await import("../src/gateway/sessions.js"); + const { normalizeActivity } = await import("../src/platforms/msteams/activity.js"); + const { sessionKeyForMessage } = await import("../src/platforms/reply-sessions.js"); + const authorId = `group-${engine}-tester`; + await setUser(authorId, { name: "Quote tester", approved: true }); + const connector = fakeConnector({ platform: "msteams" }); + const sessions = []; + const run = async (args) => { + const entry = await getChannelEntry(args.channelId); + mintGatewayCapability({ ...args, slug: entry.slug, secret: "fixture-signing-secret", engine }); + sessions.push((await resolveSession(entry.slug, args.threadKey, engine)).sessionId); + return { content: "QUOTE_OK", engine }; + }; + const message = (id, quoted = "", conversationId = `19:quotes-${engine}@thread.v2`) => normalizeActivity({ + type: "message", id, from: { id: authorId }, + conversation: { id: conversationId, conversationType: "groupChat" }, text: "Bot hello", + entities: [{ type: "mention", mentioned: { id: "28:bot" } }, + ...(quoted ? [{ type: "quotedReply", quotedReply: { messageId: quoted } }] : [])], + }, { botId: "28:bot" }); + let ingest = createIngest({ connector, run }); + await ingest(message("100")); // first topic, bot placeholder m1 + await ingest(message("200")); // unrelated new topic + ingest = createIngest({ connector, run }); // no in-memory routing dependency + await ingest(message("300", "100")); // quote the original user message + await ingest(message("400", "m1")); // quote the bot's answer + await ingest(message("500", "300")); // quote a follow-up + await ingest(message("600", "100", `19:other-${engine}@thread.v2`)); + assert.equal(sessions.length, 6); + assert.notEqual(sessions[0], sessions[1]); + assert.equal(sessions[0], sessions[2]); + assert.equal(sessions[0], sessions[3]); + assert.equal(sessions[0], sessions[4]); + assert.notEqual(sessions[0], sessions[5]); + assert.equal(sessionKeyForMessage(message("100")), "group:100", "redelivery keeps its root"); + assert.ok(connector.posted.every(p => p.threadKey === ""), "session roots never become native reply addresses"); + assert.ok(connector.edited.every(p => !p.threadKey)); + const count = sessions.length; + await ingest({ ...message("700", "m1"), mentionsBot: false }); + assert.equal(sessions.length, count, "quoting never bypasses mention gating"); + }); +} + +test("a visible fallback chunk keeps its quote mapping even if the next chunk fails", async () => { + const { sessionKeyForMessage } = await import("../src/platforms/reply-sessions.js"); + await setUser("partial-quote-user", { approved: true }); + const connector = fakeConnector({ platform: "msteams" }); + let posts = 0; + connector.post = async () => { + posts += 1; + if (posts === 1) throw new Error("placeholder unavailable"); + if (posts === 3) throw new Error("second chunk unavailable"); + return { messageId: "first-visible-chunk" }; + }; + const message = makeInbound({ platform: "msteams", kind: "group", conversationId: "19:partial-quote@thread.v2", userId: "partial-quote-user", messageId: "original-message", mentionsBot: true, text: "long answer" }); + const ingest = createIngest({ connector, run: async () => ({ content: "word\n".repeat(3000), engine: "claude" }), log: { warn() {} } }); + await assert.rejects(ingest(message), /second chunk unavailable/); + assert.equal(sessionKeyForMessage({ ...message, messageId: "followup", replyToId: "first-visible-chunk" }), "group:original-message"); +}); 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-files.test.js b/test/platform-teams-files.test.js new file mode 100644 index 0000000..6ae2ab3 --- /dev/null +++ b/test/platform-teams-files.test.js @@ -0,0 +1,100 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { ensureTestEnv } from './helpers.js'; +ensureTestEnv(); +const { createTeamsFileResolver } = await import('../src/platforms/msteams/files.js'); +const { normalizeActivity } = await import('../src/platforms/msteams/activity.js'); +const { validateTeamsFileDriveIds, saveSettings, resolveTeamsConfig, settingsForApi } = await import('../src/config/settings.js'); +const driveId = 'b!fixture_drive'; +const contentUrl = 'https://fixture.sharepoint.com/sites/Team/Documents/sample.txt'; +const downloadUrl = 'https://fixture.sharepoint.com/download/file?opaque=fixture'; +const item = { id: 'item1', name: 'sample.txt', parentReference: { driveId }, file: { mimeType: 'text/plain' }, size: 4, '@microsoft.graph.downloadUrl': downloadUrl }; +const json = (value) => new Response(JSON.stringify(value), { headers: { 'content-type': 'application/json' } }); +function fixture(over = {}) { + const calls = []; let tokens = 0; + const resolve = createTeamsFileResolver({ auth: { token: async () => { tokens += 1; return 'fixture-graph-token'; } }, allowedDriveIds: [driveId], fetchImpl: async (url, options) => { + calls.push({ url, options }); + if (url.includes('/root?')) return json({ id: 'root', webUrl: 'https://fixture.sharepoint.com/sites/Team/Documents' }); + if (url.startsWith('https://graph.microsoft.com/')) return json({ ...item, ...over }); + return new Response('DATA'); + } }); + return { resolve, calls, tokens: () => tokens }; +} + +test('canonical file URL resolves only inside an allowed drive; bearer stays on Graph', async () => { + const f = fixture(); const download = f.resolve({ contentUrl }); + assert.equal(f.calls.length, 0); assert.equal(f.tokens(), 0, 'resolution is lazy until authorized download'); + assert.equal(await (await download()).text(), 'DATA'); assert.equal(f.calls.length, 3); + assert.match(f.calls[0].url, /\/drives\/b!fixture_drive\/root\?/); + assert.match(f.calls[1].url, /\/root:\/sample.txt\?/); + assert.equal(f.calls[0].options.headers.authorization, 'Bearer fixture-graph-token'); + assert.equal(f.calls[2].options.headers, undefined); + for (const call of f.calls) assert.equal(call.options.redirect, 'error'); +}); + +test('direct drive/item descriptors skip URL lookup but recheck returned identity', async () => { + const f = fixture(); assert.equal(await (await f.resolve({ driveId, itemId: 'item1' })()).text(), 'DATA'); assert.equal(f.calls.length, 2); + const changed = fixture({ id: 'item2' }); await assert.rejects(changed.resolve({ driveId, itemId: 'item1' })(), /identity/); assert.equal(changed.calls.length, 1); +}); + +test('invalid URLs, descriptors and unlisted drives never fetch or request credentials', () => { + const f = fixture(); + for (const value of [{ contentUrl: 'http://fixture.sharepoint.com/file' }, { contentUrl: 'https://fixture.sharepoint.com.evil.test/file' }, { contentUrl: 'https://user:pass@fixture.sharepoint.com/file' }, { contentUrl: 'https://localhost/file' }, { contentUrl, arbitrary: true }, { driveId: 'other', itemId: 'item1' }, { driveId, itemId: '../item' }, { contentUrl, driveId, itemId: 'item1' }, null]) assert.equal(f.resolve(value), null); + assert.equal(f.calls.length, 0); assert.equal(f.tokens(), 0); +}); + +test('drive mismatch or hostile download host fails before any bytes request', async () => { + for (const fields of [{ parentReference: { driveId: 'other' } }, { '@microsoft.graph.downloadUrl': 'https://evil.test/file' }, { '@microsoft.graph.downloadUrl': 'https://fixture.sharepoint.com:444/file' }, { file: null }]) { + const f = fixture(fields); await assert.rejects(f.resolve({ contentUrl })()); assert.equal(f.calls.length, 2); + } +}); + +test('shortlinks and paths outside the allowed root never ask Graph shares or download content', async () => { + const f = fixture(); + await assert.rejects(f.resolve({ contentUrl: 'https://fixture.sharepoint.com/:w:/s/SomeShortLink' })(), /canonical/); + assert.equal(f.calls.length, 1); assert.equal(f.calls.some((call) => call.url.includes('/shares/')), false); +}); + +test('download stream enforces cap even when content length is absent', async () => { + const resolve = createTeamsFileResolver({ auth: { token: async () => 'fixture' }, allowedDriveIds: [driveId], maxBytes: 3, fetchImpl: async (url) => url.startsWith('https://graph.microsoft.com') ? json({ ...item, size: 2 }) : new Response('TOO BIG') }); + const response = await resolve({ driveId, itemId: 'item1' })(); await assert.rejects(response.text(), /attachment limit/); +}); + +test('reference normalization preserves lazy resolver and original personal preauthenticated path', async () => { + const f = fixture(); const base = { type: 'message', id: 'm', conversation: { id: '19:files', conversationType: 'groupchat' }, from: { id: '29:author' } }; + const message = normalizeActivity({ ...base, attachments: [{ contentType: 'reference', contentUrl, name: 'sample.txt' }] }, { resolveFile: f.resolve }); + assert.equal(f.calls.length, 0); assert.equal(await (await message.attachments[0].download()).text(), 'DATA'); + const personal = normalizeActivity({ ...base, attachments: [{ contentType: 'application/vnd.microsoft.teams.file.download.info', content: { downloadUrl, fileType: 'txt' }, name: 'sample.txt' }] }, { resolveFile: () => assert.fail('personal path must stay independent'), fetchImpl: async () => new Response('DIRECT') }); + assert.equal(await (await personal.attachments[0].download()).text(), 'DIRECT'); +}); + +test('drive settings are bounded, explicit and non-secret; malformed stored policy fails closed', async () => { + assert.deepEqual(validateTeamsFileDriveIds([driveId, driveId]), [driveId]); + for (const invalid of ['drive', ['../drive'], Array(33).fill(driveId), [1], null]) assert.throws(() => validateTeamsFileDriveIds(invalid)); + await saveSettings({ teamsFilesEnabled: true, teamsFileDriveIds: [driveId] }); + assert.equal(resolveTeamsConfig().filesEnabled, true); assert.deepEqual(settingsForApi().teams.fileDriveIds, [driveId]); + await saveSettings({ teamsFilesEnabled: 'true', teamsFileDriveIds: ['../drive'] }); + assert.equal(resolveTeamsConfig().filesEnabled, false); assert.deepEqual(resolveTeamsConfig().fileDriveIds, []); + await saveSettings({ teamsFilesEnabled: false, teamsFileDriveIds: [] }); +}); + +test('admin file settings reject nonboolean opt-in and malformed drive IDs atomically', async (t) => { + const { default: express } = await import('express'); + 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 value = app.listen(0, '127.0.0.1', () => resolve(value)); }); + t.after(() => server.close()); + const patch = (body) => fetch(`http://127.0.0.1:${server.address().port}/settings`, { method: 'PUT', headers: { 'content-type': 'application/json' }, body: JSON.stringify(body) }); + for (const value of ['true', 1, null, {}]) assert.equal((await patch({ teamsFilesEnabled: value })).status, 400); + assert.equal((await patch({ teamsFilesEnabled: true, teamsFileDriveIds: ['../bad'] })).status, 400); assert.equal(resolveTeamsConfig().filesEnabled, false); + assert.equal((await patch({ teamsFilesEnabled: true, teamsFileDriveIds: [driveId] })).status, 200); assert.equal(resolveTeamsConfig().filesEnabled, true); + await patch({ teamsFilesEnabled: false, teamsFileDriveIds: [] }); +}); + +test('unapproved Teams user cannot cause Graph file metadata or token access', async () => { + const { createIngest } = await import('../src/platforms/ingest.js'); + const f = fixture(); + const message = normalizeActivity({ type: 'message', id: 'denied', conversation: { id: '19:file-denied', conversationType: 'personal' }, from: { id: '29:file-unapproved' }, attachments: [{ contentType: 'reference', contentUrl, name: 'sample.txt' }] }, { resolveFile: f.resolve }); + const ingest = createIngest({ connector: { platform: 'msteams', post: async () => ({ messageId: 'denied-reply' }) }, run: async () => assert.fail('unapproved run'), log: { info() {} } }); + assert.equal((await ingest(message)).skipped, 'unauthorized'); assert.equal(f.calls.length, 0); assert.equal(f.tokens(), 0); +}); 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 514ab69..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 () => { @@ -339,3 +340,25 @@ test("with no transport connected the Teams connector THROWS on a write", async const connector = teamsAdapter.createConnector(); await assert.rejects(connector.post({ conversationId: "19:a", text: "x" }), /cannot post/); }); + +test("Teams quote references normalize current entities and legacy HTML without using replyToId", () => { + const base = { type: "message", id: "200", from: { id: "29:user" }, conversation: { id: "19:quote@thread.v2", conversationType: "groupChat" }, text: "reply", replyToId: "not-a-chat-quote" }; + assert.equal(normalizeActivity(base).replyToId, ""); + const entity = { type: "quotedReply", quotedReply: { messageId: "100" } }; + assert.equal(normalizeActivity({ ...base, entities: [entity] }).replyToId, "100"); + assert.equal(normalizeActivity({ ...base, entities: [entity, entity] }).replyToId, ""); + for (const field of ["isReplyDeleted", "validatedMessageReference"]) { + assert.equal(normalizeActivity({ ...base, entities: [{ ...entity, quotedReply: { messageId: "100", [field]: field === "isReplyDeleted" } }] }).replyToId, ""); + } + const html = '
User
hello'; + assert.equal(normalizeActivity({ ...base, text: html }).replyToId, "100"); + assert.equal(normalizeActivity({ ...base, attachments: [{ contentType: "text/html", content: html }] }).replyToId, "100"); + assert.equal(normalizeActivity({ ...base, text: '
ordinary quote
' }).replyToId, ""); +}); + +test("Teams connector never sends a synthetic group session key as a channel thread", async () => { + let sent; + const connector = createTeamsConnector({ capabilities: teamsAdapter.capabilities, api: { sendActivity: async (id, body) => { sent = { id, body }; return { messageId: "300" }; } } }); + await connector.post({ conversationId: "19:quote@thread.v2", threadKey: "group:100", text: "done" }); + assert.equal(sent.body.threadKey, ""); +}); diff --git a/test/platform-voice.test.js b/test/platform-voice.test.js new file mode 100644 index 0000000..1e22670 --- /dev/null +++ b/test/platform-voice.test.js @@ -0,0 +1,69 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { ensureTestEnv } from './helpers.js'; +ensureTestEnv(); +const { prepareVoiceAttachments } = await import('../src/platforms/voice.js'); +const { createIngest } = await import('../src/platforms/ingest.js'); +const { makeInbound } = await import('../src/platforms/inbound.js'); +const { setUser } = await import('../src/config/store.js'); +const { runProcess, transcribeAudioFiles } = await import('../src/gateway/transcribe.js'); + +const audio = (name = 'clip.wav') => ({ name, contentType: 'audio/wav', download: async () => Buffer.from('fixture audio') }); + +test('voice metadata follows original attachment index through download failures; files remain files', async () => { + let seen; + const result = await prepareVoiceAttachments({ text: 'Please summarize', attachments: [{ name: 'missing.pdf', contentType: 'application/pdf' }, audio(), { name: 'data.txt', contentType: 'text/plain' }] }, ['/fixture/2-clip.wav', '/fixture/3-data.txt'], { enabled: true, transcribe: async (files) => { seen = files; return { transcripts: [{ name: 'clip.wav', text: 'Book a meeting' }], failed: [] }; } }); + assert.equal(seen[0].path, '/fixture/2-clip.wav'); assert.deepEqual(result.paths, ['/fixture/3-data.txt']); assert.match(result.text, /Please summarize/); assert.match(result.text, /Book a meeting/); +}); + +test('local-only disabled, failed, missing and empty speech paths explain failure', async () => { + const message = { text: '', attachments: [audio()] }; + const disabled = await prepareVoiceAttachments(message, ['/fixture/1-clip.wav'], { enabled: false, transcribe: async () => assert.fail('disabled must not transcribe') }); + assert.equal(disabled.hasPrompt, false); assert.deepEqual(disabled.paths, []); assert.match(disabled.failureNotice, /disabled/); + const failed = await prepareVoiceAttachments(message, ['/fixture/1-clip.wav'], { enabled: true, transcribe: async () => { throw new Error('fixture failure'); } }); + assert.match(failed.failureNotice, /Local transcription failed/); + const missing = await prepareVoiceAttachments(message, [], { enabled: true }); assert.match(missing.failureNotice, /could not be downloaded/); + const empty = await prepareVoiceAttachments(message, ['/fixture/1-clip.wav'], { enabled: true, transcribe: async () => ({ transcripts: [], failed: [] }) }); assert.match(empty.failureNotice, /no speech/); +}); + +function connector() { + const posts = []; const edits = []; + return { platform: 'msteams', posts, edits, async post(p) { posts.push(p); return { messageId: String(posts.length), conversationId: p.conversationId }; }, async edit(p) { edits.push(p); }, async directory() { return null; } }; +} +const inbound = (over = {}) => makeInbound({ platform: 'msteams', conversationId: '19:voice-fixture', kind: 'dm', userId: 'voice-approved', messageId: 'voice-message', attachments: [audio()], ...over }); + +test('unauthorized authors cannot download, transcribe or call the command hook', async () => { + let downloaded = false; let transcribed = false; let commands = false; + const ingest = createIngest({ connector: connector(), log: { info() {} }, voice: async () => { transcribed = true; }, onCommand: async () => { commands = true; }, run: async () => assert.fail('unauthorized engine') }); + const result = await ingest(inbound({ userId: 'voice-stranger', attachments: [{ ...audio(), download: async () => { downloaded = true; return Buffer.from('x'); } }] })); + assert.equal(result.skipped, 'unauthorized'); assert.equal(downloaded, false); assert.equal(transcribed, false); assert.equal(commands, false); +}); + +test('audio-only success becomes a text request with progress before transcription, no raw audio', async () => { + await setUser('voice-approved', { approved: true }); + const wire = connector(); let seen; + const ingest = createIngest({ connector: wire, voice: (message, paths, options) => prepareVoiceAttachments(message, paths, { ...options, enabled: true, transcribe: async () => { assert.match(wire.posts[0].text, /voice transcription/); return { transcripts: [{ name: 'clip.wav', text: 'Say VOICE_OK' }], failed: [] }; } }), run: async (args) => { seen = args; return { content: 'VOICE_OK' }; } }); + await ingest(inbound()); assert.equal(seen.text, 'Say VOICE_OK'); assert.deepEqual(seen.attachments, []); assert.equal(wire.edits.at(-1).text, 'VOICE_OK'); +}); + +test('standalone failed audio never invokes engine; typed fallback invokes it and reports missing transcript', async () => { + await setUser('voice-approved', { approved: true }); + const wire = connector(); let calls = 0; let seen; + const ingest = createIngest({ connector: wire, voice: (message, paths, options) => prepareVoiceAttachments(message, paths, { ...options, enabled: false }), run: async (args) => { calls += 1; seen = args; return { content: 'TEXT_OK' }; } }); + assert.equal((await ingest(inbound())).skipped, 'voice-unavailable'); assert.equal(calls, 0); assert.match(wire.edits.at(-1).text, /disabled/); + await ingest(inbound({ text: 'Say TEXT_OK', messageId: 'fallback' })); assert.equal(calls, 1); assert.match(seen.text, /Say TEXT_OK/); assert.deepEqual(seen.attachments, []); assert.match(wire.edits.at(-1).text, /disabled/); +}); + +test('authorized native command hook runs before normal controls, downloads and engine', async () => { + await setUser('voice-approved', { approved: true }); let hook; + const ingest = createIngest({ connector: connector(), onCommand: async (args) => { hook = args; await args.reply('CARD_OK'); return true; }, run: async () => assert.fail('command must not run engine'), voice: async () => assert.fail('command must not transcribe') }); + const result = await ingest(inbound({ text: '/help' })); assert.equal(result.command, true); assert.ok(hook.sessionKey); assert.ok(hook.entry.slug); assert.equal(hook.message.userId, 'voice-approved'); +}); + +test('local process abort waits for child exit; an already cancelled transcription never starts', async () => { + const controller = new AbortController(); + const running = runProcess(process.execPath, ['-e', 'setInterval(() => {}, 1000)'], { signal: controller.signal, timeout: 5000 }); + const timer = setTimeout(() => controller.abort(), 50); + await assert.rejects(running, { name: 'AbortError' }); clearTimeout(timer); + await assert.rejects(transcribeAudioFiles([{ name: 'clip.wav', path: '/never-read' }], { signal: controller.signal, transcribe: async () => assert.fail('aborted') }), { name: 'AbortError' }); +}); 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..43073a1 --- /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.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", "