Compare commits

..

3 Commits

Author SHA1 Message Date
Jarvis
2879da1f6a feat(#707): secure Discord service ingress
All checks were successful
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/pr/ci Pipeline was successful
2026-07-12 16:33:44 -05:00
ca9c2b5c23 restore Tess markdown formatting gate (v2, non-author) (#714)
Some checks failed
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/push/publish Pipeline failed
2026-07-12 21:32:16 +00:00
b580d37d51 Fixes #703 (#705)
Some checks failed
ci/woodpecker/push/publish Pipeline was successful
ci/woodpecker/push/ci Pipeline failed
2026-07-12 20:49:43 +00:00
16 changed files with 679 additions and 68 deletions

View File

@@ -1,3 +1,4 @@
import { timingSafeEqual } from 'node:crypto';
import type { IncomingHttpHeaders } from 'node:http';
import { fromNodeHeaders } from 'better-auth/node';
@@ -12,6 +13,19 @@ export interface SessionAuth {
};
}
export function validateDiscordServiceToken(
candidate: unknown,
expected: string | undefined,
): boolean {
if (typeof candidate !== 'string' || !expected) return false;
const candidateBuffer = Buffer.from(candidate);
const expectedBuffer = Buffer.from(expected);
return (
candidateBuffer.length === expectedBuffer.length &&
timingSafeEqual(candidateBuffer, expectedBuffer)
);
}
export async function validateSocketSession(
headers: IncomingHttpHeaders,
auth: SessionAuth,

View File

@@ -11,6 +11,11 @@ import {
} from '@nestjs/websockets';
import { Server, Socket } from 'socket.io';
import type { AgentSessionEvent } from '@mariozechner/pi-coding-agent';
import {
verifyDiscordIngressEnvelope,
type DiscordIngressEnvelope,
type DiscordIngressPayload,
} from '@mosaicstack/discord-plugin';
import type { Auth } from '@mosaicstack/auth';
import type { Brain } from '@mosaicstack/brain';
import type {
@@ -28,7 +33,8 @@ import { CommandExecutorService } from '../commands/command-executor.service.js'
import { RoutingEngineService } from '../agent/routing/routing-engine.service.js';
import { v4 as uuid } from 'uuid';
import { ChatSocketMessageDto } from './chat.dto.js';
import { validateSocketSession } from './chat.gateway-auth.js';
import { validateDiscordServiceToken, validateSocketSession } from './chat.gateway-auth.js';
import { DiscordReplayProtector } from '../plugin/discord-replay-protector.js';
/** Per-client state tracking streaming accumulation for persistence. */
interface ClientSession {
@@ -50,6 +56,37 @@ interface ClientSession {
*/
const modelOverrides = new Map<string, string>();
function isDiscordIngressEnvelope(value: unknown): value is DiscordIngressEnvelope {
if (typeof value !== 'object' || value === null) return false;
const envelope = value as { payload?: unknown; signature?: unknown };
if (
typeof envelope.signature !== 'string' ||
typeof envelope.payload !== 'object' ||
envelope.payload === null
) {
return false;
}
const payload = envelope.payload as Record<string, unknown>;
return [
payload['correlationId'],
payload['messageId'],
payload['guildId'],
payload['channelId'],
payload['userId'],
payload['conversationId'],
payload['content'],
].every((field: unknown): boolean => typeof field === 'string');
}
function isChatSocketMessage(value: unknown): value is ChatSocketMessageDto {
if (typeof value !== 'object' || value === null) return false;
const payload = value as { content?: unknown; conversationId?: unknown };
return (
typeof payload.content === 'string' &&
(payload.conversationId === undefined || typeof payload.conversationId === 'string')
);
}
@WebSocketGateway({
cors: {
origin: process.env['GATEWAY_CORS_ORIGIN'] ?? 'http://localhost:3000',
@@ -62,6 +99,7 @@ export class ChatGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa
private readonly logger = new Logger(ChatGateway.name);
private readonly clientSessions = new Map<string, ClientSession>();
private readonly discordReplayProtector = new DiscordReplayProtector();
constructor(
@Inject(AgentService) private readonly agentService: AgentService,
@@ -77,6 +115,13 @@ export class ChatGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa
}
async handleConnection(client: Socket): Promise<void> {
const serviceToken = client.handshake.auth['discordServiceToken'];
if (validateDiscordServiceToken(serviceToken, process.env['DISCORD_SERVICE_TOKEN'])) {
client.data.discordService = true;
this.logger.log(`Authenticated Discord service connected: ${client.id}`);
return;
}
const session = await validateSocketSession(client.handshake.headers, this.auth);
if (!session) {
this.logger.warn(`Rejected unauthenticated WebSocket client: ${client.id}`);
@@ -87,8 +132,6 @@ export class ChatGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa
client.data.user = session.user;
client.data.session = session.session;
this.logger.log(`Client connected: ${client.id}`);
// Broadcast command manifest to the newly connected client
client.emit('commands:manifest', { manifest: this.commandRegistry.getManifest() });
}
@@ -105,12 +148,41 @@ export class ChatGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa
@SubscribeMessage('message')
async handleMessage(
@ConnectedSocket() client: Socket,
@MessageBody() data: ChatSocketMessageDto,
@MessageBody() rawData: unknown,
): Promise<void> {
let discordIngress: DiscordIngressPayload | null = null;
let data: ChatSocketMessageDto;
if (client.data.discordService) {
if (!isDiscordIngressEnvelope(rawData)) {
this.logger.warn(`Rejected malformed Discord ingress from ${client.id}`);
return;
}
discordIngress = this.resolveDiscordIngress(client, rawData);
if (!discordIngress) return;
data = { conversationId: discordIngress.conversationId, content: discordIngress.content };
} else {
if (!isChatSocketMessage(rawData)) {
this.logger.warn(`Rejected malformed chat message from ${client.id}`);
return;
}
data = rawData;
}
const conversationId = data.conversationId ?? uuid();
const userId = (client.data.user as { id: string } | undefined)?.id;
const discordServiceUserId = process.env['DISCORD_SERVICE_USER_ID'];
if (discordIngress && !discordServiceUserId) {
this.logger.warn(
`Rejected Discord ingress without configured service owner from ${client.id}`,
);
return;
}
const userId = discordIngress
? discordServiceUserId
: (client.data.user as { id: string } | undefined)?.id;
const correlationId = discordIngress?.correlationId;
this.logger.log(`Message from ${client.id} in conversation ${conversationId}`);
this.logger.log(
`Message from ${client.id} in conversation ${conversationId}${correlationId ? ` correlation=${correlationId}` : ''}`,
);
// Ensure agent session exists for this conversation
let sessionRoutingDecision: RoutingDecisionInfo | undefined;
@@ -213,6 +285,13 @@ export class ChatGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa
content: data.content,
metadata: {
timestamp: new Date().toISOString(),
...(correlationId
? {
correlationId,
discordMessageId: discordIngress?.messageId,
discordUserId: discordIngress?.userId,
}
: {}),
},
},
userId,
@@ -271,7 +350,17 @@ export class ChatGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa
}
// Send acknowledgment
client.emit('message:ack', { conversationId, messageId: uuid() });
client.emit('message:ack', {
conversationId,
messageId: uuid(),
...(correlationId
? {
correlationId,
discordMessageId: discordIngress?.messageId,
discordUserId: discordIngress?.userId,
}
: {}),
});
// Dispatch to agent
try {
@@ -442,6 +531,39 @@ export class ChatGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa
* Creates it if absent — safe to call concurrently since a duplicate insert
* would fail on the PK constraint and be caught here.
*/
private resolveDiscordIngress(
client: Socket,
envelope: DiscordIngressEnvelope,
): DiscordIngressPayload | null {
const payload = verifyDiscordIngressEnvelope(
envelope,
process.env['DISCORD_SERVICE_TOKEN'] ?? '',
{
guildIds: this.readDiscordAllowlist('DISCORD_ALLOWED_GUILD_IDS'),
channelIds: this.readDiscordAllowlist('DISCORD_ALLOWED_CHANNEL_IDS'),
userIds: this.readDiscordAllowlist('DISCORD_ALLOWED_USER_IDS'),
},
);
if (!payload) {
this.logger.warn(`Rejected invalid Discord ingress envelope from ${client.id}`);
return null;
}
if (!this.discordReplayProtector.claim(payload.messageId)) {
this.logger.warn(
`Rejected replayed Discord message=${payload.messageId} correlation=${payload.correlationId}`,
);
return null;
}
return payload;
}
private readDiscordAllowlist(name: string): string[] {
return (process.env[name] ?? '')
.split(',')
.map((id: string): string => id.trim())
.filter((id: string): boolean => id.length > 0);
}
private async ensureConversation(conversationId: string, userId: string): Promise<void> {
try {
const existing = await this.brain.conversations.findById(conversationId, userId);

View File

@@ -0,0 +1,89 @@
import { describe, expect, it } from 'vitest';
import {
createDiscordIngressEnvelope,
verifyDiscordIngressEnvelope,
type DiscordIngressPayload,
} from '@mosaicstack/discord-plugin';
import { validateDiscordServiceToken } from '../chat/chat.gateway-auth.js';
import { DiscordReplayProtector } from './discord-replay-protector.js';
const SERVICE_TOKEN = 'test-service-token';
function createPayload(overrides: Partial<DiscordIngressPayload> = {}): DiscordIngressPayload {
return {
correlationId: 'correlation-001',
messageId: 'discord-message-001',
guildId: 'guild-001',
channelId: 'channel-001',
userId: 'user-001',
conversationId: 'discord-channel-001',
content: 'hello Tess',
...overrides,
};
}
describe('Discord ingress security', () => {
it('accepts only the configured Discord service identity', () => {
expect(validateDiscordServiceToken(SERVICE_TOKEN, SERVICE_TOKEN)).toBe(true);
expect(validateDiscordServiceToken('wrong-service-token', SERVICE_TOKEN)).toBe(false);
expect(validateDiscordServiceToken(undefined, SERVICE_TOKEN)).toBe(false);
});
it('rejects unauthenticated or tampered service envelopes', () => {
const envelope = createDiscordIngressEnvelope(createPayload(), SERVICE_TOKEN);
expect(verifyDiscordIngressEnvelope(envelope, SERVICE_TOKEN)).toEqual(createPayload());
expect(verifyDiscordIngressEnvelope(envelope, 'wrong-service-token')).toBeNull();
expect(
verifyDiscordIngressEnvelope(
{ ...envelope, payload: { ...envelope.payload, content: 'forged command' } },
SERVICE_TOKEN,
),
).toBeNull();
});
it.each([
['guild', { guildId: 'unlisted-guild' }],
['channel', { channelId: 'unlisted-channel' }],
['user', { userId: 'unlisted-user' }],
])(
'rejects an unallowlisted Discord %s',
(_kind: string, overrides: Partial<DiscordIngressPayload>) => {
const envelope = createDiscordIngressEnvelope(createPayload(overrides), SERVICE_TOKEN);
expect(
verifyDiscordIngressEnvelope(envelope, SERVICE_TOKEN, {
guildIds: ['guild-001'],
channelIds: ['channel-001'],
userIds: ['user-001'],
}),
).toBeNull();
},
);
it('retains Discord message and correlation IDs after authenticated allowlisted validation', () => {
const payload = createPayload({
correlationId: 'correlation-trace-123',
messageId: 'discord-snowflake-987',
});
const envelope = createDiscordIngressEnvelope(payload, SERVICE_TOKEN);
expect(
verifyDiscordIngressEnvelope(envelope, SERVICE_TOKEN, {
guildIds: ['guild-001'],
channelIds: ['channel-001'],
userIds: ['user-001'],
}),
).toEqual(payload);
});
it('rejects a replayed Discord message ID while retaining bounded replay state', () => {
const replayProtector = new DiscordReplayProtector(60_000, 2);
expect(replayProtector.claim('discord-message-001')).toBe(true);
expect(replayProtector.claim('discord-message-001')).toBe(false);
expect(replayProtector.claim('discord-message-002')).toBe(true);
expect(replayProtector.claim('discord-message-003')).toBe(true);
expect(replayProtector.size).toBe(2);
});
});

View File

@@ -0,0 +1,40 @@
/**
* Bounded replay cache for Discord's globally unique native message IDs.
* Durable ingress idempotency is added with Tess's canonical inbox/outbox work.
*/
export class DiscordReplayProtector {
private readonly claimedAt = new Map<string, number>();
constructor(
private readonly ttlMs = 15 * 60 * 1000,
private readonly maxEntries = 10_000,
) {}
get size(): number {
return this.claimedAt.size;
}
/** Claims an ID exactly once within its bounded retention window. */
claim(messageId: string, now = Date.now()): boolean {
this.prune(now);
if (this.claimedAt.has(messageId)) return false;
this.claimedAt.set(messageId, now);
this.evictOverflow();
return true;
}
private prune(now: number): void {
for (const [messageId, claimedAt] of this.claimedAt) {
if (now - claimedAt >= this.ttlMs) this.claimedAt.delete(messageId);
}
}
private evictOverflow(): void {
while (this.claimedAt.size > this.maxEntries) {
const oldestMessageId = this.claimedAt.keys().next().value;
if (oldestMessageId === undefined) return;
this.claimedAt.delete(oldestMessageId);
}
}
}

View File

@@ -50,19 +50,41 @@ class TelegramChannelPluginAdapter implements IChannelPlugin {
const DEFAULT_GATEWAY_URL = 'http://localhost:14242';
function requiredDiscordAllowlist(name: string): string[] {
const value = process.env[name]
?.split(',')
.map((id: string): string => id.trim())
.filter((id: string): boolean => id.length > 0);
if (!value || value.length === 0) {
throw new Error(`${name} is required when DISCORD_BOT_TOKEN is configured`);
}
return value;
}
function createPluginRegistry(): IChannelPlugin[] {
const plugins: IChannelPlugin[] = [];
const discordToken = process.env['DISCORD_BOT_TOKEN'];
const discordGuildId = process.env['DISCORD_GUILD_ID'];
const discordGatewayUrl = process.env['DISCORD_GATEWAY_URL'] ?? DEFAULT_GATEWAY_URL;
const discordServiceToken = process.env['DISCORD_SERVICE_TOKEN'];
const discordServiceUserId = process.env['DISCORD_SERVICE_USER_ID'];
if (discordToken) {
if (!discordServiceToken || !discordServiceUserId) {
throw new Error(
'DISCORD_SERVICE_TOKEN and DISCORD_SERVICE_USER_ID are required when DISCORD_BOT_TOKEN is configured',
);
}
plugins.push(
new DiscordChannelPluginAdapter(
new DiscordPlugin({
token: discordToken,
guildId: discordGuildId,
gatewayUrl: discordGatewayUrl,
serviceToken: discordServiceToken,
allowedGuildIds: requiredDiscordAllowlist('DISCORD_ALLOWED_GUILD_IDS'),
allowedChannelIds: requiredDiscordAllowlist('DISCORD_ALLOWED_CHANNEL_IDS'),
allowedUserIds: requiredDiscordAllowlist('DISCORD_ALLOWED_USER_IDS'),
}),
),
);

View File

@@ -232,6 +232,10 @@ The following sections document how each supported channel maps its native messa
**Outbound:** Adapter calls Discord REST `POST /channels/{id}/messages`. Markdown content is sent as-is (Discord renders it). For `contentType = "code"` the adapter wraps in triple-backtick fences with the `metadata.language` tag.
### Discord service ingress security
The Discord adapter is an authenticated gateway service, not an anonymous Socket.IO client. It presents `DISCORD_SERVICE_TOKEN` during its `/chat` connection and signs each inbound envelope using HMAC-SHA-256. The envelope contains the Discord native message ID and a generated correlation ID. Gateway verifies the service credential, signature, and configured guild/channel/user allowlists before agent dispatch, then rejects duplicate native message IDs inside its bounded replay window. All three allowlists are default-deny and required when the Discord plugin is enabled. The service credential is injected at runtime and is never logged or included in protocol payloads.
---
### Telegram

View File

@@ -293,13 +293,24 @@ Each OIDC provider requires its client ID, client secret, and issuer URL togethe
### Plugins
| Variable | Description |
| ---------------------- | -------------------------------------------------------------------------- |
| `DISCORD_BOT_TOKEN` | Discord bot token (enables Discord plugin) |
| `DISCORD_GUILD_ID` | Discord guild/server ID |
| `DISCORD_GATEWAY_URL` | Gateway URL for Discord plugin to call (default: `http://localhost:14242`) |
| `TELEGRAM_BOT_TOKEN` | Telegram bot token (enables Telegram plugin) |
| `TELEGRAM_GATEWAY_URL` | Gateway URL for Telegram plugin to call |
| Variable | Description |
| ----------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------- |
| `DISCORD_BOT_TOKEN` | Discord bot token (enables Discord plugin) |
| `DISCORD_SERVICE_TOKEN` | Required high-entropy service credential used to authenticate and sign Discord ingress; inject through the approved secret mechanism only |
| `DISCORD_SERVICE_USER_ID` | Required Mosaic service-principal user ID that owns persisted Discord conversations; the original Discord user ID remains audit metadata |
| `DISCORD_GUILD_ID` | Discord guild/server ID |
| `DISCORD_GATEWAY_URL` | Gateway URL for Discord plugin to call (default: `http://localhost:14242`) |
| `DISCORD_ALLOWED_GUILD_IDS` | Required comma-separated Discord guild snowflake allowlist; default-deny |
| `DISCORD_ALLOWED_CHANNEL_IDS` | Required comma-separated Discord channel snowflake allowlist; default-deny |
| `DISCORD_ALLOWED_USER_IDS` | Required comma-separated Discord user snowflake allowlist; default-deny |
| `TELEGRAM_BOT_TOKEN` | Telegram bot token (enables Telegram plugin) |
| `TELEGRAM_GATEWAY_URL` | Gateway URL for Telegram plugin to call |
### Discord ingress security
When `DISCORD_BOT_TOKEN` is configured, `DISCORD_SERVICE_TOKEN`, `DISCORD_SERVICE_USER_ID`, and all three Discord allowlists are required. Gateway startup fails rather than enabling a broad or unauthenticated remote-control surface. The service user ID identifies a provisioned Mosaic service principal for persistence; the original Discord user ID is retained in ingress audit metadata. The service token is a secret supplied by the approved runtime secret mechanism and is never committed or logged.
Inbound Discord messages must originate from an allowed guild, channel, and user, mention the bot, and carry a signed envelope containing the native Discord message ID and a generated correlation ID. The gateway validates the service identity, envelope signature, and allowlists again before dispatching. Replayed Discord message IDs are rejected during the bounded ingress replay window. Durable inbox/idempotency retention is introduced with Tess durable state.
### Observability

View File

@@ -0,0 +1,49 @@
# #703 Git Wrapper Interactive and Auth Resilience
## Objective
Restore the deployed Git wrapper contract: issue-create supports interactive invocation and Gitea mutation behavior tolerates a stale Tea authenticated user by validating current identity and using the existing host-scoped API fallback.
## Scope
- `packages/mosaic/framework/tools/git/issue-create.sh`
- `packages/mosaic/framework/tools/git/detect-platform.sh`
- Git wrapper regression harnesses
- This scratchpad
## Requirements / acceptance evidence
1. `issue-create -i` and `--interactive` prompt for missing issue fields without exposing credentials.
2. Explicit command-line fields retain precedence and do not trigger prompt input.
3. Gitea wrapper resolves the current user dynamically from the target host and does not rely on the saved Tea user identity.
4. A Tea `GetUserByName` failure falls back to authenticated API creation.
5. Existing body-safety, login-resolution, issue-create, and pr-create paths remain green.
6. Source framework is re-seeded to deployed `~/.config/mosaic`, then deployed wrappers are verified end to end.
## Plan
1. Add failing shell regression harness for interactive input and stale Tea user fallback.
2. Implement minimal helper and parser changes.
3. Run wrapper harnesses, syntax checks, and repository baseline checks.
4. Re-seed deployed framework and run live wrapper verification.
5. Commit, queue guard, push, open PR, and stop for independent review.
## Progress
- Issue #703 filed before code; issue comment records #536 root cause and stale-login trigger.
- Deployed wrapper `issue-create.sh -i` reproduced: `Unknown option: -i` (exit 1).
- Live Tea mutation did not reproduce `GetUserByName` on this host because the current mosaicstack Tea login is valid. The test harness models the reported stale authenticated-user condition.
- Implemented `-i` / `--interactive` prompt collection and a dynamic Tea `/user` validation. A stale Tea identity now selects the existing host-scoped Gitea API fallback before mutation for both issue and PR creation.
- Re-seeded the framework with `MOSAIC_INSTALL_MODE=keep MOSAIC_SYNC_ONLY=1 bash packages/mosaic/framework/install.sh`. Installed and source wrapper SHA-256 values matched.
- Live deployed verification: interactive issue-create opened then closed #704; installed dynamic identity resolved `jason.woltje`.
## Verification
- PASS: `packages/mosaic/framework/tools/git/test-issue-create-interactive-auth.sh`
- PASS: `packages/mosaic/framework/tools/git/test-issue-create-body-safety.sh`
- PASS: `packages/mosaic/framework/tools/git/test-gitea-login-resolution.sh`
- PASS: `packages/mosaic/framework/tools/git/test-pr-metadata-gitea.sh`
- PASS: `packages/mosaic/framework/tools/git/test-pr-merge-gitea-empty-uid.sh`
- PASS: `bash packages/mosaic/framework/tools/git/test-lane-brief-pr-linkage.sh`
- PASS: `bash -n packages/mosaic/framework/tools/git/*.sh`
- PASS: Prettier check for this scratchpad

View File

@@ -0,0 +1,35 @@
# Scratchpad — TESS-M1-SEC-004 Discord ingress
- **Task / issue:** TESS-M1-SEC-004 / #707
- **Branch:** `fix/tess-discord-ingress` from `origin/main` at `59e49cfd`
- **Objective:** Authenticate the Discord plugin service at gateway ingress; enforce explicit guild/channel/user allowlists; attach Discord message and generated correlation IDs; reject replayed native message IDs.
- **Scope:** `plugins/discord`, `apps/gateway`, and existing Discord admin/developer protocol docs.
- **Budget:** Task estimate 28K; no explicit hard cap supplied.
- **Assumptions:** The Discord plugin and gateway share an injected high-entropy `DISCORD_SERVICE_TOKEN`; a configured Discord plugin fails closed without it. Allowlist configuration is comma-separated Discord snowflakes. Discord native message ID is the replay key, with bounded in-memory retention pending the M2 durable inbox/idempotency work.
## Plan
1. Add failing tests covering ingress service authentication/signing, unlisted guild/channel/user rejection, correlation propagation, and replay rejection.
2. Implement the signed Discord ingress envelope and allowlist validation in the plugin.
3. Authenticate and validate the envelope at the gateway boundary, then enforce bounded replay protection before agent dispatch.
4. Document the service-token and allowlist operations; run focused and baseline gates; obtain independent review.
## Progress
- 2026-07-12: Intake complete; PRD TESS-SEC-005, architecture, and threat model reviewed.
- Added service-token Socket.IO authentication, HMAC-signed Discord envelopes, default-deny guild/channel/user allowlists, correlated message metadata, bounded replay rejection, and fail-fast configuration checks.
- Code and security reviews completed. Code review findings on service persistence ownership, package-boundary tests, disconnected ingress observability, and chat payload validation were remediated; final independent code review approved.
## Risks / blockers
- Existing `main` has known unrelated Prettier debt; only changed files will be held format-clean. Durable replay persistence is intentionally out of scope for this M1 prerequisite and belongs to TESS-M2 durable inbox/idempotency work.
## Verification evidence
- Focused ingress suite: `pnpm --filter @mosaicstack/gateway test -- discord-ingress.security.spec.ts` — 7 passed.
- Gateway suite: `pnpm --filter @mosaicstack/gateway test` — 513 passed, 11 skipped.
- Plugin suite: `pnpm --filter @mosaicstack/discord-plugin test` — no tests, passed by configured `--passWithNoTests`.
- `pnpm typecheck` — passed.
- `pnpm lint` — passed.
- `pnpm format:check` — fails only on the known pre-existing Tess documentation debt listed in the task dispatch; changed files pass targeted Prettier verification.
- Codex security review — no findings; final Codex code review — approved.

View File

@@ -240,6 +240,33 @@ get_gitea_login_for_host() {
return 1
}
# Validate the current authenticated Gitea user for a resolved Tea login.
# Tea stores a user name with each login which can become stale after user rename,
# token rotation, or server migration. Querying /user derives the identity from the
# active credential instead of trusting that saved name. Callers fall back to the
# host-scoped API path when this validation fails.
get_gitea_authenticated_user() {
local login_name="$1" response
command -v tea >/dev/null 2>&1 || return 1
response=$(tea api --login "$login_name" /user 2>/dev/null) || return 1
TEA_AUTHENTICATED_USER_JSON="$response" python3 - <<'PY'
import json
import os
try:
user = json.loads(os.environ["TEA_AUTHENTICATED_USER_JSON"])
except (KeyError, json.JSONDecodeError):
raise SystemExit(1)
login = user.get("login") if isinstance(user, dict) else None
if isinstance(login, str) and login:
print(login)
raise SystemExit(0)
raise SystemExit(1)
PY
}
get_default_tea_login() {
local logins_json

View File

@@ -12,6 +12,7 @@ TITLE=""
BODY=""
LABELS=""
MILESTONE=""
INTERACTIVE=false
# get_remote_host and get_gitea_token are provided by detect-platform.sh
@@ -66,11 +67,13 @@ Options:
-b, --body BODY Issue body/description
-l, --labels LABELS Comma-separated labels (e.g., "bug,feature")
-m, --milestone NAME Milestone name to assign
-i, --interactive Prompt for missing issue fields
-h, --help Show this help message
Examples:
$(basename "$0") -t "Fix login bug" -l "bug,priority-high"
$(basename "$0") -t "Add dark mode" -b "Implement theme switching" -m "0.2.0"
$(basename "$0") -i
EOF
exit "${1:-1}"
}
@@ -94,6 +97,10 @@ while [[ $# -gt 0 ]]; do
MILESTONE="$2"
shift 2
;;
-i|--interactive)
INTERACTIVE=true
shift
;;
-h|--help)
usage 0
;;
@@ -104,6 +111,13 @@ while [[ $# -gt 0 ]]; do
esac
done
if [[ "$INTERACTIVE" == true ]]; then
[[ -n "$TITLE" ]] || read -r -p "Issue title: " TITLE
[[ -n "$BODY" ]] || read -r -p "Issue body (optional): " BODY || true
[[ -n "$LABELS" ]] || read -r -p "Labels, comma-separated (optional): " LABELS || true
[[ -n "$MILESTONE" ]] || read -r -p "Milestone (optional): " MILESTONE || true
fi
if [[ -z "$TITLE" ]]; then
echo "Error: Title is required (-t)" >&2
usage
@@ -127,6 +141,11 @@ case "$PLATFORM" in
gitea_issue_create_api
exit $?
}
if ! get_gitea_authenticated_user "$GITEA_LOGIN_NAME" >/dev/null; then
echo "Warning: Tea authenticated-user validation failed (possible stale user/login); trying Gitea API fallback..." >&2
gitea_issue_create_api
exit $?
fi
REPO_ARGS=(--repo "$REPO_SLUG" --login "$GITEA_LOGIN_NAME")
CMD=(tea issue create "${REPO_ARGS[@]}" --title "$TITLE")
[[ -n "$BODY" ]] && CMD+=(--description "$BODY")

View File

@@ -183,6 +183,11 @@ case "$PLATFORM" in
gitea_pr_create_api
exit $?
}
if ! get_gitea_authenticated_user "$GITEA_LOGIN_NAME" >/dev/null; then
echo "Warning: Tea authenticated-user validation failed (possible stale user/login); trying Gitea API fallback..." >&2
gitea_pr_create_api
exit $?
fi
REPO_ARGS=(--repo "$REPO_SLUG" --login "$GITEA_LOGIN_NAME")
CMD=(tea pr create "${REPO_ARGS[@]}" --title "$TITLE")
[[ -n "$BODY" ]] && CMD+=(--description "$BODY")

View File

@@ -45,6 +45,11 @@ JSON
exit 0
fi
if [[ "${1:-}" == "api" ]]; then
printf '%s\n' '{"login":"ci-bot"}'
exit 0
fi
printf 'tea %s\n' "$*" >> "$MOSAIC_TEST_LOG"
if [[ "${MOSAIC_TEA_FAIL_PR_CREATE:-}" == "1" && "$*" == pr\ create* ]]; then
echo 'GetUserByName: simulated stale login failure' >&2

View File

@@ -55,6 +55,11 @@ JSON
exit 0
fi
if [[ "${1:-}" == "api" ]]; then
printf '%s\n' '{"login":"ci-bot"}'
exit 0
fi
if [[ "${1:-}" == "issue" && "${2:-}" == "create" ]]; then
desc=""
while [[ $# -gt 0 ]]; do

View File

@@ -0,0 +1,88 @@
#!/usr/bin/env bash
# Regression harness for #703: interactive issue creation and stale Tea-user fallback.
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
WORK_DIR="${MOSAIC_TEST_WORK_DIR:-$PWD/.mosaic-test-work/issue-create-interactive-auth}"
REPO_DIR="$WORK_DIR/repo"
BIN_DIR="$WORK_DIR/bin"
LOG_FILE="$WORK_DIR/calls.log"
CREDENTIALS_FILE="$WORK_DIR/credentials.json"
rm -rf "$WORK_DIR"
mkdir -p "$REPO_DIR" "$BIN_DIR"
git -C "$REPO_DIR" init -q
git -C "$REPO_DIR" remote add origin https://git.mosaicstack.dev/mosaicstack/stack.git
cat > "$CREDENTIALS_FILE" <<'JSON'
{"gitea":{"mosaicstack":{"url":"https://git.mosaicstack.dev","token":"test-token"}}}
JSON
cat > "$BIN_DIR/tea" <<'SH'
#!/usr/bin/env bash
set -euo pipefail
if [[ "$*" == "login list --output json" ]]; then
printf '%s\n' '[{"name":"mosaicstack","url":"https://git.mosaicstack.dev"}]'
exit 0
fi
if [[ "${1:-}" == "api" ]]; then
if [[ "${MOSAIC_TEA_STALE_USER:-0}" == "1" ]]; then
echo 'GetUserByName: stale configured user' >&2
exit 1
fi
printf '%s\n' '{"login":"current-user"}'
exit 0
fi
printf 'tea %s\n' "$*" >> "$MOSAIC_TEST_LOG"
exit 0
SH
cat > "$BIN_DIR/curl" <<'SH'
#!/usr/bin/env bash
set -euo pipefail
printf 'curl %s\n' "$*" >> "$MOSAIC_TEST_LOG"
printf '%s\n' '{"number":703}'
SH
chmod +x "$BIN_DIR/tea" "$BIN_DIR/curl"
run_wrapper() {
(
cd "$REPO_DIR"
PATH="$BIN_DIR:$PATH" \
MOSAIC_CREDENTIALS_FILE="$CREDENTIALS_FILE" \
MOSAIC_TEST_LOG="$LOG_FILE" \
"$@"
)
}
: > "$LOG_FILE"
printf 'Interactive title\nInteractive body\nlabel-a,label-b\nM1\n' | run_wrapper "$SCRIPT_DIR/issue-create.sh" -i >/dev/null
grep -q -- 'tea issue create --repo mosaicstack/stack --login mosaicstack --title Interactive title --description Interactive body --labels label-a,label-b --milestone M1' "$LOG_FILE"
# Explicit values take precedence in interactive mode: no title input is
# supplied, but the wrapper still creates the issue with the explicit title.
: > "$LOG_FILE"
printf '\n\n\n' | run_wrapper "$SCRIPT_DIR/issue-create.sh" -i -t 'Explicit title' >/dev/null
grep -q -- 'tea issue create --repo mosaicstack/stack --login mosaicstack --title Explicit title' "$LOG_FILE"
: > "$LOG_FILE"
run_wrapper env MOSAIC_TEA_STALE_USER=1 "$SCRIPT_DIR/issue-create.sh" -t 'Fallback title' -b 'Fallback body' >/dev/null 2>"$WORK_DIR/issue-stderr"
grep -q -- 'curl .*https://git.mosaicstack.dev/api/v1/repos/mosaicstack/stack/issues' "$LOG_FILE"
grep -q -- 'Tea authenticated-user validation failed' "$WORK_DIR/issue-stderr"
if grep -q -- 'tea issue create' "$LOG_FILE"; then
echo 'FAIL: issue-create invoked Tea mutation after stale-user validation failed' >&2
exit 1
fi
: > "$LOG_FILE"
run_wrapper env MOSAIC_TEA_STALE_USER=1 "$SCRIPT_DIR/pr-create.sh" -t 'PR fallback' -H feature/wrapfix >/dev/null 2>"$WORK_DIR/pr-stderr"
grep -q -- 'curl .*https://git.mosaicstack.dev/api/v1/repos/mosaicstack/stack/pulls' "$LOG_FILE"
grep -q -- 'Tea authenticated-user validation failed' "$WORK_DIR/pr-stderr"
if grep -q -- 'tea pr create' "$LOG_FILE"; then
echo 'FAIL: pr-create invoked Tea mutation after stale-user validation failed' >&2
exit 1
fi
echo 'issue-create interactive/auth regression harness passed'

View File

@@ -1,24 +1,109 @@
import { createHmac, randomUUID, timingSafeEqual } from 'node:crypto';
import { ChannelType, Client, GatewayIntentBits, type Message as DiscordMessage } from 'discord.js';
import { io, type Socket } from 'socket.io-client';
export interface DiscordPluginConfig {
token: string;
gatewayUrl: string;
/** Which guild to bind to (single-guild only for v0.1.0) */
/** Shared service credential injected by the approved secret mechanism. */
serviceToken: string;
/** Which guild to bind to (single-guild only for v0.1.0). */
guildId?: string;
allowedGuildIds: readonly string[];
allowedChannelIds: readonly string[];
allowedUserIds: readonly string[];
}
export interface DiscordIngressPayload {
correlationId: string;
messageId: string;
guildId: string;
channelId: string;
userId: string;
conversationId: string;
content: string;
}
export interface DiscordIngressEnvelope {
payload: DiscordIngressPayload;
signature: string;
}
export interface DiscordIngressAllowlists {
guildIds: readonly string[];
channelIds: readonly string[];
userIds: readonly string[];
}
function signedPayload(payload: DiscordIngressPayload): string {
return [
payload.correlationId,
payload.messageId,
payload.guildId,
payload.channelId,
payload.userId,
payload.conversationId,
payload.content,
].join('\n');
}
function signPayload(payload: DiscordIngressPayload, serviceToken: string): string {
return createHmac('sha256', serviceToken).update(signedPayload(payload)).digest('hex');
}
function isSignatureValid(actual: string, expected: string): boolean {
const actualBuffer = Buffer.from(actual, 'hex');
const expectedBuffer = Buffer.from(expected, 'hex');
return (
actualBuffer.length === expectedBuffer.length && timingSafeEqual(actualBuffer, expectedBuffer)
);
}
function includesId(allowedIds: readonly string[], id: string): boolean {
return allowedIds.includes(id);
}
/** Creates the signed, auditable envelope accepted by the gateway Discord service boundary. */
export function createDiscordIngressEnvelope(
payload: DiscordIngressPayload,
serviceToken: string,
): DiscordIngressEnvelope {
return { payload, signature: signPayload(payload, serviceToken) };
}
/**
* Verifies service-origin integrity and applies default-deny Discord identity allowlists.
* Returns null instead of a partially trusted payload on every failure path.
*/
export function verifyDiscordIngressEnvelope(
envelope: DiscordIngressEnvelope,
serviceToken: string,
allowlists?: DiscordIngressAllowlists,
): DiscordIngressPayload | null {
const expectedSignature = signPayload(envelope.payload, serviceToken);
if (!isSignatureValid(envelope.signature, expectedSignature)) return null;
if (
allowlists &&
(!includesId(allowlists.guildIds, envelope.payload.guildId) ||
!includesId(allowlists.channelIds, envelope.payload.channelId) ||
!includesId(allowlists.userIds, envelope.payload.userId))
) {
return null;
}
return envelope.payload;
}
export class DiscordPlugin {
private client: Client;
private socket: Socket | null = null;
private config: DiscordPluginConfig;
/** Map Discord channel ID → Mosaic conversation ID */
/** Map Discord channel ID → Mosaic conversation ID. */
private channelConversations = new Map<string, string>();
/** Track in-flight responses to avoid duplicate streaming */
/** Track in-flight responses to avoid duplicate streaming. */
private pendingResponses = new Map<string, string>();
constructor(config: DiscordPluginConfig) {
this.config = config;
constructor(private readonly config: DiscordPluginConfig) {
this.client = new Client({
intents: [
GatewayIntentBits.Guilds,
@@ -30,8 +115,8 @@ export class DiscordPlugin {
}
async start(): Promise<void> {
// Connect to gateway WebSocket
this.socket = io(`${this.config.gatewayUrl}/chat`, {
auth: { discordServiceToken: this.config.serviceToken },
transports: ['websocket'],
});
@@ -48,7 +133,6 @@ export class DiscordPlugin {
console.error(`[discord] Gateway connection error: ${err.message}`);
});
// Handle streaming text from gateway
this.socket.on('agent:text', (data: { conversationId: string; text: string }) => {
const pending = this.pendingResponses.get(data.conversationId);
if (pending !== undefined) {
@@ -56,12 +140,11 @@ export class DiscordPlugin {
}
});
// When agent finishes, send the accumulated response
this.socket.on('agent:end', (data: { conversationId: string }) => {
const text = this.pendingResponses.get(data.conversationId);
if (text) {
this.pendingResponses.delete(data.conversationId);
this.sendToDiscord(data.conversationId, text).catch((err) => {
this.sendToDiscord(data.conversationId, text).catch((err: unknown) => {
console.error(`[discord] Error sending response for ${data.conversationId}:`, err);
});
}
@@ -71,8 +154,9 @@ export class DiscordPlugin {
this.pendingResponses.set(data.conversationId, '');
});
// Set up Discord message handler
this.client.on('messageCreate', (message) => this.handleDiscordMessage(message));
this.client.on('messageCreate', (message: DiscordMessage) =>
this.handleDiscordMessage(message),
);
this.client.on('ready', () => {
console.log(`[discord] Bot logged in as ${this.client.user?.tag}`);
@@ -96,7 +180,6 @@ export class DiscordPlugin {
const guild = this.client.guilds.cache.get(this.config.guildId);
if (!guild) return null;
// Slugify project name for channel: lowercase, replace spaces/special chars with hyphens
const channelName = `mosaic-${project.name
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
@@ -108,58 +191,58 @@ export class DiscordPlugin {
topic: project.description ?? `Mosaic project: ${project.name}`,
});
// Register the channel mapping so messages route correctly
this.channelConversations.set(channel.id, `discord-${channel.id}`);
return { channelId: channel.id };
}
private handleDiscordMessage(message: DiscordMessage): void {
// Ignore bot messages
if (message.author.bot) return;
if (message.author.bot || !this.client.user) return;
if (!message.guildId || !this.isAllowedMessage(message)) return;
// Not ready yet
if (!this.client.user) return;
// Check guild binding
if (this.config.guildId && message.guildId !== this.config.guildId) return;
// Respond to DMs always, or mentions in channels
const isDM = !message.guildId;
const isMention = message.mentions.has(this.client.user);
if (!isMention) return;
if (!isDM && !isMention) return;
// Strip bot mention from message content
const content = message.content
.replace(new RegExp(`<@!?${this.client.user.id}>`, 'g'), '')
.trim();
if (!content) return;
// Get or create conversation for this Discord channel
const channelId = message.channelId;
let conversationId = this.channelConversations.get(channelId);
if (!conversationId) {
conversationId = `discord-${channelId}`;
this.channelConversations.set(channelId, conversationId);
}
// Send to gateway
if (!this.socket?.connected) {
console.error(
`[discord] Cannot forward message: not connected to gateway. channel=${channelId}`,
`[discord] Cannot forward message: not connected to gateway. channel=${message.channelId} message=${message.id}`,
);
return;
}
this.socket.emit('message', {
conversationId,
content,
});
const channelId = message.channelId;
const conversationId = this.channelConversations.get(channelId) ?? `discord-${channelId}`;
this.channelConversations.set(channelId, conversationId);
const envelope = createDiscordIngressEnvelope(
{
correlationId: randomUUID(),
messageId: message.id,
guildId: message.guildId,
channelId,
userId: message.author.id,
conversationId,
content,
},
this.config.serviceToken,
);
this.socket.emit('message', envelope);
}
private isAllowedMessage(message: DiscordMessage): boolean {
const guildId = message.guildId;
return (
guildId !== null &&
includesId(this.config.allowedGuildIds, guildId) &&
includesId(this.config.allowedChannelIds, message.channelId) &&
includesId(this.config.allowedUserIds, message.author.id)
);
}
private async sendToDiscord(conversationId: string, text: string): Promise<void> {
// Find the Discord channel for this conversation
const channelId = Array.from(this.channelConversations.entries()).find(
([, convId]) => convId === conversationId,
)?.[0];
@@ -177,12 +260,10 @@ export class DiscordPlugin {
return;
}
// Chunk responses for Discord's 2000-char limit
const chunks = this.chunkText(text, 1900);
for (const chunk of chunks) {
for (const chunk of this.chunkText(text, 1900)) {
try {
await (channel as { send: (content: string) => Promise<unknown> }).send(chunk);
} catch (err) {
} catch (err: unknown) {
console.error(`[discord] Failed to send message to channel ${channelId}:`, err);
}
}
@@ -193,21 +274,16 @@ export class DiscordPlugin {
const chunks: string[] = [];
let remaining = text;
while (remaining.length > 0) {
if (remaining.length <= maxLength) {
chunks.push(remaining);
break;
}
// Try to break at a newline
let breakPoint = remaining.lastIndexOf('\n', maxLength);
if (breakPoint <= 0) breakPoint = maxLength;
chunks.push(remaining.slice(0, breakPoint));
remaining = remaining.slice(breakPoint).trimStart();
}
return chunks;
}
}