Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
8416ecd
Add AI-generated worktree branch naming and safe branch rename
juliusmarminge Mar 1, 2026
06912c3
Move worktree branch auto-rename into provider turn reactor
juliusmarminge Mar 1, 2026
61e1bab
Scope upstream refresh in checkout effect lifecycle
juliusmarminge Mar 1, 2026
0df68cc
Remove git.renameBranch RPC and harden branch rename args
juliusmarminge Mar 1, 2026
9b0fe5e
Generate temporary worktree branch names from UUID tokens
juliusmarminge Mar 1, 2026
d3d0a08
Resolve persisted attachment paths across server pipelines
juliusmarminge Mar 1, 2026
44e409f
nit
juliusmarminge Mar 1, 2026
4bc43a0
data uri for app server
juliusmarminge Mar 1, 2026
ad79b5a
nit
juliusmarminge Mar 1, 2026
700579b
fine there
juliusmarminge Mar 1, 2026
0e146e2
Handle branch-name generation failures as typed errors
juliusmarminge Mar 1, 2026
e64ad4b
Use Effect Schema to decode Codex structured outputs
juliusmarminge Mar 1, 2026
e1a8ee1
Handle parameterized base64 image data URLs
juliusmarminge Mar 1, 2026
875e6ed
Inject test ServerConfig across orchestration and git layers
juliusmarminge Mar 1, 2026
cd732a2
fix: address PR review feedback
juliusmarminge Mar 1, 2026
5850315
Switch image attachments to persisted ID-based paths
juliusmarminge Mar 1, 2026
88dc6c9
Harden attachment ID resolution and missing-file handling
juliusmarminge Mar 1, 2026
fb92741
Read stdin before validating required image flag in codex test stub
juliusmarminge Mar 2, 2026
dadda0d
Reuse attachment thread segment sanitizer in projection pipeline
juliusmarminge Mar 2, 2026
afc0c78
Fix attachment cleanup to match exact thread segments
juliusmarminge Mar 2, 2026
39f0786
Normalize attachment thread segments to lowercase
juliusmarminge Mar 2, 2026
150e171
Remove unused attachment route path helpers
juliusmarminge Mar 2, 2026
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
13 changes: 13 additions & 0 deletions apps/server/integration/OrchestrationEngineHarness.integration.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,8 @@ import {

import { CheckpointStoreLive } from "../src/checkpointing/Layers/CheckpointStore.ts";
import { CheckpointStore } from "../src/checkpointing/Services/CheckpointStore.ts";
import { GitCore, type GitCoreShape } from "../src/git/Services/GitCore.ts";
import { TextGeneration, type TextGenerationShape } from "../src/git/Services/TextGeneration.ts";
import { OrchestrationCommandReceiptRepositoryLive } from "../src/persistence/Layers/OrchestrationCommandReceipts.ts";
import { OrchestrationEventStoreLive } from "../src/persistence/Layers/OrchestrationEventStore.ts";
import { ProjectionCheckpointRepositoryLive } from "../src/persistence/Layers/ProjectionCheckpoints.ts";
Expand Down Expand Up @@ -54,6 +56,7 @@ import {
makeTestProviderAdapterHarness,
type TestProviderAdapterHarness,
} from "./TestProviderAdapter.integration.ts";
import { ServerConfig } from "../src/config.ts";

function runGit(cwd: string, args: ReadonlyArray<string>) {
return execFileSync("git", args, {
Expand Down Expand Up @@ -227,8 +230,17 @@ export const makeOrchestrationIntegrationHarness = Effect.gen(function* () {
const runtimeIngestionLayer = ProviderRuntimeIngestionLive.pipe(
Layer.provideMerge(runtimeServicesLayer),
);
const gitCoreLayer = Layer.succeed(GitCore, {
renameBranch: (input: Parameters<GitCoreShape["renameBranch"]>[0]) =>
Effect.succeed({ branch: input.newBranch }),
} as unknown as GitCoreShape);
const textGenerationLayer = Layer.succeed(TextGeneration, {
generateBranchName: () => Effect.succeed({ branch: null }),
} as unknown as TextGenerationShape);
const providerCommandReactorLayer = ProviderCommandReactorLive.pipe(
Layer.provideMerge(runtimeServicesLayer),
Layer.provideMerge(gitCoreLayer),
Layer.provideMerge(textGenerationLayer),
);
const checkpointReactorLayer = CheckpointReactorLive.pipe(
Layer.provideMerge(runtimeServicesLayer),
Expand All @@ -240,6 +252,7 @@ export const makeOrchestrationIntegrationHarness = Effect.gen(function* () {
);
const layer = orchestrationReactorLayer.pipe(
Layer.provide(persistenceLayer),
Layer.provideMerge(ServerConfig.layerTest(workspaceDir, stateDir)),
Layer.provideMerge(NodeServices.layer),
);

Expand Down
28 changes: 28 additions & 0 deletions apps/server/src/attachmentPaths.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import path from "node:path";

export const ATTACHMENTS_ROUTE_PREFIX = "/attachments";

export function normalizeAttachmentRelativePath(rawRelativePath: string): string | null {
const normalized = path.normalize(rawRelativePath).replace(/^[/\\]+/, "");
if (normalized.length === 0 || normalized.startsWith("..") || normalized.includes("\0")) {
return null;
}
return normalized.replace(/\\/g, "/");
}

export function resolveAttachmentRelativePath(input: {
readonly stateDir: string;
readonly relativePath: string;
}): string | null {
const normalizedRelativePath = normalizeAttachmentRelativePath(input.relativePath);
if (!normalizedRelativePath) {
return null;
}

const attachmentsRoot = path.resolve(path.join(input.stateDir, "attachments"));
const filePath = path.resolve(path.join(attachmentsRoot, normalizedRelativePath));
if (!filePath.startsWith(`${attachmentsRoot}${path.sep}`)) {
return null;
}
return filePath;
}
77 changes: 77 additions & 0 deletions apps/server/src/attachmentStore.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
import fs from "node:fs";
import os from "node:os";
import path from "node:path";

import { describe, expect, it } from "vitest";

import {
createAttachmentId,
parseThreadSegmentFromAttachmentId,
resolveAttachmentPathById,
} from "./attachmentStore.ts";

describe("attachmentStore", () => {
it("sanitizes thread ids when creating attachment ids", () => {
const attachmentId = createAttachmentId("thread.folder/unsafe space");
expect(attachmentId).toBeTruthy();
if (!attachmentId) {
return;
}

const threadSegment = parseThreadSegmentFromAttachmentId(attachmentId);
expect(threadSegment).toBeTruthy();
expect(threadSegment).toMatch(/^[a-z0-9_-]+$/i);
expect(threadSegment).not.toContain(".");
expect(threadSegment).not.toContain("%");
expect(threadSegment).not.toContain("/");
});

it("parses exact thread segments from attachment ids without prefix collisions", () => {
const fooId = "foo-00000000-0000-4000-8000-000000000001";
const fooBarId = "foo-bar-00000000-0000-4000-8000-000000000002";

expect(parseThreadSegmentFromAttachmentId(fooId)).toBe("foo");
expect(parseThreadSegmentFromAttachmentId(fooBarId)).toBe("foo-bar");
});

it("normalizes created thread segments to lowercase", () => {
const attachmentId = createAttachmentId("Thread.Foo");
expect(attachmentId).toBeTruthy();
if (!attachmentId) {
return;
}
expect(parseThreadSegmentFromAttachmentId(attachmentId)).toBe("thread-foo");
});

it("resolves attachment path by id using the extension that exists on disk", () => {
const stateDir = fs.mkdtempSync(path.join(os.tmpdir(), "t3code-attachment-store-"));
try {
const attachmentId = "thread-1-attachment";
const attachmentsDir = path.join(stateDir, "attachments");
fs.mkdirSync(attachmentsDir, { recursive: true });
const pngPath = path.join(attachmentsDir, `${attachmentId}.png`);
fs.writeFileSync(pngPath, Buffer.from("hello"));

const resolved = resolveAttachmentPathById({
stateDir,
attachmentId,
});
expect(resolved).toBe(pngPath);
} finally {
fs.rmSync(stateDir, { recursive: true, force: true });
}
});

it("returns null when no attachment file exists for the id", () => {
const stateDir = fs.mkdtempSync(path.join(os.tmpdir(), "t3code-attachment-store-"));
try {
const resolved = resolveAttachmentPathById({
stateDir,
attachmentId: "thread-1-missing",
});
expect(resolved).toBeNull();
} finally {
fs.rmSync(stateDir, { recursive: true, force: true });
}
});
});
110 changes: 110 additions & 0 deletions apps/server/src/attachmentStore.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
import { randomUUID } from "node:crypto";
import { existsSync } from "node:fs";

import type { ChatAttachment } from "@t3tools/contracts";

import {
normalizeAttachmentRelativePath,
resolveAttachmentRelativePath,
} from "./attachmentPaths.ts";
import { inferImageExtension, SAFE_IMAGE_FILE_EXTENSIONS } from "./imageMime.ts";

const ATTACHMENT_FILENAME_EXTENSIONS = [...SAFE_IMAGE_FILE_EXTENSIONS, ".bin"];
const ATTACHMENT_ID_THREAD_SEGMENT_MAX_CHARS = 80;
const ATTACHMENT_ID_THREAD_SEGMENT_PATTERN = "[a-z0-9_]+(?:-[a-z0-9_]+)*";
const ATTACHMENT_ID_UUID_PATTERN =
"[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}";
const ATTACHMENT_ID_PATTERN = new RegExp(
`^(${ATTACHMENT_ID_THREAD_SEGMENT_PATTERN})-(${ATTACHMENT_ID_UUID_PATTERN})$`,
"i",
);

export function toSafeThreadAttachmentSegment(threadId: string): string | null {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

馃煛 Medium src/attachmentStore.ts:16

Thread segment sanitization allows hyphens, causing prefix collisions during cleanup. Thread foo produces segment foo, so .startsWith('foo-') incorrectly matches files from thread foo-bar (segment foo-bar-uuid). Consider replacing hyphens with a different character or using a hash-based approach to ensure unique, non-colliding prefixes.

馃殌 Reply "fix it for me" or copy this AI Prompt for your agent:
In file apps/server/src/attachmentStore.ts around line 16:

Thread segment sanitization allows hyphens, causing prefix collisions during cleanup. Thread `foo` produces segment `foo`, so `.startsWith('foo-')` incorrectly matches files from thread `foo-bar` (segment `foo-bar-uuid`). Consider replacing hyphens with a different character or using a hash-based approach to ensure unique, non-colliding prefixes.

Evidence trail:
- apps/server/src/attachmentStore.ts lines 16-26: `toSafeThreadAttachmentSegment` regex `/[^a-z0-9_-]+/gi` preserves hyphens
- apps/server/src/attachmentStore.ts lines 29-34: `createAttachmentId` creates IDs as `${threadSegment}-${randomUUID()}`
- apps/server/src/orchestration/Layers/ProjectionPipeline.ts lines 201, 239, 275: cleanup uses `.startsWith(`${threadSegment}-`)` for prefix matching

const segment = threadId
.trim()
.toLowerCase()
.replace(/[^a-z0-9_-]+/gi, "-")
.replace(/-+/g, "-")
.replace(/^[-_]+|[-_]+$/g, "")
.slice(0, ATTACHMENT_ID_THREAD_SEGMENT_MAX_CHARS)
.replace(/[-_]+$/g, "");
if (segment.length === 0) {
return null;
}
return segment;
}
Comment thread
cursor[bot] marked this conversation as resolved.

export function createAttachmentId(threadId: string): string | null {
const threadSegment = toSafeThreadAttachmentSegment(threadId);
if (!threadSegment) {
return null;
}
return `${threadSegment}-${randomUUID()}`;
}

export function parseThreadSegmentFromAttachmentId(attachmentId: string): string | null {
const normalizedId = normalizeAttachmentRelativePath(attachmentId);
if (!normalizedId || normalizedId.includes("/") || normalizedId.includes(".")) {
return null;
}
const match = normalizedId.match(ATTACHMENT_ID_PATTERN);
if (!match) {
return null;
}
return match[1]?.toLowerCase() ?? null;
}

export function attachmentRelativePath(attachment: ChatAttachment): string {
switch (attachment.type) {
case "image": {
const extension = inferImageExtension({
mimeType: attachment.mimeType,
fileName: attachment.name,
});
return `${attachment.id}${extension}`;
}
}
}

export function resolveAttachmentPath(input: {
readonly stateDir: string;
readonly attachment: ChatAttachment;
}): string | null {
return resolveAttachmentRelativePath({
stateDir: input.stateDir,
relativePath: attachmentRelativePath(input.attachment),
});
}

export function resolveAttachmentPathById(input: {
readonly stateDir: string;
readonly attachmentId: string;
}): string | null {
const normalizedId = normalizeAttachmentRelativePath(input.attachmentId);
if (!normalizedId || normalizedId.includes("/") || normalizedId.includes(".")) {
Comment thread
macroscopeapp[bot] marked this conversation as resolved.
return null;
}
for (const extension of ATTACHMENT_FILENAME_EXTENSIONS) {
const maybePath = resolveAttachmentRelativePath({
stateDir: input.stateDir,
relativePath: `${normalizedId}${extension}`,
});
if (maybePath && existsSync(maybePath)) {
return maybePath;
}
}
return null;
}
Comment thread
juliusmarminge marked this conversation as resolved.

export function parseAttachmentIdFromRelativePath(relativePath: string): string | null {
const normalized = normalizeAttachmentRelativePath(relativePath);
if (!normalized || normalized.includes("/")) {
return null;
}
const extensionIndex = normalized.lastIndexOf(".");
if (extensionIndex <= 0) {
return null;
}
const id = normalized.slice(0, extensionIndex);
return id.length > 0 && !id.includes(".") ? id : null;
}
10 changes: 2 additions & 8 deletions apps/server/src/codexAppServerManager.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -191,10 +191,7 @@ describe("sendTurn", () => {
attachments: [
{
type: "image",
name: "error.png",
mimeType: "image/png",
sizeBytes: 1_024,
dataUrl: "data:image/png;base64,AAAA",
url: "data:image/png;base64,AAAA",
},
],
model: "gpt-5.3",
Expand Down Expand Up @@ -242,10 +239,7 @@ describe("sendTurn", () => {
attachments: [
{
type: "image",
name: "diagram.png",
mimeType: "image/png",
sizeBytes: 256,
dataUrl: "data:image/png;base64,BBBB",
url: "data:image/png;base64,BBBB",
},
],
});
Expand Down
13 changes: 10 additions & 3 deletions apps/server/src/codexAppServerManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,6 @@ import {
normalizeModelSlug,
type ProviderApprovalDecision,
type ProviderEvent,
type ProviderSendTurnInput,
type ProviderSession,
type ProviderSessionStartInput,
type ProviderTurnStartResult,
Expand Down Expand Up @@ -71,6 +70,14 @@ interface JsonRpcNotification {
params?: unknown;
}

export interface CodexAppServerSendTurnInput {
readonly sessionId: ProviderSessionId;
readonly input?: string;
readonly attachments?: ReadonlyArray<{ type: "image"; url: string }>;
readonly model?: string;
readonly effort?: string;
}

export interface CodexThreadTurnSnapshot {
id: ProviderTurnId;
items: unknown[];
Expand Down Expand Up @@ -290,7 +297,7 @@ export class CodexAppServerManager extends EventEmitter<CodexAppServerManagerEve
}
}

async sendTurn(input: ProviderSendTurnInput): Promise<ProviderTurnStartResult> {
async sendTurn(input: CodexAppServerSendTurnInput): Promise<ProviderTurnStartResult> {
const context = this.requireSession(input.sessionId);
if (!context.session.threadId) {
throw new Error("Session is missing a thread id.");
Expand All @@ -310,7 +317,7 @@ export class CodexAppServerManager extends EventEmitter<CodexAppServerManagerEve
if (attachment.type === "image") {
turnInput.push({
type: "image",
url: attachment.dataUrl,
url: attachment.url,
});
}
}
Expand Down
24 changes: 23 additions & 1 deletion apps/server/src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,29 @@ export interface ServerConfigShape {
*/
export class ServerConfig extends ServiceMap.Service<ServerConfig, ServerConfigShape>()(
"t3/config/ServerConfig",
) {}
) {
static readonly layerTest = (cwd: string, statedir: string) =>
Layer.effect(
ServerConfig,
Effect.gen(function* () {
const path = yield* Path.Path;
return {
cwd,
stateDir: statedir,
mode: "web",
autoBootstrapProjectFromCwd: false,
logWebSocketEvents: false,
port: 0,
host: undefined,
authToken: undefined,
keybindingsConfigPath: path.join(statedir, "keybindings.json"),
staticDir: undefined,
devUrl: undefined,
noBrowser: false,
};
}),
);
}

// Helpers

Expand Down
Loading