synabun 2026.4.20-beta.8 → 2026.4.21
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +17 -0
- package/neural-interface/public/onboarding.html +59 -10
- package/neural-interface/public/shared/ocp/ocp-render.js +7 -3
- package/neural-interface/public/shared/ocp/ocp-styles.js +65 -46
- package/neural-interface/public/shared/ocp/ocp-tabs.js +37 -1
- package/neural-interface/public/shared/styles.css +17 -0
- package/neural-interface/public/shared/ui-file-explorer.js +83 -16
- package/neural-interface/public/shared/ui-settings.js +38 -0
- package/neural-interface/server.js +220 -45
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,23 @@
|
|
|
2
2
|
|
|
3
3
|
Raw internal changelogs are archived in [changelog/](changelog/).
|
|
4
4
|
|
|
5
|
+
## 2026-04-21
|
|
6
|
+
|
|
7
|
+
### Added — Workspace Trust Flow For Windows Git "Dubious Ownership" Repos
|
|
8
|
+
- **New `POST /api/terminal/trust-workspace` endpoint** — In `neural-interface/server.js`, added `detectDubiousOwnership(dir)` helper that probes `git rev-parse --is-inside-work-tree` and matches stderr against `/dubious ownership|safe\.directory/i` (CVE-2022-24765 protection; fires on Windows when the repo is owned by a different SID — external drives, cross-user installs, copied repos). New `normalizeGitPath(dir)` converts backslashes to forward slashes and strips trailing slashes to match the format `git config --add safe.directory` expects. The endpoint validates `path` (non-empty string, no control chars, not `*`/`/`/`\\`, `existsSync` + `isDirectory`), runs `spawnGit(['config', '--global', '--add', 'safe.directory', normalizeGitPath(abs)])`, re-probes to confirm, logs `[git] trusted workspace: <path>`, and returns `{ ok, path, stillDubious }`.
|
|
9
|
+
- **`POST /api/claude-code/integrations` flags dubious workspaces on registration** — The `target === 'project'` branch now calls `detectDubiousOwnership(normalized)` after saving the project to the hook-projects list; when the error fires it adds `dubiousOwnership: true, trustPath: normalizeGitPath(normalized)` to the response JSON. Mac/Linux and healthy Windows installs see no change to response shape.
|
|
10
|
+
- **`promptTrustWorkspace(trustPath)` modal in the file-explorer add-project flow** — In `neural-interface/public/shared/ui-file-explorer.js`, `addProjectFlow()` now reads the integrations response and `await`s a new `promptTrustWorkspace()` modal when `dubiousOwnership` is true. The modal reuses the existing `.fe-modal-overlay` system with a `.fe-trust-modal` amber variant, shows the exact path in a `.fe-trust-path` monospace chip, and offers **Skip** (close, no git change) or **Trust workspace** (POSTs to `/api/terminal/trust-workspace`, toasts the result, closes). The promise resolves either way so the add flow never blocks.
|
|
11
|
+
- **Inline trust row in the onboarding wizard** — In `neural-interface/public/onboarding.html`, the project-add step on `$addBtn` now persists `dubiousOwnership` + `trustPath` fields onto each `wizardState.projects[i]` entry, and `renderProjectList()` appends an amber inline `⚠ Git ownership mismatch — [Trust workspace]` row under the card when flagged. Click POSTs to `/api/terminal/trust-workspace`; on success the row swaps to `✓ Workspace trusted`. Separate from the file-explorer modal because onboarding already has its own card-based flow and shouldn't launch overlay dialogs.
|
|
12
|
+
- **Amber modal styling** — In `neural-interface/public/shared/styles.css`, added `.fe-trust-modal` overrides of `.fe-modal-title` (amber `#ffb74d`) and `.fe-modal-confirm` (warm `rgba(255,183,77,…)`) so the trust dialog is visually distinct from the destructive-red delete modal. New `.fe-trust-path` class styles the monospace path chip with dark background and `word-break: break-all`.
|
|
13
|
+
|
|
14
|
+
### Fixed — Git UI Silently Disabled On Windows Across Shim / Version / PATH Variants
|
|
15
|
+
- **`git` invocation rewritten to resolve absolute path, then fall back to shell only when needed** — In `neural-interface/server.js`, added `resolveGitPath()` that runs `where git` on Windows / `command -v git` on Unix via `execSync` at first use, caches the result, and prefers `.exe` entries over `.cmd`/`.bat` shims (Scoop, winget) so `spawnSync` can execute git directly without cmd.exe involvement. New `spawnGit(args, opts)` uses the resolved absolute path with no shell on all platforms; only falls back to `shell: true` with per-arg metacharacter quoting when path resolution fails OR the resolved target is a `.cmd`/`.bat` shim. Logs `[git] resolved: <path>` at first use so Windows users can confirm which binary Node is running.
|
|
16
|
+
- **Chained branch-name fallback for old git installs** — `getGitInfo()` and `GET /api/terminal/branches` used `git branch --show-current` which requires git 2.22+ — older Git for Windows builds silently exited non-zero and the branch label disappeared. Added chained fallback: `branch --show-current` → `rev-parse --abbrev-ref HEAD` → `rev-parse --short HEAD`. The `--abbrev-ref` form works on every git version and handles both named branches and detached HEAD (returns literal `"HEAD"`, which the parser treats as "no branch" and escalates to the short SHA).
|
|
17
|
+
- **Plain `git branch` parse replacing `--format=%(refname:short)`** — The branch-list endpoint's `--format=%(refname:short)` arg was prone to cmd.exe variable-expansion mangling on Windows even with double-quoting. Replaced with bare `git branch` and a `line.replace(/^[\s*]+/, '').trim()` parser that strips the current-branch marker and filters out `(HEAD detached …)` lines and rename arrows. Removes the cmd.exe `%(…)` hazard entirely.
|
|
18
|
+
- **All git calls now route through `spawnGit` with unified error logging** — The call sites (`getGitInfo`, `/api/terminal/branches`, `/api/terminal/checkout`, the project-add integration probe) had inconsistent handling — some used raw `execSync`, some `spawnSync` directly. Every git call now goes through `spawnGit(['…'])`, and every failure routes through a new `logGitFailure(where, err)` helper that suppresses the expected "not a git repository" case, deduplicates the "git not found on PATH" warning via `_gitMissingLogged`, and logs everything else with one-line stderr.
|
|
19
|
+
- **Startup `git --version` probe** — Added a `setImmediate(() => spawnGit(['--version']))` at module init that always logs `[git] git version X.Y.Z — OK` or `[git] startup probe FAILED: <reason>. File explorer git UI will be hidden.` — the first diagnostic Windows users should check, since every prior git failure was silently swallowed by the catch blocks.
|
|
20
|
+
- **`windowsHide: true` on every git spawn** — Prevents a momentary cmd.exe console window flash on Windows whenever the file explorer refreshes git status.
|
|
21
|
+
|
|
5
22
|
## 2026-04-20
|
|
6
23
|
|
|
7
24
|
### Added — Install Toggles For Claude / Gemini / Codex / OpenCode In Onboarding Step 5
|
|
@@ -2154,15 +2154,58 @@ document.getElementById('buildBtn').addEventListener('click', async () => {
|
|
|
2154
2154
|
wizardState.projects.forEach((p, i) => {
|
|
2155
2155
|
const card = document.createElement('div');
|
|
2156
2156
|
card.className = 'onboard-project-card';
|
|
2157
|
-
card.style.cssText = 'display:flex;
|
|
2157
|
+
card.style.cssText = 'display:flex;flex-direction:column;gap:8px;padding:10px 12px;background:rgba(109,213,140,0.06);border:1px solid rgba(109,213,140,0.2);border-radius:8px;font-size:12px';
|
|
2158
|
+
const safePath = p.path.replace(/\\/g, '/');
|
|
2158
2159
|
card.innerHTML = `
|
|
2159
|
-
<
|
|
2160
|
-
|
|
2161
|
-
<div style="
|
|
2162
|
-
|
|
2160
|
+
<div style="display:flex;align-items:center;gap:10px">
|
|
2161
|
+
<svg viewBox="0 0 24 24" style="width:18px;height:18px;fill:none;stroke:rgba(109,213,140,0.8);stroke-width:2;flex-shrink:0"><path d="M22 11.08V12a10 10 0 1 1-5.93-9.14"/><polyline points="22 4 12 14.01 9 11.01"/></svg>
|
|
2162
|
+
<div style="flex:1;min-width:0">
|
|
2163
|
+
<div style="color:var(--t-bright);font-weight:500">${p.label}</div>
|
|
2164
|
+
<div style="color:var(--t-muted);font-size:11px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap" title="${safePath}">${safePath}</div>
|
|
2165
|
+
</div>
|
|
2166
|
+
<button class="onboard-project-remove" style="background:none;border:none;color:var(--t-muted);cursor:pointer;font-size:16px;padding:2px 6px;line-height:1;opacity:0.5" title="Remove">×</button>
|
|
2163
2167
|
</div>
|
|
2164
|
-
<button class="onboard-project-remove" style="background:none;border:none;color:var(--t-muted);cursor:pointer;font-size:16px;padding:2px 6px;line-height:1;opacity:0.5" title="Remove">×</button>
|
|
2165
2168
|
`;
|
|
2169
|
+
if (p.dubiousOwnership && !p.trusted) {
|
|
2170
|
+
const trustRow = document.createElement('div');
|
|
2171
|
+
trustRow.className = 'onboard-trust-row';
|
|
2172
|
+
trustRow.style.cssText = 'display:flex;align-items:center;gap:8px;padding:6px 8px;background:rgba(255,183,77,0.08);border:1px solid rgba(255,183,77,0.25);border-radius:6px;font-size:11px;color:rgba(255,204,128,0.9)';
|
|
2173
|
+
trustRow.innerHTML = `
|
|
2174
|
+
<span>⚠ Git ownership mismatch — workspace needs to be trusted</span>
|
|
2175
|
+
<button class="onboard-trust-btn" style="margin-left:auto;background:rgba(255,183,77,0.2);border:1px solid rgba(255,183,77,0.35);color:#ffe0b2;padding:4px 10px;border-radius:5px;font-size:11px;cursor:pointer">Trust workspace</button>
|
|
2176
|
+
`;
|
|
2177
|
+
const $btn = trustRow.querySelector('.onboard-trust-btn');
|
|
2178
|
+
$btn.addEventListener('click', async () => {
|
|
2179
|
+
$btn.disabled = true;
|
|
2180
|
+
$btn.textContent = 'Trusting...';
|
|
2181
|
+
try {
|
|
2182
|
+
const r = await fetch('/api/terminal/trust-workspace', {
|
|
2183
|
+
method: 'POST',
|
|
2184
|
+
headers: { 'Content-Type': 'application/json' },
|
|
2185
|
+
body: JSON.stringify({ path: p.trustPath || safePath }),
|
|
2186
|
+
});
|
|
2187
|
+
const d = await r.json();
|
|
2188
|
+
if (d.ok) {
|
|
2189
|
+
p.trusted = true;
|
|
2190
|
+
renderProjectList();
|
|
2191
|
+
} else {
|
|
2192
|
+
$btn.disabled = false;
|
|
2193
|
+
$btn.textContent = 'Trust workspace';
|
|
2194
|
+
$error.textContent = d.error || 'Trust failed';
|
|
2195
|
+
}
|
|
2196
|
+
} catch {
|
|
2197
|
+
$btn.disabled = false;
|
|
2198
|
+
$btn.textContent = 'Trust workspace';
|
|
2199
|
+
$error.textContent = 'Trust failed';
|
|
2200
|
+
}
|
|
2201
|
+
});
|
|
2202
|
+
card.appendChild(trustRow);
|
|
2203
|
+
} else if (p.trusted) {
|
|
2204
|
+
const okRow = document.createElement('div');
|
|
2205
|
+
okRow.style.cssText = 'font-size:11px;color:rgba(109,213,140,0.8)';
|
|
2206
|
+
okRow.textContent = '✓ Workspace trusted';
|
|
2207
|
+
card.appendChild(okRow);
|
|
2208
|
+
}
|
|
2166
2209
|
card.querySelector('.onboard-project-remove').addEventListener('click', () => {
|
|
2167
2210
|
wizardState.projects.splice(i, 1);
|
|
2168
2211
|
renderProjectList();
|
|
@@ -2212,15 +2255,16 @@ document.getElementById('buildBtn').addEventListener('click', async () => {
|
|
|
2212
2255
|
|
|
2213
2256
|
// Register via API
|
|
2214
2257
|
$addBtn.innerHTML = '<div class="spinner" style="width:14px;height:14px"></div> Registering...';
|
|
2258
|
+
let integData = null;
|
|
2215
2259
|
try {
|
|
2216
2260
|
const res = await fetch('/api/claude-code/integrations', {
|
|
2217
2261
|
method: 'POST',
|
|
2218
2262
|
headers: { 'Content-Type': 'application/json' },
|
|
2219
2263
|
body: JSON.stringify({ target: 'project', projectPath: folderPath, label }),
|
|
2220
2264
|
});
|
|
2221
|
-
|
|
2222
|
-
if (!
|
|
2223
|
-
$error.textContent =
|
|
2265
|
+
integData = await res.json();
|
|
2266
|
+
if (!integData.ok) {
|
|
2267
|
+
$error.textContent = integData.error || 'Failed to register project.';
|
|
2224
2268
|
$addBtn.disabled = false;
|
|
2225
2269
|
$addBtn.innerHTML = origHTML;
|
|
2226
2270
|
return;
|
|
@@ -2232,7 +2276,12 @@ document.getElementById('buildBtn').addEventListener('click', async () => {
|
|
|
2232
2276
|
return;
|
|
2233
2277
|
}
|
|
2234
2278
|
|
|
2235
|
-
|
|
2279
|
+
const projectEntry = { path: folderPath, label };
|
|
2280
|
+
if (integData && integData.dubiousOwnership && integData.trustPath) {
|
|
2281
|
+
projectEntry.dubiousOwnership = true;
|
|
2282
|
+
projectEntry.trustPath = integData.trustPath;
|
|
2283
|
+
}
|
|
2284
|
+
wizardState.projects.push(projectEntry);
|
|
2236
2285
|
$addBtn.disabled = false;
|
|
2237
2286
|
$addBtn.innerHTML = origHTML;
|
|
2238
2287
|
renderProjectList();
|
|
@@ -1620,21 +1620,25 @@ export function renderToolActivityDock(tab, panelRoot, callbacks = {}) {
|
|
|
1620
1620
|
Agents
|
|
1621
1621
|
</span>
|
|
1622
1622
|
<span class="ocp-activity-header-count">${runningCount ? `${runningCount}/` : ''}${totalCount}</span>
|
|
1623
|
+
<button class="ocp-activity-header-hide" type="button" title="Collapse (Ctrl+Shift+A)" aria-label="Close agents drawer">×</button>
|
|
1623
1624
|
</div>
|
|
1624
1625
|
<div class="ocp-activity-list">
|
|
1625
1626
|
${entries.map(e => activityRowHtml(e, { expanded: !!expandedMap[e.key] })).join('')}
|
|
1626
1627
|
</div>
|
|
1627
1628
|
</div>
|
|
1628
1629
|
<button class="${tagClass}" title="${escAttr(tagTitle)}" type="button">
|
|
1629
|
-
<span class="ocp-activity-tag-dot"></span>
|
|
1630
|
-
<span class="ocp-activity-tag-count">${tagCount}</span>
|
|
1631
|
-
<span class="ocp-activity-tag-label">${runningCount > 0 ? 'Running' : 'Agents'}</span>
|
|
1630
|
+
${runningCount > 0 ? '<span class="ocp-activity-tag-dot"></span>' : ''}
|
|
1631
|
+
<span class="ocp-activity-tag-count">${tagCount} ${runningCount > 0 ? 'RUN' : 'AGT'}</span>
|
|
1632
1632
|
</button>
|
|
1633
1633
|
`;
|
|
1634
1634
|
|
|
1635
1635
|
dock.querySelector('.ocp-activity-tag')?.addEventListener('click', () => {
|
|
1636
1636
|
if (typeof onToggle === 'function') onToggle(!isOpen);
|
|
1637
1637
|
});
|
|
1638
|
+
dock.querySelector('.ocp-activity-header-hide')?.addEventListener('click', (ev) => {
|
|
1639
|
+
ev.stopPropagation();
|
|
1640
|
+
if (typeof onToggle === 'function') onToggle(false);
|
|
1641
|
+
});
|
|
1638
1642
|
|
|
1639
1643
|
dock.querySelectorAll('.ocp-activity-row').forEach((row) => {
|
|
1640
1644
|
const agentKey = row.dataset.agentKey;
|
|
@@ -2024,72 +2024,64 @@ export function injectStyles() {
|
|
|
2024
2024
|
border-color: rgba(232,224,220,0.35);
|
|
2025
2025
|
}
|
|
2026
2026
|
|
|
2027
|
-
/* ── Tool activity dock (
|
|
2027
|
+
/* ── Tool activity dock (sibling surface, matches Claude todo dock aesthetic) ── */
|
|
2028
2028
|
.ocp-activity-dock {
|
|
2029
2029
|
position: absolute;
|
|
2030
|
-
bottom:
|
|
2030
|
+
bottom: 0;
|
|
2031
2031
|
right: calc(100% + 6px);
|
|
2032
2032
|
display: flex;
|
|
2033
|
+
flex-direction: row-reverse;
|
|
2033
2034
|
align-items: flex-end;
|
|
2034
|
-
z-index:
|
|
2035
|
+
z-index: 20;
|
|
2035
2036
|
pointer-events: none;
|
|
2037
|
+
max-height: 100%;
|
|
2036
2038
|
}
|
|
2037
2039
|
.ocp-activity-tag {
|
|
2038
2040
|
pointer-events: auto;
|
|
2039
2041
|
display: flex;
|
|
2040
2042
|
flex-direction: column;
|
|
2041
2043
|
align-items: center;
|
|
2042
|
-
|
|
2043
|
-
|
|
2044
|
-
|
|
2045
|
-
|
|
2046
|
-
|
|
2047
|
-
border
|
|
2048
|
-
|
|
2049
|
-
backdrop-filter: blur(14px);
|
|
2050
|
-
-webkit-backdrop-filter: blur(14px);
|
|
2051
|
-
box-shadow: inset 1px 0 0 rgba(255, 255, 255, 0.06), -2px 0 10px rgba(0, 0, 0, 0.18);
|
|
2044
|
+
gap: 5px;
|
|
2045
|
+
padding: 9px 6px;
|
|
2046
|
+
background: rgba(18, 18, 20, 0.92);
|
|
2047
|
+
backdrop-filter: blur(60px) saturate(1.5);
|
|
2048
|
+
-webkit-backdrop-filter: blur(60px) saturate(1.5);
|
|
2049
|
+
border: 0.5px solid rgba(255, 255, 255, 0.06);
|
|
2050
|
+
border-radius: 10px;
|
|
2052
2051
|
color: rgba(232, 224, 220, 0.78);
|
|
2053
|
-
font-size: 11px;
|
|
2054
|
-
font-weight: 600;
|
|
2055
|
-
font-variant-numeric: tabular-nums;
|
|
2056
2052
|
cursor: pointer;
|
|
2057
|
-
|
|
2053
|
+
font-family: 'JetBrains Mono', monospace;
|
|
2054
|
+
font-variant-numeric: tabular-nums;
|
|
2055
|
+
box-shadow:
|
|
2056
|
+
0 0 0 0.5px rgba(0, 0, 0, 0.3),
|
|
2057
|
+
0 1px 2px rgba(0, 0, 0, 0.15),
|
|
2058
|
+
0 4px 8px rgba(0, 0, 0, 0.12),
|
|
2059
|
+
0 12px 24px rgba(0, 0, 0, 0.14);
|
|
2060
|
+
transition: background 0.15s, border-color 0.15s, color 0.15s;
|
|
2058
2061
|
}
|
|
2059
2062
|
.ocp-activity-tag:hover {
|
|
2060
|
-
background:
|
|
2061
|
-
color: rgba(
|
|
2062
|
-
|
|
2063
|
-
.ocp-activity-tag.running {
|
|
2064
|
-
color: #b5edc1;
|
|
2063
|
+
background: rgba(28, 26, 22, 0.94);
|
|
2064
|
+
border-color: rgba(255, 255, 255, 0.12);
|
|
2065
|
+
color: rgba(232, 224, 220, 0.98);
|
|
2065
2066
|
}
|
|
2067
|
+
.ocp-activity-tag.running { color: #b5edc1; }
|
|
2066
2068
|
.ocp-activity-tag-dot {
|
|
2067
|
-
width:
|
|
2068
|
-
height:
|
|
2069
|
+
width: 5px;
|
|
2070
|
+
height: 5px;
|
|
2069
2071
|
border-radius: 50%;
|
|
2070
2072
|
background: rgba(232, 224, 220, 0.45);
|
|
2071
2073
|
}
|
|
2072
2074
|
.ocp-activity-tag.running .ocp-activity-tag-dot {
|
|
2073
2075
|
background: #6fd18a;
|
|
2074
|
-
box-shadow: 0 0
|
|
2076
|
+
box-shadow: 0 0 6px rgba(111, 209, 138, 0.7);
|
|
2075
2077
|
animation: ocp-activity-pulse 1.4s ease-in-out infinite;
|
|
2076
2078
|
}
|
|
2077
2079
|
.ocp-activity-tag-count {
|
|
2078
|
-
font-size:
|
|
2079
|
-
font-weight: 700;
|
|
2080
|
-
}
|
|
2081
|
-
.ocp-activity-tag-label {
|
|
2082
|
-
font-size: 9px;
|
|
2083
|
-
font-weight: 600;
|
|
2084
|
-
letter-spacing: 0.06em;
|
|
2085
|
-
text-transform: uppercase;
|
|
2080
|
+
font-size: 9.5px;
|
|
2086
2081
|
writing-mode: vertical-rl;
|
|
2087
2082
|
transform: rotate(180deg);
|
|
2088
|
-
|
|
2089
|
-
|
|
2090
|
-
}
|
|
2091
|
-
.ocp-activity-tag.running .ocp-activity-tag-label {
|
|
2092
|
-
color: rgba(181, 237, 193, 0.8);
|
|
2083
|
+
letter-spacing: 0.04em;
|
|
2084
|
+
opacity: 0.85;
|
|
2093
2085
|
}
|
|
2094
2086
|
|
|
2095
2087
|
.ocp-activity-drawer {
|
|
@@ -2098,19 +2090,45 @@ export function injectStyles() {
|
|
|
2098
2090
|
max-width: 0;
|
|
2099
2091
|
overflow: hidden;
|
|
2100
2092
|
opacity: 0;
|
|
2101
|
-
background:
|
|
2102
|
-
backdrop-filter: blur(
|
|
2103
|
-
-webkit-backdrop-filter: blur(
|
|
2104
|
-
border
|
|
2105
|
-
|
|
2106
|
-
|
|
2107
|
-
|
|
2093
|
+
background: rgba(18, 18, 20, 0.92);
|
|
2094
|
+
backdrop-filter: blur(60px) saturate(1.5);
|
|
2095
|
+
-webkit-backdrop-filter: blur(60px) saturate(1.5);
|
|
2096
|
+
border: 0.5px solid rgba(255, 255, 255, 0.06);
|
|
2097
|
+
border-radius: 14px;
|
|
2098
|
+
box-shadow:
|
|
2099
|
+
0 0 0 0.5px rgba(0, 0, 0, 0.3),
|
|
2100
|
+
0 1px 2px rgba(0, 0, 0, 0.15),
|
|
2101
|
+
0 4px 8px rgba(0, 0, 0, 0.12),
|
|
2102
|
+
0 12px 24px rgba(0, 0, 0, 0.14),
|
|
2103
|
+
0 32px 64px rgba(0, 0, 0, 0.18);
|
|
2104
|
+
display: flex;
|
|
2105
|
+
flex-direction: column;
|
|
2106
|
+
margin-right: 6px;
|
|
2107
|
+
max-height: 100%;
|
|
2108
|
+
transition: width 0.22s cubic-bezier(0.2, 0, 0.2, 1), max-width 0.22s cubic-bezier(0.2, 0, 0.2, 1), opacity 0.18s ease;
|
|
2108
2109
|
}
|
|
2109
2110
|
.ocp-activity-dock.open .ocp-activity-drawer {
|
|
2110
2111
|
width: 320px;
|
|
2111
2112
|
max-width: 320px;
|
|
2112
2113
|
opacity: 1;
|
|
2113
2114
|
}
|
|
2115
|
+
.ocp-activity-dock.open .ocp-activity-tag { display: none; }
|
|
2116
|
+
.ocp-activity-header-hide {
|
|
2117
|
+
margin-left: 6px;
|
|
2118
|
+
background: none;
|
|
2119
|
+
border: none;
|
|
2120
|
+
color: rgba(232, 224, 220, 0.55);
|
|
2121
|
+
cursor: pointer;
|
|
2122
|
+
font-size: 15px;
|
|
2123
|
+
line-height: 1;
|
|
2124
|
+
padding: 2px 5px;
|
|
2125
|
+
border-radius: 4px;
|
|
2126
|
+
transition: background 0.12s, color 0.12s;
|
|
2127
|
+
}
|
|
2128
|
+
.ocp-activity-header-hide:hover {
|
|
2129
|
+
color: rgba(232, 224, 220, 1);
|
|
2130
|
+
background: rgba(255, 255, 255, 0.06);
|
|
2131
|
+
}
|
|
2114
2132
|
.ocp-activity-header {
|
|
2115
2133
|
padding: 12px 14px 8px;
|
|
2116
2134
|
font-size: 10px;
|
|
@@ -2119,14 +2137,15 @@ export function injectStyles() {
|
|
|
2119
2137
|
text-transform: uppercase;
|
|
2120
2138
|
color: rgba(232, 224, 220, 0.55);
|
|
2121
2139
|
display: flex;
|
|
2122
|
-
justify-content: space-between;
|
|
2123
2140
|
align-items: center;
|
|
2141
|
+
gap: 8px;
|
|
2124
2142
|
border-bottom: 1px solid rgba(255, 255, 255, 0.05);
|
|
2125
2143
|
}
|
|
2126
2144
|
.ocp-activity-header-title {
|
|
2127
2145
|
display: inline-flex;
|
|
2128
2146
|
align-items: center;
|
|
2129
2147
|
gap: 7px;
|
|
2148
|
+
margin-right: auto;
|
|
2130
2149
|
}
|
|
2131
2150
|
.ocp-activity-header-dot {
|
|
2132
2151
|
width: 6px;
|
|
@@ -99,6 +99,7 @@ let _primaryAgents = new Set();
|
|
|
99
99
|
let _sessions = [];
|
|
100
100
|
const _userMsgIds = new Set(); // track user messageIDs to filter echoed SSE events
|
|
101
101
|
const _partTypes = new Map(); // track partID → type (reasoning/text) from part.updated events
|
|
102
|
+
const _renderedAssistantMsgIds = new Set(); // prevents double-render when message.updated re-fires after finalize (Windows timing)
|
|
102
103
|
let _activeSendRequestId = null; // WS request id of the in-flight message:send (for abort cancellation)
|
|
103
104
|
let _renderSeq = 0; // guard against stale async renders overwriting fresh ones
|
|
104
105
|
let _activeQuestionToolId = null; // toolId of the currently active (unanswered) question card
|
|
@@ -760,6 +761,10 @@ export async function sendMessage(content, options = {}) {
|
|
|
760
761
|
removeThinking(container);
|
|
761
762
|
finalizeStreamingMessage(container);
|
|
762
763
|
}
|
|
764
|
+
// Mark this assistant message as rendered so late terminal SSE
|
|
765
|
+
// message.updated events (which arrive after POST resolves on some
|
|
766
|
+
// Windows/model combos) don't re-stream the full payload into a new bubble.
|
|
767
|
+
if (info.id) _renderedAssistantMsgIds.add(info.id);
|
|
763
768
|
tab.running = false;
|
|
764
769
|
tab.turnStartedAt = 0;
|
|
765
770
|
if (info.tokens || info.usage) {
|
|
@@ -1163,6 +1168,15 @@ export function handleSSEEvent(eventType, event) {
|
|
|
1163
1168
|
if (mid) _userMsgIds.add(mid);
|
|
1164
1169
|
break;
|
|
1165
1170
|
}
|
|
1171
|
+
// Guard: once we've rendered this assistant message's final payload, ignore
|
|
1172
|
+
// re-emitted message.updated events (OpenCode on Windows sometimes fires a
|
|
1173
|
+
// late duplicate after finalize, which would otherwise re-stream the whole
|
|
1174
|
+
// response into a brand-new assistant bubble).
|
|
1175
|
+
const assistantMid = info.id || event.messageID;
|
|
1176
|
+
if (assistantMid && _renderedAssistantMsgIds.has(assistantMid)) {
|
|
1177
|
+
update();
|
|
1178
|
+
break;
|
|
1179
|
+
}
|
|
1166
1180
|
// If no streaming element was built by prior deltas, render the
|
|
1167
1181
|
// assistant's content directly from the event (non-streaming path)
|
|
1168
1182
|
const hasStreaming = container.querySelector('.ocp-msg-assistant.streaming');
|
|
@@ -1176,6 +1190,13 @@ export function handleSSEEvent(eventType, event) {
|
|
|
1176
1190
|
removeThinking(container);
|
|
1177
1191
|
finalizeStreamingMessage(container);
|
|
1178
1192
|
_partTypes.clear();
|
|
1193
|
+
// Only mark the ID as rendered at the terminal signal (tokens/usage
|
|
1194
|
+
// arrived or status is explicitly terminal). Mid-turn message.updated
|
|
1195
|
+
// events may fire before streaming starts and shouldn't lock out later
|
|
1196
|
+
// part.delta / part.updated events.
|
|
1197
|
+
if (assistantMid && (explicitTerminal || info.tokens || info.usage)) {
|
|
1198
|
+
_renderedAssistantMsgIds.add(assistantMid);
|
|
1199
|
+
}
|
|
1179
1200
|
if (info.tokens || info.usage) {
|
|
1180
1201
|
const raw = info.tokens || info.usage || {};
|
|
1181
1202
|
tab.threadTokenUsage = normalizeThreadTokenUsage(raw);
|
|
@@ -1202,6 +1223,8 @@ export function handleSSEEvent(eventType, event) {
|
|
|
1202
1223
|
// Streaming text delta — the primary streaming event from OpenCode
|
|
1203
1224
|
case 'message.part.delta': {
|
|
1204
1225
|
if (_userMsgIds.has(event.messageID)) break; // skip user message echoes
|
|
1226
|
+
// Skip deltas for assistant messages already finalized (late echoes).
|
|
1227
|
+
if (event.messageID && _renderedAssistantMsgIds.has(event.messageID)) break;
|
|
1205
1228
|
if (event.delta) {
|
|
1206
1229
|
const partType = String(event.part?.type || event.partType || _partTypes.get(event.partID) || '').trim().toLowerCase();
|
|
1207
1230
|
const isReasoning = partType === 'reasoning' || partType === 'thinking' || partType === 'thought';
|
|
@@ -1236,7 +1259,11 @@ export function handleSSEEvent(eventType, event) {
|
|
|
1236
1259
|
update();
|
|
1237
1260
|
} else if (updatedType === 'text') {
|
|
1238
1261
|
const hasStreaming = container.querySelector('.ocp-msg-assistant.streaming');
|
|
1239
|
-
if
|
|
1262
|
+
// Skip the fallback render if this assistant message has already been
|
|
1263
|
+
// finalized by POST response or a prior message.updated — otherwise we
|
|
1264
|
+
// create a duplicate bubble when late text part.updated events arrive.
|
|
1265
|
+
const partMsgId = part.messageID || part.messageId;
|
|
1266
|
+
if (!hasStreaming && !(partMsgId && _renderedAssistantMsgIds.has(partMsgId))) {
|
|
1240
1267
|
renderAssistantPayload(container, part, { textMode: 'stream' });
|
|
1241
1268
|
}
|
|
1242
1269
|
setTurnStatus(tab, 'Writing response…', tabStatusDetail(tab));
|
|
@@ -1287,6 +1314,14 @@ export function handleSSEEvent(eventType, event) {
|
|
|
1287
1314
|
}
|
|
1288
1315
|
case 'message.completed': {
|
|
1289
1316
|
removeThinking(container);
|
|
1317
|
+
const completedMid = event.messageID || event.id;
|
|
1318
|
+
if (completedMid && _renderedAssistantMsgIds.has(completedMid)) {
|
|
1319
|
+
tab.running = false;
|
|
1320
|
+
tab.turnStartedAt = 0;
|
|
1321
|
+
setTurnStatus(tab);
|
|
1322
|
+
update();
|
|
1323
|
+
break;
|
|
1324
|
+
}
|
|
1290
1325
|
// Fallback: render content if no streaming element was built
|
|
1291
1326
|
const hasStreamingLegacy = container.querySelector('.ocp-msg-assistant.streaming');
|
|
1292
1327
|
renderAssistantPayload(container, event.content ?? event.parts ?? event.text ?? '', {
|
|
@@ -1294,6 +1329,7 @@ export function handleSSEEvent(eventType, event) {
|
|
|
1294
1329
|
});
|
|
1295
1330
|
finalizeStreamingMessage(container);
|
|
1296
1331
|
_partTypes.clear();
|
|
1332
|
+
if (completedMid) _renderedAssistantMsgIds.add(completedMid);
|
|
1297
1333
|
tab.running = false;
|
|
1298
1334
|
tab.turnStartedAt = 0;
|
|
1299
1335
|
setTurnStatus(tab);
|
|
@@ -13868,6 +13868,23 @@
|
|
|
13868
13868
|
}
|
|
13869
13869
|
.fe-modal-confirm:hover { background: rgba(229,115,115,0.35); color: #ffab91; }
|
|
13870
13870
|
|
|
13871
|
+
/* ── Trust-workspace modal (amber accent, different from destructive red) ── */
|
|
13872
|
+
.fe-trust-modal .fe-modal-title { color: #ffb74d; }
|
|
13873
|
+
.fe-trust-modal .fe-modal-confirm { background: rgba(255,183,77,0.18); color: #ffcc80; }
|
|
13874
|
+
.fe-trust-modal .fe-modal-confirm:hover { background: rgba(255,183,77,0.3); color: #ffe0b2; }
|
|
13875
|
+
.fe-trust-modal .fe-modal-confirm:disabled { opacity: 0.5; cursor: default; }
|
|
13876
|
+
.fe-trust-path {
|
|
13877
|
+
margin-top: 10px;
|
|
13878
|
+
padding: 8px 10px;
|
|
13879
|
+
background: rgba(0,0,0,0.3);
|
|
13880
|
+
border: 1px solid rgba(255,255,255,0.08);
|
|
13881
|
+
border-radius: 6px;
|
|
13882
|
+
font-family: var(--font-mono, ui-monospace, monospace);
|
|
13883
|
+
font-size: 11px;
|
|
13884
|
+
color: rgba(255,255,255,0.6);
|
|
13885
|
+
word-break: break-all;
|
|
13886
|
+
}
|
|
13887
|
+
|
|
13871
13888
|
/* ── Color palette popup ── */
|
|
13872
13889
|
.fe-color-palette {
|
|
13873
13890
|
position: absolute;
|
|
@@ -648,7 +648,13 @@ async function addProjectFlow() {
|
|
|
648
648
|
|
|
649
649
|
// Register as a project
|
|
650
650
|
const label = data.path.split(/[\\/]/).pop() || data.path;
|
|
651
|
-
await fetch('/api/claude-code/integrations', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ target: 'project', projectPath: data.path, label }) });
|
|
651
|
+
const integRes = await fetch('/api/claude-code/integrations', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ target: 'project', projectPath: data.path, label }) });
|
|
652
|
+
const integData = await integRes.json().catch(() => ({}));
|
|
653
|
+
|
|
654
|
+
// Windows git CVE-2022-24765 safe.directory prompt — only fires when server detected the specific error.
|
|
655
|
+
if (integData && integData.dubiousOwnership && integData.trustPath) {
|
|
656
|
+
await promptTrustWorkspace(integData.trustPath);
|
|
657
|
+
}
|
|
652
658
|
|
|
653
659
|
// Refresh projects list and select the new one
|
|
654
660
|
await fetchProjects();
|
|
@@ -665,6 +671,67 @@ async function addProjectFlow() {
|
|
|
665
671
|
}
|
|
666
672
|
}
|
|
667
673
|
|
|
674
|
+
function promptTrustWorkspace(trustPath) {
|
|
675
|
+
return new Promise((resolve) => {
|
|
676
|
+
const existing = document.getElementById('fe-trust-modal');
|
|
677
|
+
if (existing) existing.remove();
|
|
678
|
+
|
|
679
|
+
const overlay = document.createElement('div');
|
|
680
|
+
overlay.id = 'fe-trust-modal';
|
|
681
|
+
overlay.className = 'fe-modal-overlay';
|
|
682
|
+
|
|
683
|
+
const modal = document.createElement('div');
|
|
684
|
+
modal.className = 'fe-modal fe-trust-modal';
|
|
685
|
+
modal.innerHTML = `
|
|
686
|
+
<div class="fe-modal-title">Git ownership mismatch</div>
|
|
687
|
+
<div class="fe-modal-body">
|
|
688
|
+
Git is refusing to read this workspace because it's owned by a different user account. Trusting it adds the directory to your global <code>safe.directory</code> config so Synabun's git UI can show the branch and changes.
|
|
689
|
+
<div class="fe-trust-path" id="fe-trust-path"></div>
|
|
690
|
+
</div>
|
|
691
|
+
<div class="fe-modal-actions">
|
|
692
|
+
<button class="fe-modal-btn fe-modal-cancel" id="fe-trust-skip">Skip</button>
|
|
693
|
+
<button class="fe-modal-btn fe-modal-confirm" id="fe-trust-confirm">Trust workspace</button>
|
|
694
|
+
</div>
|
|
695
|
+
`;
|
|
696
|
+
overlay.appendChild(modal);
|
|
697
|
+
document.body.appendChild(overlay);
|
|
698
|
+
modal.querySelector('#fe-trust-path').textContent = trustPath;
|
|
699
|
+
|
|
700
|
+
requestAnimationFrame(() => overlay.classList.add('fe-modal-overlay--in'));
|
|
701
|
+
|
|
702
|
+
const done = () => {
|
|
703
|
+
overlay.classList.remove('fe-modal-overlay--in');
|
|
704
|
+
setTimeout(() => overlay.remove(), 150);
|
|
705
|
+
document.removeEventListener('keydown', onKey);
|
|
706
|
+
resolve();
|
|
707
|
+
};
|
|
708
|
+
const onKey = (ev) => { if (ev.key === 'Escape') done(); };
|
|
709
|
+
document.addEventListener('keydown', onKey);
|
|
710
|
+
|
|
711
|
+
modal.querySelector('#fe-trust-skip').addEventListener('click', done);
|
|
712
|
+
overlay.addEventListener('click', (ev) => { if (ev.target === overlay) done(); });
|
|
713
|
+
|
|
714
|
+
modal.querySelector('#fe-trust-confirm').addEventListener('click', async () => {
|
|
715
|
+
const btn = modal.querySelector('#fe-trust-confirm');
|
|
716
|
+
btn.disabled = true;
|
|
717
|
+
btn.textContent = 'Trusting...';
|
|
718
|
+
try {
|
|
719
|
+
const res = await fetch('/api/terminal/trust-workspace', {
|
|
720
|
+
method: 'POST',
|
|
721
|
+
headers: { 'Content-Type': 'application/json' },
|
|
722
|
+
body: JSON.stringify({ path: trustPath }),
|
|
723
|
+
});
|
|
724
|
+
const data = await res.json();
|
|
725
|
+
if (data.ok) showToast('Workspace trusted');
|
|
726
|
+
else showToast(data.error || 'Trust failed');
|
|
727
|
+
} catch {
|
|
728
|
+
showToast('Trust failed');
|
|
729
|
+
}
|
|
730
|
+
done();
|
|
731
|
+
});
|
|
732
|
+
});
|
|
733
|
+
}
|
|
734
|
+
|
|
668
735
|
function initProjectSelector() {
|
|
669
736
|
const btn = $('fe-project-btn');
|
|
670
737
|
const wrap = $('fe-project-selector');
|
|
@@ -1704,21 +1771,21 @@ function focusEditorLocation(lineNum, columnNum = 1) {
|
|
|
1704
1771
|
syncGutterScroll();
|
|
1705
1772
|
}
|
|
1706
1773
|
|
|
1707
|
-
async function openFileEditor(filePath, opts = {}) {
|
|
1708
|
-
try {
|
|
1709
|
-
const normalizedPath = String(filePath || '').replace(/\\/g, '/');
|
|
1710
|
-
const res = await fetch(`/api/file-content?path=${encodeURIComponent(filePath)}`);
|
|
1711
|
-
const data = await res.json();
|
|
1712
|
-
if (!res.ok) {
|
|
1713
|
-
const isOpencodeConfig = normalizedPath.endsWith('/.config/opencode/config.json');
|
|
1714
|
-
const isAllowlistMiss = res.status === 403 && data?.error === 'Path outside registered project roots';
|
|
1715
|
-
if (isOpencodeConfig && isAllowlistMiss) {
|
|
1716
|
-
showToast('Restart Synabun to apply OpenCode config editor support');
|
|
1717
|
-
return;
|
|
1718
|
-
}
|
|
1719
|
-
showToast(data.error || 'Cannot open file');
|
|
1720
|
-
return;
|
|
1721
|
-
}
|
|
1774
|
+
async function openFileEditor(filePath, opts = {}) {
|
|
1775
|
+
try {
|
|
1776
|
+
const normalizedPath = String(filePath || '').replace(/\\/g, '/');
|
|
1777
|
+
const res = await fetch(`/api/file-content?path=${encodeURIComponent(filePath)}`);
|
|
1778
|
+
const data = await res.json();
|
|
1779
|
+
if (!res.ok) {
|
|
1780
|
+
const isOpencodeConfig = normalizedPath.endsWith('/.config/opencode/config.json');
|
|
1781
|
+
const isAllowlistMiss = res.status === 403 && data?.error === 'Path outside registered project roots';
|
|
1782
|
+
if (isOpencodeConfig && isAllowlistMiss) {
|
|
1783
|
+
showToast('Restart Synabun to apply OpenCode config editor support');
|
|
1784
|
+
return;
|
|
1785
|
+
}
|
|
1786
|
+
showToast(data.error || 'Cannot open file');
|
|
1787
|
+
return;
|
|
1788
|
+
}
|
|
1722
1789
|
|
|
1723
1790
|
_editorOpen = true;
|
|
1724
1791
|
_editorFilePath = data.path;
|
|
@@ -2269,6 +2269,7 @@ function buildProjectsTab(ccIntegrations) {
|
|
|
2269
2269
|
</div>
|
|
2270
2270
|
<div class="cc-panel-actions">
|
|
2271
2271
|
<button class="cc-explore-btn" data-cc-explore="${i}" style="background:var(--accent-blue-bg);border:1px solid var(--accent-blue-border);color:var(--accent-blue);padding:5px 12px;border-radius:4px;cursor:pointer;font-size:12px">Learn it</button>
|
|
2272
|
+
<button class="cc-trust-btn" data-cc-trust="${i}" title="Add this workspace to git safe.directory (fixes 'dubious ownership' errors)" style="background:rgba(255,183,77,0.14);border:1px solid rgba(255,183,77,0.3);color:#ffcc80;padding:5px 12px;border-radius:4px;cursor:pointer;font-size:12px">Trust workspace</button>
|
|
2272
2273
|
<button class="cc-enable-btn${p.installed ? ' on' : ''}" data-cc-project-toggle="${i}">${p.installed ? 'Enabled' : 'Enable'}</button>
|
|
2273
2274
|
<button class="cc-remove-panel-btn" data-cc-remove="${i}">Remove</button>
|
|
2274
2275
|
</div>
|
|
@@ -6147,6 +6148,43 @@ export async function openSettingsModal() {
|
|
|
6147
6148
|
});
|
|
6148
6149
|
});
|
|
6149
6150
|
|
|
6151
|
+
// Trust workspace (adds to git safe.directory global config)
|
|
6152
|
+
overlay.querySelectorAll('.cc-trust-btn[data-cc-trust]').forEach(btn => {
|
|
6153
|
+
btn.addEventListener('click', async (e) => {
|
|
6154
|
+
e.stopPropagation();
|
|
6155
|
+
const idx = btn.dataset.ccTrust;
|
|
6156
|
+
const panel = overlay.querySelector(`.cc-panel[data-cc-idx="${idx}"]`);
|
|
6157
|
+
const projectPath = panel?.dataset.ccPath;
|
|
6158
|
+
if (!projectPath) return;
|
|
6159
|
+
const origText = btn.textContent;
|
|
6160
|
+
btn.disabled = true;
|
|
6161
|
+
btn.textContent = 'Trusting...';
|
|
6162
|
+
try {
|
|
6163
|
+
const res = await fetch('/api/terminal/trust-workspace', {
|
|
6164
|
+
method: 'POST',
|
|
6165
|
+
headers: { 'Content-Type': 'application/json' },
|
|
6166
|
+
body: JSON.stringify({ path: projectPath }),
|
|
6167
|
+
});
|
|
6168
|
+
const data = await res.json();
|
|
6169
|
+
if (data.ok) {
|
|
6170
|
+
btn.textContent = '✓ Trusted';
|
|
6171
|
+
btn.style.background = 'rgba(109,213,140,0.14)';
|
|
6172
|
+
btn.style.borderColor = 'rgba(109,213,140,0.3)';
|
|
6173
|
+
btn.style.color = '#a5d6a7';
|
|
6174
|
+
setTimeout(() => { btn.disabled = false; btn.textContent = 'Trust workspace'; btn.style.background = 'rgba(255,183,77,0.14)'; btn.style.borderColor = 'rgba(255,183,77,0.3)'; btn.style.color = '#ffcc80'; }, 2500);
|
|
6175
|
+
} else {
|
|
6176
|
+
btn.disabled = false;
|
|
6177
|
+
btn.textContent = origText;
|
|
6178
|
+
alert('Trust failed: ' + (data.error || 'unknown error'));
|
|
6179
|
+
}
|
|
6180
|
+
} catch (err) {
|
|
6181
|
+
btn.disabled = false;
|
|
6182
|
+
btn.textContent = origText;
|
|
6183
|
+
alert('Trust failed: ' + err.message);
|
|
6184
|
+
}
|
|
6185
|
+
});
|
|
6186
|
+
});
|
|
6187
|
+
|
|
6150
6188
|
// Remove project
|
|
6151
6189
|
overlay.querySelectorAll('.cc-remove-panel-btn[data-cc-remove]').forEach(btn => {
|
|
6152
6190
|
btn.addEventListener('click', async (e) => {
|
|
@@ -95,7 +95,20 @@ dotenvConfig({ path: resolve(process.env.SYNABUN_DATA_HOME || resolve(__dirname,
|
|
|
95
95
|
|
|
96
96
|
const app = express();
|
|
97
97
|
const SERVER_BOOT_ID = randomUUID();
|
|
98
|
-
app.use(express.json({ limit: '
|
|
98
|
+
app.use(express.json({ limit: '25mb' }));
|
|
99
|
+
|
|
100
|
+
// Clean body-parser error handler: ui-state / whiteboard persist base64 images and can
|
|
101
|
+
// exceed the JSON limit. Default Express handler dumps a 10-line raw-body stack with no
|
|
102
|
+
// URL — this logs one line with context and responds 413 instead.
|
|
103
|
+
app.use((err, req, res, next) => {
|
|
104
|
+
if (err && (err.type === 'entity.too.large' || err.type === 'entity.parse.failed')) {
|
|
105
|
+
const len = req.headers['content-length'] || '?';
|
|
106
|
+
console.warn(`[body-parser] ${err.type} ${req.method} ${req.originalUrl || req.url} content-length=${len} limit=${err.limit || '?'}`);
|
|
107
|
+
if (!res.headersSent) res.status(err.status || 413).json({ error: err.type, limit: err.limit });
|
|
108
|
+
return;
|
|
109
|
+
}
|
|
110
|
+
next(err);
|
|
111
|
+
});
|
|
99
112
|
|
|
100
113
|
// Block tunnel traffic from accessing anything except /mcp and /invite
|
|
101
114
|
// Cloudflared adds CF-Connecting-IP header on proxied requests
|
|
@@ -3814,8 +3827,8 @@ let _ocpBootReconcileTried = false;
|
|
|
3814
3827
|
let _ocpLastBootAt = 0; // Date.now() when serve process last became ready
|
|
3815
3828
|
let _ocpAuthReconciling = false; // prevent concurrent reconciliation
|
|
3816
3829
|
|
|
3817
|
-
// ── Verbose trace logging (
|
|
3818
|
-
const OCP_VERBOSE = process.env.OCP_VERBOSE
|
|
3830
|
+
// ── Verbose trace logging (opt-in: OCP_VERBOSE=1 to enable) ──
|
|
3831
|
+
const OCP_VERBOSE = process.env.OCP_VERBOSE === '1';
|
|
3819
3832
|
let _ocpTraceSeq = 0;
|
|
3820
3833
|
function _ocpNow() { return Number(process.hrtime.bigint() / 1_000_000n); }
|
|
3821
3834
|
function _ocpMs(start) { return _ocpNow() - start; }
|
|
@@ -12742,6 +12755,18 @@ app.post('/api/claude-code/integrations', (req, res) => {
|
|
|
12742
12755
|
// Create default category tree for the project (parent + children)
|
|
12743
12756
|
try { ensureProjectCategories(); } catch {}
|
|
12744
12757
|
|
|
12758
|
+
// Probe for git "dubious ownership" — signals the UI to show an explicit trust prompt.
|
|
12759
|
+
// Only relevant on Windows when the project dir is owned by a different SID than the
|
|
12760
|
+
// account running Node (external drives, copied repos, etc.). No-op on Mac/Linux.
|
|
12761
|
+
let dubiousOwnership = false;
|
|
12762
|
+
let trustPath = null;
|
|
12763
|
+
try {
|
|
12764
|
+
if (detectDubiousOwnership(normalized)) {
|
|
12765
|
+
dubiousOwnership = true;
|
|
12766
|
+
trustPath = normalizeGitPath(normalized);
|
|
12767
|
+
}
|
|
12768
|
+
} catch { /* non-critical */ }
|
|
12769
|
+
|
|
12745
12770
|
// Auto-create greeting config entry for the new project
|
|
12746
12771
|
try {
|
|
12747
12772
|
const greetingKey = projectLabel.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '');
|
|
@@ -12761,7 +12786,7 @@ app.post('/api/claude-code/integrations', (req, res) => {
|
|
|
12761
12786
|
}
|
|
12762
12787
|
} catch { /* non-critical */ }
|
|
12763
12788
|
|
|
12764
|
-
return res.json({ ok: true, message: `Hook enabled for ${basename(normalized)}
|
|
12789
|
+
return res.json({ ok: true, message: `Hook enabled for ${basename(normalized)}.`, dubiousOwnership, trustPath });
|
|
12765
12790
|
}
|
|
12766
12791
|
|
|
12767
12792
|
res.status(400).json({ error: 'Invalid target. Use "global" or "project".' });
|
|
@@ -17072,27 +17097,132 @@ app.get('/api/terminal/sessions/:id/claude-session', (req, res) => {
|
|
|
17072
17097
|
|
|
17073
17098
|
// ── Terminal file tree ──
|
|
17074
17099
|
|
|
17100
|
+
const IS_WINDOWS = process.platform === 'win32';
|
|
17101
|
+
let _gitMissingLogged = false;
|
|
17102
|
+
let _gitExePath = null; // Cached absolute path — preferring .exe on Windows
|
|
17103
|
+
let _gitResolved = false;
|
|
17104
|
+
|
|
17105
|
+
// Resolve git's absolute path at first use. On Windows, `where git` lists all matches;
|
|
17106
|
+
// prefer .exe so spawnSync works without cmd.exe quoting issues. On Unix, use `command -v`.
|
|
17107
|
+
function resolveGitPath() {
|
|
17108
|
+
if (_gitResolved) return _gitExePath;
|
|
17109
|
+
_gitResolved = true;
|
|
17110
|
+
try {
|
|
17111
|
+
const cmd = IS_WINDOWS ? 'where git' : 'command -v git';
|
|
17112
|
+
const out = execSync(cmd, { encoding: 'utf8', timeout: 3000, windowsHide: true, stdio: ['ignore', 'pipe', 'ignore'] }).trim();
|
|
17113
|
+
const lines = out.split(/\r?\n/).map(l => l.trim()).filter(Boolean);
|
|
17114
|
+
if (IS_WINDOWS) {
|
|
17115
|
+
_gitExePath = lines.find(l => /\.exe$/i.test(l)) || lines[0] || null;
|
|
17116
|
+
} else {
|
|
17117
|
+
_gitExePath = lines[0] || null;
|
|
17118
|
+
}
|
|
17119
|
+
if (_gitExePath) console.log(`[git] resolved: ${_gitExePath}`);
|
|
17120
|
+
else console.warn('[git] resolver returned no paths');
|
|
17121
|
+
} catch (err) {
|
|
17122
|
+
console.warn('[git] resolution failed:', err?.message || String(err));
|
|
17123
|
+
_gitExePath = null;
|
|
17124
|
+
}
|
|
17125
|
+
return _gitExePath;
|
|
17126
|
+
}
|
|
17127
|
+
|
|
17128
|
+
// Cross-platform git invocation. Uses resolved absolute path when possible (spawnable
|
|
17129
|
+
// directly on all OSes with no shell/quoting pitfalls). Falls back to `shell: true` on
|
|
17130
|
+
// Windows only when path resolution fails or when the resolved target is a .cmd/.bat shim.
|
|
17131
|
+
function spawnGit(args, opts = {}) {
|
|
17132
|
+
const baseOpts = { stdio: 'pipe', timeout: 5000, windowsHide: true, ...opts };
|
|
17133
|
+
const gitPath = resolveGitPath();
|
|
17134
|
+
if (gitPath) {
|
|
17135
|
+
if (IS_WINDOWS && /\.(cmd|bat)$/i.test(gitPath)) {
|
|
17136
|
+
const quoted = args.map(a => /[\s()%&|<>^"']/.test(a) ? `"${String(a).replace(/"/g, '\\"')}"` : a);
|
|
17137
|
+
return spawnSync(gitPath, quoted, { ...baseOpts, shell: true });
|
|
17138
|
+
}
|
|
17139
|
+
return spawnSync(gitPath, args, baseOpts);
|
|
17140
|
+
}
|
|
17141
|
+
if (IS_WINDOWS) {
|
|
17142
|
+
const quoted = args.map(a => /[\s()%&|<>^"']/.test(a) ? `"${String(a).replace(/"/g, '\\"')}"` : a);
|
|
17143
|
+
return spawnSync('git', quoted, { ...baseOpts, shell: true });
|
|
17144
|
+
}
|
|
17145
|
+
return spawnSync('git', args, baseOpts);
|
|
17146
|
+
}
|
|
17147
|
+
|
|
17148
|
+
function logGitFailure(where, err) {
|
|
17149
|
+
const msg = err?.stderr?.toString?.() || err?.message || String(err);
|
|
17150
|
+
if (!msg || /not a git repository/i.test(msg)) return;
|
|
17151
|
+
if (/ENOENT/i.test(msg) || /is not recognized/i.test(msg) || /command not found/i.test(msg)) {
|
|
17152
|
+
if (_gitMissingLogged) return;
|
|
17153
|
+
_gitMissingLogged = true;
|
|
17154
|
+
console.warn(`[git] '${where}' failed: git executable not found on PATH. Install Git and ensure it's on the Node process PATH.`);
|
|
17155
|
+
return;
|
|
17156
|
+
}
|
|
17157
|
+
console.warn(`[git] '${where}' failed:`, msg.trim().split('\n')[0]);
|
|
17158
|
+
}
|
|
17159
|
+
|
|
17160
|
+
// Startup probe — always logs git status so Windows users can see whether git works at all.
|
|
17161
|
+
setImmediate(() => {
|
|
17162
|
+
const probe = spawnGit(['--version'], { timeout: 3000 });
|
|
17163
|
+
if (probe.status === 0) {
|
|
17164
|
+
console.log(`[git] ${probe.stdout.toString().trim()} — OK`);
|
|
17165
|
+
} else {
|
|
17166
|
+
const msg = probe.stderr?.toString?.()?.trim() || probe.error?.message || 'unknown error';
|
|
17167
|
+
console.warn(`[git] startup probe FAILED: ${msg}. File explorer git UI will be hidden.`);
|
|
17168
|
+
}
|
|
17169
|
+
});
|
|
17170
|
+
|
|
17171
|
+
// Git "dubious ownership" detection — CVE-2022-24765 safety check refuses to operate on
|
|
17172
|
+
// repos owned by a different OS user/SID. Common on Windows external drives or repos
|
|
17173
|
+
// copied from another machine. Returns true only when git emits the specific error.
|
|
17174
|
+
function detectDubiousOwnership(dir) {
|
|
17175
|
+
const r = spawnGit(['rev-parse', '--is-inside-work-tree'], { cwd: dir, timeout: 3000 });
|
|
17176
|
+
if (r.status === 0) return false;
|
|
17177
|
+
const stderr = r.stderr?.toString?.() || '';
|
|
17178
|
+
return /dubious ownership|safe\.directory/i.test(stderr);
|
|
17179
|
+
}
|
|
17180
|
+
|
|
17181
|
+
// Normalize a filesystem path for `git config --add safe.directory` — forward-slashed
|
|
17182
|
+
// absolute path, no trailing slash.
|
|
17183
|
+
function normalizeGitPath(dir) {
|
|
17184
|
+
return String(dir).replace(/\\/g, '/').replace(/\/+$/, '');
|
|
17185
|
+
}
|
|
17186
|
+
|
|
17075
17187
|
/** Try to get git status for a directory. Returns { branch, statuses } or null. */
|
|
17076
17188
|
function getGitInfo(dir) {
|
|
17077
|
-
|
|
17078
|
-
|
|
17079
|
-
|
|
17189
|
+
// Check if inside a git work tree (spawnGit for consistent path resolution)
|
|
17190
|
+
const inside = spawnGit(['rev-parse', '--is-inside-work-tree'], { cwd: dir, timeout: 3000 });
|
|
17191
|
+
if (inside.status !== 0) {
|
|
17192
|
+
logGitFailure('rev-parse --is-inside-work-tree', inside.error || { stderr: inside.stderr });
|
|
17193
|
+
return null;
|
|
17194
|
+
}
|
|
17080
17195
|
|
|
17081
|
-
|
|
17082
|
-
|
|
17083
|
-
|
|
17084
|
-
|
|
17085
|
-
|
|
17086
|
-
|
|
17087
|
-
|
|
17088
|
-
|
|
17089
|
-
|
|
17196
|
+
// Get branch name — try --show-current first (git 2.22+), then fall back to
|
|
17197
|
+
// --abbrev-ref HEAD (works on older git; detached HEAD returns the literal "HEAD"),
|
|
17198
|
+
// then short SHA for detached HEAD. The chained fallback is essential for Windows users
|
|
17199
|
+
// with older Git for Windows installs where --show-current exits non-zero.
|
|
17200
|
+
let branch = '';
|
|
17201
|
+
const showCurrent = spawnGit(['branch', '--show-current'], { cwd: dir, timeout: 3000 });
|
|
17202
|
+
if (showCurrent.status === 0) {
|
|
17203
|
+
branch = showCurrent.stdout.toString().trim();
|
|
17204
|
+
} else {
|
|
17205
|
+
logGitFailure('branch --show-current', showCurrent.error || { stderr: showCurrent.stderr });
|
|
17206
|
+
}
|
|
17207
|
+
if (!branch) {
|
|
17208
|
+
const abbrev = spawnGit(['rev-parse', '--abbrev-ref', 'HEAD'], { cwd: dir, timeout: 3000 });
|
|
17209
|
+
if (abbrev.status === 0) {
|
|
17210
|
+
const b = abbrev.stdout.toString().trim();
|
|
17211
|
+
if (b && b !== 'HEAD') branch = b;
|
|
17212
|
+
}
|
|
17213
|
+
}
|
|
17214
|
+
if (!branch) {
|
|
17215
|
+
const shortSha = spawnGit(['rev-parse', '--short', 'HEAD'], { cwd: dir, timeout: 3000 });
|
|
17216
|
+
if (shortSha.status === 0) branch = shortSha.stdout.toString().trim();
|
|
17217
|
+
}
|
|
17090
17218
|
|
|
17219
|
+
try {
|
|
17091
17220
|
// Get status for this directory (porcelain v1: XY <path>)
|
|
17092
|
-
|
|
17093
|
-
|
|
17094
|
-
|
|
17095
|
-
|
|
17221
|
+
const statusResult = spawnGit(['status', '--porcelain', '-u', '.'], { cwd: dir, maxBuffer: 256 * 1024 * 1024 });
|
|
17222
|
+
if (statusResult.status !== 0) {
|
|
17223
|
+
logGitFailure('status --porcelain', statusResult.error || { stderr: statusResult.stderr });
|
|
17224
|
+
return { branch, statuses: new Map() };
|
|
17225
|
+
}
|
|
17096
17226
|
const raw = statusResult.stdout.toString();
|
|
17097
17227
|
const statuses = new Map(); // name → status code
|
|
17098
17228
|
|
|
@@ -17130,8 +17260,9 @@ function getGitInfo(dir) {
|
|
|
17130
17260
|
}
|
|
17131
17261
|
|
|
17132
17262
|
return { branch, statuses };
|
|
17133
|
-
} catch {
|
|
17134
|
-
|
|
17263
|
+
} catch (err) {
|
|
17264
|
+
logGitFailure('getGitInfo status-parse', err);
|
|
17265
|
+
return { branch, statuses: new Map() };
|
|
17135
17266
|
}
|
|
17136
17267
|
}
|
|
17137
17268
|
|
|
@@ -17213,23 +17344,36 @@ app.get('/api/terminal/branches', (req, res) => {
|
|
|
17213
17344
|
const dir = req.query.path;
|
|
17214
17345
|
if (!dir || typeof dir !== 'string') return res.status(400).json({ error: 'path required' });
|
|
17215
17346
|
|
|
17216
|
-
|
|
17217
|
-
|
|
17218
|
-
|
|
17347
|
+
const inside = spawnGit(['rev-parse', '--is-inside-work-tree'], { cwd: dir, timeout: 3000 });
|
|
17348
|
+
if (inside.status !== 0) {
|
|
17349
|
+
logGitFailure('branches: rev-parse', inside.error || { stderr: inside.stderr });
|
|
17219
17350
|
return res.json({ branches: [], current: null });
|
|
17220
17351
|
}
|
|
17221
17352
|
|
|
17222
17353
|
try {
|
|
17223
|
-
// Current branch
|
|
17354
|
+
// Current branch — chained fallback (older git may not support --show-current)
|
|
17224
17355
|
let current = '';
|
|
17225
|
-
|
|
17226
|
-
|
|
17227
|
-
|
|
17228
|
-
|
|
17229
|
-
|
|
17230
|
-
|
|
17231
|
-
|
|
17232
|
-
|
|
17356
|
+
const showCurrent = spawnGit(['branch', '--show-current'], { cwd: dir, timeout: 3000 });
|
|
17357
|
+
if (showCurrent.status === 0) current = showCurrent.stdout.toString().trim();
|
|
17358
|
+
else logGitFailure('branches: show-current', showCurrent.error || { stderr: showCurrent.stderr });
|
|
17359
|
+
if (!current) {
|
|
17360
|
+
const abbrev = spawnGit(['rev-parse', '--abbrev-ref', 'HEAD'], { cwd: dir, timeout: 3000 });
|
|
17361
|
+
if (abbrev.status === 0) {
|
|
17362
|
+
const b = abbrev.stdout.toString().trim();
|
|
17363
|
+
if (b && b !== 'HEAD') current = b;
|
|
17364
|
+
}
|
|
17365
|
+
}
|
|
17366
|
+
|
|
17367
|
+
// All local branches — plain `git branch` parsed manually; avoids --format=%(refname:short)
|
|
17368
|
+
// which can get mangled by cmd.exe variable expansion on Windows even with proper quoting.
|
|
17369
|
+
const branchResult = spawnGit(['branch'], { cwd: dir });
|
|
17370
|
+
if (branchResult.status !== 0) logGitFailure('branch list', branchResult.error || { stderr: branchResult.stderr });
|
|
17371
|
+
const raw = branchResult.status === 0 ? branchResult.stdout.toString() : '';
|
|
17372
|
+
// Plain `git branch` output: each line is " branch" or "* branch" (current). Strip
|
|
17373
|
+
// marker + whitespace. Skip detached-HEAD lines like "* (HEAD detached at abc1234)".
|
|
17374
|
+
const branches = raw.split('\n')
|
|
17375
|
+
.map(l => l.replace(/^[\s*]+/, '').trim())
|
|
17376
|
+
.filter(b => b && !b.startsWith('(') && !b.includes(' -> '));
|
|
17233
17377
|
|
|
17234
17378
|
res.json({ branches, current });
|
|
17235
17379
|
} catch (err) {
|
|
@@ -17237,6 +17381,38 @@ app.get('/api/terminal/branches', (req, res) => {
|
|
|
17237
17381
|
}
|
|
17238
17382
|
});
|
|
17239
17383
|
|
|
17384
|
+
// Trust a workspace path with git — runs `git config --global --add safe.directory <path>`
|
|
17385
|
+
// so git stops refusing to operate on it (CVE-2022-24765 dubious-ownership protection).
|
|
17386
|
+
// Triggered by the add-project UI flows when the server detects the dubious-ownership error.
|
|
17387
|
+
app.post('/api/terminal/trust-workspace', (req, res) => {
|
|
17388
|
+
const { path: dir } = req.body || {};
|
|
17389
|
+
if (!dir || typeof dir !== 'string') return res.status(400).json({ ok: false, error: 'path required' });
|
|
17390
|
+
if (/[\x00-\x1f]/.test(dir) || dir === '*' || dir === '/' || dir === '\\') {
|
|
17391
|
+
return res.status(400).json({ ok: false, error: 'Invalid path' });
|
|
17392
|
+
}
|
|
17393
|
+
let abs;
|
|
17394
|
+
try {
|
|
17395
|
+
abs = resolve(dir);
|
|
17396
|
+
if (!existsSync(abs)) return res.status(400).json({ ok: false, error: `Directory not found: ${abs}` });
|
|
17397
|
+
if (!statSync(abs).isDirectory()) return res.status(400).json({ ok: false, error: 'Path is not a directory' });
|
|
17398
|
+
} catch (err) {
|
|
17399
|
+
return res.status(400).json({ ok: false, error: err.message });
|
|
17400
|
+
}
|
|
17401
|
+
|
|
17402
|
+
const gitPath = normalizeGitPath(abs);
|
|
17403
|
+
const r = spawnGit(['config', '--global', '--add', 'safe.directory', gitPath]);
|
|
17404
|
+
if (r.status !== 0) {
|
|
17405
|
+
const stderr = r.stderr?.toString?.()?.trim() || r.error?.message || 'git config failed';
|
|
17406
|
+
logGitFailure('config --add safe.directory', r.error || { stderr: r.stderr });
|
|
17407
|
+
return res.status(500).json({ ok: false, error: stderr });
|
|
17408
|
+
}
|
|
17409
|
+
|
|
17410
|
+
// Confirm by re-probing
|
|
17411
|
+
const stillDubious = detectDubiousOwnership(abs);
|
|
17412
|
+
console.log(`[git] trusted workspace: ${gitPath}${stillDubious ? ' (but still flagged — check permissions)' : ''}`);
|
|
17413
|
+
res.json({ ok: true, path: gitPath, stillDubious });
|
|
17414
|
+
});
|
|
17415
|
+
|
|
17240
17416
|
app.post('/api/terminal/checkout', (req, res) => {
|
|
17241
17417
|
const { path: dir, branch } = req.body;
|
|
17242
17418
|
if (!dir || !branch) return res.status(400).json({ error: 'path and branch required' });
|
|
@@ -17246,26 +17422,25 @@ app.post('/api/terminal/checkout', (req, res) => {
|
|
|
17246
17422
|
return res.status(400).json({ error: 'Invalid branch name' });
|
|
17247
17423
|
}
|
|
17248
17424
|
|
|
17249
|
-
|
|
17250
|
-
|
|
17251
|
-
|
|
17425
|
+
const inside = spawnGit(['rev-parse', '--is-inside-work-tree'], { cwd: dir, timeout: 3000 });
|
|
17426
|
+
if (inside.status !== 0) {
|
|
17427
|
+
logGitFailure('checkout: rev-parse', inside.error || { stderr: inside.stderr });
|
|
17252
17428
|
return res.status(400).json({ error: 'Not a git repository' });
|
|
17253
17429
|
}
|
|
17254
17430
|
|
|
17255
|
-
//
|
|
17256
|
-
// No '--' separator — that would make git treat the branch name as a file pathspec
|
|
17257
|
-
const result =
|
|
17258
|
-
|
|
17431
|
+
// Branch is validated against /^[\w.\-/]+$/ above, so no shell metacharacters are possible.
|
|
17432
|
+
// No '--' separator — that would make git treat the branch name as a file pathspec.
|
|
17433
|
+
const result = spawnGit(['checkout', branch], { cwd: dir, timeout: 10000 });
|
|
17259
17434
|
if (result.status !== 0) {
|
|
17260
|
-
const stderr = result.stderr?.toString?.()?.trim() || 'Checkout failed';
|
|
17435
|
+
const stderr = result.stderr?.toString?.()?.trim() || result.error?.message || 'Checkout failed';
|
|
17261
17436
|
return res.status(500).json({ error: stderr });
|
|
17262
17437
|
}
|
|
17263
17438
|
|
|
17264
17439
|
// Return confirmed branch name + git's own output
|
|
17265
17440
|
let current = '';
|
|
17266
|
-
|
|
17267
|
-
|
|
17268
|
-
|
|
17441
|
+
const showCurrent = spawnGit(['branch', '--show-current'], { cwd: dir, timeout: 3000 });
|
|
17442
|
+
if (showCurrent.status === 0) current = showCurrent.stdout.toString().trim();
|
|
17443
|
+
else logGitFailure('checkout: show-current', showCurrent.error || { stderr: showCurrent.stderr });
|
|
17269
17444
|
const output = result.stderr?.toString?.()?.trim() || `Switched to branch '${current}'`;
|
|
17270
17445
|
res.json({ ok: true, branch: current, output });
|
|
17271
17446
|
});
|