oh-my-copilot 5.6.2 → 5.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (127) hide show
  1. package/.claude-plugin/marketplace.json +2 -2
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/README.md +1 -0
  4. package/bridge/__pycache__/gyoshu_bridge.cpython-312.pyc +0 -0
  5. package/bridge/claude-md-coordinator.cjs +2 -2
  6. package/bridge/cli.cjs +78578 -75769
  7. package/bridge/mcp-server.cjs +36510 -32259
  8. package/bridge/runtime-cli.cjs +95 -34
  9. package/bridge/team-mcp.cjs +95 -34
  10. package/bridge/team.js +131 -70
  11. package/dist/__tests__/run-cjs-fail-closed-timeout.test.d.ts +2 -0
  12. package/dist/__tests__/run-cjs-fail-closed-timeout.test.d.ts.map +1 -0
  13. package/dist/__tests__/run-cjs-fail-closed-timeout.test.js +68 -0
  14. package/dist/__tests__/run-cjs-fail-closed-timeout.test.js.map +1 -0
  15. package/dist/__tests__/session-end-process-exit.test.js +70 -2
  16. package/dist/__tests__/session-end-process-exit.test.js.map +1 -1
  17. package/dist/cli/commands/__tests__/smoke-tier2.test.d.ts +2 -0
  18. package/dist/cli/commands/__tests__/smoke-tier2.test.d.ts.map +1 -0
  19. package/dist/cli/commands/__tests__/smoke-tier2.test.js +72 -0
  20. package/dist/cli/commands/__tests__/smoke-tier2.test.js.map +1 -0
  21. package/dist/cli/commands/smoke.d.ts +36 -0
  22. package/dist/cli/commands/smoke.d.ts.map +1 -0
  23. package/dist/cli/commands/smoke.js +169 -0
  24. package/dist/cli/commands/smoke.js.map +1 -0
  25. package/dist/cli/index.d.ts.map +1 -1
  26. package/dist/cli/index.js +39 -0
  27. package/dist/cli/index.js.map +1 -1
  28. package/dist/constants/names.d.ts +1 -0
  29. package/dist/constants/names.d.ts.map +1 -1
  30. package/dist/constants/names.js +1 -0
  31. package/dist/constants/names.js.map +1 -1
  32. package/dist/hooks/session-end/index.d.ts +2 -3
  33. package/dist/hooks/session-end/index.d.ts.map +1 -1
  34. package/dist/hooks/session-end/index.js +3 -10
  35. package/dist/hooks/session-end/index.js.map +1 -1
  36. package/dist/hooks/session-end/wiki-foreground-bootstrap.d.ts +17 -0
  37. package/dist/hooks/session-end/wiki-foreground-bootstrap.d.ts.map +1 -0
  38. package/dist/hooks/session-end/wiki-foreground-bootstrap.js +20 -0
  39. package/dist/hooks/session-end/wiki-foreground-bootstrap.js.map +1 -0
  40. package/dist/lib/__tests__/atomic-write.test.js +32 -1
  41. package/dist/lib/__tests__/atomic-write.test.js.map +1 -1
  42. package/dist/lib/__tests__/mode-state-lock.test.js +121 -12
  43. package/dist/lib/__tests__/mode-state-lock.test.js.map +1 -1
  44. package/dist/lib/atomic-write.d.ts +13 -3
  45. package/dist/lib/atomic-write.d.ts.map +1 -1
  46. package/dist/lib/atomic-write.js +29 -15
  47. package/dist/lib/atomic-write.js.map +1 -1
  48. package/dist/lib/mode-state-io.d.ts +2 -2
  49. package/dist/lib/mode-state-io.d.ts.map +1 -1
  50. package/dist/lib/mode-state-io.js +16 -10
  51. package/dist/lib/mode-state-io.js.map +1 -1
  52. package/dist/mcp/__tests__/host-smoke-tool.test.d.ts +2 -0
  53. package/dist/mcp/__tests__/host-smoke-tool.test.d.ts.map +1 -0
  54. package/dist/mcp/__tests__/host-smoke-tool.test.js +174 -0
  55. package/dist/mcp/__tests__/host-smoke-tool.test.js.map +1 -0
  56. package/dist/mcp/disable-tools.d.ts.map +1 -1
  57. package/dist/mcp/disable-tools.js +1 -0
  58. package/dist/mcp/disable-tools.js.map +1 -1
  59. package/dist/mcp/tool-registry.d.ts.map +1 -1
  60. package/dist/mcp/tool-registry.js +2 -0
  61. package/dist/mcp/tool-registry.js.map +1 -1
  62. package/dist/smoke/__tests__/copilot-sdk-driver.test.d.ts +2 -0
  63. package/dist/smoke/__tests__/copilot-sdk-driver.test.d.ts.map +1 -0
  64. package/dist/smoke/__tests__/copilot-sdk-driver.test.js +561 -0
  65. package/dist/smoke/__tests__/copilot-sdk-driver.test.js.map +1 -0
  66. package/dist/smoke/__tests__/copilot-sdk-scenarios.test.d.ts +2 -0
  67. package/dist/smoke/__tests__/copilot-sdk-scenarios.test.d.ts.map +1 -0
  68. package/dist/smoke/__tests__/copilot-sdk-scenarios.test.js +311 -0
  69. package/dist/smoke/__tests__/copilot-sdk-scenarios.test.js.map +1 -0
  70. package/dist/smoke/__tests__/copilot-session-eval.test.d.ts +2 -0
  71. package/dist/smoke/__tests__/copilot-session-eval.test.d.ts.map +1 -0
  72. package/dist/smoke/__tests__/copilot-session-eval.test.js +264 -0
  73. package/dist/smoke/__tests__/copilot-session-eval.test.js.map +1 -0
  74. package/dist/smoke/__tests__/copilot-smoke.test.d.ts +2 -0
  75. package/dist/smoke/__tests__/copilot-smoke.test.d.ts.map +1 -0
  76. package/dist/smoke/__tests__/copilot-smoke.test.js +482 -0
  77. package/dist/smoke/__tests__/copilot-smoke.test.js.map +1 -0
  78. package/dist/smoke/copilot-binary.d.ts +21 -0
  79. package/dist/smoke/copilot-binary.d.ts.map +1 -0
  80. package/dist/smoke/copilot-binary.js +46 -0
  81. package/dist/smoke/copilot-binary.js.map +1 -0
  82. package/dist/smoke/copilot-sdk-driver.d.ts +186 -0
  83. package/dist/smoke/copilot-sdk-driver.d.ts.map +1 -0
  84. package/dist/smoke/copilot-sdk-driver.js +622 -0
  85. package/dist/smoke/copilot-sdk-driver.js.map +1 -0
  86. package/dist/smoke/copilot-sdk-scenarios.d.ts +159 -0
  87. package/dist/smoke/copilot-sdk-scenarios.d.ts.map +1 -0
  88. package/dist/smoke/copilot-sdk-scenarios.js +510 -0
  89. package/dist/smoke/copilot-sdk-scenarios.js.map +1 -0
  90. package/dist/smoke/copilot-session-eval.d.ts +90 -0
  91. package/dist/smoke/copilot-session-eval.d.ts.map +1 -0
  92. package/dist/smoke/copilot-session-eval.js +323 -0
  93. package/dist/smoke/copilot-session-eval.js.map +1 -0
  94. package/dist/smoke/copilot-smoke.d.ts +228 -0
  95. package/dist/smoke/copilot-smoke.d.ts.map +1 -0
  96. package/dist/smoke/copilot-smoke.js +818 -0
  97. package/dist/smoke/copilot-smoke.js.map +1 -0
  98. package/dist/smoke/process-utils.d.ts +47 -0
  99. package/dist/smoke/process-utils.d.ts.map +1 -0
  100. package/dist/smoke/process-utils.js +99 -0
  101. package/dist/smoke/process-utils.js.map +1 -0
  102. package/dist/team/__tests__/tmux-session.pane-env.test.d.ts +2 -0
  103. package/dist/team/__tests__/tmux-session.pane-env.test.d.ts.map +1 -0
  104. package/dist/team/__tests__/tmux-session.pane-env.test.js +52 -0
  105. package/dist/team/__tests__/tmux-session.pane-env.test.js.map +1 -0
  106. package/dist/team/tmux-session.d.ts +7 -0
  107. package/dist/team/tmux-session.d.ts.map +1 -1
  108. package/dist/team/tmux-session.js +80 -18
  109. package/dist/team/tmux-session.js.map +1 -1
  110. package/dist/tools/host-smoke-tool.d.ts +34 -0
  111. package/dist/tools/host-smoke-tool.d.ts.map +1 -0
  112. package/dist/tools/host-smoke-tool.js +133 -0
  113. package/dist/tools/host-smoke-tool.js.map +1 -0
  114. package/docs/CLAUDE.md +1 -1
  115. package/docs/DEVELOPERS.md +101 -0
  116. package/docs/HOOKS.md +1 -1
  117. package/docs/MIGRATION.md +71 -0
  118. package/docs/REFERENCE.md +42 -1
  119. package/docs/TOOLS.md +32 -1
  120. package/package.json +11 -2
  121. package/plugin.json +1 -1
  122. package/scripts/dev/repro-state-lock.mjs +1 -1
  123. package/scripts/lib/atomic-write.mjs +4 -2
  124. package/scripts/lib/state-lock.mjs +21 -12
  125. package/scripts/run.cjs +13 -3
  126. package/scripts/wiki-session-end.mjs +2 -1
  127. package/templates/hooks/lib/atomic-write.mjs +4 -2
@@ -0,0 +1,818 @@
1
+ /**
2
+ * `omg smoke copilot` — load-check the oh-my-copilot plugin inside the real
3
+ * GitHub Copilot CLI.
4
+ *
5
+ * Tier 0 makes no model call: manifest/version, generated hooks/agents drift,
6
+ * `plugin list`/`skill list` against `--plugin-dir`, and the standalone MCP
7
+ * server's tools/list. Tier 1 adds one cheap non-interactive session in a
8
+ * throwaway COPILOT_HOME and asserts on its persisted events, debug log and
9
+ * the `.omg/` state our hooks write. Tier 2 replaces that session with
10
+ * `@github/copilot-sdk` static checks and scripted scenarios
11
+ * (src/smoke/copilot-sdk-driver.ts).
12
+ */
13
+ import { spawn, spawnSync } from 'child_process';
14
+ import { randomUUID } from 'crypto';
15
+ import { existsSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, realpathSync, rmSync, statSync, writeFileSync } from 'fs';
16
+ import { tmpdir } from 'os';
17
+ import { dirname, join, resolve, sep } from 'path';
18
+ import { fileURLToPath } from 'url';
19
+ import { buildHostBinarySpawn } from '../cli/tmux-utils.js';
20
+ import { OMC_PLUGIN_ROOT_ENV } from '../lib/env-vars.js';
21
+ import { getCopilotConfigDir } from '../utils/config-dir.js';
22
+ import { parseJsonc } from '../utils/jsonc.js';
23
+ import { parseCopilotVersion, resolveCopilotBinary } from './copilot-binary.js';
24
+ import { EVIDENCE_MAX, evaluateAdapterErrors, evaluateEventsLog, evaluateHooks, evaluateMcpLoaded, evaluatePluginsLoaded, evaluateSessionExit, evaluateStateWritten, evaluateSubagent, excerpt, parseJsonl, REQUIRED_HOOK_TYPES, TOOL_HOOK_TYPES, } from './copilot-session-eval.js';
25
+ import { hasExited, killProcessTree, runAsync, useProcessGroup } from './process-utils.js';
26
+ import { DEFAULT_SCENARIO_TIMEOUT_MS, loadCopilotSdk, runSdkTier } from './copilot-sdk-driver.js';
27
+ import { ALL_SCENARIOS, DEFAULT_SCENARIOS, failedTier2Checks, RUNTIME_MIN_MAX_CREDITS } from './copilot-sdk-scenarios.js';
28
+ export { ALL_SCENARIOS, DEFAULT_SCENARIOS } from './copilot-sdk-scenarios.js';
29
+ /**
30
+ * No default model id: on Copilot CLI 1.0.91 every explicit `--model` tried
31
+ * (gpt-5-mini, gpt-6-luna, claude-sonnet-5) failed with `Model "<id>" from
32
+ * --model flag is not available` before any model call, while omitting the
33
+ * flag lets the CLI auto-pick (it chose mai-code-1.1-flash, a low-price flash
34
+ * model). Cost stays bounded by --max-ai-credits. Pass --model to pin one.
35
+ */
36
+ export const DEFAULT_SMOKE_MODEL = undefined;
37
+ export const DEFAULT_MAX_CREDITS = 30;
38
+ /** Copilot CLI rejects `--max-ai-credits` below this. */
39
+ export const MIN_MAX_CREDITS = RUNTIME_MIN_MAX_CREDITS;
40
+ export const DEFAULT_TIER0_TIMEOUT_MS = 60_000;
41
+ export const DEFAULT_TIER1_TIMEOUT_MS = 180_000;
42
+ export const MCP_LIST_TIMEOUT_MS = 10_000;
43
+ export const PLUGIN_NAME = 'oh-my-copilot';
44
+ export const DELEGATE_AGENT = 'oh-my-copilot:architect';
45
+ export const DEFAULT_SMOKE_PROMPT = 'Reply with exactly: SMOKE_OK. Do not use tools.';
46
+ export const DELEGATE_SMOKE_PROMPT = `Delegate one task to the ${DELEGATE_AGENT} agent: ask it to reply with one line. `
47
+ + 'Then reply with exactly: SMOKE_OK. Do not edit any files.';
48
+ /**
49
+ * Copy of the non-interactive permission flags from COPILOT_WORKER_BASE_FLAGS
50
+ * (src/team/model-contract.ts), minus the path/url grants the smoke does not
51
+ * need. Copied rather than imported so the smoke does not pull in team code.
52
+ */
53
+ const SMOKE_PERMISSION_FLAGS = ['--allow-all-tools', '--no-ask-user'];
54
+ /**
55
+ * Tier 1 session sandboxing (spellings verified against `copilot --help` and
56
+ * `copilot help permissions`, CLI 1.0.91; deny rules beat --allow-all-tools):
57
+ * - never let the model call our own `host_smoke` tool (server `t`) recursively;
58
+ * - no built-in MCP servers (github-mcp-server, githubiq) in the smoke;
59
+ * - the default prompt forbids tools, so also deny shell and file writes.
60
+ * `--delegate` keeps them, because the subagent hand-off is a tool call.
61
+ */
62
+ export function sessionSandboxFlags(delegate) {
63
+ return [
64
+ '--deny-tool', 't(host_smoke)',
65
+ '--disable-builtin-mcps',
66
+ ...(delegate ? [] : ['--deny-tool', 'shell', '--deny-tool', 'write']),
67
+ ];
68
+ }
69
+ /**
70
+ * Env policy for every copilot child the smoke spawns (the caller's env minus
71
+ * what could redirect, disable, or re-host our hooks; precedent:
72
+ * scripts/verify-graph-contained-fs.mjs).
73
+ *
74
+ * Stripped:
75
+ * - every `OMC_*` var and `DISABLE_OMC` (state dir, plugin root, skip/disable
76
+ * switches, debug toggles), then only {@link SMOKE_SET_ENV} is re-added;
77
+ * - Claude Code host markers: `CLAUDECODE`, `CLAUDE_SESSION_ID`,
78
+ * `CLAUDE_PLUGIN_ROOT`, and every `CLAUDE_CODE_*` (incl. CLAUDE_CODE_ENTRYPOINT);
79
+ * - `NODE_OPTIONS` (preloads would run inside our node hooks);
80
+ * - `COPILOT_HOME`, `COPILOT_OFFLINE`, `COPILOT_MODEL` (only `--model` picks the
81
+ * model), every `COPILOT_PROVIDER_*` (BYO provider routing);
82
+ * - tier 1 with a copied login identity: `GH_TOKEN`, `GITHUB_TOKEN` (see
83
+ * {@link buildSessionEnv}).
84
+ * Kept: everything else, notably PATH/PATHEXT/SystemRoot/HOME/USERPROFILE/
85
+ * APPDATA/LOCALAPPDATA/TEMP (hooks spawn node and resolve home dirs),
86
+ * `COPILOT_GITHUB_TOKEN`, and the remaining `COPILOT_*` (e.g. COPILOT_CLI_PATH).
87
+ */
88
+ export const STRIPPED_ENV_EXACT = [
89
+ 'DISABLE_OMC',
90
+ 'CLAUDECODE',
91
+ 'CLAUDE_SESSION_ID',
92
+ 'CLAUDE_PLUGIN_ROOT',
93
+ 'NODE_OPTIONS',
94
+ 'COPILOT_HOME',
95
+ 'COPILOT_OFFLINE',
96
+ 'COPILOT_MODEL',
97
+ ];
98
+ export const STRIPPED_ENV_PREFIXES = ['OMC_', 'CLAUDE_CODE_', 'COPILOT_PROVIDER_'];
99
+ /** Generic GitHub tokens dropped from the tier 1 session when a stored login was copied. */
100
+ export const LOGIN_SHADOWING_TOKENS = ['GH_TOKEN', 'GITHUB_TOKEN'];
101
+ /** Set on every copilot child (on top of COPILOT_HOME). */
102
+ export const SMOKE_SET_ENV = {
103
+ COPILOT_ALLOW_ALL: 'false',
104
+ COPILOT_AUTO_UPDATE: 'false',
105
+ NO_COLOR: '1',
106
+ };
107
+ /** Set on the tier 1 session only: a failing hook must surface as hook.end success:false. */
108
+ export const SESSION_SET_ENV = { OMC_HOOK_FAIL_CLOSED: '1' };
109
+ export function isStrippedEnvKey(key) {
110
+ const upper = process.platform === 'win32' ? key.toUpperCase() : key;
111
+ return STRIPPED_ENV_EXACT.includes(upper)
112
+ || STRIPPED_ENV_PREFIXES.some((p) => upper.startsWith(p));
113
+ }
114
+ const TIER0_COPILOT_IDS = ['copilot.binary', 'copilot.plugin_list', 'copilot.skill_list'];
115
+ function tier1Ids(delegate) {
116
+ return [
117
+ 'session.exit',
118
+ 'session.events',
119
+ ...REQUIRED_HOOK_TYPES.map((t) => `hooks.${t}`),
120
+ ...TOOL_HOOK_TYPES.map((t) => `hooks.${t}`),
121
+ 'hooks.adapter_errors',
122
+ 'plugins.loaded',
123
+ 'mcp.server_loaded',
124
+ 'state.written',
125
+ ...(delegate ? ['subagent.selected'] : []),
126
+ ];
127
+ }
128
+ /** Walk up from this module to the directory holding the oh-my-copilot plugin.json. */
129
+ export function resolveDefaultPluginRoot(env = process.env) {
130
+ const fromEnv = env[OMC_PLUGIN_ROOT_ENV]?.trim();
131
+ if (fromEnv)
132
+ return resolve(fromEnv);
133
+ return findPluginRoot([...moduleDirs(), process.cwd()]) ?? process.cwd();
134
+ }
135
+ /**
136
+ * The oh-my-copilot package root that contains the running module (no env,
137
+ * no cwd fallback), or null when this code is not running from a plugin tree.
138
+ */
139
+ export function resolvePackageRoot() {
140
+ return findPluginRoot(moduleDirs());
141
+ }
142
+ function moduleDirs() {
143
+ const starts = [];
144
+ if (typeof __dirname !== 'undefined' && __dirname)
145
+ starts.push(__dirname);
146
+ try {
147
+ starts.push(dirname(fileURLToPath(import.meta.url)));
148
+ }
149
+ catch { /* CJS bundle */ }
150
+ return starts;
151
+ }
152
+ function findPluginRoot(starts) {
153
+ for (const start of starts) {
154
+ let dir = resolve(start);
155
+ for (let i = 0; i < 8; i++) {
156
+ if (isPluginRoot(dir))
157
+ return dir;
158
+ const parent = dirname(dir);
159
+ if (parent === dir)
160
+ break;
161
+ dir = parent;
162
+ }
163
+ }
164
+ return null;
165
+ }
166
+ function isPluginRoot(dir) {
167
+ try {
168
+ const manifest = JSON.parse(readFileSync(join(dir, 'plugin.json'), 'utf-8'));
169
+ return manifest.name === PLUGIN_NAME && existsSync(join(dir, 'package.json'));
170
+ }
171
+ catch {
172
+ return false;
173
+ }
174
+ }
175
+ function readJson(path) {
176
+ try {
177
+ return JSON.parse(readFileSync(path, 'utf-8'));
178
+ }
179
+ catch {
180
+ return null;
181
+ }
182
+ }
183
+ function countDirsWith(dir, file) {
184
+ try {
185
+ return readdirSync(dir).filter((name) => existsSync(join(dir, name, file))).sort();
186
+ }
187
+ catch {
188
+ return [];
189
+ }
190
+ }
191
+ function countFiles(dir, ext) {
192
+ try {
193
+ return readdirSync(dir).filter((name) => name.endsWith(ext)).length;
194
+ }
195
+ catch {
196
+ return -1;
197
+ }
198
+ }
199
+ /** Extract the first JSON array from CLI output that may carry warnings around it. */
200
+ export function extractJsonArray(text) {
201
+ const start = text.indexOf('[');
202
+ const end = text.lastIndexOf(']');
203
+ if (start === -1 || end < start)
204
+ return null;
205
+ try {
206
+ const parsed = JSON.parse(text.slice(start, end + 1));
207
+ return Array.isArray(parsed) ? parsed : null;
208
+ }
209
+ catch {
210
+ return null;
211
+ }
212
+ }
213
+ function makeTempDir(prefix) {
214
+ return mkdtempSync(join(tmpdir(), prefix));
215
+ }
216
+ function removeDir(path) {
217
+ if (!path)
218
+ return;
219
+ try {
220
+ rmSync(path, { recursive: true, force: true, maxRetries: 3, retryDelay: 200 });
221
+ }
222
+ catch { /* best effort */ }
223
+ }
224
+ // ---------------------------------------------------------------------------
225
+ // Tier 0
226
+ // ---------------------------------------------------------------------------
227
+ function checkManifest(root) {
228
+ const manifest = readJson(join(root, 'plugin.json'));
229
+ const pkg = readJson(join(root, 'package.json'));
230
+ const pluginVersion = typeof manifest?.version === 'string' ? manifest.version : null;
231
+ const packageVersion = typeof pkg?.version === 'string' ? pkg.version : null;
232
+ const problems = [];
233
+ if (!manifest)
234
+ problems.push('plugin.json missing or not JSON');
235
+ else {
236
+ if (manifest.name !== PLUGIN_NAME)
237
+ problems.push(`name is ${JSON.stringify(manifest.name)}, expected ${PLUGIN_NAME}`);
238
+ if (!packageVersion)
239
+ problems.push('package.json version missing');
240
+ else if (pluginVersion !== packageVersion)
241
+ problems.push(`version ${pluginVersion} != package.json ${packageVersion}`);
242
+ }
243
+ return {
244
+ check: {
245
+ id: 'plugin.manifest',
246
+ ok: problems.length === 0,
247
+ detail: problems.length === 0 ? `${PLUGIN_NAME}@${pluginVersion}` : problems.join('; '),
248
+ },
249
+ pluginVersion,
250
+ packageVersion,
251
+ };
252
+ }
253
+ function checkGeneratedHooks(ctx) {
254
+ const scripts = ['scripts/copilot/build-hooks.mjs', 'scripts/copilot/build-agents.mjs'];
255
+ const failures = [];
256
+ const evidence = [];
257
+ for (const script of scripts) {
258
+ const res = ctx.spawnSyncFn(process.execPath, [script, '--verify'], {
259
+ cwd: ctx.root,
260
+ env: ctx.env,
261
+ encoding: 'utf8',
262
+ timeout: ctx.timeoutMs,
263
+ windowsHide: true,
264
+ });
265
+ const out = `${res.stdout ?? ''}${res.stderr ?? ''}`.trim();
266
+ if (res.status !== 0) {
267
+ failures.push(`${script} --verify exit ${res.status ?? res.error?.message ?? 'null'}`);
268
+ evidence.push(out);
269
+ }
270
+ }
271
+ return {
272
+ id: 'plugin.hooks',
273
+ ok: failures.length === 0,
274
+ detail: failures.length === 0 ? 'build-hooks and build-agents --verify pass' : failures.join('; '),
275
+ ...(failures.length ? { evidence: excerpt(evidence.join('\n')) } : {}),
276
+ };
277
+ }
278
+ function checkAgents(root) {
279
+ const source = countFiles(join(root, 'agents'), '.md');
280
+ const generated = countFiles(join(root, 'copilot', 'agents'), '.md');
281
+ const ok = source > 0 && source === generated;
282
+ return {
283
+ id: 'copilot.agents',
284
+ ok,
285
+ detail: `copilot/agents ${generated} vs agents ${source} *.md`,
286
+ };
287
+ }
288
+ function runCopilotSync(ctx, bin, args, env, cwd) {
289
+ const plan = buildHostBinarySpawn(bin, args);
290
+ return ctx.spawnSyncFn(plan.command, plan.args, {
291
+ cwd,
292
+ env,
293
+ encoding: 'utf8',
294
+ timeout: ctx.timeoutMs,
295
+ windowsHide: true,
296
+ windowsVerbatimArguments: plan.windowsVerbatimArguments,
297
+ maxBuffer: 32 * 1024 * 1024,
298
+ });
299
+ }
300
+ function checkPluginList(ctx, bin, env, cwd, packageVersion) {
301
+ const res = runCopilotSync(ctx, bin, ['--plugin-dir', ctx.root, '--no-auto-update', 'plugin', 'list', '--json'], env, cwd);
302
+ const stdout = String(res.stdout ?? '');
303
+ const list = extractJsonArray(stdout);
304
+ if (res.status !== 0 || !list) {
305
+ return { id: 'copilot.plugin_list', ok: false, detail: `plugin list exit ${res.status}${list ? '' : ', no JSON array'}`, evidence: excerpt(`${stdout}${res.stderr ?? ''}`) };
306
+ }
307
+ // A bad --plugin-dir only warns and returns [] with exit 0: assert the exact entry.
308
+ const entry = list.find((p) => p.name === PLUGIN_NAME);
309
+ const ok = !!entry && entry.version === packageVersion && entry.source === 'external';
310
+ return {
311
+ id: 'copilot.plugin_list',
312
+ ok,
313
+ detail: entry
314
+ ? `${PLUGIN_NAME}@${String(entry.version)} source=${String(entry.source)}${ok ? '' : ` (expected ${packageVersion}, external)`}`
315
+ : `${PLUGIN_NAME} not listed (${list.length} plugin(s))`,
316
+ ...(ok ? {} : { evidence: excerpt(`${stdout}${res.stderr ?? ''}`) }),
317
+ };
318
+ }
319
+ /** Canonical path for comparisons: realpath (symlinked roots), case-folded on win32. */
320
+ function normalizePath(p) {
321
+ let out = resolve(p);
322
+ try {
323
+ out = realpathSync.native(out);
324
+ }
325
+ catch { /* missing path: keep the resolved form */ }
326
+ return process.platform === 'win32' ? out.toLowerCase() : out;
327
+ }
328
+ function checkSkillList(ctx, bin, env, cwd) {
329
+ const expected = countDirsWith(join(ctx.root, 'skills'), 'SKILL.md');
330
+ const res = runCopilotSync(ctx, bin, ['--plugin-dir', ctx.root, '--no-auto-update', 'skill', 'list', '--json'], env, cwd);
331
+ const stdout = String(res.stdout ?? '');
332
+ const list = extractJsonArray(stdout);
333
+ if (res.status !== 0 || !list) {
334
+ return { id: 'copilot.skill_list', ok: false, detail: `skill list exit ${res.status}${list ? '' : ', no JSON array'}`, evidence: excerpt(`${stdout}${res.stderr ?? ''}`) };
335
+ }
336
+ // Copilot also surfaces plugin `commands/*.md` as plugin skills, so count only
337
+ // plugin-sourced entries whose path is a skills/<dir>.
338
+ const skillsDir = normalizePath(join(ctx.root, 'skills')) + sep;
339
+ const pluginSkills = list.filter((s) => s.source === 'plugin');
340
+ const fromSkillsDir = new Set(pluginSkills
341
+ .filter((s) => typeof s.path === 'string' && normalizePath(s.path).startsWith(skillsDir))
342
+ .map((s) => normalizePath(s.path).slice(skillsDir.length).split(/[\\/]/)[0]));
343
+ const missing = expected.filter((d) => !fromSkillsDir.has(process.platform === 'win32' ? d.toLowerCase() : d));
344
+ const ok = expected.length > 0 && missing.length === 0 && fromSkillsDir.size === expected.length;
345
+ return {
346
+ id: 'copilot.skill_list',
347
+ ok,
348
+ detail: `${fromSkillsDir.size}/${expected.length} skills/*/SKILL.md loaded as source=plugin (${pluginSkills.length} plugin entries incl. commands)`,
349
+ ...(ok ? {} : { evidence: excerpt(missing.length ? `missing: ${missing.join(', ')}` : stdout) }),
350
+ };
351
+ }
352
+ async function defaultExpectedToolCount() {
353
+ const registry = await import('../mcp/tool-registry.js');
354
+ return registry.allTools.length;
355
+ }
356
+ async function checkMcpListTools(ctx) {
357
+ const id = 'mcp.list_tools';
358
+ const server = join(ctx.root, 'dist', 'mcp', 'standalone-server.js');
359
+ if (!existsSync(server))
360
+ return { id, ok: false, detail: `${server} missing; run npm run build` };
361
+ let expected;
362
+ try {
363
+ expected = await (ctx.deps.loadExpectedToolCount ?? defaultExpectedToolCount)();
364
+ }
365
+ catch (err) {
366
+ return { id, ok: false, detail: `could not load tool registry: ${err.message}` };
367
+ }
368
+ const env = { ...ctx.env };
369
+ delete env.OMC_DISABLE_TOOLS; // compare against the full registry
370
+ return new Promise((resolveCheck) => {
371
+ let settled = false;
372
+ let buffer = '';
373
+ let stderr = '';
374
+ const child = ctx.spawnFn(process.execPath, [server], {
375
+ cwd: ctx.root,
376
+ env,
377
+ stdio: ['pipe', 'pipe', 'pipe'],
378
+ windowsHide: true,
379
+ detached: useProcessGroup(),
380
+ });
381
+ const done = (check) => {
382
+ if (settled)
383
+ return;
384
+ settled = true;
385
+ clearTimeout(timer);
386
+ try {
387
+ child.stdin?.end();
388
+ }
389
+ catch { /* closed */ }
390
+ if (!hasExited(child))
391
+ killProcessTree(child.pid, ctx.spawnSyncFn);
392
+ resolveCheck(check);
393
+ };
394
+ const timer = setTimeout(() => done({ id, ok: false, detail: `no tools/list response within ${MCP_LIST_TIMEOUT_MS} ms`, evidence: excerpt(stderr) }), MCP_LIST_TIMEOUT_MS);
395
+ const send = (msg) => { try {
396
+ child.stdin?.write(`${JSON.stringify(msg)}\n`);
397
+ }
398
+ catch { /* closed */ } };
399
+ child.on('error', (err) => done({ id, ok: false, detail: `spawn failed: ${err.message}` }));
400
+ child.on('close', (code) => done({ id, ok: false, detail: `server exited (${code}) before tools/list`, evidence: excerpt(stderr) }));
401
+ child.stdin?.on('error', () => { });
402
+ child.stderr?.setEncoding('utf8');
403
+ child.stderr?.on('data', (c) => { if (stderr.length < 64_000)
404
+ stderr += c; });
405
+ child.stdout?.setEncoding('utf8');
406
+ child.stdout?.on('data', (chunk) => {
407
+ buffer += chunk;
408
+ let nl;
409
+ while ((nl = buffer.indexOf('\n')) !== -1) {
410
+ const line = buffer.slice(0, nl).trim();
411
+ buffer = buffer.slice(nl + 1);
412
+ if (!line)
413
+ continue;
414
+ let msg;
415
+ try {
416
+ msg = JSON.parse(line);
417
+ }
418
+ catch {
419
+ continue;
420
+ }
421
+ if (msg.id === 1) {
422
+ if (msg.error) {
423
+ done({ id, ok: false, detail: 'initialize failed', evidence: excerpt(JSON.stringify(msg.error)) });
424
+ return;
425
+ }
426
+ send({ jsonrpc: '2.0', method: 'notifications/initialized' });
427
+ send({ jsonrpc: '2.0', id: 2, method: 'tools/list', params: {} });
428
+ }
429
+ else if (msg.id === 2) {
430
+ const tools = msg.result?.tools;
431
+ if (!Array.isArray(tools)) {
432
+ done({ id, ok: false, detail: 'tools/list returned no tools array', evidence: excerpt(line) });
433
+ return;
434
+ }
435
+ const ok = tools.length === expected;
436
+ done({ id, ok, detail: `tools/list returned ${tools.length}, registry allTools ${expected}${ok ? '' : ' (stale dist? run npm run build)'}` });
437
+ return;
438
+ }
439
+ }
440
+ });
441
+ send({
442
+ jsonrpc: '2.0',
443
+ id: 1,
444
+ method: 'initialize',
445
+ params: { protocolVersion: '2025-06-18', capabilities: {}, clientInfo: { name: 'omg-smoke', version: '1' } },
446
+ });
447
+ });
448
+ }
449
+ /** Apply the env policy documented at {@link STRIPPED_ENV_EXACT}. */
450
+ export function buildSessionEnv(base, home, opts = {}) {
451
+ const env = {};
452
+ for (const [key, value] of Object.entries(base)) {
453
+ if (isStrippedEnvKey(key))
454
+ continue;
455
+ if (opts.hasLogin && LOGIN_SHADOWING_TOKENS.includes(key.toUpperCase()))
456
+ continue;
457
+ env[key] = value;
458
+ }
459
+ env.COPILOT_HOME = home;
460
+ Object.assign(env, SMOKE_SET_ENV);
461
+ if (opts.session)
462
+ Object.assign(env, SESSION_SET_ENV);
463
+ return env;
464
+ }
465
+ /**
466
+ * Copy only the login identity (`loggedInUsers`, `lastLoggedInUser`) from the
467
+ * user's real config. The token stays in the OS credential store; without
468
+ * these keys an empty COPILOT_HOME falls back to `gh auth token`, whose OAuth
469
+ * app is not entitled to Copilot models ("Model ... is not available").
470
+ *
471
+ * Token precedence (`copilot help environment`, 1.0.91): COPILOT_GITHUB_TOKEN,
472
+ * GH_TOKEN, GITHUB_TOKEN each take precedence over stored credentials. So when
473
+ * a login was copied, the session drops GH_TOKEN/GITHUB_TOKEN (typically gh or
474
+ * Actions tokens, which would shadow the entitled login) and keeps
475
+ * COPILOT_GITHUB_TOKEN (an explicit Copilot opt-in). Without a stored login
476
+ * (CI) all three pass through, since a token is then the only credential.
477
+ */
478
+ export function loginIdentity(userConfigDir) {
479
+ try {
480
+ const parsed = parseJsonc(readFileSync(join(userConfigDir, 'config.json'), 'utf-8'));
481
+ if (!parsed || typeof parsed !== 'object')
482
+ return {};
483
+ const out = {};
484
+ for (const key of ['loggedInUsers', 'lastLoggedInUser'])
485
+ if (parsed[key] !== undefined)
486
+ out[key] = parsed[key];
487
+ return out;
488
+ }
489
+ catch {
490
+ return {};
491
+ }
492
+ }
493
+ function initProject(ctx, projectDir, env) {
494
+ const res = ctx.spawnSyncFn('git', ['init', '-q'], { cwd: projectDir, env, encoding: 'utf8', timeout: 30_000, windowsHide: true });
495
+ if (res.status !== 0 || !existsSync(join(projectDir, '.git')))
496
+ mkdirSync(join(projectDir, '.git'), { recursive: true });
497
+ }
498
+ function readText(path) {
499
+ try {
500
+ return readFileSync(path, 'utf-8');
501
+ }
502
+ catch {
503
+ return null;
504
+ }
505
+ }
506
+ /** All process logs under the log dir, newest last, joined; plus the newest path. */
507
+ function readDebugLogs(logDir) {
508
+ let files;
509
+ try {
510
+ files = readdirSync(logDir).filter((f) => f.endsWith('.log')).map((f) => join(logDir, f));
511
+ }
512
+ catch {
513
+ return { text: null };
514
+ }
515
+ if (files.length === 0)
516
+ return { text: null };
517
+ files.sort((a, b) => statSync(a).mtimeMs - statSync(b).mtimeMs);
518
+ return { text: files.map((f) => readText(f) ?? '').join('\n'), path: files[files.length - 1] };
519
+ }
520
+ function mcpServerNames(root) {
521
+ const mcp = readJson(join(root, '.mcp.json'));
522
+ const servers = mcp?.mcpServers;
523
+ return servers && typeof servers === 'object' ? Object.keys(servers) : [];
524
+ }
525
+ /**
526
+ * Run the live session. Registers its temp dir in `cleanup` and its paths in
527
+ * `artifacts` as soon as they exist, so the caller's `finally` removes them
528
+ * even when this throws midway.
529
+ */
530
+ async function runTier1(ctx, bin, opts, cleanup, artifacts) {
531
+ // One parent so --keep-home leaves home/ and project/ side by side.
532
+ const parent = makeTempDir('omg-smoke-');
533
+ cleanup.push(parent);
534
+ const home = join(parent, 'home');
535
+ artifacts.copilotHome = home;
536
+ const projectDir = join(parent, 'project');
537
+ mkdirSync(home);
538
+ mkdirSync(projectDir);
539
+ const logDir = join(home, 'logs');
540
+ const sessionId = (ctx.deps.randomUUID ?? randomUUID)();
541
+ const identity = loginIdentity(ctx.deps.userConfigDir ?? getCopilotConfigDir());
542
+ const env = buildSessionEnv(ctx.env, home, { session: true, hasLogin: identity.loggedInUsers !== undefined });
543
+ const config = { ...identity, trustedFolders: [projectDir] };
544
+ writeFileSync(join(home, 'config.json'), `${JSON.stringify(config, null, 2)}\n`);
545
+ initProject(ctx, projectDir, env);
546
+ const prompt = opts.prompt ?? (opts.delegate ? DELEGATE_SMOKE_PROMPT : DEFAULT_SMOKE_PROMPT);
547
+ const timeoutMs = opts.timeoutMs ?? DEFAULT_TIER1_TIMEOUT_MS;
548
+ const args = [
549
+ '--plugin-dir', ctx.root,
550
+ '--session-id', sessionId,
551
+ '--output-format', 'json',
552
+ ...SMOKE_PERMISSION_FLAGS,
553
+ ...sessionSandboxFlags(!!opts.delegate),
554
+ '--no-auto-update',
555
+ '--log-level', 'debug',
556
+ '--log-dir', logDir,
557
+ ...((opts.model ?? DEFAULT_SMOKE_MODEL) ? ['--model', (opts.model ?? DEFAULT_SMOKE_MODEL)] : []),
558
+ '--max-ai-credits', String(opts.maxCredits ?? DEFAULT_MAX_CREDITS),
559
+ ];
560
+ // No -p: `-p <text>` would put the prompt on argv. Piped stdin is the
561
+ // CLI's other non-interactive entry (verified on 1.0.91).
562
+ const plan = buildHostBinarySpawn(bin, args);
563
+ const run = await runAsync(ctx.spawnFn, ctx.spawnSyncFn, plan.command, plan.args, {
564
+ cwd: projectDir,
565
+ env,
566
+ input: `${prompt}\n`,
567
+ timeoutMs,
568
+ windowsVerbatimArguments: plan.windowsVerbatimArguments,
569
+ });
570
+ const stdoutPath = join(home, 'smoke-stdout.jsonl');
571
+ try {
572
+ writeFileSync(stdoutPath, run.stdout);
573
+ }
574
+ catch { /* best effort */ }
575
+ const eventsPath = join(home, 'session-state', sessionId, 'events.jsonl');
576
+ const logs = readDebugLogs(logDir);
577
+ Object.assign(artifacts, { eventsLog: eventsPath, debugLog: logs.path, stdout: stdoutPath });
578
+ return evaluateTier1({
579
+ exitCode: run.code,
580
+ timedOut: run.timedOut,
581
+ error: run.error,
582
+ stdout: run.stdout,
583
+ stderr: run.stderr,
584
+ timeoutMs,
585
+ eventsPath,
586
+ eventsText: readText(eventsPath),
587
+ logText: logs.text,
588
+ projectDir,
589
+ mcpServerNames: mcpServerNames(ctx.root),
590
+ delegate: !!opts.delegate,
591
+ });
592
+ }
593
+ /** Evaluate captured Tier 1 artifacts (exported for fixture-driven tests). */
594
+ export function evaluateTier1(input) {
595
+ const checks = [];
596
+ checks.push(evaluateSessionExit(input));
597
+ const { check: eventsCheck, events } = evaluateEventsLog(input.eventsPath, input.eventsText);
598
+ checks.push(eventsCheck);
599
+ // Ephemeral events (e.g. MCP status changes) only reach stdout, so evaluate both streams.
600
+ const stdoutEvents = parseJsonl(input.stdout).events;
601
+ const allEvents = [...events, ...stdoutEvents.filter((e) => !e.id || !events.some((p) => p.id === e.id))];
602
+ checks.push(...evaluateHooks(events.length ? events : stdoutEvents));
603
+ checks.push(evaluateAdapterErrors([
604
+ { name: 'debug log', text: input.logText },
605
+ { name: 'session stderr', text: input.stderr },
606
+ { name: 'events.jsonl', text: input.eventsText },
607
+ { name: 'stdout', text: input.stdout },
608
+ ]));
609
+ checks.push(evaluatePluginsLoaded(input.logText));
610
+ checks.push(evaluateMcpLoaded(allEvents, input.logText, input.mcpServerNames.length ? input.mcpServerNames : ['t']));
611
+ checks.push(evaluateStateWritten(input.projectDir));
612
+ if (input.delegate)
613
+ checks.push(evaluateSubagent(allEvents, DELEGATE_AGENT));
614
+ return checks.map((c) => (c.evidence && c.evidence.length > EVIDENCE_MAX ? { ...c, evidence: excerpt(c.evidence) } : c));
615
+ }
616
+ // ---------------------------------------------------------------------------
617
+ // Entry point
618
+ // ---------------------------------------------------------------------------
619
+ export const LIVE_RUN_REFUSED_DETAIL = 'live run refused under vitest — set OMC_LIVE_SMOKE=1 (tier 1) / =2 (tier 2 scenarios)';
620
+ /**
621
+ * Hard no-billing guard: under a test runner (`VITEST` or `NODE_ENV=test` in
622
+ * the real process env) a run that would call a model (tier 1; tier 2 with
623
+ * any scenario, the default included) is refused before any subprocess or SDK
624
+ * client, unless `OMC_LIVE_SMOKE` names that tier or the test injected the
625
+ * seam that would otherwise bill (`deps.spawn` at tier 1, `deps.loadSdk` at
626
+ * tier 2). Returns the refusal detail, or null to proceed.
627
+ */
628
+ export function liveRunRefusal(tier, scenarios, deps, env = process.env) {
629
+ const underTest = !!env.VITEST || env.NODE_ENV === 'test';
630
+ if (!underTest)
631
+ return null;
632
+ const billable = tier === 1 || (tier === 2 && (scenarios ?? DEFAULT_SCENARIOS).length > 0);
633
+ if (!billable)
634
+ return null;
635
+ if (env.OMC_LIVE_SMOKE === String(tier))
636
+ return null;
637
+ if ((tier === 1 && deps.spawn) || (tier === 2 && deps.loadSdk))
638
+ return null;
639
+ return LIVE_RUN_REFUSED_DETAIL;
640
+ }
641
+ export async function runCopilotSmoke(input = {}) {
642
+ const started = Date.now();
643
+ const env = input.env ?? process.env;
644
+ const deps = input.deps ?? {};
645
+ const tier = input.tier === 1 || input.tier === 2 ? input.tier : 0;
646
+ const root = resolve(input.pluginRoot ?? resolveDefaultPluginRoot(env));
647
+ const opts = { ...input, pluginRoot: root, tier };
648
+ if (opts.prompt !== undefined && /\0/.test(opts.prompt))
649
+ throw new Error('smoke prompt must not contain NUL');
650
+ const unknown = (opts.scenarios ?? []).filter((s) => !ALL_SCENARIOS.includes(s));
651
+ if (unknown.length)
652
+ throw new Error(`unknown smoke scenario(s): ${unknown.join(', ')} (known: ${ALL_SCENARIOS.join(', ')})`);
653
+ const refusal = liveRunRefusal(tier, opts.scenarios, deps);
654
+ if (refusal) {
655
+ return {
656
+ ok: false,
657
+ tier,
658
+ pluginRoot: root,
659
+ pluginVersion: null,
660
+ copilot: { bin: null, version: null },
661
+ checks: [{ id: 'cli.guard', ok: false, detail: refusal }],
662
+ artifacts: {},
663
+ durationMs: Date.now() - started,
664
+ skipped: refusal,
665
+ };
666
+ }
667
+ const ctx = {
668
+ root,
669
+ env: { ...env },
670
+ spawnFn: deps.spawn ?? spawn,
671
+ spawnSyncFn: deps.spawnSync ?? spawnSync,
672
+ deps,
673
+ timeoutMs: tier === 0 ? (opts.timeoutMs ?? DEFAULT_TIER0_TIMEOUT_MS) : DEFAULT_TIER0_TIMEOUT_MS,
674
+ };
675
+ const checks = [];
676
+ const artifacts = {};
677
+ const cleanup = [];
678
+ try {
679
+ return await runChecks(ctx, opts, started, checks, artifacts, cleanup);
680
+ }
681
+ finally {
682
+ if (!opts.keepHome) {
683
+ for (const dir of cleanup)
684
+ removeDir(dir);
685
+ for (const key of Object.keys(artifacts))
686
+ delete artifacts[key];
687
+ }
688
+ }
689
+ }
690
+ async function runChecks(ctx, opts, started, checks, artifacts, cleanup) {
691
+ const { root, deps, env } = ctx;
692
+ const tier = opts.tier;
693
+ let skipped;
694
+ const manifest = checkManifest(root);
695
+ checks.push(manifest.check);
696
+ checks.push(checkGeneratedHooks(ctx));
697
+ const resolution = resolveCopilotBinary(opts.copilotBin, env, deps);
698
+ let version = null;
699
+ if (!resolution.bin) {
700
+ skipped = opts.copilotBin
701
+ ? `copilot binary not found: ${opts.copilotBin}`
702
+ : 'copilot binary not found on PATH, COPILOT_CLI_PATH, or the WinGet package dir';
703
+ checks.push({ id: 'copilot.binary', ok: false, detail: skipped });
704
+ for (const id of TIER0_COPILOT_IDS.slice(1))
705
+ checks.push({ id, ok: false, detail: 'skipped' });
706
+ }
707
+ else {
708
+ const bin = resolution.bin;
709
+ // cwd is tmpdir, not the plugin root: a missing --plugin-root must not
710
+ // surface as an ENOENT on the binary probe.
711
+ const res = runCopilotSync(ctx, bin, ['--version'], ctx.env, tmpdir());
712
+ version = parseCopilotVersion(String(res.stdout ?? ''));
713
+ const binaryOk = res.status === 0 && version !== null;
714
+ checks.push({
715
+ id: 'copilot.binary',
716
+ ok: binaryOk,
717
+ detail: binaryOk ? `${bin} (${resolution.source}) v${version}` : `${bin} --version exit ${res.status ?? res.error?.message}`,
718
+ ...(binaryOk ? {} : { evidence: excerpt(`${res.stdout ?? ''}${res.stderr ?? ''}`) }),
719
+ });
720
+ const listHome = makeTempDir('omg-smoke-list-');
721
+ cleanup.push(listHome);
722
+ artifacts.listHome = listHome;
723
+ const listEnv = buildSessionEnv(ctx.env, listHome);
724
+ checks.push(checkPluginList(ctx, bin, listEnv, listHome, manifest.packageVersion));
725
+ checks.push(checkSkillList(ctx, bin, listEnv, listHome));
726
+ }
727
+ checks.push(checkAgents(root));
728
+ checks.push(await checkMcpListTools(ctx));
729
+ if (tier === 1) {
730
+ if (!resolution.bin) {
731
+ for (const id of tier1Ids(!!opts.delegate))
732
+ checks.push({ id, ok: false, detail: 'skipped' });
733
+ }
734
+ else {
735
+ checks.push(...await runTier1(ctx, resolution.bin, opts, cleanup, artifacts));
736
+ }
737
+ }
738
+ let tier2;
739
+ if (tier === 2) {
740
+ const scenarios = [...new Set(opts.scenarios ?? DEFAULT_SCENARIOS)];
741
+ if (!resolution.bin) {
742
+ checks.push(...failedTier2Checks(scenarios, 'skipped'));
743
+ }
744
+ else {
745
+ tier2 = await runTier2(ctx, resolution.bin, version, manifest.packageVersion, opts, scenarios, cleanup, artifacts);
746
+ checks.push(...tier2.checks);
747
+ skipped ??= tier2.skipped;
748
+ }
749
+ }
750
+ return {
751
+ ok: checks.every((c) => c.ok),
752
+ tier,
753
+ pluginRoot: root,
754
+ pluginVersion: manifest.pluginVersion,
755
+ copilot: { bin: resolution.bin, version },
756
+ checks,
757
+ artifacts,
758
+ durationMs: Date.now() - started,
759
+ ...(skipped ? { skipped } : {}),
760
+ ...(tier2?.sdk ? { sdk: tier2.sdk } : {}),
761
+ ...(tier2?.cost ? { cost: tier2.cost } : {}),
762
+ };
763
+ }
764
+ // ---------------------------------------------------------------------------
765
+ // Tier 2
766
+ // ---------------------------------------------------------------------------
767
+ /**
768
+ * Same isolation as tier 1 (throwaway COPILOT_HOME with the copied login
769
+ * identity and trustedFolders, {@link buildSessionEnv} with
770
+ * OMC_HOOK_FAIL_CLOSED=1), driven through one SDK client. Env is per client,
771
+ * so OMC_GIT_GUARDRAILS=1 holds for every scenario; only the guardrail
772
+ * scenario is permitted a shell, so the others never reach the guardrail.
773
+ */
774
+ async function runTier2(ctx, bin, binVersion, packageVersion, opts, scenarios, cleanup, artifacts) {
775
+ const parent = makeTempDir('omg-smoke-sdk-');
776
+ cleanup.push(parent);
777
+ const home = join(parent, 'home');
778
+ const projectDir = join(parent, 'project');
779
+ mkdirSync(home);
780
+ artifacts.copilotHome = home;
781
+ const identity = loginIdentity(ctx.deps.userConfigDir ?? getCopilotConfigDir());
782
+ const env = buildSessionEnv(ctx.env, home, { session: true, hasLogin: identity.loggedInUsers !== undefined });
783
+ env.OMC_GIT_GUARDRAILS = '1';
784
+ writeFileSync(join(home, 'config.json'), `${JSON.stringify({ ...identity, trustedFolders: [projectDir] }, null, 2)}\n`);
785
+ let agentFiles = [];
786
+ try {
787
+ agentFiles = readdirSync(join(ctx.root, 'agents')).filter((f) => f.endsWith('.md')).sort();
788
+ }
789
+ catch { /* evaluated as 0 */ }
790
+ const result = await runSdkTier({
791
+ root: ctx.root,
792
+ bin,
793
+ binVersion,
794
+ packageVersion,
795
+ home,
796
+ projectDir,
797
+ remoteDir: join(parent, 'remote.git'),
798
+ env,
799
+ scenarios,
800
+ model: opts.model,
801
+ timeoutMs: opts.timeoutMs ?? DEFAULT_SCENARIO_TIMEOUT_MS,
802
+ maxCredits: opts.maxCredits ?? DEFAULT_MAX_CREDITS,
803
+ keepHome: !!opts.keepHome,
804
+ loadSdk: ctx.deps.loadSdk ?? (() => loadCopilotSdk(ctx.env, ctx.spawnSyncFn)),
805
+ loadExpectedToolCount: ctx.deps.loadExpectedToolCount ?? defaultExpectedToolCount,
806
+ spawnSync: ctx.spawnSyncFn,
807
+ skillDirs: countDirsWith(join(ctx.root, 'skills'), 'SKILL.md'),
808
+ agentFiles,
809
+ mcpServer: mcpServerNames(ctx.root)[0] ?? 't',
810
+ });
811
+ if (Object.keys(result.events).length)
812
+ artifacts.events = result.events;
813
+ const logs = readDebugLogs(join(home, 'logs'));
814
+ if (logs.path)
815
+ artifacts.debugLog = logs.path;
816
+ return result;
817
+ }
818
+ //# sourceMappingURL=copilot-smoke.js.map