Skip to content
Closed
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 .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -30,3 +30,6 @@ SENTINEL_CLIENT_ID=
SENTINEL_CLIENT_SECRET=
SENTINEL_WORKSPACE_ID=
MUSTER_MOCK_INTEGRATIONS=true
MUSTER_AGENT_GATEWAY_TOKEN=replace-with-at-least-32-random-bytes
# Optional comma-separated HTTPS origins for additional Alfie research feeds.
MUSTER_RESEARCH_ALLOWED_FEED_ORIGINS=
6 changes: 4 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -177,8 +177,10 @@ that will use the service. When using HTTPS behind a reverse proxy, set
`AUTH_SECURE_COOKIES=true`; do not add broad wildcard origins.

The installer creates `.env.homelab` with mode `600`. Keep it out of source
control. The generated topology still uses synthetic connector endpoints until
you deliberately configure governed real connectors.
control. Its generated `MUSTER_AGENT_GATEWAY_TOKEN` authenticates internal
web/worker calls to the unexposed agent gateway; do not reuse or publish it.
The generated topology still uses synthetic connector endpoints until you
deliberately configure governed real connectors.

### Codex subscription authentication

Expand Down
44 changes: 42 additions & 2 deletions apps/agent-gateway/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,10 @@ import {
import { and, eq } from "drizzle-orm";
import { z } from "zod";
import { DurableAgentRuntime } from "./runtime.ts";
import {
isGatewayRequestAuthorised,
parseGatewayOrganisationId,
} from "./service-auth.ts";

const AgentRunRequestSchema = AgentInvestigationJobSchema.extend({
humanRequest: z.string().trim().min(1).max(4_000).optional(),
Expand All @@ -24,6 +28,10 @@ const executionRuntime =
process.env.MUSTER_AGENT_RUNTIME === "mock" ? "mock" : "codex";
const codexHome = process.env.CODEX_HOME ?? "/var/lib/muster/codex";
const globalKillSwitch = process.env.AGENT_KILL_SWITCH === "true";
const gatewayToken = z
.string()
.min(32)
.parse(process.env.MUSTER_AGENT_GATEWAY_TOKEN);
const runtime = new DurableAgentRuntime({
executionRuntime,
codexHome,
Expand All @@ -47,6 +55,12 @@ async function body(request: IncomingMessage): Promise<unknown> {
return JSON.parse(Buffer.concat(chunks).toString("utf8"));
}

function requestOrganisationId(request: IncomingMessage) {
return parseGatewayOrganisationId(
request.headers["x-muster-organisation-id"],
);
}

async function queueDirectRun(
input: z.infer<typeof AgentRunRequestSchema>,
idempotencyKey: string,
Expand Down Expand Up @@ -170,12 +184,26 @@ const server = createServer(async (incoming, response) => {
return;
}

if (
!isGatewayRequestAuthorised(incoming.headers.authorization, gatewayToken)
) {
response.writeHead(401);
response.end(JSON.stringify({ error: "Unauthorised" }));
return;
}

const runMatch =
incoming.method === "GET"
? url.pathname.match(/^\/v1\/runs\/([^/]+)$/)
: null;
if (runMatch?.[1]) {
const run = await runtime.read(runMatch[1]);
const organisationId = requestOrganisationId(incoming);
if (!organisationId) {
response.writeHead(400);
response.end(JSON.stringify({ error: "Organisation header required" }));
return;
}
const run = await runtime.read(runMatch[1], organisationId);
response.writeHead(run ? 200 : 404);
response.end(JSON.stringify(run ?? { error: "Run not found" }));
return;
Expand Down Expand Up @@ -234,6 +262,12 @@ const server = createServer(async (incoming, response) => {
);
return;
}
const organisationId = requestOrganisationId(incoming);
if (!organisationId || organisationId !== parsed.data.organisationId) {
response.writeHead(403);
response.end(JSON.stringify({ error: "Organisation mismatch" }));
return;
}
const idempotencyKey =
incoming.headers["idempotency-key"]?.toString().trim() ||
`api:${parsed.data.traceId}`;
Expand Down Expand Up @@ -269,7 +303,13 @@ const server = createServer(async (incoming, response) => {
? url.pathname.match(/^\/v1\/runs\/([^/]+)\/cancel$/)
: null;
if (cancelMatch?.[1]) {
const cancelled = await runtime.cancel(cancelMatch[1]);
const organisationId = requestOrganisationId(incoming);
if (!organisationId) {
response.writeHead(400);
response.end(JSON.stringify({ error: "Organisation header required" }));
return;
}
const cancelled = await runtime.cancel(cancelMatch[1], organisationId);
response.writeHead(cancelled ? 202 : 404);
response.end(
JSON.stringify({
Expand Down
Loading
Loading