From 31024ff0316aa29da1307bd44cbf508f4fb8d9ae Mon Sep 17 00:00:00 2001 From: Jinwoo-H Date: Sun, 6 Sep 2026 17:35:23 -0400 Subject: [PATCH] feat(mobile-web): serve page-safe file searches and text reads from desktop Reuse host file methods behind Desktop-owned identity redaction and move list/text presentation into the hosted page. Preserve older shells and Desktop versions through legacy fallback, without changing the generic bridge contract. --- .../2026-09-06-long-lived-mobile-shell.md | 29 ++++- .../mobile-web/mobile-web-file-operations.ts | 39 +------ src/main/runtime/rpc/methods/index.ts | 2 + .../rpc/methods/mobile-web-file-reads.test.ts | 53 ++++++++++ .../rpc/methods/mobile-web-file-reads.ts | 24 +++++ .../methods/mobile-web-host-catalog.test.ts | 20 +++- .../rpc/methods/mobile-web-host-catalog.ts | 9 +- .../src/mobile-web-file-host-reads.test.ts | 75 +++++++++++++ .../mobile-web-file-read-request-client.ts | 2 +- .../src/mobile-web-file-request-client.ts | 100 ++++++++++++------ .../src/mobile-web-host-file-content.ts | 32 ++++++ .../mobile-web/file-list-presentation.ts | 46 ++++++++ 12 files changed, 356 insertions(+), 75 deletions(-) create mode 100644 src/main/runtime/rpc/methods/mobile-web-file-reads.test.ts create mode 100644 src/main/runtime/rpc/methods/mobile-web-file-reads.ts create mode 100644 src/mobile-web/src/mobile-web-host-file-content.ts create mode 100644 src/shared/mobile-web/file-list-presentation.ts diff --git a/docs/reference/plans/2026-09-06-long-lived-mobile-shell.md b/docs/reference/plans/2026-09-06-long-lived-mobile-shell.md index a9f21a3146a..98449b630ad 100644 --- a/docs/reference/plans/2026-09-06-long-lived-mobile-shell.md +++ b/docs/reference/plans/2026-09-06-long-lived-mobile-shell.md @@ -21,6 +21,7 @@ pages; no protocol or manifest bump is planned. - [x] First complete generic unary slice: `9910fccc298`. Desktop catalog, opaque workspace binding, source-control status/diff, page-side presentation, legacy fallback, hard payload and concurrency bounds. +- [x] Directory and binary chunk reads use generic forwarding: `a8bbed52da4`. - [ ] Complete the generic bridge and migrate remaining domain consumers. - [ ] Complete iOS end-to-end evidence and Android final smoke check. @@ -157,8 +158,11 @@ pnpm test src/shared/mobile-web src/mobile-web src/main/runtime/rpc pnpm run build:mobile-web ``` -Run mobile tests separately from the web export: a previous overlapping run -failed React Native resolution while an isolated rerun passed. Build the terminal +Run mobile tests and simulator Metro separately from the web export. The root +`build:mobile-web-rnw` script runs `pnpm --dir mobile install --frozen-lockfile`, +which replaces dependency directories and can invalidate a live Metro resolver. +A previous overlapping mobile test run also failed React Native resolution while +an isolated rerun passed. Build the terminal WebView engine if mobile typechecking needs it. Run Kotlin unit tests with the configured JDK 17/Android SDK; prebuild Android when required. Run native Swift store tests when native package/CSP behavior changes. Format only changed files @@ -187,3 +191,24 @@ with `pnpm exec oxfmt --write`. Exact command tails: `/tmp/orca-ota-e2e/file-gates/`. - iOS retry reached native compilation but failed at React-RCTFabric `RCTFabricSurface.mm`; capturing full compiler diagnostics before proceeding. + +- iOS native build now passes after regenerating Pods and replacing stale derived + compiler caches. The old derived data is preserved at + `/tmp/orca-ota-e2e/stale-ios-derived-data`; no dependency source patches needed. + Full successful log: `/tmp/orca-ota-e2e/ios-native-build-clean.log`. +- Running the existing iOS adversarial-content journey (which includes source + control) on that build, with `--skip-native-build`, in + `/tmp/orca-ota-e2e/ios-journey`. Pairing runtime started and Metro is loading. +- In progress: file list/search and text reads via Desktop privacy adapters + `mobileWeb.files.searchPaths` / `mobileWeb.files.read`. They reuse existing + host methods and remove private workspace/root fields before forwarding. + Future result fields remain available to the page; native request contract + stays unchanged. Focused tests pass; full gates running. + +- File list/search/text migration passes every required gate: mobile 831 files / + 5,493 tests; root 317 files / 2,699 tests. Page build: + `8143c37da629651d2e672268423846e8d44fbf33e90637f4436791b4a33e7a53`, + 52 assets / 9,688,231 bytes. Logs: `/tmp/orca-ota-e2e/file-text-gates/`. +- First iOS hosted run paired successfully, then page export's dependency + reinstall invalidated the live Metro resolver (`InitializeCore` not found). + Retired that launcher; rerun after all builds, with no concurrent install/export. diff --git a/mobile/src/mobile-web/mobile-web-file-operations.ts b/mobile/src/mobile-web/mobile-web-file-operations.ts index 3d6c7723e25..1ed2d0678b0 100644 --- a/mobile/src/mobile-web/mobile-web-file-operations.ts +++ b/mobile/src/mobile-web/mobile-web-file-operations.ts @@ -1,18 +1,16 @@ +import { sanitizeListResult } from '../../../src/shared/mobile-web/file-list-presentation' import { Buffer } from 'buffer/' import { MOBILE_WEB_FILE_CONTENT_MAX_BYTES, MobileWebFileChunkPayloadSchema, MobileWebFileDirectoryPayloadSchema, - MobileWebFileEntrySchema, MobileWebFileListPayloadSchema, - MobileWebFileListResultSchema, MobileWebFileOpenPayloadSchema, MobileWebFileReadPayloadSchema, MobileWebFileReadResultSchema, MobileWebFileSearchPayloadSchema, type MobileWebFileChunkWireResult, type MobileWebFileDirectoryResult, - type MobileWebFileEntry, type MobileWebFileListResult, type MobileWebFileReadWireResult } from '../../../src/shared/mobile-web/bridge-operation-contract' @@ -143,41 +141,6 @@ async function listFiles( return sanitizeListResult(response.result, pageWorkspaceId, hostWorkspaceId, limit) } -function sanitizeListResult( - result: unknown, - pageWorkspaceId: string, - hostWorkspaceId: string, - limit: number -): MobileWebFileListResult { - if (!isRecord(result) || result.worktree !== hostWorkspaceId || !Array.isArray(result.files)) { - throw new MobileWebBrokerError('host_error') - } - const files = result.files.slice(0, limit).flatMap((value): MobileWebFileEntry[] => { - if (!isRecord(value) || typeof value.relativePath !== 'string') { - return [] - } - const parsed = MobileWebFileEntrySchema.safeParse({ - relativePath: value.relativePath, - basename: value.relativePath.split('/').at(-1)?.slice(0, 255), - kind: value.kind === 'binary' ? 'binary' : 'text' - }) - return parsed.success ? [parsed.data] : [] - }) - const totalCount = - typeof result.totalCount === 'number' && - Number.isSafeInteger(result.totalCount) && - result.totalCount >= 0 - ? result.totalCount - : result.files.length - return MobileWebFileListResultSchema.parse({ - workspaceId: pageWorkspaceId, - files, - totalCount, - truncated: - result.truncated === true || result.files.length > files.length || totalCount > files.length - }) -} - function sanitizeReadResult( result: unknown, pageWorkspaceId: string, diff --git a/src/main/runtime/rpc/methods/index.ts b/src/main/runtime/rpc/methods/index.ts index dc58fcace1b..97db5066f31 100644 --- a/src/main/runtime/rpc/methods/index.ts +++ b/src/main/runtime/rpc/methods/index.ts @@ -45,6 +45,7 @@ import { UPDATER_METHODS } from './updater' import { AGENT_SESSION_METHODS } from './agent-session' import { STRUCTURED_AGENT_SESSION_METHODS } from './structured-agent-session' import { ARTIFACT_METHODS } from './artifacts' +import { MOBILE_WEB_FILE_READ_METHODS } from './mobile-web-file-reads' import { MOBILE_WEB_HOST_CATALOG_METHOD } from './mobile-web-host-catalog' import { MOBILE_WEB_PACKAGE_METHODS } from './mobile-web-package' import { MOBILE_FILE_WRITE_METHODS } from './mobile-file-write-if-unchanged' @@ -103,5 +104,6 @@ export const ALL_RPC_METHODS: readonly RpcAnyMethod[] = [ ...PAIRING_METHODS, ...UPDATER_METHODS, MOBILE_WEB_HOST_CATALOG_METHOD, + ...MOBILE_WEB_FILE_READ_METHODS, ...MOBILE_WEB_PACKAGE_METHODS ] diff --git a/src/main/runtime/rpc/methods/mobile-web-file-reads.test.ts b/src/main/runtime/rpc/methods/mobile-web-file-reads.test.ts new file mode 100644 index 00000000000..3d3c20f0695 --- /dev/null +++ b/src/main/runtime/rpc/methods/mobile-web-file-reads.test.ts @@ -0,0 +1,53 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { MOBILE_WEB_FILE_READ_METHODS } from './mobile-web-file-reads' +import { FILE_METHODS } from './files' +import type { RpcContext } from '../core' + +afterEach(() => vi.restoreAllMocks()) + +describe('page-safe host file reads', () => { + it.each(['searchPaths', 'read'])( + 'reuses files.%s while keeping host identity off the page', + async (operation) => { + const source = FILE_METHODS.find((method) => method.name === `files.${operation}`)! + const method = MOBILE_WEB_FILE_READ_METHODS.find( + (entry) => entry.name === `mobileWeb.files.${operation}` + )! + const handler = vi.spyOn(source, 'handler').mockResolvedValue({ + worktree: 'host-workspace-id', + rootPath: '/private/host/repository', + relativePath: 'docs/readme.md', + content: 'hello', + files: [], + futureField: { kind: 'new-domain-shape' } + }) + const context = { signal: new AbortController().signal } as RpcContext + const params = { + worktree: 'id:host-workspace-id', + relativePath: 'docs/readme.md', + query: 'docs' + } + expect(method.params).toBe(source.params) + const result = await method.handler(params, context) + expect(handler).toHaveBeenCalledWith(params, context) + expect(result).toEqual({ + relativePath: 'docs/readme.md', + content: 'hello', + files: [], + futureField: { kind: 'new-domain-shape' } + }) + expect(JSON.stringify(result)).not.toMatch(/host-workspace-id|private\/host/) + } + ) + + it('preserves an execution-host failure instead of answering locally', async () => { + const source = FILE_METHODS.find((method) => method.name === 'files.read')! + vi.spyOn(source, 'handler').mockRejectedValue(new Error('SSH provider unavailable')) + const method = MOBILE_WEB_FILE_READ_METHODS.find( + (entry) => entry.name === 'mobileWeb.files.read' + )! + await expect( + method.handler({ worktree: 'id:remote', relativePath: 'a.txt' }, {} as RpcContext) + ).rejects.toThrow('SSH provider unavailable') + }) +}) diff --git a/src/main/runtime/rpc/methods/mobile-web-file-reads.ts b/src/main/runtime/rpc/methods/mobile-web-file-reads.ts new file mode 100644 index 00000000000..0bd44536d5b --- /dev/null +++ b/src/main/runtime/rpc/methods/mobile-web-file-reads.ts @@ -0,0 +1,24 @@ +import { defineMethod, isStreamingMethod } from '../core' +import { FILE_METHODS } from './files' + +// Desktop owns identity redaction; installed shells need no file-result vocabulary. +export const MOBILE_WEB_FILE_READ_METHODS = ['searchPaths', 'read'].map((operation) => { + const source = FILE_METHODS.find((method) => method.name === `files.${operation}`) + if (!source || isStreamingMethod(source)) { + throw new Error(`Missing unary file method: ${operation}`) + } + return defineMethod({ + name: `mobileWeb.files.${operation}`, + params: source.params, + handler: async (params, context) => { + const result = await source.handler(params, context) + if (typeof result !== 'object' || result === null || Array.isArray(result)) { + throw new Error('Invalid file read result') + } + const pageResult: Record = { ...result } + delete pageResult.worktree + delete pageResult.rootPath + return pageResult + } + }) +}) diff --git a/src/main/runtime/rpc/methods/mobile-web-host-catalog.test.ts b/src/main/runtime/rpc/methods/mobile-web-host-catalog.test.ts index 75115061d37..f7ee140f760 100644 --- a/src/main/runtime/rpc/methods/mobile-web-host-catalog.test.ts +++ b/src/main/runtime/rpc/methods/mobile-web-host-catalog.test.ts @@ -12,6 +12,8 @@ describe('mobile web host catalog', () => { 'git.diff', 'files.readDir', 'files.readChunk', + 'mobileWeb.files.searchPaths', + 'mobileWeb.files.read', 'git.status', 'pairing.getEndpoints', 'files.searchPaths', @@ -21,14 +23,28 @@ describe('mobile web host catalog', () => { {} as RpcContext ) expect(result).toEqual({ - grants: ['git.status', 'git.diff', 'files.readDir', 'files.readChunk'].map((method) => ({ + grants: [ + 'git.status', + 'git.diff', + 'files.readDir', + 'files.readChunk', + 'mobileWeb.files.searchPaths', + 'mobileWeb.files.read' + ].map((method) => ({ method, workspaceParam: 'worktree', maxRequestBytes: 16 * 1024, maxResponseBytes: 512 * 1024 })) }) - for (const method of ['git.status', 'git.diff', 'files.readDir', 'files.readChunk']) { + for (const method of [ + 'git.status', + 'git.diff', + 'files.readDir', + 'files.readChunk', + 'mobileWeb.files.searchPaths', + 'mobileWeb.files.read' + ]) { expect(ALL_RPC_METHODS.some((entry) => entry.name === method)).toBe(true) } }) diff --git a/src/main/runtime/rpc/methods/mobile-web-host-catalog.ts b/src/main/runtime/rpc/methods/mobile-web-host-catalog.ts index 2d357541469..77a59d690bc 100644 --- a/src/main/runtime/rpc/methods/mobile-web-host-catalog.ts +++ b/src/main/runtime/rpc/methods/mobile-web-host-catalog.ts @@ -3,7 +3,14 @@ import { defineMethod } from '../core' // Only page-safe results belong here; transport credentials never enter this catalog. const PAGE_METHODS = new Map( - ['git.status', 'git.diff', 'files.readDir', 'files.readChunk'].map((method) => [ + [ + 'git.status', + 'git.diff', + 'files.readDir', + 'files.readChunk', + 'mobileWeb.files.searchPaths', + 'mobileWeb.files.read' + ].map((method) => [ method, { method, diff --git a/src/mobile-web/src/mobile-web-file-host-reads.test.ts b/src/mobile-web/src/mobile-web-file-host-reads.test.ts index 2ff87ca2e8b..e5f068dd6e4 100644 --- a/src/mobile-web/src/mobile-web-file-host-reads.test.ts +++ b/src/mobile-web/src/mobile-web-file-host-reads.test.ts @@ -32,6 +32,9 @@ function fixture(generic = true) { context, grants: [ { capability: 'file', operation: 'directory', limits }, + { capability: 'file', operation: 'list', limits }, + { capability: 'file', operation: 'search', limits }, + { capability: 'file', operation: 'read', limits }, { capability: 'file', operation: 'readChunk', limits }, ...(generic ? [{ capability: 'workspace' as const, operation: 'hostRequest', limits }] : []) ], @@ -140,3 +143,75 @@ describe('page-owned generic file reads', () => { client.dispose() }) }) + +describe('page-safe file listing and text', () => { + it('searches through the host privacy adapter using an opaque workspace', async () => { + const { client, messages, respond } = fixture() + const result = client.fileSearch({ + workspaceId: directory.workspaceId, + query: 'report', + limit: 10 + }) + expect(messages[0]).toMatchObject({ + operation: 'hostRequest', + payload: { + method: 'mobileWeb.files.searchPaths', + workspaceId: directory.workspaceId, + params: { query: 'report', limit: 10 } + } + }) + respond({ + files: [{ relativePath: 'docs/report.md', kind: 'text' }], + totalCount: 1, + truncated: false, + futureField: true + }) + await expect(result).resolves.toEqual({ + workspaceId: directory.workspaceId, + files: [{ relativePath: 'docs/report.md', basename: 'report.md', kind: 'text' }], + totalCount: 1, + truncated: false + }) + client.dispose() + }) + + it('reads Unicode content directly without a shell base64 projection', async () => { + const { client, messages, respond } = fixture() + const result = client.fileRead({ + workspaceId: directory.workspaceId, + relativePath: 'report.txt' + }) + expect(messages[0]).toMatchObject({ + operation: 'hostRequest', + payload: { method: 'mobileWeb.files.read' } + }) + const content = '\uFEFFhello 🌍' + const byteLength = new TextEncoder().encode(content).byteLength + respond({ + relativePath: 'report.txt', + content, + truncated: false, + byteLength, + futureField: 'new' + }) + await expect(result).resolves.toEqual({ + workspaceId: directory.workspaceId, + relativePath: 'report.txt', + content, + truncated: false, + byteLength + }) + client.dispose() + }) + + it('rejects inconsistent content size', async () => { + const { client, respond } = fixture() + const result = client.fileRead({ + workspaceId: directory.workspaceId, + relativePath: 'report.txt' + }) + respond({ relativePath: 'report.txt', content: '🌍', truncated: false, byteLength: 1 }) + await expect(result).rejects.toMatchObject({ code: 'invalid_message' }) + client.dispose() + }) +}) diff --git a/src/mobile-web/src/mobile-web-file-read-request-client.ts b/src/mobile-web/src/mobile-web-file-read-request-client.ts index 03769724096..2d7686ff181 100644 --- a/src/mobile-web/src/mobile-web-file-read-request-client.ts +++ b/src/mobile-web/src/mobile-web-file-read-request-client.ts @@ -90,7 +90,7 @@ export class MobileWebFileReadClient { ) } - private readHost( + protected readHost( method: string, workspaceId: string, params: Record, diff --git a/src/mobile-web/src/mobile-web-file-request-client.ts b/src/mobile-web/src/mobile-web-file-request-client.ts index 1fad3ce836e..c2675bcb536 100644 --- a/src/mobile-web/src/mobile-web-file-request-client.ts +++ b/src/mobile-web/src/mobile-web-file-request-client.ts @@ -1,3 +1,5 @@ +import { sanitizeListResult } from '../../shared/mobile-web/file-list-presentation' +import { projectMobileWebHostFileContent } from './mobile-web-host-file-content' import { MOBILE_WEB_FILE_CHUNK_MAX_BYTES, MobileWebFileListPayloadSchema, @@ -46,49 +48,85 @@ export class MobileWebFileRequestClient extends MobileWebFileReadClient { payload: MobileWebFileListPayload, options?: MobileWebBridgeRequestOptions ): Promise { - return this.requests - .request( - 'file', - 'list', - payload, - MobileWebFileListPayloadSchema, - MobileWebFileListResultSchema, - options - ) - .then((result) => matchingFileList(payload, result)) + const legacy = () => + this.requests + .request( + 'file', + 'list', + payload, + MobileWebFileListPayloadSchema, + MobileWebFileListResultSchema, + options + ) + .then((result) => matchingFileList(payload, result)) + if (!MobileWebFileListPayloadSchema.safeParse(payload).success) { + return legacy() + } + return this.readHost( + 'mobileWeb.files.searchPaths', + payload.workspaceId, + { query: '', limit: payload.limit }, + (result) => sanitizeListResult(result, payload.workspaceId, undefined, payload.limit), + legacy, + options + ) } search( payload: MobileWebFileSearchPayload, options?: MobileWebBridgeRequestOptions ): Promise { - return this.requests - .request( - 'file', - 'search', - payload, - MobileWebFileSearchPayloadSchema, - MobileWebFileListResultSchema, - options - ) - .then((result) => matchingFileList(payload, result)) + const legacy = () => + this.requests + .request( + 'file', + 'search', + payload, + MobileWebFileSearchPayloadSchema, + MobileWebFileListResultSchema, + options + ) + .then((result) => matchingFileList(payload, result)) + if (!MobileWebFileSearchPayloadSchema.safeParse(payload).success) { + return legacy() + } + return this.readHost( + 'mobileWeb.files.searchPaths', + payload.workspaceId, + { query: payload.query, limit: payload.limit }, + (result) => sanitizeListResult(result, payload.workspaceId, undefined, payload.limit), + legacy, + options + ) } read( payload: MobileWebFileReadPayload, options?: MobileWebBridgeRequestOptions ): Promise { - return this.requests - .request( - 'file', - 'read', - payload, - MobileWebFileReadPayloadSchema, - MobileWebFileReadResultSchema, - options - ) - .then(decodeMobileWebFileContent) - .then((result) => matchingFile(payload, result)) + const legacy = () => + this.requests + .request( + 'file', + 'read', + payload, + MobileWebFileReadPayloadSchema, + MobileWebFileReadResultSchema, + options + ) + .then(decodeMobileWebFileContent) + .then((result) => matchingFile(payload, result)) + if (!MobileWebFileReadPayloadSchema.safeParse(payload).success) { + return legacy() + } + return this.readHost( + 'mobileWeb.files.read', + payload.workspaceId, + { relativePath: payload.relativePath }, + (result) => projectMobileWebHostFileContent(result, payload), + legacy, + options + ) } open(payload: MobileWebFileOpenPayload, options?: MobileWebBridgeRequestOptions): Promise { diff --git a/src/mobile-web/src/mobile-web-host-file-content.ts b/src/mobile-web/src/mobile-web-host-file-content.ts new file mode 100644 index 00000000000..173ff03f801 --- /dev/null +++ b/src/mobile-web/src/mobile-web-host-file-content.ts @@ -0,0 +1,32 @@ +import { z } from 'zod' +import { + MOBILE_WEB_FILE_CONTENT_MAX_BYTES, + type MobileWebFileReadPayload, + type MobileWebFileReadResult +} from '../../shared/mobile-web/file-operation-contract' +import { MobileWebBridgeClientError } from './mobile-web-bridge-client-error' + +const HostContentSchema = z.object({ + relativePath: z.string(), + content: z.string(), + truncated: z.boolean(), + byteLength: z.number().int().nonnegative() +}) + +export function projectMobileWebHostFileContent( + result: unknown, + payload: MobileWebFileReadPayload +): MobileWebFileReadResult { + const parsed = HostContentSchema.safeParse(result) + if (!parsed.success || parsed.data.relativePath !== payload.relativePath) { + throw new MobileWebBridgeClientError('invalid_message', false) + } + const bytes = new TextEncoder().encode(parsed.data.content) + if (bytes.byteLength > parsed.data.byteLength) { + throw new MobileWebBridgeClientError('invalid_message', false) + } + if (bytes.byteLength > MOBILE_WEB_FILE_CONTENT_MAX_BYTES) { + throw new MobileWebBridgeClientError('too_large', false) + } + return { ...parsed.data, workspaceId: payload.workspaceId } +} diff --git a/src/shared/mobile-web/file-list-presentation.ts b/src/shared/mobile-web/file-list-presentation.ts new file mode 100644 index 00000000000..6a47943105d --- /dev/null +++ b/src/shared/mobile-web/file-list-presentation.ts @@ -0,0 +1,46 @@ +import { + MobileWebFileEntrySchema, + MobileWebFileListResultSchema, + type MobileWebFileEntry, + type MobileWebFileListResult +} from './file-operation-contract' +import { MobileWebBrokerError } from './bridge-operation-error' + +export function sanitizeListResult( + result: unknown, + pageWorkspaceId: string, + hostWorkspaceId: string | undefined, + limit: number +): MobileWebFileListResult { + if (!isRecord(result) || result.worktree !== hostWorkspaceId || !Array.isArray(result.files)) { + throw new MobileWebBrokerError('host_error') + } + const files = result.files.slice(0, limit).flatMap((value): MobileWebFileEntry[] => { + if (!isRecord(value) || typeof value.relativePath !== 'string') { + return [] + } + const parsed = MobileWebFileEntrySchema.safeParse({ + relativePath: value.relativePath, + basename: value.relativePath.split('/').at(-1)?.slice(0, 255), + kind: value.kind === 'binary' ? 'binary' : 'text' + }) + return parsed.success ? [parsed.data] : [] + }) + const totalCount = + typeof result.totalCount === 'number' && + Number.isSafeInteger(result.totalCount) && + result.totalCount >= 0 + ? result.totalCount + : result.files.length + return MobileWebFileListResultSchema.parse({ + workspaceId: pageWorkspaceId, + files, + totalCount, + truncated: + result.truncated === true || result.files.length > files.length || totalCount > files.length + }) +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value) +}