From 20c34ea7bd17ce5f03233ee9add189ff23a7cab0 Mon Sep 17 00:00:00 2001 From: kipavy Date: Sun, 16 Aug 2026 09:52:52 +0000 Subject: [PATCH 1/3] feat(handle): gate the claim control on a verified email, not on a tier MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Custom handles are free now, so AccountSection stops reading the tier entirely. The claim control is offered to everyone and disabled — with the reason — for an unverified email; hiding it is what made the tier gate confusing, and a Business teammate whose own row reads `free` was being told to upgrade while their employer paid. The copy stops naming a plan and says what a custom handle does: it makes you findable outside your teams, and your current handle already works for anyone you give it to. The 402 branch is replaced by the server's 403, so the failure says "verify your email first" instead of "invalid handle". MeResponse gains email_verified; /v1/auth/me has always returned it. It stays null until that call answers so a verified user never sees the "verify your email" line flash. --- .../sections/AccountSection.handle.test.tsx | 61 ++++++++++++------- .../settings/sections/AccountSection.tsx | 40 +++++------- src/i18n/locales/en/settings.json | 10 ++- src/i18n/locales/fr/settings.json | 10 ++- src/i18n/locales/ru/settings.json | 10 ++- src/i18n/locales/zh/settings.json | 10 ++- src/services/account.ts | 1 + 7 files changed, 72 insertions(+), 70 deletions(-) diff --git a/src/components/settings/sections/AccountSection.handle.test.tsx b/src/components/settings/sections/AccountSection.handle.test.tsx index 7fc2d4d16..1cbae9164 100644 --- a/src/components/settings/sections/AccountSection.handle.test.tsx +++ b/src/components/settings/sections/AccountSection.handle.test.tsx @@ -61,16 +61,33 @@ afterEach(() => { vi.clearAllMocks(); }); -test("a free account sees its generated handle, a copy button and the upsell — no claim form", async () => { - h.getMe.mockResolvedValue({ handle: "swift-otter-4821", handle_is_custom: false, tier: "free", allow_stranger_invites: true }); +test("a free but verified account is offered the claim control — no tier gate", async () => { + h.getMe.mockResolvedValue({ handle: "swift-otter-4821", handle_is_custom: false, tier: "free", email_verified: true, allow_stranger_invites: true }); render(); expect(await screen.findByText("@swift-otter-4821")).toBeTruthy(); - expect(screen.getByText("settings.account.handle.upsell")).toBeTruthy(); - expect(screen.queryByRole("button", { name: "settings.account.handle.save" })).toBeNull(); + const choose = screen.getByRole("button", { name: "settings.account.handle.choose" }); + expect(choose.hasAttribute("disabled")).toBe(false); + expect(screen.getByText("settings.account.handle.chooseSub")).toBeTruthy(); + expect(screen.queryByText("settings.account.handle.unverified")).toBeNull(); }); -test("a pro account can claim and the taken case is explained, not swallowed", async () => { - h.getMe.mockResolvedValue({ handle: "swift-otter-4821", handle_is_custom: false, tier: "pro", allow_stranger_invites: true }); +test("an unverified account sees the control disabled with the reason, not hidden", async () => { + h.getMe.mockResolvedValue({ handle: "swift-otter-4821", handle_is_custom: false, tier: "pro", email_verified: false, allow_stranger_invites: true }); + render(); + const choose = await screen.findByRole("button", { name: "settings.account.handle.choose" }); + expect(choose.hasAttribute("disabled")).toBe(true); + expect(screen.getByText("settings.account.handle.unverified")).toBeTruthy(); +}); + +test("the copy explains what a custom handle does rather than naming a tier", async () => { + h.getMe.mockResolvedValue({ handle: "swift-otter-4821", handle_is_custom: false, tier: "free", email_verified: true, allow_stranger_invites: true }); + render(); + await screen.findByText("settings.account.handle.chooseSub"); + expect(screen.getByText("settings.account.handle.generatedNote")).toBeTruthy(); +}); + +test("a verified account can claim and the taken case is explained, not swallowed", async () => { + h.getMe.mockResolvedValue({ handle: "swift-otter-4821", handle_is_custom: false, tier: "free", email_verified: true, allow_stranger_invites: true }); h.claimHandle.mockRejectedValue(new HandleClaimError(409)); render(); await userEvent.click(await screen.findByRole("button", { name: "settings.account.handle.choose" })); @@ -80,14 +97,14 @@ test("a pro account can claim and the taken case is explained, not swallowed", a }); test("the stranger-invite toggle persists", async () => { - h.getMe.mockResolvedValue({ handle: "h", handle_is_custom: false, tier: "pro", allow_stranger_invites: true }); + h.getMe.mockResolvedValue({ handle: "h", handle_is_custom: false, tier: "pro", email_verified: true, allow_stranger_invites: true }); render(); await userEvent.click(await screen.findByRole("switch", { name: "settings.account.strangerInvites.label" })); expect(h.updateInvitePreferences).toHaveBeenCalledWith(false); }); test("the stranger-invite toggle reverts on failure", async () => { - h.getMe.mockResolvedValue({ handle: "h", handle_is_custom: false, tier: "pro", allow_stranger_invites: true }); + h.getMe.mockResolvedValue({ handle: "h", handle_is_custom: false, tier: "pro", email_verified: true, allow_stranger_invites: true }); h.updateInvitePreferences.mockRejectedValueOnce(new Error("network error")); render(); const toggle = await screen.findByRole("switch", { name: "settings.account.strangerInvites.label" }); @@ -96,10 +113,10 @@ test("the stranger-invite toggle reverts on failure", async () => { expect(toggle.getAttribute("aria-checked")).toBe("true"); }); -test("a pro account sees distinct copy for each claim-failure status", async () => { - h.getMe.mockResolvedValue({ handle: "h", handle_is_custom: false, tier: "pro", allow_stranger_invites: true }); +test("distinct copy for each claim-failure status", async () => { + h.getMe.mockResolvedValue({ handle: "h", handle_is_custom: false, tier: "pro", email_verified: true, allow_stranger_invites: true }); const cases: [number, string][] = [ - [402, "settings.account.handle.errorTierRequired"], + [403, "settings.account.handle.errorEmailNotVerified"], [422, "settings.account.handle.errorInvalid"], [429, "settings.account.handle.errorCooldown"], ]; @@ -114,30 +131,30 @@ test("a pro account sees distinct copy for each claim-failure status", async () } }); -test("a lapsed pro user keeps the custom-handle message, not the upsell", async () => { - h.getMe.mockResolvedValue({ handle: "kevin-p", handle_is_custom: true, tier: "free", allow_stranger_invites: true }); +test("a lapsed account can still rename its custom handle", async () => { + h.getMe.mockResolvedValue({ handle: "kevin-p", handle_is_custom: true, tier: "free", email_verified: true, allow_stranger_invites: true }); render(); await screen.findByText("@kevin-p"); - expect(screen.getByText("settings.account.handle.lapsedKeepsHandle")).toBeTruthy(); - expect(screen.queryByText("settings.account.handle.upsell")).toBeNull(); + const change = screen.getByRole("button", { name: "settings.account.handle.change" }); + expect(change.hasAttribute("disabled")).toBe(false); }); -test("no upsell flash before the tier is known", async () => { +test("no verify-your-email flash before /auth/me answers", async () => { let resolveMe!: (v: MeResponse) => void; h.getMe.mockReturnValue(new Promise((resolve) => { resolveMe = resolve; })); render(); - // Wait for mode ("server") to resolve and the handle block to mount, while - // getMe (and so the tier) is still pending — this is the exact window a - // paying user would otherwise see the free-tier upsell flash in. + // Wait for mode ("server") to resolve and the handle block to mount while + // getMe (and so email_verified) is still pending — the exact window a + // verified user would otherwise see the "verify your email" line flash in. await screen.findByText("settings.account.handle.title"); - expect(screen.queryByText("settings.account.handle.upsell")).toBeNull(); + expect(screen.queryByText("settings.account.handle.unverified")).toBeNull(); expect(screen.queryByRole("button", { name: "settings.account.handle.choose" })).toBeNull(); - resolveMe({ handle: "swift-otter-4821", handle_is_custom: false, tier: "pro", allow_stranger_invites: true }); + resolveMe({ handle: "swift-otter-4821", handle_is_custom: false, tier: "pro", email_verified: true, allow_stranger_invites: true }); expect(await screen.findByRole("button", { name: "settings.account.handle.choose" })).toBeTruthy(); }); test("the stranger-invite toggle disables itself mid-flight so a second click can't race it", async () => { - h.getMe.mockResolvedValue({ handle: "h", handle_is_custom: false, tier: "pro", allow_stranger_invites: true }); + h.getMe.mockResolvedValue({ handle: "h", handle_is_custom: false, tier: "pro", email_verified: true, allow_stranger_invites: true }); let resolveUpdate!: () => void; h.updateInvitePreferences.mockReturnValue(new Promise((resolve) => { resolveUpdate = resolve; })); render(); diff --git a/src/components/settings/sections/AccountSection.tsx b/src/components/settings/sections/AccountSection.tsx index fecb834cc..a2f5b54d0 100644 --- a/src/components/settings/sections/AccountSection.tsx +++ b/src/components/settings/sections/AccountSection.tsx @@ -78,11 +78,12 @@ async function openCheckout(plan: "pro" | "teams") { } /** Maps claimHandle's status-carrying error to the copy the server's per-status - * contract calls for — each status needs a distinct next step, not one generic message. */ + * contract calls for — each status needs a distinct next step, not one generic message. + * 403 is the server's only refusal here that the user can act on themselves. */ function mapHandleClaimError(e: unknown, t: (key: string) => string): Error { if (e instanceof HandleClaimError) { const key = - e.status === 402 ? "settings.account.handle.errorTierRequired" : + e.status === 403 ? "settings.account.handle.errorEmailNotVerified" : e.status === 409 ? "settings.account.handle.errorTaken" : e.status === 422 ? "settings.account.handle.errorInvalid" : e.status === 429 ? "settings.account.handle.errorCooldown" : @@ -105,8 +106,9 @@ export default function AccountSection() { const [showChangePassword, setShowChangePassword] = useState(false); const [handle, setHandle] = useState(null); const [handleIsCustom, setHandleIsCustom] = useState(false); - const [meTier, setMeTier] = useState(undefined); - const [tierKnown, setTierKnown] = useState(false); + // `null` until /auth/me answers — the claim control renders in neither state + // until then, so a verified user never sees the "verify your email" row flash. + const [emailVerified, setEmailVerified] = useState(null); const [allowStrangerInvites, setAllowStrangerInvites] = useState(true); const [strangerInvitesError, setStrangerInvitesError] = useState(""); const [strangerInvitesLoading, setStrangerInvitesLoading] = useState(false); @@ -123,12 +125,6 @@ export default function AccountSection() { }, (value) => { setHandle(value); setHandleIsCustom(true); }, ); - // Lapsing from Pro drops back to "free" but keeps a custom handle and its - // searchability — only the ability to rename is gated on tier. That account - // must never see the "upgrade to get a searchable handle" upsell, since it - // already has exactly that. - const isFreeTier = tierKnown && (!meTier || meTier === "free"); - const isLapsedCustom = isFreeTier && handleIsCustom; const { copied: handleCopied, copy: handleCopyHandle } = useCopyHandle(handle); const toggleStrangerInvites = async (next: boolean) => { @@ -161,8 +157,7 @@ export default function AccountSection() { if (!me) return; if (me.handle) setHandle(me.handle); setHandleIsCustom(!!me.handle_is_custom); - setMeTier(me.tier); - setTierKnown(true); + setEmailVerified(!!me.email_verified); if (typeof me.allow_stranger_invites === "boolean") setAllowStrangerInvites(me.allow_stranger_invites); }).catch(() => {}); setStep("idle"); @@ -256,17 +251,7 @@ export default function AccountSection() { - {!tierKnown ? null : isLapsedCustom ? ( -
-

{t("settings.account.handle.lapsedKeepsHandle")}

-

{t("settings.account.handle.lapsedRenameLocked")}

-
- ) : isFreeTier ? ( -
-

{t("settings.account.handle.upsell")}

-

{t("settings.account.handle.reachableNote")}

-
- ) : handleField.editing ? ( + {emailVerified === null ? null : handleField.editing ? (
{ e.preventDefault(); @@ -290,14 +275,21 @@ export default function AccountSection() {
) : (
+ {/* Disabled with the reason rather than hidden: hiding the + control is what made the old tier gate unreadable. */}

{t("settings.account.handle.chooseSub")}

+

{t("settings.account.handle.generatedNote")}

+ {!emailVerified && ( +

{t("settings.account.handle.unverified")}

+ )}
)} diff --git a/src/i18n/locales/en/settings.json b/src/i18n/locales/en/settings.json index 9801115b2..b678d5463 100644 --- a/src/i18n/locales/en/settings.json +++ b/src/i18n/locales/en/settings.json @@ -263,17 +263,15 @@ "copied": "Copied", "choose": "Choose a handle", "change": "Change handle", - "chooseSub": "Lets people invite you by @handle instead of your email.", + "chooseSub": "A custom handle makes you findable by people outside your teams.", + "generatedNote": "Your current handle already works for anyone you give it to.", + "unverified": "Verify your email to choose a custom handle.", "placeholder": "your-handle", "inputLabel": "Handle", "save": "Save", - "upsell": "Custom handles require Pro or higher.", - "reachableNote": "People can still reach you by your @handle — a custom one just makes you searchable.", - "lapsedKeepsHandle": "Your plan lapsed, but you keep your existing handle.", - "lapsedRenameLocked": "Renaming is locked until you resubscribe.", "errorInvalid": "Handles can only contain letters, numbers, and hyphens.", "errorTaken": "That handle is already taken.", - "errorTierRequired": "Custom handles require Pro or higher.", + "errorEmailNotVerified": "Verify your email first, then choose your handle.", "errorCooldown": "You can change your handle again soon.", "errorGeneric": "Could not update your handle." }, diff --git a/src/i18n/locales/fr/settings.json b/src/i18n/locales/fr/settings.json index f92108793..e2614c006 100644 --- a/src/i18n/locales/fr/settings.json +++ b/src/i18n/locales/fr/settings.json @@ -263,17 +263,15 @@ "copied": "Copié", "choose": "Choisir un pseudo", "change": "Changer de pseudo", - "chooseSub": "Permet aux autres de vous inviter par @pseudo plutôt que par e-mail.", + "chooseSub": "Un pseudo personnalisé vous rend trouvable par des personnes extérieures à vos équipes.", + "generatedNote": "Votre pseudo actuel fonctionne déjà auprès de toute personne à qui vous le donnez.", + "unverified": "Vérifiez votre adresse e-mail pour choisir un pseudo personnalisé.", "placeholder": "votre-pseudo", "inputLabel": "Pseudo", "save": "Enregistrer", - "upsell": "Les pseudos personnalisés nécessitent l'offre Pro ou supérieure.", - "reachableNote": "Vous restez joignable par votre @pseudo — un pseudo personnalisé vous rend simplement trouvable.", - "lapsedKeepsHandle": "Votre offre a expiré, mais vous conservez votre pseudo actuel.", - "lapsedRenameLocked": "Le changement de pseudo est verrouillé jusqu'à votre réabonnement.", "errorInvalid": "Un pseudo ne peut contenir que des lettres, des chiffres et des tirets.", "errorTaken": "Ce pseudo est déjà pris.", - "errorTierRequired": "Les pseudos personnalisés nécessitent l'offre Pro ou supérieure.", + "errorEmailNotVerified": "Vérifiez d'abord votre adresse e-mail, puis choisissez votre pseudo.", "errorCooldown": "Vous pourrez de nouveau changer de pseudo bientôt.", "errorGeneric": "Impossible de mettre à jour votre pseudo." }, diff --git a/src/i18n/locales/ru/settings.json b/src/i18n/locales/ru/settings.json index aee08c59e..a79419613 100644 --- a/src/i18n/locales/ru/settings.json +++ b/src/i18n/locales/ru/settings.json @@ -263,17 +263,15 @@ "copied": "Скопировано", "choose": "Выбрать псевдоним", "change": "Изменить псевдоним", - "chooseSub": "Позволяет приглашать вас по @псевдониму вместо e-mail.", + "chooseSub": "Собственный псевдоним делает вас находимым для людей вне ваших команд.", + "generatedNote": "Ваш текущий псевдоним уже работает для всех, кому вы его дадите.", + "unverified": "Подтвердите электронную почту, чтобы выбрать собственный псевдоним.", "placeholder": "ваш-псевдоним", "inputLabel": "Псевдоним", "save": "Сохранить", - "upsell": "Пользовательские псевдонимы требуют тариф Pro или выше.", - "reachableNote": "С вами всё равно можно связаться по вашему @псевдониму — свой псевдоним лишь делает вас находимым в поиске.", - "lapsedKeepsHandle": "Ваш тариф истёк, но текущий псевдоним сохраняется за вами.", - "lapsedRenameLocked": "Переименование заблокировано до возобновления подписки.", "errorInvalid": "Псевдоним может содержать только буквы, цифры и дефисы.", "errorTaken": "Этот псевдоним уже занят.", - "errorTierRequired": "Пользовательские псевдонимы требуют тариф Pro или выше.", + "errorEmailNotVerified": "Сначала подтвердите электронную почту, затем выберите псевдоним.", "errorCooldown": "Вы сможете снова сменить псевдоним чуть позже.", "errorGeneric": "Не удалось обновить псевдоним." }, diff --git a/src/i18n/locales/zh/settings.json b/src/i18n/locales/zh/settings.json index 3b18d1c10..76708cf3e 100644 --- a/src/i18n/locales/zh/settings.json +++ b/src/i18n/locales/zh/settings.json @@ -149,17 +149,15 @@ "copied": "已复制", "choose": "选择 handle", "change": "更改 handle", - "chooseSub": "让其他人可以通过 @handle 而不是邮箱邀请您。", + "chooseSub": "自定义 handle 让您团队以外的人也能搜索到您。", + "generatedNote": "您当前的 handle 已经可以让任何知道它的人找到您。", + "unverified": "请先验证邮箱,才能选择自定义 handle。", "placeholder": "your-handle", "inputLabel": "Handle", "save": "保存", - "upsell": "自定义 handle 需要 Pro 或更高套餐。", - "reachableNote": "他人仍可通过您的 @handle 联系您 — 自定义 handle 只是让您可被搜索到。", - "lapsedKeepsHandle": "您的套餐已过期,但仍保留现有 handle。", - "lapsedRenameLocked": "重新订阅前无法更改 handle。", "errorInvalid": "Handle 只能包含字母、数字和连字符。", "errorTaken": "该 handle 已被占用。", - "errorTierRequired": "自定义 handle 需要 Pro 或更高套餐。", + "errorEmailNotVerified": "请先验证邮箱,然后再选择您的 handle。", "errorCooldown": "您很快就可以再次更改 handle。", "errorGeneric": "无法更新您的 handle。" }, diff --git a/src/services/account.ts b/src/services/account.ts index 3592d70e3..123a0da0f 100644 --- a/src/services/account.ts +++ b/src/services/account.ts @@ -368,6 +368,7 @@ export interface MeResponse { handle_is_custom?: boolean; allow_stranger_invites?: boolean; tier?: string; + email_verified?: boolean; } /** Fetches /v1/auth/me and caches the handle for offline use. Returns the From a93ff3b840e158ddc48b07d9024dd512e15db37f Mon Sep 17 00:00:00 2001 From: kipavy Date: Sun, 16 Aug 2026 10:41:20 +0000 Subject: [PATCH 2/3] =?UTF-8?q?fix:=20invite=20follow-ups=20=E2=80=94=2042?= =?UTF-8?q?9=20copy,=20duplicated=20knock,=20container=20ownership?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Sign-in collapsed every failed auth response into one message, so the hardcoded 10/min-per-IP limiter rendered as "Account not found" and sent people off to create a second account. authFailure() branches on the status at all three call sites — both in signInToCloud and the challenge inside login — mapping 429 to a rate-limit message and anything unexpected to the status itself. A knock appeared in the bell twice. Its 8-second toast is archived to history on dismissal and history rows carry no actions, so the panel showed the same knock once actionable in the inbox and once dead below it — and the dead copy is what reads like Join and Decline were lost. ToastEntry.inboxId marks a toast as an echo of an entry that owns the event, and such a toast is dropped instead of archived. The headless container ran as root and left ~30k root-owned files in the mounted worktree. It now builds a user from UID/GID build args; rust moved to /opt so a non-root user can reach it, and CARGO_TARGET_DIR is /target, outside the bind mount — a target dir under vite's root makes the watcher crawl it until the webview never loads. A .dockerignore of "*" stops each build tarring a multi-gigabyte worktree the image never copies from. i18n: dropped the gendered participles the "(а)" and "Invité" forms papered over, and fixed a claim that was stale in all four locales — the people search offered "by name", but display_name is gone and the server matches teammates on email and everyone else on handle. --- .../skills/iterating-on-voltius-ui/SKILL.md | 4 +- .../testing-voltius-team-features-e2e/wd.mjs | 2 +- .dockerignore | 5 ++ Dockerfile.tauri-headless | 50 +++++++++++++++---- compose.headless.yml | 13 +++++ src/i18n/locales/en/common.json | 1 + src/i18n/locales/en/terminal.json | 2 +- src/i18n/locales/fr/common.json | 1 + src/i18n/locales/fr/notifications.json | 6 +-- src/i18n/locales/fr/terminal.json | 4 +- src/i18n/locales/ru/common.json | 1 + src/i18n/locales/ru/notifications.json | 6 +-- src/i18n/locales/ru/terminal.json | 4 +- src/i18n/locales/zh/common.json | 5 +- src/i18n/locales/zh/terminal.json | 2 +- src/services/account.serverAuth.test.ts | 18 +++++++ src/services/account.ts | 19 +++++-- src/services/teamInbox.ts | 12 +++-- src/stores/notificationStore.inbox.test.ts | 34 +++++++++++++ src/stores/notificationStore.ts | 9 ++++ 20 files changed, 166 insertions(+), 32 deletions(-) create mode 100644 .dockerignore diff --git a/.claude/skills/iterating-on-voltius-ui/SKILL.md b/.claude/skills/iterating-on-voltius-ui/SKILL.md index 87571b9c4..6424d4fa1 100644 --- a/.claude/skills/iterating-on-voltius-ui/SKILL.md +++ b/.claude/skills/iterating-on-voltius-ui/SKILL.md @@ -24,7 +24,7 @@ docker compose -f compose.headless.yml logs tauri-headless # wait for the driv Ready signals: log shows `Joined session keyring`, and `claude mcp list` shows `tauri-docker` connected. First build from a cold cache takes minutes; a warm cache -(`target/` is host-mounted) finishes in seconds. If the MCP is registered but failing, +(the `voltius-target` volume at `/target`) finishes in seconds. If the MCP is registered but failing, the cause is almost always that the container isn't up yet — bring it up and retry. Register the MCP if absent: @@ -32,7 +32,7 @@ Register the MCP if absent: ## Loop -1. `launch_app` `appPath=/app/target/debug/voltius` (check `get_app_state` first). +1. `launch_app` `appPath=/target/debug/voltius` (check `get_app_state` first). 2. Interact: `click_element`, `type_text` (`clear:true` to overwrite; `\n` sends Enter), `press_key` (Enter, arrows, chords like `["Control","l"]`), `wait_for_element`. 3. `capture_screenshot` with `returnBase64:false` → saves to `/app/screenshots/.png` diff --git a/.claude/skills/testing-voltius-team-features-e2e/wd.mjs b/.claude/skills/testing-voltius-team-features-e2e/wd.mjs index 716859e82..8525f3afe 100644 --- a/.claude/skills/testing-voltius-team-features-e2e/wd.mjs +++ b/.claude/skills/testing-voltius-team-features-e2e/wd.mjs @@ -26,7 +26,7 @@ try { // (kept for reference); set VOLTIUS_KEYCHAIN_NS at `docker run -e ...` instead. const env = {}; if (a[0]) env.VOLTIUS_KEYCHAIN_NS = a[0]; - const r = await j('POST', '/session', { capabilities:{ alwaysMatch:{ 'tauri:options':{ application:'/app/target/debug/voltius', env } } } }); + const r = await j('POST', '/session', { capabilities:{ alwaysMatch:{ 'tauri:options':{ application: process.env.VOLTIUS_APP_BIN || '/target/debug/voltius', env } } } }); const s = r.d && r.d.value && r.d.value.sessionId; if (!s) { console.log('FAIL '+JSON.stringify(r.d).slice(0,300)); process.exit(1); } writeFileSync(SIDF, s); await j('POST', `/session/${s}/timeouts`, { implicit: 6000 }); diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 000000000..36ea625dc --- /dev/null +++ b/.dockerignore @@ -0,0 +1,5 @@ +# Dockerfile.tauri-headless has no COPY or ADD — the repo arrives as a bind +# mount at run time, not baked into the image. Without this, every build tars +# the whole worktree, which carries a multi-gigabyte cargo target/ and +# node_modules and takes minutes before the first instruction runs. +* diff --git a/Dockerfile.tauri-headless b/Dockerfile.tauri-headless index 4e6d37479..385f34043 100644 --- a/Dockerfile.tauri-headless +++ b/Dockerfile.tauri-headless @@ -2,6 +2,15 @@ FROM ubuntu:24.04 ENV DEBIAN_FRONTEND=noninteractive +# The container writes into the bind-mounted worktree (pnpm install, and any +# cargo output that still lands there). Running as root left ~30k root-owned +# files the host user could not clean up, so the build user is created with the +# host's uid/gid: +# docker build -f Dockerfile.tauri-headless \ +# --build-arg UID="$(id -u)" --build-arg GID="$(id -g)" -t tauri-mcp . +ARG UID=1000 +ARG GID=1000 + # System dependencies: Xvfb + WebKit + compilers + mold (fast linker) + keyutils # (the app's OS keychain talks to the kernel keyutils keyring at startup) + ffmpeg # (records the Xvfb framebuffer for headless UI screen-capture / video) + xdotool @@ -25,14 +34,28 @@ RUN apt-get update && apt-get install -y \ python3-pil \ && rm -rf /var/lib/apt/lists/* +# ubuntu:24.04 ships a stock `ubuntu` user at 1000:1000, which collides with the +# common host uid. Drop it first so the requested ids are always free. +RUN userdel -r ubuntu 2>/dev/null || true; \ + groupadd -g "${GID}" builder 2>/dev/null || true; \ + useradd -m -u "${UID}" -g "${GID}" -s /bin/bash builder + # Install Node.js and enable pnpm RUN curl -fsSL https://deb.nodesource.com/setup_22.x | bash - && \ apt-get install -y nodejs && \ npm install -g pnpm -# Install Rust -RUN curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y -ENV PATH="/root/.cargo/bin:${PATH}" +# Rust lives outside root's home so the build user can reach it, and cargo's +# output lives OUTSIDE the bind mount at /app. A target dir anywhere under +# vite's root makes vite's watcher crawl it — the dev server climbs to ~15GB RSS +# and the webview never loads, which presents as every WebDriver call hanging +# with no error. Mount a host dir at /target to keep the cache across rebuilds. +ENV CARGO_HOME=/opt/cargo \ + RUSTUP_HOME=/opt/rustup \ + CARGO_TARGET_DIR=/target \ + PATH=/opt/cargo/bin:${PATH} + +RUN curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --no-modify-path # Install tauri-driver RUN cargo install tauri-driver @@ -41,12 +64,15 @@ RUN cargo install tauri-driver # RUSTFLAGS env) keeps the build fingerprint constant so the target/ cache is # reused across rebuilds. The host triple is detected at build time so this # works on any arch (arm64 / x86_64) without hardcoding. -RUN mkdir -p /root/.cargo && \ - TRIPLE="$(rustc -vV | sed -n 's/^host: //p')" && \ +RUN TRIPLE="$(rustc -vV | sed -n 's/^host: //p')" && \ printf '[target.%s]\nrustflags = ["-C", "link-arg=-fuse-ld=mold"]\n' "$TRIPLE" \ - > /root/.cargo/config.toml + > "${CARGO_HOME}/config.toml" + +RUN mkdir -p /target /app && \ + chown -R "${UID}:${GID}" /opt/cargo /opt/rustup /target /app WORKDIR /app +USER builder # Dev / hot-reload workflow: # 1. install JS deps @@ -63,16 +89,22 @@ CMD bash -c "CI=true pnpm install && \ (pnpm dev > /tmp/vite.log 2>&1 &) && \ keyctl session - xvfb-run --auto-servernum tauri-driver --port 4444" -# docker build -f Dockerfile.tauri-headless -t tauri-mcp . +# docker build -f Dockerfile.tauri-headless \ +# --build-arg UID="$(id -u)" --build-arg GID="$(id -g)" -t tauri-mcp . # # IMPORTANT: --security-opt seccomp=unconfined is REQUIRED. The app's OS keychain # uses the kernel keyutils keyring; the default Docker seccomp profile blocks # those syscalls, which makes keychain_get fail and the app hang on its splash. -# docker run -d --name tauri-headless --security-opt seccomp=unconfined -v "$(pwd):/app" tauri-mcp +# docker run -d --name tauri-headless --security-opt seccomp=unconfined \ +# -v "$(pwd):/app" -v voltius-target:/target tauri-mcp # # docker logs -f tauri-headless # claude mcp add tauri-docker -- docker exec -i tauri-headless npx -y github:VoltiusApp/mcp-tauri-automation # # Then, in Claude Code, drive the live dev build via the MCP: -# launch_app appPath=/app/target/debug/voltius +# launch_app appPath=/target/debug/voltius # capture_screenshot / click_element / type_text / ... +# +# NEVER run cargo against this repo mounted anywhere but /app: the absolute path +# is baked into the target dir's fingerprints, and a later build at /app then +# fails to read plugin permissions from the stale path. diff --git a/compose.headless.yml b/compose.headless.yml index c436e7109..2efe0d852 100644 --- a/compose.headless.yml +++ b/compose.headless.yml @@ -25,6 +25,12 @@ services: build: context: . dockerfile: Dockerfile.tauri-headless + args: + # Matches the container's build user to the host's, so pnpm install and + # cargo stop leaving root-owned files in the mounted worktree. Export + # UID/GID before `up` if your host ids are not 1000. + UID: ${UID:-1000} + GID: ${GID:-1000} image: tauri-mcp container_name: tauri-headless security_opt: @@ -38,6 +44,10 @@ services: - WEBKIT_DISABLE_COMPOSITING_MODE=1 volumes: - .:/app + # CARGO_TARGET_DIR. Deliberately not inside /app: vite's watcher crawls a + # target dir under its root, climbs to ~15GB RSS, and the webview then + # never loads — which presents as every WebDriver call hanging. + - voltius-target:/target networks: - voltius-test @@ -68,3 +78,6 @@ services: networks: voltius-test: driver: bridge + +volumes: + voltius-target: diff --git a/src/i18n/locales/en/common.json b/src/i18n/locales/en/common.json index d641ab4f6..441bf8c64 100644 --- a/src/i18n/locales/en/common.json +++ b/src/i18n/locales/en/common.json @@ -82,6 +82,7 @@ "emailAlreadyRegistered": "Email already registered", "registrationFailed": "Registration failed: {{status}}", "accountNotFound": "Account not found", + "tooManyAttempts": "Too many attempts — wait a minute and try again", "noAccountFoundCreateOne": "No account found. Please create one first.", "serverLoginFailed": "Server login failed", "sessionRefreshFailed": "Session refresh failed", diff --git a/src/i18n/locales/en/terminal.json b/src/i18n/locales/en/terminal.json index ef94e63f3..ed3f6ea25 100644 --- a/src/i18n/locales/en/terminal.json +++ b/src/i18n/locales/en/terminal.json @@ -151,7 +151,7 @@ "uninviteFailed": "Could not withdraw the invite", "inviteNoTeammates": "No teammates yet", "inviteLoadFailed": "Could not load teammates", - "peopleSearchPlaceholder": "Search by name, @handle, or email…", + "peopleSearchPlaceholder": "Search by @handle or email…", "peopleNoMatch": "No one in your teams matches \"{{query}}\".", "peopleFindRule": "People outside your teams are found by their @handle or their full email address.", "recentLabel": "Recent", diff --git a/src/i18n/locales/fr/common.json b/src/i18n/locales/fr/common.json index 66cf51a65..5943f51c7 100644 --- a/src/i18n/locales/fr/common.json +++ b/src/i18n/locales/fr/common.json @@ -82,6 +82,7 @@ "emailAlreadyRegistered": "Cet e-mail est déjà enregistré", "registrationFailed": "Échec de l'inscription : {{status}}", "accountNotFound": "Compte introuvable", + "tooManyAttempts": "Trop de tentatives — attendez une minute puis réessayez", "noAccountFoundCreateOne": "Aucun compte trouvé. Veuillez d'abord en créer un.", "serverLoginFailed": "Échec de la connexion au serveur", "sessionRefreshFailed": "Échec du renouvellement de la session", diff --git a/src/i18n/locales/fr/notifications.json b/src/i18n/locales/fr/notifications.json index da6e22fcf..6380a9f5c 100644 --- a/src/i18n/locales/fr/notifications.json +++ b/src/i18n/locales/fr/notifications.json @@ -43,7 +43,7 @@ "inbox": { "someone": "Quelqu'un", "invite": { - "message": "{{inviter}} vous a invité à rejoindre {{team}}", + "message": "{{inviter}} vous invite à rejoindre {{team}}", "accept": "Accepter", "decline": "Refuser" }, @@ -53,7 +53,7 @@ "joined": "Rejoint" }, "sessionInvite": { - "message": "{{inviter}} vous a invité à {{name}}" + "message": "{{inviter}} vous invite à rejoindre {{name}}" }, "sessionKnock": { "message": "{{inviter}} souhaite partager un terminal", @@ -68,7 +68,7 @@ "granted": "Vous avez le contrôle" }, "awaitingKey": { - "message": "En attente qu'un propriétaire de {{team}} accorde l'accès au coffre" + "message": "En attente qu'un propriétaire de {{team}} vous donne accès au coffre" } } } diff --git a/src/i18n/locales/fr/terminal.json b/src/i18n/locales/fr/terminal.json index c4d98b5e1..5c4924512 100644 --- a/src/i18n/locales/fr/terminal.json +++ b/src/i18n/locales/fr/terminal.json @@ -144,14 +144,14 @@ "hasControl": "A le contrôle", "stopSharing": "Arrêter le partage", "inviteHasAccess": "A déjà accès", - "inviteSent": "Invité", + "inviteSent": "Invitation envoyée", "inviteCapReached": "Limite atteinte", "inviteFailed": "Impossible d'inviter {{name}}", "withdrawInvite": "Retirer", "uninviteFailed": "Impossible de retirer l'invitation", "inviteNoTeammates": "Aucun coéquipier pour l'instant", "inviteLoadFailed": "Impossible de charger les coéquipiers", - "peopleSearchPlaceholder": "Rechercher par nom, @pseudo ou e-mail…", + "peopleSearchPlaceholder": "Rechercher par @pseudo ou e-mail…", "peopleNoMatch": "Personne dans vos équipes ne correspond à « {{query}} ».", "peopleFindRule": "En dehors de vos équipes, une personne se trouve par son @pseudo ou son adresse e-mail complète.", "recentLabel": "Récent", diff --git a/src/i18n/locales/ru/common.json b/src/i18n/locales/ru/common.json index 66784a513..d45254e42 100644 --- a/src/i18n/locales/ru/common.json +++ b/src/i18n/locales/ru/common.json @@ -82,6 +82,7 @@ "emailAlreadyRegistered": "Этот email уже зарегистрирован", "registrationFailed": "Не удалось зарегистрироваться: {{status}}", "accountNotFound": "Учётная запись не найдена", + "tooManyAttempts": "Слишком много попыток — подождите минуту и попробуйте снова", "noAccountFoundCreateOne": "Учётная запись не найдена. Сначала создайте её.", "serverLoginFailed": "Не удалось войти на сервер", "sessionRefreshFailed": "Не удалось обновить сессию", diff --git a/src/i18n/locales/ru/notifications.json b/src/i18n/locales/ru/notifications.json index 5577ab1f5..d08ad1e88 100644 --- a/src/i18n/locales/ru/notifications.json +++ b/src/i18n/locales/ru/notifications.json @@ -43,17 +43,17 @@ "inbox": { "someone": "Кто-то", "invite": { - "message": "{{inviter}} пригласил(а) вас в {{team}}", + "message": "Приглашение в {{team}} от {{inviter}}", "accept": "Принять", "decline": "Отклонить" }, "session": { - "message": "Коллега поделился сессией {{name}}", + "message": "Доступ к сессии {{name}} открыт коллегой", "join": "Присоединиться", "joined": "Вы в сессии" }, "sessionInvite": { - "message": "{{inviter}} пригласил(а) вас в {{name}}" + "message": "Приглашение в сессию {{name}} от {{inviter}}" }, "sessionKnock": { "message": "{{inviter}} хочет поделиться терминалом", diff --git a/src/i18n/locales/ru/terminal.json b/src/i18n/locales/ru/terminal.json index ff7e53560..98d003f09 100644 --- a/src/i18n/locales/ru/terminal.json +++ b/src/i18n/locales/ru/terminal.json @@ -154,14 +154,14 @@ "hasControl": "Управляет", "stopSharing": "Остановить совместный доступ", "inviteHasAccess": "Уже есть доступ", - "inviteSent": "Приглашён", + "inviteSent": "Приглашение отправлено", "inviteCapReached": "Лимит достигнут", "inviteFailed": "Не удалось пригласить {{name}}", "withdrawInvite": "Отозвать", "uninviteFailed": "Не удалось отозвать приглашение", "inviteNoTeammates": "Пока нет коллег по команде", "inviteLoadFailed": "Не удалось загрузить список коллег", - "peopleSearchPlaceholder": "Поиск по имени, @псевдониму или e-mail…", + "peopleSearchPlaceholder": "Поиск по @псевдониму или e-mail…", "peopleNoMatch": "В ваших командах никто не соответствует «{{query}}».", "peopleFindRule": "Людей вне ваших команд можно найти по их @псевдониму или полному адресу e-mail.", "recentLabel": "Недавние", diff --git a/src/i18n/locales/zh/common.json b/src/i18n/locales/zh/common.json index 2d2302390..363099db5 100644 --- a/src/i18n/locales/zh/common.json +++ b/src/i18n/locales/zh/common.json @@ -82,6 +82,7 @@ "emailAlreadyRegistered": "邮箱已被注册", "registrationFailed": "注册失败:{{status}}", "accountNotFound": "未找到账户", + "tooManyAttempts": "尝试次数过多,请稍等一分钟后重试", "noAccountFoundCreateOne": "未找到账户。请先创建一个。", "serverLoginFailed": "服务器登录失败", "sessionRefreshFailed": "会话刷新失败", @@ -184,13 +185,13 @@ "cascadeCopied_other": "{{names}}将被复制而非移动,因为留在原处的对象仍在使用它们。", "cascadeAlsoCopied_one": "其{{names}}也会一并复制。", "cascadeAlsoCopied_other": "其{{names}}也会一并复制。", - "pasteRootCrossVault": "同时显示多个保管库时,在顶层粘贴无法在保管库之间移动项目——请选择单个保管库,或在目标保管库中打开一个文件夹。", + "pasteRootCrossVault": "同时显示多个保险库时,在顶层粘贴无法在保险库之间移动项目——请选择单个保险库,或在目标保险库中打开一个文件夹。", "pasteFailed": "粘贴失败:{{error}}", "pasteBlocked": "未粘贴任何内容:你在相关保险库中缺少“{{permissions}}”权限。", "permissionsMissing": "你在相关保险库中缺少“{{permissions}}”权限", "pasteSkipped_one": "{{count}} 个项目已不存在,已跳过。", "pasteSkipped_other": "{{count}} 个项目已不存在,已跳过。", - "pasteWouldDangle": "未粘贴任何内容:这些项目引用的{{kinds}}会留在另一个保管库中并因此失效。请先将它们移入目标保管库。", + "pasteWouldDangle": "未粘贴任何内容:这些项目引用的{{kinds}}会留在另一个保险库中并因此失效。请先将它们移入目标保险库。", "kind": { "connection": "主机", "identity": "身份", diff --git a/src/i18n/locales/zh/terminal.json b/src/i18n/locales/zh/terminal.json index 405d8cd98..1dde11125 100644 --- a/src/i18n/locales/zh/terminal.json +++ b/src/i18n/locales/zh/terminal.json @@ -151,7 +151,7 @@ "uninviteFailed": "无法撤回邀请", "inviteNoTeammates": "暂无队友", "inviteLoadFailed": "无法加载队友列表", - "peopleSearchPlaceholder": "按姓名、@handle 或邮箱搜索…", + "peopleSearchPlaceholder": "按 @handle 或邮箱搜索…", "peopleNoMatch": "您的团队中没有人与“{{query}}”匹配。", "peopleFindRule": "团队之外的用户可通过其 @handle 或完整邮箱地址找到。", "recentLabel": "最近", diff --git a/src/services/account.serverAuth.test.ts b/src/services/account.serverAuth.test.ts index 740407278..dae5e5a2b 100644 --- a/src/services/account.serverAuth.test.ts +++ b/src/services/account.serverAuth.test.ts @@ -160,6 +160,24 @@ test("signInToCloud maps a failed login to invalidEmailOrPassword", async () => await expect(signInToCloud("a@b.co", "pw", S)).rejects.toThrow("common.error.invalidEmailOrPassword"); }); +test("signInToCloud reports a rate-limited challenge as such, not as a missing account", async () => { + // The auth limiter is a hardcoded 10/min per IP. Rendering its 429 as + // "Account not found" sends people off to create a second account. + h.http["/auth/challenge"] = err(429); + await expect(signInToCloud("a@b.co", "pw", S)).rejects.toThrow("common.error.tooManyAttempts"); +}); + +test("signInToCloud reports a rate-limited login as such, not as a bad password", async () => { + h.http["/auth/challenge"] = ok({ account_id: "acc" }); + h.http["/auth/login"] = err(429); + await expect(signInToCloud("a@b.co", "pw", S)).rejects.toThrow("common.error.tooManyAttempts"); +}); + +test("signInToCloud names the status on a server fault rather than blaming the account", async () => { + h.http["/auth/challenge"] = err(503); + await expect(signInToCloud("a@b.co", "pw", S)).rejects.toThrow("common.error.serverError"); +}); + test("signInToCloud wipes the previous local vault on success", async () => { h.http["/auth/challenge"] = ok({ account_id: "acc" }); h.http["/auth/login"] = ok({ ...TOKENS, wrapped_user_secrets: "W" }); diff --git a/src/services/account.ts b/src/services/account.ts index 123a0da0f..436567dcb 100644 --- a/src/services/account.ts +++ b/src/services/account.ts @@ -201,7 +201,7 @@ export async function login(password: string, email?: string, serverUrl?: string if (!accountId && email && serverUrl) { const res = await fetchWithTimeout(`${serverUrl}/v1/auth/challenge?email=${encodeURIComponent(email)}`); - if (!res.ok) throw new Error(i18n.t("common.error.accountNotFound")); + if (!res.ok) throw authFailure(res.status, "common.error.accountNotFound"); accountId = (await res.json()).account_id; } if (!accountId) throw new Error(i18n.t("common.error.noAccountFoundCreateOne")); @@ -472,6 +472,19 @@ export async function setMasterPassword(password: string): Promise { } } +/** + * Turns a failed auth response into the message the status actually means. + * The auth limiter is a hardcoded 10/min per IP with no env override, and a + * retry loop can exhaust it before the user types anything — collapsing every + * non-ok status into `expected` rendered that 429 as "Account not found", + * which sends people off to create a second account they don't need. + */ +function authFailure(status: number, expected: string): Error { + if (status === 429) return new Error(i18n.t("common.error.tooManyAttempts")); + if (status === 404 || status === 401 || status === 403) return new Error(i18n.t(expected)); + return new Error(i18n.t("common.error.serverError", { status })); +} + /** Sign in to an existing cloud account (any local mode — replaces local identity). */ export async function signInToCloud( email: string, @@ -480,7 +493,7 @@ export async function signInToCloud( ): Promise { serverUrl = normalizeServerUrl(serverUrl); const res = await fetchWithTimeout(`${serverUrl}/v1/auth/challenge?email=${encodeURIComponent(email)}`); - if (!res.ok) throw new Error(i18n.t("common.error.accountNotFound")); + if (!res.ok) throw authFailure(res.status, "common.error.accountNotFound"); const { account_id: accountId } = await res.json(); const { auth_key, enc_key: kek } = await deriveKeys(password, accountId); @@ -490,7 +503,7 @@ export async function signInToCloud( headers: { "Content-Type": "application/json" }, body: JSON.stringify({ account_id: accountId, auth_key }), }); - if (!loginRes.ok) throw new Error(i18n.t("common.error.invalidEmailOrPassword")); + if (!loginRes.ok) throw authFailure(loginRes.status, "common.error.invalidEmailOrPassword"); const data = await loginRes.json(); let vaultKey = kek; diff --git a/src/services/teamInbox.ts b/src/services/teamInbox.ts index 460ce1bcd..fc0de5cc2 100644 --- a/src/services/teamInbox.ts +++ b/src/services/teamInbox.ts @@ -32,13 +32,19 @@ export function resetTeamInboxState(): void { controlHeldSessions.clear(); } -function toast(message: string, duration: number): void { +/** + * `inboxId` marks the toast as an echo of an inbox entry, which keeps it out of + * the dismissed-notification history — see `ToastEntry.inboxId`. Omit it only + * for toasts that have no inbox entry behind them. + */ +function toast(message: string, duration: number, inboxId?: string): void { useNotificationStore.getState().addToast({ source: APP_SOURCE, type: "toast", message, severity: "info", duration, + inboxId, }); } @@ -212,7 +218,7 @@ export function reconcileSessions( .map((e) => e.id), ); for (const e of entries) { - if ((e.kind === "sessionInvite" || e.kind === "sessionKnock") && !known.has(e.id)) toast(e.message, 8000); + if ((e.kind === "sessionInvite" || e.kind === "sessionKnock") && !known.has(e.id)) toast(e.message, 8000, e.id); } reconcile(["sessionShared", "sessionInvite", "sessionKnock"], entries); @@ -260,7 +266,7 @@ export function reconcileControlRequests(connections: Record e.kind === "controlRequest").map((e) => e.id), ); for (const e of entries) { - if (!known.has(e.id)) toast(e.message, 8000); + if (!known.has(e.id)) toast(e.message, 8000, e.id); } reconcile(["controlRequest"], entries); diff --git a/src/stores/notificationStore.inbox.test.ts b/src/stores/notificationStore.inbox.test.ts index fb1015a82..0e7599074 100644 --- a/src/stores/notificationStore.inbox.test.ts +++ b/src/stores/notificationStore.inbox.test.ts @@ -63,3 +63,37 @@ test("unreadCount counts pending inbox entries and banners, and falls to zero wh get().retractInbox("invite:2"); expect(get().unreadCount()).toBe(0); }); + +test("a toast echoing an inbox entry is dropped, not archived to history", () => { + get().upsertInbox(entry("session:abc", "@ada wants to share a terminal")); + const toastId = get().addToast({ + source: APP, + type: "toast", + message: "@ada wants to share a terminal", + severity: "info", + duration: 8000, + inboxId: "session:abc", + }); + + get().dismissToast(toastId); + + expect(get().toasts).toHaveLength(0); + // A history row would render the same knock a second time with no Join or + // Decline on it — the inbox entry is the only copy that owns the actions. + expect(get().history).toHaveLength(0); + expect(get().inbox).toHaveLength(1); +}); + +test("a toast with no inbox entry behind it is still archived", () => { + const toastId = get().addToast({ + source: APP, + type: "toast", + message: "You have control", + severity: "info", + duration: 4000, + }); + + get().dismissToast(toastId); + + expect(get().history).toHaveLength(1); +}); diff --git a/src/stores/notificationStore.ts b/src/stores/notificationStore.ts index 418280c5c..b1dee7f29 100644 --- a/src/stores/notificationStore.ts +++ b/src/stores/notificationStore.ts @@ -24,6 +24,14 @@ export interface ToastEntry { finished?: boolean; finishedSeverity?: ToastSeverity; timedOutAt?: number; + /** + * Set when this toast is only a transient echo of an inbox entry that owns + * the same event. Such a toast is never archived: history rows carry no + * actions, so a knock whose toast timed out appeared in the panel twice — + * once still actionable in the inbox, once dead below it — and the dead copy + * is the one that reads like Join and Decline were lost. + */ + inboxId?: string; // Meta createdAt: number; } @@ -147,6 +155,7 @@ export const useNotificationStore = create((set, get) => ({ set((s) => { const toast = s.toasts.find((t) => t.id === id); if (!toast) return s; + if (toast.inboxId) return { toasts: removeById(s.toasts, id) }; const historyEntry: HistoryEntry = { id: toast.id, source: toast.source, From f0ce68c2fa71fed8dae018c84f560a261cab96e6 Mon Sep 17 00:00:00 2001 From: kipavy Date: Sun, 16 Aug 2026 11:00:29 +0000 Subject: [PATCH 3/3] fix(headless): give the container its own node_modules MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit pnpm records an absolute storeDir in node_modules/.modules.yaml. With the worktree bind-mounted, the container's install stamped /app/.pnpm-store there, and the next command on the host read a storeDir that does not exist locally, decided the tree was foreign and asked to purge it — which fails outside a TTY. That surfaced as every plugin-bundle test failing with a bare "Command failed: pnpm vite build", nowhere near the cause. A named volume at /app/node_modules keeps the two trees apart. Recovery if a container built from the old image poisoned yours: CI=true pnpm install. --- Dockerfile.tauri-headless | 8 +++++++- compose.headless.yml | 8 ++++++++ 2 files changed, 15 insertions(+), 1 deletion(-) diff --git a/Dockerfile.tauri-headless b/Dockerfile.tauri-headless index 385f34043..8dd667940 100644 --- a/Dockerfile.tauri-headless +++ b/Dockerfile.tauri-headless @@ -96,7 +96,13 @@ CMD bash -c "CI=true pnpm install && \ # uses the kernel keyutils keyring; the default Docker seccomp profile blocks # those syscalls, which makes keychain_get fail and the app hang on its splash. # docker run -d --name tauri-headless --security-opt seccomp=unconfined \ -# -v "$(pwd):/app" -v voltius-target:/target tauri-mcp +# -v "$(pwd):/app" -v voltius-node-modules:/app/node_modules \ +# -v voltius-target:/target tauri-mcp +# +# node_modules MUST be a volume, not shared with the host: pnpm records an +# absolute storeDir in node_modules/.modules.yaml, and a host command that then +# sees the container's path decides the tree is foreign and asks to purge it — +# which fails outside a TTY. Recovery on the host: `CI=true pnpm install`. # # docker logs -f tauri-headless # claude mcp add tauri-docker -- docker exec -i tauri-headless npx -y github:VoltiusApp/mcp-tauri-automation diff --git a/compose.headless.yml b/compose.headless.yml index 2efe0d852..fbc30d228 100644 --- a/compose.headless.yml +++ b/compose.headless.yml @@ -44,6 +44,13 @@ services: - WEBKIT_DISABLE_COMPOSITING_MODE=1 volumes: - .:/app + # The container gets its own node_modules. Sharing the host's poisons it: + # pnpm records an absolute `storeDir` in node_modules/.modules.yaml, the + # container's differs from the host's, and the next host command decides + # the tree is foreign and asks to purge it — which fails outside a TTY, + # so the plugin-bundle tests start failing with a bare "Command failed". + # (Recovery if it happens anyway: `CI=true pnpm install` on the host.) + - voltius-node-modules:/app/node_modules # CARGO_TARGET_DIR. Deliberately not inside /app: vite's watcher crawls a # target dir under its root, climbs to ~15GB RSS, and the webview then # never loads — which presents as every WebDriver call hanging. @@ -81,3 +88,4 @@ networks: volumes: voltius-target: + voltius-node-modules: