diff --git a/AGENTS.md b/AGENTS.md index 774ce44..ec8687c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -17,6 +17,17 @@ Project-specific testing rules live in `.agents/skills/testing-guide/SKILL.md`. Use that skill whenever writing, modifying, reviewing, or planning tests, closing coverage gaps, triaging test failures, or creating test helpers/harnesses. Keep detailed testing conventions there rather than growing this top-level orientation file. +## Client application URL convention + +- Build PI WEB-owned browser paths as application-relative references without a leading slash, for example `api/...` and `pi-web-plugins/...`. +- Encode every dynamic path segment with `encodeURIComponent`; encode query values, using `URLSearchParams` for multi-field queries. +- Resolve each reference exactly once at the browser boundary: ordinary JSON HTTP paths go to `request()`, direct browser APIs receive URLs from helpers backed by `resolveAppUrl()`, and WebSockets use `resolveAppWebSocketUrl()`. +- Name helpers returning unresolved application references with a `Path` suffix and helpers returning browser-ready absolute values with a `Url` suffix. +- Plugin module references must go through `resolvePluginModuleUrl()`. Its leading-slash handling is the documented rolling-compatibility exception; do not introduce other leading-root app references. +- Pre-JavaScript HTML assets use Vite `%BASE_URL%`; PWA manifest references stay `./`-relative. External links, data URLs, and module-relative plugin assets are not application paths. +- To assess deviations, search production client code for raw `fetch`, `WebSocket`, `XMLHttpRequest`, URL-bearing DOM attributes, and leading `/api` or `/pi-web-plugins` literals. Every app-owned result must follow one of the boundaries above. +- Published nested deployments require a canonical trailing slash; the reverse proxy must redirect a slashless prefix before serving the app. + ## Configuration conventions - `$PI_WEB_DATA_DIR` (`~/.pi-web` by default) contains PI WEB-managed state such as `projects.json` and `machines.json`; do not treat it as the user-editable config API. diff --git a/src/client/src/api/clients.test.ts b/src/client/src/api/clients.test.ts index 60a6dea..6146bbc 100644 --- a/src/client/src/api/clients.test.ts +++ b/src/client/src/api/clients.test.ts @@ -268,6 +268,17 @@ describe("machine-scoped file suggestion API", () => { }); }); +describe("machine-scoped workspace API", () => { + it("keeps project ids in one encoded route segment when listing workspaces", async () => { + const fetchMock = stubJsonFetch([]); + + await workspacesApi.workspaces("../p /?", "remote a"); + + expect(fetchMock).toHaveBeenCalledOnce(); + expect(fetchCall(fetchMock, 0)[0]).toBe("https://pi.example.test/api/machines/remote%20a/projects/..%2Fp%20%2F%3F/workspaces"); + }); +}); + describe("machine-scoped terminal command-run API", () => { it("deletes workspaces through the selected machine scope", async () => { const fetchMock = stubJsonFetch(commandRun); diff --git a/src/client/src/api/clients.ts b/src/client/src/api/clients.ts index 9b12e4f..35b9940 100644 --- a/src/client/src/api/clients.ts +++ b/src/client/src/api/clients.ts @@ -50,7 +50,7 @@ import { parseWorkspace, parseWorkspaceActivityResponse, } from "./parsers"; -import { machineGitDiffUrl, messageUrl } from "./urls"; +import { machineGitDiffPath, messagePath } from "./urls"; const machinePrefix = (machineId = "local") => `api/machines/${encodeURIComponent(machineId)}`; @@ -64,20 +64,20 @@ function sessionCwd(session: SessionLookup): string | undefined { return typeof session === "string" ? undefined : session.cwd; } -function sessionBaseUrl(session: SessionLookup, machineId = "local"): string { +function sessionBasePath(session: SessionLookup, machineId = "local"): string { return `${machinePrefix(machineId)}/sessions/${encodeURIComponent(sessionId(session))}`; } -function sessionUrl(session: SessionLookup, endpoint: string, machineId = "local"): string { - return `${sessionBaseUrl(session, machineId)}/${endpoint}`; +function sessionPath(session: SessionLookup, endpoint: string, machineId = "local"): string { + return `${sessionBasePath(session, machineId)}/${endpoint}`; } -function sessionQueryUrl(session: SessionLookup, endpoint: string, machineId = "local"): string { - return `${sessionUrl(session, endpoint, machineId)}${sessionQuery(session)}`; +function sessionQueryPath(session: SessionLookup, endpoint: string, machineId = "local"): string { + return `${sessionPath(session, endpoint, machineId)}${sessionQuery(session)}`; } -function sessionBaseQueryUrl(session: SessionLookup, machineId = "local"): string { - return `${sessionBaseUrl(session, machineId)}${sessionQuery(session)}`; +function sessionBaseQueryPath(session: SessionLookup, machineId = "local"): string { + return `${sessionBasePath(session, machineId)}${sessionQuery(session)}`; } function sessionQuery(session: SessionLookup): string { @@ -100,13 +100,13 @@ function sessionBulkMutationRef(session: SessionLookup): SessionBulkMutationRef return cwd === undefined || cwd === "" ? { id } : { id, cwd }; } -function piWebStatusUrl(machineId: string): string { +function piWebStatusPath(machineId: string): string { return machineId === "local" ? "api/pi-web/status" : `${machinePrefix(machineId)}/pi-web/status`; } export const piWebApi = { - piWebStatus: (machineId = "local") => request(piWebStatusUrl(machineId), parsePiWebStatusResponse), - checkForUpdates: (machineId = "local") => request(`${piWebStatusUrl(machineId)}?refresh=1`, parsePiWebStatusResponse, { cache: "no-store" }), + piWebStatus: (machineId = "local") => request(piWebStatusPath(machineId), parsePiWebStatusResponse), + checkForUpdates: (machineId = "local") => request(`${piWebStatusPath(machineId)}?refresh=1`, parsePiWebStatusResponse, { cache: "no-store" }), piWebRuntime: () => request("api/pi-web/runtime", parsePiWebRuntimeResponse), }; @@ -118,41 +118,41 @@ export const machinesApi = { runtime: (machineId: string) => request(`api/machines/${encodeURIComponent(machineId)}/runtime`, parseMachineRuntime), }; -function configUrl(machineId?: string): string { +function configPath(machineId?: string): string { return machineId === undefined ? "api/config" : `${machinePrefix(machineId)}/config`; } -function pluginsUrl(machineId?: string): string { +function pluginsPath(machineId?: string): string { return machineId === undefined ? "api/plugins" : `${machinePrefix(machineId)}/plugins`; } export const configApi = { - config: (machineId?: string) => request(configUrl(machineId), parsePiWebConfigResponse), - saveConfig: (config: PiWebConfigValues, machineId?: string) => request(configUrl(machineId), parsePiWebConfigResponse, { method: "PUT", body: JSON.stringify({ config }) }), + config: (machineId?: string) => request(configPath(machineId), parsePiWebConfigResponse), + saveConfig: (config: PiWebConfigValues, machineId?: string) => request(configPath(machineId), parsePiWebConfigResponse, { method: "PUT", body: JSON.stringify({ config }) }), }; export const pluginsApi = { - plugins: (machineId?: string) => request(pluginsUrl(machineId), parsePiWebPluginsResponse), + plugins: (machineId?: string) => request(pluginsPath(machineId), parsePiWebPluginsResponse), }; -function piPackageUrl(endpoint = "", machineId?: string): string { - const baseUrl = machineId === undefined ? "api/pi-packages" : `${machinePrefix(machineId)}/pi-packages`; - return endpoint === "" ? baseUrl : `${baseUrl}/${endpoint}`; +function piPackagePath(endpoint = "", machineId?: string): string { + const basePath = machineId === undefined ? "api/pi-packages" : `${machinePrefix(machineId)}/pi-packages`; + return endpoint === "" ? basePath : `${basePath}/${endpoint}`; } export const piPackagesApi = { - packages: (machineId?: string) => request(piPackageUrl("", machineId), parsePiPackagesResponse), + packages: (machineId?: string) => request(piPackagePath("", machineId), parsePiPackagesResponse), install: (source: string, machineId?: string) => { const body: PiPackageInstallRequest = { source }; - return request(piPackageUrl("install", machineId), parsePiPackageMutationResponse, { method: "POST", body: JSON.stringify(body) }); + return request(piPackagePath("install", machineId), parsePiPackageMutationResponse, { method: "POST", body: JSON.stringify(body) }); }, remove: (source: string, scope?: PiPackageScope, machineId?: string) => { const body: PiPackageRemoveRequest = scope === undefined ? { source } : { source, scope }; - return request(piPackageUrl("remove", machineId), parsePiPackageMutationResponse, { method: "POST", body: JSON.stringify(body) }); + return request(piPackagePath("remove", machineId), parsePiPackageMutationResponse, { method: "POST", body: JSON.stringify(body) }); }, update: (source?: string, machineId?: string) => { const body: PiPackageUpdateRequest | undefined = source === undefined ? undefined : { source }; - return request(piPackageUrl("update", machineId), parsePiPackageMutationResponse, { method: "POST", ...(body === undefined ? {} : { body: JSON.stringify(body) }) }); + return request(piPackagePath("update", machineId), parsePiPackageMutationResponse, { method: "POST", ...(body === undefined ? {} : { body: JSON.stringify(body) }) }); }, }; @@ -168,7 +168,7 @@ export const projectsApi = { }; export const workspacesApi = { - workspaces: (projectId: string, machineId = "local") => request(`${machinePrefix(machineId)}/projects/${projectId}/workspaces`, arrayOf(parseWorkspace)), + workspaces: (projectId: string, machineId = "local") => request(`${machinePrefix(machineId)}/projects/${encodeURIComponent(projectId)}/workspaces`, arrayOf(parseWorkspace)), deleteWorkspace: (projectId: string, workspaceId: string, machineId = "local") => request(`${machinePrefix(machineId)}/projects/${encodeURIComponent(projectId)}/workspaces/${encodeURIComponent(workspaceId)}`, parseTerminalCommandRun, { method: "DELETE" }), workspaceTree: (projectId: string, workspaceId: string, path = "", machineId = "local") => request(`${machinePrefix(machineId)}/projects/${encodeURIComponent(projectId)}/workspaces/${encodeURIComponent(workspaceId)}/tree?path=${encodeURIComponent(path)}`, parseFileTreeResponse), workspaceFile: (projectId: string, workspaceId: string, path: string, machineId = "local") => request(`${machinePrefix(machineId)}/projects/${encodeURIComponent(projectId)}/workspaces/${encodeURIComponent(workspaceId)}/file?path=${encodeURIComponent(path)}`, parseFileContentResponse), @@ -207,28 +207,28 @@ export const sessionsApi = { cleanup: (input: SessionCleanupRequest, machineId = "local") => request(`${machinePrefix(machineId)}/sessions/cleanup`, parseSessionCleanupExecuteResponse, { method: "POST", body: JSON.stringify(input) }), archiveMany: (sessions: readonly SessionLookup[], machineId = "local") => request(`${machinePrefix(machineId)}/sessions/bulk/archive`, parseSessionBulkArchiveResponse, { method: "POST", body: sessionBulkMutationBody(sessions) }), deleteArchivedMany: (sessions: readonly SessionLookup[], machineId = "local") => request(`${machinePrefix(machineId)}/sessions/bulk/delete-archived`, parseSessionBulkDeleteArchivedResponse, { method: "POST", body: sessionBulkMutationBody(sessions) }), - messages: (session: SessionLookup, options?: { limit?: number; before?: number }, machineId = "local") => request(messageUrl(session, options, machineId), parseMessagePage), - status: (session: SessionLookup, machineId = "local") => request(sessionQueryUrl(session, "status", machineId), parseSessionStatus), - models: (session: SessionLookup, machineId = "local") => request(sessionQueryUrl(session, "models", machineId), parseModelSelectionResponse), - setModel: (session: SessionLookup, provider: string, modelId: string, machineId = "local") => request(sessionUrl(session, "model", machineId), parseSessionStatus, { method: "POST", body: sessionBody(session, { provider, modelId }) }), - cycleModel: (session: SessionLookup, direction: "forward" | "backward", machineId = "local") => request(sessionUrl(session, "model/cycle", machineId), parseSessionStatus, { method: "POST", body: sessionBody(session, { direction }) }), - thinkingLevels: (session: SessionLookup, machineId = "local") => request(sessionQueryUrl(session, "thinking-levels", machineId), parseThinkingLevelsResponse), - setThinkingLevel: (session: SessionLookup, level: string, machineId = "local") => request(sessionUrl(session, "thinking-level", machineId), parseSessionStatus, { method: "POST", body: sessionBody(session, { level }) }), - cycleThinkingLevel: (session: SessionLookup, machineId = "local") => request(sessionUrl(session, "thinking-level/cycle", machineId), parseSessionStatus, { method: "POST", body: sessionBody(session) }), - commands: (session: SessionLookup, machineId = "local") => request(sessionQueryUrl(session, "commands", machineId), arrayOf(parseSlashCommand)), - prompt: (session: SessionLookup, text: string, streamingBehavior?: "steer" | "followUp", machineId = "local", attachments?: PromptAttachment[]) => request(sessionUrl(session, "prompt", machineId), parseAccepted, { method: "POST", body: sessionBody(session, { text, ...(streamingBehavior === undefined ? {} : { streamingBehavior }), ...(attachments !== undefined && attachments.length > 0 ? { attachments } : {}) }) }), - saveAttachments: (session: SessionLookup, attachments: PromptAttachment[], machineId = "local", folder?: string) => request(sessionUrl(session, "attachments", machineId), parseSavedAttachments, { method: "POST", body: sessionBody(session, { attachments, ...(folder === undefined ? {} : { folder }) }) }), - shell: (session: SessionLookup, text: string, machineId = "local") => request(sessionUrl(session, "shell", machineId), parseAccepted, { method: "POST", body: sessionBody(session, { text }) }), - runCommand: (session: SessionLookup, text: string, machineId = "local") => request(sessionUrl(session, "commands/run", machineId), parseCommandResult, { method: "POST", body: sessionBody(session, { text }) }), - respondToCommand: (session: SessionLookup, requestId: string, value: string, machineId = "local") => request(sessionUrl(session, "commands/respond", machineId), parseCommandResult, { method: "POST", body: sessionBody(session, { requestId, value }) }), - abort: (session: SessionLookup, machineId = "local") => request(sessionUrl(session, "abort", machineId), parseAborted, { method: "POST", body: sessionBody(session) }), - stop: (session: SessionLookup, machineId = "local") => request(sessionUrl(session, "stop", machineId), parseStopped, { method: "POST", body: sessionBody(session) }), - archive: (session: SessionLookup, machineId = "local") => request(sessionUrl(session, "archive", machineId), parseArchived, { method: "POST", body: sessionBody(session) }), - archiveWithDescendants: (session: SessionLookup, machineId = "local") => request(sessionUrl(session, "archive-tree", machineId), parseArchived, { method: "POST", body: sessionBody(session) }), - restore: (session: SessionLookup, machineId = "local") => request(sessionUrl(session, "restore", machineId), parseRestored, { method: "POST", body: sessionBody(session) }), - deleteArchived: (session: SessionLookup, machineId = "local") => request(sessionBaseQueryUrl(session, machineId), parseDeleted, { method: "DELETE" }), - detachParent: (session: SessionLookup, machineId = "local") => request(sessionUrl(session, "detach-parent", machineId), parseDetached, { method: "POST", body: sessionBody(session) }), - reloadSession: (session: SessionLookup, machineId = "local") => request(sessionUrl(session, "reload", machineId), parseReloaded, { method: "POST", body: sessionBody(session) }), + messages: (session: SessionLookup, options?: { limit?: number; before?: number }, machineId = "local") => request(messagePath(session, options, machineId), parseMessagePage), + status: (session: SessionLookup, machineId = "local") => request(sessionQueryPath(session, "status", machineId), parseSessionStatus), + models: (session: SessionLookup, machineId = "local") => request(sessionQueryPath(session, "models", machineId), parseModelSelectionResponse), + setModel: (session: SessionLookup, provider: string, modelId: string, machineId = "local") => request(sessionPath(session, "model", machineId), parseSessionStatus, { method: "POST", body: sessionBody(session, { provider, modelId }) }), + cycleModel: (session: SessionLookup, direction: "forward" | "backward", machineId = "local") => request(sessionPath(session, "model/cycle", machineId), parseSessionStatus, { method: "POST", body: sessionBody(session, { direction }) }), + thinkingLevels: (session: SessionLookup, machineId = "local") => request(sessionQueryPath(session, "thinking-levels", machineId), parseThinkingLevelsResponse), + setThinkingLevel: (session: SessionLookup, level: string, machineId = "local") => request(sessionPath(session, "thinking-level", machineId), parseSessionStatus, { method: "POST", body: sessionBody(session, { level }) }), + cycleThinkingLevel: (session: SessionLookup, machineId = "local") => request(sessionPath(session, "thinking-level/cycle", machineId), parseSessionStatus, { method: "POST", body: sessionBody(session) }), + commands: (session: SessionLookup, machineId = "local") => request(sessionQueryPath(session, "commands", machineId), arrayOf(parseSlashCommand)), + prompt: (session: SessionLookup, text: string, streamingBehavior?: "steer" | "followUp", machineId = "local", attachments?: PromptAttachment[]) => request(sessionPath(session, "prompt", machineId), parseAccepted, { method: "POST", body: sessionBody(session, { text, ...(streamingBehavior === undefined ? {} : { streamingBehavior }), ...(attachments !== undefined && attachments.length > 0 ? { attachments } : {}) }) }), + saveAttachments: (session: SessionLookup, attachments: PromptAttachment[], machineId = "local", folder?: string) => request(sessionPath(session, "attachments", machineId), parseSavedAttachments, { method: "POST", body: sessionBody(session, { attachments, ...(folder === undefined ? {} : { folder }) }) }), + shell: (session: SessionLookup, text: string, machineId = "local") => request(sessionPath(session, "shell", machineId), parseAccepted, { method: "POST", body: sessionBody(session, { text }) }), + runCommand: (session: SessionLookup, text: string, machineId = "local") => request(sessionPath(session, "commands/run", machineId), parseCommandResult, { method: "POST", body: sessionBody(session, { text }) }), + respondToCommand: (session: SessionLookup, requestId: string, value: string, machineId = "local") => request(sessionPath(session, "commands/respond", machineId), parseCommandResult, { method: "POST", body: sessionBody(session, { requestId, value }) }), + abort: (session: SessionLookup, machineId = "local") => request(sessionPath(session, "abort", machineId), parseAborted, { method: "POST", body: sessionBody(session) }), + stop: (session: SessionLookup, machineId = "local") => request(sessionPath(session, "stop", machineId), parseStopped, { method: "POST", body: sessionBody(session) }), + archive: (session: SessionLookup, machineId = "local") => request(sessionPath(session, "archive", machineId), parseArchived, { method: "POST", body: sessionBody(session) }), + archiveWithDescendants: (session: SessionLookup, machineId = "local") => request(sessionPath(session, "archive-tree", machineId), parseArchived, { method: "POST", body: sessionBody(session) }), + restore: (session: SessionLookup, machineId = "local") => request(sessionPath(session, "restore", machineId), parseRestored, { method: "POST", body: sessionBody(session) }), + deleteArchived: (session: SessionLookup, machineId = "local") => request(sessionBaseQueryPath(session, machineId), parseDeleted, { method: "DELETE" }), + detachParent: (session: SessionLookup, machineId = "local") => request(sessionPath(session, "detach-parent", machineId), parseDetached, { method: "POST", body: sessionBody(session) }), + reloadSession: (session: SessionLookup, machineId = "local") => request(sessionPath(session, "reload", machineId), parseReloaded, { method: "POST", body: sessionBody(session) }), authProviders: (options?: { mode?: "login" | "logout"; authType?: "oauth" | "api_key"; machineId?: string }) => { const params = new URLSearchParams(); if (options?.mode !== undefined) params.set("mode", options.mode); @@ -314,7 +314,7 @@ export const filesApi = { export const gitApi = { gitStatus: (projectId: string, workspaceId: string, machineId = "local") => request(`${machinePrefix(machineId)}/projects/${encodeURIComponent(projectId)}/workspaces/${encodeURIComponent(workspaceId)}/git/status`, parseGitStatusResponse), - gitDiff: (projectId: string, workspaceId: string, options?: { path?: string; staged?: boolean }, machineId = "local") => request(machineGitDiffUrl(machineId, projectId, workspaceId, options), parseGitDiffResponse), + gitDiff: (projectId: string, workspaceId: string, options?: { path?: string; staged?: boolean }, machineId = "local") => request(machineGitDiffPath(machineId, projectId, workspaceId, options), parseGitDiffResponse), }; export const api = { diff --git a/src/client/src/api/sockets.ts b/src/client/src/api/sockets.ts index aa917e9..54336fe 100644 --- a/src/client/src/api/sockets.ts +++ b/src/client/src/api/sockets.ts @@ -15,7 +15,7 @@ export function globalSessionEvents(machineId = "local"): WebSocket { } export function terminalSocket(projectId: string, workspaceId: string, terminalId: string, initialSize?: { cols: number; rows: number }, machineId = "local"): WebSocket { - const sizeQuery = initialSize === undefined ? "" : `?cols=${encodeURIComponent(String(initialSize.cols))}&rows=${encodeURIComponent(String(initialSize.rows))}`; + const sizeQuery = initialSize === undefined ? "" : `?${new URLSearchParams({ cols: String(initialSize.cols), rows: String(initialSize.rows) }).toString()}`; return new WebSocket(resolveAppWebSocketUrl(`${machinePrefix(machineId)}/projects/${encodeURIComponent(projectId)}/workspaces/${encodeURIComponent(workspaceId)}/terminals/${encodeURIComponent(terminalId)}/socket${sizeQuery}`)); } diff --git a/src/client/src/api/urls.ts b/src/client/src/api/urls.ts index b84aa88..d5e0c93 100644 --- a/src/client/src/api/urls.ts +++ b/src/client/src/api/urls.ts @@ -11,22 +11,22 @@ function sessionCwd(session: SessionLookup): string | undefined { return typeof session === "string" ? undefined : session.cwd; } -export function machineGitDiffUrl(machineId: string, projectId: string, workspaceId: string, options?: { path?: string; staged?: boolean }): string { +export function machineGitDiffPath(machineId: string, projectId: string, workspaceId: string, options?: { path?: string; staged?: boolean }): string { const params = new URLSearchParams(); if (options?.path !== undefined) params.set("path", options.path); if (options?.staged === true) params.set("staged", "true"); const query = params.toString(); - return resolveAppUrl(`api/machines/${encodeURIComponent(machineId)}/projects/${encodeURIComponent(projectId)}/workspaces/${encodeURIComponent(workspaceId)}/git/diff${query ? `?${query}` : ""}`); + return `api/machines/${encodeURIComponent(machineId)}/projects/${encodeURIComponent(projectId)}/workspaces/${encodeURIComponent(workspaceId)}/git/diff${query ? `?${query}` : ""}`; } -export function messageUrl(session: SessionLookup, options?: { limit?: number; before?: number }, machineId = "local"): string { +export function messagePath(session: SessionLookup, options?: { limit?: number; before?: number }, machineId = "local"): string { const params = new URLSearchParams(); const cwd = sessionCwd(session); if (cwd !== undefined && cwd !== "") params.set("cwd", cwd); if (options?.limit !== undefined) params.set("limit", String(options.limit)); if (options?.before !== undefined) params.set("before", String(options.before)); const query = params.toString(); - return resolveAppUrl(`api/machines/${encodeURIComponent(machineId)}/sessions/${encodeURIComponent(sessionId(session))}/messages${query === "" ? "" : `?${query}`}`); + return `api/machines/${encodeURIComponent(machineId)}/sessions/${encodeURIComponent(sessionId(session))}/messages${query === "" ? "" : `?${query}`}`; } export function workspaceFileWriteUrl(projectId: string, workspaceId: string, path: string, options?: { createDirs?: boolean; overwrite?: boolean; machineId?: string }): string { diff --git a/src/client/src/appUrl.ts b/src/client/src/appUrl.ts index 72d6c36..4df92d3 100644 --- a/src/client/src/appUrl.ts +++ b/src/client/src/appUrl.ts @@ -3,6 +3,13 @@ export interface AppUrlContext { documentBaseUrl: string; } +/** + * Resolve a PI WEB-owned reference at a browser boundary. + * + * Core callers keep paths application-relative (no leading slash), encode every dynamic path segment, + * and resolve exactly once. Leading slashes are accepted only for existing plugin-manifest compatibility + * and mean the application root rather than the origin root. + */ export function resolveAppUrl(path: string, context: AppUrlContext = browserAppUrlContext()): string { const applicationBaseUrl = new URL(context.viteBaseUrl, context.documentBaseUrl); return new URL(appRelativePath(path), applicationBaseUrl).toString();