| author | |
| committer | |
| log | 2d162d157dfb0eea7e205c508f47d663ab4e8bf9 |
| tree | 3705eab234773877c8064eb410f300e9260136fb |
| parent | efeeb7737bed89bbfc6dc81662058f8ce7fba2ea |
| signature | Signed by PGP key B5690EEEBB952194 |
Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>8 files changed, 193 insertions(+), 26 deletions(-)
apps/mobile/src/features/devices/DevicePreviewRouteScreen.tsx+9-2| ... | @@ -54,10 +54,17 @@ type DevicePreviewRouteScreenProps = StaticScreenProps<{ | ... | @@ -54,10 +54,17 @@ type DevicePreviewRouteScreenProps = StaticScreenProps<{ |
| 54 | export function DevicePreviewRouteScreen({ route }: DevicePreviewRouteScreenProps) { | 54 | export function DevicePreviewRouteScreen({ route }: DevicePreviewRouteScreenProps) { |
| 55 | const navigation = useNavigation(); | 55 | const navigation = useNavigation(); |
| 56 | const onClose = useCallback(() => navigation.goBack(), [navigation]); | 56 | const onClose = useCallback(() => navigation.goBack(), [navigation]); |
| 57 | const { environmentId, threadId } = route.params; | ||
| 58 | // A hand-typed deep link can carry a blank ID, which the branded IDs reject. | ||
| 59 | const isBlankLink = environmentId.trim().length === 0 || threadId.trim().length === 0; | ||
| 60 | useEffect(() => { | ||
| 61 | if (isBlankLink) onClose(); | ||
| 62 | }, [isBlankLink, onClose]); | ||
| 63 | if (isBlankLink) return null; | ||
| 57 | return ( | 64 | return ( |
| 58 | <DevicePreviewScreen | 65 | <DevicePreviewScreen |
| 59 | environmentId={EnvironmentId.make(route.params.environmentId)} | 66 | environmentId={EnvironmentId.make(environmentId)} |
| 60 | threadId={ThreadId.make(route.params.threadId)} | 67 | threadId={ThreadId.make(threadId)} |
| 61 | onClose={onClose} | 68 | onClose={onClose} |
| 62 | /> | 69 | /> |
| 63 | ); | 70 | ); |
apps/mobile/src/features/files/AttachmentFileScreen.tsx+3-1| ... | @@ -172,7 +172,9 @@ export function AttachmentFileScreen(props: AttachmentFileScreenProps) { | ... | @@ -172,7 +172,9 @@ export function AttachmentFileScreen(props: AttachmentFileScreenProps) { |
| 172 | const iconColor = useUniwindTheme()["--color-icon"]; | 172 | const iconColor = useUniwindTheme()["--color-icon"]; |
| 173 | const isAndroid = Platform.OS === "android"; | 173 | const isAndroid = Platform.OS === "android"; |
| 174 | const params = props.route.params; | 174 | const params = props.route.params; |
| 175 | const environmentId = params.environmentId ? EnvironmentId.make(params.environmentId) : null; | 175 | const environmentId = params.environmentId?.trim() |
| 176 | ? EnvironmentId.make(params.environmentId) | ||
| 177 | : null; | ||
| 176 | const sizeBytes = Number.parseInt(params.sizeBytes, 10) || 0; | 178 | const sizeBytes = Number.parseInt(params.sizeBytes, 10) || 0; |
| 177 | const draftKey = params.draftKey ?? null; | 179 | const draftKey = params.draftKey ?? null; |
| 178 | const draft = useComposerDraft(draftKey); | 180 | const draft = useComposerDraft(draftKey); |
apps/mobile/src/features/files/ThreadFilesRouteScreen.tsx+4-5| ... | @@ -171,12 +171,11 @@ function FileHeader(props: { | ... | @@ -171,12 +171,11 @@ function FileHeader(props: { |
| 171 | 171 | ||
| 172 | type FileViewMode = "preview" | "source"; | 172 | type FileViewMode = "preview" | "source"; |
| 173 | 173 | ||
| 174 | // A blank param (a hand-typed deep link) is treated as missing, since branded | ||
| 175 | // IDs reject whitespace-only values. | ||
| 174 | function firstRouteParam(value: string | string[] | undefined): string | null { | 176 | function firstRouteParam(value: string | string[] | undefined): string | null { |
| 175 | if (Array.isArray(value)) { | 177 | const first = Array.isArray(value) ? value[0] : value; |
| 176 | return value[0] ?? null; | 178 | return first === undefined || first.trim().length === 0 ? null : first; |
| 177 | } | ||
| 178 | |||
| 179 | return value ?? null; | ||
| 180 | } | 179 | } |
| 181 | 180 | ||
| 182 | function normalizeRoutePath(value: string | string[] | undefined): string | null { | 181 | function normalizeRoutePath(value: string | string[] | undefined): string | null { |
apps/mobile/src/features/terminal/ThreadTerminalRouteScreen.tsx+4-5| ... | @@ -188,12 +188,11 @@ type TerminalToolbarAction = | ... | @@ -188,12 +188,11 @@ type TerminalToolbarAction = |
| 188 | readonly modifier: PendingModifier; | 188 | readonly modifier: PendingModifier; |
| 189 | }; | 189 | }; |
| 190 | 190 | ||
| 191 | // A blank param (a hand-typed deep link) is treated as missing, since branded | ||
| 192 | // IDs reject whitespace-only values. | ||
| 191 | function firstRouteParam(value: string | string[] | undefined): string | null { | 193 | function firstRouteParam(value: string | string[] | undefined): string | null { |
| 192 | if (Array.isArray(value)) { | 194 | const first = Array.isArray(value) ? value[0] : value; |
| 193 | return value[0] ?? null; | 195 | return first === undefined || first.trim().length === 0 ? null : first; |
| 194 | } | ||
| 195 | |||
| 196 | return value ?? null; | ||
| 197 | } | 196 | } |
| 198 | 197 | ||
| 199 | function inferHostPlatform(environmentLabel: string | null): HostPlatform { | 198 | function inferHostPlatform(environmentLabel: string | null): HostPlatform { |
apps/mobile/src/features/threads/ThreadRouteScreen.tsx+4-5| ... | @@ -200,12 +200,11 @@ function InspectorPaneRoleActivation() { | ... | @@ -200,12 +200,11 @@ function InspectorPaneRoleActivation() { |
| 200 | return null; | 200 | return null; |
| 201 | } | 201 | } |
| 202 | 202 | ||
| 203 | // A blank param (a hand-typed deep link) is treated as missing, since branded | ||
| 204 | // IDs reject whitespace-only values. | ||
| 203 | function firstRouteParam(value: string | string[] | undefined): string | null { | 205 | function firstRouteParam(value: string | string[] | undefined): string | null { |
| 204 | if (Array.isArray(value)) { | 206 | const first = Array.isArray(value) ? value[0] : value; |
| 205 | return value[0] ?? null; | 207 | return first === undefined || first.trim().length === 0 ? null : first; |
| 206 | } | ||
| 207 | |||
| 208 | return value ?? null; | ||
| 209 | } | 208 | } |
| 210 | 209 | ||
| 211 | function OpeningThreadLoadingScreen() { | 210 | function OpeningThreadLoadingScreen() { |
apps/mobile/src/state/use-thread-selection.ts+4-5| ... | @@ -38,12 +38,11 @@ type ThreadSelectionRouteParams = { | ... | @@ -38,12 +38,11 @@ type ThreadSelectionRouteParams = { |
| 38 | readonly threadId?: string | string[]; | 38 | readonly threadId?: string | string[]; |
| 39 | }; | 39 | }; |
| 40 | 40 | ||
| 41 | // A blank param (a hand-typed deep link) is treated as missing, since branded | ||
| 42 | // IDs reject whitespace-only values. | ||
| 41 | function firstRouteParam(value: string | string[] | undefined): string | null { | 43 | function firstRouteParam(value: string | string[] | undefined): string | null { |
| 42 | if (Array.isArray(value)) { | 44 | const first = Array.isArray(value) ? value[0] : value; |
| 43 | return value[0] ?? null; | 45 | return first === undefined || first.trim().length === 0 ? null : first; |
| 44 | } | ||
| 45 | |||
| 46 | return value ?? null; | ||
| 47 | } | 46 | } |
| 48 | 47 | ||
| 49 | function latestUserMessageAt( | 48 | function latestUserMessageAt( |
packages/contracts/src/baseSchemas.test.ts+144| ... | @@ -1,13 +1,17 @@ | ... | @@ -1,13 +1,17 @@ |
| 1 | import { describe, expect, it } from "@effect/vitest"; | 1 | import { describe, expect, it } from "@effect/vitest"; |
| 2 | import * as Arbitrary from "effect/Arbitrary"; | ||
| 2 | import * as DateTime from "effect/DateTime"; | 3 | import * as DateTime from "effect/DateTime"; |
| 3 | import * as Effect from "effect/Effect"; | 4 | import * as Effect from "effect/Effect"; |
| 5 | import * as Exit from "effect/Exit"; | ||
| 4 | import * as Schema from "effect/Schema"; | 6 | import * as Schema from "effect/Schema"; |
| 5 | 7 | ||
| 8 | import * as BaseSchemas from "./baseSchemas.ts"; | ||
| 6 | import { | 9 | import { |
| 7 | ForwardCompatibleArray, | 10 | ForwardCompatibleArray, |
| 8 | ForwardCompatibleUnion, | 11 | ForwardCompatibleUnion, |
| 9 | ForwardCompatibleUnionArray, | 12 | ForwardCompatibleUnionArray, |
| 10 | isUnknownUnionMember, | 13 | isUnknownUnionMember, |
| 14 | TrimmedNonEmptyString, | ||
| 11 | UnknownUnionMember, | 15 | UnknownUnionMember, |
| 12 | } from "./baseSchemas.ts"; | 16 | } from "./baseSchemas.ts"; |
| 13 | 17 | ||
| ... | @@ -43,6 +47,21 @@ describe("ForwardCompatibleArray", () => { | ... | @@ -43,6 +47,21 @@ describe("ForwardCompatibleArray", () => { |
| 43 | { name: "a", count: 7 }, | 47 | { name: "a", count: 7 }, |
| 44 | ]); | 48 | ]); |
| 45 | }); | 49 | }); |
| 50 | |||
| 51 | it("sends an element it cannot encode as a hole instead of failing the array", () => { | ||
| 52 | const Named = ForwardCompatibleArray(Schema.Struct({ name: TrimmedNonEmptyString })); | ||
| 53 | const wire = JSON.parse( | ||
| 54 | JSON.stringify( | ||
| 55 | Schema.encodeUnknownSync(Schema.toCodecJson(Named))([ | ||
| 56 | { name: "a" }, | ||
| 57 | { name: " " }, | ||
| 58 | { name: "b" }, | ||
| 59 | ]), | ||
| 60 | ), | ||
| 61 | ); | ||
| 62 | expect(wire).toEqual([{ name: "a" }, null, { name: "b" }]); | ||
| 63 | expect(fromWire(Named)(wire)).toEqual([{ name: "a" }, { name: "b" }]); | ||
| 64 | }); | ||
| 46 | }); | 65 | }); |
| 47 | 66 | ||
| 48 | describe("ForwardCompatibleUnion", () => { | 67 | describe("ForwardCompatibleUnion", () => { |
| ... | @@ -105,3 +124,128 @@ describe("ForwardCompatibleUnionArray", () => { | ... | @@ -105,3 +124,128 @@ describe("ForwardCompatibleUnionArray", () => { |
| 105 | expect(encode([square])).toEqual([square]); | 124 | expect(encode([square])).toEqual([square]); |
| 106 | }); | 125 | }); |
| 107 | }); | 126 | }); |
| 127 | |||
| 128 | describe("trimmed non-empty strings", () => { | ||
| 129 | const entityIds = [ | ||
| 130 | "ThreadId", | ||
| 131 | "ProjectId", | ||
| 132 | "EnvironmentId", | ||
| 133 | "CommandId", | ||
| 134 | "EventId", | ||
| 135 | "MessageId", | ||
| 136 | "TurnId", | ||
| 137 | "RunId", | ||
| 138 | "RunAttemptId", | ||
| 139 | "NodeId", | ||
| 140 | "AuthSessionId", | ||
| 141 | "ProviderItemId", | ||
| 142 | "ProviderSessionId", | ||
| 143 | "ProviderThreadId", | ||
| 144 | "ProviderTurnId", | ||
| 145 | "RuntimeSessionId", | ||
| 146 | "RuntimeItemId", | ||
| 147 | "TurnItemId", | ||
| 148 | "RuntimeRequestId", | ||
| 149 | "RuntimeTaskId", | ||
| 150 | "ScheduledTaskId", | ||
| 151 | "ApprovalRequestId", | ||
| 152 | "CheckpointRef", | ||
| 153 | "CheckpointId", | ||
| 154 | "CheckpointScopeId", | ||
| 155 | "ContextHandoffId", | ||
| 156 | "ContextTransferId", | ||
| 157 | "RawEventId", | ||
| 158 | "PlanId", | ||
| 159 | ] as const; | ||
| 160 | const schemas = [ | ||
| 161 | ["TrimmedNonEmptyString", TrimmedNonEmptyString], | ||
| 162 | ...entityIds.map((name) => [name, BaseSchemas[name]] as const), | ||
| 163 | ] as const; | ||
| 164 | |||
| 165 | // Schema-derived strings are rarely padded, so pad them with every kind of | ||
| 166 | // whitespace `trim()` removes, and include whitespace-only values. | ||
| 167 | const whitespace = Arbitrary.schema( | ||
| 168 | Schema.Literals([" ", "\t", "\n", "\r\n", "\v", "\f", "\u00a0", "\u2028", "\u3000", "\ufeff"]), | ||
| 169 | ); | ||
| 170 | const padding = Arbitrary.array(whitespace, { maxLength: 4 }).pipe( | ||
| 171 | Arbitrary.map((parts) => parts.join("")), | ||
| 172 | ); | ||
| 173 | const paddedString = Arbitrary.all([padding, Arbitrary.schema(Schema.String), padding]).pipe( | ||
| 174 | Arbitrary.map(([before, value, after]) => before + value + after), | ||
| 175 | ); | ||
| 176 | const options = { arbitrary: { runs: 500 } }; | ||
| 177 | |||
| 178 | for (const [name, schema] of schemas) { | ||
| 179 | const make = schema.makeOption; | ||
| 180 | const encode = Schema.encodeUnknownExit(schema); | ||
| 181 | const decode = Schema.decodeExit(schema); | ||
| 182 | |||
| 183 | it.prop( | ||
| 184 | `${name}: whatever make accepts encodes to something that decodes back`, | ||
| 185 | [paddedString], | ||
| 186 | ([input]) => { | ||
| 187 | const made = make(input); | ||
| 188 | if (made._tag === "None") { | ||
| 189 | expect(input.trim()).toBe(""); | ||
| 190 | return; | ||
| 191 | } | ||
| 192 | const encoded = encode(made.value); | ||
| 193 | expect(encoded).toStrictEqual(Exit.succeed(input.trim())); | ||
| 194 | expect(decode(input.trim())).toStrictEqual(Exit.succeed(input.trim())); | ||
| 195 | }, | ||
| 196 | options, | ||
| 197 | ); | ||
| 198 | |||
| 199 | it.prop( | ||
| 200 | `${name}: decoding then encoding is stable`, | ||
| 201 | [paddedString], | ||
| 202 | ([input]) => { | ||
| 203 | const decoded = decode(input); | ||
| 204 | if (Exit.isFailure(decoded)) { | ||
| 205 | expect(input.trim()).toBe(""); | ||
| 206 | expect(Exit.isFailure(encode(input))).toBe(true); | ||
| 207 | return; | ||
| 208 | } | ||
| 209 | expect(decoded.value).toBe(input.trim()); | ||
| 210 | expect(encode(decoded.value)).toStrictEqual(Exit.succeed(decoded.value)); | ||
| 211 | }, | ||
| 212 | options, | ||
| 213 | ); | ||
| 214 | |||
| 215 | it.prop( | ||
| 216 | `${name}: generated values round-trip`, | ||
| 217 | [schema], | ||
| 218 | ([value]) => { | ||
| 219 | const encoded = encode(value); | ||
| 220 | expect(Exit.isSuccess(encoded)).toBe(true); | ||
| 221 | if (Exit.isSuccess(encoded)) | ||
| 222 | expect(decode(encoded.value)).toStrictEqual(Exit.succeed(value.trim())); | ||
| 223 | }, | ||
| 224 | options, | ||
| 225 | ); | ||
| 226 | } | ||
| 227 | |||
| 228 | const isThreadId = Schema.is(BaseSchemas.ThreadId); | ||
| 229 | const encodeThreadId = Schema.encodeUnknownExit(BaseSchemas.ThreadId); | ||
| 230 | const decodeThreadId = Schema.decodeExit(BaseSchemas.ThreadId); | ||
| 231 | |||
| 232 | it("rejects whitespace-only values everywhere", () => { | ||
| 233 | for (const value of [" ", "\t\n", "\u00a0\u3000"]) { | ||
| 234 | expect(() => BaseSchemas.ThreadId.make(value)).toThrow(); | ||
| 235 | expect(isThreadId(value)).toBe(false); | ||
| 236 | expect(Exit.isFailure(encodeThreadId(value))).toBe(true); | ||
| 237 | expect(Exit.isFailure(decodeThreadId(value))).toBe(true); | ||
| 238 | } | ||
| 239 | }); | ||
| 240 | |||
| 241 | it("keeps the encoded form and JSON Schema of valid values", () => { | ||
| 242 | expect(encodeThreadId(BaseSchemas.ThreadId.make("thread-1"))).toStrictEqual( | ||
| 243 | Exit.succeed("thread-1"), | ||
| 244 | ); | ||
| 245 | expect(encodeThreadId(" a b ")).toStrictEqual(Exit.succeed("a b")); | ||
| 246 | expect(Schema.toJsonSchemaDocument(Schema.toType(BaseSchemas.ThreadId)).schema).toEqual({ | ||
| 247 | type: "string", | ||
| 248 | minLength: 1, | ||
| 249 | }); | ||
| 250 | }); | ||
| 251 | }); |
packages/contracts/src/baseSchemas.ts+21-3| ... | @@ -13,7 +13,18 @@ export const TrimmedString = Schema.String.pipe( | ... | @@ -13,7 +13,18 @@ export const TrimmedString = Schema.String.pipe( |
| 13 | }), | 13 | }), |
| 14 | ), | 14 | ), |
| 15 | ); | 15 | ); |
| 16 | export const TrimmedNonEmptyString = TrimmedString.check(Schema.isNonEmpty()); | 16 | /** |
| 17 | * Non-empty once trimmed. A `TrimmedString` only trims when decoding or | ||
| 18 | * encoding, so `make` and encode see the untrimmed value: a plain | ||
| 19 | * `isNonEmpty` would accept `" "` there and encode it to `""`, which no | ||
| 20 | * longer decodes. | ||
| 21 | */ | ||
| 22 | const isNonBlank = Schema.makeFilter((value: string) => value.trim().length > 0, { | ||
| 23 | expected: "a non-blank string", | ||
| 24 | toJsonSchema: () => [{ minLength: 1 }, true], | ||
| 25 | arbitraryConstraint: { minLength: 1 }, | ||
| 26 | }); | ||
| 27 | export const TrimmedNonEmptyString = TrimmedString.check(isNonBlank); | ||
| 17 | 28 | ||
| 18 | export const NonNegativeInt = Schema.Int.check(Schema.isGreaterThanOrEqualTo(0)); | 29 | export const NonNegativeInt = Schema.Int.check(Schema.isGreaterThanOrEqualTo(0)); |
| 19 | export const PositiveInt = Schema.Int.check(Schema.isGreaterThanOrEqualTo(1)); | 30 | export const PositiveInt = Schema.Int.check(Schema.isGreaterThanOrEqualTo(1)); |
| ... | @@ -108,7 +119,7 @@ export const OmittedWhenNull = <Value extends Schema.Top>(value: Value) => { | ... | @@ -108,7 +119,7 @@ export const OmittedWhenNull = <Value extends Schema.Top>(value: Value) => { |
| 108 | * | 119 | * |
| 109 | * Decoding runs each value through its own schema, so transformations (dates, | 120 | * Decoding runs each value through its own schema, so transformations (dates, |
| 110 | * trimming, decoding defaults) apply as usual. Only values that schema | 121 | * trimming, decoding defaults) apply as usual. Only values that schema |
| 111 | * rejects are dropped; encoding is the plain encoding. | 122 | * rejects are dropped, on either side. |
| 112 | * | 123 | * |
| 113 | * For a tagged union, prefer {@link ForwardCompatibleUnion}: it drops only | 124 | * For a tagged union, prefer {@link ForwardCompatibleUnion}: it drops only |
| 114 | * values whose tag this build does not know, so a known member with a broken | 125 | * values whose tag this build does not know, so a known member with a broken |
| ... | @@ -119,10 +130,17 @@ export const ForwardCompatibleArray = <Element extends Schema.Top>(element: Elem | ... | @@ -119,10 +130,17 @@ export const ForwardCompatibleArray = <Element extends Schema.Top>(element: Elem |
| 119 | Schema.UndefinedOr(element).pipe( | 130 | Schema.UndefinedOr(element).pipe( |
| 120 | // An element this build cannot read becomes a hole, filtered out below. | 131 | // An element this build cannot read becomes a hole, filtered out below. |
| 121 | Schema.catchDecoding(() => Effect.succeedSome(undefined)), | 132 | Schema.catchDecoding(() => Effect.succeedSome(undefined)), |
| 133 | // Likewise an element that cannot be encoded is sent as a hole, so one | ||
| 134 | // bad element costs only itself rather than the whole payload. | ||
| 135 | Schema.catchEncoding(() => Effect.succeedSome(undefined)), | ||
| 122 | ), | 136 | ), |
| 123 | ).pipe( | 137 | ).pipe( |
| 124 | Schema.decodeTo( | 138 | Schema.decodeTo( |
| 125 | Schema.Array(Schema.toType(element)), | 139 | Schema.Array( |
| 140 | Schema.UndefinedOr(Schema.toType(element)).pipe( | ||
| 141 | Schema.catchEncoding(() => Effect.succeedSome(undefined)), | ||
| 142 | ), | ||
| 143 | ), | ||
| 126 | SchemaTransformation.transform< | 144 | SchemaTransformation.transform< |
| 127 | ReadonlyArray<Element["Type"]>, | 145 | ReadonlyArray<Element["Type"]>, |
| 128 | ReadonlyArray<Element["Type"] | undefined> | 146 | ReadonlyArray<Element["Type"] | undefined> |