feat: add host-local custom project icons (#2335)
* feat(projects): add local icons and colors * refactor(projects): simplify appearance handling * fix(projects): serialize appearance saves * test(app): update project appearance expectations * test: fix project appearance CI expectations * refactor(projects): organize appearance code * fix(server): adopt main's reconcile model in project appearance updates * fix(projects): preserve host-local appearance targets * fix(desktop): restore Linux development startup Ubuntu can block Electron's unprivileged user-namespace fallback while the npm-installed sandbox helper is not SUID. Disable the Chromium process sandbox only in the Linux development runner so packaged desktop policy remains unchanged. * feat(projects): add custom project icons Keep URL acquisition in the client so the host daemon only validates uploaded bytes. Project name and icon changes share one adaptive edit sheet. * refactor(settings): scope projects by host --------- Co-authored-by: Mohamed Boudra <boudra.moha@gmail.com>
This commit is contained in:
co-authored by
Mohamed Boudra
parent
706ae3d9bb
commit
800bd2bef1
@@ -40,6 +40,19 @@ function fetchWorkspacesResponse(workspace: Record<string, unknown>) {
|
||||
};
|
||||
}
|
||||
|
||||
describe("project icon message security", () => {
|
||||
test("rejects URL sources at the daemon boundary", () => {
|
||||
const parsed = SessionInboundMessageSchema.safeParse({
|
||||
type: "project.icon.set.request",
|
||||
projectId: "project-1",
|
||||
source: { type: "url", url: "http://127.0.0.1/private" },
|
||||
requestId: "request-1",
|
||||
});
|
||||
|
||||
expect(parsed.success).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("workspace descriptor message compatibility", () => {
|
||||
test("old-shaped fetch_workspaces_response without project still parses", () => {
|
||||
const parsed = SessionOutboundMessageSchema.parse(
|
||||
|
||||
@@ -813,6 +813,13 @@ export const UpdateAgentRequestMessageSchema = z.object({
|
||||
requestId: z.string(),
|
||||
});
|
||||
|
||||
// The daemon accepts only image bytes chosen or acquired by the client. It must
|
||||
// never fetch a user-provided URL on the host's network.
|
||||
export const ProjectIconSourceSchema = z.discriminatedUnion("type", [
|
||||
z.object({ type: z.literal("automatic") }),
|
||||
z.object({ type: z.literal("upload"), data: z.string() }),
|
||||
]);
|
||||
|
||||
export const ProjectRenameRequestSchema = z.object({
|
||||
type: z.literal("project.rename.request"),
|
||||
projectId: z.string(),
|
||||
@@ -821,6 +828,13 @@ export const ProjectRenameRequestSchema = z.object({
|
||||
requestId: z.string(),
|
||||
});
|
||||
|
||||
export const ProjectIconSetRequestSchema = z.object({
|
||||
type: z.literal("project.icon.set.request"),
|
||||
projectId: z.string(),
|
||||
source: ProjectIconSourceSchema,
|
||||
requestId: z.string(),
|
||||
});
|
||||
|
||||
export const ProjectRemoveRequestSchema = z.object({
|
||||
type: z.literal("project.remove.request"),
|
||||
projectId: z.string(),
|
||||
@@ -1515,6 +1529,16 @@ export const ProjectRenameResponseSchema = z.object({
|
||||
payload: ProjectRenameResponsePayloadSchema,
|
||||
});
|
||||
|
||||
export const ProjectIconSetResponseSchema = z.object({
|
||||
type: z.literal("project.icon.set.response"),
|
||||
payload: z.object({
|
||||
requestId: z.string(),
|
||||
projectId: z.string(),
|
||||
accepted: z.boolean(),
|
||||
error: z.string().nullable(),
|
||||
}),
|
||||
});
|
||||
|
||||
export const ProjectRemoveResponsePayloadSchema = z.object({
|
||||
requestId: z.string(),
|
||||
projectId: z.string(),
|
||||
@@ -2209,6 +2233,12 @@ export const ProjectIconRequestSchema = z.object({
|
||||
requestId: z.string(),
|
||||
});
|
||||
|
||||
export const ProjectIconGetRequestSchema = z.object({
|
||||
type: z.literal("project.icon.get.request"),
|
||||
projectId: z.string(),
|
||||
requestId: z.string(),
|
||||
});
|
||||
|
||||
export const FileDownloadTokenRequestSchema = z.object({
|
||||
type: z.literal("file_download_token_request"),
|
||||
cwd: z.string(),
|
||||
@@ -2457,6 +2487,7 @@ export const SessionInboundMessageSchema = z.discriminatedUnion("type", [
|
||||
CloseItemsRequestMessageSchema,
|
||||
UpdateAgentRequestMessageSchema,
|
||||
ProjectRenameRequestSchema,
|
||||
ProjectIconSetRequestSchema,
|
||||
ProjectRemoveRequestSchema,
|
||||
WorkspaceTitleSetRequestSchema,
|
||||
WorkspacePinSetRequestSchema,
|
||||
@@ -2555,6 +2586,7 @@ export const SessionInboundMessageSchema = z.discriminatedUnion("type", [
|
||||
FileUnsubscribeRequestSchema,
|
||||
FileWriteRequestSchema,
|
||||
ProjectIconRequestSchema,
|
||||
ProjectIconGetRequestSchema,
|
||||
FileDownloadTokenRequestSchema,
|
||||
FileUploadRequestSchema,
|
||||
ClearAgentAttentionMessageSchema,
|
||||
@@ -2840,6 +2872,8 @@ export const ServerInfoStatusPayloadSchema = z
|
||||
stableProjectIdentity: z.boolean().optional(),
|
||||
// COMPAT(workspaceScriptManagement): added in v0.1.105, remove gate after 2027-01-10.
|
||||
workspaceScriptManagement: z.boolean().optional(),
|
||||
// COMPAT(projectCustomIcon): added in v0.2.0, remove after 2027-01-20.
|
||||
projectCustomIcon: z.boolean().optional(),
|
||||
})
|
||||
.optional(),
|
||||
})
|
||||
@@ -3106,6 +3140,9 @@ export const WorkspaceDescriptorPayloadSchema = z
|
||||
// value (customName) and projectCustomName mirrors the raw override so the
|
||||
// settings UI can prefill its input and offer a "reset" action.
|
||||
projectCustomName: z.string().nullable().optional(),
|
||||
// Identifies the project's stored custom icon; null means automatic.
|
||||
// COMPAT(projectCustomIcon): added in v0.2.0, remove after 2027-01-20.
|
||||
projectCustomIconRevision: z.string().nullable().optional(),
|
||||
projectRootPath: z.string(),
|
||||
workspaceDirectory: z.string().optional(),
|
||||
projectKind: z.enum(["git", "non_git", "directory"]),
|
||||
@@ -3244,6 +3281,8 @@ export const WorkspaceProjectDescriptorPayloadSchema = z.object({
|
||||
projectKey: z.string().optional(),
|
||||
projectDisplayName: z.string(),
|
||||
projectCustomName: z.string().nullable().optional(),
|
||||
// COMPAT(projectCustomIcon): added in v0.2.0, remove after 2027-01-20.
|
||||
projectCustomIconRevision: z.string().nullable().optional(),
|
||||
projectRootPath: z.string(),
|
||||
projectKind: z.enum(["git", "non_git", "directory"]),
|
||||
});
|
||||
@@ -4755,6 +4794,16 @@ export const ProjectIconResponseSchema = z.object({
|
||||
}),
|
||||
});
|
||||
|
||||
export const ProjectIconGetResponseSchema = z.object({
|
||||
type: z.literal("project.icon.get.response"),
|
||||
payload: z.object({
|
||||
projectId: z.string(),
|
||||
icon: ProjectIconSchema.nullable(),
|
||||
error: z.string().nullable(),
|
||||
requestId: z.string(),
|
||||
}),
|
||||
});
|
||||
|
||||
export const FileDownloadTokenResponseSchema = z.object({
|
||||
type: z.literal("file_download_token_response"),
|
||||
payload: z.object({
|
||||
@@ -5260,6 +5309,7 @@ export const SessionOutboundMessageSchema = z.discriminatedUnion("type", [
|
||||
AgentRewindResponseMessageSchema,
|
||||
UpdateAgentResponseMessageSchema,
|
||||
ProjectRenameResponseSchema,
|
||||
ProjectIconSetResponseSchema,
|
||||
ProjectRemoveResponseSchema,
|
||||
WorkspaceTitleSetResponseSchema,
|
||||
WorkspacePinSetResponseSchema,
|
||||
@@ -5310,6 +5360,7 @@ export const SessionOutboundMessageSchema = z.discriminatedUnion("type", [
|
||||
FileWriteResponseSchema,
|
||||
FileUpdateSchema,
|
||||
ProjectIconResponseSchema,
|
||||
ProjectIconGetResponseSchema,
|
||||
FileDownloadTokenResponseSchema,
|
||||
FileUploadResponseSchema,
|
||||
ListProviderModelsResponseMessageSchema,
|
||||
@@ -5444,6 +5495,7 @@ export type AgentDetachResponseMessage = z.infer<typeof AgentDetachResponseMessa
|
||||
export type AgentRewindResponseMessage = z.infer<typeof AgentRewindResponseMessageSchema>;
|
||||
export type UpdateAgentResponseMessage = z.infer<typeof UpdateAgentResponseMessageSchema>;
|
||||
export type ProjectRenameResponse = z.infer<typeof ProjectRenameResponseSchema>;
|
||||
export type ProjectIconSetResponse = z.infer<typeof ProjectIconSetResponseSchema>;
|
||||
export type ProjectRemoveResponse = z.infer<typeof ProjectRemoveResponseSchema>;
|
||||
export type WorkspaceTitleSetResponse = z.infer<typeof WorkspaceTitleSetResponseSchema>;
|
||||
export type WorkspaceTitleSetResponsePayload = z.infer<
|
||||
@@ -5590,7 +5642,9 @@ export type LoopStopRequest = z.infer<typeof LoopStopRequestSchema>;
|
||||
export type ResumeAgentRequestMessage = z.infer<typeof ResumeAgentRequestMessageSchema>;
|
||||
export type DeleteAgentRequestMessage = z.infer<typeof DeleteAgentRequestMessageSchema>;
|
||||
export type UpdateAgentRequestMessage = z.infer<typeof UpdateAgentRequestMessageSchema>;
|
||||
export type ProjectIconSource = z.infer<typeof ProjectIconSourceSchema>;
|
||||
export type ProjectRenameRequest = z.infer<typeof ProjectRenameRequestSchema>;
|
||||
export type ProjectIconSetRequest = z.infer<typeof ProjectIconSetRequestSchema>;
|
||||
export type ProjectRemoveRequest = z.infer<typeof ProjectRemoveRequestSchema>;
|
||||
export type WorkspaceTitleSetRequest = z.infer<typeof WorkspaceTitleSetRequestSchema>;
|
||||
export type WorkspacePinSetRequest = z.infer<typeof WorkspacePinSetRequestSchema>;
|
||||
@@ -5728,6 +5782,8 @@ export type FileWriteResult = z.infer<typeof FileWriteResultSchema>;
|
||||
export type FileUpdate = z.infer<typeof FileUpdateSchema>;
|
||||
export type ProjectIconRequest = z.infer<typeof ProjectIconRequestSchema>;
|
||||
export type ProjectIconResponse = z.infer<typeof ProjectIconResponseSchema>;
|
||||
export type ProjectIconGetRequest = z.infer<typeof ProjectIconGetRequestSchema>;
|
||||
export type ProjectIconGetResponse = z.infer<typeof ProjectIconGetResponseSchema>;
|
||||
export type ProjectIcon = z.infer<typeof ProjectIconSchema>;
|
||||
export type FileDownloadTokenRequest = z.infer<typeof FileDownloadTokenRequestSchema>;
|
||||
export type FileDownloadTokenResponse = z.infer<typeof FileDownloadTokenResponseSchema>;
|
||||
|
||||
Reference in New Issue
Block a user