-
Notifications
You must be signed in to change notification settings - Fork 328
fix(zcode): launch hooks via hidden wscript VBS — no console flash, non-blocking #596
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,6 +1,6 @@ | ||
| import path from 'node:path'; | ||
| import { realpathSync } from 'node:fs'; | ||
| import { readJson, writeJson, expandHome, ensureDir, pathExists } from './utils/fs.js'; | ||
| import { rmSync, realpathSync } from 'node:fs'; | ||
| import { readJson, writeJson, readFileSafe, writeFile, expandHome, ensureDir, pathExists } from './utils/fs.js'; | ||
| import { log } from './utils/logger.js'; | ||
| import { TEAMAI_HOOK_DESCRIPTION_PREFIX, TEAMAI_CUSTOM_HOOK_PREFIX, TEAMAI_AGENT_HOOK_PREFIX, resolveHookScope, resolveLegacyProjectHookScope } from './types.js'; | ||
| import type { HookDef, TeamaiConfig, LocalConfig } from './types.js'; | ||
|
|
@@ -303,7 +303,7 @@ function toCodexEntry(def: HookDef): CodexHookMatcher { | |
| return entry; | ||
| } | ||
|
|
||
| function toZcodeEntry(def: HookDef): ZcodeHookMatcher { | ||
| function toZcodeEntry(def: HookDef, vbsPath: string): ZcodeHookMatcher { | ||
| // ZCode sessions run hooks inline: a session-start dispatch carries a network | ||
| // pull (SSH to the team host), which on slower links exceeds the 10–15s | ||
| // builtin defaults and gets killed mid-pull — so the timeouts here are | ||
|
|
@@ -314,34 +314,27 @@ function toZcodeEntry(def: HookDef): ZcodeHookMatcher { | |
| PostToolUse: 30000, | ||
| UserPromptSubmit: 60000, | ||
| }; | ||
| // wscript.exe is a GUI-subsystem binary: unlike cmd/bash it never allocates | ||
| // a console window, so hook runs don't flash a black box over the desktop. | ||
| // The VBS launcher preserves the STDIN contract (ZCode's payload reaches | ||
| // hook-dispatch via a spooled temp file), waits for the dispatch bounded by | ||
| // the per-event timeout, and runs everything hidden (window style 0) with | ||
| // the dispatch tail cmd-level quoted so team-declared commands survive | ||
| // cmd's operator parsing. The payload travels verbatim as a single argument | ||
| // so managed-entry detection and the manifest keep one command | ||
| // representation. | ||
| // The table is ZCode's DEFAULT, not an override: a timeout the team stated in | ||
| // hooks.yaml (per-hook `timeout`, or `builtin.overrides.<key>.timeout`) is the | ||
| // one the user asked for and still wins, as it does on every other tool. | ||
| // `def.timeout` is in seconds; ZCode entries are in milliseconds. | ||
| const timeoutMs = | ||
| def.timeout !== undefined ? def.timeout * 1000 : ZCODE_TIMEOUT_MS[def.event] ?? 60000; | ||
| const entry: ZcodeHookEntry = | ||
| process.platform === 'win32' | ||
| ? { | ||
| // Windows must NOT spawn bare `bash`: CreateProcess resolves it to | ||
| // System32's WSL launcher before any PATH directory, and the WSL side | ||
| // has a different $HOME (no ~/.teamai state) and often no Node ≥ 20. | ||
| // cmd.exe is always present in System32 and resolves teamai from the | ||
| // Windows PATH (the npm shim is a .cmd, so a shell is required). | ||
| type: 'process', | ||
| command: 'cmd', | ||
| args: ['/c', def.command], | ||
| timeoutMs, | ||
| } | ||
| : { | ||
| type: 'process', | ||
| command: 'bash', | ||
| // Stored verbatim: the shell payload must equal `def.command` exactly | ||
| // so managed-entry detection and the managed-hooks manifest share one | ||
| // command representation (the same invariant the Codex format keeps). | ||
| args: ['-lc', def.command], | ||
| timeoutMs, | ||
| }; | ||
| const entry: ZcodeHookEntry = { | ||
| type: 'process', | ||
| command: 'wscript.exe', | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This writes On macOS/Linux the next reconcile will swap the existing The Ubuntu and macOS CI jobs don't catch this because the test was changed to assert |
||
| args: [vbsPath, def.command], | ||
| timeoutMs, | ||
| }; | ||
| const group: ZcodeHookMatcher = { hooks: [entry] }; | ||
| // ZCode's matcher is a case-sensitive regex on the match value; '*' is an | ||
| // invalid pattern that would never match. Omitted matcher matches everything. | ||
|
|
@@ -352,8 +345,9 @@ function toZcodeEntry(def: HookDef): ZcodeHookMatcher { | |
| /** Shell payload of a ZCode hook entry, for managed-entry matching. */ | ||
| function zcodeEntryCommand(entry: ZcodeHookMatcher): string { | ||
| const hook = entry.hooks?.[0]; | ||
| // Both variants (posix bash -lc / win32 cmd /c) carry the payload at args[1]. | ||
| if (Array.isArray(hook?.args) && hook.args.length > 1) return hook.args[1] ?? ''; | ||
| // The wscript launcher's argv: [vbsPath, mode, payload] — the payload is the | ||
| // dispatch tail ('teamai hook-dispatch <event> --tool <tool>'). | ||
| if (Array.isArray(hook?.args) && hook.args.length > 2) return hook.args[2] ?? ''; | ||
| return hook?.command ?? ''; | ||
| } | ||
|
|
||
|
|
@@ -568,6 +562,33 @@ async function reconcileZcodeFormat( | |
| ): Promise<void> { | ||
| const expanded = expandHome(settingsPath); | ||
| await ensureDir(path.dirname(expanded)); | ||
| const vbsPath = path.join(path.dirname(expanded), 'teamai-hook-dispatch.vbs'); | ||
| // Hidden launcher: wscript.exe is a GUI-subsystem binary, so hook runs don't | ||
| // flash a black box over the desktop, and the spool file keeps the STDIN | ||
| // payload contract intact (ZCode's JSON reaches hook-dispatch even though | ||
| // WScript.Shell.Run cannot forward a live stdin pipe). | ||
| const vbsScript = [ | ||
| "' TeamAI hook dispatcher - hidden, timeout-bounded, stdin-preserving.", | ||
| 'Option Explicit', | ||
| 'Dim sh, fso, spool, f', | ||
| 'Set sh = CreateObject("WScript.Shell")', | ||
| 'Set fso = CreateObject("Scripting.FileSystemObject")', | ||
| 'spool = fso.GetSpecialFolder(2) & "\\teamai-hook-" & fso.GetTempName', | ||
| 'Set f = fso.CreateTextFile(spool, True)', | ||
| 'On Error Resume Next', | ||
| 'f.Write WScript.StdIn.ReadAll()', | ||
| 'f.Close', | ||
| 'sh.Run "cmd /d /s /c ""teamai hook-dispatch " & WScript.Arguments(0) & " < """ & spool & """ >nul 2>&1""", 0, True', | ||
| 'fso.DeleteFile spool, True', | ||
| ].join('\r\n'); | ||
| const existingVbs = await readFileSafe(vbsPath); | ||
| if (existingVbs !== vbsScript) { | ||
| if (opts.removeAll) { | ||
| await rmSync(vbsPath, { force: true }); | ||
| } else { | ||
| await writeFile(vbsPath, vbsScript); | ||
| } | ||
| } | ||
| const cfg: ZcodeHooksJson = (await readJson<ZcodeHooksJson>(expanded)) ?? {}; | ||
| if (!cfg.hooks) cfg.hooks = {}; | ||
| let changed = false; | ||
|
|
@@ -607,7 +628,7 @@ async function reconcileZcodeFormat( | |
| for (const event of events) { | ||
| const existing = eventsMap[event] ?? []; | ||
| const untouched = existing.filter((e) => !isManaged(e)); | ||
| const desiredEntries = defs.filter((d) => d.event === event).map(toZcodeEntry); | ||
| const desiredEntries = defs.filter((d) => d.event === event).map((d) => toZcodeEntry(d, vbsPath)); | ||
| const newArr = [...untouched, ...desiredEntries]; | ||
| if (JSON.stringify(existing) !== JSON.stringify(newArr)) { | ||
| eventsMap[event] = newArr; | ||
|
|
@@ -902,11 +923,12 @@ export async function getHookStatus(settingsPath: string, tool?: string): Promis | |
| } | ||
|
|
||
| if (format === 'zcode') { | ||
| const vbsPath = path.join(path.dirname(expanded), 'teamai-hook-dispatch.vbs'); | ||
| const cfg = await readJson<ZcodeHooksJson>(expanded); | ||
| const eventsMap = cfg?.hooks?.events; | ||
| if (!eventsMap) return 'missing'; | ||
| const present = defs.every((def) => { | ||
| const want = toZcodeEntry(def); | ||
| const want = toZcodeEntry(def, vbsPath); | ||
| const wantCmd = zcodeEntryCommand(want); | ||
| const entries = eventsMap[def.event] ?? []; | ||
| return entries.some((e) => e.matcher === want.matcher && zcodeEntryCommand(e) === wantCmd); | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
With the platform conditional removed here, the macOS and Ubuntu CI jobs no longer assert anything about the POSIX entry shape — which is why the regression above passes CI. If the
win32branch comes back intoZcodeEntry, this assertion should branch with it.The PR's Validation section also mentions a "VBS launcher existence" assertion, but I don't see one in the diff; a check that
teamai-hook-dispatch.vbsis written next toconfig.json(and removed byremoveAll) would be a good addition.