diff --git a/.github/workflows/publish-images.yml b/.github/workflows/publish-images.yml index 124fc46f55..b6d4eaaca6 100644 --- a/.github/workflows/publish-images.yml +++ b/.github/workflows/publish-images.yml @@ -2,10 +2,13 @@ name: Publish Images on: push: - branches: [main] + branches: + - main + - release/c7e-centaur-overlay* tags: [v*] paths: - .github/workflows/publish-images.yml + - contrib/chart/** - services/** - crates/harness-server/** - harness/** @@ -31,22 +34,27 @@ permissions: env: REGISTRY: ghcr.io - IMAGE_NAMESPACE: paradigmxyz/centaur - IMAGE_SOURCE: https://github.com/paradigmxyz/centaur + IMAGE_NAMESPACE: cartridge-gg/centaur + IMAGE_SOURCE: https://github.com/cartridge-gg/centaur # Main/tags keep optimized release images. PRs and manual branch publishes # use debug builds so staging/dev iteration does not spend minutes optimizing # Rust binaries that are immediately replaced by the next test build. RUST_BUILD_PROFILE: ${{ (github.event_name == 'pull_request' || github.event_name == 'workflow_dispatch') && 'debug' || 'release' }} jobs: - # Build each image with Depot's hosted builders, push by digest, and hand - # the digests to the merge job below which assembles the multi-arch manifest. + # Build each image natively per platform (amd64 on x64 runners, arm64 on + # arm runners — no QEMU emulation), push by digest, and hand the digests + # to the merge job below which assembles the multi-arch manifest. # arm64 is only built on pushes to main and release tags — PR and manual # dispatch builds stay amd64-only to keep iteration fast. Fork PRs skip both # jobs so untrusted changes do not run on the hosted image builders. + # + # c7e overlay: upstream builds on Depot (depot-* runners + paradigmxyz's Depot + # project). This fork has no Depot project, so the build stays on GitHub-hosted + # runners with docker buildx. Reconcile if upstream reworks the build again. build: if: ${{ github.event_name != 'pull_request' || !github.event.pull_request.head.repo.fork }} - runs-on: ${{ matrix.platform == 'linux/arm64' && 'depot-ubuntu-24.04-arm-16' || 'depot-ubuntu-24.04-16' }} + runs-on: ${{ matrix.platform == 'linux/arm64' && 'ubuntu-24.04-arm' || 'ubuntu-latest' }} strategy: fail-fast: false matrix: @@ -110,8 +118,8 @@ jobs: platform="${{ matrix.platform }}" echo "PLATFORM_SLUG=${platform//\//-}" >> "$GITHUB_ENV" - - name: Set up Depot - uses: depot/setup-action@91bc8495a33ebfc504ffc89e5674379ccf23c29c # v1.7.2 + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3 - name: Log in to GHCR uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0 @@ -133,9 +141,8 @@ jobs: - name: Build and push ${{ matrix.image }} (${{ matrix.platform }}) id: build - uses: depot/build-push-action@98e78adca7817480b8185f474a400b451d74e287 # v1.18.0 + uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6 with: - project: d8qqlh1bmq context: ${{ matrix.context }} file: ${{ matrix.dockerfile }} target: ${{ matrix.target }} @@ -147,6 +154,14 @@ jobs: outputs: type=image,name=${{ env.REGISTRY }}/${{ env.IMAGE_NAMESPACE }}/${{ matrix.image }},push-by-digest=true,name-canonical=true,push=${{ !github.event.pull_request.head.repo.fork }} build-args: | RUST_BUILD_PROFILE=${{ env.RUST_BUILD_PROFILE }} + cache-from: | + type=registry,ref=${{ env.REGISTRY }}/${{ env.IMAGE_NAMESPACE }}/${{ matrix.image }}:buildcache-${{ env.PLATFORM_SLUG }} + type=gha,scope=${{ matrix.image }}-${{ env.PLATFORM_SLUG }} + # Fork PRs run with a read-only GITHUB_TOKEN: exporting the registry + # cache would fail the build, so only export it when we can push. + cache-to: | + ${{ !github.event.pull_request.head.repo.fork && format('type=registry,ref={0}/{1}/{2}:buildcache-{3},mode=max', env.REGISTRY, env.IMAGE_NAMESPACE, matrix.image, env.PLATFORM_SLUG) || '' }} + type=gha,mode=max,scope=${{ matrix.image }}-${{ env.PLATFORM_SLUG }} - name: Export digest if: ${{ !github.event.pull_request.head.repo.fork }} @@ -165,7 +180,7 @@ jobs: retention-days: 1 merge: - runs-on: depot-ubuntu-24.04-16 + runs-on: ubuntu-latest needs: build if: ${{ github.event_name != 'pull_request' || !github.event.pull_request.head.repo.fork }} strategy: @@ -231,3 +246,25 @@ jobs: - name: Inspect manifest run: | docker buildx imagetools inspect ${{ env.REGISTRY }}/${{ env.IMAGE_NAMESPACE }}/${{ matrix.image }}:${{ steps.meta.outputs.version }} + + # Auto-deploy: once the canonical release branch's base images are published, + # tell the c7e overlay to roll them out (it gates on the `latitude` + # environment). Manual pin-bump + dispatch still works as a fallback. + notify-overlay: + needs: merge + if: github.event_name == 'push' && github.ref == 'refs/heads/release/c7e-centaur-overlay' + runs-on: ubuntu-latest + steps: + - name: Dispatch overlay deploy with the new base image sha + env: + GH_TOKEN: ${{ secrets.OVERLAY_DISPATCH_TOKEN }} + run: | + short="${GITHUB_SHA:0:7}" + gh api repos/cartridge-gg/agent/dispatches --method POST --input - < tags that collide with + # upstream's. Forks version themselves via tag-release.yml (c7e-* tags). + if: github.event_name == 'push' && github.repository == 'paradigmxyz/centaur' env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: | @@ -67,6 +70,10 @@ jobs: uses: mikefarah/yq@c14f446382944492701b16c1ddb48bb9dbe683e3 # v4.53.6 - name: Validate chart version bump + # Upstream only. Chart.yaml .version is upstream's release number; forcing forks to + # bump it would both block overlay PRs that touch contrib/chart and destroy its + # meaning as the upstream base that tag-release.yml reads. + if: github.repository == 'paradigmxyz/centaur' run: | set -e @@ -98,7 +105,8 @@ jobs: exit 1 - name: Run chart-releaser - if: github.event_name == 'push' + # Upstream only — this is what mints the centaur- tags and releases. + if: github.event_name == 'push' && github.repository == 'paradigmxyz/centaur' uses: helm/chart-releaser-action@cae68fefc6b5f367a0275617c9f83181ba54714f # v1.7.0 with: skip_existing: true diff --git a/.github/workflows/tag-release.yml b/.github/workflows/tag-release.yml new file mode 100644 index 0000000000..008c726aab --- /dev/null +++ b/.github/workflows/tag-release.yml @@ -0,0 +1,187 @@ +name: Tag release + +# Cuts a durable, fork-distinct version tag from main. +# +# Scheme: c7e--, e.g. c7e-0.1.109-2 +# +# The upstream base is derived from the newest paradigmxyz/centaur release tag that is an +# ancestor of HEAD, NOT from contrib/chart/Chart.yaml — release-chart.yml forces that field +# to be bumped whenever contrib/chart changes, at which point it stops meaning "the upstream +# release we sit on". +# +# Why tag at all: the previously deployed commit (1807424a) was orphaned by a rebase of the +# release branch and is reachable from no ref. A tag cannot be orphaned that way, so the +# commit — and its published sha- image — stay resolvable. + +on: + workflow_dispatch: + inputs: + dry_run: + description: Resolve the next version but create nothing + type: boolean + required: false + default: false + create_release: + description: Publish a GitHub Release alongside the tag + type: boolean + required: false + default: true + +permissions: + contents: write + +concurrency: + group: tag-release + cancel-in-progress: false + +jobs: + tag: + name: Cut release tag + # ubuntu-latest is load-bearing: depot-* jobs never get a runner in this fork. + runs-on: ubuntu-latest + steps: + - name: Require main + run: | + set -euo pipefail + if [ "${GITHUB_REF_NAME}" != "main" ]; then + echo "::error::Releases are cut from main only; this ran on '${GITHUB_REF_NAME}'." + exit 1 + fi + + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + with: + fetch-depth: 0 + + - name: Fetch upstream tags and main + run: | + set -euo pipefail + url=https://github.com/paradigmxyz/centaur.git + git remote add upstream "$url" 2>/dev/null || git remote set-url upstream "$url" + # Local to this runner; never pushed back to origin. + git fetch --quiet upstream 'refs/tags/centaur-*:refs/tags/centaur-*' + git fetch --quiet upstream main:refs/remotes/upstream/main + + - name: Resolve next version + id: version + run: | + set -euo pipefail + + base_tag=$(git describe --tags --match 'centaur-*' --abbrev=0 HEAD 2>/dev/null || true) + if [ -z "$base_tag" ]; then + echo "::error::No upstream centaur-* release tag is an ancestor of HEAD; cannot derive a base version." + exit 1 + fi + base="${base_tag#centaur-}" + + already=$(git tag --list 'c7e-*' --points-at HEAD | head -n 1) + if [ -n "$already" ]; then + echo "::error::HEAD is already tagged '${already}'. Nothing new to release." + exit 1 + fi + + last=$(git tag --list "c7e-${base}-*" \ + | sed "s|^c7e-${base}-||" \ + | grep -E '^[0-9]+$' \ + | sort -n | tail -n 1 || true) + next=$(( ${last:-0} + 1 )) + tag="c7e-${base}-${next}" + + prev=$(git tag --list 'c7e-*' --sort=-creatordate | head -n 1) + + # Three distinct figures. Conflating them is the trap: "commits since the upstream + # release" mixes synced upstream commits with our own, and is not staleness. + since_base=$(git rev-list --count "${base_tag}..HEAD") + overlay=$(git rev-list --count upstream/main..HEAD) + behind=$(git rev-list --count HEAD..upstream/main) + upstream_latest=$(git tag --list 'centaur-*' | sort -V | tail -n 1) + + { + echo "tag=${tag}" + echo "base=${base}" + echo "base_tag=${base_tag}" + echo "prev=${prev}" + echo "since_base=${since_base}" + echo "overlay=${overlay}" + echo "behind=${behind}" + echo "upstream_latest=${upstream_latest}" + } >> "$GITHUB_OUTPUT" + + { + echo "# ${tag}" + echo + echo "| | |" + echo "| --- | --- |" + echo "| Upstream base | \`${base_tag}\` (\`$(git rev-parse --short "${base_tag}^{commit}")\`) |" + echo "| Release commit | \`$(git rev-parse --short HEAD)\` |" + echo "| Overlay commits | ${overlay} |" + echo "| Behind upstream | ${behind} |" + echo "| Commits since upstream release | ${since_base} |" + if [ "$upstream_latest" != "$base_tag" ]; then + echo + echo "> [!WARNING]" + echo "> Upstream's newest release is \`${upstream_latest}\`, but this release sits on \`${base_tag}\` and is ${behind} commit(s) behind upstream main." + fi + } >> "$GITHUB_STEP_SUMMARY" + + - name: Create and push tag + if: ${{ !inputs.dry_run }} + env: + TAG: ${{ steps.version.outputs.tag }} + BASE_TAG: ${{ steps.version.outputs.base_tag }} + OVERLAY: ${{ steps.version.outputs.overlay }} + BEHIND: ${{ steps.version.outputs.behind }} + SINCE_BASE: ${{ steps.version.outputs.since_base }} + UPSTREAM_LATEST: ${{ steps.version.outputs.upstream_latest }} + run: | + set -euo pipefail + + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + + { + echo "${TAG}" + echo + echo "Upstream base: ${BASE_TAG} ($(git rev-parse --short "${BASE_TAG}^{commit}"))" + echo "Release commit: $(git rev-parse HEAD)" + echo "Overlay commits: ${OVERLAY}" + echo "Behind upstream: ${BEHIND}" + echo "Since upstream release: ${SINCE_BASE}" + if [ "${UPSTREAM_LATEST}" != "${BASE_TAG}" ]; then + echo + echo "Upstream's newest release at tag time was ${UPSTREAM_LATEST}." + fi + } > /tmp/tag-message.txt + + git tag -a "${TAG}" -F /tmp/tag-message.txt + git push origin "refs/tags/${TAG}" + echo "::notice::Created tag ${TAG}" + + - name: Publish GitHub Release + if: ${{ !inputs.dry_run && inputs.create_release }} + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + # Pin gh to this repo: the checkout has an `upstream` remote (added by + # the tag-fetch step), and gh otherwise resolves the base repo to + # paradigmxyz/centaur and fails with "tag ... has not been pushed". + GH_REPO: ${{ github.repository }} + TAG: ${{ steps.version.outputs.tag }} + PREV: ${{ steps.version.outputs.prev }} + run: | + set -euo pipefail + args=(--title "${TAG}" --generate-notes) + if [ -n "${PREV}" ]; then + args+=(--notes-start-tag "${PREV}") + fi + url=$(gh release create "${TAG}" "${args[@]}") + echo "::notice::Published release ${url}" + echo "" >> "$GITHUB_STEP_SUMMARY" + echo "Release: ${url}" >> "$GITHUB_STEP_SUMMARY" + + - name: Report dry run + if: ${{ inputs.dry_run }} + env: + TAG: ${{ steps.version.outputs.tag }} + run: | + echo "::notice::Dry run — next version would be ${TAG}. Nothing was created." + echo "" >> "$GITHUB_STEP_SUMMARY" + echo "**Dry run** — nothing was created." >> "$GITHUB_STEP_SUMMARY" diff --git a/contrib/chart/templates/console.yaml b/contrib/chart/templates/console.yaml index 246b1c0910..2a5e86777b 100644 --- a/contrib/chart/templates/console.yaml +++ b/contrib/chart/templates/console.yaml @@ -212,6 +212,24 @@ spec: - name: CENTAUR_CONSOLE_SSO_EMAIL_DOMAINS value: {{ join "," . | quote }} {{- end }} + # GitHub App credentials used by the github_app secret source. The + # console mints short-lived installation tokens for per-sandbox + # proxies over /proxy/sync; raw tokens still never enter sandboxes. + - name: GITHUB_APP_ID + valueFrom: + secretKeyRef: + name: {{ $secretEnv }} + key: {{ printf "%sGITHUB_APP_ID" $prefix }} + - name: GITHUB_APP_INSTALLATION_ID + valueFrom: + secretKeyRef: + name: {{ $secretEnv }} + key: {{ printf "%sGITHUB_APP_INSTALLATION_ID" $prefix }} + - name: GITHUB_APP_PRIVATE_KEY_B64 + valueFrom: + secretKeyRef: + name: {{ $secretEnv }} + key: {{ printf "%sGITHUB_APP_PRIVATE_KEY_B64" $prefix }} {{- if $console.googleOauth.enabled }} # Google OAuth app credentials (sign-in + brokered token refresh). # Gated on console.googleOauth.enabled; the keys must exist in the diff --git a/docs/pages/deploying-in-production.mdx b/docs/pages/deploying-in-production.mdx index 7d91c4c065..f1a1f0bd95 100644 --- a/docs/pages/deploying-in-production.mdx +++ b/docs/pages/deploying-in-production.mdx @@ -337,7 +337,10 @@ Use the app page to install the bot, copy the Bot User OAuth Token for 7. Subscribe to `app_mention` and to the message events you want Centaur to see: `message.channels`, `message.groups`, and `message.im`. To automatically join newly-created public channels, set `slackbotv2.autoJoinCreatedChannels` to - `true` and subscribe to `channel_created`. + `true` and subscribe to `channel_created`. When the file-event workflow + trigger is enabled (`SLACKBOTV2_FILE_EVENT_WORKFLOW_NAME`), also subscribe + to the file events it matches — `file_shared` and `file_change` by default — + and grant `files:read` if the title-keyword filter is configured. 8. Enable Interactivity and set its Request URL to the same `https:///api/webhooks/slack` URL. Block Kit actions are emitted to the workflow engine as `slack.block_action.` events. diff --git a/docs/pages/reference/configuration.mdx b/docs/pages/reference/configuration.mdx index 27aa7de51b..8ac864fabf 100644 --- a/docs/pages/reference/configuration.mdx +++ b/docs/pages/reference/configuration.mdx @@ -168,6 +168,11 @@ Execution tuning: | `SLACKBOTV2_AUTO_JOIN_CREATED_CHANNELS` | `slackbotv2.autoJoinCreatedChannels`. | Joins newly-created public channels after subscribed `channel_created` events. Requires `channels:read` and `channels:join`. Defaults to `false`. | | `SLACKBOTV2_STEERING_REACTION_ENABLED` | `slackbotv2.steeringReactionEnabled`. | Reacts to mentioned follow-up messages while an execution is active, then removes their reactions after the assistant response is posted. Requires `reactions:write`. Defaults to `false` and disables itself for the running process if Slack reports the scope is missing. | | `SLACKBOTV2_STEERING_REACTION` | `slackbotv2.steeringReaction`. | Slack emoji name used for steering acknowledgements. Defaults to `hourglass_flowing_sand`. | +| `SLACKBOTV2_FILE_EVENT_WORKFLOW_NAME` | `slackbotv2.extraEnv`. | Workflow started (via `POST /api/workflows/runs`) when a matching Slack file event arrives. Unset disables the file-event trigger. Requires subscribing the Slack app to the file events being matched (`file_shared`, `file_change`) and `files:read` when the title-keyword filter is used. | +| `SLACKBOTV2_FILE_EVENT_TYPES` | `slackbotv2.extraEnv`. | Comma-separated Slack event types matched by the file-event trigger. Defaults to `file_shared,file_change`. | +| `SLACKBOTV2_FILE_EVENT_CHANNELS` | `slackbotv2.extraEnv`. | Optional comma-separated channel-id allowlist for the file-event trigger. Only enforced on events that carry a channel (`file_change` does not). Empty allows all channels. | +| `SLACKBOTV2_FILE_EVENT_TITLE_KEYWORDS` | `slackbotv2.extraEnv`. | Optional comma-separated, case-insensitive substrings matched against the file's `files.info` title/name/pretty_type before dispatching. Empty skips the lookup and dispatches every matching file event. | +| `SLACKBOTV2_FILE_EVENT_DEDUPE_SECONDS` | `slackbotv2.extraEnv`. | Time-buckets the dispatch idempotency key (`slack-file-event::`). Unset: one run per file, ever — later edit events dedupe against the first run, which is expected to debounce internally. | | `SLACKBOTV2_DEFAULT_HARNESS` | `sandbox.harnessEngine`. | Base harness for new Slack threads without an explicit flag or channel default. | | `SLACKBOTV2_CODEX_NANOCODEX_ROLLOUT_PERCENT` | `slackbotv2.codexNanocodexRolloutPercent`. | Percentage of otherwise-default Codex Slack threads assigned to Nanocodex. Assignment is deterministic by thread key and recorded in session and execution metadata. Selecting a non-default model bypasses the rollout. When response metadata is enabled, Slack shows the resolved harness name. Defaults to `0`; increase it to enroll new Codex Slack threads. | | `SLACKBOTV2_CHANNEL_DEFAULTS` | `slackbotv2.channelDefaults`. | Per-channel default harness / model / provider / reasoning as a JSON object keyed by Slack conversation id, where each value is an object of optional `harness`/`model`/`provider`/`reasoning` fields (same vocabulary as the inline flags, so `harness: claude`, `provider: bedrock`, and Claude model aliases like `opus` all work), e.g. `{"C0ENG":{"harness":"claude","model":"opus","reasoning":"high"},"C0TRIAGE":{"reasoning":"low"}}`. A model is only meaningful within a harness, so name the harness alongside it. Applied when a message in that channel carries no explicit/sticky per-thread flag (below such a flag, above the deployment/baked default) and forwarded onto the harness input line so it takes effect; setting the harness restarts a thread onto it like a `--claude`/`--codex` flag. `reasoning` affects the Codex and Nanocodex harnesses. Malformed JSON and unrecognized field values are logged and ignored. | diff --git a/services/api-rs/crates/centaur-api-server/src/auth.rs b/services/api-rs/crates/centaur-api-server/src/auth.rs index ab3b09a8e1..5dd7b5453c 100644 --- a/services/api-rs/crates/centaur-api-server/src/auth.rs +++ b/services/api-rs/crates/centaur-api-server/src/auth.rs @@ -131,6 +131,9 @@ impl ApiAuthConfig { if spec.workflow_events { capabilities.push(Capability::WorkflowsEvents); } + if spec.workflow_runs { + capabilities.push(Capability::WorkflowsWrite); + } callers.push(static_caller( spec.identity, CallerClass::Ingress, @@ -171,6 +174,7 @@ impl ApiAuthConfig { Capability::SessionsRead, Capability::SessionsWrite, Capability::WorkflowsEvents, + Capability::WorkflowsWrite, ], Some(&["slack:"]), )]; @@ -296,12 +300,16 @@ const INGRESS_SPECS: &[IngressSpec] = &[ identity: "slackbot", platform_prefixes: &["slack:"], workflow_events: true, + // c7e fork: slackbotv2's Slack file-event trigger starts workflow runs + // (POST /api/workflows/runs), which needs WorkflowsWrite. + workflow_runs: true, }, IngressSpec { env_var: "DISCORDBOT_API_KEY", identity: "discordbot", platform_prefixes: &["discord:"], workflow_events: false, + workflow_runs: false, }, IngressSpec { env_var: "GITHUBBOT_API_KEY", @@ -313,18 +321,21 @@ const INGRESS_SPECS: &[IngressSpec] = &[ "github-review:", ], workflow_events: true, + workflow_runs: false, }, IngressSpec { env_var: "LINEARBOT_API_KEY", identity: "linearbot", platform_prefixes: &["linear:"], workflow_events: false, + workflow_runs: false, }, IngressSpec { env_var: "TEAMSBOT_API_KEY", identity: "teamsbot", platform_prefixes: &["teams:"], workflow_events: false, + workflow_runs: false, }, ]; @@ -334,6 +345,8 @@ struct IngressSpec { /// Every session thread-key prefix this ingress mints. platform_prefixes: &'static [&'static str], workflow_events: bool, + /// Whether the ingress may start and cancel workflow runs. + workflow_runs: bool, } fn static_caller( @@ -554,6 +567,21 @@ mod tests { ); } + #[test] + fn only_slackbot_ingress_can_start_workflow_runs() { + // services/slackbotv2/src/file-event-trigger.ts POSTs + // /api/workflows/runs with SLACKBOT_API_KEY; the other ingresses only + // emit workflow events. + for spec in INGRESS_SPECS { + assert_eq!( + spec.workflow_runs, + spec.identity == "slackbot", + "{} workflow_runs grant drifted", + spec.identity + ); + } + } + #[test] fn every_ingress_scopes_itself_to_at_least_one_prefix() { for spec in INGRESS_SPECS { diff --git a/services/api-rs/crates/centaur-api-server/src/tool_discovery.rs b/services/api-rs/crates/centaur-api-server/src/tool_discovery.rs index 8dd06c8d28..8ff6150e72 100644 --- a/services/api-rs/crates/centaur-api-server/src/tool_discovery.rs +++ b/services/api-rs/crates/centaur-api-server/src/tool_discovery.rs @@ -610,6 +610,7 @@ enum ToolSecret { struct HttpSecret { name: String, secret_ref: String, + source: Option>, labels: BTreeMap, mode: HttpSecretMode, hosts: Vec, @@ -730,6 +731,7 @@ fn parse_secret( return Ok(ToolSecret::Http(HttpSecret { name: name.clone(), secret_ref: name.clone(), + source: None, labels: labels.clone(), mode: HttpSecretMode::Replace, hosts: default_hosts.to_vec(), @@ -776,6 +778,7 @@ fn parse_http_secret( default_hosts: &[String], labels: &BTreeMap, ) -> Result { + let source = optional_string_map(table.get("source"))?; let hosts = optional_string_array(table.get("hosts"))?.unwrap_or_else(|| default_hosts.to_vec()); if hosts.is_empty() || hosts.iter().any(String::is_empty) { @@ -801,6 +804,7 @@ fn parse_http_secret( Ok(ToolSecret::Http(HttpSecret { name, secret_ref, + source, labels: labels.clone(), mode: HttpSecretMode::Replace, hosts, @@ -836,6 +840,7 @@ fn parse_http_secret( Ok(ToolSecret::Http(HttpSecret { name, secret_ref, + source, labels: labels.clone(), mode: HttpSecretMode::Inject, hosts, @@ -1107,7 +1112,10 @@ fn http_secret_transform(secrets: &[ToolSecret]) -> Result, To let mut extra = BTreeMap::new(); let mut entry = Secret { id: Some(key.name.clone()), - source: Some(yaml_map([("placeholder", yaml_string(&key.secret_ref))])?), + source: Some(match key.source { + Some(source) => yaml_value(source)?, + None => yaml_map([("placeholder", yaml_string(&key.secret_ref))])?, + }), rules: host_rules(hosts)?, ..Default::default() }; @@ -1156,6 +1164,7 @@ fn http_secret_transform(secrets: &[ToolSecret]) -> Result, To struct HttpSecretKey { name: String, secret_ref: String, + source: Option>, mode: HttpSecretMode, replacer: String, match_headers: Vec, @@ -1171,6 +1180,7 @@ impl From<&HttpSecret> for HttpSecretKey { Self { name: secret.name.clone(), secret_ref: secret.secret_ref.clone(), + source: secret.source.clone(), mode: secret.mode.clone(), replacer: secret.replacer.clone(), match_headers: secret.match_headers.clone(), @@ -1551,6 +1561,41 @@ fn optional_string_array( Ok(Some(out)) } +fn optional_string_map( + value: Option<&TomlValue>, +) -> Result>, ToolDiscoveryError> { + let Some(value) = value else { + return Ok(None); + }; + let table = value + .as_table() + .ok_or_else(|| ToolDiscoveryError::Invalid("expected string map".to_owned()))?; + let mut out = BTreeMap::new(); + for (key, value) in table { + let Some(value) = value + .as_str() + .map(str::trim) + .filter(|value| !value.is_empty()) + else { + return Err(ToolDiscoveryError::Invalid(format!( + "source.{key} must be a non-empty string" + ))); + }; + out.insert(key.clone(), value.to_owned()); + } + if out.is_empty() { + return Err(ToolDiscoveryError::Invalid( + "source must contain at least one key".to_owned(), + )); + } + if !out.contains_key("type") { + return Err(ToolDiscoveryError::Invalid( + "source must include non-empty string key \"type\"".to_owned(), + )); + } + Ok(Some(out)) +} + fn optional_bool(table: &toml::Table, key: &str) -> Result, ToolDiscoveryError> { match table.get(key) { Some(value) => value @@ -1797,6 +1842,30 @@ secrets = [ let _ = fs::remove_dir_all(temp); } + #[test] + fn http_secret_source_table_is_preserved_in_proxy_fragment() { + let toml = r#" +[ + {type = "http", name = "GITHUB_TOKEN_BEARER", source = {type = "github_app", app_id_env = "GITHUB_APP_ID", installation_id_env = "GITHUB_APP_INSTALLATION_ID", private_key_b64_env = "GITHUB_APP_PRIVATE_KEY_B64"}, replacer = "Bearer GITHUB_TOKEN", match_headers = ["Authorization"], hosts = ["github.com"]} +] +"#; + let doc: TomlValue = toml::from_str(&format!("secrets = {}", toml.trim())).unwrap(); + let secrets = parse_secret_list(Some(&doc["secrets"]), &[], &BTreeMap::new()).unwrap(); + let transform = http_secret_transform(&secrets).unwrap().unwrap(); + let source = transform.config.secrets[0].source.as_ref().unwrap(); + + assert_eq!(source["type"].as_str(), Some("github_app")); + assert_eq!(source["app_id_env"].as_str(), Some("GITHUB_APP_ID")); + assert_eq!( + source["installation_id_env"].as_str(), + Some("GITHUB_APP_INSTALLATION_ID") + ); + assert_eq!( + source["private_key_b64_env"].as_str(), + Some("GITHUB_APP_PRIVATE_KEY_B64") + ); + } + #[test] fn discovers_personas_with_overlay_shadowing_and_default_validation() { let temp = temp_dir("api-rs-personas"); diff --git a/services/api-rs/crates/centaur-iron-control/src/registry.rs b/services/api-rs/crates/centaur-iron-control/src/registry.rs index 3d65b43476..d46628ee24 100644 --- a/services/api-rs/crates/centaur-iron-control/src/registry.rs +++ b/services/api-rs/crates/centaur-iron-control/src/registry.rs @@ -344,6 +344,30 @@ fn token_broker_source( Ok(Some(SecretSource::token_broker(credential_id))) } +/// Pass through a typed secret source (any `source` table with a `type`, e.g. +/// `github_app`): `type` becomes the `source_type` and the remaining string +/// fields become `config`. Mirrors `centaur_perms::translate::http_source`. +/// Returns `None` when `source` has no `type` key. +fn typed_source(source: &YamlValue) -> Option { + let source_type = yaml_str(source, "type")?.to_owned(); + let mut config = serde_json::Map::new(); + if let Some(mapping) = source.as_mapping() { + for (key, value) in mapping { + let (Some(key), Some(value)) = (key.as_str(), value.as_str()) else { + continue; + }; + if key != "type" { + config.insert(key.to_owned(), serde_json::Value::String(value.to_owned())); + } + } + } + Some(SecretSource { + source_type, + secret: None, + config: serde_json::Value::Object(config), + }) +} + fn source_from_secret( role: &str, secret: &Secret, @@ -360,9 +384,17 @@ fn source_from_secret( yaml_str(source, "json_key"), )); } + // A typed source (e.g. `type = "github_app"`): pass it through verbatim so + // iron-control's resolver handles it. Mirrors + // `centaur_perms::translate::http_source`, so the api-rs startup reconcile + // accepts the same tool-secret shapes the perms CLI registers (without + // this, a `github_app` source crashes api-rs at startup). + if let Some(typed) = typed_source(source) { + return Ok(typed); + } return Err(malformed( role, - "secret source must be a placeholder or token_broker reference", + "secret source must be a placeholder, token_broker, or typed (type=...) reference", )); } if let Some(proxy_value) = replace_proxy_value(secret) { @@ -1021,6 +1053,43 @@ transforms: assert!(input.replace_config.is_none()); } + #[test] + fn translates_typed_github_app_secret() { + // Regression: a `github_app` typed source must register (passed through to + // the resolver) rather than crash the api-rs startup reconcile. + let fragment = load_fragment_str( + r#" +transforms: + - name: secrets + config: + secrets: + - name: GITHUB_TOKEN_BEARER + source: + type: github_app + app_id_env: GITHUB_APP_ID + installation_id_env: GITHUB_APP_INSTALLATION_ID + private_key_b64_env: GITHUB_APP_PRIVATE_KEY_B64 + replace: { proxy_value: "Bearer GITHUB_TOKEN", match_headers: [Authorization] } + rules: [{ host: github.com }] +"#, + ) + .unwrap(); + let inputs = + secret_inputs_from_fragment("default", "infra", &fragment, &env_policy()).unwrap(); + let SecretInput::Static(input) = &inputs[0] else { + panic!("expected a static secret"); + }; + assert_eq!(input.source.source_type, "github_app"); + assert_eq!( + input.source.config, + json!({ + "app_id_env": "GITHUB_APP_ID", + "installation_id_env": "GITHUB_APP_INSTALLATION_ID", + "private_key_b64_env": "GITHUB_APP_PRIVATE_KEY_B64", + }) + ); + } + #[test] fn placeholder_inject_secret_derives_source() { let fragment = load_fragment_str( diff --git a/services/api-rs/crates/centaur-perms/src/tests.rs b/services/api-rs/crates/centaur-perms/src/tests.rs index a597730694..d9738a80f8 100644 --- a/services/api-rs/crates/centaur-perms/src/tests.rs +++ b/services/api-rs/crates/centaur-perms/src/tests.rs @@ -472,6 +472,41 @@ fn translates_http_replace_to_static_input() { assert_eq!(input.rules[0].host.as_deref(), Some("slack.com")); } +#[test] +fn translates_http_source_table_to_static_input() { + let secrets = vec![ + tools::parse_secret( + &entry(r#"{type = "http", name = "GITHUB_TOKEN_BEARER", source = {type = "github_app", app_id_env = "GITHUB_APP_ID", installation_id_env = "GITHUB_APP_INSTALLATION_ID", private_key_b64_env = "GITHUB_APP_PRIVATE_KEY_B64"}, replacer = "Bearer GITHUB_TOKEN", match_headers = ["Authorization"], hosts = ["github.com"]}"#), + &[], + ) + .unwrap(), + ]; + let ParsedSecret::Http(http) = &secrets[0] else { + panic!("expected http") + }; + assert_eq!( + http.source + .as_ref() + .and_then(|s| s.get("type")) + .map(String::as_str), + Some("github_app") + ); + + let out = translate::translate("tool-github", &secrets, &SourcePolicy::env()); + let SecretInput::Static(input) = &out.inputs[0] else { + panic!("expected static") + }; + assert_eq!(input.source.source_type, "github_app"); + assert_eq!( + input.source.config, + serde_json::json!({ + "app_id_env": "GITHUB_APP_ID", + "installation_id_env": "GITHUB_APP_INSTALLATION_ID", + "private_key_b64_env": "GITHUB_APP_PRIVATE_KEY_B64", + }) + ); +} + #[test] fn translates_gcp_auth_defaults_scopes_when_unset() { let secrets = vec![ diff --git a/services/api-rs/crates/centaur-perms/src/tools.rs b/services/api-rs/crates/centaur-perms/src/tools.rs index 33073d34ff..ec6273039c 100644 --- a/services/api-rs/crates/centaur-perms/src/tools.rs +++ b/services/api-rs/crates/centaur-perms/src/tools.rs @@ -8,7 +8,10 @@ //! into iron-control inputs. Only the secret *schema* is reimplemented here; //! the API's loader stays the source of truth for runtime tool loading. -use std::path::{Path, PathBuf}; +use std::{ + collections::BTreeMap, + path::{Path, PathBuf}, +}; use centaur_iron_control::{GCP_ID_TOKEN_ALLOWED_HEADERS, normalize_gcp_id_token_header}; use centaur_iron_proxy::{PgDsnSetting, PgDsnSettingValueFrom}; @@ -91,6 +94,7 @@ pub struct FieldSource { pub struct HttpSecret { pub name: String, pub secret_ref: String, + pub source: Option>, pub mode: SecretMode, pub hosts: Vec, // replace mode @@ -426,6 +430,7 @@ pub fn parse_secret(entry: &Value, default_hosts: &[String]) -> Result Result { + let source = string_map(table.get("source"))?; let mode = match table .get("mode") .and_then(Value::as_str) @@ -536,6 +542,7 @@ fn parse_http( Ok(HttpSecret { name: name.to_owned(), secret_ref: secret_ref.to_owned(), + source, mode, hosts, replacer, @@ -572,6 +579,7 @@ fn parse_http( Ok(HttpSecret { name: name.to_owned(), secret_ref: secret_ref.to_owned(), + source, mode, hosts, replacer: String::new(), @@ -1065,6 +1073,29 @@ fn non_empty_str_array(value: Option<&Value>) -> Option> { Some(out) } +fn string_map(value: Option<&Value>) -> Result>> { + let Some(value) = value else { + return Ok(None); + }; + let table = value + .as_table() + .ok_or_else(|| eyre!("source must be a table of strings"))?; + let mut out = BTreeMap::new(); + for (key, value) in table { + let Some(value) = value.as_str().filter(|s| !s.is_empty()) else { + bail!("source.{key} must be a non-empty string"); + }; + out.insert(key.clone(), value.to_owned()); + } + if out.is_empty() { + bail!("source must contain at least one key"); + } + if !out.contains_key("type") { + bail!("source must include non-empty string key \"type\""); + } + Ok(Some(out)) +} + fn validate_gcp_id_token_header(value: String) -> Result { normalize_gcp_id_token_header(&value).ok_or_else(|| { eyre!( diff --git a/services/api-rs/crates/centaur-perms/src/translate.rs b/services/api-rs/crates/centaur-perms/src/translate.rs index 4554daa285..6737476c37 100644 --- a/services/api-rs/crates/centaur-perms/src/translate.rs +++ b/services/api-rs/crates/centaur-perms/src/translate.rs @@ -18,6 +18,7 @@ use centaur_iron_control::{ }; use centaur_iron_proxy::SourcePolicy; use centaur_iron_proxy::{PgDsnSetting, PgDsnSettingValueFrom}; +use serde_json::json; use crate::tools::{ AwsAuthSecret, BrokerTokenSecret, FieldSource, GcpAuthSecret, GcpIdTokenSecret, HmacSignSecret, @@ -183,11 +184,30 @@ fn static_input( labels: labels.clone(), inject_config, replace_config, - source: source_from_placeholder(policy, &http.secret_ref, None), + source: http_source(http, policy), rules: rules_from_hosts(&http.hosts), } } +fn http_source(http: &HttpSecret, policy: &SourcePolicy) -> SecretSource { + if let Some(source) = &http.source + && let Some(source_type) = source.get("type") + { + let mut config = serde_json::Map::new(); + for (key, value) in source { + if key != "type" { + config.insert(key.clone(), json!(value)); + } + } + return SecretSource { + source_type: source_type.clone(), + secret: None, + config: serde_json::Value::Object(config), + }; + } + source_from_placeholder(policy, &http.secret_ref, None) +} + fn oauth_input( role: &str, oauth: &OAuthTokenSecret, diff --git a/services/api-rs/crates/centaur-session-sqlx/migrations/0055_company_context_reader_memory_notes.sql b/services/api-rs/crates/centaur-session-sqlx/migrations/0055_company_context_reader_memory_notes.sql new file mode 100644 index 0000000000..0a42d43269 --- /dev/null +++ b/services/api-rs/crates/centaur-session-sqlx/migrations/0055_company_context_reader_memory_notes.sql @@ -0,0 +1,40 @@ +-- Let the company-context reader see hand-curated memory notes. +-- +-- `centaur_cc_reader_documents_select` (0048/0050) admits only `source = 'slack'` +-- rows, so agent sessions searching company context never see documents +-- other writers put in `company_context_documents`. Memory notes +-- (`source = 'c7e_memory'`, `source_type = 'memory_note'`) are facts a user +-- explicitly asked the agent to remember, written with +-- `access_scope = 'company'`; hiding them from the agent that stored them +-- defeats the purpose. +-- +-- Visibility is tied to `centaur.slack_include_public`: a principal allowed +-- to read public Slack may read company-scoped notes. A connection with no +-- access settings still sees nothing (fail closed), matching the other +-- reader policies. Other `c7e_memory` document types (rollups, metric +-- snapshots) are unchanged. +-- +-- The embeddings policy delegates to this table +-- (`centaur_company_context_embedding_document_visible`), so the vector +-- search lane follows automatically. +drop policy if exists centaur_cc_reader_documents_select + on company_context_documents; +create policy centaur_cc_reader_documents_select + on company_context_documents + for select + to centaur_company_context_reader + using ( + ( + source = 'slack' + and metadata ->> 'channel_id' in ( + select channels.channel_id + from slack_sync_channels channels + ) + ) + or ( + source = 'c7e_memory' + and source_type = 'memory_note' + and access_scope = 'company' + and (select centaur_company_context_include_public_slack()) + ) + ); diff --git a/services/api-rs/crates/centaur-session-sqlx/migrations/0056_c7e_testflight_submissions.sql b/services/api-rs/crates/centaur-session-sqlx/migrations/0056_c7e_testflight_submissions.sql new file mode 100644 index 0000000000..43548c30cf --- /dev/null +++ b/services/api-rs/crates/centaur-session-sqlx/migrations/0056_c7e_testflight_submissions.sql @@ -0,0 +1,96 @@ +-- TestFlight feedback submissions for the Cartridge overlay +-- (cartridge-gg/agent, docs/dango-feedback-pipeline.md "Recovery"). +-- +-- One row per TestFlight submission the overlay's feedback pipeline has seen: +-- the parsed inbox comment (tester text, screenshots, device), where the +-- inbox comment and the Slack post live, and the triage state the overlay's +-- recovery scan (`c7e_dango_feedback_recovery_scan`) queries by: status, +-- attempts, the failure reason and its timestamps, and the dispatch that +-- last claimed it. The overlay's webhook feature writes the row at ingest, +-- its triage projects every state-document save into it, and the recovery +-- scan reads it with one query. +-- +-- The per-submission state DOCUMENT in company_context_documents stays: it +-- is the claim's advisory-lock target and what the issue resolver, the +-- PostHog loop's shared machinery and the pipeline-monitor board read. This +-- table is the submission registry and the query index, written from the +-- same code path (`_c7e_dango_feedback_common.project_submission_state`). +-- +-- Fork-only migration (like 0055): no upstream table is touched. The +-- backfill seeds a row for every submission that already has a state +-- document, so earlier failures are visible the moment api-rs starts with +-- this migration; the tester text and screenshots of those rows are null and +-- the scan re-reads the inbox comment for them. + +create table if not exists c7e_testflight_submissions ( + submission_id text primary key, + repo text not null default '', + kind text not null default 'feedback', + comment text, -- tester text (null: not captured) + screenshots jsonb, -- list of signed Apple urls + device_model text not null default '', + os_version text not null default '', + app_platform text not null default '', + build_bundle_id text not null default '', + build_id text not null default '', + tester_id text not null default '', + submitted_at text not null default '', -- Apple's createdDate, as posted + inbox_issue_number integer, + inbox_comment_id bigint, + inbox_comment_url text not null default '', + slack_channel_id text not null default '', + slack_message_ts text not null default '', + slack_permalink text not null default '', + status text not null default '', -- '' | in_flight | failed | filed | not_filed + attempts integer not null default 0, + run_id text not null default '', -- the run that last claimed it + dispatch_id text not null default '', -- recovery re-dispatch that superseded a failure + claimed_at timestamptz, + first_failed_at timestamptz, + last_failed_at timestamptz, + last_dispatched_at timestamptz, -- the recovery scan's last re-dispatch + reason text not null default '', + verdict text not null default '', + issue_url text not null default '', + issue_urls jsonb not null default '[]'::jsonb, + received_at timestamptz not null default now(), + updated_at timestamptz not null default now() +); + +create index if not exists c7e_testflight_submissions_status_idx + on c7e_testflight_submissions (status, updated_at desc); + +-- Backfill from the state documents (never overwrites a row that exists). +insert into c7e_testflight_submissions ( + submission_id, repo, inbox_issue_number, inbox_comment_id, inbox_comment_url, + slack_channel_id, slack_message_ts, slack_permalink, + status, attempts, run_id, dispatch_id, claimed_at, first_failed_at, last_failed_at, + reason, verdict, issue_url, issue_urls, received_at, updated_at +) +select + metadata->>'submission_id', + coalesce(metadata->>'repo', ''), + nullif(metadata->>'inbox_issue_number', '')::integer, + nullif(metadata->>'inbox_comment_id', '')::bigint, + coalesce(metadata->>'inbox_comment_url', ''), + coalesce(metadata->>'slack_channel_id', ''), + coalesce(metadata->>'slack_message_ts', ''), + coalesce(metadata->>'slack_permalink', ''), + coalesce(metadata->>'status', ''), + coalesce(nullif(metadata->>'attempts', '')::integer, 0), + coalesce(metadata->>'run_id', ''), + coalesce(metadata->>'dispatch_id', ''), + nullif(metadata->>'claimed_at', '')::timestamptz, + nullif(metadata->>'first_failed_at', '')::timestamptz, + case when metadata->>'status' = 'failed' + then nullif(metadata->>'state_updated_at', '')::timestamptz end, + coalesce(metadata->>'reason', ''), + coalesce(metadata->>'verdict', ''), + coalesce(metadata->>'issue_url', ''), + coalesce(metadata->'issue_urls', '[]'::jsonb), + coalesce(nullif(metadata->>'source_received_at', '')::timestamptz, occurred_at, now()), + coalesce(nullif(metadata->>'state_updated_at', '')::timestamptz, updated_at, now()) +from company_context_documents +where source_type = 'testflight_feedback_state' + and metadata->>'submission_id' is not null +on conflict (submission_id) do nothing; diff --git a/services/api-rs/crates/centaur-session-sqlx/tests/etl_context_rls.rs b/services/api-rs/crates/centaur-session-sqlx/tests/etl_context_rls.rs index e1d9c18973..abc2ae2caf 100644 --- a/services/api-rs/crates/centaur-session-sqlx/tests/etl_context_rls.rs +++ b/services/api-rs/crates/centaur-session-sqlx/tests/etl_context_rls.rs @@ -256,10 +256,16 @@ async fn assert_channel_visibility(conn: &mut PgConnection) -> Result<(), Box Result<(), Box> { + // Public-context readers see public Slack plus company-scoped memory notes; + // other c7e_memory document types and user-scoped notes stay hidden. let company_context_public = company_context_docs(conn, None, r#"[]"#, true).await?; assert_eq!( company_context_public, - vec!["doc_slack_alpha".to_owned(), "doc_slack_beta".to_owned(),] + vec![ + "doc_memory_note".to_owned(), + "doc_slack_alpha".to_owned(), + "doc_slack_beta".to_owned(), + ] ); let company_context_private_history = @@ -267,12 +273,14 @@ async fn assert_company_context_reader_search_behavior( assert_eq!( company_context_private_history, vec![ + "doc_memory_note".to_owned(), "doc_slack_alpha".to_owned(), "doc_slack_beta".to_owned(), "doc_slack_private".to_owned(), ] ); + // Without public context, memory notes are hidden too (fail closed). let company_context_history_no_public = company_context_docs(conn, None, r#"["C_ALPHA"]"#, false).await?; assert_eq!( @@ -292,6 +300,7 @@ async fn assert_company_context_reader_search_behavior( search_rows, CompanyContextSearchRows { company_context_docs: vec![ + "doc_memory_note".to_owned(), "doc_slack_alpha".to_owned(), "doc_slack_beta".to_owned(), "doc_slack_private".to_owned(), @@ -1172,7 +1181,15 @@ async fn insert_fixture_rows(conn: &mut PgConnection) -> Result<(), sqlx::Error> ('doc_slack_unknown_channel', 'slack', 'slack_thread', 'unknown', '{}'), ('doc_gdrive', 'google_drive', 'google_doc', 'gdrive_file', '{}'), ('doc_gcal', 'google_calendar', 'calendar_event', 'gcal_event', '{}'), - ('doc_linear', 'linear', 'linear_issue', 'linear_issue', '{}'); + ('doc_linear', 'linear', 'linear_issue', 'linear_issue', '{}'), + ('doc_memory_note', 'c7e_memory', 'memory_note', 'note', '{}'), + ('doc_memory_update', 'c7e_memory', 'memory_update', 'update', '{}'); + -- A user-scoped note must stay hidden from the company-context reader + -- even when public context is enabled. + insert into company_context_documents + (document_id, source, source_type, source_document_id, metadata, access_scope) + values + ('doc_memory_note_user', 'c7e_memory', 'memory_note', 'note_user', '{}', 'user:U_OTHER'); insert into google_drive_sync_runs (run_id, status) values ('gdrive_run', 'succeeded'); insert into google_drive_sync_files (file_id) values ('gdrive_file'); @@ -1613,6 +1630,9 @@ fn public_visible_rows() -> VisibleRows { "doc_gcal".to_owned(), "doc_gdrive".to_owned(), "doc_linear".to_owned(), + "doc_memory_note".to_owned(), + "doc_memory_note_user".to_owned(), + "doc_memory_update".to_owned(), "doc_slack_alpha".to_owned(), "doc_slack_beta".to_owned(), ], diff --git a/services/console/db/migrate/20260904000100_seed_github_app_broker_credential.rb b/services/console/db/migrate/20260904000100_seed_github_app_broker_credential.rb new file mode 100644 index 0000000000..9cf9c91867 --- /dev/null +++ b/services/console/db/migrate/20260904000100_seed_github_app_broker_credential.rb @@ -0,0 +1,38 @@ +# frozen_string_literal: true + +# Versioned after upstream migrations; `up` remains idempotent for deployments +# that already applied the earlier fork-local version of this seed. + +# Provision the GitHub App broker credential from the deployment's GITHUB_APP_* +# env (the same values the old inline `github_app` secret source read) so the +# `github_auth_headers` tool's `token_broker` source can resolve it and the +# broker mints/rotates the installation token out-of-band. +# +# Idempotent (find_or_create by namespace+foreign_id) and a no-op where the env +# is unset (dev/test). Uses the model so client_secret is stored encrypted; a +# failure is logged rather than raised so it never breaks a console deploy. +class SeedGithubAppBrokerCredential < ActiveRecord::Migration[8.1] + def up + app_id = ENV["GITHUB_APP_ID"].to_s + installation_id = ENV["GITHUB_APP_INSTALLATION_ID"].to_s + private_key_b64 = ENV["GITHUB_APP_PRIVATE_KEY_B64"].to_s + if app_id.blank? || installation_id.blank? || private_key_b64.blank? + say "GITHUB_APP_* env not set; skipping github_app broker credential seed" + return + end + + BrokerCredential.find_or_create_by!(namespace: "default", foreign_id: "github-app") do |c| + c.grant = "github_app" + c.client_id = app_id + c.token_endpoint = "https://api.github.com/app/installations/#{installation_id}/access_tokens" + c.client_secret = private_key_b64 + end + say "seeded github_app broker credential (namespace=default foreign_id=github-app)" + rescue StandardError => e + say "WARNING: github_app broker credential seed failed: #{e.class}: #{e.message}" + end + + def down + BrokerCredential.where(namespace: "default", foreign_id: "github-app", grant: "github_app").destroy_all + end +end diff --git a/services/console/lib/broker/credential_grants.rb b/services/console/lib/broker/credential_grants.rb index 8a389fd3c7..0865f0c869 100644 --- a/services/console/lib/broker/credential_grants.rb +++ b/services/console/lib/broker/credential_grants.rb @@ -3,15 +3,24 @@ module Broker # owns persistence and scheduling; these strategies own provider-specific # request shapes and bootstrap validation. module CredentialGrants + require "base64" + require "json" + require "net/http" + require "openssl" + require "time" + require "uri" + PREQIN_TOKEN_ENDPOINT = "https://api.preqin.com/connect/token".freeze PREQIN_REFRESH_TOKEN_ENDPOINT = "https://api.preqin.com/connect/refresh_token".freeze - GRANTS = %w[refresh_token client_credentials password preqin].freeze - REFRESHABLE_WITHOUT_TOKEN_GRANTS = %w[client_credentials password preqin].freeze + GRANTS = %w[refresh_token client_credentials password preqin github_app].freeze + REFRESHABLE_WITHOUT_TOKEN_GRANTS = %w[client_credentials password preqin github_app].freeze Outcome = Data.define(:result, :clear_refresh_token, :dead_reason) class << self + attr_writer :github_app_http_client + def default_token_endpoint(grant) PREQIN_TOKEN_ENDPOINT if grant == "preqin" end @@ -28,6 +37,8 @@ def validate(credential) validate_password(credential) when "preqin" validate_preqin(credential) + when "github_app" + validate_github_app(credential) end end @@ -39,6 +50,8 @@ def refresh(credential) refresh_password(credential) when "preqin" refresh_preqin(credential) + when "github_app" + refresh_github_app(credential) else refresh_token(credential) end @@ -129,6 +142,63 @@ def refresh_preqin(credential) success(result, clear_refresh_token: clear_stale_refresh_token && result.refresh_token.blank?) end + def refresh_github_app(credential) + now = Time.current + require_value!("client_id", credential.client_id) + require_value!("client_secret", credential.client_secret) + require_value!("token_endpoint", credential.token_endpoint) + + uri = URI.parse(credential.token_endpoint) + request = Net::HTTP::Post.new(uri) + request["Authorization"] = "Bearer #{github_app_jwt(credential, now)}" + request["Accept"] = "application/vnd.github+json" + request["X-GitHub-Api-Version"] = "2022-11-28" + + response = github_app_http_request(uri, request, credential.refresh_timeout_seconds) + unless response.is_a?(Net::HTTPSuccess) + raise github_app_http_error(response) + end + + parsed = JSON.parse(response.body) + token = parsed["token"].to_s + raise Broker::RefreshError.new("GitHub App token response missing token", stage: "parse", retryable: true) if token.blank? + + expires_at = Time.iso8601(parsed.fetch("expires_at")) + expires_in = [ (expires_at - now).to_i, 1 ].max + result = Broker::RefreshClient::Result.new( + access_token: token, + refresh_token: nil, + expires_in: expires_in + ) + success(result, clear_refresh_token: true) + rescue JSON::ParserError, KeyError, ArgumentError, TypeError => e + raise Broker::RefreshError.new( + "GitHub App token response could not be parsed: #{e.class}", + stage: "parse", + retryable: true + ) + rescue OpenSSL::PKey::PKeyError => e + raise Broker::RefreshError.new( + "GitHub App private key is invalid: #{e.class}", + stage: "config", + code: "invalid_private_key", + retryable: false + ) + rescue URI::InvalidURIError => e + raise Broker::RefreshError.new( + "GitHub App token endpoint is invalid: #{e.class}", + stage: "config", + code: "invalid_token_endpoint", + retryable: false + ) + rescue IOError, SystemCallError, Timeout::Error, Net::OpenTimeout, Net::ReadTimeout => e + raise Broker::RefreshError.new( + "GitHub App token endpoint request failed: #{e.class}", + stage: "network", + retryable: true + ) + end + def oauth_refresh_token(credential) post_token_form( credential, @@ -202,6 +272,55 @@ def preqin_refresh_token_form(credential) { "refresh_token" => credential.refresh_token } end + def github_app_jwt(credential, now) + header = base64url(JSON.generate({ alg: "RS256", typ: "JWT" })) + payload = base64url(JSON.generate({ + iat: now.to_i - 60, + exp: now.to_i + 9.minutes.to_i, + iss: credential.client_id + })) + signing_input = "#{header}.#{payload}" + key = OpenSSL::PKey::RSA.new(github_app_private_key_pem(credential.client_secret)) + signature = key.sign(OpenSSL::Digest.new("SHA256"), signing_input) + "#{signing_input}.#{base64url(signature)}" + end + + def github_app_private_key_pem(value) + text = value.to_s + return text if text.include?("-----BEGIN") + + Base64.strict_decode64(text) + rescue ArgumentError + text + end + + def github_app_http_request(uri, request, timeout) + if @github_app_http_client + return @github_app_http_client.call(uri, request) + end + + Net::HTTP.start(uri.host, uri.port, use_ssl: uri.scheme == "https", + open_timeout: timeout, read_timeout: timeout) do |http| + http.request(request) + end + end + + def github_app_http_error(response) + status = response.code.to_i + retryable = status / 100 == 5 || status == 429 + Broker::RefreshError.new( + "GitHub App installation token endpoint returned HTTP #{status}", + stage: "http", + code: "http_#{status}", + status: status, + retryable: retryable + ) + end + + def base64url(value) + Base64.urlsafe_encode64(value, padding: false) + end + def add_oauth_optional_fields(form, credential) form["client_secret"] = credential.effective_client_secret if credential.effective_client_secret.present? @@ -230,6 +349,12 @@ def validate_preqin(credential) credential.errors.add(:api_key, "can't be blank for the Preqin broker grant") if credential.api_key.blank? end + def validate_github_app(credential) + if credential.client_secret.blank? + credential.errors.add(:client_secret, "can't be blank for the GitHub App broker grant") + end + end + def password_values_present?(credential) credential.username.present? && credential.password.present? end diff --git a/services/console/test/controllers/api/v1/broker_credentials_controller_test.rb b/services/console/test/controllers/api/v1/broker_credentials_controller_test.rb index 629da94a31..c719b81787 100644 --- a/services/console/test/controllers/api/v1/broker_credentials_controller_test.rb +++ b/services/console/test/controllers/api/v1/broker_credentials_controller_test.rb @@ -174,6 +174,36 @@ def json_body = JSON.parse(response.body) assert created.next_attempt_at.present? end + test "create github_app grant stores private key and redacts it" do + private_key = OpenSSL::PKey::RSA.generate(2048).to_pem + body = { + data: { + foreign_id: "github-app", + grant: "github_app", + token_endpoint: "https://api.github.com/app/installations/456/access_tokens", + client_id: "123", + client_secret: private_key + } + } + + assert_difference -> { BrokerCredential.count } => 1 do + post api_v1_broker_credentials_url, params: body.to_json, headers: auth_headers + end + assert_response :created + data = json_body.fetch("data") + assert_equal "github_app", data["grant"] + assert_equal "123", data["client_id"] + refute data.key?("client_secret") + refute data.key?("access_token") + + created = BrokerCredential.find_by_oid(data["id"]) + assert_equal private_key, created.client_secret + # A freshly-created github_app credential must be due for its first mint. + # next_attempt_at is left nil on create here, which the refreshable scope + # treats as immediately due (next_attempt_at IS NULL). + assert BrokerCredential.refreshable.exists?(id: created.id) + end + test "create rejects a missing client_id" do body = { data: { diff --git a/services/console/test/models/broker_credential_test.rb b/services/console/test/models/broker_credential_test.rb index 2cb99ae89a..42721f5b69 100644 --- a/services/console/test/models/broker_credential_test.rb +++ b/services/console/test/models/broker_credential_test.rb @@ -41,6 +41,26 @@ def create_credential(**kw) bc end + def github_private_key + @github_private_key ||= OpenSSL::PKey::RSA.generate(2048) + end + + def github_app_response(token: "ghs_installation", expires_at: 1.hour.from_now) + response = Net::HTTPOK.new("1.1", "200", "OK") + response.instance_variable_set( + :@body, + JSON.generate({ "token" => token, "expires_at" => expires_at.iso8601 }) + ) + # Mark the body as already read; otherwise Net::HTTPResponse#body tries to + # read from a (nil) socket and raises "attempt to read body out of block". + response.instance_variable_set(:@read, true) + response + end + + teardown do + Broker::CredentialGrants.github_app_http_client = nil + end + # --- validations ---------------------------------------------------------- test "valid with a client_id" do @@ -100,6 +120,29 @@ def create_credential(**kw) assert bc.errors[:api_key].any? { |m| m.include?("Preqin broker grant") } end + test "github_app grant is valid with app id, private key, and installation token endpoint" do + bc = build_credential( + grant: "github_app", + token_endpoint: "https://api.github.com/app/installations/456/access_tokens", + client_id: "123", + client_secret: github_private_key.to_pem, + refresh_token: nil + ) + assert bc.valid?, bc.errors.full_messages.to_sentence + end + + test "github_app grant requires a private key" do + bc = build_credential( + grant: "github_app", + token_endpoint: "https://api.github.com/app/installations/456/access_tokens", + client_id: "123", + client_secret: nil, + refresh_token: nil + ) + refute bc.valid? + assert bc.errors[:client_secret].any? { |m| m.include?("GitHub App broker grant") } + end + # --- oauth_app provenance (flow-minted credentials) ----------------------- def build_app(**overrides) @@ -454,6 +497,86 @@ def build_app(**overrides) assert_equal "AT", bc.access_token end + test "github_app grant mints and stores an installation access token" do + captured = {} + expires_at = 1.hour.from_now + Broker::CredentialGrants.github_app_http_client = ->(uri, request) { + captured = { uri: uri, request: request } + github_app_response(token: "ghs_installation", expires_at: expires_at) + } + now = Time.current + bc = create_credential( + grant: "github_app", + token_endpoint: "https://api.github.com/app/installations/456/access_tokens", + client_id: "123", + client_secret: github_private_key.to_pem, + refresh_token: nil + ) + + bc.refresh!(now: now) + bc.reload + + assert_equal "ghs_installation", bc.access_token + assert_nil bc.refresh_token + assert_in_delta expires_at.to_f, bc.expires_at.to_f, 2 + assert_equal "https://api.github.com/app/installations/456/access_tokens", captured[:uri].to_s + assert_match(/\ABearer /, captured[:request]["Authorization"]) + assert_equal "application/vnd.github+json", captured[:request]["Accept"] + end + + test "github_app grant output is delivered through existing token_broker sources" do + Broker::CredentialGrants.github_app_http_client = ->(_uri, _request) { + github_app_response(token: "ghs_brokered", expires_at: 1.hour.from_now) + } + bc = create_credential( + grant: "github_app", + token_endpoint: "https://api.github.com/app/installations/456/access_tokens", + client_id: "123", + client_secret: github_private_key.to_pem, + refresh_token: nil + ) + bc.refresh! + + source = SecretSource.new(source_type: "token_broker", config: { "credential_id" => bc.oid }) + + assert_equal({ "type" => "control_plane", "value" => "ghs_brokered" }, source.to_proxy_source) + assert source.deliverable? + end + + test "github_app grant accepts a base64 encoded private key" do + Broker::CredentialGrants.github_app_http_client = ->(_uri, _request) { + github_app_response(token: "ghs_encoded", expires_at: 1.hour.from_now) + } + bc = create_credential( + grant: "github_app", + token_endpoint: "https://api.github.com/app/installations/456/access_tokens", + client_id: "123", + client_secret: Base64.strict_encode64(github_private_key.to_pem), + refresh_token: nil + ) + + bc.refresh! + bc.reload + + assert_equal "ghs_encoded", bc.access_token + end + + test "github_app grant marks invalid private keys dead" do + bc = create_credential( + grant: "github_app", + token_endpoint: "https://api.github.com/app/installations/456/access_tokens", + client_id: "123", + client_secret: "not-a-private-key", + refresh_token: nil + ) + + bc.refresh! + bc.reload + + assert bc.dead? + assert_equal "invalid_private_key", bc.dead_reason + end + test "preqin grant prefers the Preqin refresh endpoint when it has a refresh token" do client = Minitest::Mock.new expect_refresh(client, returns: result(access_token: "AT", refresh_token: nil)) do |request| @@ -565,6 +688,19 @@ def build_app(**overrides) assert_includes BrokerCredential.refreshable.pluck(:id), bc.id end + test "refreshable includes github_app credentials without a refresh_token" do + bc = create_credential( + grant: "github_app", + token_endpoint: "https://api.github.com/app/installations/456/access_tokens", + client_id: "123", + client_secret: github_private_key.to_pem, + refresh_token: nil + ) + bc.update_columns(last_refresh: 1.hour.ago, next_attempt_at: 1.minute.ago) + + assert_includes BrokerCredential.refreshable.pluck(:id), bc.id + end + # --- delete guard --------------------------------------------------------- test "cannot be destroyed while a token_broker source references it" do diff --git a/services/slackbotv2/AGENTS.md b/services/slackbotv2/AGENTS.md index d25d93b463..f07a942111 100644 --- a/services/slackbotv2/AGENTS.md +++ b/services/slackbotv2/AGENTS.md @@ -30,11 +30,12 @@ lifecycle, harness formatting, and durable execution state belong in `api-rs`. message, block, attachment, and rate limits, including fallback text. - Avoid serializing raw webhook bodies on the hot path or in normal logs. Never log bot tokens, signing secrets, private file URLs, or user file contents. -- Preserve mentioned stop commands, harness/model overrides, late-file repair, - initial thread context, and mention-gated subscribed-message semantics when - refactoring the main callback flow. Unmentioned replies must not be appended - to or interrupt an active execution; collect them when the next mention - refreshes the Slack thread context. +- Preserve stop commands, harness/model overrides, late-file repair, initial + thread context, and ambient subscribed-message semantics when refactoring the + main callback flow. Every allowed reply in a subscribed thread executes (or + durably appends while an execution is active) without requiring a mention; + new top-level messages still require one. Bot-authored messages must stay + excluded outside the trigger-bot allowlist so bots cannot loop. ## Validation diff --git a/services/slackbotv2/src/file-event-trigger.ts b/services/slackbotv2/src/file-event-trigger.ts new file mode 100644 index 0000000000..8b3fdce58a --- /dev/null +++ b/services/slackbotv2/src/file-event-trigger.ts @@ -0,0 +1,185 @@ +/** + * Generic Slack file-event → workflow-run trigger. + * + * The Chat SDK's event dispatch drops file events (file_shared, file_change, + * ...) before any callback fires, so this sidecar parses the raw webhook body + * itself — after the SDK has verified the Slack signature — and, when + * enabled, creates a durable workflow run through the session API + * (POST /api/workflows/runs). + * + * Deployment-neutral and default-off: the target workflow name, accepted + * event types, channel allowlist, and file-title keywords all come from + * configuration (see server.ts). The bot stays timer-free on purpose — any + * settle/debounce behavior belongs in the target workflow, which can sleep + * durably. Per-edit event storms collapse at spawn time via the idempotency + * key `slack-file-event:{workflow}:{file_id}`; with `fileEventDedupeSeconds` + * unset the key never varies (and the server never expires keys), so a file + * triggers at most one run, ever. + */ +import { dispatchWorkflowRun } from './session-api' +import type { SlackbotV2Options } from './types' +import { errorMessage, traceLog, traceWarn } from './utils' + +const DEFAULT_FILE_EVENT_TYPES = ['file_shared', 'file_change'] as const + +const KEYWORD_FILE_FIELDS = ['title', 'name', 'pretty_type'] as const + +export type SlackFileEventTriggerInput = { + channelId: string + eventTs: string + eventType: string + fileId: string + teamId: string +} + +export type SlackFileEventTriggerDeps = { + filesInfo: (fileId: string) => Promise | null> +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value) +} + +function firstString(...values: unknown[]): string { + for (const value of values) { + if (typeof value === 'string' && value.trim()) return value.trim() + } + return '' +} + +export function slackFileEventTriggerInput( + rawBody: string, + eventTypes: readonly string[] +): SlackFileEventTriggerInput | null { + let payload: unknown + try { + payload = JSON.parse(rawBody) + } catch { + return null + } + if (!isRecord(payload) || payload.type !== 'event_callback') return null + const event = payload.event + if (!isRecord(event)) return null + const eventType = typeof event.type === 'string' ? event.type : '' + if (!eventTypes.includes(eventType)) return null + const file = isRecord(event.file) ? event.file : undefined + const fileId = firstString(event.file_id, file?.id) + if (!fileId) return null + return { + // file_shared carries channel_id; file_change does not — the target + // workflow derives the channel from the file's shares in that case. + channelId: firstString(event.channel_id), + eventTs: firstString( + event.event_ts, + typeof payload.event_time === 'number' ? String(payload.event_time) : undefined + ), + eventType, + fileId, + teamId: firstString(payload.team_id) + } +} + +export function fileEventIdempotencyKey( + workflowName: string, + input: SlackFileEventTriggerInput, + dedupeSeconds?: number +): string { + const base = `slack-file-event:${workflowName}:${input.fileId}` + const bucketSeconds = dedupeSeconds ?? 0 + if (bucketSeconds <= 0) return base + const eventSeconds = Math.floor(Number.parseFloat(input.eventTs)) + if (!Number.isFinite(eventSeconds)) return base + return `${base}:${Math.floor(eventSeconds / bucketSeconds)}` +} + +/** + * Returns null when the event is not a matching file event (or the trigger is + * disabled); otherwise a promise for the caller's waitUntil. The returned + * promise never rejects — the webhook path must not see a throw from this + * sidecar. + */ +export function triggerFileEventWorkflow( + rawBody: string, + options: SlackbotV2Options, + deps: SlackFileEventTriggerDeps +): Promise | null { + const workflowName = options.fileEventWorkflowName?.trim() + if (!workflowName) return null + const eventTypes = options.fileEventTypes?.length + ? options.fileEventTypes + : DEFAULT_FILE_EVENT_TYPES + const input = slackFileEventTriggerInput(rawBody, eventTypes) + if (!input) return null + const allowlist = options.fileEventChannelAllowlist ?? [] + // Only enforced when the event carries a channel: file_change events have + // none, and the target workflow applies its own scoping. + if (allowlist.length && input.channelId && !allowlist.includes(input.channelId)) { + return null + } + return dispatchFileEventWorkflow(workflowName, input, options, deps) +} + +async function dispatchFileEventWorkflow( + workflowName: string, + input: SlackFileEventTriggerInput, + options: SlackbotV2Options, + deps: SlackFileEventTriggerDeps +): Promise { + try { + const keywords = (options.fileEventTitleKeywords ?? []) + .map(keyword => keyword.trim().toLowerCase()) + .filter(Boolean) + if (keywords.length) { + const file = await deps.filesInfo(input.fileId) + if (!file) { + traceWarn(options, 'slackbotv2_file_event_files_info_missing', undefined, { + slack_file_id: input.fileId + }) + return + } + const haystack = KEYWORD_FILE_FIELDS.map(key => + typeof file[key] === 'string' ? (file[key] as string) : '' + ) + .join(' ') + .toLowerCase() + if (!keywords.some(keyword => haystack.includes(keyword))) { + traceLog(options, 'slackbotv2_file_event_skipped_keyword_filter', undefined, { + event_type: input.eventType, + slack_file_id: input.fileId + }) + return + } + } + const idempotencyKey = fileEventIdempotencyKey( + workflowName, + input, + options.fileEventDedupeSeconds + ) + const run = await dispatchWorkflowRun(options, { + workflow_name: workflowName, + input: { + channel_id: input.channelId, + event_ts: input.eventTs, + event_type: input.eventType, + file_id: input.fileId, + team_id: input.teamId + }, + idempotency_key: idempotencyKey + }) + traceLog(options, 'slackbotv2_file_event_workflow_dispatched', undefined, { + created: run.created ?? null, + event_type: input.eventType, + idempotency_key: idempotencyKey, + run_id: run.run_id ?? null, + slack_file_id: input.fileId, + workflow_name: workflowName + }) + } catch (error) { + traceWarn(options, 'slackbotv2_file_event_workflow_dispatch_failed', undefined, { + error: errorMessage(error), + event_type: input.eventType, + slack_file_id: input.fileId, + workflow_name: workflowName + }) + } +} diff --git a/services/slackbotv2/src/index.ts b/services/slackbotv2/src/index.ts index ae0b751e17..e57fce283a 100644 --- a/services/slackbotv2/src/index.ts +++ b/services/slackbotv2/src/index.ts @@ -31,6 +31,7 @@ import { } from '@centaur/rendering' import { conflateChatSdkStream } from './conflate' import { resolveHarnessRollout } from './harness-rollout' +import { triggerFileEventWorkflow } from './file-event-trigger' import { observeSeconds, slackbotMetrics } from './metrics' import { renderSlackDisplayText, @@ -420,18 +421,9 @@ export function createSlackbotV2(options: SlackbotV2Options): SlackbotV2 { chat.onSubscribedMessage(async (thread, message) => { if (!(await isAllowedSlackMessage(message, options, logger))) return if (slackRichTextMentionsUser(message.raw, options.botUserId)) message.isMention = true - if (message.isMention !== true) { - traceLog( - options, - 'slackbotv2_subscribed_message_without_mention_ignored', - createHandoffTrace(thread, message, 'append'), - { trigger: 'subscribed_message' } - ) - return - } lateSlackFiles.rememberFilelessMention(thread, message) await handleSlackMessageHandoff(thread, message, { - assistantStatusRequested: true, + assistantStatusRequested: message.isMention === true, mode: 'execute', options, state, @@ -519,6 +511,12 @@ export function createSlackbotV2(options: SlackbotV2Options): SlackbotV2 { } const lateFileTask = lateSlackFiles.repairFromWebhook(rawBody) if (lateFileTask) waitUntil(c, lateFileTask) + // The Chat SDK drops file events before any callback, so the file-event + // workflow trigger parses the (signature-verified) raw body itself. + const fileEventTask = triggerFileEventWorkflow(rawBody, options, { + filesInfo: fileId => slackFilesInfo(options, fileId) + }) + if (fileEventTask) waitUntil(c, fileEventTask) outcome = response.ok ? 'success' : 'error' return new globalThis.Response(await response.text(), { headers: response.headers, diff --git a/services/slackbotv2/src/server.ts b/services/slackbotv2/src/server.ts index 208eb02664..fef616b600 100644 --- a/services/slackbotv2/src/server.ts +++ b/services/slackbotv2/src/server.ts @@ -63,6 +63,12 @@ const options: SlackbotV2Options = { responseMetadataMode: responseMetadataModeEnv('SLACKBOTV2_RESPONSE_METADATA_MODE'), responseServiceTierEnabled: booleanEnv('SLACKBOTV2_RESPONSE_SERVICE_TIER_ENABLED', false), defaultHarnessType: optionalEnv('SLACKBOTV2_DEFAULT_HARNESS'), + fileEventWorkflowName: optionalEnv('SLACKBOTV2_FILE_EVENT_WORKFLOW_NAME'), + fileEventTypes: commaListEnv('SLACKBOTV2_FILE_EVENT_TYPES'), + fileEventChannelAllowlist: commaListEnv('SLACKBOTV2_FILE_EVENT_CHANNELS'), + // Comma-separated only: keywords may contain spaces ("huddle notes"). + fileEventTitleKeywords: commaListEnv('SLACKBOTV2_FILE_EVENT_TITLE_KEYWORDS'), + fileEventDedupeSeconds: optionalNumberEnv('SLACKBOTV2_FILE_EVENT_DEDUPE_SECONDS'), // Same env vars deployers use to override the sandbox harness model // (sandbox.extraEnv); the chart mirrors them here so displayed defaults // track the deployment instead of the baked harness config. @@ -124,6 +130,8 @@ console.log( response_service_tier_enabled: options.responseServiceTierEnabled, steering_reaction_enabled: options.steeringReactionEnabled, steering_reaction_name: options.steeringReactionName, + file_event_trigger_enabled: Boolean(options.fileEventWorkflowName), + file_event_workflow: options.fileEventWorkflowName ?? null, port: server.port, api_url: apiUrl }) @@ -150,6 +158,16 @@ function numberEnv(name: string, fallback: number): number { return optionalNumberEnv(name) ?? fallback } +function commaListEnv(name: string): string[] | undefined { + const value = optionalEnv(name) + if (!value) return undefined + const parts = value + .split(',') + .map(part => part.trim()) + .filter(Boolean) + return parts.length ? parts : undefined +} + function booleanEnv(name: string, fallback: boolean): boolean { const value = optionalEnv(name) if (!value) return fallback diff --git a/services/slackbotv2/src/session-api.ts b/services/slackbotv2/src/session-api.ts index bada60f227..cea178372b 100644 --- a/services/slackbotv2/src/session-api.ts +++ b/services/slackbotv2/src/session-api.ts @@ -551,6 +551,46 @@ export async function forwardToSessionApi( return openSessionEventStream(options, input) } +export type SlackbotV2WorkflowRunRequest = { + workflow_name: string + input: Record + idempotency_key?: string +} + +export type SlackbotV2WorkflowRunResponse = { + ok?: boolean + run_id?: string + task_id?: string + status?: string + created?: boolean +} + +export async function dispatchWorkflowRun( + options: SlackbotV2Options, + request: SlackbotV2WorkflowRunRequest +): Promise { + const action = `dispatch workflow run ${request.workflow_name}` + const response = await recordSessionApiOperation( + 'create_workflow_run', + () => + fetchWithTimeout( + options.fetch ?? globalThis.fetch, + new URL('/api/workflows/runs', ensureTrailingSlash(options.apiUrl)), + { + body: JSON.stringify(request), + headers: apiHeaders(options), + method: 'POST' + }, + sessionApiTimeoutMs(options), + action + ), + sessionApiTimeoutMs(options), + action + ) + await ensureApiOk(response, action) + return (await response.json()) as SlackbotV2WorkflowRunResponse +} + export async function dispatchSlackBlockAction( options: SlackbotV2Options, payload: SlackbotV2BlockActionPayload diff --git a/services/slackbotv2/src/types.ts b/services/slackbotv2/src/types.ts index 7217022c64..86716ac8e9 100644 --- a/services/slackbotv2/src/types.ts +++ b/services/slackbotv2/src/types.ts @@ -166,6 +166,37 @@ export type SlackbotV2Options = { */ defaultHarnessType?: string fetch?: SlackbotV2Fetch + /** + * Workflow name to run when a matching Slack file event arrives + * (SLACKBOTV2_FILE_EVENT_WORKFLOW_NAME). Unset disables the file-event + * trigger entirely. See file-event-trigger.ts. + */ + fileEventWorkflowName?: string + /** + * Slack event types that trigger the file-event workflow + * (SLACKBOTV2_FILE_EVENT_TYPES). Defaults to file_shared + file_change; the + * Slack app must be subscribed to each listed bot event. + */ + fileEventTypes?: readonly string[] + /** + * Channel ids whose file events may trigger the workflow + * (SLACKBOTV2_FILE_EVENT_CHANNELS). Only enforced when the event carries a + * channel — file_change events do not. Empty/unset allows all channels. + */ + fileEventChannelAllowlist?: readonly string[] + /** + * Case-insensitive substrings matched against the file's files.info + * title/name/pretty_type before dispatching + * (SLACKBOTV2_FILE_EVENT_TITLE_KEYWORDS). Empty/unset skips the lookup and + * dispatches for every matching file event. + */ + fileEventTitleKeywords?: readonly string[] + /** + * Time-bucket the dispatch idempotency key in seconds + * (SLACKBOTV2_FILE_EVENT_DEDUPE_SECONDS). Unset/0: one run per file, ever + * (workflow idempotency keys never expire server-side). + */ + fileEventDedupeSeconds?: number /** * Deployment-configured default model per harness wire value (claudecode | * codex), from the CLAUDE_MODEL / CODEX_MODEL env vars the chart mirrors diff --git a/services/slackbotv2/test/chat-sdk-emulate.test.ts b/services/slackbotv2/test/chat-sdk-emulate.test.ts index 0fdde23fb3..f17bc94710 100644 --- a/services/slackbotv2/test/chat-sdk-emulate.test.ts +++ b/services/slackbotv2/test/chat-sdk-emulate.test.ts @@ -436,7 +436,7 @@ describe('slackbotv2', () => { expect(codexApi.workflowEvents).toHaveLength(1) }) - it('collects ignored subscribed messages when the bot is next mentioned', async () => { + it('executes unmentioned subscribed messages as ambient thread replies', async () => { const parent = await postUserMessage('The deploy context is above.') const firstMention = await postUserMessage( `<@${BOT_USER_ID}> run with this screenshot`, @@ -498,8 +498,8 @@ describe('slackbotv2', () => { expect(followUpResponse.status).toBe(200) await Promise.all(followUpWaits) - expect(codexApi.appends).toHaveLength(1) - expect(codexApi.executes).toHaveLength(1) + expect(codexApi.appends).toHaveLength(2) + expect(codexApi.executes).toHaveLength(2) const secondMention = await postUserMessage(`<@${BOT_USER_ID}> now execute with the latest`, parent.ts) const secondMentionWaits: Promise[] = [] @@ -524,12 +524,13 @@ describe('slackbotv2', () => { expect(secondMentionResponse.status).toBe(200) await Promise.all(secondMentionWaits) - expect(codexApi.appends).toHaveLength(2) + expect(codexApi.appends).toHaveLength(3) expect(codexApi.creates.map(create => create.threadKey)).toEqual([ + threadKey(parent.ts), threadKey(parent.ts), threadKey(parent.ts) ]) - expect(codexApi.executes).toHaveLength(2) + expect(codexApi.executes).toHaveLength(3) const firstAppend = codexApi.appends[0]! expect(firstAppend.threadKey).toBe(threadKey(parent.ts)) @@ -582,26 +583,36 @@ describe('slackbotv2', () => { ) expect(JSON.stringify(firstInputLine)).not.toContain('data:image/png;base64') - const secondMentionAppend = codexApi.appends[1]! + const followUpAppend = codexApi.appends[1]! + expect(followUpAppend.threadKey).toBe(threadKey(parent.ts)) + expect(followUpAppend.body.messages.map(message => message.client_message_id)).toEqual([ + followUp.ts + ]) + expect(sessionMessageTexts(followUpAppend.body.messages)).toEqual([ + 'Additional detail for the subscribed thread.' + ]) + const followUpExecute = codexApi.executes[1]! + expect(followUpExecute.body.idempotency_key).toBe(followUp.ts) + expect(JSON.stringify(JSON.parse(followUpExecute.body.input_lines[0]!))).toContain( + 'Additional detail for the subscribed thread.' + ) + + const secondMentionAppend = codexApi.appends[2]! expect(secondMentionAppend.threadKey).toBe(threadKey(parent.ts)) expect(secondMentionAppend.body.messages.map(message => message.client_message_id)).toEqual([ - followUp.ts, secondMention.ts ]) - expect(sessionMessageTexts(secondMentionAppend.body.messages)[0]).toBe( - 'Additional detail for the subscribed thread.' - ) - expect(sessionMessageTexts(secondMentionAppend.body.messages)[1]).toContain( + expect(sessionMessageTexts(secondMentionAppend.body.messages)[0]).toContain( 'now execute with the latest' ) - const secondExecute = codexApi.executes[1]! + const secondExecute = codexApi.executes[2]! expect(secondExecute.body.idempotency_key).toBe(secondMention.ts) expect(JSON.stringify(JSON.parse(secondExecute.body.input_lines[0]!))).toContain( 'now execute with the latest' ) expectSlackPlanStreamShape(slackApi.calls, { - answers: ['Executed request 1.', 'Executed request 2.'], + answers: ['Executed request 1.', 'Executed request 2.', 'Executed request 3.'], parentTs: parent.ts }) const assistantStatuses = slackApi.calls @@ -609,8 +620,8 @@ describe('slackbotv2', () => { .map(call => stringField(call.body.status)) expect(assistantStatuses[0]).toBe('Thinking...') expect(assistantStatuses.at(-1)).toBe('') - expect(assistantStatuses.filter(status => status === 'Thinking...').length).toBeGreaterThanOrEqual(2) - expect(assistantStatuses.filter(status => status === '').length).toBeGreaterThanOrEqual(2) + expect(assistantStatuses.filter(status => status === 'Thinking...').length).toBeGreaterThanOrEqual(3) + expect(assistantStatuses.filter(status => status === '').length).toBeGreaterThanOrEqual(3) expect( slackApi.calls .filter(call => call.method === 'assistant.threads.setTitle') @@ -618,8 +629,10 @@ describe('slackbotv2', () => { ).toEqual([ 'run with this screenshot', 'Codex request 1', + 'Additional detail for the subscribed thread.', + 'Codex request 2', 'now execute with the latest', - 'Codex request 2' + 'Codex request 3' ]) const text = await threadText(parent.ts) @@ -633,13 +646,15 @@ describe('slackbotv2', () => { expect(text).not.toContain('tests passed') expect(text).toContain('Executed request 1.') expect(text).toContain('Executed request 2.') + expect(text).toContain('Executed request 3.') const renderedReplies = (await threadTexts(parent.ts)).filter(reply => reply.includes('Executed request') ) - expect(renderedReplies).toHaveLength(2) + expect(renderedReplies).toHaveLength(3) expectSlackRenderedReply(renderedReplies[0]!, 'Executed request 1.') expectSlackRenderedReply(renderedReplies[1]!, 'Executed request 2.') + expectSlackRenderedReply(renderedReplies[2]!, 'Executed request 3.') }) // The paragraph break (`\n\n`) after the model value is deliberate: the @@ -2388,7 +2403,7 @@ describe('slackbotv2', () => { ) }) - it('ignores unmentioned subscribed messages during a stream, including stop', async () => { + it('appends unmentioned subscribed messages during a stream and honors an unmentioned stop', async () => { codexApi.autoRespond = false const parent = await postUserMessage('Context before the long run.') @@ -2438,6 +2453,10 @@ describe('slackbotv2', () => { expect(followUpResponse.status).toBe(200) await Promise.all(followUpWaits) + // The unmentioned reply is durably appended while the stream is active, but + // does not start a second execution. + await waitFor(() => codexApi.appends.length === 2) + expect(codexApi.executes).toHaveLength(1) const stop = await postUserMessage('stop', parent.ts) const stopWaits: Promise[] = [] @@ -2461,8 +2480,12 @@ describe('slackbotv2', () => { expect(stopResponse.status).toBe(200) await Promise.all(stopWaits) - expect(codexApi.creates).toHaveLength(1) - expect(codexApi.appends).toHaveLength(1) + // The unmentioned stop interrupts the active execution instead of being + // appended or executed. + expect(codexApi.interrupts).toHaveLength(1) + expect(codexApi.interrupts[0]!.threadKey).toBe(threadKey(parent.ts)) + expect(codexApi.interrupts[0]!.body.reason).toContain(USER_ID) + expect(codexApi.appends).toHaveLength(2) expect(codexApi.executes).toHaveLength(1) codexApi.closeStreams() @@ -5956,6 +5979,7 @@ type MockSessionApi = { close(): Promise closeStreams(): void creates: MockSessionRequest[] + interrupts: MockSessionRequest<{ reason: string }>[] emitOutputLine(threadKey: string, line: string, executionId?: string): void emitOutputLines(threadKey: string, lines: string[], executionId?: string): void emitSessionEvent(threadKey: string, event: string, data: unknown, executionId?: string): void @@ -5979,6 +6003,7 @@ async function startMockCodexApi(): Promise { const eventRequests: MockSessionEventRequest[] = [] const events: MockSessionEvent[] = [] const executes: MockSessionRequest[] = [] + const interrupts: MockSessionRequest<{ reason: string }>[] = [] const idempotentExecutions = new Map() const streams = new Set() const workflowEvents: MockWorkflowEventRequest[] = [] @@ -6002,6 +6027,7 @@ async function startMockCodexApi(): Promise { events, eventRequests, executes, + interrupts, get autoRespond() { return autoRespond }, @@ -6046,6 +6072,7 @@ async function startMockCodexApi(): Promise { creates, eventRequests, executes, + interrupts, reset() { appends.length = 0 createResponses.length = 0 @@ -6053,6 +6080,7 @@ async function startMockCodexApi(): Promise { eventRequests.length = 0 events.length = 0 executes.length = 0 + interrupts.length = 0 idempotentExecutions.clear() executeHoldRelease?.() executeHold = null @@ -6155,6 +6183,7 @@ async function handleMockCodexRequest( eventRequests: MockSessionEventRequest[] executeHold: Promise | null executes: MockSessionRequest[] + interrupts: MockSessionRequest<{ reason: string }>[] failNextExecuteAfterAccept: boolean failNextEvents: boolean failNextExecute: boolean @@ -6175,7 +6204,7 @@ async function handleMockCodexRequest( await sendWebResponse(res, Response.json({ ok: true })) return } - const match = /^\/api\/session\/([^/]+)(?:\/(messages|execute|events))?$/.exec(url.pathname) + const match = /^\/api\/session\/([^/]+)(?:\/(messages|execute|events|interrupt))?$/.exec(url.pathname) if (!match?.[1]) { await sendWebResponse(res, new Response('not found', { status: 404 })) return @@ -6241,6 +6270,12 @@ async function handleMockCodexRequest( } const request = await nodeRequestToWebRequest(req, url) + if (endpoint === 'interrupt') { + const body = (await request.json()) as { reason: string } + input.interrupts.push({ threadKey, body }) + await sendWebResponse(res, Response.json({ execution_id: 'exe-interrupted', interrupted: true })) + return + } if (endpoint === 'messages') { const body = (await request.json()) as SlackbotV2AppendMessagesRequest input.appends.push({ threadKey, body }) diff --git a/services/slackbotv2/test/file-event-trigger.test.ts b/services/slackbotv2/test/file-event-trigger.test.ts new file mode 100644 index 0000000000..03a4cac302 --- /dev/null +++ b/services/slackbotv2/test/file-event-trigger.test.ts @@ -0,0 +1,272 @@ +import { describe, expect, it } from 'bun:test' +import { + fileEventIdempotencyKey, + slackFileEventTriggerInput, + triggerFileEventWorkflow, + type SlackFileEventTriggerDeps +} from '../src/file-event-trigger' +import type { SlackbotV2Options } from '../src/types' + +const DEFAULT_TYPES = ['file_shared', 'file_change'] as const + +function eventBody(event: Record, extra: Record = {}): string { + return JSON.stringify({ + type: 'event_callback', + team_id: 'T1', + event_time: 1_780_000_000, + event, + ...extra + }) +} + +const FILE_SHARED_BODY = eventBody({ + type: 'file_shared', + channel_id: 'C123', + file_id: 'F123', + file: { id: 'F123' }, + event_ts: '1780000000.100' +}) + +const FILE_CHANGE_BODY = eventBody({ + type: 'file_change', + file_id: 'F123', + file: { id: 'F123' }, + event_ts: '1780000100.200' +}) + +function options(overrides: Partial = {}): SlackbotV2Options { + return { + apiUrl: 'http://session.test', + apiKey: 'test-api-key', + botToken: 'xoxb-test', + signingSecret: 'test', + fileEventWorkflowName: 'file_review', + ...overrides + } +} + +function deps(file: Record | null = null): SlackFileEventTriggerDeps & { + calls: string[] +} { + const calls: string[] = [] + return { + calls, + filesInfo: async (fileId: string) => { + calls.push(fileId) + return file + } + } +} + +type CapturedRequest = { url: string; init: RequestInit } + +function captureFetch( + responses: Response[] = [] +): { fetch: SlackbotV2Options['fetch']; requests: CapturedRequest[] } { + const requests: CapturedRequest[] = [] + return { + requests, + fetch: async (input, init) => { + requests.push({ url: String(input), init: init ?? {} }) + return responses.shift() ?? Response.json({ ok: true, run_id: 'wfr_1', created: true }) + } + } +} + +describe('slackFileEventTriggerInput', () => { + it('parses file_shared with channel', () => { + const input = slackFileEventTriggerInput(FILE_SHARED_BODY, DEFAULT_TYPES) + expect(input).toEqual({ + channelId: 'C123', + eventTs: '1780000000.100', + eventType: 'file_shared', + fileId: 'F123', + teamId: 'T1' + }) + }) + + it('parses channel-less file_change', () => { + const input = slackFileEventTriggerInput(FILE_CHANGE_BODY, DEFAULT_TYPES) + expect(input?.channelId).toBe('') + expect(input?.eventType).toBe('file_change') + expect(input?.fileId).toBe('F123') + }) + + it('falls back to event.file.id when file_id is absent', () => { + const body = eventBody({ type: 'file_shared', file: { id: 'F9' } }) + expect(slackFileEventTriggerInput(body, DEFAULT_TYPES)?.fileId).toBe('F9') + }) + + it('rejects non-file events, other payload types, and invalid JSON', () => { + expect( + slackFileEventTriggerInput(eventBody({ type: 'message', text: 'hi' }), DEFAULT_TYPES) + ).toBeNull() + expect( + slackFileEventTriggerInput( + JSON.stringify({ type: 'url_verification', challenge: 'x' }), + DEFAULT_TYPES + ) + ).toBeNull() + expect(slackFileEventTriggerInput('not json', DEFAULT_TYPES)).toBeNull() + expect( + slackFileEventTriggerInput(eventBody({ type: 'file_shared' }), DEFAULT_TYPES) + ).toBeNull() // no file id anywhere + }) + + it('respects a custom event-type list', () => { + expect(slackFileEventTriggerInput(FILE_CHANGE_BODY, ['file_shared'])).toBeNull() + }) +}) + +describe('fileEventIdempotencyKey', () => { + const input = slackFileEventTriggerInput(FILE_SHARED_BODY, DEFAULT_TYPES)! + + it('is stable and unbucketed by default', () => { + expect(fileEventIdempotencyKey('file_review', input)).toBe( + 'slack-file-event:file_review:F123' + ) + expect(fileEventIdempotencyKey('file_review', input, 0)).toBe( + 'slack-file-event:file_review:F123' + ) + }) + + it('buckets by event time when dedupeSeconds is set', () => { + expect(fileEventIdempotencyKey('file_review', input, 3600)).toBe( + `slack-file-event:file_review:F123:${Math.floor(1_780_000_000 / 3600)}` + ) + }) + + it('falls back to the unbucketed key when event_ts is unparsable', () => { + expect( + fileEventIdempotencyKey('file_review', { ...input, eventTs: '' }, 3600) + ).toBe('slack-file-event:file_review:F123') + }) +}) + +describe('triggerFileEventWorkflow', () => { + it('is disabled without a configured workflow name', () => { + const { fetch, requests } = captureFetch() + expect( + triggerFileEventWorkflow( + FILE_SHARED_BODY, + options({ fetch, fileEventWorkflowName: undefined }), + deps() + ) + ).toBeNull() + expect(requests).toEqual([]) + }) + + it('POSTs a workflow run with the expected shape', async () => { + const { fetch, requests } = captureFetch() + const task = triggerFileEventWorkflow(FILE_SHARED_BODY, options({ fetch }), deps()) + expect(task).not.toBeNull() + await task + + expect(requests).toHaveLength(1) + expect(requests[0]!.url).toBe('http://session.test/api/workflows/runs') + expect(requests[0]!.init.method).toBe('POST') + const headers = requests[0]!.init.headers as Record + expect(headers['content-type']).toBe('application/json') + expect(headers.authorization).toBe('Bearer test-api-key') + const body = JSON.parse(String(requests[0]!.init.body)) + expect(body).toEqual({ + workflow_name: 'file_review', + input: { + channel_id: 'C123', + event_ts: '1780000000.100', + event_type: 'file_shared', + file_id: 'F123', + team_id: 'T1' + }, + idempotency_key: 'slack-file-event:file_review:F123' + }) + }) + + it('dispatches channel-less file_change events even with a channel allowlist', async () => { + const { fetch, requests } = captureFetch() + const task = triggerFileEventWorkflow( + FILE_CHANGE_BODY, + options({ fetch, fileEventChannelAllowlist: ['COTHER'] }), + deps() + ) + await task + expect(requests).toHaveLength(1) + const body = JSON.parse(String(requests[0]!.init.body)) + expect(body.input.channel_id).toBe('') + }) + + it('drops file_shared events outside the channel allowlist', () => { + const { fetch, requests } = captureFetch() + expect( + triggerFileEventWorkflow( + FILE_SHARED_BODY, + options({ fetch, fileEventChannelAllowlist: ['COTHER'] }), + deps() + ) + ).toBeNull() + expect(requests).toEqual([]) + }) + + it('applies the title-keyword filter through files.info', async () => { + const matching = deps({ title: 'Huddle notes: 8/12/26 in #general' }) + const { fetch, requests } = captureFetch() + await triggerFileEventWorkflow( + FILE_SHARED_BODY, + options({ fetch, fileEventTitleKeywords: ['huddle notes', 'huddle transcript'] }), + matching + ) + expect(matching.calls).toEqual(['F123']) + expect(requests).toHaveLength(1) + + const nonMatching = deps({ title: 'Q3 planning doc' }) + const { fetch: fetch2, requests: requests2 } = captureFetch() + await triggerFileEventWorkflow( + FILE_SHARED_BODY, + options({ fetch: fetch2, fileEventTitleKeywords: ['huddle notes'] }), + nonMatching + ) + expect(requests2).toEqual([]) + }) + + it('swallows files.info failures and missing files', async () => { + const failing: SlackFileEventTriggerDeps = { + filesInfo: async () => { + throw new Error('files.info exploded') + } + } + const { fetch, requests } = captureFetch() + await triggerFileEventWorkflow( + FILE_SHARED_BODY, + options({ fetch, fileEventTitleKeywords: ['huddle notes'] }), + failing + ) + expect(requests).toEqual([]) + + const { fetch: fetch2, requests: requests2 } = captureFetch() + await triggerFileEventWorkflow( + FILE_SHARED_BODY, + options({ fetch: fetch2, fileEventTitleKeywords: ['huddle notes'] }), + deps(null) + ) + expect(requests2).toEqual([]) + }) + + it('swallows non-2xx workflow API responses', async () => { + const { fetch } = captureFetch([new Response('nope', { status: 500 })]) + const task = triggerFileEventWorkflow(FILE_SHARED_BODY, options({ fetch }), deps()) + expect(task).not.toBeNull() + await task // must not reject + }) + + it('ignores non-file payloads', () => { + const { fetch, requests } = captureFetch() + expect( + triggerFileEventWorkflow( + eventBody({ type: 'message', text: 'hello' }), + options({ fetch }), + deps() + ) + ).toBeNull() + expect(requests).toEqual([]) + }) +}) diff --git a/tools/productivity/gsuite/cli.py b/tools/productivity/gsuite/cli.py index f011bc5664..4ce60102dc 100644 --- a/tools/productivity/gsuite/cli.py +++ b/tools/productivity/gsuite/cli.py @@ -376,6 +376,7 @@ def calendar_create( description: str = typer.Option(None, "--description", "-d", help="Event description"), location: str = typer.Option(None, "--location", "-l", help="Event location"), attendees: str = typer.Option(None, "--attendees", "-a", help="Comma-separated emails"), + meet: bool = typer.Option(False, "--meet", "-m", help="Add a Google Meet link"), ): """Create a calendar event. @@ -383,6 +384,7 @@ def calendar_create( gsuite calendar create "Team Meeting" "2024-01-15T10:00:00Z" "2024-01-15T11:00:00Z" gsuite calendar create "All-day event" "2024-01-15" "2024-01-16" gsuite calendar create "Meeting" "..." "..." -a "a@b.com,c@d.com" -l "Room 1" + gsuite calendar create "Standup" "..." "..." --meet """ from .client import calendar_create_event @@ -397,9 +399,12 @@ def calendar_create( description=description, location=location, attendees=attendee_list, + conference=meet, ) console.print("[green]✓ Event created[/]") console.print(f"[dim]{result['html_link']}[/]") + if result.get("meet_link"): + console.print(f"[dim]{result['meet_link']}[/]") except Exception as e: console.print(f"[red]Error: {e}[/]") raise typer.Exit(1) @@ -415,6 +420,12 @@ def calendar_update( description: str = typer.Option(None, "--description", "-d", help="New description"), location: str = typer.Option(None, "--location", "-l", help="New location"), add_attendees: str = typer.Option(None, "--add", "-a", help="Comma-separated emails to add"), + meet: bool = typer.Option(False, "--meet", "-m", help="Add a Google Meet link"), + notify: bool = typer.Option( + None, + "--notify/--no-notify", + help="Email attendees (default: on a material change to an event with attendees)", + ), ): """Update a calendar event. @@ -422,6 +433,8 @@ def calendar_update( gsuite calendar update "event_id" --summary "New Title" gsuite calendar update "event_id" --start "2024-01-15T14:00:00Z" --end "2024-01-15T15:00:00Z" gsuite calendar update "event_id" --add "a@b.com,c@d.com" + gsuite calendar update "event_id" --meet + gsuite calendar update "event_id" --start "..." --end "..." --no-notify """ from .client import calendar_update_event @@ -437,14 +450,42 @@ def calendar_update( description=description, location=location, add_attendees=attendee_list, + conference=meet, + notify=notify, ) console.print("[green]✓ Event updated[/]") console.print(f"[dim]{result['html_link']}[/]") + if result.get("meet_link"): + console.print(f"[dim]{result['meet_link']}[/]") except Exception as e: console.print(f"[red]Error: {e}[/]") raise typer.Exit(1) +@calendar_app.command("delete") +def calendar_delete( + event_id: str = typer.Argument(..., help="Event ID"), + calendar: str = typer.Option("primary", "--calendar", "-c", help="Calendar ID"), + notify: bool = typer.Option( + True, "--notify/--no-notify", help="Email attendees that the event was cancelled" + ), +): + """Delete a calendar event. + + Examples: + gsuite calendar delete "event_id" + gsuite calendar delete "event_id" --no-notify + """ + from .client import calendar_delete_event + + try: + calendar_delete_event(event_id=event_id, calendar_id=calendar, notify=notify) + console.print("[green]✓ Event deleted[/]") + except Exception as e: + console.print(f"[red]Error: {e}[/]") + raise typer.Exit(1) from e + + @calendar_app.command("rsvp") def calendar_rsvp_cmd( event_id: str = typer.Argument(..., help="Event ID"), diff --git a/tools/productivity/gsuite/client.py b/tools/productivity/gsuite/client.py index d896456940..25dfa51507 100644 --- a/tools/productivity/gsuite/client.py +++ b/tools/productivity/gsuite/client.py @@ -6,6 +6,7 @@ import os import re import urllib.request +import uuid from email.mime.text import MIMEText from pathlib import Path from urllib.parse import quote, urljoin, urlparse, urlsplit @@ -469,6 +470,84 @@ def calendar_events( return events +# Google Meet is not a plain event field: the body carries a conferencing +# createRequest and the call must opt in with conferenceDataVersion=1, or +# Calendar silently drops conferenceData and returns an event with no link. +_MEET_SOLUTION_KEY = "hangoutsMeet" + + +def _conference_create_request() -> dict: + """Body fragment asking Calendar to mint a Google Meet conference. + + ``requestId`` is the mint's idempotency key: Calendar reuses the existing + conference when the same id is replayed, so it must be fresh per event. + """ + return { + "createRequest": { + "requestId": uuid.uuid4().hex, + "conferenceSolutionKey": {"type": _MEET_SOLUTION_KEY}, + } + } + + +def _is_material_change( + *, + summary: str | None, + start: str | None, + end: str | None, + location: str | None, + attendees_added: bool, + conference_added: bool, +) -> bool: + """Whether an edit is one an attendee needs to be told about. + + When, where, what it is called, who else is coming, and whether there is + now a call to join. Description-only edits are deliberately excluded: they + are the common case for tidying wording, and mailing everyone for that + trains people to ignore the notices that matter. + + The field tests are truthiness, not ``is not None``, to match how + calendar_update_event applies them: it writes a field only when the value + is truthy, so an empty string changes nothing and must not send mail. + Likewise ``attendees_added`` is whether the merge actually appended + somebody, not whether the caller passed a list -- re-passing an existing + guest is a no-op nobody needs to hear about. + """ + return ( + any(field for field in (summary, start, end, location)) + or attendees_added + or conference_added + ) + + +def _meet_link(event: dict) -> str: + """Return the event's Google Meet URL, or "" when it has none.""" + link = event.get("hangoutLink") + if link: + return link + + for entry in event.get("conferenceData", {}).get("entryPoints", []): + if entry.get("entryPointType") == "video" and entry.get("uri"): + return entry["uri"] + + return "" + + +def _await_meet_link(service, calendar_id: str, event: dict) -> str: + """Return the Meet URL, re-fetching the event once if the mint is pending. + + Calendar can answer the write before the conference exists, leaving a + pending createRequest and no entry points. One re-fetch settles it in + practice and spares callers from polling. + """ + link = _meet_link(event) + if link or not event.get("id"): + return link + + refreshed = service.events().get(calendarId=calendar_id, eventId=event["id"]).execute() + return _meet_link(refreshed) + + def calendar_create_event( summary: str, start: str, @@ -477,6 +556,7 @@ def calendar_create_event( description: str | None = None, location: str | None = None, attendees: list[str] | None = None, + conference: bool = False, ) -> dict: """Create a calendar event. @@ -488,9 +568,10 @@ def calendar_create_event( description: Event description location: Event location attendees: List of attendee emails + conference: Attach a Google Meet conference to the event Returns: - Dict with id, html_link + Dict with id, html_link, meet_link (meet_link is "" without conference) """ service = get_calendar_service() @@ -512,12 +593,18 @@ def calendar_create_event( if attendees: event["attendees"] = [{"email": email} for email in attendees] + insert_kwargs = {} + if conference: + event["conferenceData"] = _conference_create_request() + insert_kwargs["conferenceDataVersion"] = 1 + result = ( service.events() .insert( calendarId=calendar_id, body=event, sendUpdates="all" if attendees else "none", + **insert_kwargs, ) .execute() ) @@ -525,6 +612,7 @@ def calendar_create_event( return { "id": result.get("id", ""), "html_link": result.get("htmlLink", ""), + "meet_link": _await_meet_link(service, calendar_id, result) if conference else "", } @@ -537,6 +625,8 @@ def calendar_update_event( description: str | None = None, location: str | None = None, add_attendees: list[str] | None = None, + conference: bool = False, + notify: bool | None = None, ) -> dict: """Update a calendar event. @@ -549,9 +639,13 @@ def calendar_update_event( description: New description location: New location add_attendees: List of attendee emails to add + conference: Attach a Google Meet conference if the event lacks one + notify: Email the attendees. Defaults to notifying when the event has + attendees and the change is material (see _is_material_change); + pass True or False to decide explicitly. Returns: - Dict with id, html_link + Dict with id, html_link, meet_link ("" when the event has no Meet) """ service = get_calendar_service() @@ -575,21 +669,46 @@ def calendar_update_event( else: event["end"] = {"date": end} + attendees_added = False if add_attendees: existing = event.get("attendees", []) existing_emails = {a.get("email", "").lower() for a in existing} for email in add_attendees: if email.lower() not in existing_emails: existing.append({"email": email}) + existing_emails.add(email.lower()) + attendees_added = True event["attendees"] = existing + # conferenceDataVersion defaults to 0, under which Calendar ignores the + # conferenceData we just read back -- that is what keeps an existing Meet + # intact on an ordinary update. + update_kwargs = {} + conference_added = False + if conference: + update_kwargs["conferenceDataVersion"] = 1 + if not event.get("conferenceData"): + event["conferenceData"] = _conference_create_request() + conference_added = True + + if notify is None: + notify = bool(event.get("attendees")) and _is_material_change( + summary=summary, + start=start, + end=end, + location=location, + attendees_added=attendees_added, + conference_added=conference_added, + ) + result = ( service.events() .update( calendarId=calendar_id, eventId=event_id, body=event, - sendUpdates="all" if add_attendees else "none", + sendUpdates="all" if notify else "none", + **update_kwargs, ) .execute() ) @@ -597,9 +716,38 @@ def calendar_update_event( return { "id": result.get("id", ""), "html_link": result.get("htmlLink", ""), + "meet_link": _await_meet_link(service, calendar_id, result) if conference else "", } +def calendar_delete_event( + event_id: str, + calendar_id: str = "primary", + notify: bool = True, +) -> dict: + """Delete a calendar event. + + Args: + event_id: Event ID to delete + calendar_id: Calendar ID (default: primary) + notify: Email the attendees that the event was cancelled. Defaults to + True: a cancellation nobody is told about just leaves the meeting + sitting on everyone's calendar. + + Returns: + Dict with id, deleted + """ + service = get_calendar_service() + + service.events().delete( + calendarId=calendar_id, + eventId=event_id, + sendUpdates="all" if notify else "none", + ).execute() + + return {"id": event_id, "deleted": True} + + def calendar_rsvp( event_id: str, response: str, @@ -2812,6 +2960,7 @@ def calendar_create_event( description: str | None = None, location: str | None = None, attendees: list[str] | None = None, + conference: bool = False, ) -> dict: """Create a calendar event. @@ -2823,9 +2972,10 @@ def calendar_create_event( description: Event description location: Event location attendees: List of attendee emails + conference: Attach a Google Meet conference to the event Returns: - Dict with id, html_link + Dict with id, html_link, meet_link """ return calendar_create_event( summary, @@ -2835,6 +2985,7 @@ def calendar_create_event( description=description, location=location, attendees=attendees, + conference=conference, ) def calendar_update_event( @@ -2847,6 +2998,8 @@ def calendar_update_event( description: str | None = None, location: str | None = None, add_attendees: list[str] | None = None, + conference: bool = False, + notify: bool | None = None, ) -> dict: """Update a calendar event. @@ -2859,9 +3012,12 @@ def calendar_update_event( description: New description location: New location add_attendees: List of attendee emails to add + conference: Attach a Google Meet conference if the event lacks one + notify: Email the attendees; defaults to notifying on a material + change to an event that has attendees Returns: - Dict with id, html_link + Dict with id, html_link, meet_link """ return calendar_update_event( event_id, @@ -2872,8 +3028,28 @@ def calendar_update_event( description=description, location=location, add_attendees=add_attendees, + conference=conference, + notify=notify, ) + def calendar_delete_event( + self, + event_id: str, + calendar_id: str = "primary", + notify: bool = True, + ) -> dict: + """Delete a calendar event. + + Args: + event_id: Event ID to delete + calendar_id: Calendar ID (default: primary) + notify: Email the attendees that the event was cancelled + + Returns: + Dict with id, deleted + """ + return calendar_delete_event(event_id, calendar_id=calendar_id, notify=notify) + def calendar_rsvp( self, event_id: str, diff --git a/tools/productivity/gsuite/test_client.py b/tools/productivity/gsuite/test_client.py index 86b458962b..9192871987 100644 --- a/tools/productivity/gsuite/test_client.py +++ b/tools/productivity/gsuite/test_client.py @@ -1,5 +1,6 @@ import base64 import tomllib +import uuid from pathlib import Path import pytest @@ -1147,3 +1148,364 @@ def test_docs_insert_passes_expected_revision_id_through(monkeypatch): assert len(calls) == 2 assert "writeControl" not in calls[0]["body"] assert calls[1]["body"]["writeControl"] == {"requiredRevisionId": "rev-99"} + + +class _FakeEventsApi: + def __init__(self, insert_result=None, update_result=None, get_results=None): + self.insert_calls: list[dict] = [] + self.update_calls: list[dict] = [] + self.get_calls: list[dict] = [] + self.delete_calls: list[dict] = [] + self._insert_result = insert_result or {} + self._update_result = update_result or {} + self._get_results = list(get_results or []) + + def insert(self, **kwargs): + self.insert_calls.append(kwargs) + return _CreateRequest(self._insert_result) + + def update(self, **kwargs): + self.update_calls.append(kwargs) + return _CreateRequest(self._update_result) + + def get(self, **kwargs): + self.get_calls.append(kwargs) + return _CreateRequest(self._get_results.pop(0) if self._get_results else {}) + + def delete(self, **kwargs): + self.delete_calls.append(kwargs) + return _CreateRequest({}) + + +class _FakeCalendarService: + def __init__(self, **kwargs): + self.events_api = _FakeEventsApi(**kwargs) + + def events(self): + return self.events_api + + +def test_calendar_create_event_without_conference_leaves_body_untouched(monkeypatch): + fake_service = _FakeCalendarService( + insert_result={"id": "event-123", "htmlLink": "https://calendar.google.com/event-123"} + ) + monkeypatch.setattr(client, "get_calendar_service", lambda: fake_service) + + result = client.calendar_create_event("Standup", "2026-09-01T09:00:00Z", "2026-09-01T09:30:00Z") + + insert_call = fake_service.events_api.insert_calls[0] + assert "conferenceData" not in insert_call["body"] + assert "conferenceDataVersion" not in insert_call + assert fake_service.events_api.get_calls == [] + assert result == { + "id": "event-123", + "html_link": "https://calendar.google.com/event-123", + "meet_link": "", + } + + +def test_calendar_create_event_requests_google_meet(monkeypatch): + fake_service = _FakeCalendarService( + insert_result={ + "id": "event-123", + "htmlLink": "https://calendar.google.com/event-123", + "hangoutLink": "https://meet.google.com/abc-defg-hij", + } + ) + monkeypatch.setattr(client, "get_calendar_service", lambda: fake_service) + + result = client.calendar_create_event( + "Standup", "2026-09-01T09:00:00Z", "2026-09-01T09:30:00Z", conference=True + ) + + insert_call = fake_service.events_api.insert_calls[0] + # Without conferenceDataVersion=1 Calendar drops conferenceData silently. + assert insert_call["conferenceDataVersion"] == 1 + create_request = insert_call["body"]["conferenceData"]["createRequest"] + assert create_request["conferenceSolutionKey"] == {"type": "hangoutsMeet"} + assert create_request["requestId"] == uuid.UUID(create_request["requestId"]).hex + assert result["meet_link"] == "https://meet.google.com/abc-defg-hij" + + +def test_calendar_create_event_mints_a_fresh_request_id_per_event(monkeypatch): + fake_service = _FakeCalendarService(insert_result={"id": "event-123"}) + monkeypatch.setattr(client, "get_calendar_service", lambda: fake_service) + + client.calendar_create_event( + "A", "2026-09-01T09:00:00Z", "2026-09-01T09:30:00Z", conference=True + ) + client.calendar_create_event( + "B", "2026-09-02T09:00:00Z", "2026-09-02T09:30:00Z", conference=True + ) + + request_ids = { + call["body"]["conferenceData"]["createRequest"]["requestId"] + for call in fake_service.events_api.insert_calls + } + assert len(request_ids) == 2 + + +def test_calendar_create_event_refetches_a_pending_meet_link(monkeypatch): + fake_service = _FakeCalendarService( + insert_result={ + "id": "event-123", + "conferenceData": {"createRequest": {"status": {"statusCode": "pending"}}}, + }, + get_results=[ + { + "id": "event-123", + "conferenceData": { + "entryPoints": [ + {"entryPointType": "phone", "uri": "tel:+1-650-555-0100"}, + {"entryPointType": "video", "uri": "https://meet.google.com/abc-defg-hij"}, + ] + }, + } + ], + ) + monkeypatch.setattr(client, "get_calendar_service", lambda: fake_service) + + result = client.calendar_create_event( + "Standup", "2026-09-01T09:00:00Z", "2026-09-01T09:30:00Z", conference=True + ) + + assert len(fake_service.events_api.get_calls) == 1 + assert result["meet_link"] == "https://meet.google.com/abc-defg-hij" + + +def test_calendar_update_event_adds_meet_when_event_has_none(monkeypatch): + fake_service = _FakeCalendarService( + get_results=[{"id": "event-123", "summary": "Standup"}], + update_result={ + "id": "event-123", + "htmlLink": "https://calendar.google.com/event-123", + "hangoutLink": "https://meet.google.com/abc-defg-hij", + }, + ) + monkeypatch.setattr(client, "get_calendar_service", lambda: fake_service) + + result = client.calendar_update_event("event-123", conference=True) + + update_call = fake_service.events_api.update_calls[0] + assert update_call["conferenceDataVersion"] == 1 + assert update_call["body"]["conferenceData"]["createRequest"]["conferenceSolutionKey"] == { + "type": "hangoutsMeet" + } + assert result["meet_link"] == "https://meet.google.com/abc-defg-hij" + + +def test_calendar_update_event_keeps_an_existing_conference(monkeypatch): + existing = { + "id": "event-123", + "summary": "Standup", + "conferenceData": { + "conferenceId": "abc-defg-hij", + "entryPoints": [ + {"entryPointType": "video", "uri": "https://meet.google.com/abc-defg-hij"} + ], + }, + } + fake_service = _FakeCalendarService( + get_results=[existing], + update_result={ + "id": "event-123", + "hangoutLink": "https://meet.google.com/abc-defg-hij", + }, + ) + monkeypatch.setattr(client, "get_calendar_service", lambda: fake_service) + + client.calendar_update_event("event-123", summary="Standup v2", conference=True) + + conference_data = fake_service.events_api.update_calls[0]["body"]["conferenceData"] + assert "createRequest" not in conference_data + assert conference_data["conferenceId"] == "abc-defg-hij" + + +def test_calendar_update_event_without_conference_omits_the_version_flag(monkeypatch): + fake_service = _FakeCalendarService( + get_results=[{"id": "event-123", "summary": "Standup"}], + update_result={"id": "event-123", "htmlLink": "https://calendar.google.com/event-123"}, + ) + monkeypatch.setattr(client, "get_calendar_service", lambda: fake_service) + + result = client.calendar_update_event("event-123", summary="Standup v2") + + assert "conferenceDataVersion" not in fake_service.events_api.update_calls[0] + assert result["meet_link"] == "" + + +def _calendar_service_with_attendee(**kwargs): + defaults = { + "get_results": [ + { + "id": "event-123", + "summary": "Standup", + "attendees": [{"email": "outside@example.com"}], + } + ], + "update_result": {"id": "event-123", "htmlLink": "https://calendar.google.com/event-123"}, + } + defaults.update(kwargs) + return _FakeCalendarService(**defaults) + + +def test_calendar_update_event_notifies_attendees_of_a_time_change(monkeypatch): + fake_service = _calendar_service_with_attendee() + monkeypatch.setattr(client, "get_calendar_service", lambda: fake_service) + + client.calendar_update_event( + "event-123", start="2026-09-01T10:00:00Z", end="2026-09-01T11:00:00Z" + ) + + assert fake_service.events_api.update_calls[0]["sendUpdates"] == "all" + + +def test_calendar_update_event_stays_quiet_for_a_description_edit(monkeypatch): + fake_service = _calendar_service_with_attendee() + monkeypatch.setattr(client, "get_calendar_service", lambda: fake_service) + + client.calendar_update_event("event-123", description="typo fixed") + + assert fake_service.events_api.update_calls[0]["sendUpdates"] == "none" + + +def test_calendar_update_event_stays_quiet_when_there_are_no_attendees(monkeypatch): + fake_service = _FakeCalendarService( + get_results=[{"id": "event-123", "summary": "Solo focus block"}], + update_result={"id": "event-123"}, + ) + monkeypatch.setattr(client, "get_calendar_service", lambda: fake_service) + + client.calendar_update_event("event-123", start="2026-09-01T10:00:00Z") + + assert fake_service.events_api.update_calls[0]["sendUpdates"] == "none" + + +def test_calendar_update_event_notify_false_silences_a_material_change(monkeypatch): + fake_service = _calendar_service_with_attendee() + monkeypatch.setattr(client, "get_calendar_service", lambda: fake_service) + + client.calendar_update_event("event-123", start="2026-09-01T10:00:00Z", notify=False) + + assert fake_service.events_api.update_calls[0]["sendUpdates"] == "none" + + +def test_calendar_update_event_notify_true_announces_a_trivial_change(monkeypatch): + fake_service = _calendar_service_with_attendee() + monkeypatch.setattr(client, "get_calendar_service", lambda: fake_service) + + client.calendar_update_event("event-123", description="typo fixed", notify=True) + + assert fake_service.events_api.update_calls[0]["sendUpdates"] == "all" + + +def test_calendar_update_event_stays_quiet_when_a_field_is_blank(monkeypatch): + # calendar_update_event writes a field only when it is truthy, so "" edits + # nothing. Notifying here would mail everyone about a no-op. + fake_service = _calendar_service_with_attendee() + monkeypatch.setattr(client, "get_calendar_service", lambda: fake_service) + + client.calendar_update_event("event-123", location="", summary="") + + update_call = fake_service.events_api.update_calls[0] + assert update_call["sendUpdates"] == "none" + assert update_call["body"]["summary"] == "Standup" + assert "location" not in update_call["body"] + + +def test_calendar_update_event_stays_quiet_when_the_guest_is_already_invited(monkeypatch): + fake_service = _calendar_service_with_attendee() + monkeypatch.setattr(client, "get_calendar_service", lambda: fake_service) + + client.calendar_update_event("event-123", add_attendees=["OUTSIDE@example.com"]) + + update_call = fake_service.events_api.update_calls[0] + assert update_call["sendUpdates"] == "none" + assert update_call["body"]["attendees"] == [{"email": "outside@example.com"}] + + +def test_calendar_update_event_notifies_the_first_guest_on_a_solo_event(monkeypatch): + # The decision reads the merged attendee list, so an event that had nobody + # still notifies the guest it just gained. + fake_service = _FakeCalendarService( + get_results=[{"id": "event-123", "summary": "Solo focus block"}], + update_result={"id": "event-123"}, + ) + monkeypatch.setattr(client, "get_calendar_service", lambda: fake_service) + + client.calendar_update_event("event-123", add_attendees=["new@example.com"]) + + assert fake_service.events_api.update_calls[0]["sendUpdates"] == "all" + + +def test_calendar_update_event_appends_a_repeated_new_guest_once(monkeypatch): + fake_service = _calendar_service_with_attendee() + monkeypatch.setattr(client, "get_calendar_service", lambda: fake_service) + + client.calendar_update_event("event-123", add_attendees=["new@example.com", "NEW@example.com"]) + + assert fake_service.events_api.update_calls[0]["body"]["attendees"] == [ + {"email": "outside@example.com"}, + {"email": "new@example.com"}, + ] + + +def test_calendar_update_event_still_notifies_when_adding_attendees(monkeypatch): + fake_service = _calendar_service_with_attendee() + monkeypatch.setattr(client, "get_calendar_service", lambda: fake_service) + + client.calendar_update_event("event-123", add_attendees=["new@example.com"]) + + assert fake_service.events_api.update_calls[0]["sendUpdates"] == "all" + + +def test_calendar_update_event_notifies_when_a_meet_is_added(monkeypatch): + fake_service = _calendar_service_with_attendee( + update_result={"id": "event-123", "hangoutLink": "https://meet.google.com/abc-defg-hij"} + ) + monkeypatch.setattr(client, "get_calendar_service", lambda: fake_service) + + client.calendar_update_event("event-123", conference=True) + + assert fake_service.events_api.update_calls[0]["sendUpdates"] == "all" + + +def test_calendar_update_event_stays_quiet_when_the_meet_already_existed(monkeypatch): + fake_service = _calendar_service_with_attendee( + get_results=[ + { + "id": "event-123", + "summary": "Standup", + "attendees": [{"email": "outside@example.com"}], + "conferenceData": {"conferenceId": "abc-defg-hij"}, + } + ] + ) + monkeypatch.setattr(client, "get_calendar_service", lambda: fake_service) + + client.calendar_update_event("event-123", conference=True) + + assert fake_service.events_api.update_calls[0]["sendUpdates"] == "none" + + +def test_calendar_delete_event_notifies_attendees_by_default(monkeypatch): + fake_service = _FakeCalendarService() + monkeypatch.setattr(client, "get_calendar_service", lambda: fake_service) + + result = client.calendar_delete_event("event-123") + + assert fake_service.events_api.delete_calls == [ + {"calendarId": "primary", "eventId": "event-123", "sendUpdates": "all"} + ] + assert result == {"id": "event-123", "deleted": True} + + +def test_calendar_delete_event_can_cancel_silently(monkeypatch): + fake_service = _FakeCalendarService() + monkeypatch.setattr(client, "get_calendar_service", lambda: fake_service) + + client.calendar_delete_event("event-123", calendar_id="team@example.com", notify=False) + + assert fake_service.events_api.delete_calls == [ + {"calendarId": "team@example.com", "eventId": "event-123", "sendUpdates": "none"} + ]