Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
44 changes: 43 additions & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ jobs:
rust_api: ${{ steps.filter.outputs.rust_api }}
slackbotv2_tests: ${{ steps.filter.outputs.slackbotv2_tests }}
discordbot_checks: ${{ steps.filter.outputs.discordbot_checks }}
teamsbot_checks: ${{ steps.filter.outputs.teamsbot_checks }}
steps:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
with:
Expand Down Expand Up @@ -72,11 +73,19 @@ jobs:
'^services/slackbotv2/' \
'^package\.json$' \
'^pnpm-lock\.yaml$' \
'^pnpm-workspace\.yaml$' \
'^\.github/workflows/ci\.yml$'
set_output discordbot_checks \
'^services/discordbot/' \
'^package\.json$' \
'^pnpm-lock\.yaml$' \
'^pnpm-workspace\.yaml$' \
'^\.github/workflows/ci\.yml$'
set_output teamsbot_checks \
'^services/teamsbot/' \
'^package\.json$' \
'^pnpm-lock\.yaml$' \
'^pnpm-workspace\.yaml$' \
'^\.github/workflows/ci\.yml$'

migration-order:
Expand Down Expand Up @@ -261,6 +270,38 @@ jobs:
working-directory: services/discordbot
run: bun run test

teamsbot-checks:
name: Teamsbot typecheck and tests
runs-on: depot-ubuntu-24.04-16
needs: ci_changes
if: needs.ci_changes.outputs.teamsbot_checks == 'true'
steps:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
with:
persist-credentials: false

- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
with:
node-version: "24"

- uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4
with:
version: 10.28.1
run_install: false

- uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2

- name: Install Node dependencies
run: pnpm install --frozen-lockfile

- name: Typecheck Teamsbot
working-directory: services/teamsbot
run: bun run check:types

- name: Teamsbot tests
working-directory: services/teamsbot
run: bun run test

ci-success:
name: CI success
runs-on: ubuntu-latest
Expand All @@ -271,10 +312,11 @@ jobs:
- rust-api
- slackbotv2-tests
- discordbot-checks
- teamsbot-checks
timeout-minutes: 30
steps:
- name: Decide whether the needed jobs succeeded or failed
uses: re-actors/alls-green@05ac9388f0aebcb5727afa17fcccfecd6f8ec5fe # release/v1
with:
allowed-skips: migration-order, rust-api, slackbotv2-tests, discordbot-checks
allowed-skips: migration-order, rust-api, slackbotv2-tests, discordbot-checks, teamsbot-checks
jobs: ${{ toJSON(needs) }}
8 changes: 7 additions & 1 deletion .github/workflows/publish-images.yml
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@ jobs:
strategy:
fail-fast: false
matrix:
service: [api-rs, slackbotv2, discordbot, agent, iron-proxy, console]
service: [api-rs, slackbotv2, discordbot, teamsbot, agent, iron-proxy, console]
platform: ${{ github.event_name == 'push' && fromJSON('["linux/amd64", "linux/arm64"]') || fromJSON('["linux/amd64"]') }}
include:
- service: api-rs
Expand All @@ -64,6 +64,11 @@ jobs:
context: .
dockerfile: services/discordbot/Dockerfile
target: ""
- service: teamsbot
image: centaur-teamsbot
context: .
dockerfile: services/teamsbot/Dockerfile
target: ""
- service: agent
image: centaur-agent
context: .
Expand Down Expand Up @@ -159,6 +164,7 @@ jobs:
- image: centaur-iron-proxy
- image: centaur-console
- image: centaur-discordbot
- image: centaur-teamsbot

steps:
- name: Download digests
Expand Down
12 changes: 6 additions & 6 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -525,11 +525,11 @@ For local development, infra secrets are stored in Kubernetes Secrets created by

### centaur-perms

`centaur-perms` is the operator CLI for iron-control permissions: it controls which Slack principals (users and channels) and which roles hold which tool roles and secrets. It lives at `services/api-rs/crates/centaur-perms` and reuses iron-control's canonical mappings (`derive_principal`, `RoleSpec::tool`), so every principal and role `foreign_id` it writes matches exactly what `api-rs` registers at session start. It is the supported way to inspect and edit grants by hand; the API writes the same resources at runtime.
`centaur-perms` is the operator CLI for iron-control permissions: it controls which chat principals (Slack users/channels, Discord channels, and Teams users/conversations) and which roles hold which tool roles and secrets. It lives at `services/api-rs/crates/centaur-perms` and reuses iron-control's canonical mappings (`derive_principal`, `RoleSpec::tool`), so every principal and role `foreign_id` it writes matches exactly what `api-rs` registers at session start. It is the supported way to inspect and edit grants by hand; the API writes the same resources at runtime.

#### Concepts

- **Principal** — a Slack user or channel that an agent session runs as. `foreign_id`s are derived canonically: `slack-channel-<team>-<conv>` for a channel, `slack-user-<team>-<user>` for a DM. A channel's grants win when present; otherwise the session falls back to the requesting user's grants.
- **Principal** — the chat identity an agent session runs as. `foreign_id`s are derived canonically: `slack-channel-<team>-<conv>` for a Slack channel, `slack-user-<team>-<user>` for a Slack DM, `discord-channel-<guild>-<channel>` for Discord, `teams-conversation-<tenant-slug>-<conversation-slug>` for Teams conversations, and `teams-user-<tenant-slug>-<user-slug>` for Teams user-scoped runs. Each session binds to one derived principal; grant the channel/conversation principal for shared contexts and the user principal for DM or user-scoped contexts.
- **Role** — a named bundle of secret grants assignable to principals. Canonical roles: `infra` (shared infra secrets), `tools` (shared harness/tool secrets), and one `tool-<slug>` per tool (e.g. `tool-github`).
- **Secret** — a typed iron-control resource (static `ssr_`, OAuth token `ots_`, GCP auth `gas_`, Postgres DSN `pgs_`, HMAC signing `hms_`). iron-control never returns credential values, only the source each resolves from. Each `tool-<slug>` secret keeps a canonical `tool-<slug>-…` id so the same object is shared no matter which role grants it.
- **Grant** — binds a secret to a grantee (a principal or a role). `centaur-perms` resources carry the label `managed-by=centaur`.
Expand Down Expand Up @@ -562,9 +562,9 @@ Commands are resource-first — `centaur-perms <noun> <verb>`:
| Command | What it does |
|---------|--------------|
| `principals list [--filter S] [--label k=v] [--managed]` | List principals. `--filter` is a case-insensitive substring on `foreign_id`/name; `--managed` is shorthand for `--label managed-by=centaur`. |
| `principals show <principal> [--slack-user U]` | Show a principal's roles (with each role's grants), direct grants, and effective replace-secret placeholders. |
| `principals grant <principal> [--tool N] [--role F] [--secret OID]` | Grant access. `--tool` registers the tool's `tool-<slug>` role + secrets then assigns it; `--role` assigns an existing role; `--secret` grants a secret OID directly. All repeatable; creates the principal if absent. |
| `principals revoke <principal> [--tool N] [--role F] [--secret OID] [--grant-id OID]` | Reverse of grant. `--tool`/`--role` unassign the role; `--secret` deletes the direct grant for that secret; `--grant-id` deletes a grant by its `grant_…` id. |
| `principals show <principal> [--slack-user U] [--teams-tenant-id T]` | Show a principal's roles (with each role's grants), direct grants, and effective replace-secret placeholders. |
| `principals grant <principal> [--slack-user U] [--teams-tenant-id T] [--tool N] [--role F] [--secret OID]` | Grant access. `--tool` registers the tool's `tool-<slug>` role + secrets then assigns it; `--role` assigns an existing role; `--secret` grants a secret OID directly. All repeatable; creates the principal if absent. |
| `principals revoke <principal> [--slack-user U] [--teams-tenant-id T] [--tool N] [--role F] [--secret OID] [--grant-id OID]` | Reverse of grant. `--tool`/`--role` unassign the role; `--secret` deletes the direct grant for that secret; `--grant-id` deletes a grant by its `grant_…` id. |
| `roles list / show <role>` | List roles, or show the secrets granted to one role. |
| `roles grant <role> [--secret OID] [--tool N [--secret-name NAME]]` | Grant secrets to a role by OID, or register+grant a tool's declared secrets. `--secret-name` (repeatable, requires `--tool`) selects specific declared secrets instead of all. |
| `roles revoke <role> --secret OID` | Revoke one or more secrets from a role (`--secret` required, repeatable). |
Expand All @@ -573,7 +573,7 @@ Commands are resource-first — `centaur-perms <noun> <verb>`:
| `broker create --foreign-id F --token-endpoint URL --client-id ID [--client-secret S] [--refresh-token SEED] [--scope SC]…` | Create or update an iron-control broker credential. Values are passed literally; iron-control owns the OAuth refresh loop. Re-supplying `--refresh-token` re-bootstraps it. |
| `broker list / show <credential> / delete <credential>` | List broker credentials, show one (status/expiry; secret material is never returned), or delete one (by `bcr_` OID or `foreign_id`). |

A `<principal>` argument is treated as a Slack thread key when it contains `:` (e.g. `slack:T123:C456:1700000000.0001`) and run through `derive_principal` — pass `--slack-user` so a DM thread keys to the user. Any value without a `:` is used verbatim as a `foreign_id` (e.g. `slack-channel-t123-c456`) or an OID. Grant/revoke operations are idempotent: re-granting an assigned role or revoking a missing grant is a no-op, reported as such.
A `<principal>` argument is treated as a chat thread key when it contains `:` (for example `slack:T123:C456:1700000000.0001`, `discord:111:222:333`, or `teams:<base64url-conversation-id>:<base64url-service-url>`) and run through `derive_principal`. Pass `--slack-user` so a Slack DM thread keys to the user, and pass `--teams-tenant-id` so an official Teams adapter thread key derives the tenant-scoped `teams-*` principal. Any value without a `:` is used verbatim as a `foreign_id` (e.g. `slack-channel-t123-c456` or `teams-conversation-tenant-1-19-abc123-thread-tacv2`) or an OID. Grant/revoke operations are idempotent: re-granting an assigned role or revoking a missing grant is a no-op, reported as such.

A tool's `brokered_token` secret registers the *consumer* side — a static secret that injects the access token from a `token_broker` source. The broker credential itself (the managed OAuth refresh loop) is provisioned out of band with `broker create`; the tool's `brokered_token` references it by `foreign_id` (its `credential`, defaulting to the secret `name`).

Expand Down
12 changes: 9 additions & 3 deletions Justfile
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ build:
just _build-all-sequential
else
pids=()
for recipe in _build-api-rs _build-iron-proxy _build-slackbotv2 _build-discordbot _build-agent _build-console; do
for recipe in _build-api-rs _build-iron-proxy _build-slackbotv2 _build-discordbot _build-teamsbot _build-agent _build-console; do
just "$recipe" &
pids+=("$!")
done
Expand All @@ -46,6 +46,7 @@ _build-all-sequential:
just _build-iron-proxy
just _build-slackbotv2
just _build-discordbot
just _build-teamsbot
just _build-agent
just _build-console

Expand All @@ -57,6 +58,7 @@ build-one service:
iron-proxy) just _build-iron-proxy ;;
slackbotv2) just _build-slackbotv2 ;;
discordbot) just _build-discordbot ;;
teamsbot) just _build-teamsbot ;;
agent|sandbox) just _build-agent ;;
console) just _build-console ;;
*) echo "unknown service: {{service}}" >&2; exit 2 ;;
Expand All @@ -74,6 +76,9 @@ _build-slackbotv2:
_build-discordbot:
docker build -t centaur-discordbot:latest -f services/discordbot/Dockerfile .

_build-teamsbot:
docker build -t centaur-teamsbot:latest -f services/teamsbot/Dockerfile .

_build-agent:
docker build --target "{{agent_build_target}}" -t "{{agent_image}}" -f "{{agent_dockerfile}}" .

Expand All @@ -88,7 +93,7 @@ _build-console:
_push-registry:
#!/usr/bin/env bash
set -euo pipefail
for img in centaur-api-rs centaur-iron-proxy centaur-slackbotv2 centaur-discordbot centaur-agent centaur-console; do
for img in centaur-api-rs centaur-iron-proxy centaur-slackbotv2 centaur-discordbot centaur-teamsbot centaur-agent centaur-console; do
target="{{registry}}/library/${img}:latest"
echo "pushing ${img}:latest -> ${target}..."
docker tag "${img}:latest" "${target}"
Expand All @@ -101,7 +106,7 @@ _push-registry:
_import-k3s:
#!/usr/bin/env bash
set -euo pipefail
for img in centaur-api-rs centaur-iron-proxy centaur-slackbotv2 centaur-discordbot centaur-agent centaur-console; do
for img in centaur-api-rs centaur-iron-proxy centaur-slackbotv2 centaur-discordbot centaur-teamsbot centaur-agent centaur-console; do
echo "importing ${img}:latest into k3s containerd..."
docker save "${img}:latest" | {{k3s_ctr}} images import -
done
Expand All @@ -122,6 +127,7 @@ deploy:
--set ironProxy.image.repository=ghcr.io/paradigmxyz/centaur/centaur-iron-proxy
--set slackbotv2.image.repository=ghcr.io/paradigmxyz/centaur/centaur-slackbotv2
--set discordbot.image.repository=ghcr.io/paradigmxyz/centaur/centaur-discordbot
--set teamsbot.image.repository=ghcr.io/paradigmxyz/centaur/centaur-teamsbot
--set sandbox.image.repository=ghcr.io/paradigmxyz/centaur/centaur-agent
--set console.image.repository=ghcr.io/paradigmxyz/centaur/centaur-console
)
Expand Down
11 changes: 11 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -128,6 +128,15 @@ export SLACK_SIGNING_SECRET=...
export SLACKBOT_API_KEY=...
```

To boot optional Teams ingress locally, also export:

```bash
export TEAMS_BOT_APP_ID=...
export TEAMS_BOT_APP_PASSWORD=...
export TEAMS_BOT_APP_TENANT_ID=...
# TEAMSBOT_API_KEY is generated by bootstrap when omitted.
```

Create the Slackbot app at [api.slack.com/apps](https://api.slack.com/apps).
Use the app's Bot User OAuth Token for `SLACK_BOT_TOKEN` and its Signing Secret
for `SLACK_SIGNING_SECRET`.
Expand All @@ -139,6 +148,8 @@ What they are for:
- `SLACK_BOT_TOKEN`: Slack bot token for the local Slackbot service
- `SLACK_SIGNING_SECRET`: verifies incoming Slack requests
- `SLACKBOT_API_KEY`: API key the Slackbot uses to call Centaur
- `TEAMS_BOT_APP_ID`, `TEAMS_BOT_APP_PASSWORD`, `TEAMS_BOT_APP_TENANT_ID`: optional Teams ingress app credentials when `teamsbot.enabled=true`
- `TEAMSBOT_API_KEY`: API key the Teamsbot uses to call Centaur; generated by local bootstrap when Teams credentials are present and this is omitted

Then create local Kubernetes Secrets from those environment variables and boot the stack:

Expand Down
59 changes: 59 additions & 0 deletions contrib/chart/templates/networkpolicy.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,11 @@ spec:
matchLabels:
{{ include "centaur.componentSelectorLabels" (dict "root" . "component" "discordbot") | nindent 14 }}
{{- end }}
{{- if .Values.teamsbot.enabled }}
- podSelector:
matchLabels:
{{ include "centaur.componentSelectorLabels" (dict "root" . "component" "teamsbot") | nindent 14 }}
{{- end }}
{{- if $console.enabled }}
- podSelector:
matchLabels:
Expand Down Expand Up @@ -152,6 +157,11 @@ spec:
- podSelector:
matchLabels:
{{ include "centaur.componentSelectorLabels" (dict "root" . "component" "discordbot") | nindent 14 }}
{{- end }}
{{- if .Values.teamsbot.enabled }}
- podSelector:
matchLabels:
{{ include "centaur.componentSelectorLabels" (dict "root" . "component" "teamsbot") | nindent 14 }}
{{- end }}
# Sandboxes call back into the control plane. agent-k8s labels its pods
# centaur.ai/managed-by=api-rs (MANAGED_BY_VALUE in centaur-sandbox-agent-k8s),
Expand Down Expand Up @@ -320,6 +330,55 @@ spec:
port: 443
---
{{- end }}
{{- if .Values.teamsbot.enabled }}
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: {{ include "centaur.componentName" (dict "root" . "component" "teamsbot") }}
labels:
{{ include "centaur.componentLabels" (dict "root" . "component" "teamsbot") | nindent 4 }}
spec:
podSelector:
matchLabels:
{{ include "centaur.componentSelectorLabels" (dict "root" . "component" "teamsbot") | nindent 6 }}
policyTypes:
- Ingress
- Egress
ingress:
- from:
{{- range $ingressSourceNamespaces }}
- namespaceSelector:
matchLabels:
kubernetes.io/metadata.name: {{ . | quote }}
{{- end }}
ports:
- protocol: TCP
port: 3100
egress:
{{- if .Values.apiRs.enabled }}
- to:
- podSelector:
matchLabels:
{{ include "centaur.componentSelectorLabels" (dict "root" . "component" "api-rs") | nindent 14 }}
ports:
- protocol: TCP
port: {{ .Values.apiRs.port }}
{{- end }}
{{- if .Values.postgres.enabled }}
- to:
- podSelector:
matchLabels:
{{ include "centaur.componentSelectorLabels" (dict "root" . "component" "postgres") | nindent 14 }}
ports:
- protocol: TCP
port: 5432
{{- end }}
# Teams Bot Framework, Graph, SharePoint, and proactive activity delivery use direct HTTPS.
- ports:
- protocol: TCP
port: 443
---
{{- end }}
{{- if eq .Values.ironProxy.secretSource "onepassword-connect" }}
# Targets the 1Password Connect subchart's pods, which carry
# `app: <applicationName>` (default `onepassword-connect`).
Expand Down
33 changes: 33 additions & 0 deletions contrib/chart/templates/teamsbot-ingress.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
{{- if and .Values.teamsbot.enabled .Values.teamsbot.ingress.enabled }}
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: {{ include "centaur.componentName" (dict "root" . "component" "teamsbot") }}
labels:
{{ include "centaur.componentLabels" (dict "root" . "component" "teamsbot") | nindent 4 }}
{{- with .Values.teamsbot.ingress.annotations }}
annotations:
{{ toYaml . | nindent 4 }}
{{- end }}
spec:
{{- with .Values.teamsbot.ingress.className }}
ingressClassName: {{ . }}
{{- end }}
rules:
- {{- if .Values.teamsbot.ingress.host }}
host: {{ .Values.teamsbot.ingress.host | quote }}
{{- end }}
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: {{ include "centaur.componentName" (dict "root" . "component" "teamsbot") }}
port:
number: 3100
{{- with .Values.teamsbot.ingress.tls }}
tls:
{{ toYaml . | nindent 4 }}
{{- end }}
{{- end }}
Loading