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
1 change: 1 addition & 0 deletions apps/server/src/environment/ServerEnvironment.ts
Original file line number Diff line number Diff line change
Expand Up @@ -241,6 +241,7 @@ export const make = Effect.gen(function* () {
pullRequestStackActions: true,
threadPullRequestLinking: true,
environmentIcon: true,
environmentIconOverride: true,
projectCloneTracking: true,
...(serverSelfUpdate === null ? {} : { serverSelfUpdate }),
...(serverSelfUpdate === "boot-service" || desktopAppUpdate
Expand Down
39 changes: 39 additions & 0 deletions apps/server/src/serverSettings.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,10 @@ import { resolveProviderInstanceTerminalEnvironment } from "./terminal/Manager.t

const decodeSettingsPatch = Schema.decodeUnknownEffect(ServerSettingsPatch);
const decodeServerSettings = Schema.decodeUnknownEffect(ServerSettings);
// The settings file as written, before any defaults or lifting apply.
const decodeRawSettingsJson = Schema.decodeUnknownEffect(
Schema.fromJsonString(Schema.Record(Schema.String, Schema.Unknown)),
);

const makeServerSettingsLayer = () =>
ServerSettingsModule.layer.pipe(
Expand Down Expand Up @@ -303,6 +307,41 @@ it.layer(NodeServices.layer)("server settings", (it) => {
).pipe(Effect.provide(makeServerSettingsLayer())),
);

it.effect("persists an environment icon whole and swaps variants without leftovers", () =>
Effect.scoped(
Effect.gen(function* () {
const serverConfig = yield* ServerConfig.ServerConfig;
const fileSystem = yield* FileSystem.FileSystem;
const serverSettings = yield* ServerSettingsModule.ServerSettingsService;
// Inspect the raw file. A partial object or a stale key from the
// previous variant would only be visible before schema decoding.
const readPersistedIcon = fileSystem.readFileString(serverConfig.settingsPath).pipe(
Effect.flatMap(decodeRawSettingsJson),
Effect.map((raw) => raw.environmentIcon),
);

yield* serverSettings.updateSettings({
environmentIcon: { kind: "icon", name: "laptop", color: "red" },
});
assert.deepEqual(yield* readPersistedIcon, { kind: "icon", name: "laptop", color: "red" });

yield* serverSettings.updateSettings({ environmentIcon: { kind: "emoji", emoji: "🚀" } });
assert.deepEqual(yield* readPersistedIcon, { kind: "emoji", emoji: "🚀" });
assert.deepEqual((yield* serverSettings.getSettings).environmentIcon, {
kind: "emoji",
emoji: "🚀",
});

// A plain legacy kind lands on disk as the string an older server reads.
yield* serverSettings.updateSettings({ environmentIcon: { kind: "icon", name: "laptop" } });
assert.strictEqual(yield* readPersistedIcon, "laptop");

yield* serverSettings.updateSettings({ environmentIcon: null });
assert.isUndefined(yield* readPersistedIcon);
}),
).pipe(Effect.provide(makeServerSettingsLayer())),
);

it.effect("persists and broadcasts thread settlement settings", () =>
Effect.scoped(
Effect.gen(function* () {
Expand Down
1 change: 1 addition & 0 deletions apps/server/src/serverSettings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -358,6 +358,7 @@ const ATOMIC_SETTINGS_KEYS: ReadonlySet<string> = new Set([
"sourceControlWriterModelSelection",
"textGenerationModelSelection",
"pullRequestMergeMethod",
"environmentIcon",
]);

// Preserve both enabled states because provider history cannot recover a new opt-in.
Expand Down
6 changes: 5 additions & 1 deletion apps/web/src/components/settings/EnvironmentIconPicker.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -112,7 +112,11 @@ export function EnvironmentIconMenu({
value={resolved}
onValueChange={(next) => {
if (lock !== null || !isEnvironmentMachineKind(next)) return;
updateSettings({ environmentIcon: next === detected ? null : next });
// A plain machine kind encodes to the bare string on the wire, so a
// server that predates the object form accepts this unchanged.
updateSettings({
environmentIcon: next === detected ? null : { kind: "icon", name: next },
Comment thread
amanthanvi marked this conversation as resolved.
Outdated
});
}}
>
{ENVIRONMENT_MACHINE_KINDS.map((kind) => (
Expand Down
129 changes: 125 additions & 4 deletions packages/contracts/src/environment.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import * as Effect from "effect/Effect";
import * as Schema from "effect/Schema";
import * as SchemaTransformation from "effect/SchemaTransformation";

import {
EnvironmentId,
Expand All @@ -8,6 +9,14 @@ import {
ThreadId,
TrimmedNonEmptyString,
} from "./baseSchemas.ts";
import {
IconColor,
IconEmoji,
IconImageDataUrl,
isMonogramLength,
LucideIconName,
MonogramText,
} from "./icon.ts";

/** Wire version for orchestration snapshots, streams, commands, and RPC payloads. */
export const ORCHESTRATION_PROTOCOL_VERSION = 1;
Expand All @@ -25,11 +34,20 @@ export const ExecutionEnvironmentPlatformArch = Schema.Literals(["arm64", "x64",
export type ExecutionEnvironmentPlatformArch = typeof ExecutionEnvironmentPlatformArch.Type;

/**
* The curated set of machine shapes and OS identities an environment can wear as its icon.
* Servers detect one from the hardware they run on (`platform.machine`), and
* the `environmentIcon` server setting lets a user pick one instead.
* The kinds a released server accepts as a bare string. Only these have that
* wire form. A server without `environmentIconOverride` takes them and
* nothing else, and an older client decodes them from a snapshot. A kind
* added later travels as the object, because an older peer decodes a string
* it does not know as null and loses the icon. The list is frozen.
*
* `linux` has one gap. The `environmentIcon` capability shipped on
* 2026-09-02 and `linux` joined the set on 2026-09-06, so 25 nightly builds
* in between advertise the capability and reject the string, and picking the
* Linux glyph against one of those fails the whole settings patch. No stable
* release sits in that window. Dropping `linux` here would instead lock the
* glyph on every stable server shipping today, which is the larger loss.
*/
export const ENVIRONMENT_MACHINE_KINDS = [
export const LEGACY_ENVIRONMENT_MACHINE_KINDS = [
"server",
"cloud",
"linux",
Expand All @@ -38,10 +56,107 @@ export const ENVIRONMENT_MACHINE_KINDS = [
"mac-mini",
"mac-studio",
] as const;
export const isLegacyEnvironmentMachineKind = Schema.is(
Schema.Literals(LEGACY_ENVIRONMENT_MACHINE_KINDS),
);

/**
* The curated set of machine shapes and OS identities an environment can wear as its icon.
* Servers detect one from the hardware they run on (`platform.machine`), and
* the `environmentIcon` server setting lets a user pick one instead. This list
* grows as detection improves, which is why the wire form above does not.
*/
export const ENVIRONMENT_MACHINE_KINDS = [...LEGACY_ENVIRONMENT_MACHINE_KINDS] as const;
export const EnvironmentMachineKind = Schema.Literals(ENVIRONMENT_MACHINE_KINDS);
export type EnvironmentMachineKind = typeof EnvironmentMachineKind.Type;
export const isEnvironmentMachineKind = Schema.is(EnvironmentMachineKind);

/**
* A named glyph: one of the curated ids above, an id the clients add on top
* of them, or a Lucide id. One field rather than one variant per source,
* because renderers resolve the curated map first and fall through, so the
* name alone says which map answers.
*/
export const EnvironmentIconName = LucideIconName;
export type EnvironmentIconName = typeof EnvironmentIconName.Type;

const EnvironmentNamedIcon = Schema.Struct({
kind: Schema.Literal("icon"),
name: EnvironmentIconName,
color: Schema.optionalKey(IconColor),
});
const EnvironmentEmojiIcon = Schema.Struct({
kind: Schema.Literal("emoji"),
emoji: IconEmoji,
});
const EnvironmentMonogramIcon = Schema.Struct({
kind: Schema.Literal("monogram"),
text: MonogramText,
color: Schema.optionalKey(IconColor),
});
/**
* The only setting that holds bytes. It lives in one environment's own
* `settings.json`, never a map across environments, and the settings stream
* sends the whole object to every client on change, so one icon per payload
* is the entire exposure. `IconImageDataUrl` caps the size.
*/
const EnvironmentImageIcon = Schema.Struct({
kind: Schema.Literal("image"),
dataUrl: IconImageDataUrl,
});

const EnvironmentIcon = Schema.Union([
EnvironmentNamedIcon,
EnvironmentEmojiIcon,
EnvironmentMonogramIcon,
EnvironmentImageIcon,
]);
export type EnvironmentIcon = typeof EnvironmentIcon.Type;

/**
* What a user picked for an environment's icon. Servers that predate the
* override stored a bare machine kind, and that string form stays on the
* wire and on disk for a plain pick of one of the seven kinds. That string is
* what an older server accepts in a patch and what an older client can decode
* from a snapshot, so the picks that always existed keep working across
* versions.
* Anything richer (a color, a name outside the seven, another variant)
* encodes as the object, which older peers drop to null through
* `ForwardCompatibleNullable`; the `environmentIconOverride` capability keeps
* clients from sending an object to a server that would reject it.
*/
export const EnvironmentIconOverride = Schema.Union([EnvironmentMachineKind, EnvironmentIcon]).pipe(
Schema.decodeTo(
EnvironmentIcon,
SchemaTransformation.transform({
decode: (icon): EnvironmentIcon =>
typeof icon === "string" ? { kind: "icon", name: icon } : icon,
encode: (icon) =>
icon.kind === "icon" &&
icon.color === undefined &&
isLegacyEnvironmentMachineKind(icon.name)
? icon.name
: icon,
}),
),
);
export type EnvironmentIconOverride = typeof EnvironmentIconOverride.Type;

/**
* What a client may write. Projects check the two-character monogram bound in
* their decider; a settings patch has no such boundary, so it lives here.
*
* It stays off `EnvironmentIconOverride` because that schema also decodes
* snapshots. A peer writing outside the picker, or a later build that widens
* the bound, can store a longer monogram, and checking it during decode would
* send that icon through `ForwardCompatibleNullable` to null. The user would
* get the detected glyph with nothing saying why. Only the write boundary
* counts, so a snapshot draws what is stored.
*/
export const EnvironmentIconOverrideWrite = EnvironmentIconOverride.check(
Schema.makeFilter((icon) => icon.kind !== "monogram" || isMonogramLength(icon.text)),
);

export const ExecutionEnvironmentPlatform = Schema.Struct({
os: ExecutionEnvironmentPlatformOs,
arch: ExecutionEnvironmentPlatformArch,
Expand Down Expand Up @@ -180,6 +295,12 @@ export const ExecutionEnvironmentCapabilities = Schema.Struct({
setting. Older servers drop the key on write, so clients show the
picker inert rather than offering a choice that would never stick. */
environmentIcon: Schema.optionalKey(Schema.Boolean),
/** Server stores `environmentIcon` as an `EnvironmentIconOverride` object.
`environmentIcon` alone means the server only knows the bare machine
kind: it would reject an object patch, so clients that see only the
older flag keep writing the string form and offer only the seven
legacy kinds. */
environmentIconOverride: Schema.optionalKey(Schema.Boolean),
/** The desktop app supervising this server can be driven over RPC:
server.updateServer runs its check -> download -> relaunch. Absent on
desktop servers whose app predates the remote trigger, where clients
Expand Down
101 changes: 101 additions & 0 deletions packages/contracts/src/icon.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
import * as Schema from "effect/Schema";

import { TrimmedNonEmptyString } from "./baseSchemas.ts";

/**
* Leaf schemas shared by every user-chosen icon (project overrides and
* environment overrides). The composite override shapes differ per owner and
* live beside the owner; only the pieces that must agree across them live here.
*/

export const IconColor = Schema.Literals([
"gray",
"red",
"orange",
"amber",
"yellow",
"lime",
"green",
"emerald",
"teal",
"cyan",
"sky",
"blue",
"indigo",
"violet",
"purple",
"fuchsia",
"pink",
"rose",
]);
export type IconColor = typeof IconColor.Type;

/** A Lucide icon id, or any curated id that follows the same kebab-case grammar. */
export const LucideIconName = TrimmedNonEmptyString.check(
Schema.isMaxLength(64),
Schema.isPattern(/^[a-z0-9]+(?:-[a-z0-9]+)*$/),
);
export type LucideIconName = typeof LucideIconName.Type;

export const IconEmoji = TrimmedNonEmptyString.check(Schema.isMaxLength(32));
export type IconEmoji = typeof IconEmoji.Type;

// Grapheme-count validation belongs to the write boundary, not snapshot decoding.
export const MonogramText = TrimmedNonEmptyString.check(
Schema.isMaxLength(32),
Schema.isPattern(/^[\p{L}\p{N}][\p{L}\p{N}\p{M}\u200c\u200d]*$/u),
);
export type MonogramText = typeof MonogramText.Type;

/**
* Whether `text` reads as at most two characters, the bound a monogram tile
* can hold. The count is code points after stripping the combining marks and
* joiners `MonogramText` admits, which matches grapheme counting for Latin,
* digits, and accents either precomposed or decomposed. A Devanagari conjunct
* or a decomposed Hangul syllable counts high, so those scripts get one
* cluster rather than two.
*
* `Intl.Segmenter` would be exact, and Hermes does not ship it. Reaching for
* it where it exists would accept on the server and on web what mobile
* refuses, and one bound every client computes the same way is worth more
* than the extra scripts.
*/
export function isMonogramLength(text: string): boolean {
return Array.from(text.replace(/[\p{M}\u200c\u200d]/gu, "")).length <= 2;
}

/**
* Encoded length budget for an inline raster icon. A 64 by 64 PNG with alpha
* is at most 16 KiB of pixels before compression, which base64 grows by a
* third; the cap leaves room for the container without admitting a photo.
*/
export const ICON_IMAGE_DATA_URL_MAX_LENGTH = 32_768;

/**
* A small raster icon carried inline. The prefix is pinned to PNG rather than
* any `data:image/`. On web that is what keeps an SVG, which can script, out
* of the `<img>`, because Blink picks the decoder from the declared type. Mobile's
* image library sniffs content instead, so there the guarantee is that neither
* renderer in use has a script engine, not the prefix.
*
* Nothing past the signature is read, so this says nothing about frame count.
* APNG carries the same signature and animates.
*
* The first pattern spells out whole base64 quartets rather than a run of
* characters and loose padding. That refuses the three in four truncations
* that stop mid-quartet. One that stops on a quartet boundary still decodes,
* to a PNG carrying a signature and no pixels, and reaches the renderer.
*
* The second is the PNG signature, which base64 fixes to `iVBORw0KGg` for any
* PNG whatever its ninth byte. Checking the encoded prefix costs nothing on a
* path that decodes settings for every connected client on every change, and it
* means the declared type is the writer's claim while this is the evidence.
*/
export const IconImageDataUrl = Schema.String.check(
Schema.isMaxLength(ICON_IMAGE_DATA_URL_MAX_LENGTH),
Schema.isPattern(
/^data:image\/png;base64,(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{4}|[A-Za-z0-9+/]{3}=|[A-Za-z0-9+/]{2}==)$/,
),
Schema.isPattern(/^data:image\/png;base64,iVBORw0KGg/),

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/icon.ts:99

IconImageDataUrl accepts data:image/png;base64,iVBORw0KGg==, which decodes to only seven bytes and is not a valid PNG signature; that value can be persisted and cannot be displayed by a PNG decoder. The signature regex checks only the prefix, so require the complete encoded signature (iVBORw0KGgo=) or validate all eight decoded bytes.

🤖 Copy this AI Prompt to have your agent fix this:
In file @packages/contracts/src/icon.ts around line 99:

`IconImageDataUrl` accepts `data:image/png;base64,iVBORw0KGg==`, which decodes to only seven bytes and is not a valid PNG signature; that value can be persisted and cannot be displayed by a PNG decoder. The signature regex checks only the prefix, so require the complete encoded signature (`iVBORw0KGgo=`) or validate all eight decoded bytes.

@amanthanvi amanthanvi Oct 4, 2026 •

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

This is a real gap, fixed in bb5200e on the replacement PR, #15511. The check now requires iVBORw0KGgo, the full eight-byte signature, and settings.test.ts rejects iVBORw0KGg==.

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.

Sorry, I'm unable to act on this request because you do not have permissions within this repository.

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.

Sorry, I'm unable to act on this request because you do not have permissions within this repository.

);
export type IconImageDataUrl = typeof IconImageDataUrl.Type;
1 change: 1 addition & 0 deletions packages/contracts/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
export * from "./baseSchemas.ts";
export * from "./icon.ts";
export * from "./assistantCitations.ts";
export * from "./composerContext.ts";
export * from "./composerContextClipboard.ts";
Expand Down
Loading
Loading