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
35 changes: 35 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,41 @@ concurrency:
cancel-in-progress: ${{ github.event_name == 'pull_request' }}

jobs:
hermes_plugin:
name: Hermes Companion Plugin
runs-on: ubuntu-24.04
timeout-minutes: 5
permissions:
contents: read
steps:
# actions/checkout's persist-credentials:false cleanup currently fails on
# the read-only vendored gitlinks that intentionally have no root
# .gitmodules entry. Fetch with a process-local auth header instead: the
# token is available only to this step and never lands in Git config.
- name: Checkout without persisted credentials
env:
GH_TOKEN: ${{ github.token }}
run: |
set -euo pipefail
git init .
git remote add origin "${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}.git"
auth_header=$(printf 'x-access-token:%s' "${GH_TOKEN}" | base64 | tr -d '\n')
git -c "http.${GITHUB_SERVER_URL}/.extraheader=AUTHORIZATION: basic ${auth_header}" \
fetch --no-tags --depth=1 origin "${GITHUB_SHA}"
git checkout --detach --force FETCH_HEAD
git remote remove origin

- name: Setup Python
uses: actions/setup-python@v5
with:
python-version: "3.12"

- name: Lint
run: pipx run ruff==0.16.3 check integrations/hermes-t3-gateway

- name: Test
run: python -m unittest discover -s integrations/hermes-t3-gateway/tests -v

check:
name: Check
runs-on: ubuntu-24.04
Expand Down
61 changes: 59 additions & 2 deletions apps/mobile/src/features/threads/ThreadFeed.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -193,6 +193,47 @@ function MessageAttachmentImage(props: {
);
}

function MessageAttachmentFile(props: {
readonly environmentId: EnvironmentId;
readonly attachmentId: string;
readonly name: string;
readonly mimeType: string;
}) {
const uri = useAssetUrl(props.environmentId, {
_tag: "attachment",
attachmentId: props.attachmentId,
fileName: props.name,
mimeType: props.mimeType,
});
const iconColor = useThemeColor("--color-icon-subtle");

return (
<Pressable
accessibilityRole="link"
accessibilityLabel={`Open ${props.name}`}
disabled={uri === null}
onPress={() => {
if (uri !== null) void tryOpenExternalUrl(uri, "file-preview");
}}
className="mt-1.5 min-h-12 flex-row items-center gap-2 rounded-[14px] border border-neutral-200 bg-neutral-100 px-3 py-2 dark:border-white/[0.08] dark:bg-neutral-900"
>
{uri === null ? (
<ActivityIndicator size="small" />
) : (
<SymbolView name="doc.text" size={18} tintColor={iconColor} type="monochrome" />
)}
<View className="min-w-0 flex-1">
<Text className="font-t3-medium text-sm text-foreground" numberOfLines={1}>
{props.name}
</Text>
<Text className="text-xs text-foreground-muted" numberOfLines={1}>
{props.mimeType}
</Text>
</View>
</Pressable>
);
}

const MARKDOWN_MONO_FONT = Platform.select({
ios: "ui-monospace",
android: "monospace",
Expand Down Expand Up @@ -906,14 +947,22 @@ function renderFeedEntry(
/>
) : null}
{attachments.map((attachment) => {
return (
return attachment.type === "image" ? (
<MessageAttachmentImage
key={attachment.id}
environmentId={props.environmentId}
attachmentId={attachment.id}
className="aspect-[1.3] w-full rounded-[14px] bg-white/15"
onPressImage={props.onPressImage}
/>
) : (
<MessageAttachmentFile
key={attachment.id}
environmentId={props.environmentId}
attachmentId={attachment.id}
name={attachment.name}
mimeType={attachment.mimeType}
/>
);
})}
</View>
Expand Down Expand Up @@ -967,14 +1016,22 @@ function renderFeedEntry(
)
) : null}
{attachments.map((attachment) => {
return (
return attachment.type === "image" ? (
<MessageAttachmentImage
key={attachment.id}
environmentId={props.environmentId}
attachmentId={attachment.id}
className="mt-1.5 aspect-[1.3] w-full rounded-[18px] bg-neutral-200 dark:bg-neutral-800"
onPressImage={props.onPressImage}
/>
) : (
<MessageAttachmentFile
key={attachment.id}
environmentId={props.environmentId}
attachmentId={attachment.id}
name={attachment.name}
mimeType={attachment.mimeType}
/>
);
})}
{showAssistantMeta ? (
Expand Down
31 changes: 31 additions & 0 deletions apps/server/src/assets/AssetAccess.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -208,6 +208,37 @@ describe("AssetAccess", () => {
}).pipe(Effect.provide(testLayer)),
);

it.effect("signs MIME response metadata for opaque file attachments", () =>
Effect.gen(function* () {
const config = yield* ServerConfig.ServerConfig;
const fileSystem = yield* FileSystem.FileSystem;
const path = yield* Path.Path;
const attachmentId = "thread-1-00000000-0000-4000-8000-000000000002";
const attachmentPath = path.join(config.attachmentsDir, `${attachmentId}.bin`);
yield* fileSystem.makeDirectory(config.attachmentsDir, { recursive: true });
yield* fileSystem.writeFile(attachmentPath, new Uint8Array([37, 80, 68, 70]));

const result = yield* issueAssetUrl({
resource: {
_tag: "attachment",
attachmentId,
fileName: "release notes.pdf",
mimeType: "application/pdf",
},
});
expect(result.relativeUrl.endsWith("/release%20notes.pdf")).toBe(true);
const suffix = result.relativeUrl.slice(`${ASSET_ROUTE_PREFIX}/`.length);
const separatorIndex = suffix.indexOf("/");

expect(yield* resolveAsset(suffix.slice(0, separatorIndex), "release notes.pdf")).toEqual({
kind: "file",
path: attachmentPath,
contentType: "application/pdf",
downloadName: "release notes.pdf",
});
}).pipe(Effect.provide(testLayer)),
);

it.effect("issues project favicon capabilities with a signed fallback", () =>
Effect.gen(function* () {
const fileSystem = yield* FileSystem.FileSystem;
Expand Down
42 changes: 39 additions & 3 deletions apps/server/src/assets/AssetAccess.ts
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,8 @@ const AssetClaimsSchema = Schema.Union([
version: Schema.Literal(1),
kind: Schema.Literal("attachment"),
attachmentId: Schema.String,
contentType: Schema.optional(Schema.String),
downloadName: Schema.optional(Schema.String),
expiresAt: Schema.Number,
}),
Schema.Struct({
Expand All @@ -95,7 +97,22 @@ const AssetClaimsJson = Schema.fromJsonString(AssetClaimsSchema);
const decodeAssetClaims = Schema.decodeUnknownOption(AssetClaimsJson);
const encodeAssetClaims = Schema.encodeSync(AssetClaimsJson);

export type ResolvedAsset = { readonly kind: "file"; readonly path: string };
export type ResolvedAsset = {
readonly kind: "file";
readonly path: string;
readonly contentType?: string;
readonly downloadName?: string;
};

const SAFE_MEDIA_TYPE = /^[a-z0-9][a-z0-9!#$&^_.+-]*\/[a-z0-9][a-z0-9!#$&^_.+-]*$/i;

/** Normalize signed response metadata before it can reach an HTTP header. */
function normalizeAttachmentContentType(value: string | undefined): string {
const normalized = value?.trim().toLowerCase() ?? "";
return normalized.length <= 100 && SAFE_MEDIA_TYPE.test(normalized)
? normalized
: "application/octet-stream";
}

function decodeClaims(encodedPayload: string): AssetClaims | null {
try {
Expand Down Expand Up @@ -273,9 +290,23 @@ export const issueAssetUrl = Effect.fn("AssetAccess.issueAssetUrl")(function* (i
version: 1,
kind: "attachment",
attachmentId: input.resource.attachmentId,
// Generic files deliberately use an opaque `.bin` physical path so
// user-controlled names and extensions never influence storage. Carry
// their MIME/name as signed response metadata instead. The HTTP route
// adds Content-Disposition: attachment, preventing an HTML-like MIME
// from executing in T3's origin.
...(attachmentPath.endsWith(".bin")
? {
contentType: normalizeAttachmentContentType(input.resource.mimeType),
downloadName: input.resource.fileName?.trim() || "attachment",
}
: {}),
expiresAt,
};
fileName = path.basename(attachmentPath);
fileName =
attachmentPath.endsWith(".bin") && input.resource.fileName
? input.resource.fileName
: path.basename(attachmentPath);
break;
}
case "project-favicon": {
Expand Down Expand Up @@ -428,7 +459,12 @@ export const resolveAsset = Effect.fn("AssetAccess.resolveAsset")(function* (
Effect.orElseSucceed(() => Option.none()),
);
return Option.isSome(info) && info.value.type === "File"
? ({ kind: "file", path: attachmentPath } satisfies ResolvedAsset)
? ({
kind: "file",
path: attachmentPath,
...(claims.contentType !== undefined ? { contentType: claims.contentType } : {}),
...(claims.downloadName !== undefined ? { downloadName: claims.downloadName } : {}),
} satisfies ResolvedAsset)
: null;
}

Expand Down
11 changes: 9 additions & 2 deletions apps/server/src/attachmentStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,12 +34,15 @@ export function toSafeThreadAttachmentSegment(threadId: string): string | null {
return segment;
}

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

export function parseThreadSegmentFromAttachmentId(attachmentId: string): string | null {
Expand All @@ -63,6 +66,10 @@ export function attachmentRelativePath(attachment: ChatAttachment): string {
});
return `${attachment.id}${extension}`;
}
case "file":
// Keep user-controlled names and MIME-derived extensions out of paths.
// The asset service supplies safe download semantics for this opaque file.
return `${attachment.id}.bin`;
}
}

Expand Down
6 changes: 6 additions & 0 deletions apps/server/src/auth/RpcAuthorization.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,12 @@ export const RPC_REQUIRED_SCOPES = {
[WS_METHODS.serverRemoveKeybinding]: AuthOrchestrationOperateScope,
[WS_METHODS.serverGetSettings]: AuthOrchestrationReadScope,
[WS_METHODS.serverUpdateSettings]: AuthOrchestrationOperateScope,
[WS_METHODS.hermesGatewayCreateEnrollment]: AuthOrchestrationOperateScope,
[WS_METHODS.hermesGatewayGetInstanceStatus]: AuthOrchestrationReadScope,
[WS_METHODS.hermesGatewayListInstances]: AuthOrchestrationReadScope,
[WS_METHODS.hermesGatewayRenameInstance]: AuthOrchestrationOperateScope,
[WS_METHODS.hermesGatewayRevokeInstance]: AuthOrchestrationOperateScope,
[WS_METHODS.hermesGatewayRemoveInstance]: AuthOrchestrationOperateScope,
[WS_METHODS.serverDiscoverSourceControl]: AuthOrchestrationReadScope,
[WS_METHODS.serverGetTraceDiagnostics]: AuthOrchestrationReadScope,
[WS_METHODS.serverGetProcessDiagnostics]: AuthOrchestrationReadScope,
Expand Down
27 changes: 26 additions & 1 deletion apps/server/src/http.test.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,12 @@
import { expect, it } from "@effect/vitest";
import { describe } from "vite-plus/test";

import { assetResponseHeaders, isLoopbackHostname, resolveDevRedirectUrl } from "./http.ts";
import {
assetResponseHeaders,
assetResponseOptions,
isLoopbackHostname,
resolveDevRedirectUrl,
} from "./http.ts";

describe("http dev routing", () => {
it("treats localhost and loopback addresses as local", () => {
Expand Down Expand Up @@ -44,4 +49,24 @@ describe("assetResponseHeaders", () => {
"X-Content-Type-Options": "nosniff",
});
});

it("serves opaque attachments with their signed MIME and safe download disposition", () => {
expect(
assetResponseOptions({
kind: "file",
path: "/attachments/opaque.bin",
contentType: "application/pdf",
downloadName: 'release "notes".pdf',
}),
).toEqual({
status: 200,
contentType: "application/pdf",
headers: {
"Cache-Control": "private, max-age=3600",
"X-Content-Type-Options": "nosniff",
"Content-Disposition":
"attachment; filename=\"attachment\"; filename*=UTF-8''release%20%22notes%22.pdf",
},
});
});
});
32 changes: 26 additions & 6 deletions apps/server/src/http.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ import * as HttpApiBuilder from "effect/unstable/httpapi/HttpApiBuilder";
import { OtlpTracer } from "effect/unstable/observability";

import * as ServerConfig from "./config.ts";
import { ASSET_ROUTE_PREFIX, resolveAsset } from "./assets/AssetAccess.ts";
import { ASSET_ROUTE_PREFIX, resolveAsset, type ResolvedAsset } from "./assets/AssetAccess.ts";
import * as BrowserTraceCollector from "./observability/BrowserTraceCollector.ts";
import * as EnvironmentAuth from "./auth/EnvironmentAuth.ts";
import { traceRelayRequest } from "./cloud/traceRelayRequest.ts";
Expand All @@ -45,16 +45,39 @@ const LOOPBACK_HOSTNAMES = new Set(["127.0.0.1", "::1", "localhost"]);
const DESKTOP_RENDERER_ORIGINS = ["t3code://app", "t3code-dev://app"];
const SVG_CONTENT_SECURITY_POLICY = "default-src 'none'; style-src 'unsafe-inline'; sandbox";

export function assetResponseHeaders(filePath: string): Record<string, string> {
function encodeContentDispositionFileName(fileName: string): string {
return encodeURIComponent(fileName).replace(
/[!'()*]/g,
(character) => `%${character.charCodeAt(0).toString(16).toUpperCase()}`,
);
}

export function assetResponseHeaders(
filePath: string,
downloadName?: string,
): Record<string, string> {
return {
"Cache-Control": "private, max-age=3600",
"X-Content-Type-Options": "nosniff",
...(downloadName !== undefined
? {
"Content-Disposition": `attachment; filename="attachment"; filename*=UTF-8''${encodeContentDispositionFileName(downloadName)}`,
}
: {}),
...(filePath.toLowerCase().endsWith(".svg")
? { "Content-Security-Policy": SVG_CONTENT_SECURITY_POLICY }
: {}),
};
}

export function assetResponseOptions(asset: ResolvedAsset) {
return {
status: 200 as const,
...(asset.contentType !== undefined ? { contentType: asset.contentType } : {}),
headers: assetResponseHeaders(asset.path, asset.downloadName),
};
}

export const httpCompressionLayer = HttpRouter.middleware(HttpMiddleware.compression(), {
global: true,
});
Expand Down Expand Up @@ -217,10 +240,7 @@ export const assetRouteLayer = HttpRouter.add(
if (!asset) {
return HttpServerResponse.text("Not Found", { status: 404 });
}
return yield* HttpServerResponse.file(asset.path, {
status: 200,
headers: assetResponseHeaders(asset.path),
}).pipe(
return yield* HttpServerResponse.file(asset.path, assetResponseOptions(asset)).pipe(
Effect.orElseSucceed(() => HttpServerResponse.text("Internal Server Error", { status: 500 })),
);
}),
Expand Down
Loading
Loading