From aeabea45b70d551b76f682329e6a2bd00b615862 Mon Sep 17 00:00:00 2001 From: buguoshixc <3127765991@qq.com> Date: Sun, 6 Sep 2026 10:09:18 +0800 Subject: [PATCH 1/2] feat: add configurable custom avatars # Conflicts: # docs/usage.md # package.json # src/client/ActivityPanel.module.css # src/client/ActivityPanel.tsx # src/client/activity-monitor.ts # src/client/locales.ts # src/index.ts # src/snapshot.ts # src/tools.ts --- README.md | 7 + README_ZH.md | 7 + cordis.patch.yml | 6 + docs/usage.md | 9 +- package.json | 3 +- scripts/avatar-verify.mjs | 354 ++++++++++++ scripts/web-routes-verify.mjs | 29 +- src/avatar.ts | 661 ++++++++++++++++++++++ src/client/ActivityPanel.module.css | 115 ++++ src/client/ActivityPanel.tsx | 139 ++++- src/client/AgentTeamsCard.tsx | 28 +- src/client/ArtworkImage.tsx | 27 + src/client/activity-monitor.ts | 39 +- src/client/agent-teams-card-definition.ts | 2 + src/client/artwork.ts | 59 +- src/client/avatar-api.ts | 60 ++ src/client/locales.ts | 28 + src/index.ts | 35 +- src/snapshot.ts | 31 +- src/state.ts | 2 + src/tools.ts | 11 + src/types.ts | 4 + 22 files changed, 1601 insertions(+), 55 deletions(-) create mode 100644 scripts/avatar-verify.mjs create mode 100644 src/avatar.ts create mode 100644 src/client/ArtworkImage.tsx create mode 100644 src/client/avatar-api.ts diff --git a/README.md b/README.md index aa6e3c49a3..c52f90f36d 100644 --- a/README.md +++ b/README.md @@ -133,12 +133,19 @@ Defaults work without extra setup. A trusted profile can override member behavio memberModel: deepseek-v4 memberMaxDepth: 1 maxMembers: 8 + captainAvatar: '' # Empty keeps the packaged captain whale + roleAvatars: # Optional role/category overrides + researcher: https://example.com/researcher.webp + engineer: https://example.com/engineer.png + avatarMaxBytes: 2097152 # Default/hard maximum: 2 MiB; may be reduced ``` `memberProvider` is the sub-agent runtime backend (`spawn` / `fork`), not an LLM provider. Cross-LLM-provider routing uses the optional `provider` + `model` fields of `agent_teams_add_member`; `memberModel` is only a model default for all members. A member on the captain's current provider/model inherits the captain's reasoning effort, while a changed provider or model automatically uses the target model's default. To request a particular effort, pass the optional `reasoning_effort` field — one of the target model's supported effort ids, or `"default"` to force the model's own default. `slashCommand: false` disables the deterministic `/agent-teams` activation surfaces (slash command and gesture boundary), leaving the natural-language trigger as the only entry point. +The **Avatar** action beside a live team's title lets the captain paste an HTTP(S) URL, upload PNG/JPEG/WebP for the captain or any member, or restore the default. Per-team/member choices outrank `roleAvatars`, which outrank packaged role whales; unmatched members still use the initial-letter fallback. Uploads live under the team's `avatars/` directory and `team.json` stores only a short reference. Remote images load through the same-origin Host proxy for Desktop CSP compatibility. + ## Boundaries - One captain leads one active team at a time. diff --git a/README_ZH.md b/README_ZH.md index eb81b2859e..783bb3f916 100644 --- a/README_ZH.md +++ b/README_ZH.md @@ -122,12 +122,19 @@ dsh plugin --profile web add --save-exact @nanmicoder/dsh-agent-teams@0.1.16-rc. memberModel: deepseek-v4 memberMaxDepth: 1 maxMembers: 8 + captainAvatar: '' # 空值保留默认队长鲸鱼 + roleAvatars: # 可选:覆盖角色分类头像 + researcher: https://example.com/researcher.webp + engineer: https://example.com/engineer.png + avatarMaxBytes: 2097152 # 默认/最大 2 MiB,可调小 ``` 这里的 `memberProvider` 指子 Agent 的运行后端(`spawn` / `fork`),不是 LLM provider。跨 LLM provider 由 `agent_teams_add_member` 的可选 `provider` + `model` 参数表达;`memberModel` 只是所有成员的模型默认覆盖。成员沿用队长当前 provider/model 时会继承队长的思考强度;provider 或 model 任一改变时会自动使用目标模型的默认档。需要指定特定强度时,可传入可选的 `reasoning_effort` 参数(目标模型支持的档位 id,或 `"default"` 表示强制使用模型自身默认档)。 `slashCommand: false` 可关闭确定性的 `/agent-teams` 激活面(slash 命令与手势边界),仅保留自然语言触发。 +活动面板团队标题旁的“头像”入口可以为当前队长或任一成员粘贴 HTTP(S) URL、上传 PNG/JPEG/WebP,或恢复默认。会话级设置优先于 `roleAvatars`,后者再优先于内置角色鲸鱼;无匹配成员仍显示首字母。上传文件保存在团队目录的 `avatars/` 下,`team.json` 只记录短引用。外链由 Host 同源代理加载,以兼容 Desktop CSP。 + ## 使用边界 - 一个队长同一时间只能带一个活动团队。 diff --git a/cordis.patch.yml b/cordis.patch.yml index 83f859a3b1..b6c37be8ac 100644 --- a/cordis.patch.yml +++ b/cordis.patch.yml @@ -19,3 +19,9 @@ stateDir: .agent-teams # Provider used to spawn member subagents ('spawn' or 'fork'). memberProvider: spawn + # Optional default captain image. Empty keeps the packaged lead whale. + captainAvatar: '' + # Optional role/category -> HTTP(S) or same-origin image URL overrides. + roleAvatars: {} + # Upload and remote-proxy limit (default and hard maximum: 2 MiB). + avatarMaxBytes: 2097152 diff --git a/docs/usage.md b/docs/usage.md index 29ec718b1d..0fd9fd398f 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -80,10 +80,17 @@ fallback: # 主模型不可用时的第二选择 provider: openai model: gpt-5.5 + captainAvatar: '' # 默认队长头像;空值保留内置鲸鱼 + roleAvatars: # 可选:角色分类 -> 图片 URL + researcher: https://example.com/researcher.webp + engineer: https://example.com/engineer.png + avatarMaxBytes: 2097152 # 上传/外链代理限制,最大 2 MiB ``` 最终优先级为:成员显式 `provider` + `model` / `model` → `memberModel` → 队长当前路由。成员沿用队长当前 provider/model 时继承队长的思考强度;provider 或 model 任一改变时自动使用目标模型的默认档。显式 `reasoning_effort`(目标模型支持的档位 id,或 `"default"`)优先,并在目标 provider/model 上创建前校验;不兼容时成员创建会明确失败。最终生效的 provider/model/思考强度会写入 `team.json`,供状态查询和成员冷恢复使用。 +头像优先级为:队长/成员的会话级自定义头像 → `roleAvatars` 分类覆盖 → 内置角色鲸鱼 → 首字母圆形。活动面板团队标题旁的“头像”入口可选择队长或成员,使用 HTTP(S) URL、上传 PNG/JPEG/WebP,或恢复默认。上传文件写入 `//avatars/`,`team.json` 只保存 URL 或 `managed:` 短引用;Host 会校验大小、MIME、文件签名和文件名,并通过同源代理输出远程图片以兼容 Electron CSP。 + ## 使用协议 插件提示段会指导模型按两阶段协议执行:创建 staged 团队 → 写入可编辑成员占位 → 拆任务并声明依赖 → 等待用户审查 → **Approve & Run** 后原子创建成员并启动调度 → 队长监控/引导 → 汇报后 `agent_teams_delete`。staged 阶段没有子会话、不会领取任务。只有用户明确要求跳过审查时才使用 `approval: automatic`。成员之间可以直接互发消息,无需队长中转。驻留成员在中断或正常结束一轮后若仍持有 `claimed/in_progress` 任务,该 attempt 会停驻;只有显式重试/转派/接管才会撤销它。本进程已经观察过的停驻 attempt 在 Harness 回收其 AgentHandle 后仍保持原 attempt,Captain 轮询 `agent_teams_status` 不会因此重铸。只有冷启动或从未被本进程观察过的开放任务,才会自动恢复一次;恢复投递失败会回到原来的 capability,而不会变成可无限重派的 `pending`。 @@ -154,6 +161,6 @@ profiles: ## 验证 -- 离线与生命周期:`pnpm build && pnpm typecheck && pnpm verify`。除基础检查外,还包含 8 成员、31 节点多层 DAG(运行中扩展至 38 任务)的故障矩阵:并发接管/移除、50 次迟到写入、4 个开放任务冷重启、7 路认领竞争、40 次终态覆盖、42 条消息突发和最终归档;组合验证 `dsh --profile agent-teams-check --dump-config` +- 离线与生命周期:`pnpm build && pnpm typecheck && pnpm verify`。除基础检查外,还包含头像 MIME/签名/大小/路径穿越/CSRF/旧状态兼容测试,以及 8 成员、31 节点多层 DAG(运行中扩展至 38 任务)的故障矩阵:并发接管/移除、50 次迟到写入、4 个开放任务冷重启、7 路认领竞争、40 次终态覆盖、42 条消息突发和最终归档;组合验证 `dsh --profile agent-teams-check --dump-config` - 真实 e2e:`dsh plugin --profile headless add ` 后 `dsh --profile headless "用 AgentTeams …"`,核对 `.agent-teams/` 状态文件与会话日志事件流 - GUI:独立实例 + ego-browser(详见 `verification-guide.md`) diff --git a/package.json b/package.json index cf452e6e94..b7addf8141 100644 --- a/package.json +++ b/package.json @@ -84,8 +84,9 @@ "typecheck": "tsc -p tsconfig.json --noEmit && tsc -p tsconfig.client.json --noEmit", "sync:skill": "node scripts/sync-skill.mjs", "verify:skill": "node scripts/sync-skill.mjs --check", - "verify": "node scripts/verify.mjs && node scripts/fallback-tdd.mjs && node scripts/member-failure-tdd.mjs && node scripts/quality-gates-tdd.mjs && node scripts/lifecycle-verify.mjs && node scripts/stress-verify.mjs && pnpm verify:web-routes && pnpm verify:release && pnpm verify:skill && pnpm verify:harness-contract && pnpm verify:http-body && pnpm verify:compatibility", + "verify": "node scripts/verify.mjs && node scripts/avatar-verify.mjs && node scripts/fallback-tdd.mjs && node scripts/member-failure-tdd.mjs && node scripts/quality-gates-tdd.mjs && node scripts/lifecycle-verify.mjs && node scripts/stress-verify.mjs && pnpm verify:web-routes && pnpm verify:release && pnpm verify:skill && pnpm verify:harness-contract && pnpm verify:http-body && pnpm verify:compatibility", "prepublishOnly": "pnpm build && pnpm verify", + "verify:avatar": "node scripts/avatar-verify.mjs", "verify:web-routes": "node scripts/web-routes-verify.mjs", "verify:release": "node --test scripts/release-metadata.test.mjs", "verify:harness-contract": "node scripts/harness-compat-tdd.mjs && node scripts/lifecycle-verify.mjs --modern-harness && node scripts/member-failure-tdd.mjs --modern-harness", diff --git a/scripts/avatar-verify.mjs b/scripts/avatar-verify.mjs new file mode 100644 index 0000000000..ce83544272 --- /dev/null +++ b/scripts/avatar-verify.mjs @@ -0,0 +1,354 @@ +#!/usr/bin/env node +/** Focused security and compatibility verification for custom avatars. */ + +import { createServer } from 'node:http' +import { mkdtemp, readFile, rm, stat } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { Readable } from 'node:stream' +import { + AvatarHttpError, + createAvatarService, + DEFAULT_AVATAR_MAX_BYTES, + fetchRemoteAvatar, + isPrivateAddress, + managedAvatarPath, + normalizeRemoteAvatarUrl, + resolvePublicRemote, + validateAvatarFilename, + validateAvatarImage, +} from '../lib/avatar.js' +import { captainArtCandidates, memberArtCandidates } from '../lib/client/artwork.js' +import { collectTeamsActivity } from '../lib/snapshot.js' +import { createTeamDir, readTeam } from '../lib/state.js' + +const failures = [] +function check(label, condition, detail = '') { + const status = condition ? 'PASS' : 'FAIL' + console.log(` ${status} ${label}${detail ? ` — ${detail}` : ''}`) + if (!condition) failures.push(label) +} + +function rejectsAvatar(operation, status) { + try { + operation() + return false + } catch (error) { + return error instanceof AvatarHttpError && error.status === status + } +} + +async function rejectsAvatarAsync(operation, status) { + try { + await operation() + return false + } catch (error) { + return error instanceof AvatarHttpError && error.status === status + } +} + +function remoteResponse(statusCode, headers = {}, body = Buffer.alloc(0)) { + const response = Readable.from(body.length === 0 ? [] : [body]) + response.statusCode = statusCode + response.headers = headers + return response +} + +console.log('dsh-agent-teams avatar verification') + +const png = await readFile(new URL('../assets/agent-teams/team-lead-v2.png', import.meta.url)) +const pngType = validateAvatarImage(png, 'image/png') +check('valid PNG signature and MIME are accepted', pngType.contentType === 'image/png') +check('non-image MIME is rejected', rejectsAvatar(() => validateAvatarImage(png, 'text/plain'), 415)) +check('MIME/signature mismatch is rejected', rejectsAvatar(() => validateAvatarImage(png, 'image/jpeg'), 415)) +check('oversized image is rejected', rejectsAvatar(() => { + const oversized = Buffer.alloc(DEFAULT_AVATAR_MAX_BYTES + 1) + png.copy(oversized, 0, 0, 16) + validateAvatarImage(oversized, 'image/png') +}, 413)) +check('safe filename is accepted', (() => { + validateAvatarFilename(encodeURIComponent('鲸鱼.png'), pngType) + return true +})()) +check('path-traversal filename is rejected', rejectsAvatar(() => { + validateAvatarFilename(encodeURIComponent('../../captain.png'), pngType) +}, 400)) +check('Windows path-traversal filename is rejected', rejectsAvatar(() => { + validateAvatarFilename(encodeURIComponent('..\\captain.png'), pngType) +}, 400)) +check('managed path rejects attacker-controlled segments', rejectsAvatar(() => { + managedAvatarPath('C:\\safe', 'team', '../../captain.png') +}, 404)) +check('HTTP(S) URL is canonicalized', normalizeRemoteAvatarUrl('https://example.com/a.png#x') === 'https://example.com/a.png') +check('file URL is rejected', rejectsAvatar(() => normalizeRemoteAvatarUrl('file:///etc/passwd'), 400)) +check('credential-bearing URL is rejected', rejectsAvatar(() => normalizeRemoteAvatarUrl('https://user:pass@example.com/a.png'), 400)) + +check('private IPv4 ranges are rejected', [ + '0.0.0.0', '10.0.0.1', '100.64.0.1', '127.0.0.1', '169.254.1.1', '172.16.0.1', '192.168.1.1', +].every(isPrivateAddress)) +check('public IPv4 addresses remain eligible', !isPrivateAddress('93.184.216.34')) +check('private IPv6 ranges are rejected', [ + '::', '::1', 'fc00::1', 'fe80::1', 'ff02::1', '2001:db8::1', +].every(isPrivateAddress)) +check('IPv4-mapped IPv6 cannot bypass private IPv4 validation', [ + '::ffff:127.0.0.1', '::ffff:7f00:1', '0:0:0:0:0:ffff:7f00:1', +].every(isPrivateAddress)) +check('public IPv6 addresses remain eligible', !isPrivateAddress('2606:4700:4700::1111')) + +let literalDnsCalls = 0 +const rejectUnexpectedDns = async () => { + literalDnsCalls += 1 + throw new Error('literal IP must not use DNS') +} +const literalV4 = await resolvePublicRemote(new URL('https://93.184.216.34/a.png'), rejectUnexpectedDns) +const literalV6 = await resolvePublicRemote(new URL('https://[2606:4700:4700::1111]/a.png'), rejectUnexpectedDns) +check('public IPv4 and IPv6 literals bypass DNS but remain pinned', + literalDnsCalls === 0 && literalV4.family === 4 && literalV6.family === 6) +check('private IPv6 literals are rejected before any request', await rejectsAvatarAsync( + () => resolvePublicRemote(new URL('https://[::1]/a.png'), rejectUnexpectedDns), + 403, +)) +check('mixed public/private DNS answers reject the entire hostname', await rejectsAvatarAsync( + () => resolvePublicRemote(new URL('https://mixed.example/a.png'), async () => [ + { address: '93.184.216.34', family: 4 }, + { address: '127.0.0.1', family: 4 }, + ]), + 403, +)) +check('DNS lookup failures are reported as a proxy failure', await rejectsAvatarAsync( + () => resolvePublicRemote(new URL('https://missing.example/a.png'), async () => { throw new Error('NXDOMAIN') }), + 502, +)) + +const redirectDnsCalls = [] +const redirectRequests = [] +const redirected = await fetchRemoteAvatar( + 'https://redirect.example/avatar', + DEFAULT_AVATAR_MAX_BYTES, + async (hostname) => { + redirectDnsCalls.push(hostname) + return hostname === 'redirect.example' + ? [{ address: '93.184.216.34', family: 4 }] + : [{ address: '2606:4700:4700::1111', family: 6 }] + }, + async (url, pinned) => { + redirectRequests.push({ hostname: url.hostname, pinned }) + return url.hostname === 'redirect.example' + ? remoteResponse(302, { location: 'https://images.example/avatar.png' }) + : remoteResponse(200, { 'content-type': 'image/png', 'content-length': String(png.length) }, png) + }, +) +check('redirects re-run DNS validation and pin each IPv4/IPv6 hop', + redirected.type.contentType === 'image/png' + && redirectDnsCalls.join(',') === 'redirect.example,images.example' + && redirectRequests[0]?.pinned.family === 4 + && redirectRequests[1]?.pinned.family === 6) + +let privateRedirectRequests = 0 +const privateRedirectRejected = await rejectsAvatarAsync(() => fetchRemoteAvatar( + 'https://redirect.example/avatar', + DEFAULT_AVATAR_MAX_BYTES, + async (hostname) => hostname === 'redirect.example' + ? [{ address: '93.184.216.34', family: 4 }] + : [{ address: '127.0.0.1', family: 4 }], + async () => { + privateRedirectRequests += 1 + return remoteResponse(302, { location: 'http://private.example/avatar.png' }) + }, +), 403) +check('redirects to a private DNS result are rejected before the next request', + privateRedirectRejected && privateRedirectRequests === 1) + +let redirectLimitRequests = 0 +const redirectLimitRejected = await rejectsAvatarAsync(() => fetchRemoteAvatar( + 'https://redirect.example/avatar', + DEFAULT_AVATAR_MAX_BYTES, + async () => [{ address: '93.184.216.34', family: 4 }], + async () => { + redirectLimitRequests += 1 + return remoteResponse(302, { location: '/again' }) + }, +), 502) +check('remote proxy caps redirect hops', redirectLimitRejected && redirectLimitRequests === 4) + +check('remote proxy rejects a declared oversized response before buffering', await rejectsAvatarAsync( + () => fetchRemoteAvatar( + 'https://images.example/avatar.png', + 64, + async () => [{ address: '93.184.216.34', family: 4 }], + async () => remoteResponse(200, { 'content-type': 'image/png', 'content-length': '65' }), + ), + 413, +)) +check('remote proxy enforces the size limit on streamed bodies without content-length', await rejectsAvatarAsync( + () => fetchRemoteAvatar( + 'https://images.example/avatar.png', + 64, + async () => [{ address: '93.184.216.34', family: 4 }], + async () => remoteResponse(200, { 'content-type': 'image/png' }, Buffer.alloc(65)), + ), + 413, +)) + +const custom = '/plugins/dsh-agent-teams/avatar-file?x=1' +const override = '/plugins/dsh-agent-teams/avatar-proxy?x=2' +check('member custom avatar outranks role mapping', memberArtCandidates('alice', 'researcher', custom, { researcher: override })[0] === custom) +check('role override outranks packaged artwork', memberArtCandidates('alice', 'researcher', undefined, { researcher: override })[0] === override) +check('unconfigured role retains packaged artwork', memberArtCandidates('alice', 'researcher')[0]?.endsWith('member-researcher-v2.png') === true) +check('unmatched role retains initial fallback', memberArtCandidates('alice', 'arbiter').length === 0) +check('captain team override outranks configured and packaged artwork', captainArtCandidates(custom, override)[0] === custom) + +const workspace = await mkdtemp(join(tmpdir(), 'dsh-agent-teams-avatar-')) +const stateRoot = join(workspace, '.agent-teams') +const team = { + name: 'Avatar Team', + id: 'avatar-team', + captainSessionId: 'captain-session', + createdAt: Date.now(), + members: [{ id: 'member-session', name: 'alice', role: 'researcher', joinedAt: Date.now(), status: 'idle' }], + tasks: [], + taskSeq: 0, +} +const routes = new Map() +const routeHost = { + register(route) { + routes.set(route.path, route.handler) + return () => { routes.delete(route.path) } + }, +} +const ctx = { logger: { warn() {}, debug() {} }, agents: { get() { return undefined } } } +const workspaceRegistry = { list: () => [{ path: workspace, title: 'Avatar Test' }] } +let server +try { + // This intentionally starts from a legacy state shape with no avatar fields. + await createTeamDir(stateRoot, team) + check('legacy team.json without avatar fields still loads', (await readTeam(stateRoot, team.id))?.members[0]?.avatar === undefined) + const legacySnapshot = (await collectTeamsActivity(ctx, [{ workspace: 'Avatar Test', stateRoot }]))[0] + check('legacy state without avatar fields still projects for the current UI', + legacySnapshot?.captainAvatarUrl === undefined && legacySnapshot?.members[0]?.avatarUrl === undefined) + + const service = createAvatarService(ctx, workspaceRegistry, { + stateDir: '.agent-teams', + captainAvatar: '', + roleAvatars: {}, + maxUploadBytes: DEFAULT_AVATAR_MAX_BYTES, + }) + service.register(routeHost) + server = createServer((req, res) => { + const path = new URL(req.url ?? '/', 'http://x').pathname + const handler = routes.get(path) + if (handler === undefined) { + res.writeHead(404) + res.end() + return + } + void Promise.resolve(handler(req, res)).catch((error) => { + res.writeHead(500) + res.end(String(error)) + }) + }) + await new Promise((resolve, reject) => { + server.once('error', reject) + server.listen(0, '127.0.0.1', resolve) + }) + const address = server.address() + if (typeof address !== 'object' || address === null) throw new Error('test server did not expose an address') + const base = `http://127.0.0.1:${address.port}` + const token = service.editToken(stateRoot, team) + const query = new URLSearchParams({ + team_id: team.id, + captain_session_id: team.captainSessionId, + target: 'captain', + }) + const headers = { + 'x-agent-teams-request': 'avatar-v1', + 'x-agent-teams-avatar-token': token, + } + const upload = await fetch(`${base}/plugins/dsh-agent-teams/avatar?${query}`, { + method: 'POST', + headers: { ...headers, 'content-type': 'image/png', 'x-agent-teams-file-name': encodeURIComponent('captain.png') }, + body: png, + }) + const uploadBody = await upload.json() + const uploadedState = await readTeam(stateRoot, team.id) + check('valid upload endpoint stores only a managed reference', upload.ok && uploadedState?.captainAvatar?.startsWith('managed:') === true) + check('managed upload never serializes image bytes', JSON.stringify(uploadedState).length < 4096) + const storedFilename = uploadedState?.captainAvatar?.slice('managed:'.length) ?? '' + check('managed upload file exists below the team directory', (await stat(managedAvatarPath(stateRoot, team.id, storedFilename))).isFile()) + const served = await fetch(`${base}${uploadBody.avatarUrl}`) + check('managed avatar is served with a locked image MIME', served.ok && served.headers.get('content-type') === 'image/png') + const uploadedSnapshot = (await collectTeamsActivity(ctx, [{ workspace: 'Avatar Test', stateRoot }], { + avatarUrl: service.publicUrl, + avatarEditToken: service.editToken, + }))[0] + check('live snapshot projects captain URL and edit capability for both UIs', + uploadedSnapshot?.captainAvatarUrl === uploadBody.avatarUrl + && typeof uploadedSnapshot.avatarEditToken === 'string') + + const badMime = await fetch(`${base}/plugins/dsh-agent-teams/avatar?${query}`, { + method: 'POST', + headers: { ...headers, 'content-type': 'text/plain', 'x-agent-teams-file-name': encodeURIComponent('captain.png') }, + body: png, + }) + check('upload endpoint rejects non-image MIME', badMime.status === 415) + const badName = await fetch(`${base}/plugins/dsh-agent-teams/avatar?${query}`, { + method: 'POST', + headers: { ...headers, 'content-type': 'image/png', 'x-agent-teams-file-name': encodeURIComponent('../../captain.png') }, + body: png, + }) + check('upload endpoint rejects malicious filename', badName.status === 400) + const tooLarge = Buffer.alloc(DEFAULT_AVATAR_MAX_BYTES + 1) + png.copy(tooLarge, 0, 0, 16) + const oversized = await fetch(`${base}/plugins/dsh-agent-teams/avatar?${query}`, { + method: 'POST', + headers: { ...headers, 'content-type': 'image/png', 'x-agent-teams-file-name': encodeURIComponent('large.png') }, + body: tooLarge, + }) + check('upload endpoint rejects oversized body', oversized.status === 413) + const missingMarker = await fetch(`${base}/plugins/dsh-agent-teams/avatar?${query}`, { + method: 'DELETE', + headers: { 'x-agent-teams-avatar-token': token }, + }) + check('mutation endpoint rejects missing CSRF marker', missingMarker.status === 403) + + const memberQuery = new URLSearchParams({ + team_id: team.id, + captain_session_id: team.captainSessionId, + target: 'member', + member: 'alice', + }) + const remoteUrl = 'https://example.com/alice.webp' + const setMember = await fetch(`${base}/plugins/dsh-agent-teams/avatar?${memberQuery}`, { + method: 'PUT', + headers: { ...headers, 'content-type': 'application/json' }, + body: JSON.stringify({ url: remoteUrl }), + }) + check('URL endpoint persists member-specific URL', setMember.ok && (await readTeam(stateRoot, team.id))?.members[0]?.avatar === remoteUrl) + const memberSnapshot = (await collectTeamsActivity(ctx, [{ workspace: 'Avatar Test', stateRoot }], { + avatarUrl: service.publicUrl, + avatarEditToken: service.editToken, + }))[0] + check('live snapshot projects member override ahead of role fallback', + memberSnapshot?.members[0]?.avatarUrl?.startsWith('/plugins/dsh-agent-teams/avatar-proxy?') === true + && memberArtCandidates('alice', 'researcher', memberSnapshot.members[0].avatarUrl)[0] + === memberSnapshot.members[0].avatarUrl) + const privateProxyStatuses = await Promise.all([ + `${base}/secret.png`, + 'http://[::1]/secret.png', + 'http://[::ffff:7f00:1]/secret.png', + ].map(async (url) => (await fetch(`${base}/plugins/dsh-agent-teams/avatar-proxy?url=${encodeURIComponent(url)}`)).status)) + check('remote proxy rejects IPv4, IPv6, and mapped-IPv4 loopback targets', + privateProxyStatuses.every((status) => status === 403)) + + const cleared = await fetch(`${base}/plugins/dsh-agent-teams/avatar?${query}`, { method: 'DELETE', headers }) + check('captain override can be cleared to default', cleared.ok && (await readTeam(stateRoot, team.id))?.captainAvatar === undefined) +} finally { + if (server !== undefined) await new Promise((resolve) => { server.close(resolve) }) + await rm(workspace, { recursive: true, force: true }) +} + +if (failures.length > 0) { + console.error(`\n${failures.length} avatar check(s) FAILED: ${failures.join(', ')}`) + process.exit(1) +} +console.log('\nall avatar checks passed') diff --git a/scripts/web-routes-verify.mjs b/scripts/web-routes-verify.mjs index 17f90486a9..2dd3b2d343 100644 --- a/scripts/web-routes-verify.mjs +++ b/scripts/web-routes-verify.mjs @@ -39,7 +39,16 @@ ctx.provide('credentials', { ctx.provide('webServer', webServer) let gate let calls = 0 -const paths = ['/plugins/dsh-agent-teams/state', '/plugins/dsh-agent-teams/plan'] +const planPath = '/plugins/dsh-agent-teams/plan' +const avatarPath = '/plugins/dsh-agent-teams/avatar' +const paths = [ + '/plugins/dsh-agent-teams/state', + planPath, + avatarPath, + '/plugins/dsh-agent-teams/avatar-file', + '/plugins/dsh-agent-teams/avatar-proxy', +] +const postPaths = new Set([planPath, avatarPath]) const routePlugin = { name: 'agent-teams-auth-test', apply(owner) { @@ -75,7 +84,7 @@ try { const fiber = ctx.plugin(routePlugin) await fiber.inertia for (const path of paths) { - const response = await fetch(base + path, { method: path.endsWith('/plan') ? 'POST' : 'GET' }) + const response = await fetch(base + path, { method: postPaths.has(path) ? 'POST' : 'GET' }) assert.equal(response.status, 401) assert.equal(response.headers.get('cache-control'), 'no-store') assert.deepEqual(await response.json(), { error: 'unauthorized' }) @@ -88,21 +97,21 @@ try { assert(cookie, 'real Connection must issue a browser cookie') for (const path of paths) { const response = await fetch(base + path, { - method: path.endsWith('/plan') ? 'POST' : 'GET', headers: { cookie, origin: base }, + method: postPaths.has(path) ? 'POST' : 'GET', headers: { cookie, origin: base }, }) assert.equal(response.status, 200) assert.deepEqual(await response.json(), { ok: true }) } - assert.equal(calls, 2) - const unauthenticatedUpload = await fetch(base + paths[1], { method: 'POST', body: 'x'.repeat(65) }) + assert.equal(calls, paths.length) + const unauthenticatedUpload = await fetch(base + avatarPath, { method: 'POST', body: 'x'.repeat(65) }) assert.equal(unauthenticatedUpload.status, 401, 'authentication runs before parsing request bodies') await unauthenticatedUpload.arrayBuffer() - const oversized = await fetch(base + paths[1], { + const oversized = await fetch(base + avatarPath, { method: 'POST', headers: { cookie, origin: base }, body: 'x'.repeat(65), }) assert.equal(oversized.status, 413, 'authorized oversized requests fail before mutating team state') await oversized.arrayBuffer() - assert.equal(calls, 2) + assert.equal(calls, paths.length) for (const headers of [ { cookie, origin: 'https://untrusted.invalid' }, { cookie, 'sec-fetch-site': 'cross-site' }, @@ -110,7 +119,7 @@ try { ]) { // node:http preserves an explicitly hostile Host; Fetch may replace it. const status = await new Promise((resolve, reject) => { - const req = request(base + paths[1], { method: 'POST', headers }, response => { + const req = request(base + avatarPath, { method: 'POST', headers }, response => { response.resume() response.on('end', () => resolve(response.statusCode)) }) @@ -119,13 +128,13 @@ try { }) assert.equal(status, 403, `reject ${Object.keys(headers).filter(key => key !== 'cookie').join(', ')}`) } - assert.equal(calls, 2, 'rejected origins must not enter the mutation handler') + assert.equal(calls, paths.length, 'rejected origins must not enter the mutation handler') gate = undefined const unavailable = await fetch(base + paths[0], { headers: { cookie } }) assert.equal(unavailable.status, 503) await unavailable.arrayBuffer() - assert.equal(calls, 2, 'missing Connection must fail closed') + assert.equal(calls, paths.length, 'missing Connection must fail closed') await fiber.dispose() assert(paths.every(path => !routes.has(path)), 'unload removes every owned route') diff --git a/src/avatar.ts b/src/avatar.ts new file mode 100644 index 0000000000..2c3a74d17d --- /dev/null +++ b/src/avatar.ts @@ -0,0 +1,661 @@ +/** + * Secure avatar storage, projection, and Web routes. + * + * Durable team records keep only a remote URL or a short `managed:` file + * reference. Browser-facing URLs are always same-origin: managed files are + * served from the owning team directory, while remote images pass through a + * bounded, type-checked proxy so Electron CSP never needs an external origin. + * @module dsh-agent-teams/avatar + */ + +import type { Context } from '@deepseek-ai/cordis' +import type { WorkspaceRegistry } from '@deepseek-ai/dsh-workspace' +import { createHash, createHmac, randomBytes, randomUUID, timingSafeEqual } from 'node:crypto' +import { lookup } from 'node:dns/promises' +import { mkdir, readFile, rm, writeFile } from 'node:fs/promises' +import { request as httpRequest, type IncomingMessage, type ServerResponse } from 'node:http' +import { request as httpsRequest } from 'node:https' +import { BlockList, isIP, SocketAddress } from 'node:net' +import { dirname, extname, join, resolve } from 'node:path' +import { readTeam, sanitizeKey, withTeamLock, writeTeam } from './state.ts' +import type { TeamMember, TeamState } from './types.ts' + +/** Default and absolute upper bound for one avatar payload (2 MiB). */ +export const DEFAULT_AVATAR_MAX_BYTES = 2 * 1024 * 1024 +/** JSON metadata is intentionally tiny and never carries image bytes. */ +const MAX_AVATAR_JSON_BYTES = 8 * 1024 +const MAX_AVATAR_URL_LENGTH = 2048 +const MAX_REDIRECTS = 3 +const REMOTE_TIMEOUT_MS = 10_000 +const MANAGED_PREFIX = 'managed:' +const AVATAR_DIR = 'avatars' +const AVATAR_BASE = '/plugins/dsh-agent-teams' + +/** Configurable role-to-image override after host-side URL projection. */ +export interface PublicAvatarArtwork { + readonly captainAvatarUrl?: string + readonly roleAvatars: Readonly> + readonly maxUploadBytes: number +} + +/** Snapshot-side avatar projection context. */ +export interface AvatarProjectionContext { + readonly stateRoot: string + readonly teamId: string + readonly historic: boolean +} + +/** Minimal Web route registration shape used by DSH host webserver builds. */ +export interface AvatarRouteHost { + register(route: { + kind: 'exact' | 'prefix' + path: string + handler: (req: IncomingMessage, res: ServerResponse) => void | Promise + }): () => void +} + +/** Runtime configuration owned by the avatar service. */ +export interface AvatarServiceOptions { + readonly stateDir: string + readonly captainAvatar: string + readonly roleAvatars: Readonly> + readonly maxUploadBytes: number +} + +/** Avatar Web surface plus helpers consumed by the state snapshot route. */ +export interface AvatarService { + readonly artwork: PublicAvatarArtwork + publicUrl(source: string, context: AvatarProjectionContext): string | undefined + editToken(stateRoot: string, team: TeamState): string + register(webServer: AvatarRouteHost): Array<() => void> +} + +/** HTTP-shaped validation failure with a safe client-facing message. */ +export class AvatarHttpError extends Error { + constructor(public readonly status: number, message: string) { + super(message) + this.name = 'AvatarHttpError' + } +} + +interface AvatarImageType { + readonly contentType: 'image/png' | 'image/jpeg' | 'image/webp' + readonly extension: 'png' | 'jpg' | 'webp' +} + +interface EditableTeam { + readonly stateRoot: string + readonly state: TeamState +} + +interface AvatarMutationTarget { + readonly teamId: string + readonly captainSessionId: string + readonly target: 'captain' | 'member' + readonly memberName?: string +} + +/** DNS result used by the proxy's address validator. */ +export interface AvatarResolvedAddress { + readonly address: string + readonly family: number +} + +/** Resolver seam used by focused network-boundary verification. */ +export type AvatarDnsResolver = (hostname: string) => Promise + +/** Request seam used to verify redirect validation without reaching the network. */ +export type AvatarPinnedRequester = ( + url: URL, + pinned: { readonly address: string; readonly family: 4 | 6 }, +) => Promise + +const PRIVATE_ADDRESSES = new BlockList() +for (const [network, prefix] of [ + ['0.0.0.0', 8], ['10.0.0.0', 8], ['100.64.0.0', 10], ['127.0.0.0', 8], + ['169.254.0.0', 16], ['172.16.0.0', 12], ['192.0.0.0', 24], ['192.0.2.0', 24], + ['192.168.0.0', 16], ['198.18.0.0', 15], ['198.51.100.0', 24], ['203.0.113.0', 24], + ['224.0.0.0', 4], ['240.0.0.0', 4], +] as const) PRIVATE_ADDRESSES.addSubnet(network, prefix, 'ipv4') +for (const [network, prefix] of [ + ['::', 128], ['::1', 128], ['fc00::', 7], ['fe80::', 10], ['ff00::', 8], + ['2001:db8::', 32], +] as const) PRIVATE_ADDRESSES.addSubnet(network, prefix, 'ipv6') + +/** Parse and canonicalize one user-provided remote avatar URL. */ +export function normalizeRemoteAvatarUrl(input: string): string { + const source = input.trim() + if (source === '') throw new AvatarHttpError(400, 'avatar URL must not be empty') + if (source.length > MAX_AVATAR_URL_LENGTH) throw new AvatarHttpError(400, 'avatar URL is too long') + let url: URL + try { + url = new URL(source) + } catch { + throw new AvatarHttpError(400, 'avatar URL is invalid') + } + if (url.protocol !== 'http:' && url.protocol !== 'https:') { + throw new AvatarHttpError(400, 'avatar URL must use http or https') + } + if (url.username !== '' || url.password !== '') { + throw new AvatarHttpError(400, 'avatar URL must not contain credentials') + } + url.hash = '' + return url.toString() +} + +/** Recognize PNG, JPEG, or WebP from the file signature, never the filename. */ +export function detectAvatarImageType(data: Uint8Array): AvatarImageType | undefined { + if (data.length >= 8 + && data[0] === 0x89 && data[1] === 0x50 && data[2] === 0x4e && data[3] === 0x47 + && data[4] === 0x0d && data[5] === 0x0a && data[6] === 0x1a && data[7] === 0x0a) { + return { contentType: 'image/png', extension: 'png' } + } + if (data.length >= 3 && data[0] === 0xff && data[1] === 0xd8 && data[2] === 0xff) { + return { contentType: 'image/jpeg', extension: 'jpg' } + } + if (data.length >= 12 + && String.fromCharCode(...data.subarray(0, 4)) === 'RIFF' + && String.fromCharCode(...data.subarray(8, 12)) === 'WEBP') { + return { contentType: 'image/webp', extension: 'webp' } + } + return undefined +} + +function declaredAvatarType(value: string | undefined): AvatarImageType | undefined { + const mime = value?.split(';', 1)[0]?.trim().toLowerCase() + if (mime === 'image/png') return { contentType: mime, extension: 'png' } + if (mime === 'image/jpeg') return { contentType: mime, extension: 'jpg' } + if (mime === 'image/webp') return { contentType: mime, extension: 'webp' } + return undefined +} + +/** Enforce the declared type, payload limit, and signature as one boundary. */ +export function validateAvatarImage( + data: Uint8Array, + contentType: string | undefined, + maxBytes = DEFAULT_AVATAR_MAX_BYTES, +): AvatarImageType { + if (data.length === 0) throw new AvatarHttpError(400, 'avatar file is empty') + if (data.length > maxBytes) throw new AvatarHttpError(413, `avatar exceeds the ${maxBytes}-byte limit`) + const declared = declaredAvatarType(contentType) + if (declared === undefined) throw new AvatarHttpError(415, 'avatar must be PNG, JPEG, or WebP') + const detected = detectAvatarImageType(data) + if (detected === undefined || detected.contentType !== declared.contentType) { + throw new AvatarHttpError(415, 'avatar content does not match its declared image type') + } + return detected +} + +/** + * Validate a browser filename for diagnostics only. Storage always uses a + * generated UUID, but rejecting traversal-shaped names keeps the upload + * boundary explicit and testable. + */ +export function validateAvatarFilename(encodedName: string | undefined, type: AvatarImageType): void { + if (encodedName === undefined || encodedName === '') throw new AvatarHttpError(400, 'avatar filename is missing') + let name: string + try { + name = decodeURIComponent(encodedName) + } catch { + throw new AvatarHttpError(400, 'avatar filename is invalid') + } + if (name.length > 128 || name === '.' || name === '..' || name.includes('/') || name.includes('\\') || name.includes('\0')) { + throw new AvatarHttpError(400, 'avatar filename must be one safe path segment') + } + const extension = extname(name).slice(1).toLowerCase() + const accepted = type.extension === 'jpg' ? extension === 'jpg' || extension === 'jpeg' : extension === type.extension + if (!accepted) throw new AvatarHttpError(400, 'avatar filename extension does not match its image type') +} + +/** Read a request stream while enforcing the limit before buffering fully. */ +export async function readLimitedBody(req: IncomingMessage, maxBytes: number): Promise { + const header = req.headers['content-length'] + if (header !== undefined) { + const declared = Number(Array.isArray(header) ? header[0] : header) + if (!Number.isSafeInteger(declared) || declared < 0) throw new AvatarHttpError(400, 'invalid content-length') + if (declared > maxBytes) throw new AvatarHttpError(413, `avatar exceeds the ${maxBytes}-byte limit`) + } + const chunks: Buffer[] = [] + let total = 0 + for await (const chunk of req) { + const data = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk as Uint8Array) + total += data.length + if (total > maxBytes) throw new AvatarHttpError(413, `avatar exceeds the ${maxBytes}-byte limit`) + chunks.push(data) + } + return Buffer.concat(chunks, total) +} + +/** Resolve one generated managed filename without allowing path traversal. */ +export function managedAvatarPath(stateRoot: string, teamId: string, filename: string): string { + if (!/^[0-9a-f-]{36}\.(?:png|jpg|webp)$/u.test(filename)) { + throw new AvatarHttpError(404, 'avatar file was not found') + } + const directory = resolve(stateRoot, teamId, AVATAR_DIR) + const target = resolve(directory, filename) + if (dirname(target) !== directory) throw new AvatarHttpError(404, 'avatar file was not found') + return target +} + +/** Store validated bytes below the team's avatar directory. */ +export async function storeManagedAvatar( + stateRoot: string, + teamId: string, + data: Uint8Array, + contentType: string | undefined, + encodedFilename: string | undefined, + maxBytes = DEFAULT_AVATAR_MAX_BYTES, +): Promise { + const type = validateAvatarImage(data, contentType, maxBytes) + validateAvatarFilename(encodedFilename, type) + const filename = `${randomUUID()}.${type.extension}` + const target = managedAvatarPath(stateRoot, teamId, filename) + await mkdir(dirname(target), { recursive: true }) + await writeFile(target, data, { flag: 'wx' }) + return `${MANAGED_PREFIX}${filename}` +} + +function managedFilename(source: string | undefined): string | undefined { + if (source?.startsWith(MANAGED_PREFIX) !== true) return undefined + const filename = source.slice(MANAGED_PREFIX.length) + return /^[0-9a-f-]{36}\.(?:png|jpg|webp)$/u.test(filename) ? filename : undefined +} + +function workspaceKey(stateRoot: string): string { + return createHash('sha256').update(resolve(stateRoot)).digest('hex').slice(0, 24) +} + +function publicConfiguredUrl(source: string): string | undefined { + const value = source.trim() + if (value === '') return undefined + if (value.startsWith('/') && !value.startsWith('//')) return value + try { + return `${AVATAR_BASE}/avatar-proxy?url=${encodeURIComponent(normalizeRemoteAvatarUrl(value))}` + } catch { + return undefined + } +} + +function contentTypeForFilename(filename: string): string { + if (filename.endsWith('.png')) return 'image/png' + if (filename.endsWith('.jpg')) return 'image/jpeg' + return 'image/webp' +} + +/** Reject non-IP and non-public IPv4/IPv6 destinations, including mapped IPv4. */ +export function isPrivateAddress(address: string): boolean { + const family = isIP(address) + if (family === 4) return PRIVATE_ADDRESSES.check(address, 'ipv4') + if (family === 6) { + const normalized = SocketAddress.parse(`[${address}]:0`)?.address.toLowerCase() ?? address.toLowerCase() + const mapped = normalized.match(/^::ffff:(\d+\.\d+\.\d+\.\d+)$/u)?.[1] + return mapped !== undefined ? PRIVATE_ADDRESSES.check(mapped, 'ipv4') : PRIVATE_ADDRESSES.check(address, 'ipv6') + } + return true +} + +const systemDnsResolver: AvatarDnsResolver = async (hostname) => lookup(hostname, { all: true, verbatim: true }) + +/** Resolve every DNS answer and reject the host if any answer is not public. */ +export async function resolvePublicRemote( + url: URL, + resolveHost: AvatarDnsResolver = systemDnsResolver, +): Promise<{ address: string; family: 4 | 6 }> { + const hostname = url.hostname.replace(/^\[|\]$/gu, '') + const literalFamily = isIP(hostname) + if (literalFamily !== 0) { + if (isPrivateAddress(hostname)) throw new AvatarHttpError(403, 'private-network avatar URLs are not allowed') + return { address: hostname, family: literalFamily === 4 ? 4 : 6 } + } + let addresses: readonly AvatarResolvedAddress[] + try { + addresses = await resolveHost(hostname) + } catch { + throw new AvatarHttpError(502, 'avatar host could not be resolved') + } + if (addresses.length === 0 || addresses.some(({ address }) => isPrivateAddress(address))) { + throw new AvatarHttpError(403, 'private-network avatar URLs are not allowed') + } + const selected = addresses[0]! + if (selected.family !== 4 && selected.family !== 6) throw new AvatarHttpError(502, 'avatar host resolved to an unsupported address') + return { address: selected.address, family: selected.family } +} + +const requestPinnedRemoteAvatar: AvatarPinnedRequester = async (url, pinned) => new Promise((resolveResponse, reject) => { + const request = (url.protocol === 'https:' ? httpsRequest : httpRequest)(url, { + method: 'GET', + signal: AbortSignal.timeout(REMOTE_TIMEOUT_MS), + headers: { accept: 'image/png,image/jpeg,image/webp' }, + // Pin the already-vetted public address. TLS still validates against the + // URL hostname, while DNS rebinding cannot swap in a private address. + lookup: (_hostname, _options, callback) => { + callback(null, pinned.address, pinned.family) + }, + }, resolveResponse) + request.once('error', reject) + request.end() +}) + +async function requestRemoteAvatar( + url: URL, + resolveHost: AvatarDnsResolver, + requestPinned: AvatarPinnedRequester, +): Promise { + const pinned = await resolvePublicRemote(url, resolveHost) + return requestPinned(url, pinned) +} + +/** Fetch an avatar while revalidating and pinning every redirect hop. */ +export async function fetchRemoteAvatar( + source: string, + maxBytes: number, + resolveHost: AvatarDnsResolver = systemDnsResolver, + requestPinned: AvatarPinnedRequester = requestPinnedRemoteAvatar, +): Promise<{ data: Buffer; type: AvatarImageType }> { + let url = new URL(normalizeRemoteAvatarUrl(source)) + for (let redirects = 0; redirects <= MAX_REDIRECTS; redirects += 1) { + let response: IncomingMessage + try { + response = await requestRemoteAvatar(url, resolveHost, requestPinned) + } catch (error: unknown) { + if (error instanceof AvatarHttpError) throw error + throw new AvatarHttpError(502, 'remote avatar could not be loaded') + } + const status = response.statusCode ?? 502 + if (status >= 300 && status < 400) { + const location = response.headers.location + response.destroy() + if (location === undefined || redirects === MAX_REDIRECTS) throw new AvatarHttpError(502, 'remote avatar redirect was rejected') + url = new URL(location, url) + normalizeRemoteAvatarUrl(url.toString()) + continue + } + if (status < 200 || status >= 300) { + response.destroy() + throw new AvatarHttpError(502, `remote avatar returned HTTP ${status}`) + } + const data = await readLimitedBody(response, maxBytes) + const header = response.headers['content-type'] + const contentType = Array.isArray(header) ? header[0] : header + return { data, type: validateAvatarImage(data, contentType, maxBytes) } + } + throw new AvatarHttpError(502, 'remote avatar redirect was rejected') +} + +function sendJson(res: ServerResponse, status: number, body: unknown): void { + res.writeHead(status, { + 'content-type': 'application/json; charset=utf-8', + 'cache-control': 'no-store', + 'x-content-type-options': 'nosniff', + }) + res.end(JSON.stringify(body)) +} + +function sendError(res: ServerResponse, error: unknown): void { + const status = error instanceof AvatarHttpError ? error.status : 500 + const message = error instanceof AvatarHttpError ? error.message : 'avatar request failed' + sendJson(res, status, { error: message }) +} + +function requireSameOriginMutation(req: IncomingMessage): void { + if (req.headers['sec-fetch-site'] === 'cross-site') { + throw new AvatarHttpError(403, 'cross-site avatar changes are not allowed') + } + const marker = req.headers['x-agent-teams-request'] + if (marker !== 'avatar-v1') throw new AvatarHttpError(403, 'avatar request marker is missing') +} + +function parseMutationTarget(req: IncomingMessage): AvatarMutationTarget { + const url = new URL(req.url ?? '/', 'http://x') + const teamId = url.searchParams.get('team_id')?.trim() ?? '' + const captainSessionId = url.searchParams.get('captain_session_id')?.trim() ?? '' + const target = url.searchParams.get('target') + const memberName = url.searchParams.get('member')?.trim() + if (teamId === '' || sanitizeKey(teamId) !== teamId || captainSessionId === '') { + throw new AvatarHttpError(400, 'invalid team identity') + } + if (target !== 'captain' && target !== 'member') throw new AvatarHttpError(400, 'invalid avatar target') + if (target === 'member' && (memberName === undefined || memberName === '')) { + throw new AvatarHttpError(400, 'member avatar target is missing') + } + return { teamId, captainSessionId, target, ...memberName === undefined ? {} : { memberName } } +} + +function avatarOf(state: TeamState, target: AvatarMutationTarget): string | undefined { + if (target.target === 'captain') return state.captainAvatar + return state.members.find((member) => member.name === target.memberName)?.avatar +} + +function assignAvatar(state: TeamState, target: AvatarMutationTarget, source: string | undefined): void { + if (target.target === 'captain') { + state.captainAvatar = source + return + } + const member = state.members.find((candidate) => candidate.name === target.memberName && candidate.status !== 'removed') + if (member === undefined) throw new AvatarHttpError(404, 'active team member was not found') + member.avatar = source +} + +async function removeManagedAvatar(stateRoot: string, teamId: string, source: string | undefined): Promise { + const filename = managedFilename(source) + if (filename === undefined) return + await rm(managedAvatarPath(stateRoot, teamId, filename), { force: true }).catch(() => undefined) +} + +/** Build the process-local avatar service for one plugin instance. */ +export function createAvatarService( + ctx: Context, + workspaceRegistry: WorkspaceRegistry, + options: AvatarServiceOptions, +): AvatarService { + const tokenSecret = randomBytes(32) + const configuredRoles: Record = {} + for (const [role, source] of Object.entries(options.roleAvatars)) { + const key = role.trim().toLowerCase() + const url = publicConfiguredUrl(source) + if (key !== '' && url !== undefined) configuredRoles[key] = url + else if (source.trim() !== '') ctx.logger.warn(`agent-teams: ignored invalid avatar mapping for role "${role}"`) + } + const artwork: PublicAvatarArtwork = { + ...publicConfiguredUrl(options.captainAvatar) === undefined + ? {} + : { captainAvatarUrl: publicConfiguredUrl(options.captainAvatar) }, + roleAvatars: configuredRoles, + maxUploadBytes: options.maxUploadBytes, + } + const stateRoots = (): string[] => workspaceRegistry.list().map((workspace) => join(workspace.path, options.stateDir)) + const tokenFor = (stateRoot: string, team: TeamState): string => createHmac('sha256', tokenSecret) + .update(`${resolve(stateRoot)}\0${team.id}\0${team.captainSessionId}`) + .digest('base64url') + const publicUrl = (source: string, context: AvatarProjectionContext): string | undefined => { + const filename = managedFilename(source) + if (filename !== undefined) { + const root = context.historic ? dirname(context.stateRoot) : context.stateRoot + const query = new URLSearchParams({ root: workspaceKey(root), team: context.teamId, file: filename }) + if (context.historic) query.set('archived', '1') + return `${AVATAR_BASE}/avatar-file?${query.toString()}` + } + try { + return `${AVATAR_BASE}/avatar-proxy?url=${encodeURIComponent(normalizeRemoteAvatarUrl(source))}` + } catch { + return undefined + } + } + const findEditableTeam = async (target: AvatarMutationTarget): Promise => { + const matches: EditableTeam[] = [] + for (const stateRoot of stateRoots()) { + const state = await readTeam(stateRoot, target.teamId) + if (state?.captainSessionId === target.captainSessionId) matches.push({ stateRoot, state }) + } + if (matches.length === 0) throw new AvatarHttpError(404, 'captain team was not found') + if (matches.length > 1) throw new AvatarHttpError(409, 'captain team identity is ambiguous') + return matches[0]! + } + const authorize = (req: IncomingMessage, editable: EditableTeam): void => { + const supplied = req.headers['x-agent-teams-avatar-token'] + const actual = tokenFor(editable.stateRoot, editable.state) + if (typeof supplied !== 'string') throw new AvatarHttpError(403, 'avatar edit token is missing') + const suppliedBytes = Buffer.from(supplied) + const actualBytes = Buffer.from(actual) + if (suppliedBytes.length !== actualBytes.length || !timingSafeEqual(suppliedBytes, actualBytes)) { + throw new AvatarHttpError(403, 'avatar edit token is invalid') + } + } + const mutationHandler = async (req: IncomingMessage, res: ServerResponse): Promise => { + try { + requireSameOriginMutation(req) + if (req.method !== 'POST' && req.method !== 'PUT' && req.method !== 'DELETE') { + res.writeHead(405, { allow: 'POST, PUT, DELETE' }) + res.end() + return + } + const target = parseMutationTarget(req) + const located = await findEditableTeam(target) + authorize(req, located) + let upload: { data: Buffer; contentType: string | undefined; filename: string | undefined } | undefined + let next: string | undefined + if (req.method === 'POST') { + const data = await readLimitedBody(req, options.maxUploadBytes) + const contentType = Array.isArray(req.headers['content-type']) + ? req.headers['content-type'][0] + : req.headers['content-type'] + const filename = Array.isArray(req.headers['x-agent-teams-file-name']) + ? req.headers['x-agent-teams-file-name'][0] + : req.headers['x-agent-teams-file-name'] + const type = validateAvatarImage(data, contentType, options.maxUploadBytes) + validateAvatarFilename(filename, type) + upload = { data, contentType, filename } + } else if (req.method === 'PUT') { + const data = await readLimitedBody(req, MAX_AVATAR_JSON_BYTES) + let parsed: unknown + try { + parsed = JSON.parse(data.toString('utf8')) + } catch { + throw new AvatarHttpError(400, 'avatar URL payload is invalid JSON') + } + const url = typeof parsed === 'object' && parsed !== null && 'url' in parsed + ? (parsed as { url?: unknown }).url + : undefined + if (typeof url !== 'string') throw new AvatarHttpError(400, 'avatar URL is missing') + next = normalizeRemoteAvatarUrl(url) + } + let uploaded: string | undefined + try { + const changed = await withTeamLock(`team:${located.stateRoot}:${target.teamId}`, async () => { + const fresh = await readTeam(located.stateRoot, target.teamId) + if (fresh?.captainSessionId !== target.captainSessionId) { + throw new AvatarHttpError(409, 'captain team changed while editing its avatar') + } + const old = avatarOf(fresh, target) + if (upload !== undefined) { + uploaded = await storeManagedAvatar( + located.stateRoot, + target.teamId, + upload.data, + upload.contentType, + upload.filename, + options.maxUploadBytes, + ) + next = uploaded + } + try { + assignAvatar(fresh, target, next) + await writeTeam(located.stateRoot, fresh) + } catch (error: unknown) { + if (uploaded !== undefined) await removeManagedAvatar(located.stateRoot, target.teamId, uploaded) + throw error + } + return { old, next } + }) + await removeManagedAvatar(located.stateRoot, target.teamId, changed.old) + next = changed.next + } catch (error: unknown) { + if (uploaded !== undefined) await removeManagedAvatar(located.stateRoot, target.teamId, uploaded) + throw error + } + sendJson(res, 200, { avatarUrl: next === undefined ? null : publicUrl(next, { + stateRoot: located.stateRoot, + teamId: target.teamId, + historic: false, + }) }) + } catch (error: unknown) { + if (!(error instanceof AvatarHttpError)) ctx.logger.warn(`agent-teams: avatar mutation failed: ${String(error)}`) + sendError(res, error) + } + } + const fileHandler = async (req: IncomingMessage, res: ServerResponse): Promise => { + try { + if (req.method !== 'GET' && req.method !== 'HEAD') throw new AvatarHttpError(405, 'method not allowed') + const url = new URL(req.url ?? '/', 'http://x') + const rootKey = url.searchParams.get('root') ?? '' + const teamId = url.searchParams.get('team') ?? '' + const filename = url.searchParams.get('file') ?? '' + const archived = url.searchParams.get('archived') === '1' + if (!/^[0-9a-f]{24}$/u.test(rootKey) || teamId === '' || sanitizeKey(teamId) !== teamId) { + throw new AvatarHttpError(404, 'avatar file was not found') + } + const baseRoot = stateRoots().find((candidate) => workspaceKey(candidate) === rootKey) + if (baseRoot === undefined) throw new AvatarHttpError(404, 'avatar file was not found') + const stateRoot = archived ? join(baseRoot, 'archive') : baseRoot + const data = await readFile(managedAvatarPath(stateRoot, teamId, filename)) + res.writeHead(200, { + 'content-type': contentTypeForFilename(filename), + 'content-length': data.length, + 'cache-control': 'public, max-age=31536000, immutable', + 'x-content-type-options': 'nosniff', + }) + res.end(req.method === 'HEAD' ? undefined : data) + } catch (error: unknown) { + if (error instanceof AvatarHttpError && error.status === 405) { + res.writeHead(405, { allow: 'GET, HEAD' }) + res.end() + return + } + if (!(error instanceof AvatarHttpError)) + ctx.logger.warn(`agent-teams: managed avatar read failed: ${String(error)}`) + res.writeHead(error instanceof AvatarHttpError ? error.status : 404) + res.end() + } + } + const proxyHandler = async (req: IncomingMessage, res: ServerResponse): Promise => { + try { + if (req.method !== 'GET' && req.method !== 'HEAD') throw new AvatarHttpError(405, 'method not allowed') + const source = new URL(req.url ?? '/', 'http://x').searchParams.get('url') ?? '' + const { data, type } = await fetchRemoteAvatar(source, options.maxUploadBytes) + res.writeHead(200, { + 'content-type': type.contentType, + 'content-length': data.length, + 'cache-control': 'public, max-age=3600', + 'x-content-type-options': 'nosniff', + }) + res.end(req.method === 'HEAD' ? undefined : data) + } catch (error: unknown) { + if (error instanceof AvatarHttpError && error.status === 405) { + res.writeHead(405, { allow: 'GET, HEAD' }) + res.end() + return + } + if (!(error instanceof AvatarHttpError)) ctx.logger.warn(`agent-teams: remote avatar proxy failed: ${String(error)}`) + res.writeHead(error instanceof AvatarHttpError ? error.status : 502, { + 'cache-control': 'no-store', + 'x-content-type-options': 'nosniff', + }) + res.end() + } + } + return { + artwork, + publicUrl, + editToken: tokenFor, + register: (webServer) => [ + webServer.register({ kind: 'exact', path: `${AVATAR_BASE}/avatar`, handler: mutationHandler }), + webServer.register({ kind: 'exact', path: `${AVATAR_BASE}/avatar-file`, handler: fileHandler }), + webServer.register({ kind: 'exact', path: `${AVATAR_BASE}/avatar-proxy`, handler: proxyHandler }), + ], + } +} + +/** Return the writable avatar slot of a member, used only by focused tests. */ +export function memberAvatarTarget(state: TeamState, name: string): TeamMember | undefined { + return state.members.find((member) => member.name === name && member.status !== 'removed') +} diff --git a/src/client/ActivityPanel.module.css b/src/client/ActivityPanel.module.css index 79d95fc213..a84dcb6d87 100644 --- a/src/client/ActivityPanel.module.css +++ b/src/client/ActivityPanel.module.css @@ -439,6 +439,121 @@ margin-top: 1px; } +.avatarManageButton { + flex: none; + padding: 2px 7px; + border: 1px solid var(--dsw-alias-line-strong); + border-radius: 999px; + background: var(--dsw-alias-bg-module); + color: var(--dsw-alias-label-secondary); + font: inherit; + font-size: 10px; + line-height: 15px; + cursor: pointer; +} + +.avatarManageButton:hover, +.avatarManageButton[aria-expanded='true'] { + border-color: var(--dsw-alias-state-business-primary); + color: var(--dsw-alias-state-business-primary); +} + +.avatarEditor { + display: grid; + grid-template-columns: minmax(100px, 0.75fr) minmax(150px, 1.5fr); + gap: 7px 9px; + padding: 9px; + border: 1px solid var(--dsw-alias-line-normal); + border-radius: 9px; + background: var(--dsw-alias-bg-fill-neutral); +} + +.avatarField { + display: flex; + min-width: 0; + flex-direction: column; + gap: 3px; + color: var(--dsw-alias-label-secondary); + font-size: 9.5px; + line-height: 14px; +} + +.avatarField select, +.avatarField input { + width: 100%; + min-width: 0; + height: 28px; + padding: 3px 7px; + box-sizing: border-box; + border: 1px solid var(--dsw-alias-line-strong); + border-radius: 6px; + background: var(--dsw-alias-bg-module); + color: var(--dsw-alias-label-primary); + font: inherit; + font-size: 10.5px; +} + +.avatarHint, +.avatarMessage { + grid-column: 1 / -1; + color: var(--dsw-alias-label-tertiary); + font-size: 9.5px; + line-height: 14px; +} + +.avatarMessage[data-kind='success'] { + color: var(--dsw-alias-state-success); +} + +.avatarMessage[data-kind='error'] { + color: var(--dsw-alias-state-danger); +} + +.avatarActions { + display: flex; + grid-column: 1 / -1; + flex-wrap: wrap; + gap: 5px; +} + +.avatarActions button, +.avatarUpload { + display: inline-flex; + min-height: 25px; + align-items: center; + padding: 3px 8px; + box-sizing: border-box; + border: 1px solid var(--dsw-alias-line-strong); + border-radius: 6px; + background: var(--dsw-alias-bg-module); + color: var(--dsw-alias-label-secondary); + font: inherit; + font-size: 9.5px; + line-height: 15px; + cursor: pointer; +} + +.avatarActions button:hover, +.avatarUpload:hover { + border-color: var(--dsw-alias-state-business-primary); + color: var(--dsw-alias-state-business-primary); +} + +.avatarActions button:disabled, +.avatarUpload[data-disabled='true'] { + cursor: not-allowed; + opacity: 0.55; +} + +.avatarUpload input { + position: absolute; + width: 1px; + height: 1px; + overflow: hidden; + clip: rect(0 0 0 0); + clip-path: inset(50%); +} + .sectionHead { display: flex; align-items: center; diff --git a/src/client/ActivityPanel.tsx b/src/client/ActivityPanel.tsx index a52c518853..0243626c90 100644 --- a/src/client/ActivityPanel.tsx +++ b/src/client/ActivityPanel.tsx @@ -52,10 +52,13 @@ import { subscribeActivityMonitorTargets, subscribeActivitySnapshots, type ActivityMember, + type ActivityArtwork, type ActivityTask, type ActivityTeam, } from './activity-monitor.ts' -import { ACTION_ART, LEAD_ART, memberArtUrl } from './artwork.ts' +import { ACTION_ART, captainArtCandidates, memberArtCandidates } from './artwork.ts' +import { ArtworkImage } from './ArtworkImage.tsx' +import { clearAvatar, setAvatarUrl, uploadAvatarFile, type AvatarTarget } from './avatar-api.ts' import { OPEN_PANEL_EVENT } from './AgentTeamsCard.tsx' import { StagingPlanEditor } from './StagingPlanEditor.tsx' import type { AgentTeamsCardData } from './agent-teams-card-definition.ts' @@ -471,8 +474,102 @@ function DependencyMap({ tasks, members, t, discarded = false }: { ) } -function TeamSection({ team, modelDirectory, onContinuePlanning, onDiscarded, onNavigate, t, historic = false }: { +function AvatarEditor({ team, artwork, t, onClose }: { readonly team: ActivityTeam + readonly artwork: ActivityArtwork + readonly t: AgentTeamsTranslate + readonly onClose: () => void +}) { + const [targetKey, setTargetKey] = useState('captain') + const [url, setUrl] = useState('') + const [busy, setBusy] = useState(false) + const [message, setMessage] = useState<{ kind: 'success' | 'error'; text: string } | null>(null) + const target = useMemo(() => { + if (targetKey === 'captain') return { kind: 'captain' } + const index = Number(targetKey.slice('member:'.length)) + return { kind: 'member', name: team.members[index]?.name ?? '' } + }, [targetKey, team.members]) + const run = async (operation: () => Promise): Promise => { + setBusy(true) + setMessage(null) + try { + await operation() + setMessage({ kind: 'success', text: t('avatar.saved') }) + setUrl('') + } catch (error: unknown) { + setMessage({ kind: 'error', text: error instanceof Error ? error.message : t('avatar.failed') }) + } finally { + setBusy(false) + } + } + return ( +
{ + event.preventDefault() + if (url.trim() === '') { + setMessage({ kind: 'error', text: t('avatar.urlRequired') }) + return + } + void run(() => setAvatarUrl(team, target, url)) + }} + > + + + {t('avatar.hint', { mb: Math.floor(artwork.maxUploadBytes / 1024 / 1024) })} + {message !== null && {message.text}} + + + + + + +
+ ) +} + +function TeamSection({ team, artwork, modelDirectory, onContinuePlanning, onDiscarded, onNavigate, t, historic = false }: { + readonly team: ActivityTeam + readonly artwork: ActivityArtwork readonly modelDirectory?: ModelDirectory readonly onContinuePlanning?: () => void readonly onDiscarded?: () => void @@ -487,6 +584,7 @@ function TeamSection({ team, modelDirectory, onContinuePlanning, onDiscarded, on const [stopError, setStopError] = useState('') const discarded = historic && team.phase === 'staged' const stopped = !historic && team.halted === true + const [avatarEditorOpen, setAvatarEditorOpen] = useState(false) const busyCount = team.members.filter((member) => member.activity === 'working').length const assignedCount = team.tasks.filter((task) => task.assignee !== '' && task.assignee !== CAPTAIN_ASSIGNEE).length const captainOwned = team.tasks.filter((task) => task.assignee === CAPTAIN_ASSIGNEE @@ -535,6 +633,16 @@ function TeamSection({ team, modelDirectory, onContinuePlanning, onDiscarded, on {team.name} {historic && {t(discarded ? 'team.discarded' : 'team.ended')}} {stopped && {t('team.stopped')}} + {!historic && team.avatarEditToken !== undefined && ( + + )} {t('team.stats.members', { count: team.members.length })} {t('team.stats.completed', { completed: completedCount, total: team.tasks.length })} @@ -563,10 +671,17 @@ function TeamSection({ team, modelDirectory, onContinuePlanning, onDiscarded, on /> )} + {avatarEditorOpen && !historic && ( + { setAvatarEditorOpen(false) }} /> + )} +
- + @@ -629,11 +744,11 @@ function TeamSection({ team, modelDirectory, onContinuePlanning, onDiscarded, on }} > 0}> - {memberArtUrl(member.name, member.role) !== null ? ( - - ) : ( - {memberInitial(member.name)} - )} + {memberInitial(member.name)}} + /> @@ -738,6 +853,7 @@ function historicCardTeam(data: AgentTeamsCardData, owner: string): ActivityTeam name: data.teamName, captainSessionId: data.captainSessionId || owner, phase: 'running', + ...data.captainAvatarUrl === undefined ? {} : { captainAvatarUrl: data.captainAvatarUrl }, members: data.members.map((member) => ({ ...member, status: 'removed', @@ -805,7 +921,7 @@ export function ActivityPanel({ sessionsList, modelDirectories, openMember, t }: document.querySelector('[data-composer-card] textarea')?.focus() }) } - const { teams, archivedTeams } = useSyncExternalStore( + const { teams, archivedTeams, artwork } = useSyncExternalStore( subscribeActivitySnapshots, getActivitySnapshotsSnapshot, ) @@ -1235,6 +1351,7 @@ export function ActivityPanel({ sessionsList, modelDirectories, openMember, t }: (
{t(team.phase === 'staged' ? 'archive.discardedLabel' : 'archive.label')} - +
))} {visibleHistoric.map(({ data: team, owner }) => { const teamKey = `${owner}:${team.teamId}` return ( - + ) })} diff --git a/src/client/AgentTeamsCard.tsx b/src/client/AgentTeamsCard.tsx index 5c447cdd25..8720873298 100644 --- a/src/client/AgentTeamsCard.tsx +++ b/src/client/AgentTeamsCard.tsx @@ -19,7 +19,8 @@ import { subscribeActivitySnapshots, } from './activity-monitor.ts' import type { AgentTeamsCardData } from './agent-teams-card-definition.ts' -import { LEAD_ART, memberArtUrl } from './artwork.ts' +import { captainArtCandidates, memberArtCandidates } from './artwork.ts' +import { ArtworkImage } from './ArtworkImage.tsx' import css from './AgentTeamsCard.module.css' /** Window event name the floater listens for to open itself. */ @@ -56,7 +57,7 @@ export function AgentTeamsCard({ node, openMember, sessionId, t }: AgentTeamsCar // `conversation.chat.node` is session-scoped, so its framework-owned id is // a stable owner even while another conversation becomes current. const owner = data.captainSessionId || sessionId - const { teams, archivedTeams } = useSyncExternalStore( + const { teams, archivedTeams, artwork } = useSyncExternalStore( subscribeActivitySnapshots, getActivitySnapshotsSnapshot, ) @@ -69,12 +70,21 @@ export function AgentTeamsCard({ node, openMember, sessionId, t }: AgentTeamsCar ...data, captainSessionId: snapshot?.captainSessionId ?? owner, teamName: snapshot?.name ?? data.teamName, - members: snapshot?.members.map((member) => ({ id: member.id, name: member.name, role: member.role })) ?? data.members, + ...snapshot?.captainAvatarUrl === undefined ? {} : { captainAvatarUrl: snapshot.captainAvatarUrl }, + members: snapshot?.members.map((member) => ({ + id: member.id, + name: member.name, + role: member.role, + ...member.avatarUrl === undefined ? {} : { avatarUrl: member.avatarUrl }, + })) ?? data.members, }), [data, owner, snapshot]) return (
- + {resolved.teamName} {t('card.memberCount', { count: resolved.members.length })} ))} diff --git a/src/client/ArtworkImage.tsx b/src/client/ArtworkImage.tsx new file mode 100644 index 0000000000..6add81b16c --- /dev/null +++ b/src/client/ArtworkImage.tsx @@ -0,0 +1,27 @@ +/** Image renderer that advances through avatar fallbacks after load errors. */ + +import { useEffect, useMemo, useState, type ReactNode } from 'react' + +export interface ArtworkImageProps { + readonly sources: readonly string[] + readonly className?: string + readonly fallback?: ReactNode +} + +export function ArtworkImage({ sources, className, fallback = null }: ArtworkImageProps) { + const key = sources.join('\u0000') + const stableSources = useMemo(() => sources, [key]) + const [index, setIndex] = useState(0) + useEffect(() => { setIndex(0) }, [key]) + const source = stableSources[index] + if (source === undefined) return fallback + return ( + { setIndex((current) => current + 1) }} + /> + ) +} diff --git a/src/client/activity-monitor.ts b/src/client/activity-monitor.ts index a2f34630a3..fca838abb2 100644 --- a/src/client/activity-monitor.ts +++ b/src/client/activity-monitor.ts @@ -9,6 +9,7 @@ export interface ActivityMember { readonly model?: string readonly reasoningEffort?: string readonly executionPrompt?: string + readonly avatarUrl?: string readonly status?: 'idle' | 'working' | 'removed' readonly activity: 'working' | 'idle' | 'unknown' readonly progress: number @@ -50,12 +51,21 @@ export interface ActivityTeam { readonly phase: 'staged' | 'running' readonly planReviewState?: 'awaiting_review' | 'awaiting_feedback' readonly halted?: boolean + readonly captainAvatarUrl?: string + readonly avatarEditToken?: string readonly members: readonly ActivityMember[] readonly tasks: readonly ActivityTask[] readonly messageCount: number readonly captainInbox: readonly ActivityMessage[] } +/** Host-projected plugin defaults shared by the panel and conversation card. */ +export interface ActivityArtwork { + readonly captainAvatarUrl?: string + readonly roleAvatars: Readonly> + readonly maxUploadBytes: number +} + /** A successfully-created conversation card that currently needs updates. */ export interface ActivityMonitorTarget { readonly key: string @@ -67,6 +77,7 @@ export interface ActivityMonitorTarget { export interface ActivitySnapshots { readonly teams: readonly ActivityTeam[] readonly archivedTeams: readonly ActivityTeam[] + readonly artwork: ActivityArtwork } interface RegisteredTarget extends ActivityMonitorTarget { @@ -78,7 +89,11 @@ const targets = new Map() const targetListeners = new Set<() => void>() const snapshotListeners = new Set<() => void>() let targetSnapshot: readonly ActivityMonitorTarget[] = [] -let activitySnapshots: ActivitySnapshots = { teams: [], archivedTeams: [] } +let activitySnapshots: ActivitySnapshots = { + teams: [], + archivedTeams: [], + artwork: { roleAvatars: {}, maxUploadBytes: 2 * 1024 * 1024 }, +} function targetKey(sessionId: string, teamId: string): string { return `${sessionId}\u0000${teamId}` @@ -166,8 +181,11 @@ export function updateActivitySnapshots(update: Partial): voi const next = { teams: update.teams ?? activitySnapshots.teams, archivedTeams: update.archivedTeams ?? activitySnapshots.archivedTeams, + artwork: update.artwork ?? activitySnapshots.artwork, } - if (next.teams === activitySnapshots.teams && next.archivedTeams === activitySnapshots.archivedTeams) return + if (next.teams === activitySnapshots.teams + && next.archivedTeams === activitySnapshots.archivedTeams + && next.artwork === activitySnapshots.artwork) return activitySnapshots = next for (const listener of snapshotListeners) listener() } @@ -190,6 +208,18 @@ interface ActivityFetchResponse { json(): Promise } +function isActivityArtwork(value: unknown): value is ActivityArtwork { + if (typeof value !== 'object' || value === null) return false + const candidate = value as Partial + return (candidate.captainAvatarUrl === undefined || typeof candidate.captainAvatarUrl === 'string') + && typeof candidate.roleAvatars === 'object' + && candidate.roleAvatars !== null + && Object.values(candidate.roleAvatars).every((url) => typeof url === 'string') + && typeof candidate.maxUploadBytes === 'number' + && Number.isSafeInteger(candidate.maxUploadBytes) + && candidate.maxUploadBytes > 0 +} + /** Injectable browser primitives used by the poll controller and its tests. */ export interface ActivityPollingRuntime { /** @@ -269,10 +299,11 @@ export function startActivityPolling( signal: controller.signal, }) if (!liveResponse.ok) return - const body = (await liveResponse.json()) as { teams?: unknown } + const body = (await liveResponse.json()) as { teams?: unknown; artwork?: unknown } if (cancelled || !Array.isArray(body.teams)) return const liveTeams = body.teams as readonly ActivityTeam[] - publishSnapshots({ teams: liveTeams }) + const artwork = isActivityArtwork(body.artwork) ? body.artwork : undefined + publishSnapshots({ teams: liveTeams, ...artwork === undefined ? {} : { artwork } }) const previousDiscoveredKeys = discoveredLiveKeys discoveredLiveKeys = new Set(discoverySessionId === undefined || discoverySessionId === '' ? [] diff --git a/src/client/agent-teams-card-definition.ts b/src/client/agent-teams-card-definition.ts index 89e0092aed..a26d15825f 100644 --- a/src/client/agent-teams-card-definition.ts +++ b/src/client/agent-teams-card-definition.ts @@ -28,10 +28,12 @@ export interface AgentTeamsCardData { /** The captain session that owns this team (panel follows it). */ readonly captainSessionId: string readonly teamName: string + readonly captainAvatarUrl?: string readonly members: readonly { readonly id: string readonly name: string readonly role: string + readonly avatarUrl?: string }[] } diff --git a/src/client/artwork.ts b/src/client/artwork.ts index 04c7ae170b..cb715cc263 100644 --- a/src/client/artwork.ts +++ b/src/client/artwork.ts @@ -8,18 +8,18 @@ /** Artwork route prefix served by the plugin host half. */ export const ART_BASE = '/plugins/dsh-agent-teams/assets/' -/** V2 whale role artwork per role keyword. */ -const ROLE_ART: ReadonlyArray = [ - [/data|analys|metric|performance|数据|分析|指标|性能/, 'member-data-v2.png'], - [/resear|investig|explor|study|研究|调查|探索|调研/, 'member-researcher-v2.png'], +/** V2 whale role artwork per canonical category and role keyword. */ +const ROLE_ART: ReadonlyArray = [ + ['data', /data|analys|metric|performance|数据|分析|指标|性能/, 'member-data-v2.png'], + ['researcher', /resear|investig|explor|study|研究|调查|探索|调研/, 'member-researcher-v2.png'], // Match compound QA titles (for example "QA Engineer") before the broad // engineer bucket, otherwise an eight-role roster repeats the engineer art. - [/\bqa\b|test|verif|quality|测试|质量|验证/, 'member-qa-v2.png'], - [/engineer|dev\b|server|backend|\bapi\b|runtime|watcher|contract|工程|后端|服务|接口|开发|代码|编程/, 'member-engineer-v2.png'], - [/design|\bui\b|\bux\b|front|theme|accessib|设计|前端|主题|无障碍/, 'member-designer-v2.png'], - [/secur|audit|risk|threat|review|安全|审计|审查|风险/, 'member-security-v2.png'], - [/docs|writer|product|spec|撰写|文案|写作|文档|规范/, 'member-docs-v2.png'], - [/release|\bbuild\b|deploy|\bops\b|\bci\b|ship|coordin|发布|构建|部署|运维|协调/, 'member-operator-v2.png'], + ['qa', /\bqa\b|test|verif|quality|测试|质量|验证/, 'member-qa-v2.png'], + ['engineer', /engineer|dev\b|server|backend|\bapi\b|runtime|watcher|contract|工程|后端|服务|接口|开发|代码|编程/, 'member-engineer-v2.png'], + ['designer', /design|\bui\b|\bux\b|front|theme|accessib|设计|前端|主题|无障碍/, 'member-designer-v2.png'], + ['security', /secur|audit|risk|threat|review|安全|审计|审查|风险/, 'member-security-v2.png'], + ['docs', /docs|writer|product|spec|撰写|文案|写作|文档|规范/, 'member-docs-v2.png'], + ['operator', /release|\bbuild\b|deploy|\bops\b|\bci\b|ship|coordin|发布|构建|部署|运维|协调/, 'member-operator-v2.png'], ] /** Captain artwork (always the lead whale). */ @@ -38,10 +38,41 @@ export const ACTION_ART: Record<'working' | 'idle' | 'unknown', string> = { * @param role - the member's role text. * @returns the artwork URL, or null when unmatched. */ -export function memberArtUrl(name: string, role: string): string | null { +export function memberArtCandidates( + name: string, + role: string, + customAvatarUrl?: string, + roleAvatars: Readonly> = {}, +): string[] { const identity = `${name} ${role}`.toLowerCase() - for (const [pattern, art] of ROLE_ART) { - if (pattern.test(identity)) return `${ART_BASE}${art}` + const candidates: string[] = [] + if (customAvatarUrl !== undefined && customAvatarUrl.trim() !== '') candidates.push(customAvatarUrl) + // Arbitrary configured role keys match first; canonical defaults below also + // let `researcher` override broad aliases such as “investigator”. + for (const [key, url] of Object.entries(roleAvatars)) { + if (key.trim() !== '' && identity.includes(key.trim().toLowerCase())) candidates.push(url) } - return null + for (const [category, pattern, art] of ROLE_ART) { + if (!pattern.test(identity)) continue + const override = roleAvatars[category] + if (override !== undefined) candidates.push(override) + candidates.push(`${ART_BASE}${art}`) + break + } + return [...new Set(candidates.filter((url) => url.trim() !== ''))] +} + +export function memberArtUrl( + name: string, + role: string, + customAvatarUrl?: string, + roleAvatars: Readonly> = {}, +): string | null { + return memberArtCandidates(name, role, customAvatarUrl, roleAvatars)[0] ?? null +} + +/** Captain candidates in descending priority, ending in packaged artwork. */ +export function captainArtCandidates(teamAvatarUrl?: string, configuredAvatarUrl?: string): string[] { + return [...new Set([teamAvatarUrl, configuredAvatarUrl, LEAD_ART] + .filter((url): url is string => typeof url === 'string' && url.trim() !== ''))] } diff --git a/src/client/avatar-api.ts b/src/client/avatar-api.ts new file mode 100644 index 0000000000..447ee4629b --- /dev/null +++ b/src/client/avatar-api.ts @@ -0,0 +1,60 @@ +/** Browser client for the CSRF-protected host avatar mutation endpoint. */ + +import type { ActivityTeam } from './activity-monitor.ts' + +const AVATAR_URL = '/plugins/dsh-agent-teams/avatar' + +export type AvatarTarget = { readonly kind: 'captain' } | { readonly kind: 'member'; readonly name: string } + +function mutationUrl(team: ActivityTeam, target: AvatarTarget): string { + const query = new URLSearchParams({ + team_id: team.teamId, + captain_session_id: team.captainSessionId, + target: target.kind, + }) + if (target.kind === 'member') query.set('member', target.name) + return `${AVATAR_URL}?${query.toString()}` +} + +async function avatarRequest( + team: ActivityTeam, + target: AvatarTarget, + method: 'POST' | 'PUT' | 'DELETE', + body?: BodyInit, + headers: Record = {}, +): Promise { + if (team.avatarEditToken === undefined) throw new Error('avatar editing is unavailable for this team') + const response = await fetch(mutationUrl(team, target), { + method, + body, + headers: { + 'x-agent-teams-request': 'avatar-v1', + 'x-agent-teams-avatar-token': team.avatarEditToken, + ...headers, + }, + }) + if (response.ok) return + let message = `avatar request failed (${response.status})` + try { + const parsed = await response.json() as { error?: unknown } + if (typeof parsed.error === 'string') message = parsed.error + } catch { + // Preserve the status-based fallback for a host restart or non-JSON error. + } + throw new Error(message) +} + +export async function setAvatarUrl(team: ActivityTeam, target: AvatarTarget, url: string): Promise { + await avatarRequest(team, target, 'PUT', JSON.stringify({ url }), { 'content-type': 'application/json' }) +} + +export async function uploadAvatarFile(team: ActivityTeam, target: AvatarTarget, file: File): Promise { + await avatarRequest(team, target, 'POST', file, { + 'content-type': file.type, + 'x-agent-teams-file-name': encodeURIComponent(file.name), + }) +} + +export async function clearAvatar(team: ActivityTeam, target: AvatarTarget): Promise { + await avatarRequest(team, target, 'DELETE') +} diff --git a/src/client/locales.ts b/src/client/locales.ts index f7e9a05ce4..80be11bb78 100644 --- a/src/client/locales.ts +++ b/src/client/locales.ts @@ -25,6 +25,20 @@ export const zh = { 'team.stopFailed': '停止失败:{message}', 'team.stopRequestFailed': '服务器未能停止团队,请重试', 'team.discarded': '已放弃', + 'avatar.manage': '头像', + 'avatar.target': '应用到', + 'avatar.captain': '队长', + 'avatar.url': '图片 URL', + 'avatar.hint': '支持 PNG/JPEG/WebP,最大 {mb} MB;外链由本机安全代理加载。', + 'avatar.applyUrl': '使用 URL', + 'avatar.upload': '上传图片', + 'avatar.clear': '恢复默认', + 'avatar.close': '关闭', + 'avatar.saved': '头像已保存,面板与会话卡片将同步更新。', + 'avatar.failed': '头像更新失败', + 'avatar.urlRequired': '请输入 HTTP(S) 图片 URL。', + 'avatar.fileType': '只支持 PNG、JPEG 或 WebP 图片。', + 'avatar.fileTooLarge': '图片超过上传大小限制。', 'format.listSeparator': '、', 'task.status.pending': '待领取', 'task.status.claimed': '已认领', @@ -220,6 +234,20 @@ export const en = { 'team.stopFailed': 'Could not stop team: {message}', 'team.stopRequestFailed': 'The server could not stop this team. Try again.', 'team.discarded': 'Discarded', + 'avatar.manage': 'Avatar', + 'avatar.target': 'Apply to', + 'avatar.captain': 'Captain', + 'avatar.url': 'Image URL', + 'avatar.hint': 'PNG/JPEG/WebP up to {mb} MB; remote images load through the local safety proxy.', + 'avatar.applyUrl': 'Use URL', + 'avatar.upload': 'Upload image', + 'avatar.clear': 'Restore default', + 'avatar.close': 'Close', + 'avatar.saved': 'Avatar saved; the panel and conversation card will update together.', + 'avatar.failed': 'Avatar update failed', + 'avatar.urlRequired': 'Enter an HTTP(S) image URL.', + 'avatar.fileType': 'Only PNG, JPEG, or WebP images are supported.', + 'avatar.fileTooLarge': 'The image exceeds the upload size limit.', 'format.listSeparator': ', ', 'task.status.pending': 'Unclaimed', 'task.status.claimed': 'Claimed', diff --git a/src/index.ts b/src/index.ts index 9811591db7..102014ce2f 100644 --- a/src/index.ts +++ b/src/index.ts @@ -38,6 +38,7 @@ import { collectArchivedTeamsActivity, collectTeamsActivity } from './snapshot.t import { findTeamByCaptain } from './state.ts' import { formatProfilesForPrompt, type TeamProfileConfig } from './profiles.ts' import { qualityPlanningPrompt } from './quality-gates.ts' +import { createAvatarService, DEFAULT_AVATAR_MAX_BYTES } from './avatar.ts' import { authenticatedWebRoutes, readJsonRequest, RequestBodyError, type BrowserRequestGate, type WebRouteHost } from './web-routes.ts' @@ -78,6 +79,12 @@ export interface Config { * Disable to keep the natural-language trigger as the only entry point. */ slashCommand?: boolean + /** Default captain avatar URL. A per-team selection from the panel overrides it. */ + captainAvatar?: string + /** Role/category keys to custom avatar URLs (for example `researcher`). */ + roleAvatars?: Record + /** Upload/proxy byte limit; may be reduced but never exceed 2 MiB. */ + avatarMaxBytes?: number } // `z.object()` has an implicit `{}` default in Schemastery. Fallback routes @@ -128,6 +135,9 @@ export const Config: z = z.object({ maxMembers: z.natural().min(1).default(8), promptSectionOrder: z.natural().default(117), slashCommand: z.boolean().default(true), + captainAvatar: z.string().default(''), + roleAvatars: z.dict(z.string()).default({}), + avatarMaxBytes: z.natural().min(1024).max(DEFAULT_AVATAR_MAX_BYTES).default(DEFAULT_AVATAR_MAX_BYTES), }) /** The model-facing usage policy: when and how to drive AgentTeams. */ @@ -221,6 +231,13 @@ export function apply(ctx: Context, config: Config): void { const webServer = authenticatedWebRoutes(rawWebServer, () => ctx.get('connection') as BrowserRequestGate | undefined) webRegistered = true + const avatarService = createAvatarService(ctx, workspaceRegistry, { + stateDir: resolved.stateDir, + captainAvatar: config.captainAvatar ?? '', + roleAvatars: config.roleAvatars ?? {}, + maxUploadBytes: config.avatarMaxBytes ?? DEFAULT_AVATAR_MAX_BYTES, + }) + // Activity panel data route: the browser floater polls this for team // snapshots (disk truth + live subagent activity). Mirrors the Claude // Code desktop watcher's server-side snapshot pattern. @@ -235,9 +252,12 @@ export function apply(ctx: Context, config: Config): void { })) // ?archived=1 serves teams moved to archive/ (post-delete review). const snapshots = url.searchParams.get('archived') === '1' - ? await collectArchivedTeamsActivity(ctx, roots) - : await collectTeamsActivity(ctx, roots) - const body = JSON.stringify({ teams: snapshots }) + ? await collectArchivedTeamsActivity(ctx, roots, { avatarUrl: avatarService.publicUrl }) + : await collectTeamsActivity(ctx, roots, { + avatarUrl: avatarService.publicUrl, + avatarEditToken: avatarService.editToken, + }) + const body = JSON.stringify({ teams: snapshots, artwork: avatarService.artwork }) res.writeHead(200, { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store', @@ -471,6 +491,15 @@ export function apply(ctx: Context, config: Config): void { } }, }), 'agent-teams: artwork route') + + // Custom avatars use separate exact routes: one CSRF-protected mutation + // endpoint, one allowlisted managed-file reader, and one bounded remote + // proxy for Electron CSP. The packaged ART_ALLOWLIST route above remains + // unchanged and cannot be bypassed by these dynamic sources. + ctx.effect(() => { + const disposers = avatarService.register(webServer) + return () => { for (const dispose of disposers.reverse()) dispose() } + }, 'agent-teams: custom avatar routes') } registerWebSurface() diff --git a/src/snapshot.ts b/src/snapshot.ts index bb1ff1226a..125590acef 100644 --- a/src/snapshot.ts +++ b/src/snapshot.ts @@ -17,6 +17,7 @@ import { taskDepthsById, taskVisualState, } from './state.ts' import type { MemberStatus, TeamState, TeamTask } from './types.ts' +import type { AvatarProjectionContext } from './avatar.ts' /** Visual task state for the activity panel. */ export type VisualTaskState = 'blocked' | 'open' | 'running' | 'completed' | 'failed' | 'cancelled' @@ -30,6 +31,8 @@ export interface TeamActivityMember { readonly model: string readonly reasoningEffort: string readonly executionPrompt: string + /** First-party URL for the member's explicit custom avatar, when set. */ + readonly avatarUrl?: string readonly status: MemberStatus readonly activity: 'working' | 'idle' | 'unknown' readonly progress: number @@ -71,6 +74,10 @@ export interface TeamActivitySnapshot { readonly phase: 'staged' | 'running' readonly planReviewState?: 'awaiting_review' | 'awaiting_feedback' readonly halted?: boolean + /** First-party URL for this team's captain override, when set. */ + readonly captainAvatarUrl?: string + /** Process-local CSRF capability; live captain UI only, never durable. */ + readonly avatarEditToken?: string readonly members: readonly TeamActivityMember[] readonly tasks: readonly TeamActivityTask[] readonly messageCount: number @@ -83,6 +90,10 @@ export interface TeamSnapshotOptions { readonly includeRemoved?: boolean /** Archived teams have no meaningful live activity after their sessions stop. */ readonly historic?: boolean + /** Project a durable avatar reference into a browser-safe same-origin URL. */ + readonly avatarUrl?: (source: string, context: AvatarProjectionContext) => string | undefined + /** Mint the edit capability exposed only on live team snapshots. */ + readonly avatarEditToken?: (stateRoot: string, state: TeamState) => string } /** The current task of a member: its first unfinished owned task. */ @@ -126,6 +137,11 @@ export async function assembleTeamSnapshot( ? new Map() : memberActivity(ctx, roster.map((member) => member.id)) const unreadByMember = new Map() + const projectAvatar = (source: string | undefined): string | undefined => { + if (source === undefined) return undefined + if (options.avatarUrl === undefined) return source + return options.avatarUrl(source, { stateRoot, teamId: state.id, historic: options.historic === true }) + } for (const member of roster) { try { unreadByMember.set(member.name, (await readUnreadMailbox(stateRoot, state.id, member.name)).length) @@ -137,6 +153,7 @@ export async function assembleTeamSnapshot( const members: TeamActivityMember[] = roster.map((member) => { const owned = tasks.filter((task) => task.assignee === member.name) const done = owned.filter((task) => task.status === 'completed').length + const avatarUrl = projectAvatar(member.avatar) return { id: member.id, name: member.name, @@ -145,6 +162,7 @@ export async function assembleTeamSnapshot( model: member.model?.trim() ?? '', reasoningEffort: member.reasoningEffort?.trim() ?? '', executionPrompt: member.executionPrompt ?? '', + ...avatarUrl === undefined ? {} : { avatarUrl }, status: member.status, activity: options.historic === true ? 'idle' @@ -163,6 +181,7 @@ export async function assembleTeamSnapshot( } }) const captainInbox = await readUnreadMailbox(stateRoot, state.id, CAPTAIN_KEY) + const captainAvatarUrl = projectAvatar(state.captainAvatar) return { workspace, teamId: state.id, @@ -174,6 +193,12 @@ export async function assembleTeamSnapshot( ? { planReviewState: state.planReviewState ?? 'awaiting_review' as const } : {}, ...state.halted === true ? { halted: true } : {}, + ...captainAvatarUrl === undefined + ? {} + : { captainAvatarUrl }, + ...options.historic === true || options.avatarEditToken === undefined + ? {} + : { avatarEditToken: options.avatarEditToken(stateRoot, state) }, members, tasks: tasks.map((task) => ({ id: task.id, @@ -207,6 +232,7 @@ export async function assembleTeamSnapshot( export async function collectTeamsActivity( ctx: Context, roots: readonly { workspace: string; stateRoot: string }[], + presentation: Pick = {}, ): Promise { const snapshots: TeamActivitySnapshot[] = [] for (const root of roots) { @@ -224,7 +250,7 @@ export async function collectTeamsActivity( try { const state = await readTeam(root.stateRoot, entry.name) if (state === undefined) continue - snapshots.push(await assembleTeamSnapshot(ctx, root.stateRoot, root.workspace, state)) + snapshots.push(await assembleTeamSnapshot(ctx, root.stateRoot, root.workspace, state, presentation)) } catch { ctx.logger.warn(`agent-teams: skipped unreadable team state "${entry.name}" in workspace "${root.workspace}"`) } @@ -244,6 +270,7 @@ export async function collectTeamsActivity( export async function collectArchivedTeamsActivity( ctx: Context, roots: readonly { workspace: string; stateRoot: string }[], + presentation: Pick = {}, ): Promise { const snapshots: TeamActivitySnapshot[] = [] for (const root of roots) { @@ -256,7 +283,7 @@ export async function collectArchivedTeamsActivity( join(root.stateRoot, 'archive'), root.workspace, state, - { includeRemoved: true, historic: true }, + { ...presentation, includeRemoved: true, historic: true }, )) } catch { ctx.logger.warn(`agent-teams: skipped unreadable archived team "${teamId}" in workspace "${root.workspace}"`) diff --git a/src/state.ts b/src/state.ts index 6a04ddd782..f3781783c0 100644 --- a/src/state.ts +++ b/src/state.ts @@ -670,6 +670,7 @@ function isTeamMember(value: unknown): value is TeamMember { && typeof value['name'] === 'string' && value['name'].trim() !== '' && isOptionalString(value['role']) + && isOptionalString(value['avatar']) && isOptionalString(value['provider']) && isOptionalString(value['model']) && isOptionalString(value['reasoningEffort']) @@ -784,6 +785,7 @@ function isTeamState(value: unknown, expectedId: string): value is TeamState { && (value['profile'] === undefined || isTeamProfileSnapshot(value['profile'])) && typeof value['captainSessionId'] === 'string' && value['captainSessionId'] !== '' + && isOptionalString(value['captainAvatar']) && isFiniteNumber(value['createdAt']) && Array.isArray(value['members']) && value['members'].every(isTeamMember) diff --git a/src/tools.ts b/src/tools.ts index 27e6eb00ae..592adad79a 100644 --- a/src/tools.ts +++ b/src/tools.ts @@ -66,6 +66,7 @@ import { type MemberRuntimeConfig, } from './members.ts' import { TERMINAL_TASK_STATUSES, type TeamMember, type TeamState, type TeamTask } from './types.ts' +import { normalizeRemoteAvatarUrl } from './avatar.ts' import { installTeamScheduler } from './scheduler.ts' import { resolveTeamProfile } from './profiles.ts' @@ -685,6 +686,7 @@ export function registerAgentTeamsTools(ctx: Context, config: ToolsConfig): Agen enum: ['required', 'automatic'], description: 'required stages the plan for explicit user review; automatic starts immediately. Defaults to automatic for API compatibility.', }, + avatar_url: { type: 'string', description: 'Optional HTTP(S) avatar URL for the captain. The activity panel can also upload one later.' }, }, output: { schema: { @@ -722,6 +724,9 @@ export function registerAgentTeamsTools(ctx: Context, config: ToolsConfig): Agen const profileName = args.profile !== undefined && args.profile.trim() !== '' ? args.profile.trim() : undefined + const captainAvatar = args.avatar_url === undefined + ? undefined + : normalizeRemoteAvatarUrl(args.avatar_url) const created = await withTeamLock(captainLockKey(stateRoot, captain.id), async () => { const current = await findTeamByParticipant(stateRoot, captain.id) if (current !== undefined) { @@ -739,6 +744,7 @@ export function registerAgentTeamsTools(ctx: Context, config: ToolsConfig): Agen id: teamId, description: args.description, captainSessionId: captain.id, + ...captainAvatar === undefined ? {} : { captainAvatar }, createdAt: Date.now(), members: [], tasks: [], @@ -759,6 +765,7 @@ export function registerAgentTeamsTools(ctx: Context, config: ToolsConfig): Agen teamId, profileName, description: args.description, + captainAvatar, staged, }) }) @@ -998,6 +1005,7 @@ export function registerAgentTeamsTools(ctx: Context, config: ToolsConfig): Agen model: { type: 'string', description: 'Optional model override. Omit for the captain\'s current model (or the configured memberModel default).' }, reasoning_effort: { type: 'string', description: 'Optional reasoning effort override: one of the target model\'s supported effort ids, or "default" to force its default. When omitted, the captain\'s effort is inherited only for the same provider/model; a changed route uses the target default.' }, executionPrompt: { type: 'string', description: 'Optional member-specific execution prompt. It remains editable while staged.' }, + avatar_url: { type: 'string', description: 'Optional HTTP(S) avatar URL for this member. It takes priority over role artwork.' }, }, output: { schema: { @@ -1050,6 +1058,7 @@ export function registerAgentTeamsTools(ctx: Context, config: ToolsConfig): Agen id: '', name: memberName, role: args.role, + ...args.avatar_url === undefined ? {} : { avatar: normalizeRemoteAvatarUrl(args.avatar_url) }, provider: selection.provider, model: selection.model, reasoningEffort: selection.reasoningEffort, @@ -2105,6 +2114,7 @@ async function initializeProfileTeam(input: { teamId: string profileName: string description?: string + captainAvatar?: string staged: boolean }): Promise<{ committed: true; state: TeamState }> { const profile = resolveTeamProfile(input.config.profiles, input.profileName, input.config.maxMembers) @@ -2136,6 +2146,7 @@ async function initializeProfileTeam(input: { }, ...profile.reviewPolicy === undefined ? {} : { reviewPolicy: profile.reviewPolicy }, captainSessionId: input.captain.id, + ...input.captainAvatar === undefined ? {} : { captainAvatar: input.captainAvatar }, createdAt: now, ...input.staged ? { phase: 'staged' as const, planReviewState: 'awaiting_review' as const } : {}, members: profile.members.map((template, index) => { diff --git a/src/types.ts b/src/types.ts index 429a72b09d..cba60867e5 100644 --- a/src/types.ts +++ b/src/types.ts @@ -149,6 +149,8 @@ export interface TeamMember { name: string /** Role description, e.g. `researcher`, `engineer`, `reviewer`. */ role?: string + /** Custom avatar URL or short host-managed file reference (never image bytes). */ + avatar?: string /** Resolved LLM provider route captured when this member was created. */ provider?: string /** Resolved model captured when this member was created. */ @@ -215,6 +217,8 @@ export interface TeamState { profile?: TeamProfileSnapshot /** Session id of the captain agent that owns this team. */ captainSessionId: string + /** Per-team captain avatar URL or short host-managed file reference. */ + captainAvatar?: string createdAt: number /** Teammates only; the captain is implicit (the owning session). */ members: TeamMember[] From ca4b56fbd2ecbc1a038ab7f20e12564c7208583d Mon Sep 17 00:00:00 2001 From: buguoshixc <3127765991@qq.com> Date: Sun, 6 Sep 2026 21:29:30 +0800 Subject: [PATCH 2/2] test: harden avatar acceptance coverage --- .github/workflows/verify.yml | 2 + package.json | 2 + pnpm-lock.yaml | 10 + scripts/avatar-browser-verify.mjs | 361 ++++++++++++++++++++++++++++++ scripts/avatar-verify.mjs | 45 +++- src/avatar.ts | 50 ++++- 6 files changed, 459 insertions(+), 11 deletions(-) create mode 100644 scripts/avatar-browser-verify.mjs diff --git a/.github/workflows/verify.yml b/.github/workflows/verify.yml index b791f03ceb..9c0e9614fd 100644 --- a/.github/workflows/verify.yml +++ b/.github/workflows/verify.yml @@ -49,6 +49,7 @@ jobs: pnpm typecheck pnpm build pnpm verify + pnpm verify:browser - name: Pack the actual candidate once id: artifact run: | @@ -94,6 +95,7 @@ jobs: pnpm typecheck pnpm build pnpm verify + pnpm verify:browser harness: name: Real Harness (${{ matrix.host }}) diff --git a/package.json b/package.json index b7addf8141..d9320e56e5 100644 --- a/package.json +++ b/package.json @@ -87,6 +87,7 @@ "verify": "node scripts/verify.mjs && node scripts/avatar-verify.mjs && node scripts/fallback-tdd.mjs && node scripts/member-failure-tdd.mjs && node scripts/quality-gates-tdd.mjs && node scripts/lifecycle-verify.mjs && node scripts/stress-verify.mjs && pnpm verify:web-routes && pnpm verify:release && pnpm verify:skill && pnpm verify:harness-contract && pnpm verify:http-body && pnpm verify:compatibility", "prepublishOnly": "pnpm build && pnpm verify", "verify:avatar": "node scripts/avatar-verify.mjs", + "verify:browser": "node scripts/avatar-browser-verify.mjs", "verify:web-routes": "node scripts/web-routes-verify.mjs", "verify:release": "node --test scripts/release-metadata.test.mjs", "verify:harness-contract": "node scripts/harness-compat-tdd.mjs && node scripts/lifecycle-verify.mjs --modern-harness && node scripts/member-failure-tdd.mjs --modern-harness", @@ -225,6 +226,7 @@ "@types/react": "~18.3.1", "@types/react-dom": "^18.3.7", "lightningcss": "^1.33.0", + "playwright-core": "^1.63.0", "react": "^18.2.0", "react-dom": "^18.2.0", "tsdown": "0.22.2", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f3ba18ef46..b2f623a46d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -317,6 +317,9 @@ importers: lightningcss: specifier: ^1.33.0 version: 1.33.0 + playwright-core: + specifier: ^1.63.0 + version: 1.63.0 react: specifier: ^18.2.0 version: 18.3.1 @@ -4057,6 +4060,11 @@ packages: resolution: {integrity: sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ==} engines: {node: '>=16.20.0'} + playwright-core@1.63.0: + resolution: {integrity: sha512-rYCsBF/M5HjUch52bbtVONEFjv6Xu8sm8h72dNlR5bzIE1fvC/bxgspzkjSfU+MweEMmPM8KJebG6nnyxo5mCg==} + engines: {node: '>=20'} + hasBin: true + powershell-utils@0.1.0: resolution: {integrity: sha512-dM0jVuXJPsDN6DvRpea484tCUaMiXWjuCn++HGTqUWzGDjv5tZkEZldAJ/UMlqRYGFrD/etByo4/xOuC/snX2A==} engines: {node: '>=20'} @@ -8827,6 +8835,8 @@ snapshots: pkce-challenge@5.0.1: {} + playwright-core@1.63.0: {} + powershell-utils@0.1.0: {} powershell-utils@0.2.1: {} diff --git a/scripts/avatar-browser-verify.mjs b/scripts/avatar-browser-verify.mjs new file mode 100644 index 0000000000..5f99779413 --- /dev/null +++ b/scripts/avatar-browser-verify.mjs @@ -0,0 +1,361 @@ +#!/usr/bin/env node +/** Real-browser acceptance for the authenticated ActivityPanel avatar upload. */ + +import assert from 'node:assert/strict' +import { createHash } from 'node:crypto' +import { access, mkdir, mkdtemp, readFile, rm, symlink, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { dirname, join, resolve } from 'node:path' +import { fileURLToPath } from 'node:url' +import { spawn, spawnSync } from 'node:child_process' +import { chromium } from 'playwright-core' +import { createTeamDir } from '../lib/state.js' + +const root = resolve(fileURLToPath(new URL('..', import.meta.url))) +const fixture = join(root, 'assets', 'agent-teams', 'team-lead-v2.png') +const dshEntry = join(root, 'node_modules', '@deepseek-ai', 'dsh', 'lib', 'bin.js') +const testHome = await mkdtemp(join(tmpdir(), 'dsh-avatar-browser-home-')) +const workspace = await mkdtemp(join(tmpdir(), 'dsh-avatar-browser-workspace-')) +const teamId = 'avatar-browser-acceptance' +let browser +let server +let serverOutput = '' + +function redact(value) { + return value.replace(/\?token=[^\s]+/gu, '?token=') +} + +function dshEnvironment() { + return { + ...process.env, + DSH_HOME: testHome, + // The adaptive host seam intentionally selects its browser interaction + // for SSH launches, making this deterministic on local Windows and CI. + SSH_CONNECTION: 'avatar-browser-e2e', + } +} + +function initializeProfile() { + const args = ['--profile', 'web', '--dump-config'] + const result = spawnSync(process.execPath, [dshEntry, ...args], { + cwd: root, + env: dshEnvironment(), + encoding: 'utf8', + windowsHide: true, + }) + assert.equal(result.status, 0, `failed to initialize DSH Web profile:\n${redact(result.stderr ?? '')}`) +} + +async function linkCurrentPlugin() { + const link = join(testHome, 'profiles', 'web', 'node_modules', '@nanmicoder', 'dsh-agent-teams') + await mkdir(dirname(link), { recursive: true }) + await symlink(root, link, process.platform === 'win32' ? 'junction' : 'dir') +} + +function startServer() { + server = spawn(process.execPath, [dshEntry, + '--profile', 'web', + '--patch', join(root, 'cordis.patch.yml'), + '--no-open', + '--host', '127.0.0.1', + '--port', '0', + ], { + cwd: root, + env: dshEnvironment(), + windowsHide: true, + stdio: ['ignore', 'pipe', 'pipe'], + }) + server.stdout.setEncoding('utf8') + server.stderr.setEncoding('utf8') + server.stdout.on('data', (chunk) => { serverOutput += chunk }) + server.stderr.on('data', (chunk) => { serverOutput += chunk }) +} + +async function waitForServerUrl() { + const deadline = Date.now() + 30_000 + for (;;) { + const match = serverOutput.match(/https?:\/\/127\.0\.0\.1:\d+\/\?token=[^\s]+/u) + if (match !== null) return match[0] + if (server?.exitCode !== null) { + throw new Error(`DSH Web exited before listening:\n${redact(serverOutput)}`) + } + if (Date.now() >= deadline) throw new Error(`timed out waiting for DSH Web:\n${redact(serverOutput)}`) + await new Promise((resolveWait) => setTimeout(resolveWait, 100)) + } +} + +async function firstExecutable(candidates) { + for (const candidate of candidates) { + if (candidate === undefined || candidate === '') continue + try { + await access(candidate) + return candidate + } catch {} + } + throw new Error('Chrome/Edge was not found; set CHROME_PATH to a Chromium-family browser executable') +} + +async function browserExecutable() { + const localAppData = process.env.LOCALAPPDATA + return firstExecutable([ + process.env.CHROME_PATH, + process.platform === 'win32' ? 'C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe' : undefined, + process.platform === 'win32' ? 'C:\\Program Files (x86)\\Google\\Chrome\\Application\\chrome.exe' : undefined, + process.platform === 'win32' ? 'C:\\Program Files\\Microsoft\\Edge\\Application\\msedge.exe' : undefined, + process.platform === 'win32' ? 'C:\\Program Files (x86)\\Microsoft\\Edge\\Application\\msedge.exe' : undefined, + process.platform === 'win32' && localAppData !== undefined + ? join(localAppData, 'Google', 'Chrome', 'Application', 'chrome.exe') + : undefined, + process.platform === 'darwin' ? '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome' : undefined, + process.platform === 'darwin' ? '/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge' : undefined, + '/usr/bin/google-chrome', + '/usr/bin/google-chrome-stable', + '/usr/bin/chromium', + '/usr/bin/chromium-browser', + ]) +} + +async function finishOnboarding(page) { + const workspaceButton = page.getByRole('button', { name: /^(Add workspace|添加工作区)$/iu }) + const welcome = page.getByRole('button', { name: /^(Continue|继续)$/iu }) + const configureLater = page.getByRole('button', { name: /^(Configure later|稍后配置)$/iu }) + // The shell renders before persisted settings arrive, so let the optional + // welcome/API-key modals mount before treating the visible shell as ready. + await page.waitForTimeout(1_000) + for (let attempts = 0; attempts < 40; attempts += 1) { + if (await welcome.isVisible().catch(() => false)) { + if (await welcome.isEnabled({ timeout: 250 }).catch(() => false)) await welcome.click() + await page.waitForTimeout(250) + continue + } + if (await configureLater.isVisible().catch(() => false)) { + await configureLater.click() + continue + } + if (await workspaceButton.isVisible().catch(() => false)) return workspaceButton + await page.waitForTimeout(250) + } + throw new Error(`could not finish DSH onboarding:\n${(await page.locator('body').innerText()).slice(0, 2_000)}`) +} + +async function addWorkspace(page, workspaceButton) { + await workspaceButton.click() + const editPath = page.getByRole('button', { name: /Edit path|编辑路径/iu }) + await editPath.waitFor({ state: 'visible', timeout: 10_000 }) + await editPath.click() + const pathInput = page.getByRole('textbox', { name: /Edit path|编辑路径/iu }) + await pathInput.fill(workspace) + await pathInput.press('Enter') + const open = page.getByRole('button', { name: /^(Open|打开)$/iu }) + await open.waitFor({ state: 'visible', timeout: 10_000 }) + await assertEventually(async () => await open.isEnabled(), 'workspace Open button stayed disabled') + await open.click() +} + +async function assertEventually(operation, message, timeout = 10_000) { + const deadline = Date.now() + timeout + let lastError + while (Date.now() < deadline) { + try { + if (await operation()) return + } catch (error) { + lastError = error + } + await new Promise((resolveWait) => setTimeout(resolveWait, 100)) + } + throw new Error(`${message}${lastError === undefined ? '' : `: ${String(lastError)}`}`) +} + +async function createdSessionId() { + const registryPath = join(testHome, 'storages', 'workspace.json') + let result + await assertEventually(async () => { + const registry = JSON.parse(await readFile(registryPath, 'utf8')) + const workspaces = Object.values(registry.tables?.workspaces ?? {}) + result = workspaces.find((entry) => entry.path === workspace)?.sessionIds?.[0] + return typeof result === 'string' && result !== '' + }, 'workspace registry did not create a blank session') + return result +} + +async function persistFixtureSession(sessionId) { + const path = join(testHome, 'storages', 'session_projcache', 'sessions', `${sessionId}.json`) + const snapshot = JSON.parse(await readFile(path, 'utf8')) + snapshot.record.rows.title.val = 'Avatar Browser Acceptance' + snapshot.record.rows.sessionListMetadata.val = { + blank: false, + lastPromptAt: Date.now(), + } + await writeFile(path, `${JSON.stringify(snapshot, null, 2)}\n`, 'utf8') +} + +async function seedTeam(sessionId) { + const now = Date.now() + await createTeamDir(join(workspace, '.agent-teams'), { + name: 'Avatar Browser Acceptance', + id: teamId, + captainSessionId: sessionId, + createdAt: now, + members: [{ + id: 'avatar-browser-member', + name: 'alice', + role: 'researcher', + joinedAt: now, + status: 'idle', + }], + tasks: [], + taskSeq: 0, + }) +} + +async function waitForTeamPanel(page) { + await assertEventually(async () => { + const state = await page.evaluate(async (expectedTeamId) => { + const response = await fetch('/plugins/dsh-agent-teams/state', { cache: 'no-store' }) + if (!response.ok) return { status: response.status, found: false } + const body = await response.json() + return { + status: response.status, + found: Array.isArray(body.teams) && body.teams.some((team) => team.teamId === expectedTeamId), + } + }, teamId) + return state.status === 200 && state.found + }, 'authenticated state route did not project the seeded team', 15_000) + + const panel = page.getByRole('complementary', { name: /AgentTeams/iu }) + if (!await panel.isVisible().catch(() => false)) { + const blankSession = page.getByRole('treeitem', { + name: /Avatar Browser Acceptance|New (?:session|chat)|新会话/iu, + }).last() + if (await blankSession.isVisible().catch(() => false)) await blankSession.click() + } + const collapsed = page.locator('button[data-agent-teams-collapsed]') + await assertEventually( + async () => await panel.isVisible().catch(() => false) || await collapsed.isVisible().catch(() => false), + 'neither the AgentTeams panel nor its collapsed badge appeared', + 30_000, + ) + if (await collapsed.isVisible().catch(() => false)) await collapsed.click() + try { + await panel.waitFor({ state: 'visible', timeout: 30_000 }) + } catch (error) { + const rows = await page.getByRole('treeitem').allTextContents() + const body = (await page.locator('body').innerText()).slice(0, 2_000) + throw new Error(`team panel did not mount: ${String(error)}\ntreeitems=${JSON.stringify(rows)}\n${body}`) + } + return panel +} + +function digest(data) { + return createHash('sha256').update(data).digest('hex') +} + +async function terminateServer() { + if (server === undefined || server.exitCode !== null) return + server.kill('SIGTERM') + await Promise.race([ + new Promise((resolveExit) => server.once('exit', resolveExit)), + new Promise((resolveWait) => setTimeout(resolveWait, 5_000)), + ]) + if (server.exitCode === null) server.kill('SIGKILL') +} + +try { + initializeProfile() + await linkCurrentPlugin() + startServer() + const authenticatedUrl = await waitForServerUrl() + const base = new URL(authenticatedUrl) + base.search = '' + + const unauthenticated = await fetch(new URL('/plugins/dsh-agent-teams/state', base), { redirect: 'manual' }) + assert.equal(unauthenticated.status, 401, 'avatar browser surface must retain Web authentication') + + browser = await chromium.launch({ + executablePath: await browserExecutable(), + headless: true, + args: ['--disable-gpu', '--no-sandbox'], + }) + const context = await browser.newContext({ locale: 'en-US' }) + const page = await context.newPage() + await page.goto(authenticatedUrl, { waitUntil: 'domcontentloaded' }) + const workspaceButton = await finishOnboarding(page) + await addWorkspace(page, workspaceButton) + const sessionId = await createdSessionId() + await seedTeam(sessionId) + + const panel = await waitForTeamPanel(page) + await panel.getByRole('button', { name: /^(Avatar|头像)$/iu }).click() + const editor = panel.locator('[data-avatar-editor]') + await editor.waitFor({ state: 'visible' }) + + const chooserPromise = page.waitForEvent('filechooser') + await editor.locator('input[type="file"]').click({ force: true }) + const chooser = await chooserPromise + await chooser.setFiles(fixture) + await editor.locator('[data-kind="success"]').waitFor({ state: 'visible', timeout: 10_000 }) + + const statePath = join(workspace, '.agent-teams', teamId, 'team.json') + let managedName + await assertEventually(async () => { + const state = JSON.parse(await readFile(statePath, 'utf8')) + if (!state.captainAvatar?.startsWith('managed:')) return false + managedName = state.captainAvatar.slice('managed:'.length) + return true + }, 'upload did not persist a managed avatar reference') + assert.match(managedName, /^[0-9a-f-]+\.(?:png|jpg|webp)$/u) + + const stored = await readFile(join(workspace, '.agent-teams', teamId, 'avatars', managedName)) + const source = await readFile(fixture) + assert.equal(digest(stored), digest(source), 'stored avatar bytes differ from the selected browser file') + + const avatar = panel.locator(`section[data-team-id="${teamId}"] img[src*="avatar-file"]`).first() + await avatar.waitFor({ state: 'visible', timeout: 10_000 }) + const rendered = await avatar.evaluate((image) => ({ + complete: image.complete, + width: image.naturalWidth, + height: image.naturalHeight, + src: image.getAttribute('src'), + })) + assert.deepEqual( + { complete: rendered.complete, width: rendered.width, height: rendered.height }, + { complete: true, width: 256, height: 256 }, + 'browser did not decode the uploaded avatar', + ) + assert.match(rendered.src ?? '', /\/plugins\/dsh-agent-teams\/avatar-file\?/u) + + // Restart the real host so both the session and avatar are reconstructed + // from disk rather than retained client or service memory. + await terminateServer() + await persistFixtureSession(sessionId) + serverOutput = '' + startServer() + const restoredAuthenticatedUrl = await waitForServerUrl() + await page.goto(restoredAuthenticatedUrl, { waitUntil: 'domcontentloaded' }) + await finishOnboarding(page) + await waitForTeamPanel(page) + const restored = page.locator(`section[data-team-id="${teamId}"] img[src*="${managedName}"]`).first() + try { + await restored.waitFor({ state: 'visible', timeout: 15_000 }) + } catch (error) { + const images = await page.locator('img').evaluateAll((elements) => elements.map((image) => image.getAttribute('src'))) + const body = (await page.locator('body').innerText()).slice(0, 2_000) + throw new Error(`avatar was not restored after reload: ${String(error)}\nimages=${JSON.stringify(images)}\n${body}`) + } + const restoredSize = await restored.evaluate((image) => [image.naturalWidth, image.naturalHeight]) + assert.deepEqual(restoredSize, [256, 256], 'avatar did not survive a full browser reload') + + console.log('avatar browser verification passed') + console.log(` authenticated route: 401 without cookie, accepted with browser token`) + console.log(` upload: managed:${managedName} (${stored.length} bytes, sha256 ${digest(stored)})`) + console.log(' render: 256x256 before and after host restart/browser reload') +} catch (error) { + console.error(error) + if (serverOutput !== '') console.error(`DSH output:\n${redact(serverOutput)}`) + process.exitCode = 1 +} finally { + await browser?.close().catch(() => {}) + await terminateServer() + await rm(testHome, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 }) + await rm(workspace, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 }) +} diff --git a/scripts/avatar-verify.mjs b/scripts/avatar-verify.mjs index ce83544272..8a7d4f4c44 100644 --- a/scripts/avatar-verify.mjs +++ b/scripts/avatar-verify.mjs @@ -85,15 +85,26 @@ check('credential-bearing URL is rejected', rejectsAvatar(() => normalizeRemoteA check('private IPv4 ranges are rejected', [ '0.0.0.0', '10.0.0.1', '100.64.0.1', '127.0.0.1', '169.254.1.1', '172.16.0.1', '192.168.1.1', + '192.88.99.1', ].every(isPrivateAddress)) -check('public IPv4 addresses remain eligible', !isPrivateAddress('93.184.216.34')) +check('public IPv4 addresses and IANA anycast exceptions remain eligible', [ + '93.184.216.34', '192.0.0.9', '192.0.0.10', +].every((address) => !isPrivateAddress(address))) check('private IPv6 ranges are rejected', [ - '::', '::1', 'fc00::1', 'fe80::1', 'ff02::1', '2001:db8::1', + '::', '::1', '::192.168.1.1', '64:ff9b:1::7f00:1', '100::1', '100:0:0:1::1', + '2001:2::1', '2001:10::1', '2001:db8::1', '2002::1', '3fff::1', '5f00::1', + 'fc00::1', 'fec0::1', 'fe80::1', 'ff02::1', ].every(isPrivateAddress)) check('IPv4-mapped IPv6 cannot bypass private IPv4 validation', [ '::ffff:127.0.0.1', '::ffff:7f00:1', '0:0:0:0:0:ffff:7f00:1', ].every(isPrivateAddress)) -check('public IPv6 addresses remain eligible', !isPrivateAddress('2606:4700:4700::1111')) +check('well-known NAT64 cannot translate to a private IPv4 destination', [ + '64:ff9b::127.0.0.1', '64:ff9b::7f00:1', '64:ff9b::192.168.1.1', +].every(isPrivateAddress)) +check('public IPv6 and IANA special-purpose exceptions remain eligible', [ + '2606:4700:4700::1111', '64:ff9b::5db8:d822', '2001:1::1', '2001:3::1', + '2001:4:112::1', '2001:20::1', '2001:30::1', +].every((address) => !isPrivateAddress(address))) let literalDnsCalls = 0 const rejectUnexpectedDns = async () => { @@ -108,6 +119,19 @@ check('private IPv6 literals are rejected before any request', await rejectsAvat () => resolvePublicRemote(new URL('https://[::1]/a.png'), rejectUnexpectedDns), 403, )) +const specialIpv6 = ['fec0::1', '64:ff9b:1::7f00:1', '100::1', '2001:2::1'] +check('non-public special-purpose IPv6 literals are rejected before any request', (await Promise.all( + specialIpv6.map((address) => rejectsAvatarAsync( + () => resolvePublicRemote(new URL(`https://[${address}]/a.png`), rejectUnexpectedDns), + 403, + )), +)).every(Boolean)) +check('non-public special-purpose IPv6 DNS answers reject the entire hostname', (await Promise.all( + specialIpv6.map((address) => rejectsAvatarAsync( + () => resolvePublicRemote(new URL('https://special.example/a.png'), async () => [{ address, family: 6 }]), + 403, + )), +)).every(Boolean)) check('mixed public/private DNS answers reject the entire hostname', await rejectsAvatarAsync( () => resolvePublicRemote(new URL('https://mixed.example/a.png'), async () => [ { address: '93.184.216.34', family: 4 }, @@ -159,6 +183,21 @@ const privateRedirectRejected = await rejectsAvatarAsync(() => fetchRemoteAvatar check('redirects to a private DNS result are rejected before the next request', privateRedirectRejected && privateRedirectRequests === 1) +let specialIpv6RedirectRequests = 0 +const specialIpv6RedirectRejected = await rejectsAvatarAsync(() => fetchRemoteAvatar( + 'https://redirect.example/avatar', + DEFAULT_AVATAR_MAX_BYTES, + async (hostname) => hostname === 'redirect.example' + ? [{ address: '93.184.216.34', family: 4 }] + : [{ address: '64:ff9b:1::7f00:1', family: 6 }], + async () => { + specialIpv6RedirectRequests += 1 + return remoteResponse(302, { location: 'https://special.example/avatar.png' }) + }, +), 403) +check('redirects to a non-public special-purpose IPv6 result are rejected before the next request', + specialIpv6RedirectRejected && specialIpv6RedirectRequests === 1) + let redirectLimitRequests = 0 const redirectLimitRejected = await rejectsAvatarAsync(() => fetchRemoteAvatar( 'https://redirect.example/avatar', diff --git a/src/avatar.ts b/src/avatar.ts index 2c3a74d17d..d8710fc397 100644 --- a/src/avatar.ts +++ b/src/avatar.ts @@ -110,17 +110,30 @@ export type AvatarPinnedRequester = ( pinned: { readonly address: string; readonly family: 4 | 6 }, ) => Promise -const PRIVATE_ADDRESSES = new BlockList() +const NON_PUBLIC_ADDRESSES = new BlockList() for (const [network, prefix] of [ ['0.0.0.0', 8], ['10.0.0.0', 8], ['100.64.0.0', 10], ['127.0.0.0', 8], ['169.254.0.0', 16], ['172.16.0.0', 12], ['192.0.0.0', 24], ['192.0.2.0', 24], + ['192.88.99.0', 24], ['192.168.0.0', 16], ['198.18.0.0', 15], ['198.51.100.0', 24], ['203.0.113.0', 24], ['224.0.0.0', 4], ['240.0.0.0', 4], -] as const) PRIVATE_ADDRESSES.addSubnet(network, prefix, 'ipv4') +] as const) NON_PUBLIC_ADDRESSES.addSubnet(network, prefix, 'ipv4') for (const [network, prefix] of [ - ['::', 128], ['::1', 128], ['fc00::', 7], ['fe80::', 10], ['ff00::', 8], - ['2001:db8::', 32], -] as const) PRIVATE_ADDRESSES.addSubnet(network, prefix, 'ipv6') + // IANA IPv6 Special-Purpose ranges that are not globally reachable. The + // broad 2001::/23 reservation has explicit public exceptions below. + ['::', 96], ['64:ff9b:1::', 48], ['100::', 64], ['100:0:0:1::', 64], + ['2001::', 23], ['2001:db8::', 32], ['2002::', 16], ['3fff::', 20], ['5f00::', 16], + ['fc00::', 7], ['fec0::', 10], ['fe80::', 10], ['ff00::', 8], +] as const) NON_PUBLIC_ADDRESSES.addSubnet(network, prefix, 'ipv6') + +const PUBLIC_SPECIAL_ADDRESSES = new BlockList() +for (const address of ['192.0.0.9', '192.0.0.10'] as const) { + PUBLIC_SPECIAL_ADDRESSES.addAddress(address, 'ipv4') +} +for (const [network, prefix] of [ + ['2001:1::1', 128], ['2001:1::2', 128], ['2001:1::3', 128], + ['2001:3::', 32], ['2001:4:112::', 48], ['2001:20::', 28], ['2001:30::', 28], +] as const) PUBLIC_SPECIAL_ADDRESSES.addSubnet(network, prefix, 'ipv6') /** Parse and canonicalize one user-provided remote avatar URL. */ export function normalizeRemoteAvatarUrl(input: string): string { @@ -282,14 +295,35 @@ function contentTypeForFilename(filename: string): string { return 'image/webp' } -/** Reject non-IP and non-public IPv4/IPv6 destinations, including mapped IPv4. */ +function isNonPublicIpv4(address: string): boolean { + return !PUBLIC_SPECIAL_ADDRESSES.check(address, 'ipv4') + && NON_PUBLIC_ADDRESSES.check(address, 'ipv4') +} + +/** Return the IPv4 payload of the globally routed well-known NAT64 prefix. */ +function wellKnownNat64Ipv4(address: string): string | undefined { + const prefix = '64:ff9b::' + if (!address.startsWith(prefix)) return undefined + const suffix = address.slice(prefix.length) + const words = suffix === '' ? [] : suffix.split(':') + if (words.length > 2 || words.some((word) => !/^[0-9a-f]{1,4}$/u.test(word))) return undefined + const high = Number.parseInt(words.length === 2 ? words[0]! : '0', 16) + const low = Number.parseInt(words.at(-1) ?? '0', 16) + return `${high >>> 8}.${high & 0xff}.${low >>> 8}.${low & 0xff}` +} + +/** Reject non-IP and non-public IPv4/IPv6 destinations, including embedded IPv4. */ export function isPrivateAddress(address: string): boolean { const family = isIP(address) - if (family === 4) return PRIVATE_ADDRESSES.check(address, 'ipv4') + if (family === 4) return isNonPublicIpv4(address) if (family === 6) { const normalized = SocketAddress.parse(`[${address}]:0`)?.address.toLowerCase() ?? address.toLowerCase() const mapped = normalized.match(/^::ffff:(\d+\.\d+\.\d+\.\d+)$/u)?.[1] - return mapped !== undefined ? PRIVATE_ADDRESSES.check(mapped, 'ipv4') : PRIVATE_ADDRESSES.check(address, 'ipv6') + if (mapped !== undefined) return isNonPublicIpv4(mapped) + const translated = wellKnownNat64Ipv4(normalized) + if (translated !== undefined) return isNonPublicIpv4(translated) + return !PUBLIC_SPECIAL_ADDRESSES.check(normalized, 'ipv6') + && NON_PUBLIC_ADDRESSES.check(normalized, 'ipv6') } return true }