diff --git a/docs/desktop-application-capture.md b/docs/desktop-application-capture.md index dc6c8bf4b1..6a72342ea6 100644 --- a/docs/desktop-application-capture.md +++ b/docs/desktop-application-capture.md @@ -47,6 +47,10 @@ computer would not have that entry. An `--executable` runs from the current directory, as it would from a shell, and a relative path to it is resolved there. +Desktop-entry string values retain literal trailing spaces. For example, +`Path=/tmp/workspace ` and `Path=/tmp/workspace\s` name the same directory +ending in a space. Use the escaped spelling to make such a suffix visible. + Before writing anything the command refuses to start if a window of the application is already open, or if the window list cannot be read. A launch beside a running instance can hand off to it, and that instance's unsaved diff --git a/src/domain/desktop/desktopEntry.ts b/src/domain/desktop/desktopEntry.ts index 4733c1782e..57318866a7 100644 --- a/src/domain/desktop/desktopEntry.ts +++ b/src/domain/desktop/desktopEntry.ts @@ -93,11 +93,18 @@ export const parseDesktopEntry = ( found ||= group === "Desktop Entry"; continue; } - const entry = ENTRY.exec(line); + // Full-line trimming is syntax-only: string-value suffixes are data. + const entry = ENTRY.exec(raw.trimStart()); if (group !== "Desktop Entry" || entry?.groups === undefined) continue; const { key, value } = entry.groups; if (key !== undefined && value !== undefined && !values.has(key)) - values.set(key, unescapeValue(value)); + // Keep the existing enum/boolean token treatment before value unescaping. + values.set( + key, + unescapeValue( + key === "Type" || key === "Terminal" ? value.trimEnd() : value, + ), + ); } if (!found) return err("missing_desktop_entry_group"); const field = (key: string) => values.get(key) ?? null; diff --git a/src/domain/desktop/desktopEntryValues.test.ts b/src/domain/desktop/desktopEntryValues.test.ts new file mode 100644 index 0000000000..d4fafe44a6 --- /dev/null +++ b/src/domain/desktop/desktopEntryValues.test.ts @@ -0,0 +1,84 @@ +import { expect, it } from "vitest"; + +import { + escapeDesktopEntryValue, + expandDesktopExec, + parseDesktopEntry, + tokenizeDesktopExec, +} from "./desktopEntry.js"; + +it.each([ + ["/tmp/workspace ", "/tmp/workspace "], + ["/tmp/workspace ", "/tmp/workspace "], + ["/tmp/workspace\\s", "/tmp/workspace "], + ["/tmp/plain-workspace", "/tmp/plain-workspace"], +])("preserves the desktop Path value %j", (wire, path) => { + const parsed = parseDesktopEntry( + `[Desktop Entry]\nType=Application\nPath=${wire}\n`, + ); + if (!parsed.ok) throw new Error(parsed.error); + expect(parsed.value.path).toBe(path); +}); + +it.each([ + ["Name", "name", "Notes "], + ["TryExec", "try_exec", "/opt/program "], + ["Icon", "icon", "/icons/icon.svg "], + ["StartupWMClass", "startup_wm_class", "App class "], +] as const)( + "preserves the same string-value boundary for %s", + (key, field, value) => { + const parsed = parseDesktopEntry(`[Desktop Entry]\n${key}=${value}\n`); + if (!parsed.ok) throw new Error(parsed.error); + expect(parsed.value[field]).toBe(value); + }, +); + +it("round-trips values emitted by the existing desktop-entry string formatter", () => { + for (const value of ["/tmp/workspace ", " /tmp/workspace ", "/tmp/plain"]) { + const parsed = parseDesktopEntry( + `[Desktop Entry]\nPath=${escapeDesktopEntryValue(value)}\n`, + ); + if (!parsed.ok) throw new Error(parsed.error); + expect(parsed.value.path).toBe(value); + } +}); + +it("preserves Name suffixes through the existing single-argument field expansion", () => { + const parsed = parseDesktopEntry( + "[Desktop Entry]\nName=My application \nExec=notes --title=%c\n", + ); + if (!parsed.ok) throw new Error(parsed.error); + const tokens = tokenizeDesktopExec(parsed.value.exec ?? ""); + if (!tokens.ok) throw new Error(tokens.error); + expect( + expandDesktopExec( + tokens.value, + { name: parsed.value.name, icon: null, location: null }, + [], + ), + ).toEqual(["notes", "--title=My application "]); +}); + +it("keeps existing Type and Terminal token normalization and command separators", () => { + const parsed = parseDesktopEntry( + ' [Desktop Entry] \r\n Type = Application \r\n Terminal = true \r\n Exec = notes --label "two words" \r\nName[fr]=Other\r\n[Other]\r\nName=Not selected\r\n', + ); + if (!parsed.ok) throw new Error(parsed.error); + expect(parsed.value).toMatchObject({ + type: "Application", + terminal: true, + name: null, + }); + const tokens = tokenizeDesktopExec(parsed.value.exec ?? ""); + if (!tokens.ok) throw new Error(tokens.error); + expect(tokens.value).toEqual(["notes", "--label", "two words"]); +}); + +it("does not broaden Type or Terminal recognition by trimming decoded escapes", () => { + const parsed = parseDesktopEntry( + "[Desktop Entry]\nType=Application\\s\nTerminal=true\\s\n", + ); + if (!parsed.ok) throw new Error(parsed.error); + expect(parsed.value).toMatchObject({ type: "Application ", terminal: false }); +}); diff --git a/src/domain/javascript/javascriptSemanticDataEffectHelpers.ts b/src/domain/javascript/javascriptSemanticDataEffectHelpers.ts index 77a932774c..c0757a8c61 100644 --- a/src/domain/javascript/javascriptSemanticDataEffectHelpers.ts +++ b/src/domain/javascript/javascriptSemanticDataEffectHelpers.ts @@ -161,16 +161,6 @@ export interface DataEffectTraversalContext { readonly ancestors: t.Node[]; } -/** Resolve built-in methods from the shared, unambiguous binding provenance. */ -export const builtinDataEffectMethod = ( - callee: t.Node, - state: JavaScriptSemanticAnalysisState, - specifiers: readonly string[], -): string | null => { - const path = builtinDataEffectCallablePath(callee, state, specifiers); - return path?.length === 1 ? (path[0] ?? null) : null; -}; - /** Recover the complete callable export path using the shared mutation proof. */ export const builtinDataEffectCallablePath = ( callee: t.Node, diff --git a/tests/boundary/mcp/sourcePublicationComposed.test.ts b/tests/boundary/mcp/sourcePublicationComposed.test.ts index 898ac4a21f..21581b0a91 100644 --- a/tests/boundary/mcp/sourcePublicationComposed.test.ts +++ b/tests/boundary/mcp/sourcePublicationComposed.test.ts @@ -98,6 +98,59 @@ it("preserves the source when an inline result with unknown retention exceeds de expect(boundedSession.exportEvidenceBundle().records).toEqual([]); }); +it("preserves both identities and distinct retention when neither payload fits the original ten-KiB reply", async () => { + const maximumResponseBytes = 10 * 1024; + const completed = compareApplicationVersionsEvidence( + JAVASCRIPT_APPLICATION_VERSION_COMPARISON_EXAMPLE, + ); + if (!completed.ok) throw completed.error; + const session = new BinarySession( + createTestProviderRouter(() => { + throw new Error("no provider needed"); + }), + new InvestigationRecords({ maximumRepresentationBytes: 0 }), + ); + const { client } = await createApplicationMcpHarness({ + session, + serverOptions: { delivery: createToolResultDelivery(maximumResponseBytes) }, + }); + const response = await client.callTool({ + name: "compare_application_versions", + arguments: JAVASCRIPT_APPLICATION_VERSION_COMPARISON_EXAMPLE, + }); + const error = parseMcpToolError(response).error; + expect(error.remediation.action).toContain( + recoveryOmissionRemediation("unknown"), + ); + expect(error.remediation.action).toContain( + recoveryOmissionRemediation("unretained"), + ); + expect(error).toMatchObject({ + code: "resource_constraint", + details: { + partial_observation_omitted: { + reason: "mcp-response-limit", + retention: "unretained", + constraint: "receive-buffer", + evidence_id: + JAVASCRIPT_APPLICATION_VERSION_COMPARISON_EXAMPLE.left.evidence_id, + }, + completed_observation_omitted: { + reason: "mcp-response-limit", + retention: "unknown", + evidence_id: completed.value.evidence.evidence_id, + source_closure: "not-established", + }, + }, + }); + expect(error.details).not.toHaveProperty("partial_observation"); + expect(error.details).not.toHaveProperty("completed_observation"); + expect(Buffer.byteLength(JSON.stringify(response))).toBeLessThanOrEqual( + maximumResponseBytes, + ); + expect(session.exportEvidenceBundle().records).toEqual([]); +}); + it("does not infer ledger membership from an independently registered reader", async () => { const computed = compareApplicationVersionsEvidence( JAVASCRIPT_APPLICATION_VERSION_COMPARISON_EXAMPLE,