Skip to content
Merged
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
3 changes: 3 additions & 0 deletions app/(dashboard)/harnesses/[id]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1044,6 +1044,9 @@ function HermesHarnessDetail({ params }: { params: Promise<{ id: string }> }) {
/>
<p className="text-xs text-muted-foreground">
Admins can DM, add bot to groups, approve commands, and access global memory.
{platform === 'telegram' && (
<> Entries may be numeric Telegram user IDs or @usernames — @usernames are resolved to IDs automatically.</>
)}
</p>
</div>

Expand Down
58 changes: 57 additions & 1 deletion app/api/harnesses/[id]/settings/route.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,18 +14,20 @@ vi.mock('@/lib/services', () => ({
services: {
harness: { restart: vi.fn(), get: vi.fn(() => undefined) },
config: { getSettings: vi.fn(() => ({})) },
surfaceAdmins: { syncFromAllowlist: vi.fn() },
},
}))
vi.mock('@/lib/resolvers', () => ({
resolveIdentifier: vi.fn(async () => null),
expandSignalAllowlist: vi.fn(async (_id: string, users: string[]) => users),
expandTelegramAllowlist: vi.fn(async (_id: string, users: string[]) => users),
}))
vi.mock('@/lib/services/harness-compose', () => ({ generateStandaloneCompose: vi.fn(() => '') }))
vi.mock('@/lib/env-helpers', () => ({ buildSettingsEnvValue: vi.fn(() => '') }))

import { GET, PUT } from './route'
import { services } from '@/lib/services'
import { expandSignalAllowlist } from '@/lib/resolvers'
import { expandSignalAllowlist, expandTelegramAllowlist } from '@/lib/resolvers'

function makeParams(id: string) {
return { params: Promise.resolve({ id }) }
Expand Down Expand Up @@ -112,6 +114,7 @@ describe('Settings API — PUT', () => {
const env = writtenEnv()
expect(env).toContain('SLACK_CHANNEL_POLICY=approved-only')
expect(env).toContain('SIGNAL_GROUP_INVITE_POLICY=approved-only')
expect(env).toContain('TELEGRAM_GROUP_INVITE_POLICY=approved-only')
})

it('writes SLACK_CHANNEL_POLICY=allow-all when group invite policy is allow-all', async () => {
Expand All @@ -127,6 +130,7 @@ describe('Settings API — PUT', () => {
const env = writtenEnv()
expect(env).toContain('SLACK_CHANNEL_POLICY=allow-all')
expect(env).toContain('SIGNAL_GROUP_INVITE_POLICY=allow-all')
expect(env).toContain('TELEGRAM_GROUP_INVITE_POLICY=allow-all')
})

it('expands Signal allowed users to include resolved UUIDs before writing', async () => {
Expand All @@ -150,6 +154,58 @@ describe('Settings API — PUT', () => {

expect(expandSignalAllowlist).toHaveBeenCalledWith('h_test', ['+15550001234'])
})

it('expands Telegram @usernames and syncs the policy-plane admin overlay', async () => {
// Two stores hold Telegram admins: the .env allowlist (bootstrap) and the
// SurfaceAdminService overlay (served live to the policy plugin). A settings
// write must keep them converged — and expand @handles to numeric IDs, since
// the gateway matches numeric sender IDs verbatim.
;(expandTelegramAllowlist as ReturnType<typeof vi.fn>).mockResolvedValueOnce(['@juniper', '424242'])
const body = {
dmPolicy: 'approved-only',
groupInvitePolicy: 'approved-only',
mentionGating: true,
commandApprovalAdminOnly: true,
memoryScope: 'channel',
surfaces: {
telegram: {
allowedUsers: ['@juniper'],
adminUsers: ['@juniper'],
allowedGroups: [],
allowAll: false,
allowAllGroups: false,
},
},
}
await PUT(makeRequest(body), makeParams('h_test'))

expect(expandTelegramAllowlist).toHaveBeenCalledWith('h_test', ['@juniper'])
expect(services.surfaceAdmins.syncFromAllowlist).toHaveBeenCalledWith(
'h_test', 'telegram', ['@juniper', '424242'],
)
})

it('does NOT sync the overlay on an allow-all/empty telegram allowlist (would wipe an explicit admin roster)', async () => {
const body = {
dmPolicy: 'allow-all',
groupInvitePolicy: 'approved-only',
mentionGating: true,
commandApprovalAdminOnly: true,
memoryScope: 'channel',
surfaces: {
telegram: {
allowedUsers: [],
adminUsers: [],
allowedGroups: [],
allowAll: true,
allowAllGroups: false,
},
},
}
await PUT(makeRequest(body), makeParams('h_test'))

expect(services.surfaceAdmins.syncFromAllowlist).not.toHaveBeenCalled()
})
})

describe('Settings API — GET mention-gating reflects the runtime', () => {
Expand Down
35 changes: 33 additions & 2 deletions app/api/harnesses/[id]/settings/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import fs from 'fs'
import path from 'path'
import os from 'os'
import { buildSettingsEnvValue } from '@/lib/env-helpers'
import { resolveIdentifier, expandSignalAllowlist } from '@/lib/resolvers'
import { resolveIdentifier, expandSignalAllowlist, expandTelegramAllowlist } from '@/lib/resolvers'
import { services } from '@/lib/services'
import { adapterForRuntime } from '@/lib/services/harness'

Expand Down Expand Up @@ -57,9 +57,12 @@ type SurfaceSettings = {
// each connected surface honors the same approved-only/allow-all choice. Slack's
// runtime reads SLACK_CHANNEL_POLICY (approved-only = empty SLACK_ALLOWED_CHANNELS
// means NO channels approved; allow-all = empty means respond everywhere).
// Telegram's var is enforced by the group-approval POST endpoint
// (surfaces/[platform]/groups/[groupId]) that the swarm_map_policy plugin calls.
const GROUP_INVITE_VARS: Record<string, string> = {
signal: 'SIGNAL_GROUP_INVITE_POLICY',
slack: 'SLACK_CHANNEL_POLICY',
telegram: 'TELEGRAM_GROUP_INVITE_POLICY',
}

// Env var names for mention-gating per platform
Expand Down Expand Up @@ -235,6 +238,10 @@ export async function PUT(

let content = fs.readFileSync(envPath, 'utf-8')

// Telegram allowlist as written to the env — captured so the policy-plane
// admin overlay (SurfaceAdminService) can be synced after the env write.
let telegramAllowlist: string[] | undefined

for (const [platform, vars] of Object.entries(PLATFORM_VARS)) {
const settings = body.surfaces[platform]
if (!settings) continue
Expand All @@ -247,10 +254,25 @@ export async function PUT(
if (platform === 'signal' && allowedUsers.length > 0) {
allowedUsers = await expandSignalAllowlist(id, allowedUsers)
}
// For Telegram, expand @usernames to also include their resolved numeric
// ID — the gateway matches numeric sender IDs verbatim, so an
// @username-only entry never matches anyone (see expandTelegramAllowlist).
if (platform === 'telegram') {
if (allowedUsers.length > 0) {
allowedUsers = await expandTelegramAllowlist(id, allowedUsers)
}
// Only converge the admin overlay against a concrete allowlist. An
// allow-all/empty policy writes '*' or '' here — syncing that would
// silently wipe an explicitly-configured admin roster because of an
// unrelated DM-policy toggle.
if (allowedUsers.length > 0) {
telegramAllowlist = allowedUsers
}
}
const usersValue = buildSettingsEnvValue(body.dmPolicy, settings.allowAll, allowedUsers)
const usersRegex = new RegExp(`^${vars.users}=.*$`, 'm')
if (usersRegex.test(content)) {
content = content.replace(usersRegex, `${vars.users}=${usersValue}`)
content = content.replace(usersRegex, () => `${vars.users}=${usersValue}`)
} else {
content = content.trimEnd() + `\n${vars.users}=${usersValue}\n`
}
Expand Down Expand Up @@ -363,6 +385,15 @@ export async function PUT(

fs.writeFileSync(envPath, content, { mode: 0o600 })

// Keep the policy-plane admin overlay converged with the Telegram allowlist
// just written — the two admin stores must never diverge. Non-numeric entries
// (raw @handles) are dropped by the sync's validation, never stored.
if (telegramAllowlist !== undefined) {
try {
services.surfaceAdmins.syncFromAllowlist(id, 'telegram', telegramAllowlist)
} catch {}
}

// Per-harness resource limits (compose deploy.resources.limits — NOT env vars).
// Persist on the harness overlay and detect a change so the compose is only
// regenerated when needed.
Expand Down
Original file line number Diff line number Diff line change
@@ -1,16 +1,22 @@
/**
* Tests for the is-group-allowed route the swarm_map_policy plugin calls.
* GET /api/harnesses/:id/surfaces/:platform/groups/:groupId → { allowed }
* Tests for the group routes the swarm_map_policy plugin calls.
* GET /api/harnesses/:id/surfaces/:platform/groups/:groupId → { allowed }
* POST /api/harnesses/:id/surfaces/:platform/groups/:groupId → group-invite approval
*/
import { describe, it, expect, beforeEach, vi } from 'vitest'
import { GET } from './route'
import { GET, POST } from './route'

vi.mock('@/lib/services', () => ({
services: { surfaceAdmins: { isGroupAllowed: vi.fn() } },
services: {
surfaceAdmins: { isGroupAllowed: vi.fn(), approveGroupInvite: vi.fn() },
harness: { restart: vi.fn() },
},
}))

import { services } from '@/lib/services'
const isGroupAllowed = services.surfaceAdmins.isGroupAllowed as ReturnType<typeof vi.fn>
const approveGroupInvite = services.surfaceAdmins.approveGroupInvite as ReturnType<typeof vi.fn>
const restart = services.harness.restart as ReturnType<typeof vi.fn>

function makeParams(id: string, platform: string, groupId: string) {
return { params: Promise.resolve({ id, platform, groupId }) }
Expand All @@ -34,3 +40,69 @@ describe('GET is-group-allowed', () => {
expect(await res.json()).toEqual({ allowed: false })
})
})

describe('POST group-invite approval', () => {
function makePost(addedByUserId?: unknown, rawBody?: string) {
return new Request('http://x', {
method: 'POST',
body: rawBody ?? JSON.stringify({ addedByUserId }),
})
}

it('approves, recreates the container, and reports restarted when the allowlist was updated', async () => {
approveGroupInvite.mockReturnValue({ ok: true, approved: true, updated: true })
const res = await POST(makePost('111'), makeParams('h_seraph', 'telegram', '-100777'))
expect(res.status).toBe(200)
expect(await res.json()).toEqual({ approved: true, restarted: true })
expect(approveGroupInvite).toHaveBeenCalledWith('h_seraph', 'telegram', '-100777', '111')
// env_file is only read at container creation — a recreate must follow the write.
expect(restart).toHaveBeenCalledWith('h_seraph', 'recreate')
})

it('reports restarted:false (still approved) when the recreate fails', async () => {
approveGroupInvite.mockReturnValue({ ok: true, approved: true, updated: true })
restart.mockImplementationOnce(() => { throw new Error('no compose file') })
const res = await POST(makePost('111'), makeParams('h_seraph', 'telegram', '-100777'))
expect(await res.json()).toEqual({ approved: true, restarted: false })
})

it('approves without a restart when the group is already allowed', async () => {
approveGroupInvite.mockReturnValue({ ok: true, approved: true, updated: false })
const res = await POST(makePost('111'), makeParams('h_seraph', 'telegram', '-100777'))
expect(await res.json()).toEqual({ approved: true, already_allowed: true })
expect(restart).not.toHaveBeenCalled()
})

it('returns { approved: false, reason } (200) when the adder is not an admin', async () => {
approveGroupInvite.mockReturnValue({ ok: true, approved: false, reason: 'not an admin' })
const res = await POST(makePost('222'), makeParams('h_seraph', 'telegram', '-100777'))
expect(res.status).toBe(200)
expect(await res.json()).toEqual({ approved: false, reason: 'not an admin' })
expect(restart).not.toHaveBeenCalled()
})

it('maps service validation failures to 400', async () => {
approveGroupInvite.mockReturnValue({ ok: false, status: 400, error: 'Invalid group id' })
const res = await POST(makePost('111'), makeParams('h_seraph', 'telegram', 'bad,id'))
expect(res.status).toBe(400)
})

it('rejects a malformed JSON body with 400', async () => {
const res = await POST(makePost(undefined, 'not-json'), makeParams('h_seraph', 'telegram', '-100777'))
expect(res.status).toBe(400)
expect(approveGroupInvite).not.toHaveBeenCalled()
})

it('passes a non-string addedByUserId through as empty (service 400s it)', async () => {
approveGroupInvite.mockReturnValue({ ok: false, status: 400, error: 'addedByUserId is required' })
const res = await POST(makePost(42), makeParams('h_seraph', 'telegram', '-100777'))
expect(res.status).toBe(400)
expect(approveGroupInvite).toHaveBeenCalledWith('h_seraph', 'telegram', '-100777', '')
})

it('URL-decodes the groupId (signal ids are base64 with url-encoded chars)', async () => {
approveGroupInvite.mockReturnValue({ ok: true, approved: true, updated: false })
await POST(makePost('111'), makeParams('h_seraph', 'signal', 'abc%2Bdef%3D%3D'))
expect(approveGroupInvite).toHaveBeenCalledWith('h_seraph', 'signal', 'abc+def==', '111')
})
})
Original file line number Diff line number Diff line change
Expand Up @@ -17,3 +17,75 @@ export async function GET(
const allowed = services.surfaceAdmins.isGroupAllowed(id, platform, decodeURIComponent(groupId))
return NextResponse.json({ allowed })
}

/**
* POST /api/harnesses/:id/surfaces/:platform/groups/:groupId
*
* Group-invite approval for the swarm_map_policy agent plugin: the agent was
* just added to a group and asks whether the invite is approved. Consumed by a
* hermes-agent-mt change — this comment is the contract.
*
* Request body (JSON):
* { "addedByUserId": "<platform-native user id of whoever added the bot>" }
*
* Responses (always 200 unless the request itself is malformed):
* { "approved": true, "restarted": boolean } — group appended to the
* allowlist env var (e.g. TELEGRAM_GROUP_ALLOWED_CHATS); the container was
* recreated so the change takes effect (restarted:false = env written but
* recreate failed, e.g. no compose file yet).
* { "approved": true, "already_allowed": true } — allowlist is '*' or the
* group is already listed; nothing written, no restart.
* { "approved": false, "reason": "<why>" } — invite policy is
* approved-only (or unset, the secure default) and addedByUserId is not an
* admin. The plugin should leave the group.
* 400 { "error": "<what>" } — unsupported platform,
* structurally invalid groupId, missing addedByUserId, or no agent .env.
*
* Policy: allow-all → approve for anyone; approved-only/unset → approve only
* when addedByUserId is an admin (SurfaceAdminService.isAdmin, fail-closed).
*
* Auth: exempted from the operator-cookie gate in middleware.ts (agents cannot
* obtain the cookie) — see AGENT_CALLABLE_POST_PATHS there for the trust model.
*/
export async function POST(
request: Request,
{ params }: { params: Promise<{ id: string; platform: string; groupId: string }> },
) {
const { id, platform, groupId } = await params

let body: { addedByUserId?: unknown }
try {
body = (await request.json()) as { addedByUserId?: unknown }
} catch {
return NextResponse.json({ error: 'Invalid JSON body' }, { status: 400 })
}
const addedByUserId = typeof body.addedByUserId === 'string' ? body.addedByUserId : ''

const result = services.surfaceAdmins.approveGroupInvite(
id,
platform,
decodeURIComponent(groupId),
addedByUserId,
)

if (!result.ok) {
return NextResponse.json({ error: result.error }, { status: result.status })
}
if (!result.approved) {
return NextResponse.json({ approved: false, reason: result.reason })
}
if (!result.updated) {
return NextResponse.json({ approved: true, already_allowed: true })
}

// Recreate the container so the updated allowlist actually loads (env_file is
// read at container creation, not on a plain restart) — same as every other
// env-writing route. Best-effort: the env is written either way.
let restarted = false
try {
services.harness.restart(id, 'recreate')
restarted = true
} catch {}

return NextResponse.json({ approved: true, restarted })
}
Loading