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: 2 additions & 1 deletion relay/src/relay-object.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ import {
saveSharingState,
sharedRelayRequestAccess,
summarizeShare,
stripTrustedAimuxHeaders,
} from "./sharing.js";

const HEARTBEAT_INTERVAL_MS = 30_000;
Expand Down Expand Up @@ -460,7 +461,7 @@ export class RelayObject extends DurableObject<Env> {
ok: true,
requestPatch: {
headers: {
...(request.headers ?? {}),
...stripTrustedAimuxHeaders(request.headers),
"X-Aimux-Share-Id": share.id,
"X-Aimux-Share-Mode": getShareChatMode(share),
"X-Aimux-Actor-User-Id": participant.userId,
Expand Down
11 changes: 11 additions & 0 deletions relay/src/sharing.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import {
isSharedRelayRequestAllowed,
removeShareParticipant,
sharedRelayRequestAccess,
stripTrustedAimuxHeaders,
summarizeShare,
} from "./sharing";

Expand Down Expand Up @@ -190,6 +191,16 @@ describe("sharing state", () => {
expect(actorDisplayPrefix({ userId: "u", displayName: "", role: "guest" })).toBe("[User]:");
});

it("strips client-provided trusted relay headers before injection", () => {
expect(
stripTrustedAimuxHeaders({
"content-type": "application/json",
"x-aimux-actor-name": "Mallory",
"X-Aimux-Share-Mode": "multi",
}),
).toEqual({ "content-type": "application/json" });
});

it("redacts invite token hashes from public summaries", async () => {
const created = await createShareInvite(emptySharingState(), {
owner,
Expand Down
9 changes: 9 additions & 0 deletions relay/src/sharing.ts
Original file line number Diff line number Diff line change
Expand Up @@ -263,6 +263,15 @@ export function actorDisplayPrefix(actor: ShareActor): string {
return `[${sanitizeDisplayName(actor.displayName)}]:`;
}

export function stripTrustedAimuxHeaders(headers: Record<string, string> | undefined): Record<string, string> {
const safeHeaders: Record<string, string> = {};
for (const [key, value] of Object.entries(headers ?? {})) {
if (key.toLowerCase().startsWith("x-aimux-")) continue;
safeHeaders[key] = value;
}
return safeHeaders;
}

function createShare(input: {
owner: ShareActor;
projectRoot: string;
Expand Down
86 changes: 86 additions & 0 deletions src/collaboration.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
import { describe, expect, it } from "vitest";

import { applyAgentCollaborationPrefix, collaborationContextFromHeaders } from "./collaboration.js";

describe("collaboration context", () => {
it("parses relay-injected actor headers", () => {
expect(
collaborationContextFromHeaders({
"X-Aimux-Share-Id": "share_123",
"X-Aimux-Share-Mode": "multi",
"X-Aimux-Actor-User-Id": "user_123",
"X-Aimux-Actor-Name": "Sam Steady",
"X-Aimux-Actor-Email": "sam@example.com",
"X-Aimux-Actor-Role": "owner",
}),
).toEqual({
shareId: "share_123",
mode: "multi",
actor: {
userId: "user_123",
displayName: "Sam Steady",
email: "sam@example.com",
role: "owner",
},
});
});

it("returns undefined when no collaboration headers are present", () => {
expect(collaborationContextFromHeaders({ "content-type": "application/json" })).toBeUndefined();
});

it("prefixes plain text input only in multi-user mode", () => {
const collaboration = {
shareId: "share_123",
mode: "multi" as const,
actor: { userId: "user_123", displayName: "Sam Steady" },
};

expect(applyAgentCollaborationPrefix({ data: "ship it" }, collaboration)).toEqual({
data: "[Sam Steady]: ship it",
parts: undefined,
});
expect(applyAgentCollaborationPrefix({ data: "ship it" }, { ...collaboration, mode: "single" })).toEqual({
data: "ship it",
});
});

it("prefixes the first text part without mutating image parts", () => {
const input = {
parts: [
{ type: "image" as const, attachmentId: "att_1", alt: "screenshot" },
{ type: "text" as const, text: "look here" },
],
};

expect(
applyAgentCollaborationPrefix(input, {
mode: "multi",
actor: { userId: "user_123", displayName: "Casey" },
}),
).toEqual({
parts: [
{ type: "image", attachmentId: "att_1", alt: "screenshot" },
{ type: "text", text: "[Casey]: look here" },
],
});
});

it("adds a speaker-only text part before image-only input", () => {
expect(
applyAgentCollaborationPrefix(
{ parts: [{ type: "image", attachmentId: "att_1" }] },
{
mode: "multi",
actor: { userId: "user_123", displayName: "Casey" },
},
),
).toEqual({
data: undefined,
parts: [
{ type: "text", text: "[Casey]:" },
{ type: "image", attachmentId: "att_1" },
],
});
});
});
101 changes: 101 additions & 0 deletions src/collaboration.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
import type { IncomingHttpHeaders } from "node:http";

import type { AgentInputPart } from "./agent-message-parts.js";

export type CollaborationChatMode = "single" | "multi";
export type CollaborationRole = "owner" | "guest";

export interface CollaborationActor {
userId: string;
displayName: string;
email?: string;
role?: CollaborationRole;
}

export interface AgentCollaborationContext {
shareId?: string;
mode?: CollaborationChatMode;
actor?: CollaborationActor;
}

type HeaderMap = IncomingHttpHeaders | Record<string, string | string[] | undefined>;

export function collaborationContextFromHeaders(headers: HeaderMap): AgentCollaborationContext | undefined {
const shareId = headerValue(headers, "x-aimux-share-id");
const mode = parseMode(headerValue(headers, "x-aimux-share-mode"));
const userId = headerValue(headers, "x-aimux-actor-user-id");
const displayName = headerValue(headers, "x-aimux-actor-name");
const email = headerValue(headers, "x-aimux-actor-email");
const role = parseRole(headerValue(headers, "x-aimux-actor-role"));

if (!shareId && !mode && !userId && !displayName && !email && !role) {
return undefined;
}

return {
shareId,
mode,
actor: userId
? {
userId,
displayName: displayName || email || userId,
email,
role,
}
: undefined,
};
}

export function applyAgentCollaborationPrefix(
input: { data?: string; parts?: AgentInputPart[] },
collaboration?: AgentCollaborationContext,
): { data?: string; parts?: AgentInputPart[] } {
const actor = collaboration?.actor;
if (collaboration?.mode !== "multi" || !actor) {
return input;
}

const prefix = `[${formatSpeakerName(actor.displayName)}]:`;
if (Array.isArray(input.parts) && input.parts.length > 0) {
let applied = false;
const parts = input.parts.map((part) => {
if (part.type !== "text" || applied) return part;
const text = String(part.text ?? "");
if (!text.trim()) return part;
applied = true;
return { ...part, text: `${prefix} ${text}` };
});
return {
data: input.data,
parts: applied ? parts : [{ type: "text", text: prefix }, ...input.parts],
};
}

const data = String(input.data ?? "");
return {
data: data.trim() ? `${prefix} ${data}` : prefix,
parts: input.parts,
};
}

function headerValue(headers: HeaderMap, name: string): string | undefined {
const target = name.toLowerCase();
const raw = Object.entries(headers).find(([key]) => key.toLowerCase() === target)?.[1];
const value = Array.isArray(raw) ? raw[0] : raw;
Comment thread
coderabbitai[bot] marked this conversation as resolved.
const trimmed = value?.trim();
return trimmed || undefined;
}

function parseMode(value?: string): CollaborationChatMode | undefined {
const mode = value?.toLowerCase();
return mode === "single" || mode === "multi" ? mode : undefined;
}

function parseRole(value?: string): CollaborationRole | undefined {
const role = value?.toLowerCase();
return role === "owner" || role === "guest" ? role : undefined;
}

function formatSpeakerName(name: string): string {
return name.replace(/\s+/g, " ").trim().slice(0, 80) || "User";
}
73 changes: 73 additions & 0 deletions src/metadata-server.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1582,6 +1582,79 @@ describe("MetadataServer threads API", () => {
expect(writes).toEqual([{ sessionId: "codex-1", data: "hello from http", submit: true }]);
});

it("passes trusted collaboration headers with agent input over HTTP", async () => {
server?.stop();
const writes: Array<{
sessionId: string;
data?: string;
collaboration?: {
shareId?: string;
mode?: string;
actor?: { userId: string; displayName: string; email?: string; role?: string };
};
}> = [];
server = new MetadataServer({
lifecycle: {
writeAgentInput: ({ sessionId, data, collaboration }) => {
writes.push({ sessionId, data, collaboration });
return {
sessionId,
accepted: true,
operation: {
id: "inputop-collab",
sessionId,
submit: true,
state: "submitted" as const,
createdAt: "2026-01-01T00:00:00.000Z",
updatedAt: "2026-01-01T00:00:00.000Z",
},
};
},
},
});
await server.start();

const endpoint = server?.getAddress();
expect(endpoint).toBeTruthy();
const base = `http://${endpoint!.host}:${endpoint!.port}`;

const inputRes = await fetch(`${base}/agents/input`, {
method: "POST",
headers: {
"content-type": "application/json",
"x-aimux-share-id": "share_123",
"x-aimux-share-mode": "multi",
"x-aimux-actor-user-id": "user_123",
"x-aimux-actor-name": "Sam Steady",
"x-aimux-actor-email": "sam@example.com",
"x-aimux-actor-role": "owner",
},
body: JSON.stringify({
sessionId: "claude-1",
data: "hello from http",
submit: true,
}),
});

expect(inputRes.ok).toBe(true);
expect(writes).toEqual([
{
sessionId: "claude-1",
data: "hello from http",
collaboration: {
shareId: "share_123",
mode: "multi",
actor: {
userId: "user_123",
displayName: "Sam Steady",
email: "sam@example.com",
role: "owner",
},
},
},
]);
});

it("passes structured input parts with agent input over HTTP", async () => {
server?.stop();
const writes: Array<{
Expand Down
5 changes: 4 additions & 1 deletion src/metadata-server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { createHash, randomUUID } from "node:crypto";
import { mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs";
import { dirname, join } from "node:path";
import { getDashboardClientUiStatePath, getPlansDir, getProjectId, getProjectStateDir } from "./paths.js";
import { collaborationContextFromHeaders, type AgentCollaborationContext } from "./collaboration.js";
import {
type MetadataTone,
updateSessionMetadata,
Expand Down Expand Up @@ -282,6 +283,7 @@ interface MetadataServerOptions {
parts?: AgentInputPart[];
clientMessageId?: string;
submit?: boolean;
collaboration?: AgentCollaborationContext;
}) =>
| Promise<{
sessionId: string;
Expand Down Expand Up @@ -2484,7 +2486,8 @@ export class MetadataServer {
send(res, 501, { ok: false, error: "agent input not supported by this service" });
return;
}
const result = await this.options.lifecycle.writeAgentInput(body);
const collaboration = collaborationContextFromHeaders(req.headers);
const result = await this.options.lifecycle.writeAgentInput({ ...body, collaboration });
if (this.options.lifecycle.readAgentHistory) {
try {
const history = await this.options.lifecycle.readAgentHistory({ sessionId: body.sessionId, lastN: 20 });
Expand Down
4 changes: 3 additions & 1 deletion src/multiplexer/agent-io-methods.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { sendDirectMessage, sendThreadMessage } from "../orchestration.js";
import { sendHandoff } from "../orchestration-actions.js";
import { resolveOrchestrationRecipients } from "../orchestration-routing.js";
import type { DashboardSession } from "../dashboard/index.js";
import type { AgentCollaborationContext } from "../collaboration.js";
import { markMessageDelivered, type MessageKind } from "../threads.js";
import { stopProjectServices as stopProjectServicesImpl } from "./dashboard-model.js";
import {
Expand Down Expand Up @@ -257,8 +258,9 @@ export const agentIoMethods = {
parts?: any[],
clientMessageId?: string,
submit = false,
collaboration?: AgentCollaborationContext,
): Promise<{ sessionId: string }> {
return writeAgentInputImpl(this, sessionId, data, parts, clientMessageId, submit);
return writeAgentInputImpl(this, sessionId, data, parts, clientMessageId, submit, collaboration);
},

async readAgentHistory(this: any, sessionId: string, lastN?: number): Promise<any> {
Expand Down
9 changes: 8 additions & 1 deletion src/multiplexer/dashboard-model.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1188,7 +1188,14 @@ export async function startProjectServices(host: DashboardModelHost): Promise<vo
recordBackendSessionId: (input: any) =>
host.recordSessionBackendSessionId(input.sessionId, input.backendSessionId),
writeAgentInput: (input: any) =>
host.writeAgentInput(input.sessionId, input.data, input.parts, input.clientMessageId, input.submit),
host.writeAgentInput(
input.sessionId,
input.data,
input.parts,
input.clientMessageId,
input.submit,
input.collaboration,
),
readAgentOutput: (input: any) => host.readAgentOutput(input.sessionId, input.startLine),
readAgentHistory: (input: any) => host.readAgentHistory(input.sessionId, input.lastN),
},
Expand Down
Loading