From 82225e44445d18b14e69fdbb1999ffdf84e465fb Mon Sep 17 00:00:00 2001 From: can1357 Date: Wed, 10 Jun 2026 01:27:16 +0200 Subject: [PATCH] fix(coding-agent): closed vault write approval bypass and fixed interaction tools vault writes now rated write-tier and plan-mode enforced; .tar.gz rewrites keep gzip, are atomic, and write through symlinks; CRLF conflict detection works; conflict twins only invalidated when truly stale; ask discloses timeout auto-selection in result and transcript; todo rejects duplicate ids and stops persisting half-applied batches; auto-generated guard validates against mtime+size; ACP writes run post-write bookkeeping; irc errors set isError. --- packages/coding-agent/src/tools/ask.ts | 34 +++++- .../src/tools/auto-generated-guard.ts | 23 +++- .../coding-agent/src/tools/conflict-detect.ts | 54 ++++++++- packages/coding-agent/src/tools/irc.ts | 6 +- packages/coding-agent/src/tools/todo.ts | 46 ++++++-- packages/coding-agent/src/tools/write.ts | 106 +++++++++++++++--- packages/coding-agent/test/tools.test.ts | 94 ++++++++++++++++ .../test/tools/conflict-detect.test.ts | 23 ++++ 8 files changed, 350 insertions(+), 36 deletions(-) diff --git a/packages/coding-agent/src/tools/ask.ts b/packages/coding-agent/src/tools/ask.ts index 3dd3be7da..9230c0c8a 100644 --- a/packages/coding-agent/src/tools/ask.ts +++ b/packages/coding-agent/src/tools/ask.ts @@ -59,6 +59,8 @@ export interface QuestionResult { multi: boolean; selectedOptions: string[]; customInput?: string; + /** True when the answer was auto-selected because the dialog timed out. */ + timedOut?: boolean; } export interface AskToolDetails { @@ -67,6 +69,8 @@ export interface AskToolDetails { multi?: boolean; selectedOptions?: string[]; customInput?: string; + /** True when the answer was auto-selected because the dialog timed out. */ + timedOut?: boolean; /** Multi-part question mode */ results?: QuestionResult[]; } @@ -94,6 +98,10 @@ function toSelectOption(option: AskOption, label = option.label): ExtensionUISel const OTHER_OPTION = "Other (type your own)"; const RECOMMENDED_SUFFIX = " (Recommended)"; +// Window after the timeout deadline within which an `undefined` selection is +// attributed to a UI-enforced timeout (for surfaces that close the dialog at +// the deadline but never invoke `onTimeout`). Cancels beyond it are user Esc. +const TIMEOUT_DETECTION_TOLERANCE_MS = 1_000; function getDoneOptionLabel(): string { return `${theme.symbol("tool.ask")} Done selecting`; @@ -230,7 +238,12 @@ async function askSingleQuestion( ? await untilAborted(signal, () => ui.select(prompt, optionsToShow, dialogOptions)) : await ui.select(prompt, optionsToShow, dialogOptions); if (!timeoutTriggered && choice === undefined && typeof timeout === "number") { - timeoutTriggered = Date.now() - startMs >= timeout; + // Fallback for UI surfaces that enforce `timeout` without invoking + // `onTimeout`: their auto-cancel resolves right at the deadline. A + // cancel arriving well past the deadline is a deliberate user Esc on + // a surface that kept the dialog open — keep treating it as a cancel. + const elapsed = Date.now() - startMs; + timeoutTriggered = elapsed >= timeout && elapsed <= timeout + TIMEOUT_DETECTION_TOLERANCE_MS; } return { choice, timedOut: timeoutTriggered, navigation: navigationAction }; }; @@ -380,9 +393,10 @@ function formatQuestionResult(result: QuestionResult): string { return `${result.id}: "${result.customInput}"`; } if (result.selectedOptions.length > 0) { + const suffix = result.timedOut ? " (auto-selected after timeout)" : ""; return result.multi - ? `${result.id}: [${result.selectedOptions.join(", ")}]` - : `${result.id}: ${result.selectedOptions[0]}`; + ? `${result.id}: [${result.selectedOptions.join(", ")}]${suffix}` + : `${result.id}: ${result.selectedOptions[0]}${suffix}`; } return `${result.id}: (cancelled)`; } @@ -519,13 +533,15 @@ export class AskTool implements AgentTool { multi: q.multi ?? false, selectedOptions, customInput, + timedOut: timedOut || undefined, }; const responseParts: string[] = []; if (selectedOptions.length > 0) { - responseParts.push( - q.multi ? `User selected: ${selectedOptions.join(", ")}` : `User selected: ${selectedOptions[0]}`, - ); + const selectedText = q.multi + ? `User selected: ${selectedOptions.join(", ")}` + : `User selected: ${selectedOptions[0]}`; + responseParts.push(timedOut ? `${selectedText} (auto-selected after timeout)` : selectedText); } if (customInput !== undefined) { responseParts.push( @@ -573,6 +589,7 @@ export class AskTool implements AgentTool { multi: q.multi ?? false, selectedOptions, customInput, + timedOut: timedOut || undefined, }; if (navAction === "back") { @@ -828,9 +845,14 @@ export const askToolRenderer = { const dSelected = details.selectedOptions; const dMulti = details.multi; const dCustom = details.customInput; + const dTimedOut = details.timedOut; return framedBlock(uiTheme, width => { const bodyLines = md(question, width); bodyLines.push(...renderAnswerOptionLines(uiTheme, mdTheme, dOptions, dSelected, dMulti, dCustom)); + if (dTimedOut) { + // Distinguish auto-selection from a real user choice in the transcript. + bodyLines.push(uiTheme.fg("dim", "auto-selected after timeout — not a user choice")); + } return { header, sections: bodyLines.length > 0 ? [{ lines: bodyLines }] : [], diff --git a/packages/coding-agent/src/tools/auto-generated-guard.ts b/packages/coding-agent/src/tools/auto-generated-guard.ts index d6aadd693..807f2197c 100644 --- a/packages/coding-agent/src/tools/auto-generated-guard.ts +++ b/packages/coding-agent/src/tools/auto-generated-guard.ts @@ -241,15 +241,32 @@ function buildAutoGeneratedError(displayPath: string, detected: string): ToolErr const decoder = new TextDecoder("utf-8"); -const autoGeneratedMap = new LRUCache({ max: 10 }); +const autoGeneratedMap = new LRUCache({ + max: 10, +}); async function getAutoGeneratedMarker(filePath: string): Promise { if (isAutoGeneratedFileName(filePath)) { return filePath.split("/").pop() ?? ""; } + // Key the cache on (mtime, size) so a file rewritten after the first + // check (generator added/removed) is re-scanned instead of served stale. + let mtimeMs: number; + let size: number; + try { + const stat = await Bun.file(filePath).stat(); + mtimeMs = stat.mtimeMs; + size = stat.size; + } catch (err) { + if (isEnoent(err)) { + return undefined; + } + throw err; + } + const cached = autoGeneratedMap.get(filePath); - if (cached) return cached.marker; + if (cached && cached.mtimeMs === mtimeMs && cached.size === size) return cached.marker; let marker: string | undefined; try { @@ -262,7 +279,7 @@ async function getAutoGeneratedMarker(filePath: string): Promise + i < replacementLines.length - 1 || hasFollowingLine ? `${l}\r` : l, + ); + } const next = [...lines.slice(0, match.startIdx), ...replacementLines, ...lines.slice(match.endIdx + 1)]; return next.join("\n"); } /** Reconstruct the recorded marker block as it should appear in the file. */ -function buildRecordedRegion(entry: ConflictEntry): string[] { +function buildRecordedRegion(entry: ConflictBlock): string[] { const out: string[] = []; out.push(entry.oursLabel ? `${OURS_PREFIX} ${entry.oursLabel}` : OURS_PREFIX); out.push(...entry.oursLines); @@ -358,6 +369,36 @@ function buildRecordedRegion(entry: ConflictEntry): string[] { return out; } +/** + * True when two registered blocks record the same marker-block content + * (labels and all sides). Out-of-band edits can shift a block's line + * numbers between reads, registering a fresh id while the stale one + * persists; callers use content identity to treat a locate-miss for the + * stale twin as "already resolved" instead of a hard failure. + */ +export function conflictRegionsEqual(a: ConflictBlock, b: ConflictBlock): boolean { + const ra = buildRecordedRegion(a); + const rb = buildRecordedRegion(b); + if (ra.length !== rb.length) return false; + for (let i = 0; i < ra.length; i++) { + if (ra[i] !== rb[i]) return false; + } + return true; +} + +/** + * True when the entry's recorded marker block still occurs in `content` + * (LF-normalized — recorded sections are stored LF). Distinguishes a stale + * re-registration of a just-resolved region (no longer present) from a + * DISTINCT conflict block that happens to be byte-identical (still present + * elsewhere in the file and must stay addressable). + */ +export function conflictRegionPresent(content: string, entry: ConflictBlock): boolean { + const region = buildRecordedRegion(entry).join("\n"); + const normalized = content.includes("\r") ? content.replace(/\r\n/g, "\n") : content; + return normalized.includes(region); +} + /** * Find a contiguous match of `expected` inside `lines`, preferring the * occurrence closest to `preferredIdx` to disambiguate when an identical @@ -391,11 +432,16 @@ function locateRegion( function matchesAt(lines: readonly string[], startIdx: number, expected: readonly string[]): boolean { if (startIdx < 0 || startIdx + expected.length > lines.length) return false; for (let i = 0; i < expected.length; i++) { - if (lines[startIdx + i] !== expected[i]) return false; + // Recorded lines are LF-normalized; tolerate CRLF on-disk lines. + if (stripTrailingCr(lines[startIdx + i]!) !== expected[i]) return false; } return true; } +function stripTrailingCr(line: string): string { + return line.endsWith("\r") ? line.slice(0, -1) : line; +} + function normalizeTrailingNewline(replacement: string): string { if (replacement.endsWith("\r\n")) return replacement.slice(0, -2); if (replacement.endsWith("\n")) return replacement.slice(0, -1); diff --git a/packages/coding-agent/src/tools/irc.ts b/packages/coding-agent/src/tools/irc.ts index 66f6e7c05..a075b3404 100644 --- a/packages/coding-agent/src/tools/irc.ts +++ b/packages/coding-agent/src/tools/irc.ts @@ -244,11 +244,15 @@ function errorResult(text: string, details: IrcDetails): AgentToolResult(); + const seenTasks = new Set(); + for (const listEntry of entry.list) { + if (seenPhases.has(listEntry.phase)) { + errors.push(`Duplicate phase "${listEntry.phase}" in init list`); + } + seenPhases.add(listEntry.phase); + for (const content of listEntry.items) { + if (seenTasks.has(content)) { + errors.push(`Duplicate task "${content}" in init list`); + } + seenTasks.add(content); + } + } return entry.list.map(listEntry => ({ name: listEntry.phase, tasks: listEntry.items.map(content => ({ content, status: "pending" })), @@ -301,6 +317,19 @@ function appendItems(phases: TodoPhase[], entry: TodoOpEntryValue, errors: strin return phases; } + // Validate the whole batch before mutating so a failing op reports every + // duplicate and leaves nothing half-applied. + const seen = new Set(); + let hasDuplicate = false; + for (const content of entry.items) { + if (seen.has(content) || findTaskByContent(phases, content)) { + errors.push(`Task "${content}" already exists`); + hasDuplicate = true; + } + seen.add(content); + } + if (hasDuplicate) return phases; + let phase = findPhaseByName(phases, entry.phase); if (!phase) { phase = { name: entry.phase, tasks: [] }; @@ -308,10 +337,6 @@ function appendItems(phases: TodoPhase[], entry: TodoOpEntryValue, errors: strin } for (const content of entry.items) { - if (findTaskByContent(phases, content)) { - errors.push(`Task "${content}" already exists`); - return phases; - } phase.tasks.push({ content, status: "pending" }); } return phases; @@ -618,14 +643,19 @@ export class TodoTool implements AgentTool { const { phases: updated, errors } = readOnly ? { phases: previousPhases, errors: [] as string[] } : applyParams(clonePhases(previousPhases), params); - const completedTasks = readOnly ? [] : getCompletionTransitions(previousPhases, updated); - if (!readOnly) this.session.setTodoPhases?.(updated); + // A batch with any error is discarded wholesale: persisting a + // half-applied batch makes the natural retry hit "already exists" for + // the ops that did land. State and rendered summary stay at previous. + const failed = errors.length > 0; + const effective = failed ? previousPhases : updated; + const completedTasks = readOnly || failed ? [] : getCompletionTransitions(previousPhases, updated); + if (!readOnly && !failed) this.session.setTodoPhases?.(updated); const storage = this.session.getSessionFile() ? "session" : "memory"; - const details: TodoToolDetails = { phases: updated, storage }; + const details: TodoToolDetails = { phases: effective, storage }; if (completedTasks.length > 0) details.completedTasks = completedTasks; return { - content: [{ type: "text", text: formatSummary(updated, errors, readOnly) }], + content: [{ type: "text", text: formatSummary(effective, errors, readOnly) }], details, isError: errors.length > 0 ? true : undefined, }; diff --git a/packages/coding-agent/src/tools/write.ts b/packages/coding-agent/src/tools/write.ts index 6848060a2..5645a0126 100644 --- a/packages/coding-agent/src/tools/write.ts +++ b/packages/coding-agent/src/tools/write.ts @@ -25,6 +25,8 @@ import { parseArchivePathCandidates } from "./archive-reader"; import { assertEditableFile } from "./auto-generated-guard"; import { type ConflictEntry, + conflictRegionPresent, + conflictRegionsEqual, expandContentTokens, getConflictHistory, parseConflictUri, @@ -266,7 +268,14 @@ export class WriteTool implements AgentTool { const rawPath = (args as Partial).path; - return typeof rawPath === "string" && isInternalUrlPath(rawPath) ? "read" : "write"; + if (typeof rawPath !== "string" || !isInternalUrlPath(rawPath)) return "write"; + // Internal URLs are usually session-local artifacts (read tier), but a + // scheme whose handler exposes a `write` hook mutates handler-owned + // user data (e.g. vault:// notes, host-owned mcp:// URIs) and must take + // the write tier so always-ask mode actually prompts. + const match = /^([a-z][a-z0-9+.-]*):\/\//i.exec(rawPath.trim()); + const handler = match ? InternalUrlRouter.instance().getHandler(match[1]!.toLowerCase()) : undefined; + return handler?.write ? "write" : "read"; }; readonly formatApprovalDetails = (args: unknown): string[] => { const params = args as Partial; @@ -349,7 +358,18 @@ export class WriteTool implements AgentTool> { - const isZip = resolvedArchivePath.absolutePath.toLowerCase().endsWith(".zip"); + // Resolve symlinks before the tmp+rename swap: renaming over a symlink + // replaces the link itself with a regular file instead of writing + // through to its target. + const finalPath = resolvedArchivePath.exists + ? await fs.realpath(resolvedArchivePath.absolutePath).catch(() => resolvedArchivePath.absolutePath) + : resolvedArchivePath.absolutePath; + const lowerPath = finalPath.toLowerCase(); + const isZip = lowerPath.endsWith(".zip"); + const isGzip = lowerPath.endsWith(".tar.gz") || lowerPath.endsWith(".tgz"); + // Rewrites are whole-archive: write to a temp file and rename so a + // crash/disk-full mid-write can't destroy the original archive. + const tmpPath = `${finalPath}.tmp-${process.pid}`; const parentDir = path.dirname(resolvedArchivePath.absolutePath); if (parentDir && parentDir !== ".") { @@ -377,8 +397,10 @@ export class WriteTool implements AgentTool {}); throw new ToolError(error instanceof Error ? error.message : String(error)); } } else { @@ -406,8 +428,12 @@ export class WriteTool implements AgentTool {}); throw new ToolError(error instanceof Error ? error.message : String(error)); } } @@ -583,7 +609,24 @@ export class WriteTool implements AgentTool b.startLine - a.startLine); let text: string; + const resolvedEntries: ConflictEntry[] = []; + const staleEntries: ConflictEntry[] = []; + let failure: string | undefined; try { text = await Bun.file(absolutePath).text(); - for (const entry of fileEntries) { - const expanded = expandContentTokens(replacementContent, entry); - text = spliceConflict(text, entry, expanded); - } } catch (error) { failedFiles.push({ displayPath: sample.displayPath, @@ -704,15 +746,41 @@ export class WriteTool implements AgentTool conflictRegionsEqual(done, entry))) { + staleEntries.push(entry); + continue; + } + failure = error instanceof Error ? error.message : String(error); + break; + } + } + if (failure !== undefined) { + failedFiles.push({ + displayPath: sample.displayPath, + count: fileEntries.length, + error: failure, + }); + continue; + } const diagnostics = await this.#writethrough(absolutePath, text, signal, undefined, batchRequest); invalidateFsScanAfterWrite(absolutePath); this.session.bumpFileMutationVersion?.(absolutePath); this.session.fileSnapshotStore?.invalidate(absolutePath); - for (const entry of fileEntries) history.invalidate(entry.id); + for (const entry of resolvedEntries) history.invalidate(entry.id); + for (const entry of staleEntries) history.invalidate(entry.id); const header = maybeWriteSnapshotHeader(this.session, absolutePath, text); - succeededFiles.push({ displayPath: sample.displayPath, count: fileEntries.length, header }); - totalResolvedIds += fileEntries.length; + succeededFiles.push({ displayPath: sample.displayPath, count: resolvedEntries.length, header }); + totalResolvedIds += resolvedEntries.length; if (diagnostics) allDiagnostics.push(diagnostics); } @@ -751,7 +819,11 @@ export class WriteTool implements AgentTool 0 && succeededFiles.length === 0) { throw new ToolError(resultText); } - return { content: [{ type: "text", text: resultText }], details: {} }; + return { + content: [{ type: "text", text: resultText }], + details: {}, + isError: failedFiles.length > 0 ? true : undefined, + }; } const mergedSummary = allDiagnostics.map(d => d.summary).join("\n"); const mergedMessages = allDiagnostics.flatMap(d => d.messages ?? []); @@ -760,6 +832,7 @@ export class WriteTool implements AgentTool 0 ? true : undefined, }; } @@ -784,6 +857,9 @@ export class WriteTool implements AgentTool { expect(output).toContain("Use :1 to read from the start, or :3 to read the last line."); }); + it("should emit a binary notice instead of mojibake for files with NUL bytes", async () => { + const testFile = path.join(testDir, "blob.bin"); + fs.writeFileSync(testFile, Buffer.from([0x61, 0x62, 0x63, 0x00, 0xff, 0xfe, 0x64, 0x65])); + + const result = await readTool.execute("test-call-binary-nul", { path: testFile }); + const output = getTextOutput(result); + + expect(output).toContain("Cannot read binary file"); + expect(output).toContain("NUL bytes"); + }); + + it("should reject malformed internal-URL selectors instead of dumping the whole resource", async () => { + await expect(readTool.execute("test-call-bad-internal-sel", { path: "artifact://3:-100" })).rejects.toThrow( + /Invalid selector ':-100'/, + ); + }); + it("should include truncation details when truncated", async () => { const testFile = path.join(testDir, "large-file.txt"); const lines = Array.from({ length: 3500 }, (_, i) => `Line ${i + 1}`); @@ -719,6 +736,53 @@ describe("Coding Agent Tools", () => { }); } + it("should treat a selector-shaped archive subpath as a root listing selector", async () => { + const archivePath = path.join(testDir, "root-selector.tar"); + fs.writeFileSync( + archivePath, + createTarArchive([ + { path: "alpha.txt", content: "alpha\n" }, + { path: "beta.txt", content: "beta\n" }, + ]), + ); + + // Previously misparsed as a member named "2" and failed with a + // misleading "not found inside archive" error. The selector is honored + // as a 1-indexed listing offset, so `:2` starts at the second entry. + const result = await readTool.execute("test-call-archive-root-selector", { path: `${archivePath}:2` }); + const output = getTextOutput(result); + + expect(output).toContain("beta.txt"); + expect(output).not.toContain("alpha.txt"); + expect(result.details?.isDirectory).toBe(true); + }); + + it("should prefer an archive member over a selector-shaped name", async () => { + const archivePath = path.join(testDir, "member-precedence.tar"); + fs.writeFileSync(archivePath, createTarArchive([{ path: "raw", content: "member named raw\n" }])); + + const result = await readTool.execute("test-call-archive-member-raw", { path: `${archivePath}:raw` }); + const output = getTextOutput(result); + + expect(output).toContain("member named raw"); + }); + + it("should reject archive members larger than the in-memory extraction cap", async () => { + const archivePath = path.join(testDir, "bomb.zip"); + fs.writeFileSync( + archivePath, + createZipArchiveWithRawDeflateEntry({ + path: "bomb.bin", + compressed: Buffer.from([0xff, 0xff, 0xff, 0xff]), + originalSize: 3 * 1024 * 1024 * 1024, // 3GB declared, never allocated + }), + ); + + await expect(readTool.execute("test-call-archive-bomb", { path: `${archivePath}:bomb.bin` })).rejects.toThrow( + /too large to extract/i, + ); + }); + it("should detect image MIME type from file magic (not extension)", async () => { const png1x1Base64 = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+X2Z0AAAAASUVORK5CYII="; @@ -870,6 +934,36 @@ describe("Coding Agent Tools", () => { expect(await files.get("pkg/new.txt")?.text()).toBe(content); }); + it("should preserve gzip compression when writing into an existing .tar.gz", async () => { + const archivePath = path.join(testDir, "write-existing.tar.gz"); + fs.writeFileSync( + archivePath, + zlib.gzipSync( + createTarArchive([ + { path: "pkg/README.md", content: "# Original\n" }, + { path: "pkg/src/index.ts", content: "export const archiveValue = 1;\n" }, + ]), + ), + ); + + const content = "# Updated\nLine 2\n"; + await writeTool.execute("test-call-archive-write-targz", { + path: `${archivePath}:pkg/README.md`, + content, + }); + + const bytes = fs.readFileSync(archivePath); + // gzip magic must survive the rewrite (regression: archive was + // silently rewritten as a bare tar under the .gz name). + expect(bytes[0]).toBe(0x1f); + expect(bytes[1]).toBe(0x8b); + + const archive = new Bun.Archive(await Bun.file(archivePath).bytes()); + const files = await archive.files(); + expect(await files.get("pkg/README.md")?.text()).toBe(content); + expect(await files.get("pkg/src/index.ts")?.text()).toBe("export const archiveValue = 1;\n"); + }); + it("should treat a plain archive filename as a regular file write", async () => { const archivePath = path.join(testDir, "literal.zip"); const content = "plain file contents\n"; diff --git a/packages/coding-agent/test/tools/conflict-detect.test.ts b/packages/coding-agent/test/tools/conflict-detect.test.ts index ca8bb8264..e91181d62 100644 --- a/packages/coding-agent/test/tools/conflict-detect.test.ts +++ b/packages/coding-agent/test/tools/conflict-detect.test.ts @@ -94,6 +94,15 @@ describe("scanConflictLines", () => { expect(blocks[0].oursLabel).toBe("second"); expect(blocks[0].oursLines).toEqual(["good ours"]); }); + + it("detects conflicts in CRLF files and stores LF-normalized sections", () => { + const blocks = scanConflictLines(["<<<<<<< HEAD\r", "ours\r", "=======\r", "theirs\r", ">>>>>>> feat\r"], 1); + expect(blocks).toHaveLength(1); + expect(blocks[0].oursLabel).toBe("HEAD"); + expect(blocks[0].theirsLabel).toBe("feat"); + expect(blocks[0].oursLines).toEqual(["ours"]); + expect(blocks[0].theirsLines).toEqual(["theirs"]); + }); }); describe("ConflictHistory", () => { @@ -291,6 +300,20 @@ describe("spliceConflict", () => { it("rejects when the file is shorter than the recorded region", () => { expect(() => spliceConflict("short\n", entry, "x\n")).toThrow(/no longer present/); }); + + it("splices CRLF files and preserves CRLF line endings", () => { + const crlfFile = ["before", "<<<<<<< HEAD", "ours", "=======", "theirs", ">>>>>>> feat", "after", ""].join( + "\r\n", + ); + const result = spliceConflict(crlfFile, entry, "alpha\nbeta\n"); + expect(result).toBe("before\r\nalpha\r\nbeta\r\nafter\r\n"); + }); + + it("does not append \\r when the spliced region ends the file without a trailing newline", () => { + const crlfNoEof = ["before", "<<<<<<< HEAD", "ours", "=======", "theirs", ">>>>>>> feat"].join("\r\n"); + const result = spliceConflict(crlfNoEof, entry, "resolved"); + expect(result).toBe("before\r\nresolved"); + }); }); describe("renderConflictRegion", () => {