Skip to content

Harden runtime, storage, gateway, credentials, and IPC inputs - #2

Merged
likun666661 merged 6 commits into
apache:mainfrom
likun666661:hardening-phases-1-5-changelog
Jun 14, 2026
Merged

Harden runtime, storage, gateway, credentials, and IPC inputs#2
likun666661 merged 6 commits into
apache:mainfrom
likun666661:hardening-phases-1-5-changelog

Conversation

@likun666661

Copy link
Copy Markdown
Member

Summary

This PR contributes the first five hardening phases from the Rive deep-read follow-up work, plus a root CHANGELOG.md entry documenting the phase breakdown.

Phases included:

  • Phase 1: runtime permission/watchdog, permission timeout, Office abort propagation, and token usage extraction hardening.
  • Phase 2: JSONL session recovery for corrupt rows and truncated tails.
  • Phase 3: bot inbound/session limits and OpenGateway SSE abuse controls.
  • Phase 4: credential-store secret kind expansion for bot/proxy/gateway/Tavily secrets.
  • Phase 5: connection credential IPC slug/API-key validation before side effects.

Verification

Ran throughout the phase work:

  • npm --workspace @maka/runtime run typecheck
  • npm --workspace @maka/runtime run build
  • runtime focused tests for watchdog, permission engine, and AI SDK backend
  • storage build and focused session-store tests
  • desktop build:main and typecheck
  • focused desktop bot/OpenGateway, credential-store, settings/web-search, connection IPC, OAuth, and model-provider regression suites
  • git diff --check before each pushed phase

Latest focused Phase 5 verification:

  • desktop build/typecheck passed
  • focused Phase 5/OAuth regression suite: 63/63 pass
  • git diff --check passed

Notes

  • CHANGELOG.md summarizes phases 1-5 for maintainers.
  • Secrets remain out of connection records; new secret kinds are added to encrypted credential-store support only.
  • Phase 4 intentionally stops at credential-store expansion and does not migrate settings.json cleartext fields yet.

@likun666661
likun666661 force-pushed the hardening-phases-1-5-changelog branch from ceaa8a7 to 21a5851 Compare June 14, 2026 03:35
@likun666661
likun666661 merged commit 28f99a0 into apache:main Jun 14, 2026
jackwener pushed a commit that referenced this pull request Jun 21, 2026
Harden runtime, storage, gateway, credentials, and IPC inputs
jackwener pushed a commit that referenced this pull request Jun 21, 2026
Harden runtime, storage, gateway, credentials, and IPC inputs
jackwener added a commit that referenced this pull request Jun 21, 2026
PR77 (message meta) + PR76 follow-ups @kenji flagged:

**Message meta**: replaces the bare role-label `<span>` with a
`MessageMeta` component that renders [avatar][role name][timestamp]
inline. Avatar is a small (22px) tinted circle with the initial
glyph — uses the first non-ASCII codepoint when available, so userLabel
"建文" → 建, "JK" → J, "🦊 fox" → 🦊. Assistant gets a fixed "M". Per
role data-attr the circle uses the matching token (chat-user-bg for
user, accent-tinted for assistant, info-tinted for system).

**Tool output redaction (@kenji PR76 follow-up #1)**: `OverlayPreview`
now runs `redactSecrets` over `text` / `json` payloads and the
`FileDiffPreview` body. `TerminalPreview` redacts cwd-adjacent `cmd`
string + stdout + stderr — `$ curl -H "X-API-Key: ..." …` lines and
provider dumps go through the masker before display. Pairs with the
existing copy-error redaction in ToolErrorBanner (PR58 / PR60); both
display and copy paths are now consistent.

**Tool output truncation (@kenji PR76 follow-up #2)**: introduces
`TOOL_LINE_CAP = 500` + `capLines()`. file_diff body, terminal
stdout, terminal stderr, and plain text all slice to 500 lines and
append a "… N more lines hidden" marker. Prevents a single
megabyte-scale `npm test` failure from creating 10k React elements
and drowning the chat surface.

@maka/ui + desktop typecheck + build + tests green.
jackwener added a commit that referenced this pull request Jun 21, 2026
…doc-merge)

1. **iframe sandbox/CSP language tightened** (@kenji #1):
   - Remove the "separate webContents partition + permissive CSP for
     iframe" promise — DOM `<iframe>` doesn't get an independent
     partition without `<webview>` / BrowserView. Don't promise what
     the DOM can't deliver.
   - Add `allow-forms` and `allow-modals` to the not-allowed list
   - Switch from `src="file://..."` to `srcdoc={await readText()}` so
     content delivery goes through the path-guard read helper
   - Note that global CSP `default-src 'self'` stays locked — sandbox
     attribute carries all isolation

2. **Symlink-aware path guard** (@kenji #2):
   - Both `realpath(artifactRoot)` AND `realpath(target)` required,
     then containment check
   - Mirror PR56 open-path-guard which already has symlink-escape +
     `..` + URL-scheme test coverage

3. **readBinary MIME allow-list** (@kenji #3):
   - Allowlist: image/png/jpeg/gif/webp/svg+xml, application/pdf
   - MIME sniffed at read time, NOT trusted from record.kind or
     record.mimeType
   - Unknown → `reason: 'unsupported_mime'` + UI shows metadata +
     "在 Finder 中打开" / "另存为" actions (no raw bytes to renderer)

4. **Deleted tombstone blocks reads** (@kenji #4):
   - `status: 'deleted'` records immediately return `reason: 'deleted'`
     from readText / readBinary — even if the underlying file still
     exists on disk
   - Surface table updated: deleted row preview shows "此 artifact 已
     删除,预览已停止"
   - Permanent path purge is a separate Settings · 数据 flow

5. **PR108c runtime hook scope** (@kenji #5):
   - First version does NOT do LLM extractor
   - Only deterministic outputs: Write→file record, Edit→diff record,
     Bash with explicit redirect → file record
   - stdout/stderr NOT auto-promoted (noise)
   - LLM extractor (parse ```html``` from assistant message) deferred
     to PR109+

Also: type rename `storagePath` → `relativePath` to enforce contract
in the type system; full reason union widened on both read helpers.

doc total: 1416 lines.
jackwener added a commit that referenced this pull request Jun 21, 2026
Consumes @xuan's PR109c turn-level contract. Splits into three pieces
per @kenji review (smaller PR landing earlier):

**(a) deriveTurnLineageMap test coverage**
@xuan already shipped the helper in @maka/ui/materialize.ts; PR109d
adds 8 invariant cases to the existing test file:
- empty map when no descendants
- retriedTo-only / regeneratedTo-only branches
- multi-retry last-wins (matches current helper shape)
- mixed retry + regenerate
- multiple origins
- bare turn list returns empty map
- helper is pure (input not mutated)

**(b) Turn footer action matrix**
New pure helper `deriveTurnFooterActions()` at
`apps/desktop/src/renderer/turn-footer-actions.ts`. Matrix per @kenji
review gate #1: footer enabled set comes only from `TurnStatus` +
lineage state, NEVER from text content guesses.

Status → enabled set:
- `running`     → 复制
- `completed`   → 重新生成 / 分支 / 复制
- `failed`      → 重试 / 分支 / 复制
- `aborted`     → 重试 / 分支 / 复制

`copy` separately gated on `hasContent` — empty turns disable it
regardless of status.

13 node:test cases lock the matrix + tooltip Chinese-only + enum
non-leakage + `alreadyRetried` hint variation + multi-status
invariants.

New @maka/ui `<TurnFooterActions>` component (icon+text per @kenji
+ @xuan: not pure-icon hover toolbar, so check-a11y stays happy):
buttons render disabled when not enabled but stay visible so the
user sees what actions exist on the turn. Local clipboard write for
`copy`; other actions bubble to `onTurnFooterAction(turnId, actionId)`.

`ChatView` gains `turnFooterActionsByTurn` + `onTurnFooterAction`
props. Renderer (main.tsx) computes the per-turn matrix from
materialized turns + lineage map and routes button clicks to the
new IPCs (`sessions:retryTurn` / `sessions:regenerateTurn` /
`sessions:branchFromTurn`).

**(c) Aborted turn marker**
TurnView renders a small "(已中断)" italic + muted `Ban` icon block
above the assistant message body when `turn.status === 'aborted'`.
Per @kenji gate #2: aborted is dormant history, NOT a destructive
error tone — uses muted styling, not red.

CSS:
- `.maka-turn-aborted-marker` — muted background + Ban icon
- `.maka-turn-footer` + `.maka-turn-footer-action` — pill row of
  icon+text buttons; disabled state via opacity 0.45

Tests: 213 → 226 desktop passing (+13 footer matrix). check-a11y 0
violations.

Deferred to PR109e / PR109f:
- failed turn AlertOctagon + generalizedErrorMessage banner
- lineage badges (forward "重试自 turn X" + reverse derive
  "已重试 → turn Y")
- branched session banner with "从中断前分支" copy
- `turn-control-history` fixture + smoke Path 15
jackwener added a commit that referenced this pull request Jun 21, 2026
Two blocker findings from @kenji's contract-consumer review of A2
addressed:

**Blocker #1: user-facing copy no longer leaks internal PR / IPC
terms.** Every JSX-rendered string referencing `PR-CU-*`, `PR-HC-*`,
`PR-REAL-*`, `typed action IPC`, or `ToolOutputDelta` is replaced
with product language. PR-tracking references survive only in
JSDoc / code comments, where they belong.

  - Permission Center footnote: "正在 PR-CU-0 / PR-CU-1(Computer Use
    原生 helper)路上,落地后会替换这里的只读视图。" →
    "权限引导模块(Computer Use 原生 helper)接入后会替换这里的只读视图。"
  - Permission Center action hint titles:
    "操作入口等 PR-CU-0/1 typed action IPC 接入后可用" →
    "权限引导模块接入后提供"
  - Permission Center audit slot empty state:
    "审计日志将在 PR-REAL-3 接入后显示。" → "审计日志接入后显示。"
  - Health Center hard-rule p tag:
    "凭据测试只属于 validation 层,运行态需要 PR-REAL-4 接入实测探测。" →
    "凭据测试只属于 validation 层,运行态需要运行态探测接入后实测。"
  - Health Center footnote:
    "操作入口等 PR-HC-2 typed action IPC 接入后可用。所有真正的运行态探测
    (PR-REAL-4 ToolOutputDelta + send/stream/abort smoke)落地后会自动
    出现在「运行态探测」层。" →
    "运行态修复操作接入后显示。所有真正的运行态探测落地后会自动出现
    在「运行态探测」层。"

**Blocker #2: CAPABILITY_READINESS_COPY.enabled.detail no longer
overpromises runtime probe.** A2 is a read-only consumer; whether
`runtimeProbe.state` is `not_available` / `not_run` / `passed` is
already shown separately in the per-layer breakdown below the
summary pill. The summary copy must not synthesize a conclusion
the snapshot didn't make.

  - "配置、权限、运行态探测都已通过。" →
    "当前快照标记为可用,具体层级见下方。"

The runtime probe layer remains the single source of truth for
"是否运行态通过"; the summary pill's `detail` now defers to it
rather than asserting it.

@kenji non-blocking follow-up #3 (`toLocaleString()` host-locale
drift) deferred to when Permission/Health pages enter visual smoke
— mirrors the `MAKA_VISUAL_SMOKE_LOCALE` deterministic-formatter
pattern. Not blocking A2 since A2 carries no screenshot baseline.

332 desktop tests pass; typecheck + check-a11y + check-console
clean. No behavior change beyond copy.
jackwener added a commit that referenced this pull request Jun 21, 2026
Compacts 3 jackwener PR commits without changing the resulting tree.

- PR-UI-A3 review fixup #2 (@kenji msg 365ff8b9): renderer-side secondary redaction + per-tool/per-chunk size caps + JSDoc nit
- PR-UI-A3 follow-up: surface outputTruncated in ToolOutputStream header
- PR-UI-A3 copy nit (@kenji msg c0a549b1): truncated tooltip
jackwener added a commit that referenced this pull request Jun 21, 2026
Two blockers from @kenji's review of B1-a1 fixed:

**Blocker #1 — visual-smoke day-period determinism**

LAYOUT-4's `EmptyChatHero` greeting prefix called
`detectDayPeriod(new Date())`. The visual-smoke renderer freezes
`Date.now()` to a fixture timestamp but does NOT override the
`Date` constructor, so screenshot baselines drifted at the
5/11/14/18-hour boundaries.

Fix: `detectDayPeriod` now accepts `nowMs: number = Date.now()`.
Default arg picks up the visual-smoke clock freeze automatically;
real users continue reading the host clock as before. The function
is exported from `@maka/ui` so the boundary contract is testable.

New test `apps/desktop/src/main/__tests__/empty-hero-day-period.test.ts`
(7 cases) locks the boundary table:
  - 00:00–04:59 → evening
  - flip to morning at 05:00
  - flip to noon at 11:00
  - flip to afternoon at 14:00
  - flip to evening at 18:00
  - `Date.now()` stub flows through default arg (the visual-smoke
    determinism guarantee)
  - same nowMs → same period (referential transparency)

**Blocker #2 — ⌘K palette hint overflow + a11y leak**

LAYOUT-5b introduced `.maka-hero-palette-hint`. Two issues:

  a) `width: fit-content` + `white-space: nowrap` on the inner
     `<span>` caused the chip to overflow on narrow viewports
     (the en/zh hint copy easily exceeds 990px hero width). The
     screenshot matrix gate includes a 990px viewport so this
     would have shipped a layout regression.

  b) Entire `<span>` had `aria-hidden="true"`. The hint
     announces a real keyboard shortcut (⌘K opens command
     palette) and a navigation entrypoint to AT users; hiding it
     strips real info from the AT tree. The original justification
     ("shortcut is exposed via the command palette dialog") only
     holds for AT users who already know about ⌘K — for the
     discovery-mode AT user this is the same hint sighted users
     get, and it should reach them too.

Fixes:
  - CSS: drop `width: fit-content`, add `max-width: 100%`; change
    inner span from `white-space: nowrap` to `normal`; add `min-width: 0`
    + `line-height: 1.4` so wrapping reads clean. `<kbd>` keeps
    `flex: 0 0 auto` so the glyph pair stays on one line even when
    text wraps below. `border-radius` 999px → 14px so a 2-line wrap
    doesn't look like a smashed pill.
  - JSX: remove `aria-hidden="true"` from outer span. Add
    `aria-keyshortcuts="Meta+K"` so AT exposes the chord cleanly.
    Inner `<kbd>` glyphs become `aria-hidden` (they read noisily
    as "command K"); the textual hint stays in the AT tree.

354 desktop tests pass (was 347; +7 new). typecheck + check-a11y
+ check-console clean.

Other B1-a1 review findings (blockquote vs alert / focus-ring /
no contract consumption / SetupHero copy) all passed and are
unchanged.
jackwener added a commit that referenced this pull request Jun 21, 2026
redaction + per-delta/per-session caps for thinking stream

Two trust-boundary blockers from @kenji's C0 review fixed using the
same A3 tool-output pattern:

**Blocker #1: secondary redaction**

The original `thinking_delta` / `thinking_complete` handlers
appended `event.text` directly into `thinkingBySession` state and
the renderer drew it via `<pre>{text}</pre>` (NOT Markdown), so the
PR description's "走 markdown render path 自带 redaction" was
wrong. Model thinking can echo prompt / env / tool stderr / pasted
credentials past the provider's redactor, so the raw text must not
reach React state.

**Blocker #2: unbounded buffer**

Both handlers appended/replaced without any size cap. `<pre>`'s
CSS `max-height: 320px` only bounds visual height; the DOM text /
React state / DevTools snapshot still grow unbounded. A misbehaving
provider could ship multi-MB deltas.

Fix: pure helpers in `packages/ui/src/thinking-stream.ts` exporting
`applyThinkingDelta(prev, rawDelta, opts) -> { text, redacted, truncated }`
and `applyThinkingComplete(rawText, opts) -> { text, redacted, truncated }`:

  - L1 secondary `redactSecrets(raw)` BEFORE state. Raw bearer /
    `sk-...` / API-key text never reaches stored text or `<pre>`.
  - L2 per-delta cap (default 4 KB, matches A3 + runtime
    `TOOL_OUTPUT_DELTA_MAX_CHARS`): tail-keep with `[…单条 delta
    已截断]` marker.
  - L3 per-session total cap (default 32 KB, 2x A3's per-tool
    cap because thinking can run longer): tail-keep most recent
    reasoning with `[…已截断早期 reasoning]` marker so the user
    sees the CURRENT chain of thought, not the start.
  - `redacted` is monotonic: upstream `redacted: true` is never
    downgraded.
  - Defensive: non-string `rawDelta` / `rawText` returns the prev
    state untouched (runtime contract violation, drop silently
    rather than coerce).

Renderer changes in `apps/desktop/src/renderer/main.tsx`:

  - New state `thinkingTruncatedBySession: Record<string, boolean>`
    flag, monotonic per session. Flipped to `true` when the helper
    returns `truncated: true`; cleared with `thinkingBySession` in
    `clearStreaming(sessionId)` so the next turn starts clean.
  - `thinking_delta` handler now calls `applyThinkingDelta`, updates
    both text + truncated flag.
  - `thinking_complete` handler now calls `applyThinkingComplete`,
    same chokepoint.
  - `ChatView` gains `thinkingTruncated?: boolean` prop; renderer
    passes `activeThinkingTruncated`.

UI changes in `packages/ui/src/components.tsx`:

  - `<ReasoningPanel>` props gain `truncated: boolean`.
  - Header renders `已截断` pill (warning-tone, same chrome as A3's
    `<ToolOutputStream>` truncated pill) with title="部分 reasoning
    已截断;显示的是最近的内容" when truncated fires. AT users get
    the title via the cursor:help hover.

CSS additions in `apps/desktop/src/renderer/styles.css`:

  - `.maka-reasoning-panel-truncated[data-truncated="true"]` chrome
    matches the A3 `.maka-tool-output-stream-counts span[data-truncated]`.

Tests in `apps/desktop/src/main/__tests__/thinking-stream.test.ts`
(14 new cases, 368 total):
  - raw `Authorization: Bearer sk-test...` → masked in stored text +
    `redacted: true`
  - raw `sk-ant-...` bare key → masked
  - clean text → `redacted: false`, no truncation
  - non-string input → silently returns prev unchanged
  - single oversize delta → tail-keep with marker, `truncated: true`
  - tail content of oversize delta survives
  - delta at-or-under cap → unchanged
  - sustained ~32KB+ → total cap drops oldest, `[step-0]` gone,
    `[step-11]` survives
  - total-cap fires set `truncated: true`
  - `applyThinkingComplete` replace path: clean / redacted / oversize
  - combined oversize + secret → secret never in stored text

`<ReasoningPanel>` change is renderer-side and exercised through
the screenshot pipeline; no React-test framework added.

368 desktop tests pass (was 354; +14 new). typecheck +
check-a11y + check-console clean.
jackwener added a commit that referenced this pull request Jun 21, 2026
internal probe + explicit safe-scheme external allowlist

Two trust-boundary blockers from @kenji's C2 review fixed:

**Blocker #1: case-variant `maka:` was falling to external `<a>`**

`MarkdownLink` previously gated on `isMakaUri(href)` which is
strictly case-sensitive (`href.startsWith('maka:')`). So
`Maka://settings/account` / `MAKA://compose?text=hi` /
`MaKa://settings/health` were not recognized as internal-looking
URIs and fell through to the external `<a target=_blank>` path —
exactly the case the gate was supposed to catch. A prompt-injected
case-variant `Maka://` would have opened in the OS browser.

Fix: new `isMakaUriCandidate(href) = /^maka:/i.test(href)` for the
renderer's probe. `parseMakaUri` stays strict (only lowercase
`maka:` accepted). The MarkdownLink now does:

  isMakaUriCandidate(href) && parseMakaUri(href) === null
    → broken-link `<span>` with `data-reason="internal-invalid"`
       and `title="内部链接无效"`

**Blocker #2: external path had no safe-scheme gate**

Without an explicit allowlist, `javascript:`, `data:`, `file:`,
`vbscript:` and custom schemes would render as `<a target=_blank>`,
relying on react-markdown's sanitize behavior. The link chokepoint
should not depend on an upstream pipeline.

Fix: new `isSafeExternalScheme(href)` parses via `new URL(href)`
(not naive prefix-match — @kenji msg 73e92ef0 explicitly required
the parser path so `mailto:` matches actual mailto URLs, not
`mailto-info:contact` or bare emails) and accepts ONLY:
  - `http:`
  - `https:`
  - `mailto:`

MarkdownLink falls back to a separate broken-link `<span>` with
`data-reason="unsafe-scheme"` and `title="链接不安全"` for
anything else. Distinct copy + data-reason from the internal-
invalid case so visual-smoke / a11y snapshots can directly see
which gate fired (@kenji msg 73e92ef0).

Updated tests in `apps/desktop/src/main/__tests__/maka-uri.test.ts`
(12 new cases, 440 total):
  - isMakaUriCandidate: lowercase / uppercase / mixed-case accept;
    `makafake://` / `https://` reject; non-string defensive;
    combined gate (candidate=true + parseMakaUri=null) for the
    case-variants
  - isSafeExternalScheme: http / https / mailto accept;
    javascript / data / file / vbscript reject; maka: rejected
    (handled by internal path); custom / unknown schemes rejected;
    garbage / unparseable hrefs rejected; bare emails without
    `mailto:` prefix rejected (locks the parser-path guarantee);
    non-string defensive

`MarkdownLink` JSDoc updated to describe the new 3-way render
tree: candidate-valid → button; candidate-invalid →
internal-invalid `<span>`; safe-scheme external → `<a>`; unsafe →
unsafe-scheme `<span>`.

The dangerous-scheme case (`javascript:alert(1)` etc.) now hits
the unsafe-scheme broken `<span>` instead of `<a>`. Even if
react-markdown's pipeline weakened its sanitizer, the link
chokepoint here would still neutralize it.

440 desktop tests pass (was 428; +12 new). typecheck +
check-a11y + check-console clean.
jackwener added a commit that referenced this pull request Jun 21, 2026
Lands the shared privacy contract that SEARCH-2.5 / future MEMORY
read gate / VOICE-1 consume so each lane does not re-invent the
incognito flag. Contract-only — no IPC, no renderer, no settings UI,
no storage, no runtime enforcement.

Anchors:
- xuan msg `0f1a3a2b` (lane assignment), `ece30c92` (review pre-conditions
  #1 reject-no-default + #2 authority rule), `0ee0a3b7` (canonical-strip
  named test request).
- kenji msg `64ba21cb` (default-false-is-not-permission + canonical
  strip extra fields).
- Predecessors that consume this contract: PR-SEARCH-1 G3 gate, PR-MEMORY-1
  `MemoryWriteRequestContext.incognitoActive`, PR-VOICE-0 incognito refusal.

== Contract surface ==

`packages/core/src/incognito.ts`:

  export interface WorkspacePrivacyContext {
    incognitoActive: boolean;
  }

  export function defaultWorkspacePrivacyContext(): WorkspacePrivacyContext;
  export function isWorkspacePrivacyContext(value: unknown): value is WorkspacePrivacyContext;
  export function validateWorkspacePrivacyContext(input: unknown): WorkspacePrivacyContextResult;
  export const WORKSPACE_PRIVACY_CONTEXT_INVALID_REASONS = ['not_object', 'incognito_active_invalid'];

v1 has exactly one field. Extending the shape is a contract change
requiring this doc, every consumer lane, and the test catalog to update.

== Authority rules (locked) ==

1. `incognitoActive` source-of-truth is main / session / workspace owner.
2. Renderer can READ or DISPLAY the context but CANNOT submit a context
   to prove its state in either direction. A renderer payload claiming
   `false` is just as unauthoritative as one claiming `true`. Per
   xuan `ece30c92`.
3. Default state is `incognitoActive: false`, produced ONLY by
   `defaultWorkspacePrivacyContext()`. Validator NEVER invents a
   default. Missing or non-boolean `incognitoActive` is a typed reject.
   Per xuan `ece30c92` #1.
4. `default false` does NOT mean "writes allowed". It means "no
   incognito gate fired"; consumers MUST still consult their own
   per-lane policy gates. Per kenji `64ba21cb`.

== Validator pipeline ==

1. typeof object guard (rejects null / array / primitive / function).
2. `incognitoActive` typeof boolean guard.
3. Canonical return strips extra fields — renderer cannot self-attest
   policy via `{ incognitoActive: false, durableWriteAllowed: true }`.
   Per kenji `64ba21cb` + xuan `0ee0a3b7` named test request.

== Tests added (22 new) ==

`packages/core/src/__tests__/incognito.test.ts`:

  - default factory returns { incognitoActive: false } + fresh refs +
    validator does NOT produce default.
  - validate accepts {incognitoActive: true} and {incognitoActive: false}.
  - validate rejects missing incognitoActive (per xuan #1).
  - validate rejects non-boolean incognitoActive (8 values: 0, 1,
    'true', 'false', null, undefined, [], {}, () => false).
  - validate rejects null / undefined / array / 4 primitive payloads.
  - canonical-strip: shadowPolicy + forcedFalse + __proto__ stripped.
  - named test (xuan `0ee0a3b7`): `{incognitoActive:false,
    durableWriteAllowed:true}` returns exactly `{incognitoActive:false}`.
  - canonical false consumers consult own policy (no writeAllowed /
    durableWriteAllowed exists on contract).
  - isWorkspacePrivacyContext type guard accepts/rejects matrix.
  - closed reason enum exact.
  - cross-lane shape pins: exactly one field on default, exactly one
    field on canonical return.

== Threat model doc ==

`docs/workspace-privacy-context.md`:

- Authority rules (4 enumerated).
- Validator pipeline.
- Closed reason enum.
- Consumer obligations: SEARCH (PR-SEARCH-2.5), MEMORY, VOICE (PR-VOICE-1
  deferred), TELEMETRY, LOGS.
- "default false != permission grant" paragraph (kenji `64ba21cb`).
- Out-of-scope: settings UI / storage / IPC / runtime enforcement /
  per-session scoping / time-bounded / additional privacy variants.
- Forbidden source-grep surfaces.
- Migration path for downstream consumers (MEMORY-2 alignment,
  SEARCH-2.5 wiring, VOICE-1 wiring).

== Source hygiene ==

`incognito.ts` is plain ASCII UTF-8. No regex character classes, no
control bytes, no String.fromCharCode trickery (not needed for a
single-boolean contract).

== Source gate self-check ==

- No `ipcMain.handle` / `BrowserWindow` / `fetch(` / `XMLHttpRequest` /
  `new WebSocket` / `electron` import in `incognito.ts` or its test.
- No storage repo names referenced.
- No settings shape additions; `AppSettings` is not touched.
- No IPC channel registered; no preload binding; no `global.d.ts` change.
- No consumer lane wired in this packet — each consumes in its own
  future PR.

== Test sweep ==

  core: 390 pass (+22 from PR-INCOGNITO-0; was 368).
  storage: 50 pass (unchanged).
  runtime: 103 pass (unchanged).
  desktop: 599 pass (unchanged).
  total: 1142 pass.

All workspace typechecks pass. All workspace builds pass.

== Files (5 changed) ==

  packages/core/src/incognito.ts                       (NEW, ~140 lines)
  packages/core/src/__tests__/incognito.test.ts        (NEW, ~190 lines)
  docs/workspace-privacy-context.md                    (NEW, ~120 lines)
  packages/core/src/index.ts                           (+12 barrel)
  packages/core/package.json                           (+1 subpath)

== Out of scope (separate sign-off required) ==

- Settings UI toggle for entering/leaving incognito.
- Storage representation of incognito mode.
- IPC channels for renderer subscription.
- Runtime enforcement at consumer lanes (PR-SEARCH-2.5 / future MEMORY
  read gate / PR-VOICE-1 — those each wire separately).
- Per-session vs workspace-wide scoping.
- Time-bounded incognito.
- Privacy-mode variants beyond `incognitoActive`.
jackwener added a commit that referenced this pull request Jun 21, 2026
…inese-first labels, stub views for non-implemented modules

Implements the IA refactor WAWQAQ locked in msg `b86b47d1`: the
sidebar's second part is now a top-level 5-module nav (会话 / 搜索 /
计划 / 技能 / 每日回顾) rather than session filter tabs. Pinned /
Archived / Chats become internal Sessions filters. Settings stays at
the bottom; a placeholder 版本更新 button sits above it.

Per xuan `47e204f2` priorities applied directly:
  - #2 IA: top-level modules; filters moved into Sessions.
  - #4 visual hierarchy: nav rows reuse `.maka-nav-row` (transparent
    bg, accent-tinted selected state, no card stacking).
  - #5 Chinese-first naming throughout the surface; English stays
    as accessible keywords in the command palette.
  - #7 Search nav item placeholder for Phase 4 modal; clicking now
    switches the section to `'search'` and renders an empty-state
    stub. Phase 4 will swap the section switch for an
    `onOpenSearchModal()` callback (already plumbed through props).
  - #8 disabled / hover / focus states pinned for the Update
    placeholder (aria-disabled + data-disabled + CSS dim).

== Type-level changes ==

`packages/ui/src/components.tsx`:

  export type NavSelection =
    | { section: 'sessions'; filter: SessionFilter }
    | { section: 'search' }
    | { section: 'automations' }
    | { section: 'skills' }
    | { section: 'daily-review' };

  // New module label map (Chinese-first):
  const MODULE_NAV_LABEL: Record<NavSelection['section'], string> = {
    sessions: '会话', search: '搜索', automations: '计划',
    skills: '技能', 'daily-review': '每日回顾',
  };

== SessionListPanel JSX shape ==

  <aside>
    <header>新建对话 button</header>
    <nav.maka-sidebar-modules>          5 module nav rows  (NEW)
    <div.maka-session-filter hidden={!sessions}>      Pinned/Chats/Archived
    <div.maka-session-search hidden={!sessions}>      session-name search
    <section.maka-session-list>
      ├ skills view (existing, reused for `selection.section==='skills'`)
      ├ sessions view (existing, when 'sessions')
      └ STUB_VIEWS[section] (search / automations / daily-review)
    <footer.maka-session-panel-footer>
      ├ 版本更新 button (NEW; UI placeholder, aria-disabled by default)
      └ 设置 button (existing; renamed from "Settings")
  </aside>

Skills no longer appears in the footer — it lives in the top-level
module nav alongside the other 4. The chat surface (`ChatPanel`)
shows a neutral "从 会话 选择对话" message when the active section
is `search` / `automations` / `daily-review`.

== Persistence ==

`apps/desktop/src/renderer/main.tsx readNavSelection`: recognizes
`search` / `automations` / `daily-review` as valid section variants
when restoring from localStorage. Corrupted entries fall back to
`{ section: 'sessions', filter: 'chats' }`.

== CSS ==

`apps/desktop/src/renderer/styles.css`:

  .maka-session-panel grid-template-rows:
    auto auto auto auto minmax(0, 1fr) auto;

  /* Row 2 is the new module nav. Phase 1's `.maka-session-list` grid
   * fix (auto + minmax(0, 1fr)) and `.maka-list-stack` overflow:auto
   * are preserved verbatim. */

  .maka-sidebar-modules { display: grid; gap: 2px; padding: 4px 8px; }

  /* Disabled nav-row dimming (used by 版本更新 placeholder). */
  .maka-nav-row[aria-disabled="true"],
  .maka-nav-row[data-disabled="true"] { cursor: not-allowed; opacity: 0.55; }
  .maka-nav-row[aria-disabled="true"]:hover,
  .maka-nav-row[data-disabled="true"]:hover { background: transparent; }

== Tests ==

  core 392 / storage 50 / runtime 103 / desktop 645 = 1190 pass.
  Typecheck + build green across all workspaces.
  Phase 1's CSS contract test still passes (grid + min-height + overflow
  pins survive the IA refactor).

== Out of scope (Phase 3 / 4) ==

  - Session row slimming (~85px → ~32px). Phase 3.
  - Search modal implementation. Phase 4 (resurrects `useThreadSearch`
    from `yuejing/pr-search-2.6`; swaps current Search stub for modal
    trigger).
  - Real auto-update wiring (Electron updater). Future PR-AUTOUPDATE-0.
  - Automations / Daily Review feature implementation. Future
    PR-TIME-* / PR-DAILY-*.

Anchors:
  WAWQAQ msg `b86b47d1` + `f5f6f834` (IA + Skills inclusion + Update button).
  xuan msg `47e204f2` (8-point priority list); `dc790a54` (Phase 1 scope refinement);
  `adcf0c95` (per-phase gate workflow).
  kenji msg `fcd9c54f` (consolidated packet design); `0f7bb872` (scroll P0 architecture).

Branch base: `655a09f` (Phase 1 + fixup, on top of main `809875a`).
jackwener added a commit that referenced this pull request Jun 21, 2026
…ame + Permission Center cleanup + badge polish

Continuing per kenji's commit-1 review (msg 93ee9df0):

1. Sidebar group label `会话 65` → `会话 · 65` with middle-dot
   separator span + dropped `margin-left: auto` on count so the
   count clusters next to the label instead of right-aligning
   like row meta. Reads as one phrase "group · count" now.

2. 即将推出 badge polish (kenji 93ee9df0 #2): bg alpha 0.06→0.04,
   radius 999px → 4px (rounded rect not pill), `cursor: default`
   + `pointer-events: none`. Reads as informational tag, not
   clickable pill.

3. Settings · 搜索服务 → 联网搜索 (yuejing UX audit msg 9c779b56
   semantic disambiguation): the sidebar's local-content search
   modal is conceptually different from web-search-engine
   credentials. Naming both `搜索` makes users confuse them.
   `联网搜索` for web search; sidebar `搜索` modal stays for
   local content.

4. Permission Center "可暂停 · 即将可用" / "可撤销 · 即将可用"
   chips removed — they violated the capability presentation
   contract by rendering like disabled toggle buttons. Hidden
   until the actual pause/revoke wiring ships in
   PR-PERMISSION-GUIDE-0, at which point they return as real
   `data-state="available"` buttons.

5. New `? 快捷键` chip in sidebar footer (yuejing UX audit P2 #10):
   surfaces the existing `?` keyboard shortcut for
   KeyboardHelpModal. Hidden discoverability was the main issue —
   ⌘? opens the modal but no user without docs-reading habit
   could find it.

   Implementation:
   - `useKeyboardHelp()` now returns `[open, closeHelp, openHelp]`
     so non-keyboard callers can open the modal without
     dispatching synthetic KeyboardEvent's. Existing callers
     ignoring the third element are unaffected.
   - `main.tsx` AppShell destructures the new `openHelp` and
     passes it to SessionListPanel as `onOpenKeyboardHelp`.
   - SessionListPanel renders a small `.maka-session-panel-help-chip`
     button in the footer below 设置. Visual weight is intentionally
     lighter than the nav rows (it's a hint, not a primary action).
     Includes a `<kbd>?</kbd>` glyph + "快捷键" label, plus
     `aria-label`, `title`, and focus-visible ring.

Gates:
- typecheck clean, UI + renderer build clean
- desktop tests: 647 pass / 0 fail (includes xuan's PR-DESKTOP-SMOKE-0
  4 new contract assertions from `8bb09d4` rebase)
- core tests: 392 pass

Remaining commit-2 scope items (will land in commit 3 if needed):
- Daily Review Settings sub-page consolidation (audit says hide;
  needs xuan/WAWQAQ call on whether to fully hide or keep with
  passive copy)
- Modal backdrop token unification (cross-modal `--modal-backdrop-blur`
  / `--modal-backdrop-scrim`)
- Capability enum static-analysis gate (visible-copy-hygiene-contract
  extension forbidding `<button>` for `data-state="coming_soon"`)

These three are smaller and less-WAWQAQ-visible than the items
in commits 1-2; will batch after the resize blocker passes and
this stack starts merging.
jackwener added a commit that referenced this pull request Jun 21, 2026
…ist filter input

Addresses WAWQAQ msg `e0dbad11` blocker #1 + #2 and kenji msg
`2844f64f` blocker #1 + #2:

1. **Sidebar `? 快捷键` chip removed entirely.**
   - WAWQAQ called the position "很蠢"; kenji noted sidebar footer
     is for product nav/state, not help affordances.
   - Discoverability is now solely via Command Palette's existing
     `查看键盘快捷键` entry (`command-palette.tsx` line 113) + the
     global `?` keydown listener that has always existed.
   - Removed: `.maka-session-panel-help-chip` JSX + CSS,
     `onOpenKeyboardHelp` prop on SessionListPanel,
     `onOpenKeyboardHelp={openHelp}` wiring in main.tsx.
   - Retained: `useKeyboardHelp()` 3-tuple return — Command Palette
     `onOpenShortcuts` now uses the `openHelp` callback directly
     instead of dispatching a synthetic `KeyboardEvent`. Same effect,
     clearer intent, no risk of swallowing real `?` keypresses
     typed in text inputs.

2. **`筛选会话` in-list filter input removed entirely.**
   - WAWQAQ said earlier this input is unnecessary (he reminded
     us in `e0dbad11`); my P0 fixup v3 only renamed the placeholder
     instead of removing the input — that was a misread.
   - All search capability lives in the top-level `搜索` modal.
     Commit 5 (this PR, next commit) wires that modal to real
     `window.maka.search.thread()` so the global entry actually
     works.
   - Removed: `searchQuery` state, `searchInputRef`, the
     `useMemo` filter, the ⌘F/Ctrl+F focus binding `useEffect`,
     the `.maka-session-search` JSX block + clear button, the
     `.maka-session-search` + `.maka-session-search-clear` CSS,
     the "没有匹配的会话" empty state (only fired when input had
     content + no name matches).
   - `.maka-session-panel` grid-template-rows: 5 rows → 4 rows
     (header / module-nav / list / footer). Existing
     `sidebar-scroll-contract.test.ts` still passes because the
     `minmax(0, 1fr)` row is still present.

Gates:
- typecheck clean, UI + renderer build clean
- desktop tests: 647 pass / 0 fail
- core tests: 392 pass

Next commit (commit 5, this same PR):
- Wire Search modal to real `window.maka.search.thread()` per
  kenji `2844f64f` SEARCH gate: no query persistence, no
  `maka://session` fallback, incognito blocked state, plain text
  snippet (no markdown/image/path leaks), no Command Palette
  wiring, selection navigates + scrolls to message.
jackwener pushed a commit that referenced this pull request Jun 21, 2026
Harden runtime, storage, gateway, credentials, and IPC inputs
jackwener added a commit that referenced this pull request Jun 21, 2026
PR77 (message meta) + PR76 follow-ups @kenji flagged:

**Message meta**: replaces the bare role-label `<span>` with a
`MessageMeta` component that renders [avatar][role name][timestamp]
inline. Avatar is a small (22px) tinted circle with the initial
glyph — uses the first non-ASCII codepoint when available, so userLabel
"建文" → 建, "JK" → J, "🦊 fox" → 🦊. Assistant gets a fixed "M". Per
role data-attr the circle uses the matching token (chat-user-bg for
user, accent-tinted for assistant, info-tinted for system).

**Tool output redaction (@kenji PR76 follow-up #1)**: `OverlayPreview`
now runs `redactSecrets` over `text` / `json` payloads and the
`FileDiffPreview` body. `TerminalPreview` redacts cwd-adjacent `cmd`
string + stdout + stderr — `$ curl -H "X-API-Key: ..." …` lines and
provider dumps go through the masker before display. Pairs with the
existing copy-error redaction in ToolErrorBanner (PR58 / PR60); both
display and copy paths are now consistent.

**Tool output truncation (@kenji PR76 follow-up #2)**: introduces
`TOOL_LINE_CAP = 500` + `capLines()`. file_diff body, terminal
stdout, terminal stderr, and plain text all slice to 500 lines and
append a "… N more lines hidden" marker. Prevents a single
megabyte-scale `npm test` failure from creating 10k React elements
and drowning the chat surface.

@maka/ui + desktop typecheck + build + tests green.
jackwener added a commit that referenced this pull request Jun 21, 2026
…doc-merge)

1. **iframe sandbox/CSP language tightened** (@kenji #1):
   - Remove the "separate webContents partition + permissive CSP for
     iframe" promise — DOM `<iframe>` doesn't get an independent
     partition without `<webview>` / BrowserView. Don't promise what
     the DOM can't deliver.
   - Add `allow-forms` and `allow-modals` to the not-allowed list
   - Switch from `src="file://..."` to `srcdoc={await readText()}` so
     content delivery goes through the path-guard read helper
   - Note that global CSP `default-src 'self'` stays locked — sandbox
     attribute carries all isolation

2. **Symlink-aware path guard** (@kenji #2):
   - Both `realpath(artifactRoot)` AND `realpath(target)` required,
     then containment check
   - Mirror PR56 open-path-guard which already has symlink-escape +
     `..` + URL-scheme test coverage

3. **readBinary MIME allow-list** (@kenji #3):
   - Allowlist: image/png/jpeg/gif/webp/svg+xml, application/pdf
   - MIME sniffed at read time, NOT trusted from record.kind or
     record.mimeType
   - Unknown → `reason: 'unsupported_mime'` + UI shows metadata +
     "在 Finder 中打开" / "另存为" actions (no raw bytes to renderer)

4. **Deleted tombstone blocks reads** (@kenji #4):
   - `status: 'deleted'` records immediately return `reason: 'deleted'`
     from readText / readBinary — even if the underlying file still
     exists on disk
   - Surface table updated: deleted row preview shows "此 artifact 已
     删除,预览已停止"
   - Permanent path purge is a separate Settings · 数据 flow

5. **PR108c runtime hook scope** (@kenji #5):
   - First version does NOT do LLM extractor
   - Only deterministic outputs: Write→file record, Edit→diff record,
     Bash with explicit redirect → file record
   - stdout/stderr NOT auto-promoted (noise)
   - LLM extractor (parse ```html``` from assistant message) deferred
     to PR109+

Also: type rename `storagePath` → `relativePath` to enforce contract
in the type system; full reason union widened on both read helpers.

doc total: 1416 lines.
jackwener added a commit that referenced this pull request Jun 21, 2026
Two blocker findings from @kenji's contract-consumer review of A2
addressed:

**Blocker #1: user-facing copy no longer leaks internal PR / IPC
terms.** Every JSX-rendered string referencing `PR-CU-*`, `PR-HC-*`,
`PR-REAL-*`, `typed action IPC`, or `ToolOutputDelta` is replaced
with product language. PR-tracking references survive only in
JSDoc / code comments, where they belong.

  - Permission Center footnote: "正在 PR-CU-0 / PR-CU-1(Computer Use
    原生 helper)路上,落地后会替换这里的只读视图。" →
    "权限引导模块(Computer Use 原生 helper)接入后会替换这里的只读视图。"
  - Permission Center action hint titles:
    "操作入口等 PR-CU-0/1 typed action IPC 接入后可用" →
    "权限引导模块接入后提供"
  - Permission Center audit slot empty state:
    "审计日志将在 PR-REAL-3 接入后显示。" → "审计日志接入后显示。"
  - Health Center hard-rule p tag:
    "凭据测试只属于 validation 层,运行态需要 PR-REAL-4 接入实测探测。" →
    "凭据测试只属于 validation 层,运行态需要运行态探测接入后实测。"
  - Health Center footnote:
    "操作入口等 PR-HC-2 typed action IPC 接入后可用。所有真正的运行态探测
    (PR-REAL-4 ToolOutputDelta + send/stream/abort smoke)落地后会自动
    出现在「运行态探测」层。" →
    "运行态修复操作接入后显示。所有真正的运行态探测落地后会自动出现
    在「运行态探测」层。"

**Blocker #2: CAPABILITY_READINESS_COPY.enabled.detail no longer
overpromises runtime probe.** A2 is a read-only consumer; whether
`runtimeProbe.state` is `not_available` / `not_run` / `passed` is
already shown separately in the per-layer breakdown below the
summary pill. The summary copy must not synthesize a conclusion
the snapshot didn't make.

  - "配置、权限、运行态探测都已通过。" →
    "当前快照标记为可用,具体层级见下方。"

The runtime probe layer remains the single source of truth for
"是否运行态通过"; the summary pill's `detail` now defers to it
rather than asserting it.

@kenji non-blocking follow-up #3 (`toLocaleString()` host-locale
drift) deferred to when Permission/Health pages enter visual smoke
— mirrors the `MAKA_VISUAL_SMOKE_LOCALE` deterministic-formatter
pattern. Not blocking A2 since A2 carries no screenshot baseline.

332 desktop tests pass; typecheck + check-a11y + check-console
clean. No behavior change beyond copy.
jackwener added a commit that referenced this pull request Jun 21, 2026
Compacts 3 jackwener PR commits without changing the resulting tree.

- PR-UI-A3 review fixup #2 (@kenji msg 365ff8b9): renderer-side secondary redaction + per-tool/per-chunk size caps + JSDoc nit
- PR-UI-A3 follow-up: surface outputTruncated in ToolOutputStream header
- PR-UI-A3 copy nit (@kenji msg c0a549b1): truncated tooltip
jackwener added a commit that referenced this pull request Jun 21, 2026
Two blockers from @kenji's review of B1-a1 fixed:

**Blocker #1 — visual-smoke day-period determinism**

LAYOUT-4's `EmptyChatHero` greeting prefix called
`detectDayPeriod(new Date())`. The visual-smoke renderer freezes
`Date.now()` to a fixture timestamp but does NOT override the
`Date` constructor, so screenshot baselines drifted at the
5/11/14/18-hour boundaries.

Fix: `detectDayPeriod` now accepts `nowMs: number = Date.now()`.
Default arg picks up the visual-smoke clock freeze automatically;
real users continue reading the host clock as before. The function
is exported from `@maka/ui` so the boundary contract is testable.

New test `apps/desktop/src/main/__tests__/empty-hero-day-period.test.ts`
(7 cases) locks the boundary table:
  - 00:00–04:59 → evening
  - flip to morning at 05:00
  - flip to noon at 11:00
  - flip to afternoon at 14:00
  - flip to evening at 18:00
  - `Date.now()` stub flows through default arg (the visual-smoke
    determinism guarantee)
  - same nowMs → same period (referential transparency)

**Blocker #2 — ⌘K palette hint overflow + a11y leak**

LAYOUT-5b introduced `.maka-hero-palette-hint`. Two issues:

  a) `width: fit-content` + `white-space: nowrap` on the inner
     `<span>` caused the chip to overflow on narrow viewports
     (the en/zh hint copy easily exceeds 990px hero width). The
     screenshot matrix gate includes a 990px viewport so this
     would have shipped a layout regression.

  b) Entire `<span>` had `aria-hidden="true"`. The hint
     announces a real keyboard shortcut (⌘K opens command
     palette) and a navigation entrypoint to AT users; hiding it
     strips real info from the AT tree. The original justification
     ("shortcut is exposed via the command palette dialog") only
     holds for AT users who already know about ⌘K — for the
     discovery-mode AT user this is the same hint sighted users
     get, and it should reach them too.

Fixes:
  - CSS: drop `width: fit-content`, add `max-width: 100%`; change
    inner span from `white-space: nowrap` to `normal`; add `min-width: 0`
    + `line-height: 1.4` so wrapping reads clean. `<kbd>` keeps
    `flex: 0 0 auto` so the glyph pair stays on one line even when
    text wraps below. `border-radius` 999px → 14px so a 2-line wrap
    doesn't look like a smashed pill.
  - JSX: remove `aria-hidden="true"` from outer span. Add
    `aria-keyshortcuts="Meta+K"` so AT exposes the chord cleanly.
    Inner `<kbd>` glyphs become `aria-hidden` (they read noisily
    as "command K"); the textual hint stays in the AT tree.

354 desktop tests pass (was 347; +7 new). typecheck + check-a11y
+ check-console clean.

Other B1-a1 review findings (blockquote vs alert / focus-ring /
no contract consumption / SetupHero copy) all passed and are
unchanged.
jackwener added a commit that referenced this pull request Jun 21, 2026
internal probe + explicit safe-scheme external allowlist

Two trust-boundary blockers from @kenji's C2 review fixed:

**Blocker #1: case-variant `maka:` was falling to external `<a>`**

`MarkdownLink` previously gated on `isMakaUri(href)` which is
strictly case-sensitive (`href.startsWith('maka:')`). So
`Maka://settings/account` / `MAKA://compose?text=hi` /
`MaKa://settings/health` were not recognized as internal-looking
URIs and fell through to the external `<a target=_blank>` path —
exactly the case the gate was supposed to catch. A prompt-injected
case-variant `Maka://` would have opened in the OS browser.

Fix: new `isMakaUriCandidate(href) = /^maka:/i.test(href)` for the
renderer's probe. `parseMakaUri` stays strict (only lowercase
`maka:` accepted). The MarkdownLink now does:

  isMakaUriCandidate(href) && parseMakaUri(href) === null
    → broken-link `<span>` with `data-reason="internal-invalid"`
       and `title="内部链接无效"`

**Blocker #2: external path had no safe-scheme gate**

Without an explicit allowlist, `javascript:`, `data:`, `file:`,
`vbscript:` and custom schemes would render as `<a target=_blank>`,
relying on react-markdown's sanitize behavior. The link chokepoint
should not depend on an upstream pipeline.

Fix: new `isSafeExternalScheme(href)` parses via `new URL(href)`
(not naive prefix-match — @kenji msg 73e92ef0 explicitly required
the parser path so `mailto:` matches actual mailto URLs, not
`mailto-info:contact` or bare emails) and accepts ONLY:
  - `http:`
  - `https:`
  - `mailto:`

MarkdownLink falls back to a separate broken-link `<span>` with
`data-reason="unsafe-scheme"` and `title="链接不安全"` for
anything else. Distinct copy + data-reason from the internal-
invalid case so visual-smoke / a11y snapshots can directly see
which gate fired (@kenji msg 73e92ef0).

Updated tests in `apps/desktop/src/main/__tests__/maka-uri.test.ts`
(12 new cases, 440 total):
  - isMakaUriCandidate: lowercase / uppercase / mixed-case accept;
    `makafake://` / `https://` reject; non-string defensive;
    combined gate (candidate=true + parseMakaUri=null) for the
    case-variants
  - isSafeExternalScheme: http / https / mailto accept;
    javascript / data / file / vbscript reject; maka: rejected
    (handled by internal path); custom / unknown schemes rejected;
    garbage / unparseable hrefs rejected; bare emails without
    `mailto:` prefix rejected (locks the parser-path guarantee);
    non-string defensive

`MarkdownLink` JSDoc updated to describe the new 3-way render
tree: candidate-valid → button; candidate-invalid →
internal-invalid `<span>`; safe-scheme external → `<a>`; unsafe →
unsafe-scheme `<span>`.

The dangerous-scheme case (`javascript:alert(1)` etc.) now hits
the unsafe-scheme broken `<span>` instead of `<a>`. Even if
react-markdown's pipeline weakened its sanitizer, the link
chokepoint here would still neutralize it.

440 desktop tests pass (was 428; +12 new). typecheck +
check-a11y + check-console clean.
jackwener added a commit that referenced this pull request Jun 24, 2026
Kenji aesthetic audit reminder 4/6 (msg `6cc0e04d` 2026-06-24,
finding #2): Tooltip is a high-frequency hover/focus surface and
should use instant positioning + compositor-only popup transitions.
Two sites violated:

1. Positioner: `transition-[top,left,right,bottom,transform]`
   — `top` / `left` / `right` / `bottom` are layout-trigger
   properties. Every reposition (e.g. anchor moved by scroll, or
   side flipped on collision) ran through layout + paint instead
   of GPU compositor. Reduced to `transition-transform` so the
   transform-based reposition still tweens smoothly; the layout
   positioning happens instantly off-frame.

2. Popup: `transition-[width,height,scale,opacity]` — `width` /
   `height` are layout-trigger. Reduced to `transition-[scale,opacity]`,
   which is what the data-starting-style / data-ending-style scale-98
   + opacity-0 actually animate. The popup-width / popup-height
   CSS vars still set the size; just no longer transitioned.

Net behavior: tooltip pops in/out via scale+fade (compositor),
repositions via transform (compositor). Layout properties are
applied instantly per frame instead of crossfading.

Drawer's `transition-[transform,box-shadow,height,background-color]`
and Tabs' `transition-[width,translate]` indicator are flagged in
kenji's audit but deferred — both interact with swipe gesture / tab
sizing logic and need a focused PR with visual smoke verification.

Scoped to 2 lines in 1 file to keep merge surface minimal with the
in-flight PRs #202 / #204 / #206.
jackwener added a commit that referenced this pull request Jun 24, 2026
…scape hatch (#214)

Kenji aesthetic-audit reminder 9 (msg `bacde601` 2026-06-24,
findings #2 + #3): two chrome design-system holes still leaking
gradients / non-token blur into the app.

## #2 — darwin sidebar footer gradient

`html[data-os="darwin"] .maka-session-panel-footer` was setting:
`background: linear-gradient(180deg, transparent 0%, oklch(.../0.30) 100%)`

The panel above already has a 50% glass base + 8px backdrop blur,
so the footer doesn't need any additional material — but the
180deg fade IS the same "上面灰下面白" pattern WAWQAQ called out
twice before in chat sweep (`1e693dee` / `5d3b10e5`). Replaced
with flat `oklch(...) / 0.15` + hairline border-top.

## #3 — `[data-gradual-blur-layer]` block

A 56-line block that let any element opt into:
- `linear-gradient(to bottom/top, var(--background) → transparent)`
- `backdrop-filter: blur(6px)`
- raw `z-index: 5`

just by wearing the `data-gradual-blur-layer` attribute. Kenji
called it a "blur escape hatch" — a future surface could re-
introduce blur and a non-token z-index without going through
any review of the design system.

`grep -rn data-gradual-blur-layer --include=*.tsx` confirms zero
JSX consumers — the CSS is dead. Removing the entire block kills
the escape hatch + retires one of the four raw z-index numbers
kenji flagged in finding #4.

`notes/reference-atlas.md` references stay — those describe what
reference upstream does, not what we ship.

## Diff

1 file changed, 18 insertions(+), 60 deletions(-) net delete.
No JSX touched. No tests touched (no contract pinned this attr).
Footer change is darwin-only so non-darwin platforms unaffected.

Far from PR #202 styles.css edit area (onboarding ~1500-1700 +
cubic-bezier sweep on transition rules). PR #210 touches main.tsx
debounce, not styles.css. No merge conflicts.
jackwener added a commit that referenced this pull request Jun 24, 2026
…k-run buttons (#227)

WAWQAQ msg `0951e3b1` + `3871e56b` 2026-06-25, two related bugs in
the Daily Review surfaces:

## #2 — 分析模型 Select 特别长

`.settingsRow` is a 2-column grid `minmax(150px, 0.36fr) 1fr`. On a
wide settings page the right control column can be 500px+. With
`w-full` on the `<SelectTrigger>`, the dropdown stretched the entire
right column — hence "特别长,完全就不对".

Fix: add a `.settingsRow > .settingsBaseSelectTrigger` rule that
caps `max-width: 320px` and `justify-self: end`, mirroring the
existing `.settingsField[data-orient="horizontal"]` rule that
already caps inputs on the parallel horizontal-row layout. The
Select now visually sits in the same right-edge position as the
Switch toggles in adjacent rows.

## #3 — 生成 buttons 直接靠近文本

`.maka-daily-review-quick-runs` was `display: flex` with no
`justify-content`, so the 生成每日回顾 / 生成深度分析 buttons sat
flex-start (left-aligned), hugging the explanatory paragraph
directly above them.

Fix: `justify-content: flex-end` pushes them right, matching the
panel's right-action convention (the 复制 / 粘到 / 保存 row below
already sits right via its `1fr auto` grid).

## Diff

1 file, +18 / -1. CSS-only.
jackwener added a commit that referenced this pull request Jun 25, 2026
Per @kenji audit `26a221be` against #237 main `4e0247f6`:

**#1 — bottom 「想先看看效果?」 hero buttons left-aligned.**
PR #236 right-aligned the top 「打开每日回顾」 button via
`.settingsFeatureStatusHeroActions` but the bottom 「生成每日回顾 /
生成深度分析」 row still used inline `style={{ display: 'flex', gap:
8, marginTop: 8 }}`, so it stayed glued to the paragraph margin.
Route the bottom row through the same class. Add `gap: 8px` to the
class so multi-button clusters keep proper spacing.

**#2 — 执行时间 disabled when the master switch is off.**
The disable condition was `formDisabled || savingKey === 'executeTime'
|| !(effectiveConfig?.enabled ?? false)`. That last clause blocked the
common UX of "pick a time first, then turn it on". Drop it — the time
value is harmless to edit while disabled (it doesn't fire until the
switch flips), and the disabled state was misread as "broken UI".

**#3 — 分析模型 default-row label still verbose.**
WAWQAQ's directive: "所有模型选择只用模型名". PR #236 went from
`使用对话默认模型(Codex OAuth · email · gpt-5.5)` to
`对话默认(gpt-5.5)` — better, but the model id parenthetical still
mixes "selectable model" with "follow chat default". Per @kenji's
"don't conflate the two semantics", the default row becomes plain
`跟随对话默认`; the explicit options carry the model id.

**#4 — duplicate model ids across connections looked unselectable.**
When two enabled connections both expose `gpt-5.5`, the flat
`model`-only labels read as two identical entries. Detect the collision
at build time and append `· ${connection.name}` ONLY to the colliding
entries; unique model ids stay terse per WAWQAQ's directive.

Deferred (with @kenji concurrence):
- **#5** (`data-control-width` semantic API replacing CSS-selector width
  patches) — pure refactor, no end-result change. WAWQAQ explicitly
  said 「只看最后的结果」.
- **#6** (collapse Daily Review hero card pile into a single grouped
  settings surface) — bigger visual restructure judgment call; needs
  WAWQAQ sign-off before swinging at the whole page layout.
jackwener added a commit that referenced this pull request Jun 25, 2026
@kenji audit thread `#my-ai:4821a792` msg `e4cfbfb0` vs main `71d88454`.
WAWQAQ msg `782a1663` + `继续`: bundle these, don't split.

**#1 — Memory page top control rows had no outer card surface.**
The three control rows (本地 MEMORY.md / 模型上下文可读取 / 项目指令
文件) were direct children of `.settingsStructuredPage`, which is
transparent. `.settingsFormRow` only carries a hairline bottom border,
so users saw three floating rows without the grouped-card visual every
other settings surface uses. Wrap them in `.settingsRows` so they
inherit the 12px outer border + radius + overflow:hidden treatment.

**#2 — Plan Reminder delivery picker showed text-only IM channels.**
Settings → 机器人对话 switched to real brand logos (Iconify simple-icons)
in #BOT-SETTINGS-UI-0, but the chat-side Plan Reminder delivery flow's
`平台` select was still rendering plain Chinese labels. Same channels
should read the same everywhere.
- Move `BOT_BRAND` to `packages/ui/src/bot-brand.ts` so both surfaces
  share a single brand-metadata source instead of one duplicating the
  other.
- Extend `PlanReminderSelect` to accept a third tuple slot (optional
  `ReactNode` icon); `PlanReminderPanel` now passes an `<IconifyIcon
  icon={brand.iconifyId}>` per provider with the existing offline
  glyph fallback.
- Update `SettingsModal.tsx` to import `BOT_BRAND` from `@maka/ui`
  instead of declaring a local copy.

**#5 — Two inline `style={{ marginTop }}` spacing patches.**
- `Alert variant="error" style={{ marginTop: 12 }}` → `className=
  "settingsSurfaceAlert"`.
- `<div className="settingsActionRow" style={{ marginTop: 8 }}>` →
  `className="settingsActionRow settingsNoticeAction"`.
The new classes pin the spacing decision in CSS where the design
tokens live, instead of inline JSX.

Deferred per kenji's own scope note + WAWQAQ 「只看最后的结果」:
- **#3** (`RadioCard` / `ChoiceCard` primitive for theme/palette picker)
  — pure design-system refactor, no visible delta on Settings → 外观.
- **#4** (`SettingsSelect` uplift to rich-option contract) — bigger
  cross-cutting refactor; current fix covers the immediate model-name
  and disambiguation issues kenji raised in round 1.
- **#6** (layout-property transitions in sidebar/tabs/accordion) —
  kenji explicitly flagged these as visual-smoke-gated; will land
  alongside the smoke config in a separate PR.
jackwener added a commit that referenced this pull request Jun 25, 2026
…offline (#249)

@kenji audit msg `e4cfbfb0` round-2 #2: the bot logo picker was
rendering through `<IconifyIcon icon="simple-icons:telegram">` etc.,
which Iconify lazy-fetches from `api.iconify.design` at runtime. On
cold-offline Electron launches (or firewalled networks) the logos
silently degraded to the `glyph` monogram fallback. Desktop brand
logos should not depend on a third-party CDN at runtime.

Ship a partial offline fix that covers 4 of the 6 brands today:

- New `packages/ui/src/bot-brand-icons.ts` holds the verbatim SVG
  `<path>` bodies for telegram / wechat / discord / qq, each copied
  from upstream Simple Icons (CC0) at the version where the icon was
  last published (1.2.87 for the first three; 1.2.10 for `tencentqq`
  before Simple Icons retired the standalone QQ id).
- `icons.tsx` registers these as a local Iconify collection under the
  `maka-bot:*` prefix at module load via `addCollection`, mirroring the
  existing `addCollection(phData)` Phosphor registration so `<Icon
  icon="maka-bot:telegram">` resolves synchronously without any
  network roundtrip.
- `BOT_BRAND` points telegram, wechat, wecom, discord, qq at
  `maka-bot:*`.

Honest gap (documented in both `bot-brand.ts` and `bot-brand-icons.ts`):
feishu/lark and dingtalk are not (and never were) carried by Simple
Icons under those names, so they still fall through `simple-icons:*`
CDN lazy fetch with the colored-tile + glyph offline fallback. Sourcing
them from each brand's official kit is a separate follow-up so this
PR's SVG provenance stays auditable; until then, kenji's contract goal
(no `simple-icons:*` ids at all) is partially met.

No new runtime deps. `@iconify-json/simple-icons` was used only as a
local one-shot extraction source and is not added to `packages/ui`'s
`dependencies`.
jackwener added a commit that referenced this pull request Jun 25, 2026
… dvh (#250)

@kenji audit msg `232aec0f` (`#my-ai:c28a6293`). Round 3 against
`af66ebb7` post-#249.

**#2 — Plan Reminder select collapsed state was text-only.**
PR #247 added a brand icon to each `<SelectItem>`, but `<SelectValue
/>` defaulted to rendering just the label string — open dropdown
showed a logo, collapsed trigger showed plain text. Build a
`value → { label, icon }` lookup inside `PlanReminderSelect` and pass
a function-child to `SelectValue` so the picked state renders the same
icon + label row the dropdown items render.

**#6 — `100vh` residue in renderer CSS.**
Four sites still on `100vh`:
- `maka-tokens.css:760` `.maka-shell` `height`
- `maka-tokens.css:1941` `.maka-modal` `max-height: calc(100vh - 80px)`
- `styles.css:5028` `.maka-help-modal` `max-height: calc(100vh - 96px)`
- `styles.css:13121` `.maka-onboarding-stack` `min-height: calc(100vh - 84px)`

Replace each with `100dvh` so Electron browser frames with dynamic
chrome (or a future mobile-style viewport) don't get half a viewport
of height calculation drift. Other renderer surfaces already use
`100dvh`; this brings the legacy four into line.

Deferred per kenji's own scope:
- **#1** feishu/dingtalk official-brand-kit sourcing — separate PR.
- **#3 / #4** SettingsSelect / Segmented / ChoiceCard primitive
  unification — design-system refactor; no end-result delta on its
  own.
- **#5** layout-property transitions in sidebar/tabs/accordion —
  kenji explicitly gated on visual smoke harness.
jackwener pushed a commit that referenced this pull request Jul 6, 2026
…ete (#520 PR8) (#562)

* feat(ui): migrate SearchModal result list to Base UI Autocomplete (#520 PR8)

SearchModal's hand-rolled roving-focus result list (activeResultIndex /
moveActiveResult / jumpActiveResult / keyboardSelectionHandledRef /
handleResultKeyDown / data-active) is replaced by Base UI Autocomplete in
activedescendant mode:

- Autocomplete.Root inline + mode="none" + autoHighlight="always" +
  filter={null}: the list renders inline in the modal body (no floating
  popup), Autocomplete does not re-filter the server-side IPC results, and
  the first result is always highlighted so Enter works without an extra
  ArrowDown.
- Autocomplete.Input renders the input via the shared InputGroupInput
  primitive (render prop); ArrowUp/Down/Enter/Escape keyboard nav is owned
  by Autocomplete (floating-ui useListNavigation).
- Autocomplete.List + Autocomplete.Item replace the hand-rolled
  <ul role=listbox> + <li><button role=option>; item onClick fires
  selectResult for both pointer click and Enter on the highlighted item.
- aria-activedescendant on the input is now managed by Autocomplete.

selectResult navigation (sessionId + turnId, restoreFocus: false), the
debounced IPC search, the inflight ticket guard, the unmount invalidation,
the clear button, the snippet rendering, and all copy/states are unchanged.

The roving-focus kbd-nav interaction (ArrowDown moved focus to the result
button) becomes activedescendant (input keeps focus, active item reflected
via aria-activedescendant). a11y is more standard; the interaction habit
shifts. Home/End now move the input cursor (Base UI ComboboxInput default);
jump-to-first/last result is not bound for now (to be confirmed by manual
testing per PR8 plan).

search-modal-lifecycle-contract: the kbd-nav it-block is rewritten to lock
the Autocomplete shape (Root props + Item onClick + selectResult navigation)
instead of the roving-focus implementation; the focus-policy it-block drops
the activeResultIndex/moveActiveResult assertions; the empty-query it-block
repoints onChange -> onValueChange. CSS .maka-search-modal-result[data-active]
-> [data-highlighted] (Autocomplete item highlighted state).

Verification: typecheck clean, @maka/desktop 2076/2076, @maka/ui 43/43,
sidebar-search-modal-open screenshot AE=2655 (fuzz 5%, RMSE 0.0006) vs main.

* feat(ui): migrate CommandPalette to Base UI Autocomplete (#520 PR8)

CommandPalette's hand-rolled activedescendant result list (highlight
state + onInputKeyDown + reset useEffect + <div role=listbox> +
<Button role=option data-active>) is replaced by Base UI Autocomplete:

- Autocomplete.Root inline + mode="none" + autoHighlight="always" +
  filter={null}: the list renders inline in the modal body, Autocomplete
  does not re-filter the palette's own fuzzy + content-search combined
  list, and the first command is always highlighted so Enter works
  without an extra ArrowDown.
- Autocomplete.Input renders the input via the shared InputGroupInput
  primitive (render prop); ArrowUp/Down/Enter/Escape keyboard nav is
  owned by Autocomplete. aria-controls + aria-activedescendant are
  managed by Autocomplete — no manual wiring.
- Autocomplete.List + Autocomplete.Group + Autocomplete.GroupLabel +
  Autocomplete.Item replace the hand-rolled <div role=listbox> +
  <div group> + <Button role=option>. Autocomplete.Item fires onClick
  for both pointer click and Enter on the highlighted item, so commit()
  covers both paths.
- The CornerDownLeft cursor hint is now CSS-driven
  (.maka-palette-cursor visibility via [data-highlighted]) instead of
  the JS `!cmd.hint \&\& active` conditional, since the hand-rolled
  highlight state is gone.

commit() (commitPendingRef + committedCommandId + await run + finally
close), the fuzzy filter, useThreadSearch content-search, grouped
rendering, and all copy/states are unchanged.

The kbd-nav interaction stays activedescendant (input keeps focus,
active item reflected via aria-activedescendant) — same mode as before,
now owned by Autocomplete. Home/End now move the input cursor (Base UI
ComboboxInput default); jump-to-first/last command is not bound for now
(to be confirmed by manual testing per PR8 plan).

Contracts: command-palette-a11y-copy-contract #1 (listbox) rewritten to
lock the Autocomplete shape; #2 import regex drops Button (no longer
used); #3 CSS data-active -> data-highlighted; #6 commit-gate block
boundary is commit() (onInputKeyDown gone); #7 highlight reset is now
autoHighlight="always" (no hand-rolled state). renderer-utility-primitives
row assert repoints <Button role=option> -> <Autocomplete.Item>. CSS
.maka-palette-item[data-active] -> [data-highlighted] + .maka-palette-cursor.

Verification: typecheck clean, @maka/desktop 2076/2076, @maka/ui 43/43,
command-palette-open screenshot AE=7250 (fuzz 5%, RMSE 0.0017) vs main.

* fix(ui): Autocomplete inline open + item-press guard (#562 review)

P1 (blocker): both Autocomplete.Root used `inline` without `open`. Per
Base UI docs, `inline` requires `open` so the list is treated as visible:
"Specify open unconditionally in conjunction with this prop so the list is
considered visible: <Autocomplete.Root inline open>". Without `open`,
defaultOpen=false -> the input is not data-popup-open and keyboard nav /
activedescendant do not work. Add `open` to SearchModal + CommandPalette.

P2-a: object items (<Autocomplete.Item value={result/cmd}>) had no
itemToStringValue, and onValueChange did not filter item-press. Add
itemToStringValue (result.title / cmd.label) so item-press never writes
[object Object] into the query, and skip onValueChange when
details.reason === 'item-press'. In inline mode selectionMode='none' + no
Popup means shouldFillInput is currently false (popupRef.current null), so
this is defensive — but correct regardless of future Popup changes.

P2-b: contract tests now lock open + itemToStringValue + onValueChange
item-press-filter on Autocomplete.Root (catches P1/P2-a regressions), plus
the existing inline/mode/autoHighlight/List/Item shape.

Verified via CDP probe (command-palette-open fixture): input has
data-popup-open + aria-controls + aria-activedescendant; first item
data-highlighted (autoHighlight); ArrowDown 0->1->2, ArrowUp 2->1.
typecheck clean, @maka/ui 43/43, @maka/desktop 2076/2076.
command-palette-open AE=48000 (RMSE 0.011) vs main — larger than pre-fix
AE=7250 because open now correctly renders the first-item highlight bg +
input data-popup-open state (the bug state hid these). search-modal-open
AE=2655 (RMSE 0.0006, unchanged).

* test(ui): lock Home/End input-cursor decision (#562 P2-c)

P2-c decision: accept Base UI ComboboxInput's default — Home/End move the
input cursor, not the highlight. The old roving-focus jumpActiveResult
(SearchModal) / hand-rolled onInputKeyDown highlight jump (CommandPalette)
must not return. Lock via doesNotMatch on jumpActive( / onInputKeyDown.

* fix(ui): keepHighlight + empty-state inside Autocomplete.List (#562 review P2)

P2-1 (keepHighlight): both Roots had autoHighlight="always" but no
keepHighlight. keepHighlight=false (default) sets resetOnPointerLeave=true
(AriaCombobox.js:855), so pointer leave clears activeIndex, then the
autoHighlight="always" effect (line 678-682) re-highlights the first item
-> hover item[2] -> leave -> Enter ran the first item, not the hovered one.
Add keepHighlight so pointer leave preserves the hovered item.

P2-2 (empty-state): CommandPalette rendered a standalone <div> for empty
and <Autocomplete.List> only when non-empty, so the input lost its listbox
reference with no matches. Unify on Autocomplete.List always, with the Empty
primitive inside. Autocomplete.Empty is not used: filter={null} + mode="none"
keeps filteredItems non-empty (the palette's fuzzy filter is external), so
Autocomplete.Empty would never trigger.

Contracts lock keepHighlight on both Roots + empty-state-must-not-use-
standalone-div.

keepHighlight verified by source (resetOnPointerLeave = !keepHighlight) +
Base UI docs. CDP hover-leave probe could not reliably trigger floating-ui
useListNavigation's hover highlight (synthesized pointermove does not fire
its hover detection), so pointer-leave preservation is source-guaranteed,
not probe-verified. typecheck clean, 2076/2076, screenshots unchanged
(command-palette-open AE=48000, search-modal-open AE=2655 vs main — same as
prior commit, keepHighlight/empty-state don't affect non-empty visuals).
anaconda110 referenced this pull request in anaconda110/maka-agent Aug 3, 2026
…st (P0 #2)

AndroidManifest references @mipmap/ic_launcher and @mipmap/ic_launcher_round
but res/ had no mipmap-* directories, causing aapt2 to fail with
'resource mipmap/ic_launcher not found' during assembleDebug/Release.

Add adaptive icon for API 26+ (mipmap-anydpi-v26/ic_launcher{,_round}.xml)
referencing @drawable/ic_launcher_background and @drawable/ic_launcher_foreground,
plus simple PNG fallbacks in mipmap-mdpi (48x48) and mipmap-hdpi (72x72) for
API 24/25 (below adaptive icon support). Placeholder brand-blue solid with a
white ring glyph; to be replaced with final artwork.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant