merge: integrate origin/main (v0.18.3.0) — community wave

Resolves conflicts:
- VERSION: kept 0.19.0.0 (feature branch, higher than main's 0.18.3.0)
- package.json: kept 0.19.0.0
- CHANGELOG.md: preserved 0.19.0.0 at top, inserted 0.18.3.0 between 0.19.0.0 and 0.18.2.0

Main brought community wave (6 PRs + hardening):
- Windows cookie import
- Persistent browse server across CLI invocations
- One-command OpenCode install
- OpenClaw skill frontmatter fixes
- Cookie picker UI resilience

Auto-merge applied to design.ts, design-consultation/SKILL.md.tmpl,
design-shotgun/SKILL.md.tmpl, and plan-design-review/SKILL.md.tmpl —
main's UX_PRINCIPLES changes and my TASTE_PROFILE resolver coexist cleanly.

Regenerated all SKILL.md files via gen:skill-docs and refreshed ship
golden fixtures. 423 tests pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Garry Tan
2026-04-18 05:56:12 +08:00
28 changed files with 862 additions and 111 deletions

View File

@@ -4,8 +4,25 @@ FROM ubuntu:24.04
ENV DEBIAN_FRONTEND=noninteractive ENV DEBIAN_FRONTEND=noninteractive
# System deps # Switch apt sources to Hetzner's public mirror.
RUN apt-get update && apt-get install -y --no-install-recommends \ # Ubicloud runners (Hetzner FSN1-DC21) hit reliable connection timeouts to
# archive.ubuntu.com:80 — observed 90+ second outages on multiple builds.
# Hetzner's mirror is publicly accessible from any cloud and route-local for
# Ubicloud, so this fixes both reliability and latency. Ubuntu 24.04 uses
# the deb822 sources format at /etc/apt/sources.list.d/ubuntu.sources.
#
# Using HTTP (not HTTPS) intentionally: the base ubuntu:24.04 image ships
# without ca-certificates, so HTTPS apt fails with "No system certificates
# available." Apt's security model verifies via GPG-signed Release files,
# not TLS, so HTTP here is no weaker than the upstream defaults.
RUN sed -i \
-e 's|http://archive.ubuntu.com/ubuntu|http://mirror.hetzner.com/ubuntu/packages|g' \
-e 's|http://security.ubuntu.com/ubuntu|http://mirror.hetzner.com/ubuntu/packages|g' \
/etc/apt/sources.list.d/ubuntu.sources
# System deps (retry apt-get update — even Hetzner can blip occasionally)
RUN for i in 1 2 3; do apt-get update && break || sleep 5; done \
&& apt-get install -y --no-install-recommends \
git curl unzip ca-certificates jq bc gpg \ git curl unzip ca-certificates jq bc gpg \
&& rm -rf /var/lib/apt/lists/* && rm -rf /var/lib/apt/lists/*
@@ -14,7 +31,8 @@ RUN curl -fsSL https://cli.github.com/packages/githubcli-archive-keyring.gpg \
| gpg --dearmor -o /usr/share/keyrings/githubcli-archive-keyring.gpg \ | gpg --dearmor -o /usr/share/keyrings/githubcli-archive-keyring.gpg \
&& echo "deb [arch=$(dpkg --print-architecture) signed-by=/usr/share/keyrings/githubcli-archive-keyring.gpg] https://cli.github.com/packages stable main" \ && echo "deb [arch=$(dpkg --print-architecture) signed-by=/usr/share/keyrings/githubcli-archive-keyring.gpg] https://cli.github.com/packages stable main" \
| tee /etc/apt/sources.list.d/github-cli.list > /dev/null \ | tee /etc/apt/sources.list.d/github-cli.list > /dev/null \
&& apt-get update && apt-get install -y --no-install-recommends gh \ && for i in 1 2 3; do apt-get update && break || sleep 5; done \
&& apt-get install -y --no-install-recommends gh \
&& rm -rf /var/lib/apt/lists/* && rm -rf /var/lib/apt/lists/*
# Node.js 22 LTS (needed for claude CLI) # Node.js 22 LTS (needed for claude CLI)

View File

@@ -28,6 +28,24 @@
- **`scripts/resolvers/preamble/`** — 18 single-purpose generators, 16-160 lines each. The composition root in `scripts/resolvers/preamble.ts` imports them and wires them into the tier-gated section list. - **`scripts/resolvers/preamble/`** — 18 single-purpose generators, 16-160 lines each. The composition root in `scripts/resolvers/preamble.ts` imports them and wires them into the tier-gated section list.
- **Plan and reviews persisted.** Implementation followed `~/.claude/plans/declarative-riding-cook.md` which went through CEO review (SCOPE EXPANSION, 6 expansions accepted), DX review (POLISH, 5 gaps fixed), Eng review (4 architecture issues), and Codex review (11 brutal findings, all integrated and 2 prior decisions reversed). - **Plan and reviews persisted.** Implementation followed `~/.claude/plans/declarative-riding-cook.md` which went through CEO review (SCOPE EXPANSION, 6 expansions accepted), DX review (POLISH, 5 gaps fixed), Eng review (4 architecture issues), and Codex review (11 brutal findings, all integrated and 2 prior decisions reversed).
## [0.18.3.0] - 2026-04-17
### Added
- **Windows cookie import.** `/setup-browser-cookies` now works on Windows. Point it at Chrome, Edge, Brave, or Chromium, pick a profile, and gstack will pull your real browser cookies into the headless session. Handles AES-256-GCM (Chrome 80+), DPAPI key unwrap via PowerShell, and falls back to a headless CDP session for v20 App-Bound Encryption on Chrome 127+. Windows users can now do authenticated QA testing with `/qa` and `/design-review` for the first time.
- **One-command OpenCode install.** `./setup --host opencode` now wires up gstack skills for OpenCode the same way it does for Claude Code and Codex. No more manual workaround.
### Fixed
- **No more permission prompts on every skill invocation.** Every `/browse`, `/qa`, `/qa-only`, `/design-review`, `/office-hours`, `/canary`, `/pair-agent`, `/benchmark`, `/land-and-deploy`, `/design-shotgun`, `/design-consultation`, `/design-html`, `/plan-design-review`, and `/open-gstack-browser` invocation used to trigger Claude Code's sandbox asking about "tilde in assignment value." Replaced bare `~/` with `"$HOME/..."` in the browse and design resolvers plus a handful of templates that still used the old pattern. Every skill runs silently now.
- **Multi-step QA actually works.** The `$B` browse server was dying between Bash tool invocations — Claude Code's sandbox kills the parent shell when a command finishes, and the server took that as a cue to shut down. Now the server persists across calls, keeping your cookies, page state, and navigation intact. Run `$B goto`, then `$B fill`, then `$B click` in three separate Bash calls and it just works. A 30-minute idle timeout still handles eventual cleanup. `Ctrl+C` and `/stop` still do an immediate shutdown.
- **Cookie picker stops stranding the UI.** If the launching CLI exited mid-import, the picker page would flash `Failed to fetch` because the server had shut down under it. The browse server now stays alive while any picker code or session is live.
- **OpenClaw skills load cleanly in Codex.** The 4 hand-authored ClawHub skills (ceo-review, investigate, office-hours, retro) had frontmatter with unquoted colons and non-standard `version`/`metadata` fields that stricter parsers rejected. Now they load without errors on Codex CLI and render correctly on GitHub.
### For contributors
- Community wave lands 6 PRs: #993 (byliu-labs), #994 (joelgreen), #996 (voidborne-d), #864 (cathrynlavery), #982 (breakneo), #892 (msr-hickory).
- SIGTERM handling is now mode-aware. In normal mode the server ignores SIGTERM so Claude Code's sandbox doesn't tear it down mid-session. In headed mode (`/open-gstack-browser`) and tunnel mode (`/pair-agent`) SIGTERM still triggers a clean shutdown — those modes skip idle cleanup, so without the mode gate orphan daemons would accumulate forever. Note that v0.18.1.0 also disables the parent-PID watchdog when `BROWSE_HEADED=1`, so headed mode is doubly protected. Inline comments document the resolution order.
- Windows v20 App-Bound Encryption CDP fallback now logs the Chrome version on entry and has an inline comment documenting the debug-port security posture (127.0.0.1-only, random port in [9222, 9321] for collision avoidance, always killed in finally).
- New regression test `test/openclaw-native-skills.test.ts` pins OpenClaw skill frontmatter to `name` + `description` only — catches version/metadata drift at PR time.
## [0.18.2.0] - 2026-04-17 ## [0.18.2.0] - 2026-04-17
### Fixed ### Fixed

View File

@@ -1,7 +1,7 @@
/** /**
* Chromium browser cookie import — read and decrypt cookies from real browsers * Chromium browser cookie import — read and decrypt cookies from real browsers
* *
* Supports macOS and Linux Chromium-based browsers. * Supports macOS, Linux, and Windows Chromium-based browsers.
* Pure logic module — no Playwright dependency, no HTTP concerns. * Pure logic module — no Playwright dependency, no HTTP concerns.
* *
* Decryption pipeline: * Decryption pipeline:
@@ -40,6 +40,7 @@ import * as crypto from 'crypto';
import * as fs from 'fs'; import * as fs from 'fs';
import * as path from 'path'; import * as path from 'path';
import * as os from 'os'; import * as os from 'os';
import { TEMP_DIR } from './platform';
// ─── Types ────────────────────────────────────────────────────── // ─── Types ──────────────────────────────────────────────────────
@@ -50,6 +51,7 @@ export interface BrowserInfo {
aliases: string[]; aliases: string[];
linuxDataDir?: string; linuxDataDir?: string;
linuxApplication?: string; linuxApplication?: string;
windowsDataDir?: string;
} }
export interface ProfileEntry { export interface ProfileEntry {
@@ -91,7 +93,7 @@ export class CookieImportError extends Error {
} }
} }
type BrowserPlatform = 'darwin' | 'linux'; type BrowserPlatform = 'darwin' | 'linux' | 'win32';
interface BrowserMatch { interface BrowserMatch {
browser: BrowserInfo; browser: BrowserInfo;
@@ -104,11 +106,11 @@ interface BrowserMatch {
const BROWSER_REGISTRY: BrowserInfo[] = [ const BROWSER_REGISTRY: BrowserInfo[] = [
{ name: 'Comet', dataDir: 'Comet/', keychainService: 'Comet Safe Storage', aliases: ['comet', 'perplexity'] }, { name: 'Comet', dataDir: 'Comet/', keychainService: 'Comet Safe Storage', aliases: ['comet', 'perplexity'] },
{ name: 'Chrome', dataDir: 'Google/Chrome/', keychainService: 'Chrome Safe Storage', aliases: ['chrome', 'google-chrome', 'google-chrome-stable'], linuxDataDir: 'google-chrome/', linuxApplication: 'chrome' }, { name: 'Chrome', dataDir: 'Google/Chrome/', keychainService: 'Chrome Safe Storage', aliases: ['chrome', 'google-chrome', 'google-chrome-stable'], linuxDataDir: 'google-chrome/', linuxApplication: 'chrome', windowsDataDir: 'Google/Chrome/User Data/' },
{ name: 'Chromium', dataDir: 'chromium/', keychainService: 'Chromium Safe Storage', aliases: ['chromium'], linuxDataDir: 'chromium/', linuxApplication: 'chromium' }, { name: 'Chromium', dataDir: 'chromium/', keychainService: 'Chromium Safe Storage', aliases: ['chromium'], linuxDataDir: 'chromium/', linuxApplication: 'chromium', windowsDataDir: 'Chromium/User Data/' },
{ name: 'Arc', dataDir: 'Arc/User Data/', keychainService: 'Arc Safe Storage', aliases: ['arc'] }, { name: 'Arc', dataDir: 'Arc/User Data/', keychainService: 'Arc Safe Storage', aliases: ['arc'] },
{ name: 'Brave', dataDir: 'BraveSoftware/Brave-Browser/', keychainService: 'Brave Safe Storage', aliases: ['brave'], linuxDataDir: 'BraveSoftware/Brave-Browser/', linuxApplication: 'brave' }, { name: 'Brave', dataDir: 'BraveSoftware/Brave-Browser/', keychainService: 'Brave Safe Storage', aliases: ['brave'], linuxDataDir: 'BraveSoftware/Brave-Browser/', linuxApplication: 'brave', windowsDataDir: 'BraveSoftware/Brave-Browser/User Data/' },
{ name: 'Edge', dataDir: 'Microsoft Edge/', keychainService: 'Microsoft Edge Safe Storage', aliases: ['edge'], linuxDataDir: 'microsoft-edge/', linuxApplication: 'microsoft-edge' }, { name: 'Edge', dataDir: 'Microsoft Edge/', keychainService: 'Microsoft Edge Safe Storage', aliases: ['edge'], linuxDataDir: 'microsoft-edge/', linuxApplication: 'microsoft-edge', windowsDataDir: 'Microsoft/Edge/User Data/' },
]; ];
// ─── Key Cache ────────────────────────────────────────────────── // ─── Key Cache ──────────────────────────────────────────────────
@@ -133,10 +135,12 @@ export function findInstalledBrowsers(): BrowserInfo[] {
const browserDir = path.join(getBaseDir(platform), dataDir); const browserDir = path.join(getBaseDir(platform), dataDir);
try { try {
const entries = fs.readdirSync(browserDir, { withFileTypes: true }); const entries = fs.readdirSync(browserDir, { withFileTypes: true });
if (entries.some(e => if (entries.some(e => {
e.isDirectory() && e.name.startsWith('Profile ') && if (!e.isDirectory() || !e.name.startsWith('Profile ')) return false;
fs.existsSync(path.join(browserDir, e.name, 'Cookies')) const profileDir = path.join(browserDir, e.name);
)) return true; return fs.existsSync(path.join(profileDir, 'Cookies'))
|| (platform === 'win32' && fs.existsSync(path.join(profileDir, 'Network', 'Cookies')));
})) return true;
} catch {} } catch {}
} }
return false; return false;
@@ -174,8 +178,11 @@ export function listProfiles(browserName: string): ProfileEntry[] {
for (const entry of entries) { for (const entry of entries) {
if (!entry.isDirectory()) continue; if (!entry.isDirectory()) continue;
if (entry.name !== 'Default' && !entry.name.startsWith('Profile ')) continue; if (entry.name !== 'Default' && !entry.name.startsWith('Profile ')) continue;
const cookiePath = path.join(browserDir, entry.name, 'Cookies'); // Chrome 80+ on Windows stores cookies under Network/Cookies
if (!fs.existsSync(cookiePath)) continue; const cookieCandidates = platform === 'win32'
? [path.join(browserDir, entry.name, 'Network', 'Cookies'), path.join(browserDir, entry.name, 'Cookies')]
: [path.join(browserDir, entry.name, 'Cookies')];
if (!cookieCandidates.some(p => fs.existsSync(p))) continue;
// Avoid duplicates if the same profile appears on multiple platforms // Avoid duplicates if the same profile appears on multiple platforms
if (profiles.some(p => p.name === entry.name)) continue; if (profiles.some(p => p.name === entry.name)) continue;
@@ -268,7 +275,7 @@ export async function importCookies(
for (const row of rows) { for (const row of rows) {
try { try {
const value = decryptCookieValue(row, derivedKeys); const value = decryptCookieValue(row, derivedKeys, match.platform);
const cookie = toPlaywrightCookie(row, value); const cookie = toPlaywrightCookie(row, value);
cookies.push(cookie); cookies.push(cookie);
domainCounts[row.host_key] = (domainCounts[row.host_key] || 0) + 1; domainCounts[row.host_key] = (domainCounts[row.host_key] || 0) + 1;
@@ -310,7 +317,8 @@ function validateProfile(profile: string): void {
} }
function getHostPlatform(): BrowserPlatform | null { function getHostPlatform(): BrowserPlatform | null {
if (process.platform === 'darwin' || process.platform === 'linux') return process.platform; const p = process.platform;
if (p === 'darwin' || p === 'linux' || p === 'win32') return p as BrowserPlatform;
return null; return null;
} }
@@ -318,20 +326,22 @@ function getSearchPlatforms(): BrowserPlatform[] {
const current = getHostPlatform(); const current = getHostPlatform();
const order: BrowserPlatform[] = []; const order: BrowserPlatform[] = [];
if (current) order.push(current); if (current) order.push(current);
for (const platform of ['darwin', 'linux'] as BrowserPlatform[]) { for (const platform of ['darwin', 'linux', 'win32'] as BrowserPlatform[]) {
if (!order.includes(platform)) order.push(platform); if (!order.includes(platform)) order.push(platform);
} }
return order; return order;
} }
function getDataDirForPlatform(browser: BrowserInfo, platform: BrowserPlatform): string | null { function getDataDirForPlatform(browser: BrowserInfo, platform: BrowserPlatform): string | null {
return platform === 'darwin' ? browser.dataDir : browser.linuxDataDir || null; if (platform === 'darwin') return browser.dataDir;
if (platform === 'linux') return browser.linuxDataDir || null;
return browser.windowsDataDir || null;
} }
function getBaseDir(platform: BrowserPlatform): string { function getBaseDir(platform: BrowserPlatform): string {
return platform === 'darwin' if (platform === 'darwin') return path.join(os.homedir(), 'Library', 'Application Support');
? path.join(os.homedir(), 'Library', 'Application Support') if (platform === 'win32') return path.join(os.homedir(), 'AppData', 'Local');
: path.join(os.homedir(), '.config'); return path.join(os.homedir(), '.config');
} }
function findBrowserMatch(browser: BrowserInfo, profile: string): BrowserMatch | null { function findBrowserMatch(browser: BrowserInfo, profile: string): BrowserMatch | null {
@@ -339,12 +349,18 @@ function findBrowserMatch(browser: BrowserInfo, profile: string): BrowserMatch |
for (const platform of getSearchPlatforms()) { for (const platform of getSearchPlatforms()) {
const dataDir = getDataDirForPlatform(browser, platform); const dataDir = getDataDirForPlatform(browser, platform);
if (!dataDir) continue; if (!dataDir) continue;
const dbPath = path.join(getBaseDir(platform), dataDir, profile, 'Cookies'); const baseProfile = path.join(getBaseDir(platform), dataDir, profile);
try { // Chrome 80+ on Windows stores cookies under Network/Cookies; fall back to Cookies
if (fs.existsSync(dbPath)) { const candidates = platform === 'win32'
return { browser, platform, dbPath }; ? [path.join(baseProfile, 'Network', 'Cookies'), path.join(baseProfile, 'Cookies')]
} : [path.join(baseProfile, 'Cookies')];
} catch {} for (const dbPath of candidates) {
try {
if (fs.existsSync(dbPath)) {
return { browser, platform, dbPath };
}
} catch {}
}
} }
return null; return null;
} }
@@ -369,6 +385,13 @@ function getBrowserMatch(browser: BrowserInfo, profile: string): BrowserMatch {
// ─── Internal: SQLite Access ──────────────────────────────────── // ─── Internal: SQLite Access ────────────────────────────────────
function openDb(dbPath: string, browserName: string): Database { function openDb(dbPath: string, browserName: string): Database {
// On Windows, Chrome holds exclusive WAL locks even when we open readonly.
// The readonly open may "succeed" but return empty results because the WAL
// (where all actual data lives) can't be replayed. Always use the copy
// approach on Windows so we can open read-write and process the WAL.
if (process.platform === 'win32') {
return openDbFromCopy(dbPath, browserName);
}
try { try {
return new Database(dbPath, { readonly: true }); return new Database(dbPath, { readonly: true });
} catch (err: any) { } catch (err: any) {
@@ -439,6 +462,11 @@ async function getDerivedKeys(match: BrowserMatch): Promise<Map<string, Buffer>>
]); ]);
} }
if (match.platform === 'win32') {
const key = await getWindowsAesKey(match.browser);
return new Map([['v10', key]]);
}
const keys = new Map<string, Buffer>(); const keys = new Map<string, Buffer>();
keys.set('v10', getCachedDerivedKey('linux:v10', 'peanuts', 1)); keys.set('v10', getCachedDerivedKey('linux:v10', 'peanuts', 1));
@@ -452,6 +480,84 @@ async function getDerivedKeys(match: BrowserMatch): Promise<Map<string, Buffer>>
return keys; return keys;
} }
async function getWindowsAesKey(browser: BrowserInfo): Promise<Buffer> {
const cacheKey = `win32:${browser.keychainService}`;
const cached = keyCache.get(cacheKey);
if (cached) return cached;
const platform = 'win32' as const;
const dataDir = getDataDirForPlatform(browser, platform);
if (!dataDir) throw new CookieImportError(`No Windows data dir for ${browser.name}`, 'not_installed');
const localStatePath = path.join(getBaseDir(platform), dataDir, 'Local State');
let localState: any;
try {
localState = JSON.parse(fs.readFileSync(localStatePath, 'utf-8'));
} catch (err) {
const reason = err instanceof Error ? `: ${err.message}` : '';
throw new CookieImportError(
`Cannot read Local State for ${browser.name} at ${localStatePath}${reason}`,
'keychain_error',
);
}
const encryptedKeyB64: string = localState?.os_crypt?.encrypted_key;
if (!encryptedKeyB64) {
throw new CookieImportError(
`No encrypted key in Local State for ${browser.name}`,
'keychain_not_found',
);
}
// The stored value is base64(b"DPAPI" + dpapi_encrypted_bytes) — strip the 5-byte prefix
const encryptedKey = Buffer.from(encryptedKeyB64, 'base64').slice(5);
const key = await dpapiDecrypt(encryptedKey);
keyCache.set(cacheKey, key);
return key;
}
async function dpapiDecrypt(encryptedBytes: Buffer): Promise<Buffer> {
const script = [
'Add-Type -AssemblyName System.Security',
'$stdin = [Console]::In.ReadToEnd().Trim()',
'$bytes = [System.Convert]::FromBase64String($stdin)',
'$dec = [System.Security.Cryptography.ProtectedData]::Unprotect($bytes, $null, [System.Security.Cryptography.DataProtectionScope]::CurrentUser)',
'Write-Output ([System.Convert]::ToBase64String($dec))',
].join('; ');
const proc = Bun.spawn(['powershell', '-NoProfile', '-Command', script], {
stdin: 'pipe',
stdout: 'pipe',
stderr: 'pipe',
});
proc.stdin.write(encryptedBytes.toString('base64'));
proc.stdin.end();
const timeout = new Promise<never>((_, reject) =>
setTimeout(() => {
proc.kill();
reject(new CookieImportError('DPAPI decryption timed out', 'keychain_timeout', 'retry'));
}, 10_000),
);
try {
const exitCode = await Promise.race([proc.exited, timeout]);
const stdout = await new Response(proc.stdout).text();
if (exitCode !== 0) {
const stderr = await new Response(proc.stderr).text();
throw new CookieImportError(`DPAPI decryption failed: ${stderr.trim()}`, 'keychain_error');
}
return Buffer.from(stdout.trim(), 'base64');
} catch (err) {
if (err instanceof CookieImportError) throw err;
throw new CookieImportError(
`DPAPI decryption failed: ${(err as Error).message}`,
'keychain_error',
);
}
}
async function getMacKeychainPassword(service: string): Promise<string> { async function getMacKeychainPassword(service: string): Promise<string> {
// Use async Bun.spawn with timeout to avoid blocking the event loop. // Use async Bun.spawn with timeout to avoid blocking the event loop.
// macOS may show an Allow/Deny dialog that blocks until the user responds. // macOS may show an Allow/Deny dialog that blocks until the user responds.
@@ -566,7 +672,7 @@ interface RawCookie {
samesite: number; samesite: number;
} }
function decryptCookieValue(row: RawCookie, keys: Map<string, Buffer>): string { function decryptCookieValue(row: RawCookie, keys: Map<string, Buffer>, platform: BrowserPlatform): string {
// Prefer unencrypted value if present // Prefer unencrypted value if present
if (row.value && row.value.length > 0) return row.value; if (row.value && row.value.length > 0) return row.value;
@@ -574,9 +680,28 @@ function decryptCookieValue(row: RawCookie, keys: Map<string, Buffer>): string {
if (ev.length === 0) return ''; if (ev.length === 0) return '';
const prefix = ev.slice(0, 3).toString('utf-8'); const prefix = ev.slice(0, 3).toString('utf-8');
// Chrome 127+ on Windows uses App-Bound Encryption (v20) — cannot be decrypted
// outside the Chrome process. Caller should fall back to CDP extraction.
if (prefix === 'v20') throw new CookieImportError(
'Cookie uses App-Bound Encryption (v20). Use CDP extraction instead.',
'v20_encryption',
);
const key = keys.get(prefix); const key = keys.get(prefix);
if (!key) throw new Error(`No decryption key available for ${prefix} cookies`); if (!key) throw new Error(`No decryption key available for ${prefix} cookies`);
if (platform === 'win32' && prefix === 'v10') {
// Windows: AES-256-GCM — structure: v10(3) + nonce(12) + ciphertext + tag(16)
const nonce = ev.slice(3, 15);
const tag = ev.slice(ev.length - 16);
const ciphertext = ev.slice(15, ev.length - 16);
const decipher = crypto.createDecipheriv('aes-256-gcm', key, nonce) as crypto.DecipherGCM;
decipher.setAuthTag(tag);
return Buffer.concat([decipher.update(ciphertext), decipher.final()]).toString('utf-8');
}
// macOS / Linux: AES-128-CBC — structure: v10/v11(3) + ciphertext
const ciphertext = ev.slice(3); const ciphertext = ev.slice(3);
const iv = Buffer.alloc(16, 0x20); // 16 space characters const iv = Buffer.alloc(16, 0x20); // 16 space characters
const decipher = crypto.createDecipheriv('aes-128-cbc', key, iv); const decipher = crypto.createDecipheriv('aes-128-cbc', key, iv);
@@ -624,3 +749,284 @@ function mapSameSite(value: number): 'Strict' | 'Lax' | 'None' {
default: return 'Lax'; default: return 'Lax';
} }
} }
// ─── CDP-based Cookie Extraction (Windows v20 fallback) ────────
// When App-Bound Encryption (v20) is detected, we launch Chrome headless
// with remote debugging and extract cookies via the DevTools Protocol.
// This only works when Chrome is NOT already running (profile lock).
const CHROME_PATHS_WIN = [
path.join(process.env.PROGRAMFILES || 'C:\\Program Files', 'Google', 'Chrome', 'Application', 'chrome.exe'),
path.join(process.env['PROGRAMFILES(X86)'] || 'C:\\Program Files (x86)', 'Google', 'Chrome', 'Application', 'chrome.exe'),
];
const EDGE_PATHS_WIN = [
path.join(process.env['PROGRAMFILES(X86)'] || 'C:\\Program Files (x86)', 'Microsoft', 'Edge', 'Application', 'msedge.exe'),
path.join(process.env.PROGRAMFILES || 'C:\\Program Files', 'Microsoft', 'Edge', 'Application', 'msedge.exe'),
];
function findBrowserExe(browserName: string): string | null {
const candidates = browserName.toLowerCase().includes('edge') ? EDGE_PATHS_WIN : CHROME_PATHS_WIN;
for (const p of candidates) {
if (fs.existsSync(p)) return p;
}
return null;
}
function isBrowserRunning(browserName: string): Promise<boolean> {
const exe = browserName.toLowerCase().includes('edge') ? 'msedge.exe' : 'chrome.exe';
return new Promise((resolve) => {
const proc = Bun.spawn(['tasklist', '/FI', `IMAGENAME eq ${exe}`, '/NH'], {
stdout: 'pipe', stderr: 'pipe',
});
proc.exited.then(async () => {
const out = await new Response(proc.stdout).text();
resolve(out.toLowerCase().includes(exe));
}).catch(() => resolve(false));
});
}
/**
* Extract cookies via Chrome DevTools Protocol. Launches Chrome headless with
* remote debugging on the user's real profile directory. Requires Chrome to be
* closed first (profile lock).
*
* v20 App-Bound Encryption binds decryption keys to the original user-data-dir
* path, so a temp copy of the profile won't work — Chrome silently discards
* cookies it can't decrypt. We must use the real profile.
*/
export async function importCookiesViaCdp(
browserName: string,
domains: string[],
profile = 'Default',
): Promise<ImportResult> {
if (domains.length === 0) return { cookies: [], count: 0, failed: 0, domainCounts: {} };
if (process.platform !== 'win32') {
throw new CookieImportError('CDP extraction is only needed on Windows', 'not_supported');
}
const browser = resolveBrowser(browserName);
const exePath = findBrowserExe(browser.name);
if (!exePath) {
throw new CookieImportError(
`Cannot find ${browser.name} executable. Install it or use /connect-chrome.`,
'not_installed',
);
}
if (await isBrowserRunning(browser.name)) {
throw new CookieImportError(
`${browser.name} is running. Close it first so we can launch headless with your profile, or use /connect-chrome to control your real browser directly.`,
'browser_running',
'retry',
);
}
// Must use the real user data dir — v20 ABE keys are path-bound
const dataDir = getDataDirForPlatform(browser, 'win32');
if (!dataDir) throw new CookieImportError(`No Windows data dir for ${browser.name}`, 'not_installed');
const userDataDir = path.join(getBaseDir('win32'), dataDir);
// Launch Chrome headless with remote debugging on the real profile.
//
// Security posture of the debug port:
// - Chrome binds --remote-debugging-port to 127.0.0.1 by default. We rely
// on that — the port is NOT exposed to the network. Any local process
// running as the same user could connect and read cookies, but if an
// attacker already has local-user access they can read the cookie DB
// directly. Threat model: no worse than baseline.
// - Port is randomized in [9222, 9321] to avoid collisions with other
// Chrome-based tools the user may have open. Not cryptographic.
// - Chrome is always killed in the finally block below (even on crash).
//
// Debugging note: if this path starts failing after a Chrome update,
// check the Chrome version logged below — Chrome's ABE key format (v20)
// or /json/list shape can change between major versions.
const debugPort = 9222 + Math.floor(Math.random() * 100);
const chromeProc = Bun.spawn([
exePath,
`--remote-debugging-port=${debugPort}`,
`--user-data-dir=${userDataDir}`,
`--profile-directory=${profile}`,
'--headless=new',
'--no-first-run',
'--disable-background-networking',
'--disable-default-apps',
'--disable-extensions',
'--disable-sync',
'--no-default-browser-check',
], { stdout: 'pipe', stderr: 'pipe' });
// Wait for Chrome to start, then find a page target's WebSocket URL.
// Network.getAllCookies is only available on page targets, not browser.
let wsUrl: string | null = null;
const startTime = Date.now();
let loggedVersion = false;
while (Date.now() - startTime < 15_000) {
try {
// One-time version log for future diagnostics when Chrome changes v20 format.
if (!loggedVersion) {
try {
const versionResp = await fetch(`http://127.0.0.1:${debugPort}/json/version`);
if (versionResp.ok) {
const v = await versionResp.json() as { Browser?: string };
console.log(`[cookie-import] CDP fallback: ${browser.name} ${v.Browser || 'unknown version'}`);
loggedVersion = true;
}
} catch {}
}
const resp = await fetch(`http://127.0.0.1:${debugPort}/json/list`);
if (resp.ok) {
const targets = await resp.json() as Array<{ type: string; webSocketDebuggerUrl?: string }>;
const page = targets.find(t => t.type === 'page');
if (page?.webSocketDebuggerUrl) {
wsUrl = page.webSocketDebuggerUrl;
break;
}
}
} catch {
// Not ready yet
}
await new Promise(r => setTimeout(r, 300));
}
if (!wsUrl) {
chromeProc.kill();
throw new CookieImportError(
`${browser.name} headless did not start within 15s`,
'cdp_timeout',
'retry',
);
}
try {
// Connect via CDP WebSocket
const cookies = await extractCookiesViaCdp(wsUrl, domains);
const domainCounts: Record<string, number> = {};
for (const c of cookies) {
domainCounts[c.domain] = (domainCounts[c.domain] || 0) + 1;
}
return { cookies, count: cookies.length, failed: 0, domainCounts };
} finally {
chromeProc.kill();
}
}
async function extractCookiesViaCdp(wsUrl: string, domains: string[]): Promise<PlaywrightCookie[]> {
return new Promise((resolve, reject) => {
const ws = new WebSocket(wsUrl);
let msgId = 1;
const timeout = setTimeout(() => {
ws.close();
reject(new CookieImportError('CDP cookie extraction timed out', 'cdp_timeout'));
}, 10_000);
ws.onopen = () => {
// Enable Network domain first, then request all cookies
ws.send(JSON.stringify({ id: msgId++, method: 'Network.enable' }));
};
ws.onmessage = (event) => {
const data = JSON.parse(String(event.data));
// After Network.enable succeeds, request all cookies
if (data.id === 1 && !data.error) {
ws.send(JSON.stringify({ id: msgId, method: 'Network.getAllCookies' }));
return;
}
if (data.id === msgId && data.result?.cookies) {
clearTimeout(timeout);
ws.close();
// Normalize domain matching: domains like ".example.com" match "example.com" and vice versa
const domainSet = new Set<string>();
for (const d of domains) {
domainSet.add(d);
domainSet.add(d.startsWith('.') ? d.slice(1) : '.' + d);
}
const matched: PlaywrightCookie[] = [];
for (const c of data.result.cookies as CdpCookie[]) {
if (!domainSet.has(c.domain)) continue;
matched.push({
name: c.name,
value: c.value,
domain: c.domain,
path: c.path || '/',
expires: c.expires === -1 ? -1 : c.expires,
secure: c.secure,
httpOnly: c.httpOnly,
sameSite: cdpSameSite(c.sameSite),
});
}
resolve(matched);
} else if (data.id === msgId && data.error) {
clearTimeout(timeout);
ws.close();
reject(new CookieImportError(
`CDP error: ${data.error.message}`,
'cdp_error',
));
}
};
ws.onerror = (err) => {
clearTimeout(timeout);
reject(new CookieImportError(
`CDP WebSocket error: ${(err as any).message || 'unknown'}`,
'cdp_error',
));
};
});
}
interface CdpCookie {
name: string;
value: string;
domain: string;
path: string;
expires: number;
size: number;
httpOnly: boolean;
secure: boolean;
session: boolean;
sameSite: string;
}
function cdpSameSite(value: string): 'Strict' | 'Lax' | 'None' {
switch (value) {
case 'Strict': return 'Strict';
case 'Lax': return 'Lax';
case 'None': return 'None';
default: return 'Lax';
}
}
/**
* Check if a browser's cookie DB contains v20 (App-Bound) encrypted cookies.
* Quick check — reads a small sample, no decryption attempted.
*/
export function hasV20Cookies(browserName: string, profile = 'Default'): boolean {
if (process.platform !== 'win32') return false;
try {
const browser = resolveBrowser(browserName);
const match = getBrowserMatch(browser, profile);
const db = openDb(match.dbPath, browser.name);
try {
const rows = db.query('SELECT encrypted_value FROM cookies LIMIT 10').all() as Array<{ encrypted_value: Buffer | Uint8Array }>;
return rows.some(row => {
const ev = Buffer.from(row.encrypted_value);
return ev.length >= 3 && ev.slice(0, 3).toString('utf-8') === 'v20';
});
} finally {
db.close();
}
} catch {
return false;
}
}

View File

@@ -19,7 +19,7 @@
import * as crypto from 'crypto'; import * as crypto from 'crypto';
import type { BrowserManager } from './browser-manager'; import type { BrowserManager } from './browser-manager';
import { findInstalledBrowsers, listProfiles, listDomains, importCookies, CookieImportError, type PlaywrightCookie } from './cookie-import-browser'; import { findInstalledBrowsers, listProfiles, listDomains, importCookies, importCookiesViaCdp, hasV20Cookies, CookieImportError, type PlaywrightCookie } from './cookie-import-browser';
import { getCookiePickerHTML } from './cookie-picker-ui'; import { getCookiePickerHTML } from './cookie-picker-ui';
// ─── Auth State ───────────────────────────────────────────────── // ─── Auth State ─────────────────────────────────────────────────
@@ -40,6 +40,23 @@ export function generatePickerCode(): string {
return code; return code;
} }
/** Return true while the picker still has a live code or session. */
export function hasActivePicker(): boolean {
const now = Date.now();
for (const [code, expiry] of pendingCodes) {
if (expiry > now) return true;
pendingCodes.delete(code);
}
for (const [session, expiry] of validSessions) {
if (expiry > now) return true;
validSessions.delete(session);
}
return false;
}
/** Extract session ID from the gstack_picker cookie. */ /** Extract session ID from the gstack_picker cookie. */
function getSessionFromCookie(req: Request): string | null { function getSessionFromCookie(req: Request): string | null {
const cookie = req.headers.get('cookie'); const cookie = req.headers.get('cookie');
@@ -217,7 +234,25 @@ export async function handleCookiePickerRoute(
} }
// Decrypt cookies from the browser DB // Decrypt cookies from the browser DB
const result = await importCookies(browser, domains, profile || 'Default'); const selectedProfile = profile || 'Default';
let result = await importCookies(browser, domains, selectedProfile);
// If all cookies failed and v20 encryption is detected, try CDP extraction
if (result.cookies.length === 0 && result.failed > 0 && hasV20Cookies(browser, selectedProfile)) {
console.log(`[cookie-picker] v20 App-Bound Encryption detected, trying CDP extraction...`);
try {
result = await importCookiesViaCdp(browser, domains, selectedProfile);
} catch (cdpErr: any) {
console.log(`[cookie-picker] CDP fallback failed: ${cdpErr.message}`);
return jsonResponse({
imported: 0,
failed: result.failed,
domainCounts: {},
message: `Cookies use App-Bound Encryption (v20). Close ${browser}, retry, or use /connect-chrome to browse with your real browser directly.`,
code: 'v20_encryption',
}, { port });
}
}
if (result.cookies.length === 0) { if (result.cookies.length === 0) {
return jsonResponse({ return jsonResponse({

View File

@@ -17,7 +17,7 @@ import { BrowserManager } from './browser-manager';
import { handleReadCommand } from './read-commands'; import { handleReadCommand } from './read-commands';
import { handleWriteCommand } from './write-commands'; import { handleWriteCommand } from './write-commands';
import { handleMetaCommand } from './meta-commands'; import { handleMetaCommand } from './meta-commands';
import { handleCookiePickerRoute } from './cookie-picker-routes'; import { handleCookiePickerRoute, hasActivePicker } from './cookie-picker-routes';
import { sanitizeExtensionUrl } from './sidebar-utils'; import { sanitizeExtensionUrl } from './sidebar-utils';
import { COMMAND_DESCRIPTIONS, PAGE_CONTENT_COMMANDS, wrapUntrustedContent } from './commands'; import { COMMAND_DESCRIPTIONS, PAGE_CONTENT_COMMANDS, wrapUntrustedContent } from './commands';
import { import {
@@ -765,14 +765,37 @@ const idleCheckInterval = setInterval(() => {
// also checks BROWSE_HEADED in case a future launcher forgets. // also checks BROWSE_HEADED in case a future launcher forgets.
// Cleanup happens via browser disconnect event or $B disconnect. // Cleanup happens via browser disconnect event or $B disconnect.
const BROWSE_PARENT_PID = parseInt(process.env.BROWSE_PARENT_PID || '0', 10); const BROWSE_PARENT_PID = parseInt(process.env.BROWSE_PARENT_PID || '0', 10);
// Outer gate: if the spawner explicitly marks this as headed (env var set at
// launch time), skip registering the watchdog entirely. Cheaper than entering
// the closure every 15s. The CLI's connect path sets BROWSE_HEADED=1 + PID=0,
// so this branch is the normal path for /open-gstack-browser.
const IS_HEADED_WATCHDOG = process.env.BROWSE_HEADED === '1'; const IS_HEADED_WATCHDOG = process.env.BROWSE_HEADED === '1';
if (BROWSE_PARENT_PID > 0 && !IS_HEADED_WATCHDOG) { if (BROWSE_PARENT_PID > 0 && !IS_HEADED_WATCHDOG) {
let parentGone = false;
setInterval(() => { setInterval(() => {
try { try {
process.kill(BROWSE_PARENT_PID, 0); // signal 0 = existence check only, no signal sent process.kill(BROWSE_PARENT_PID, 0); // signal 0 = existence check only, no signal sent
} catch { } catch {
console.log(`[browse] Parent process ${BROWSE_PARENT_PID} exited, shutting down`); // Parent exited. Resolution order:
shutdown(); // 1. Active cookie picker (one-time code or session live)? Stay alive
// regardless of mode — tearing down the server mid-import leaves the
// picker UI with a stale "Failed to fetch" error.
// 2. Headed / tunnel mode? Shutdown. The idle timeout doesn't apply in
// these modes (see idleCheckInterval above — both early-return), so
// ignoring parent death here would leak orphan daemons after
// /pair-agent or /open-gstack-browser sessions.
// 3. Normal (headless) mode? Stay alive. Claude Code's Bash tool kills
// the parent shell between invocations. The idle timeout (30 min)
// handles eventual cleanup.
if (hasActivePicker()) return;
const headed = browserManager.getConnectionMode() === 'headed';
if (headed || tunnelActive) {
console.log(`[browse] Parent process ${BROWSE_PARENT_PID} exited in ${headed ? 'headed' : 'tunnel'} mode, shutting down`);
shutdown();
} else if (!parentGone) {
parentGone = true;
console.log(`[browse] Parent process ${BROWSE_PARENT_PID} exited (server stays alive, idle timeout will clean up)`);
}
} }
}, 15_000); }, 15_000);
} else if (IS_HEADED_WATCHDOG) { } else if (IS_HEADED_WATCHDOG) {
@@ -1241,11 +1264,36 @@ async function shutdown(exitCode: number = 0) {
} }
// Handle signals // Handle signals
//
// Node passes the signal name (e.g. 'SIGTERM') as the first arg to listeners. // Node passes the signal name (e.g. 'SIGTERM') as the first arg to listeners.
// Wrap so shutdown() receives no args — otherwise the string gets passed as // Wrap calls to shutdown() so it receives no args — otherwise the string gets
// exitCode and process.exit() coerces it to NaN, exiting with code 1 instead of 0. // passed as exitCode and process.exit() coerces it to NaN, exiting with code 1
process.on('SIGTERM', () => shutdown()); // instead of 0. (Caught in v0.18.1.0 #1025.)
//
// SIGINT (Ctrl+C): user intentionally stopping → shutdown.
process.on('SIGINT', () => shutdown()); process.on('SIGINT', () => shutdown());
// SIGTERM behavior depends on mode:
// - Normal (headless) mode: Claude Code's Bash sandbox fires SIGTERM when the
// parent shell exits between tool invocations. Ignoring it keeps the server
// alive across $B calls. Idle timeout (30 min) handles eventual cleanup.
// - Headed / tunnel mode: idle timeout doesn't apply in these modes. Respect
// SIGTERM so external tooling (systemd, supervisord, CI) can shut cleanly
// without waiting forever. Ctrl+C and /stop still work either way.
// - Active cookie picker: never tear down mid-import regardless of mode —
// would strand the picker UI with "Failed to fetch."
process.on('SIGTERM', () => {
if (hasActivePicker()) {
console.log('[browse] Received SIGTERM but cookie picker is active, ignoring to avoid stranding the picker UI');
return;
}
const headed = browserManager.getConnectionMode() === 'headed';
if (headed || tunnelActive) {
console.log(`[browse] Received SIGTERM in ${headed ? 'headed' : 'tunnel'} mode, shutting down`);
shutdown();
} else {
console.log('[browse] Received SIGTERM (ignoring — use /stop or Ctrl+C for intentional shutdown)');
}
});
// Windows: taskkill /F bypasses SIGTERM, but 'exit' fires for some shutdown paths. // Windows: taskkill /F bypasses SIGTERM, but 'exit' fires for some shutdown paths.
// Defense-in-depth — primary cleanup is the CLI's stale-state detection via health check. // Defense-in-depth — primary cleanup is the CLI's stale-state detection via health check.
if (process.platform === 'win32') { if (process.platform === 'win32') {

View File

@@ -7,7 +7,7 @@
import type { TabSession } from './tab-session'; import type { TabSession } from './tab-session';
import type { BrowserManager } from './browser-manager'; import type { BrowserManager } from './browser-manager';
import { findInstalledBrowsers, importCookies, listSupportedBrowserNames } from './cookie-import-browser'; import { findInstalledBrowsers, importCookies, importCookiesViaCdp, hasV20Cookies, listSupportedBrowserNames } from './cookie-import-browser';
import { generatePickerCode } from './cookie-picker-routes'; import { generatePickerCode } from './cookie-picker-routes';
import { validateNavigationUrl } from './url-validation'; import { validateNavigationUrl } from './url-validation';
import { validateOutputPath } from './path-security'; import { validateOutputPath } from './path-security';
@@ -504,7 +504,11 @@ export async function handleWriteCommand(
throw new Error(`--domain "${domain}" does not match current page domain "${pageHostname}". Navigate to the target site first.`); throw new Error(`--domain "${domain}" does not match current page domain "${pageHostname}". Navigate to the target site first.`);
} }
const browser = browserArg || 'comet'; const browser = browserArg || 'comet';
const result = await importCookies(browser, [domain], profile); let result = await importCookies(browser, [domain], profile);
// If all cookies failed and v20 is detected, try CDP extraction
if (result.cookies.length === 0 && result.failed > 0 && hasV20Cookies(browser, profile)) {
result = await importCookiesViaCdp(browser, [domain], profile);
}
if (result.cookies.length > 0) { if (result.cookies.length > 0) {
await page.context().addCookies(result.cookies); await page.context().addCookies(result.cookies);
bm.trackCookieImportDomains([domain]); bm.trackCookieImportDomains([domain]);

View File

@@ -7,7 +7,7 @@
*/ */
import { describe, test, expect } from 'bun:test'; import { describe, test, expect } from 'bun:test';
import { handleCookiePickerRoute, generatePickerCode } from '../src/cookie-picker-routes'; import { handleCookiePickerRoute, generatePickerCode, hasActivePicker } from '../src/cookie-picker-routes';
// ─── Mock BrowserManager ────────────────────────────────────── // ─── Mock BrowserManager ──────────────────────────────────────
@@ -284,6 +284,57 @@ describe('cookie-picker-routes', () => {
}); });
}); });
describe('active picker tracking', () => {
test('one-time codes keep the picker active until consumed', async () => {
const realNow = Date.now;
Date.now = () => realNow() + 3_700_000;
try {
expect(hasActivePicker()).toBe(false); // clears any stale state from prior tests
} finally {
Date.now = realNow;
}
const { bm } = mockBrowserManager();
const code = generatePickerCode();
expect(hasActivePicker()).toBe(true);
const res = await handleCookiePickerRoute(
makeUrl(`/cookie-picker?code=${code}`),
new Request('http://127.0.0.1:9470', { method: 'GET' }),
bm,
'test-token',
);
expect(res.status).toBe(302);
expect(hasActivePicker()).toBe(true); // session is now active
});
test('picker becomes inactive after an invalid session probe clears expired state', async () => {
const { bm } = mockBrowserManager();
const session = await getSessionCookie(bm, 'test-token');
expect(hasActivePicker()).toBe(true);
const realNow = Date.now;
Date.now = () => realNow() + 3_700_000;
try {
const res = await handleCookiePickerRoute(
makeUrl('/cookie-picker'),
new Request('http://127.0.0.1:9470', {
method: 'GET',
headers: { 'Cookie': `gstack_picker=${session}` },
}),
bm,
'test-token',
);
expect(res.status).toBe(403);
expect(hasActivePicker()).toBe(false);
} finally {
Date.now = realNow;
}
});
});
describe('session cookie auth', () => { describe('session cookie auth', () => {
test('valid session cookie grants HTML access', async () => { test('valid session cookie grants HTML access', async () => {
const { bm } = mockBrowserManager(); const { bm } = mockBrowserManager();

View File

@@ -5,16 +5,28 @@ import * as fs from 'fs';
import * as os from 'os'; import * as os from 'os';
// End-to-end regression tests for the parent-process watchdog in server.ts. // End-to-end regression tests for the parent-process watchdog in server.ts.
// Proves three invariants that the v0.18.1.0 fix depends on: // The watchdog has layered behavior since v0.18.1.0 (#1025) and v0.18.2.0
// (community wave #994 + our mode-gating follow-up):
// //
// 1. BROWSE_PARENT_PID=0 disables the watchdog (opt-in used by CI and pair-agent). // 1. BROWSE_PARENT_PID=0 disables the watchdog entirely (opt-in for CI + pair-agent).
// 2. BROWSE_HEADED=1 disables the watchdog (server-side defense-in-depth). // 2. BROWSE_HEADED=1 disables the watchdog entirely (server-side defense for headed
// 3. Default headless mode still kills the server when its parent dies // mode, where the user controls window lifecycle).
// (the original orphan-prevention must keep working). // 3. Default headless mode + parent dies: server STAYS ALIVE. The original
// "kill on parent death" was inverted by #994 because Claude Code's Bash
// sandbox kills the parent shell between every tool invocation, and #994
// makes browse persist across $B calls. Idle timeout (30 min) handles
// eventual cleanup.
// //
// Each test spawns the real server.ts, not a mock. Tests 1 and 2 verify the // Tunnel mode coverage (parent dies → shutdown because idle timeout doesn't
// code path via stdout log line (fast). Test 3 waits for the watchdog's 15s // apply) is not covered by an automated test here — tunnelActive is a runtime
// poll cycle to actually fire (slow — ~25s). // variable set by /pair-agent's tunnel-create flow, not an env var, so faking
// it would require invasive test-only hooks. The mode check is documented
// inline at the watchdog and SIGTERM handlers, and would regress visibly for
// /pair-agent users (server lingers after disconnect).
//
// Each test spawns the real server.ts. Tests 1 and 2 verify behavior via
// stdout log line (fast). Test 3 waits for the watchdog poll cycle to confirm
// the server REMAINS alive after parent death (slow — ~20s observation window).
const ROOT = path.resolve(import.meta.dir, '..'); const ROOT = path.resolve(import.meta.dir, '..');
const SERVER_SCRIPT = path.join(ROOT, 'src', 'server.ts'); const SERVER_SCRIPT = path.join(ROOT, 'src', 'server.ts');
@@ -117,7 +129,7 @@ describe('parent-process watchdog (v0.18.1.0)', () => {
expect(out).not.toContain('Parent process 999999 exited'); expect(out).not.toContain('Parent process 999999 exited');
}, 15_000); }, 15_000);
test('default headless mode: watchdog fires when parent dies', async () => { test('default headless mode: server STAYS ALIVE when parent dies (#994)', async () => {
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'watchdog-default-')); tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'watchdog-default-'));
// Spawn a real, short-lived "parent" that the watchdog will poll. // Spawn a real, short-lived "parent" that the watchdog will poll.
@@ -133,15 +145,13 @@ describe('parent-process watchdog (v0.18.1.0)', () => {
expect(isProcessAlive(serverPid)).toBe(true); expect(isProcessAlive(serverPid)).toBe(true);
// Kill the parent. The watchdog polls every 15s, so first tick after // Kill the parent. The watchdog polls every 15s, so first tick after
// parent death lands within ~15s, plus shutdown() cleanup time. // parent death lands within ~15s. Pre-#994 the server would shutdown
// here. Post-#994 the server logs the parent exit and stays alive.
parentProc.kill('SIGKILL'); parentProc.kill('SIGKILL');
// Poll for up to 25s for the server to exit. // Wait long enough for at least one watchdog tick (15s) plus margin.
const deadline = Date.now() + 25_000; // Server should still be alive — that's the whole point of #994.
while (Date.now() < deadline) { await Bun.sleep(20_000);
if (!isProcessAlive(serverPid)) break; expect(isProcessAlive(serverPid)).toBe(true);
await Bun.sleep(500);
}
expect(isProcessAlive(serverPid)).toBe(false);
}, 45_000); }, 45_000);
}); });

View File

@@ -723,7 +723,7 @@ If browse is not available, that's fine — visual research is optional. The ski
_ROOT=$(git rev-parse --show-toplevel 2>/dev/null) _ROOT=$(git rev-parse --show-toplevel 2>/dev/null)
D="" D=""
[ -n "$_ROOT" ] && [ -x "$_ROOT/.claude/skills/gstack/design/dist/design" ] && D="$_ROOT/.claude/skills/gstack/design/dist/design" [ -n "$_ROOT" ] && [ -x "$_ROOT/.claude/skills/gstack/design/dist/design" ] && D="$_ROOT/.claude/skills/gstack/design/dist/design"
[ -z "$D" ] && D=~/.claude/skills/gstack/design/dist/design [ -z "$D" ] && D="$HOME/.claude/skills/gstack/design/dist/design"
if [ -x "$D" ]; then if [ -x "$D" ]; then
echo "DESIGN_READY: $D" echo "DESIGN_READY: $D"
else else
@@ -731,7 +731,7 @@ else
fi fi
B="" B=""
[ -n "$_ROOT" ] && [ -x "$_ROOT/.claude/skills/gstack/browse/dist/browse" ] && B="$_ROOT/.claude/skills/gstack/browse/dist/browse" [ -n "$_ROOT" ] && [ -x "$_ROOT/.claude/skills/gstack/browse/dist/browse" ] && B="$_ROOT/.claude/skills/gstack/browse/dist/browse"
[ -z "$B" ] && B=~/.claude/skills/gstack/browse/dist/browse [ -z "$B" ] && B="$HOME/.claude/skills/gstack/browse/dist/browse"
if [ -x "$B" ]; then if [ -x "$B" ]; then
echo "BROWSE_READY: $B" echo "BROWSE_READY: $B"
else else
@@ -1114,7 +1114,7 @@ Generate AI-rendered mockups showing the proposed design system applied to reali
```bash ```bash
eval "$(~/.claude/skills/gstack/bin/gstack-slug 2>/dev/null)" eval "$(~/.claude/skills/gstack/bin/gstack-slug 2>/dev/null)"
_DESIGN_DIR=~/.gstack/projects/$SLUG/designs/design-system-$(date +%Y%m%d) _DESIGN_DIR="$HOME/.gstack/projects/$SLUG/designs/design-system-$(date +%Y%m%d)"
mkdir -p "$_DESIGN_DIR" mkdir -p "$_DESIGN_DIR"
echo "DESIGN_DIR: $_DESIGN_DIR" echo "DESIGN_DIR: $_DESIGN_DIR"
``` ```

View File

@@ -293,7 +293,7 @@ Generate AI-rendered mockups showing the proposed design system applied to reali
```bash ```bash
eval "$(~/.claude/skills/gstack/bin/gstack-slug 2>/dev/null)" eval "$(~/.claude/skills/gstack/bin/gstack-slug 2>/dev/null)"
_DESIGN_DIR=~/.gstack/projects/$SLUG/designs/design-system-$(date +%Y%m%d) _DESIGN_DIR="$HOME/.gstack/projects/$SLUG/designs/design-system-$(date +%Y%m%d)"
mkdir -p "$_DESIGN_DIR" mkdir -p "$_DESIGN_DIR"
echo "DESIGN_DIR: $_DESIGN_DIR" echo "DESIGN_DIR: $_DESIGN_DIR"
``` ```

View File

@@ -632,7 +632,7 @@ around obstacles.
_ROOT=$(git rev-parse --show-toplevel 2>/dev/null) _ROOT=$(git rev-parse --show-toplevel 2>/dev/null)
D="" D=""
[ -n "$_ROOT" ] && [ -x "$_ROOT/.claude/skills/gstack/design/dist/design" ] && D="$_ROOT/.claude/skills/gstack/design/dist/design" [ -n "$_ROOT" ] && [ -x "$_ROOT/.claude/skills/gstack/design/dist/design" ] && D="$_ROOT/.claude/skills/gstack/design/dist/design"
[ -z "$D" ] && D=~/.claude/skills/gstack/design/dist/design [ -z "$D" ] && D="$HOME/.claude/skills/gstack/design/dist/design"
if [ -x "$D" ]; then if [ -x "$D" ]; then
echo "DESIGN_READY: $D" echo "DESIGN_READY: $D"
else else
@@ -640,7 +640,7 @@ else
fi fi
B="" B=""
[ -n "$_ROOT" ] && [ -x "$_ROOT/.claude/skills/gstack/browse/dist/browse" ] && B="$_ROOT/.claude/skills/gstack/browse/dist/browse" [ -n "$_ROOT" ] && [ -x "$_ROOT/.claude/skills/gstack/browse/dist/browse" ] && B="$_ROOT/.claude/skills/gstack/browse/dist/browse"
[ -z "$B" ] && B=~/.claude/skills/gstack/browse/dist/browse [ -z "$B" ] && B="$HOME/.claude/skills/gstack/browse/dist/browse"
if [ -x "$B" ]; then if [ -x "$B" ]; then
echo "BROWSE_READY: $B" echo "BROWSE_READY: $B"
else else

View File

@@ -886,7 +886,7 @@ Only commit if there are changes. Stage all bootstrap files (config, test direct
_ROOT=$(git rev-parse --show-toplevel 2>/dev/null) _ROOT=$(git rev-parse --show-toplevel 2>/dev/null)
D="" D=""
[ -n "$_ROOT" ] && [ -x "$_ROOT/.claude/skills/gstack/design/dist/design" ] && D="$_ROOT/.claude/skills/gstack/design/dist/design" [ -n "$_ROOT" ] && [ -x "$_ROOT/.claude/skills/gstack/design/dist/design" ] && D="$_ROOT/.claude/skills/gstack/design/dist/design"
[ -z "$D" ] && D=~/.claude/skills/gstack/design/dist/design [ -z "$D" ] && D="$HOME/.claude/skills/gstack/design/dist/design"
if [ -x "$D" ]; then if [ -x "$D" ]; then
echo "DESIGN_READY: $D" echo "DESIGN_READY: $D"
else else
@@ -894,7 +894,7 @@ else
fi fi
B="" B=""
[ -n "$_ROOT" ] && [ -x "$_ROOT/.claude/skills/gstack/browse/dist/browse" ] && B="$_ROOT/.claude/skills/gstack/browse/dist/browse" [ -n "$_ROOT" ] && [ -x "$_ROOT/.claude/skills/gstack/browse/dist/browse" ] && B="$_ROOT/.claude/skills/gstack/browse/dist/browse"
[ -z "$B" ] && B=~/.claude/skills/gstack/browse/dist/browse [ -z "$B" ] && B="$HOME/.claude/skills/gstack/browse/dist/browse"
if [ -x "$B" ]; then if [ -x "$B" ]; then
echo "BROWSE_READY: $B" echo "BROWSE_READY: $B"
else else
@@ -931,7 +931,7 @@ If `DESIGN_NOT_AVAILABLE`: skip mockup generation — the fix loop works without
```bash ```bash
eval "$(~/.claude/skills/gstack/bin/gstack-slug 2>/dev/null)" eval "$(~/.claude/skills/gstack/bin/gstack-slug 2>/dev/null)"
REPORT_DIR=~/.gstack/projects/$SLUG/designs/design-audit-$(date +%Y%m%d) REPORT_DIR="$HOME/.gstack/projects/$SLUG/designs/design-audit-$(date +%Y%m%d)"
mkdir -p "$REPORT_DIR/screenshots" mkdir -p "$REPORT_DIR/screenshots"
echo "REPORT_DIR: $REPORT_DIR" echo "REPORT_DIR: $REPORT_DIR"
``` ```

View File

@@ -96,7 +96,7 @@ If `DESIGN_NOT_AVAILABLE`: skip mockup generation — the fix loop works without
```bash ```bash
eval "$(~/.claude/skills/gstack/bin/gstack-slug 2>/dev/null)" eval "$(~/.claude/skills/gstack/bin/gstack-slug 2>/dev/null)"
REPORT_DIR=~/.gstack/projects/$SLUG/designs/design-audit-$(date +%Y%m%d) REPORT_DIR="$HOME/.gstack/projects/$SLUG/designs/design-audit-$(date +%Y%m%d)"
mkdir -p "$REPORT_DIR/screenshots" mkdir -p "$REPORT_DIR/screenshots"
echo "REPORT_DIR: $REPORT_DIR" echo "REPORT_DIR: $REPORT_DIR"
``` ```

View File

@@ -626,7 +626,7 @@ visual brainstorming, not a review process.
_ROOT=$(git rev-parse --show-toplevel 2>/dev/null) _ROOT=$(git rev-parse --show-toplevel 2>/dev/null)
D="" D=""
[ -n "$_ROOT" ] && [ -x "$_ROOT/.claude/skills/gstack/design/dist/design" ] && D="$_ROOT/.claude/skills/gstack/design/dist/design" [ -n "$_ROOT" ] && [ -x "$_ROOT/.claude/skills/gstack/design/dist/design" ] && D="$_ROOT/.claude/skills/gstack/design/dist/design"
[ -z "$D" ] && D=~/.claude/skills/gstack/design/dist/design [ -z "$D" ] && D="$HOME/.claude/skills/gstack/design/dist/design"
if [ -x "$D" ]; then if [ -x "$D" ]; then
echo "DESIGN_READY: $D" echo "DESIGN_READY: $D"
else else
@@ -634,7 +634,7 @@ else
fi fi
B="" B=""
[ -n "$_ROOT" ] && [ -x "$_ROOT/.claude/skills/gstack/browse/dist/browse" ] && B="$_ROOT/.claude/skills/gstack/browse/dist/browse" [ -n "$_ROOT" ] && [ -x "$_ROOT/.claude/skills/gstack/browse/dist/browse" ] && B="$_ROOT/.claude/skills/gstack/browse/dist/browse"
[ -z "$B" ] && B=~/.claude/skills/gstack/browse/dist/browse [ -z "$B" ] && B="$HOME/.claude/skills/gstack/browse/dist/browse"
if [ -x "$B" ]; then if [ -x "$B" ]; then
echo "BROWSE_READY: $B" echo "BROWSE_READY: $B"
else else
@@ -906,7 +906,7 @@ Set up the output directory:
```bash ```bash
eval "$(~/.claude/skills/gstack/bin/gstack-slug 2>/dev/null)" eval "$(~/.claude/skills/gstack/bin/gstack-slug 2>/dev/null)"
_DESIGN_DIR=~/.gstack/projects/$SLUG/designs/<screen-name>-$(date +%Y%m%d) _DESIGN_DIR="$HOME/.gstack/projects/$SLUG/designs/<screen-name>-$(date +%Y%m%d)"
mkdir -p "$_DESIGN_DIR" mkdir -p "$_DESIGN_DIR"
echo "DESIGN_DIR: $_DESIGN_DIR" echo "DESIGN_DIR: $_DESIGN_DIR"
``` ```

View File

@@ -154,7 +154,7 @@ Set up the output directory:
```bash ```bash
eval "$(~/.claude/skills/gstack/bin/gstack-slug 2>/dev/null)" eval "$(~/.claude/skills/gstack/bin/gstack-slug 2>/dev/null)"
_DESIGN_DIR=~/.gstack/projects/$SLUG/designs/<screen-name>-$(date +%Y%m%d) _DESIGN_DIR="$HOME/.gstack/projects/$SLUG/designs/<screen-name>-$(date +%Y%m%d)"
mkdir -p "$_DESIGN_DIR" mkdir -p "$_DESIGN_DIR"
echo "DESIGN_DIR: $_DESIGN_DIR" echo "DESIGN_DIR: $_DESIGN_DIR"
``` ```

View File

@@ -31,9 +31,9 @@ const opencode: HostConfig = {
suppressedResolvers: ['GBRAIN_CONTEXT_LOAD', 'GBRAIN_SAVE_RESULTS'], suppressedResolvers: ['GBRAIN_CONTEXT_LOAD', 'GBRAIN_SAVE_RESULTS'],
runtimeRoot: { runtimeRoot: {
globalSymlinks: ['bin', 'browse/dist', 'browse/bin', 'gstack-upgrade', 'ETHOS.md'], globalSymlinks: ['bin', 'browse/dist', 'browse/bin', 'design/dist', 'gstack-upgrade', 'ETHOS.md', 'review/specialists', 'qa/templates', 'qa/references', 'plan-devex-review/dx-hall-of-fame.md'],
globalFiles: { globalFiles: {
'review': ['checklist.md', 'TODOS-format.md'], 'review': ['checklist.md', 'design-checklist.md', 'greptile-triage.md', 'TODOS-format.md'],
}, },
}, },

View File

@@ -1185,7 +1185,7 @@ Present via AskUserQuestion. Do NOT proceed without user approval of the approac
_ROOT=$(git rev-parse --show-toplevel 2>/dev/null) _ROOT=$(git rev-parse --show-toplevel 2>/dev/null)
D="" D=""
[ -n "$_ROOT" ] && [ -x "$_ROOT/.claude/skills/gstack/design/dist/design" ] && D="$_ROOT/.claude/skills/gstack/design/dist/design" [ -n "$_ROOT" ] && [ -x "$_ROOT/.claude/skills/gstack/design/dist/design" ] && D="$_ROOT/.claude/skills/gstack/design/dist/design"
[ -z "$D" ] && D=~/.claude/skills/gstack/design/dist/design [ -z "$D" ] && D="$HOME/.claude/skills/gstack/design/dist/design"
[ -x "$D" ] && echo "DESIGN_READY" || echo "DESIGN_NOT_AVAILABLE" [ -x "$D" ] && echo "DESIGN_READY" || echo "DESIGN_NOT_AVAILABLE"
``` ```
@@ -1200,7 +1200,7 @@ Generating visual mockups of the proposed design... (say "skip" if you don't nee
```bash ```bash
eval "$(~/.claude/skills/gstack/bin/gstack-slug 2>/dev/null)" eval "$(~/.claude/skills/gstack/bin/gstack-slug 2>/dev/null)"
_DESIGN_DIR=~/.gstack/projects/$SLUG/designs/mockup-$(date +%Y%m%d) _DESIGN_DIR="$HOME/.gstack/projects/$SLUG/designs/mockup-$(date +%Y%m%d)"
mkdir -p "$_DESIGN_DIR" mkdir -p "$_DESIGN_DIR"
echo "DESIGN_DIR: $_DESIGN_DIR" echo "DESIGN_DIR: $_DESIGN_DIR"
``` ```

View File

@@ -1,8 +1,6 @@
--- ---
name: gstack-openclaw-ceo-review name: gstack-openclaw-ceo-review
description: CEO/founder-mode plan review. Rethink the problem, find the 10-star product, challenge premises, expand scope when it creates a better product. Four modes: SCOPE EXPANSION (dream big), SELECTIVE EXPANSION (hold scope + cherry-pick), HOLD SCOPE (maximum rigor), SCOPE REDUCTION (strip to essentials). Use when asked to review a plan, challenge this, CEO review, poke holes, think bigger, or expand scope. description: Use when asked to review a plan, challenge a proposal, run a CEO review, poke holes in an approach, think bigger about scope, or decide whether to expand or reduce the plan.
version: 1.0.0
metadata: { "openclaw": { "emoji": "👑" } }
--- ---
# CEO Plan Review # CEO Plan Review
@@ -129,7 +127,6 @@ Once selected, commit fully. Do not silently drift.
**Anti-skip rule:** Never condense, abbreviate, or skip any review section regardless of plan type. If a section genuinely has zero findings, say "No issues found" and move on, but you must evaluate it. **Anti-skip rule:** Never condense, abbreviate, or skip any review section regardless of plan type. If a section genuinely has zero findings, say "No issues found" and move on, but you must evaluate it.
Ask the user about each issue ONE AT A TIME. Do NOT batch. Ask the user about each issue ONE AT A TIME. Do NOT batch.
**Reminder: Do NOT make any code changes. Review only.**
### Section 1: Architecture Review ### Section 1: Architecture Review
Evaluate system design, component boundaries, data flow (all four paths), state machines, coupling, scaling, security architecture, production failure scenarios, rollback posture. Draw dependency graphs. Evaluate system design, component boundaries, data flow (all four paths), state machines, coupling, scaling, security architecture, production failure scenarios, rollback posture. Draw dependency graphs.

View File

@@ -1,8 +1,6 @@
--- ---
name: gstack-openclaw-investigate name: gstack-openclaw-investigate
description: Systematic debugging with root cause investigation. Four phases: investigate, analyze, hypothesize, implement. Iron Law: no fixes without root cause. Use when asked to debug, fix a bug, investigate an error, or root cause analysis. Proactively use when user reports errors, stack traces, unexpected behavior, or says something stopped working. description: Use when asked to debug, fix a bug, investigate an error, or do root cause analysis, and when users report errors, stack traces, unexpected behavior, or say something stopped working.
version: 1.0.0
metadata: { "openclaw": { "emoji": "🔍" } }
--- ---
# Systematic Debugging # Systematic Debugging

View File

@@ -1,8 +1,6 @@
--- ---
name: gstack-openclaw-office-hours name: gstack-openclaw-office-hours
description: Product interrogation with six forcing questions. Two modes: startup diagnostic (demand reality, status quo, desperate specificity, narrowest wedge, observation, future-fit) and builder brainstorm. Use when asked to brainstorm, "is this worth building", "I have an idea", "office hours", or "help me think through this". Proactively use when user describes a new product idea or wants to think through design decisions before any code is written. description: Use when asked to brainstorm, evaluate whether an idea is worth building, run office hours, or think through a new product idea or design direction before any code is written.
version: 1.0.0
metadata: { "openclaw": { "emoji": "🎯" } }
--- ---
# YC Office Hours # YC Office Hours
@@ -281,8 +279,7 @@ Count the signals for the closing message.
## Phase 5: Design Doc ## Phase 5: Design Doc
Write the design document and save it to memory. After writing, tell the user: Write the design document and save it to memory.
**"Design doc saved. Other skills (/plan-ceo-review, /plan-eng-review) will find it automatically."**
### Startup mode design doc template: ### Startup mode design doc template:

View File

@@ -1,8 +1,6 @@
--- ---
name: gstack-openclaw-retro name: gstack-openclaw-retro
description: Weekly engineering retrospective. Analyzes commit history, work patterns, and code quality metrics with persistent history and trend tracking. Team-aware with per-person contributions, praise, and growth areas. Use when asked for weekly retro, what shipped this week, or engineering retrospective. description: "Weekly engineering retrospective. Analyzes commit history, work patterns, and code quality metrics with persistent history and trend tracking. Team-aware with per-person contributions, praise, and growth areas. Use when asked for weekly retro, what shipped this week, or engineering retrospective."
version: 1.0.0
metadata: { "openclaw": { "emoji": "📊" } }
--- ---
# Weekly Engineering Retrospective # Weekly Engineering Retrospective
@@ -25,11 +23,6 @@ Parse the argument to determine the time window. Default to 7 days. All times sh
--- ---
### Non-git context (optional)
Check memory for non-git context: meeting notes, calendar events, decisions, and other
context that doesn't appear in git history. If found, incorporate into the retro narrative.
### Step 1: Gather Raw Data ### Step 1: Gather Raw Data
First, fetch origin and identify the current user: First, fetch origin and identify the current user:

View File

@@ -869,7 +869,7 @@ Report findings before proceeding to Step 0.
_ROOT=$(git rev-parse --show-toplevel 2>/dev/null) _ROOT=$(git rev-parse --show-toplevel 2>/dev/null)
D="" D=""
[ -n "$_ROOT" ] && [ -x "$_ROOT/.claude/skills/gstack/design/dist/design" ] && D="$_ROOT/.claude/skills/gstack/design/dist/design" [ -n "$_ROOT" ] && [ -x "$_ROOT/.claude/skills/gstack/design/dist/design" ] && D="$_ROOT/.claude/skills/gstack/design/dist/design"
[ -z "$D" ] && D=~/.claude/skills/gstack/design/dist/design [ -z "$D" ] && D="$HOME/.claude/skills/gstack/design/dist/design"
if [ -x "$D" ]; then if [ -x "$D" ]; then
echo "DESIGN_READY: $D" echo "DESIGN_READY: $D"
else else
@@ -877,7 +877,7 @@ else
fi fi
B="" B=""
[ -n "$_ROOT" ] && [ -x "$_ROOT/.claude/skills/gstack/browse/dist/browse" ] && B="$_ROOT/.claude/skills/gstack/browse/dist/browse" [ -n "$_ROOT" ] && [ -x "$_ROOT/.claude/skills/gstack/browse/dist/browse" ] && B="$_ROOT/.claude/skills/gstack/browse/dist/browse"
[ -z "$B" ] && B=~/.claude/skills/gstack/browse/dist/browse [ -z "$B" ] && B="$HOME/.claude/skills/gstack/browse/dist/browse"
if [ -x "$B" ]; then if [ -x "$B" ]; then
echo "BROWSE_READY: $B" echo "BROWSE_READY: $B"
else else
@@ -957,7 +957,7 @@ First, set up the output directory. Name it after the screen/feature being desig
```bash ```bash
eval "$(~/.claude/skills/gstack/bin/gstack-slug 2>/dev/null)" eval "$(~/.claude/skills/gstack/bin/gstack-slug 2>/dev/null)"
_DESIGN_DIR=~/.gstack/projects/$SLUG/designs/<screen-name>-$(date +%Y%m%d) _DESIGN_DIR="$HOME/.gstack/projects/$SLUG/designs/<screen-name>-$(date +%Y%m%d)"
mkdir -p "$_DESIGN_DIR" mkdir -p "$_DESIGN_DIR"
echo "DESIGN_DIR: $_DESIGN_DIR" echo "DESIGN_DIR: $_DESIGN_DIR"
``` ```

View File

@@ -188,7 +188,7 @@ First, set up the output directory. Name it after the screen/feature being desig
```bash ```bash
eval "$(~/.claude/skills/gstack/bin/gstack-slug 2>/dev/null)" eval "$(~/.claude/skills/gstack/bin/gstack-slug 2>/dev/null)"
_DESIGN_DIR=~/.gstack/projects/$SLUG/designs/<screen-name>-$(date +%Y%m%d) _DESIGN_DIR="$HOME/.gstack/projects/$SLUG/designs/<screen-name>-$(date +%Y%m%d)"
mkdir -p "$_DESIGN_DIR" mkdir -p "$_DESIGN_DIR"
echo "DESIGN_DIR: $_DESIGN_DIR" echo "DESIGN_DIR: $_DESIGN_DIR"
``` ```

View File

@@ -792,7 +792,7 @@ export function generateDesignSetup(ctx: TemplateContext): string {
_ROOT=$(git rev-parse --show-toplevel 2>/dev/null) _ROOT=$(git rev-parse --show-toplevel 2>/dev/null)
D="" D=""
[ -n "$_ROOT" ] && [ -x "$_ROOT/${ctx.paths.localSkillRoot}/design/dist/design" ] && D="$_ROOT/${ctx.paths.localSkillRoot}/design/dist/design" [ -n "$_ROOT" ] && [ -x "$_ROOT/${ctx.paths.localSkillRoot}/design/dist/design" ] && D="$_ROOT/${ctx.paths.localSkillRoot}/design/dist/design"
[ -z "$D" ] && D=${ctx.paths.designDir}/design [ -z "$D" ] && D="$HOME${ctx.paths.designDir.replace(/^~/, '')}/design"
if [ -x "$D" ]; then if [ -x "$D" ]; then
echo "DESIGN_READY: $D" echo "DESIGN_READY: $D"
else else
@@ -800,7 +800,7 @@ else
fi fi
B="" B=""
[ -n "$_ROOT" ] && [ -x "$_ROOT/${ctx.paths.localSkillRoot}/browse/dist/browse" ] && B="$_ROOT/${ctx.paths.localSkillRoot}/browse/dist/browse" [ -n "$_ROOT" ] && [ -x "$_ROOT/${ctx.paths.localSkillRoot}/browse/dist/browse" ] && B="$_ROOT/${ctx.paths.localSkillRoot}/browse/dist/browse"
[ -z "$B" ] && B=${ctx.paths.browseDir}/browse [ -z "$B" ] && B="$HOME${ctx.paths.browseDir.replace(/^~/, '')}/browse"
if [ -x "$B" ]; then if [ -x "$B" ]; then
echo "BROWSE_READY: $B" echo "BROWSE_READY: $B"
else else
@@ -837,7 +837,7 @@ export function generateDesignMockup(ctx: TemplateContext): string {
_ROOT=$(git rev-parse --show-toplevel 2>/dev/null) _ROOT=$(git rev-parse --show-toplevel 2>/dev/null)
D="" D=""
[ -n "$_ROOT" ] && [ -x "$_ROOT/${ctx.paths.localSkillRoot}/design/dist/design" ] && D="$_ROOT/${ctx.paths.localSkillRoot}/design/dist/design" [ -n "$_ROOT" ] && [ -x "$_ROOT/${ctx.paths.localSkillRoot}/design/dist/design" ] && D="$_ROOT/${ctx.paths.localSkillRoot}/design/dist/design"
[ -z "$D" ] && D=${ctx.paths.designDir}/design [ -z "$D" ] && D="$HOME${ctx.paths.designDir.replace(/^~/, '')}/design"
[ -x "$D" ] && echo "DESIGN_READY" || echo "DESIGN_NOT_AVAILABLE" [ -x "$D" ] && echo "DESIGN_READY" || echo "DESIGN_NOT_AVAILABLE"
\`\`\` \`\`\`
@@ -852,7 +852,7 @@ Generating visual mockups of the proposed design... (say "skip" if you don't nee
\`\`\`bash \`\`\`bash
eval "$(~/.claude/skills/gstack/bin/gstack-slug 2>/dev/null)" eval "$(~/.claude/skills/gstack/bin/gstack-slug 2>/dev/null)"
_DESIGN_DIR=~/.gstack/projects/$SLUG/designs/mockup-$(date +%Y%m%d) _DESIGN_DIR="$HOME/.gstack/projects/$SLUG/designs/mockup-$(date +%Y%m%d)"
mkdir -p "$_DESIGN_DIR" mkdir -p "$_DESIGN_DIR"
echo "DESIGN_DIR: $_DESIGN_DIR" echo "DESIGN_DIR: $_DESIGN_DIR"
\`\`\` \`\`\`

119
setup
View File

@@ -22,6 +22,8 @@ CODEX_SKILLS="$HOME/.codex/skills"
CODEX_GSTACK="$CODEX_SKILLS/gstack" CODEX_GSTACK="$CODEX_SKILLS/gstack"
FACTORY_SKILLS="$HOME/.factory/skills" FACTORY_SKILLS="$HOME/.factory/skills"
FACTORY_GSTACK="$FACTORY_SKILLS/gstack" FACTORY_GSTACK="$FACTORY_SKILLS/gstack"
OPENCODE_SKILLS="$HOME/.config/opencode/skills"
OPENCODE_GSTACK="$OPENCODE_SKILLS/gstack"
IS_WINDOWS=0 IS_WINDOWS=0
case "$(uname -s)" in case "$(uname -s)" in
@@ -41,7 +43,7 @@ TEAM_MODE=0
NO_TEAM_MODE=0 NO_TEAM_MODE=0
while [ $# -gt 0 ]; do while [ $# -gt 0 ]; do
case "$1" in case "$1" in
--host) [ -z "$2" ] && echo "Missing value for --host (expected claude, codex, kiro, or auto)" >&2 && exit 1; HOST="$2"; shift 2 ;; --host) [ -z "$2" ] && echo "Missing value for --host (expected claude, codex, kiro, factory, opencode, openclaw, hermes, gbrain, or auto)" >&2 && exit 1; HOST="$2"; shift 2 ;;
--host=*) HOST="${1#--host=}"; shift ;; --host=*) HOST="${1#--host=}"; shift ;;
--local) LOCAL_INSTALL=1; shift ;; --local) LOCAL_INSTALL=1; shift ;;
--prefix) SKILL_PREFIX=1; SKILL_PREFIX_FLAG=1; shift ;; --prefix) SKILL_PREFIX=1; SKILL_PREFIX_FLAG=1; shift ;;
@@ -54,7 +56,7 @@ while [ $# -gt 0 ]; do
done done
case "$HOST" in case "$HOST" in
claude|codex|kiro|factory|auto) ;; claude|codex|kiro|factory|opencode|auto) ;;
openclaw) openclaw)
echo "" echo ""
echo "OpenClaw integration uses a different model — OpenClaw spawns Claude Code" echo "OpenClaw integration uses a different model — OpenClaw spawns Claude Code"
@@ -89,7 +91,7 @@ case "$HOST" in
echo "GBrain setup and brain skills ship from the GBrain repo." echo "GBrain setup and brain skills ship from the GBrain repo."
echo "" echo ""
exit 0 ;; exit 0 ;;
*) echo "Unknown --host value: $HOST (expected claude, codex, kiro, factory, openclaw, hermes, gbrain, or auto)" >&2; exit 1 ;; *) echo "Unknown --host value: $HOST (expected claude, codex, kiro, factory, opencode, openclaw, hermes, gbrain, or auto)" >&2; exit 1 ;;
esac esac
# ─── Resolve skill prefix preference ───────────────────────── # ─── Resolve skill prefix preference ─────────────────────────
@@ -152,13 +154,15 @@ INSTALL_CLAUDE=0
INSTALL_CODEX=0 INSTALL_CODEX=0
INSTALL_KIRO=0 INSTALL_KIRO=0
INSTALL_FACTORY=0 INSTALL_FACTORY=0
INSTALL_OPENCODE=0
if [ "$HOST" = "auto" ]; then if [ "$HOST" = "auto" ]; then
command -v claude >/dev/null 2>&1 && INSTALL_CLAUDE=1 command -v claude >/dev/null 2>&1 && INSTALL_CLAUDE=1
command -v codex >/dev/null 2>&1 && INSTALL_CODEX=1 command -v codex >/dev/null 2>&1 && INSTALL_CODEX=1
command -v kiro-cli >/dev/null 2>&1 && INSTALL_KIRO=1 command -v kiro-cli >/dev/null 2>&1 && INSTALL_KIRO=1
command -v droid >/dev/null 2>&1 && INSTALL_FACTORY=1 command -v droid >/dev/null 2>&1 && INSTALL_FACTORY=1
command -v opencode >/dev/null 2>&1 && INSTALL_OPENCODE=1
# If none found, default to claude # If none found, default to claude
if [ "$INSTALL_CLAUDE" -eq 0 ] && [ "$INSTALL_CODEX" -eq 0 ] && [ "$INSTALL_KIRO" -eq 0 ] && [ "$INSTALL_FACTORY" -eq 0 ]; then if [ "$INSTALL_CLAUDE" -eq 0 ] && [ "$INSTALL_CODEX" -eq 0 ] && [ "$INSTALL_KIRO" -eq 0 ] && [ "$INSTALL_FACTORY" -eq 0 ] && [ "$INSTALL_OPENCODE" -eq 0 ]; then
INSTALL_CLAUDE=1 INSTALL_CLAUDE=1
fi fi
elif [ "$HOST" = "claude" ]; then elif [ "$HOST" = "claude" ]; then
@@ -169,6 +173,8 @@ elif [ "$HOST" = "kiro" ]; then
INSTALL_KIRO=1 INSTALL_KIRO=1
elif [ "$HOST" = "factory" ]; then elif [ "$HOST" = "factory" ]; then
INSTALL_FACTORY=1 INSTALL_FACTORY=1
elif [ "$HOST" = "opencode" ]; then
INSTALL_OPENCODE=1
fi fi
migrate_direct_codex_install() { migrate_direct_codex_install() {
@@ -271,6 +277,16 @@ if [ "$INSTALL_FACTORY" -eq 1 ] && [ "$NEEDS_BUILD" -eq 0 ]; then
) )
fi fi
# 1d. Generate .opencode/ OpenCode skill docs
if [ "$INSTALL_OPENCODE" -eq 1 ] && [ "$NEEDS_BUILD" -eq 0 ]; then
log "Generating .opencode/ skill docs..."
(
cd "$SOURCE_GSTACK_DIR"
bun install --frozen-lockfile 2>/dev/null || bun install
bun run gen:skill-docs --host opencode
)
fi
# 2. Ensure Playwright's Chromium is available # 2. Ensure Playwright's Chromium is available
if ! ensure_playwright_browser; then if ! ensure_playwright_browser; then
echo "Installing Playwright Chromium..." echo "Installing Playwright Chromium..."
@@ -596,6 +612,59 @@ create_factory_runtime_root() {
fi fi
} }
create_opencode_runtime_root() {
local gstack_dir="$1"
local opencode_gstack="$2"
local opencode_dir="$gstack_dir/.opencode/skills"
if [ -L "$opencode_gstack" ]; then
rm -f "$opencode_gstack"
elif [ -d "$opencode_gstack" ] && [ "$opencode_gstack" != "$gstack_dir" ]; then
rm -rf "$opencode_gstack"
fi
mkdir -p "$opencode_gstack" "$opencode_gstack/browse" "$opencode_gstack/design" "$opencode_gstack/gstack-upgrade" "$opencode_gstack/review" "$opencode_gstack/qa" "$opencode_gstack/plan-devex-review"
if [ -f "$opencode_dir/gstack/SKILL.md" ]; then
ln -snf "$opencode_dir/gstack/SKILL.md" "$opencode_gstack/SKILL.md"
fi
if [ -d "$gstack_dir/bin" ]; then
ln -snf "$gstack_dir/bin" "$opencode_gstack/bin"
fi
if [ -d "$gstack_dir/browse/dist" ]; then
ln -snf "$gstack_dir/browse/dist" "$opencode_gstack/browse/dist"
fi
if [ -d "$gstack_dir/browse/bin" ]; then
ln -snf "$gstack_dir/browse/bin" "$opencode_gstack/browse/bin"
fi
if [ -d "$gstack_dir/design/dist" ]; then
ln -snf "$gstack_dir/design/dist" "$opencode_gstack/design/dist"
fi
if [ -f "$opencode_dir/gstack-upgrade/SKILL.md" ]; then
ln -snf "$opencode_dir/gstack-upgrade/SKILL.md" "$opencode_gstack/gstack-upgrade/SKILL.md"
fi
for f in checklist.md design-checklist.md greptile-triage.md TODOS-format.md; do
if [ -f "$gstack_dir/review/$f" ]; then
ln -snf "$gstack_dir/review/$f" "$opencode_gstack/review/$f"
fi
done
if [ -d "$gstack_dir/review/specialists" ]; then
ln -snf "$gstack_dir/review/specialists" "$opencode_gstack/review/specialists"
fi
if [ -d "$gstack_dir/qa/templates" ]; then
ln -snf "$gstack_dir/qa/templates" "$opencode_gstack/qa/templates"
fi
if [ -d "$gstack_dir/qa/references" ]; then
ln -snf "$gstack_dir/qa/references" "$opencode_gstack/qa/references"
fi
if [ -f "$gstack_dir/plan-devex-review/dx-hall-of-fame.md" ]; then
ln -snf "$gstack_dir/plan-devex-review/dx-hall-of-fame.md" "$opencode_gstack/plan-devex-review/dx-hall-of-fame.md"
fi
if [ -f "$gstack_dir/ETHOS.md" ]; then
ln -snf "$gstack_dir/ETHOS.md" "$opencode_gstack/ETHOS.md"
fi
}
link_factory_skill_dirs() { link_factory_skill_dirs() {
local gstack_dir="$1" local gstack_dir="$1"
local skills_dir="$2" local skills_dir="$2"
@@ -628,6 +697,38 @@ link_factory_skill_dirs() {
fi fi
} }
link_opencode_skill_dirs() {
local gstack_dir="$1"
local skills_dir="$2"
local opencode_dir="$gstack_dir/.opencode/skills"
local linked=()
if [ ! -d "$opencode_dir" ]; then
echo " Generating .opencode/ skill docs..."
( cd "$gstack_dir" && bun run gen:skill-docs --host opencode )
fi
if [ ! -d "$opencode_dir" ]; then
echo " warning: .opencode/skills/ generation failed — run 'bun run gen:skill-docs --host opencode' manually" >&2
return 1
fi
for skill_dir in "$opencode_dir"/gstack*/; do
if [ -f "$skill_dir/SKILL.md" ]; then
skill_name="$(basename "$skill_dir")"
[ "$skill_name" = "gstack" ] && continue
target="$skills_dir/$skill_name"
if [ -L "$target" ] || [ ! -e "$target" ]; then
ln -snf "$skill_dir" "$target"
linked+=("$skill_name")
fi
fi
done
if [ ${#linked[@]} -gt 0 ]; then
echo " linked skills: ${linked[*]}"
fi
}
# 4. Install for Claude (default) # 4. Install for Claude (default)
SKILLS_BASENAME="$(basename "$INSTALL_SKILLS_DIR")" SKILLS_BASENAME="$(basename "$INSTALL_SKILLS_DIR")"
SKILLS_PARENT_BASENAME="$(basename "$(dirname "$INSTALL_SKILLS_DIR")")" SKILLS_PARENT_BASENAME="$(basename "$(dirname "$INSTALL_SKILLS_DIR")")"
@@ -790,6 +891,16 @@ if [ "$INSTALL_FACTORY" -eq 1 ]; then
echo " factory skills: $FACTORY_SKILLS" echo " factory skills: $FACTORY_SKILLS"
fi fi
# 6c. Install for OpenCode
if [ "$INSTALL_OPENCODE" -eq 1 ]; then
mkdir -p "$OPENCODE_SKILLS"
create_opencode_runtime_root "$SOURCE_GSTACK_DIR" "$OPENCODE_GSTACK"
link_opencode_skill_dirs "$SOURCE_GSTACK_DIR" "$OPENCODE_SKILLS"
echo "gstack ready (opencode)."
echo " browse: $BROWSE_BIN"
echo " opencode skills: $OPENCODE_SKILLS"
fi
# 7. Create .agents/ sidecar symlinks for the real Codex skill target. # 7. Create .agents/ sidecar symlinks for the real Codex skill target.
# The root Codex skill ends up pointing at $SOURCE_GSTACK_DIR/.agents/skills/gstack, # The root Codex skill ends up pointing at $SOURCE_GSTACK_DIR/.agents/skills/gstack,
# so the runtime assets must live there for both global and repo-local installs. # so the runtime assets must live there for both global and repo-local installs.

View File

@@ -2122,15 +2122,16 @@ describe('setup script validation', () => {
expect(fnBody).toContain('rm -f "$target"'); expect(fnBody).toContain('rm -f "$target"');
}); });
test('setup supports --host auto|claude|codex|kiro', () => { test('setup supports --host auto|claude|codex|kiro|opencode', () => {
expect(setupContent).toContain('--host'); expect(setupContent).toContain('--host');
expect(setupContent).toContain('claude|codex|kiro|factory|auto'); expect(setupContent).toContain('claude|codex|kiro|factory|opencode|auto');
}); });
test('auto mode detects claude, codex, and kiro binaries', () => { test('auto mode detects claude, codex, kiro, and opencode binaries', () => {
expect(setupContent).toContain('command -v claude'); expect(setupContent).toContain('command -v claude');
expect(setupContent).toContain('command -v codex'); expect(setupContent).toContain('command -v codex');
expect(setupContent).toContain('command -v kiro-cli'); expect(setupContent).toContain('command -v kiro-cli');
expect(setupContent).toContain('command -v opencode');
}); });
// T1: Sidecar skip guard — prevents .agents/skills/gstack from being linked as a skill // T1: Sidecar skip guard — prevents .agents/skills/gstack from being linked as a skill
@@ -2150,7 +2151,6 @@ describe('setup script validation', () => {
expect(content).toContain('$GSTACK_BIN/'); expect(content).toContain('$GSTACK_BIN/');
}); });
// T3: Kiro host support in setup script
test('setup supports --host kiro with install section and sed rewrites', () => { test('setup supports --host kiro with install section and sed rewrites', () => {
expect(setupContent).toContain('INSTALL_KIRO='); expect(setupContent).toContain('INSTALL_KIRO=');
expect(setupContent).toContain('kiro-cli'); expect(setupContent).toContain('kiro-cli');
@@ -2158,6 +2158,21 @@ describe('setup script validation', () => {
expect(setupContent).toContain('~/.kiro/skills/gstack'); expect(setupContent).toContain('~/.kiro/skills/gstack');
}); });
test('setup supports --host opencode with install section and OpenCode skill path vars', () => {
expect(setupContent).toContain('INSTALL_OPENCODE=');
expect(setupContent).toContain('OPENCODE_SKILLS="$HOME/.config/opencode/skills"');
expect(setupContent).toContain('OPENCODE_GSTACK="$OPENCODE_SKILLS/gstack"');
});
test('setup installs OpenCode skills into a nested gstack runtime root', () => {
expect(setupContent).toContain('create_opencode_runtime_root');
expect(setupContent).toContain('.opencode/skills');
expect(setupContent).toContain('review/specialists');
expect(setupContent).toContain('qa/templates');
expect(setupContent).toContain('qa/references');
expect(setupContent).toContain('dx-hall-of-fame.md');
});
test('create_agents_sidecar links runtime assets', () => { test('create_agents_sidecar links runtime assets', () => {
// Sidecar must link bin, browse, review, qa // Sidecar must link bin, browse, review, qa
const fnStart = setupContent.indexOf('create_agents_sidecar()'); const fnStart = setupContent.indexOf('create_agents_sidecar()');

View File

@@ -354,6 +354,21 @@ describe('host-config-export.ts CLI', () => {
expect(lines).toContain('review/checklist.md'); expect(lines).toContain('review/checklist.md');
}); });
test('opencode symlinks returns nested runtime assets', () => {
const { stdout, exitCode } = run('symlinks', 'opencode');
expect(exitCode).toBe(0);
const lines = stdout.split('\n');
expect(lines).toContain('bin');
expect(lines).toContain('browse/dist');
expect(lines).toContain('browse/bin');
expect(lines).toContain('review/design-checklist.md');
expect(lines).toContain('review/greptile-triage.md');
expect(lines).toContain('review/specialists');
expect(lines).toContain('qa/templates');
expect(lines).toContain('qa/references');
expect(lines).toContain('plan-devex-review/dx-hall-of-fame.md');
});
test('symlinks with missing host exits 1', () => { test('symlinks with missing host exits 1', () => {
const { exitCode } = run('symlinks'); const { exitCode } = run('symlinks');
expect(exitCode).toBe(1); expect(exitCode).toBe(1);

View File

@@ -0,0 +1,35 @@
import { describe, test, expect } from 'bun:test';
import * as fs from 'fs';
import * as path from 'path';
const ROOT = path.resolve(import.meta.dir, '..');
const OPENCLAW_NATIVE_SKILLS = [
'openclaw/skills/gstack-openclaw-investigate/SKILL.md',
'openclaw/skills/gstack-openclaw-office-hours/SKILL.md',
'openclaw/skills/gstack-openclaw-ceo-review/SKILL.md',
'openclaw/skills/gstack-openclaw-retro/SKILL.md',
];
function extractFrontmatter(content: string): string {
expect(content.startsWith('---\n')).toBe(true);
const fmEnd = content.indexOf('\n---', 4);
expect(fmEnd).toBeGreaterThan(0);
return content.slice(4, fmEnd);
}
describe('OpenClaw native skills', () => {
test('frontmatter parses as YAML and keeps only name + description', () => {
for (const skill of OPENCLAW_NATIVE_SKILLS) {
const content = fs.readFileSync(path.join(ROOT, skill), 'utf-8');
const frontmatter = extractFrontmatter(content);
const parsed = Bun.YAML.parse(frontmatter) as Record<string, unknown>;
expect(Object.keys(parsed).sort()).toEqual(['description', 'name']);
expect(typeof parsed.name).toBe('string');
expect(typeof parsed.description).toBe('string');
expect((parsed.name as string).length).toBeGreaterThan(0);
expect((parsed.description as string).length).toBeGreaterThan(0);
}
});
});