@namzu/sdk 50.0.0 → 51.0.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 (133) hide show
  1. package/CHANGELOG.md +52 -0
  2. package/dist/execution/code-runtime/types.d.ts +35 -0
  3. package/dist/execution/code-runtime/types.d.ts.map +1 -1
  4. package/dist/execution/code-runtime/types.js.map +1 -1
  5. package/dist/execution/code-runtime/worker-program.d.ts +1 -1
  6. package/dist/execution/code-runtime/worker-program.d.ts.map +1 -1
  7. package/dist/execution/code-runtime/worker-program.js +72 -13
  8. package/dist/execution/code-runtime/worker-program.js.map +1 -1
  9. package/dist/execution/code-runtime/worker.d.ts.map +1 -1
  10. package/dist/execution/code-runtime/worker.js +35 -1
  11. package/dist/execution/code-runtime/worker.js.map +1 -1
  12. package/dist/execution/workflow/errors.d.ts +5 -0
  13. package/dist/execution/workflow/errors.d.ts.map +1 -0
  14. package/dist/execution/workflow/errors.js +8 -0
  15. package/dist/execution/workflow/errors.js.map +1 -0
  16. package/dist/execution/workflow/index.d.ts +9 -0
  17. package/dist/execution/workflow/index.d.ts.map +1 -0
  18. package/dist/execution/workflow/index.js +6 -0
  19. package/dist/execution/workflow/index.js.map +1 -0
  20. package/dist/execution/workflow/journal.d.ts +18 -0
  21. package/dist/execution/workflow/journal.d.ts.map +1 -0
  22. package/dist/execution/workflow/journal.js +43 -0
  23. package/dist/execution/workflow/journal.js.map +1 -0
  24. package/dist/execution/workflow/meta.d.ts +14 -0
  25. package/dist/execution/workflow/meta.d.ts.map +1 -0
  26. package/dist/execution/workflow/meta.js +314 -0
  27. package/dist/execution/workflow/meta.js.map +1 -0
  28. package/dist/execution/workflow/phases.d.ts +32 -0
  29. package/dist/execution/workflow/phases.d.ts.map +1 -0
  30. package/dist/execution/workflow/phases.js +57 -0
  31. package/dist/execution/workflow/phases.js.map +1 -0
  32. package/dist/execution/workflow/prelude.d.ts +15 -0
  33. package/dist/execution/workflow/prelude.d.ts.map +1 -0
  34. package/dist/execution/workflow/prelude.js +128 -0
  35. package/dist/execution/workflow/prelude.js.map +1 -0
  36. package/dist/execution/workflow/runtime.d.ts +31 -0
  37. package/dist/execution/workflow/runtime.d.ts.map +1 -0
  38. package/dist/execution/workflow/runtime.js +682 -0
  39. package/dist/execution/workflow/runtime.js.map +1 -0
  40. package/dist/execution/workflow/schema.d.ts +10 -0
  41. package/dist/execution/workflow/schema.d.ts.map +1 -0
  42. package/dist/execution/workflow/schema.js +145 -0
  43. package/dist/execution/workflow/schema.js.map +1 -0
  44. package/dist/execution/workflow/types.d.ts +267 -0
  45. package/dist/execution/workflow/types.d.ts.map +1 -0
  46. package/dist/execution/workflow/types.js +7 -0
  47. package/dist/execution/workflow/types.js.map +1 -0
  48. package/dist/index.d.ts +1 -0
  49. package/dist/index.d.ts.map +1 -1
  50. package/dist/index.js +1 -0
  51. package/dist/index.js.map +1 -1
  52. package/dist/plugin/shell-hook.d.ts.map +1 -1
  53. package/dist/plugin/shell-hook.js +2 -1
  54. package/dist/plugin/shell-hook.js.map +1 -1
  55. package/dist/process/kill-tree.d.ts +7 -0
  56. package/dist/process/kill-tree.d.ts.map +1 -1
  57. package/dist/process/kill-tree.js +41 -0
  58. package/dist/process/kill-tree.js.map +1 -1
  59. package/dist/provider/strict-schema.d.ts +28 -0
  60. package/dist/provider/strict-schema.d.ts.map +1 -1
  61. package/dist/provider/strict-schema.js +86 -0
  62. package/dist/provider/strict-schema.js.map +1 -1
  63. package/dist/provider/token-budget.d.ts.map +1 -1
  64. package/dist/provider/token-budget.js +31 -2
  65. package/dist/provider/token-budget.js.map +1 -1
  66. package/dist/public-runtime.d.ts +2 -1
  67. package/dist/public-runtime.d.ts.map +1 -1
  68. package/dist/public-runtime.js +4 -1
  69. package/dist/public-runtime.js.map +1 -1
  70. package/dist/public-tools.d.ts +1 -1
  71. package/dist/public-tools.d.ts.map +1 -1
  72. package/dist/public-tools.js +1 -1
  73. package/dist/public-tools.js.map +1 -1
  74. package/dist/public-types.d.ts +3 -0
  75. package/dist/public-types.d.ts.map +1 -1
  76. package/dist/runtime/jobs/job-id.d.ts +21 -0
  77. package/dist/runtime/jobs/job-id.d.ts.map +1 -0
  78. package/dist/runtime/jobs/job-id.js +66 -0
  79. package/dist/runtime/jobs/job-id.js.map +1 -0
  80. package/dist/runtime/jobs/registry.d.ts +0 -1
  81. package/dist/runtime/jobs/registry.d.ts.map +1 -1
  82. package/dist/runtime/jobs/registry.js +2 -3
  83. package/dist/runtime/jobs/registry.js.map +1 -1
  84. package/dist/runtime/query/prompt.d.ts.map +1 -1
  85. package/dist/runtime/query/prompt.js +5 -1
  86. package/dist/runtime/query/prompt.js.map +1 -1
  87. package/dist/tools/builtins/bash.d.ts +8 -1
  88. package/dist/tools/builtins/bash.d.ts.map +1 -1
  89. package/dist/tools/builtins/bash.js +20 -3
  90. package/dist/tools/builtins/bash.js.map +1 -1
  91. package/dist/tools/builtins/edit.d.ts.map +1 -1
  92. package/dist/tools/builtins/edit.js +12 -1
  93. package/dist/tools/builtins/edit.js.map +1 -1
  94. package/dist/tools/builtins/job.d.ts.map +1 -1
  95. package/dist/tools/builtins/job.js +4 -0
  96. package/dist/tools/builtins/job.js.map +1 -1
  97. package/dist/tools/builtins/wait-for-job.d.ts.map +1 -1
  98. package/dist/tools/builtins/wait-for-job.js +4 -0
  99. package/dist/tools/builtins/wait-for-job.js.map +1 -1
  100. package/dist/tools/command-shell.d.ts +55 -0
  101. package/dist/tools/command-shell.d.ts.map +1 -1
  102. package/dist/tools/command-shell.js +174 -13
  103. package/dist/tools/command-shell.js.map +1 -1
  104. package/package.json +1 -1
  105. package/src/agents/AGENTS.md +134 -46
  106. package/src/execution/code-runtime/types.ts +37 -1
  107. package/src/execution/code-runtime/worker-program.ts +72 -13
  108. package/src/execution/code-runtime/worker.ts +40 -1
  109. package/src/execution/workflow/errors.ts +7 -0
  110. package/src/execution/workflow/index.ts +8 -0
  111. package/src/execution/workflow/journal.ts +46 -0
  112. package/src/execution/workflow/meta.ts +316 -0
  113. package/src/execution/workflow/phases.ts +91 -0
  114. package/src/execution/workflow/prelude.ts +135 -0
  115. package/src/execution/workflow/runtime.ts +786 -0
  116. package/src/execution/workflow/schema.ts +137 -0
  117. package/src/execution/workflow/types.ts +300 -0
  118. package/src/index.ts +4 -0
  119. package/src/plugin/shell-hook.ts +2 -1
  120. package/src/process/kill-tree.ts +43 -0
  121. package/src/provider/strict-schema.ts +90 -0
  122. package/src/provider/token-budget.ts +31 -1
  123. package/src/public-runtime.ts +12 -0
  124. package/src/public-tools.ts +6 -0
  125. package/src/public-types.ts +3 -0
  126. package/src/runtime/jobs/job-id.ts +66 -0
  127. package/src/runtime/jobs/registry.ts +2 -3
  128. package/src/runtime/query/prompt.ts +5 -1
  129. package/src/tools/builtins/bash.ts +33 -3
  130. package/src/tools/builtins/edit.ts +12 -1
  131. package/src/tools/builtins/job.ts +4 -0
  132. package/src/tools/builtins/wait-for-job.ts +4 -0
  133. package/src/tools/command-shell.ts +194 -11
@@ -20,6 +20,17 @@ export { BackgroundJobOutputWaitLimitError } from './runtime/jobs/registry.js'
20
20
  export { HostCallDeniedError } from './execution/code-runtime/types.js'
21
21
  export { WorkerCodeRuntime } from './execution/code-runtime/worker.js'
22
22
 
23
+ // The workflow runtime: runs a model-authored orchestration script on the
24
+ // interpreter above, with `agent()` as its only host call.
25
+ export {
26
+ FileWorkflowJournal,
27
+ parseWorkflowMeta,
28
+ resolveWorkflowLimits,
29
+ summarizeWorkflowPhases,
30
+ WorkflowRuntime,
31
+ WorkflowScriptError,
32
+ } from './execution/workflow/index.js'
33
+
23
34
  export {
24
35
  CompactionConfigSchema,
25
36
  PluginRuntimeConfigSchema,
@@ -268,6 +279,7 @@ export {
268
279
  // Strict tool input is a SUBSET of JSON Schema, and a keyword outside it makes
269
280
  // the vendor reject the whole request rather than degrade one field.
270
281
  export {
282
+ acceptsStrictSchema,
271
283
  assertStrictSchema,
272
284
  findStrictSchemaViolations,
273
285
  } from './provider/strict-schema.js'
@@ -54,9 +54,15 @@ export { builtinCommandArguments, commandArgumentOf } from './tools/command-argu
54
54
  export {
55
55
  type CommandShell,
56
56
  type CommandShellProbe,
57
+ WINDOWS_BASH_GUIDANCE,
58
+ WINDOWS_CMD_FALLBACK_NOTICE,
57
59
  findCommandShell,
58
60
  findCommandShellForDialect,
61
+ findGitBash,
59
62
  hostCommandShell,
63
+ isWindowsBash,
64
+ rewriteWindowsNullRedirect,
65
+ windowsShellNotice,
60
66
  describeSpawnFailure,
61
67
  hostShellSpawn,
62
68
  installedCommandShellForDialect,
@@ -32,6 +32,9 @@ export type { CoalesceOptions } from './streaming/coalesce.js'
32
32
  export type * from './types/bidi/index.js'
33
33
  export type * from './types/tool/index.js'
34
34
  export type * from './types/job/index.js'
35
+ export type * from './execution/workflow/types.js'
36
+ export type { PhaseAgentView, PhaseRecord } from './execution/workflow/phases.js'
37
+ export type { WorkflowRuntimeOptions } from './execution/workflow/runtime.js'
35
38
  export type { WorkerCodeRuntimeOptions } from './execution/code-runtime/worker.js'
36
39
  export type {
37
40
  CodeRunOutcome,
@@ -0,0 +1,66 @@
1
+ import { randomBytes } from 'node:crypto'
2
+
3
+ /**
4
+ * Background job ids: `b` and eight characters of `0-9a-z`.
5
+ *
6
+ * Random rather than counted. A counter restarts with every registry, so a
7
+ * resumed conversation that still says `job_1` about a dead job would be
8
+ * answered by whatever job happens to be `job_1` in the new session. A random
9
+ * id from a space of 36^8 does not collide across sessions in practice, and
10
+ * the registry additionally never hands out one it already holds.
11
+ *
12
+ * Ids are opaque to callers: nothing may parse them or rely on the shape.
13
+ */
14
+ const ALPHABET = '0123456789abcdefghijklmnopqrstuvwxyz'
15
+ const ID_BODY_LENGTH = 8
16
+ const ID_PATTERN = /^b[0-9a-z]{8}$/
17
+
18
+ /**
19
+ * Whether `id` looks like one this SDK minted (`b` + 8 base36 characters).
20
+ * A helper only: the tools do not gate on it, because a host's own registry
21
+ * may use other ids. Ids are opaque; a host must not parse them.
22
+ */
23
+ export function isBackgroundJobId(id: unknown): id is string {
24
+ return typeof id === 'string' && ID_PATTERN.test(id)
25
+ }
26
+
27
+ /**
28
+ * A fresh id. Bytes are mapped onto the alphabet by rejection so every
29
+ * character is uniform (256 is not a multiple of 36).
30
+ */
31
+ export function createBackgroundJobId(isTaken: (id: string) => boolean = () => false): string {
32
+ for (;;) {
33
+ let body = ''
34
+ while (body.length < ID_BODY_LENGTH) {
35
+ for (const byte of randomBytes(ID_BODY_LENGTH * 2)) {
36
+ if (byte >= 252) continue // 252 = 36 * 7
37
+ body += ALPHABET[byte % 36]
38
+ if (body.length === ID_BODY_LENGTH) break
39
+ }
40
+ }
41
+ const id = `b${body}`
42
+ if (!isTaken(id)) return id
43
+ }
44
+ }
45
+
46
+ const MAX_JOB_ID_INPUT_LENGTH = 128
47
+
48
+ /**
49
+ * The tools' only gate on an id a caller passes in. A host may supply its own
50
+ * registry with its own id format, so shape is NOT checked here: anything that
51
+ * is a sane single token goes on to the registry, whose "No background job
52
+ * <id>." answers for an unknown one. Returns a plain refusal for input that
53
+ * cannot be an id at all (not a string, empty, over 128 characters, or
54
+ * containing whitespace or control characters), otherwise undefined.
55
+ */
56
+ export function invalidBackgroundJobIdInput(id: unknown): string | undefined {
57
+ if (typeof id !== 'string' || id.length === 0) return 'A background job id is required.'
58
+ if (id.length > MAX_JOB_ID_INPUT_LENGTH) {
59
+ return `That is not a background job id: it is longer than ${MAX_JOB_ID_INPUT_LENGTH} characters.`
60
+ }
61
+ // biome-ignore lint/suspicious/noControlCharactersInRegex: rejecting control characters is the point.
62
+ if (/[\s\u0000-\u001f\u007f-\u009f]/u.test(id)) {
63
+ return 'That is not a background job id: ids contain no spaces or control characters.'
64
+ }
65
+ return undefined
66
+ }
@@ -10,6 +10,7 @@ import type {
10
10
  BackgroundJobOutputWaitResult,
11
11
  } from '../../types/job/index.js'
12
12
  import { subscribeToAbort } from '../../utils/abort.js'
13
+ import { createBackgroundJobId } from './job-id.js'
13
14
 
14
15
  /**
15
16
  * Work that outlives a tool call, owned by whoever started it.
@@ -213,7 +214,6 @@ const GROUP_POLL_MS = 250
213
214
 
214
215
  export class BackgroundJobRegistry {
215
216
  private readonly jobs = new Map<string, JobEntry>()
216
- private counter = 0
217
217
  private readonly exitListeners = new Set<(job: BackgroundJob) => void>()
218
218
  private outputWaiters = 0
219
219
  private readonly outputWaitersByOwner = new Map<string, number>()
@@ -303,8 +303,7 @@ export class BackgroundJobRegistry {
303
303
  throw new BackgroundJobLimitError({ owner: params.owner, limit: this.maxJobs })
304
304
  }
305
305
 
306
- this.counter += 1
307
- const id = `job_${this.counter}`
306
+ const id = createBackgroundJobId((candidate) => this.jobs.has(candidate))
308
307
  // Same scrub as the foreground path: inheritance is implicit, so the
309
308
  // operator's provider credentials do not travel into a command nobody
310
309
  // decided should see them. A background job outlives the call that
@@ -5,6 +5,7 @@ import {
5
5
  type PromptContributionRegistry,
6
6
  SKILLS_CONTRIBUTION_ID,
7
7
  } from '../../prompt/contributions.js'
8
+ import { isWindowsBash } from '../../tools/command-shell.js'
8
9
  import type { ToolManager } from '../../toolsets/manager.js'
9
10
  import type { AgentRuntimeContext } from '../../types/agent/base.js'
10
11
  import type { AgentContextLevel } from '../../types/agent/factory.js'
@@ -135,8 +136,11 @@ function buildShellContext(config: PromptBuilderConfig): string | undefined {
135
136
  )
136
137
  continue
137
138
  const dialect = tool.commandDialect({ sandboxed: config.sandboxed === true })
139
+ // Git Bash on a Windows host is named, so the line does not read as the
140
+ // Linux shell it is not. The syntax rules are in the tool's description.
141
+ const gitBash = !config.sandboxed && dialect === 'bash' && isWindowsBash()
138
142
  lines.push(
139
- `Execution shell for ${name}: ${dialect}${config.sandboxed ? ' (sandbox guest)' : ' (host)'}.`,
143
+ `Execution shell for ${name}: ${dialect}${config.sandboxed ? ' (sandbox guest)' : gitBash ? ' (Git Bash on Windows, host)' : ' (host)'}.`,
140
144
  )
141
145
  if (dialect === 'cmd')
142
146
  lines.push(
@@ -8,8 +8,12 @@ import { subscribeToAbort } from '../../utils/abort.js'
8
8
  import { readPositiveIntEnv } from '../../utils/env.js'
9
9
  import {
10
10
  type CommandShell,
11
+ WINDOWS_BASH_GUIDANCE,
11
12
  bashToolDialect,
12
13
  describeSpawnFailure,
14
+ hostCommandShell,
15
+ isWindowsBash,
16
+ rewriteWindowsNullRedirect,
13
17
  sandboxShellSpawn,
14
18
  spawnHostShell,
15
19
  withoutBashStartup,
@@ -57,6 +61,12 @@ const inputSchema = z.object({
57
61
  command: z
58
62
  .string()
59
63
  .min(1)
64
+ // Git Bash on Windows: a model that writes `>nul` meant the null device.
65
+ // Rewritten here, before the permission rules read the line and before
66
+ // it runs, so the two see the same text.
67
+ .transform((command) =>
68
+ isWindowsBash(hostCommandShell()) ? rewriteWindowsNullRedirect(command) : command,
69
+ )
60
70
  .describe(
61
71
  'The bash command to execute. Required, non-empty. Single command per call (use `&&` / `;` chaining for compound commands). Avoid heredocs that span more than a few hundred bytes — large content should be created with `write`, then extended with `edit` insertLine: "end", not piped into bash.',
62
72
  ),
@@ -336,10 +346,12 @@ export const SANDBOX_CANNOT_DETACH =
336
346
  export const SANDBOX_ESCAPE_NOT_APPROVED =
337
347
  'dangerously_disable_sandbox was not approved for this call, so the command did not run. Leaving the sandbox needs the user to confirm it each time, and this turn either cannot ask or does not allow it. Run the command inside the sandbox, or tell the user what it needs and let them run it.'
338
348
 
339
- export const BashTool = defineTool({
349
+ const BASH_DESCRIPTION =
350
+ 'Executes a command line using the execution shell disclosed in the system context and returns stdout/stderr output. Command timeout is configurable. The `command` parameter is required — never call this tool with empty arguments. For very long content (e.g. building a large file), prefer `write` for the opening and `edit` with insertLine: "end" for follow-up chunks over a heredoc to avoid hitting the output token limit mid-stream.'
351
+
352
+ const BashToolBase = defineTool({
340
353
  name: 'bash',
341
- description:
342
- 'Executes a command line using the execution shell disclosed in the system context and returns stdout/stderr output. Command timeout is configurable. The `command` parameter is required — never call this tool with empty arguments. For very long content (e.g. building a large file), prefer `write` for the opening and `edit` with insertLine: "end" for follow-up chunks over a heredoc to avoid hitting the output token limit mid-stream.',
354
+ description: BASH_DESCRIPTION,
343
355
  inputSchema,
344
356
  // A command carries quotes and backslashes of its own, and a heredoc
345
357
  // carries newlines: copied into the JSON string raw, they end it.
@@ -721,3 +733,21 @@ function describeWithheldEnv(dropped: readonly string[]): string {
721
733
  const names = rest > 0 ? `${shown}, and ${rest} more` : shown
722
734
  return `NOTE: ${dropped.length} credential-shaped environment variable(s) were withheld from this command and are unset rather than empty: ${names}. A host that means this command to have one passes it explicitly.`
723
735
  }
736
+
737
+ /**
738
+ * The same tool, whose description gains the Windows guidance when the host
739
+ * shell is Git Bash. Resolved on first read, not at import: finding Git Bash
740
+ * runs `where.exe`, which a program that only imports the SDK must not pay.
741
+ */
742
+ export const BashTool: typeof BashToolBase = Object.defineProperty(
743
+ { ...BashToolBase },
744
+ 'description',
745
+ {
746
+ enumerable: true,
747
+ configurable: true,
748
+ get: () =>
749
+ isWindowsBash(hostCommandShell())
750
+ ? `${BASH_DESCRIPTION} ${WINDOWS_BASH_GUIDANCE}`
751
+ : BASH_DESCRIPTION,
752
+ },
753
+ )
@@ -213,11 +213,22 @@ const modelInputSchema: Record<string, unknown> = {
213
213
  // The two branches are disjoint, so `anyOf` and `oneOf` mean the
214
214
  // same thing here — nothing is loosened.
215
215
  //
216
+ // Every branch carries a `type`. The strict subset does not let a
217
+ // branch be a bare `{ const: 'end' }`: a validator answers
218
+ // `Invalid schema for function 'edit': In context=('properties',
219
+ // 'insertLine', 'anyOf', '1'), schema must have a 'type' key` and
220
+ // refuses the WHOLE request, so the tool never mounts and the turn
221
+ // dies before a single token. Stating `{ type: 'string', const:
222
+ // 'end' }` says the same thing — `"end"` is still the only string
223
+ // the branch admits — and satisfies the subset. Observed on the
224
+ // Zen free-tier gateway, which enforces `strict` on every model
225
+ // that reaches it.
226
+ //
216
227
  // `minimum` is gone for the same reason: numeric constraints are
217
228
  // outside the strict subset too. The bound is not lost — the
218
229
  // execution schema still enforces it, which is where a value that
219
230
  // crosses a boundary should be checked anyway.
220
- anyOf: [{ type: 'integer' }, { const: 'end' }],
231
+ anyOf: [{ type: 'integer' }, { type: 'string', const: 'end' }],
221
232
  description:
222
233
  'Insert instead of replacing. The new_string goes after this 1-indexed line; 0 inserts before the first line; "end" appends. Omit for a find-and-replace.',
223
234
  },
@@ -1,5 +1,6 @@
1
1
  import { z } from 'zod'
2
2
 
3
+ import { invalidBackgroundJobIdInput } from '../../runtime/jobs/job-id.js'
3
4
  import { defineTool } from '../defineTool.js'
4
5
 
5
6
  /**
@@ -90,6 +91,9 @@ export const JobTool = defineTool({
90
91
  }
91
92
  }
92
93
 
94
+ const badId = invalidBackgroundJobIdInput(input.id)
95
+ if (badId) return { success: false, output: '', error: badId }
96
+
93
97
  try {
94
98
  if (input.action === 'kill') {
95
99
  const killed = await jobs.kill(input.id)
@@ -1,5 +1,6 @@
1
1
  import { z } from 'zod'
2
2
 
3
+ import { invalidBackgroundJobIdInput } from '../../runtime/jobs/job-id.js'
3
4
  import { readPositiveIntEnv } from '../../utils/env.js'
4
5
  import { defineTool } from '../defineTool.js'
5
6
  import { describeJobWaitTimeout, waitForJobWithBounds } from './wait-for-job-bounds.js'
@@ -131,6 +132,9 @@ export const WaitForJobTool = defineTool({
131
132
  }
132
133
  const jobs = context.backgroundJobs
133
134
 
135
+ const badId = invalidBackgroundJobIdInput(input.id)
136
+ if (badId) return { success: false, output: '', error: badId }
137
+
134
138
  try {
135
139
  jobs.get(input.id)
136
140
  } catch (err) {
@@ -32,12 +32,30 @@
32
32
  * those would change what a command line means after the rules read it, so
33
33
  * they are removed from the environment of the spawned bash.
34
34
  * `NAMZU_BASH_SHELL=/bin/sh` restores the old shell exactly.
35
+ *
36
+ * ## Windows
37
+ *
38
+ * `cmd.exe` is a dialect the permission lexer cannot read: every command
39
+ * line comes back opaque, so every call needs a person in every mode, and a
40
+ * model writes POSIX syntax that cmd cannot run. So on Windows the same rule
41
+ * applies as everywhere else: run bash where it exists, and read the line as
42
+ * bash. The bash that exists on a Windows machine is Git for Windows'
43
+ * (`findGitBash`). Without it the tool falls back to cmd, as before, and
44
+ * {@link windowsShellNotice} says so once. `NAMZU_BASH_SHELL=cmd` keeps cmd
45
+ * on a machine that has Git Bash.
35
46
  */
36
47
 
37
- import { type ChildProcess, type SpawnOptions, spawn as nodeSpawn } from 'node:child_process'
48
+ import {
49
+ type ChildProcess,
50
+ type SpawnOptions,
51
+ execFileSync,
52
+ spawn as nodeSpawn,
53
+ } from 'node:child_process'
54
+ import { randomUUID } from 'node:crypto'
38
55
  import { constants, accessSync, existsSync } from 'node:fs'
39
- import { delimiter, isAbsolute, join } from 'node:path'
56
+ import { delimiter, isAbsolute, join, win32 } from 'node:path'
40
57
 
58
+ import { MSYS_TAG_VARIABLE, registerMsysShell } from '../process/kill-tree.js'
41
59
  import type { ShellDialect } from '../types/tool/index.js'
42
60
 
43
61
  /** The shell a host-side command runs in. */
@@ -48,6 +66,8 @@ export interface CommandShell {
48
66
  readonly dialect: ShellDialect
49
67
  /** Where the choice came from, for diagnostics. */
50
68
  readonly source: 'override' | 'bash' | 'sh' | 'platform'
69
+ /** True when this is a Windows executable (Git Bash), which needs Windows-only spawn details. */
70
+ readonly windows?: true
51
71
  }
52
72
 
53
73
  /** What resolution looks at. Injected by tests to simulate a host without bash. */
@@ -55,6 +75,13 @@ export interface CommandShellProbe {
55
75
  readonly env: NodeJS.ProcessEnv
56
76
  readonly platform: NodeJS.Platform
57
77
  readonly isExecutable: (path: string) => boolean
78
+ /**
79
+ * Windows only: the paths `where.exe git` prints, one per entry, in order.
80
+ * Absent or throwing means git was not found on PATH.
81
+ */
82
+ readonly whereGit?: () => readonly string[]
83
+ /** Windows only: the working directory, whose own programs are never trusted. Default: `process.cwd()`. */
84
+ readonly cwd?: string
58
85
  }
59
86
 
60
87
  const WELL_KNOWN_BASH = ['/bin/bash', '/usr/bin/bash']
@@ -63,6 +90,66 @@ const WELL_KNOWN_SH = ['/bin/sh', '/usr/bin/sh']
63
90
  /** Environment variables that change what a bash command line means. */
64
91
  const BASH_STARTUP_VARIABLES = new Set(['BASH_ENV', 'ENV', 'SHELLOPTS', 'BASHOPTS'])
65
92
 
93
+ /** A variable of a Windows environment, whose names are not case-sensitive. */
94
+ function windowsEnv(env: NodeJS.ProcessEnv, name: string): string | undefined {
95
+ const key = Object.keys(env).find((k) => k.toLowerCase() === name.toLowerCase())
96
+ return key === undefined ? undefined : env[key]
97
+ }
98
+
99
+ /** Is `path` the working directory or inside it? Windows paths compare without case. */
100
+ function insideDirectory(path: string, directory: string): boolean {
101
+ const relative = win32.relative(directory, path)
102
+ return relative === '' || (!relative.startsWith('..') && !win32.isAbsolute(relative))
103
+ }
104
+
105
+ /**
106
+ * Git for Windows' bash.exe, found in this order:
107
+ *
108
+ * 1. `%ProgramFiles%\Git\bin\bash.exe`, then `%ProgramFiles(x86)%\Git\bin\bash.exe`;
109
+ * 2. `%LOCALAPPDATA%\\Programs\\Git\\bin\\bash.exe`, a per-user install;
110
+ * 3. the install that holds the first `git` that `where.exe git` lists: its
111
+ * `bin\bash.exe`, one or two directories up from the `git.exe` it printed.
112
+ * A hit inside the working directory is skipped, because a repository can
113
+ * carry a `git.exe` of its own and the search must not run it.
114
+ *
115
+ * There is no registry lookup, and `where bash` is never asked: on a machine
116
+ * with WSL, `System32\bash.exe` is the launcher of another system.
117
+ */
118
+ export function findGitBash(probe: CommandShellProbe): string | undefined {
119
+ const programFiles = windowsEnv(probe.env, 'ProgramFiles') ?? 'C:\\Program Files'
120
+ const programFilesX86 = windowsEnv(probe.env, 'ProgramFiles(x86)') ?? 'C:\\Program Files (x86)'
121
+ for (const root of [programFiles, programFilesX86]) {
122
+ const candidate = win32.join(root, 'Git', 'bin', 'bash.exe')
123
+ if (probe.isExecutable(candidate)) return candidate
124
+ }
125
+ // A per-user install (the installer's "just me" choice) lives under LOCALAPPDATA.
126
+ const localAppData = windowsEnv(probe.env, 'LOCALAPPDATA')
127
+ if (localAppData !== undefined && localAppData !== '') {
128
+ const candidate = win32.join(localAppData, 'Programs', 'Git', 'bin', 'bash.exe')
129
+ if (probe.isExecutable(candidate)) return candidate
130
+ }
131
+ let listed: readonly string[] = []
132
+ try {
133
+ listed = probe.whereGit?.() ?? []
134
+ } catch {
135
+ return undefined
136
+ }
137
+ const cwd = probe.cwd ?? process.cwd()
138
+ for (const line of listed) {
139
+ const git = line.trim()
140
+ if (!win32.isAbsolute(git) || insideDirectory(git, cwd)) continue
141
+ const directory = win32.dirname(git)
142
+ // <Git>\cmd\git.exe and <Git>\bin\git.exe, then <Git>\mingw64\bin\git.exe.
143
+ for (const candidate of [
144
+ win32.join(directory, '..', 'bin', 'bash.exe'),
145
+ win32.join(directory, '..', '..', 'bin', 'bash.exe'),
146
+ ]) {
147
+ if (probe.isExecutable(candidate)) return candidate
148
+ }
149
+ }
150
+ return undefined
151
+ }
152
+
66
153
  export function findCommandShell(probe: CommandShellProbe): CommandShell {
67
154
  const override = probe.env.NAMZU_BASH_SHELL
68
155
  if (override !== undefined && override !== '') {
@@ -76,11 +163,27 @@ export function findCommandShell(probe: CommandShellProbe): CommandShell {
76
163
  ?.toLowerCase()
77
164
  .replace(/\.exe$/, '')
78
165
  : override.slice(override.lastIndexOf('/') + 1)
79
- return { path: override, dialect: name === 'bash' ? 'bash' : 'sh', source: 'override' }
166
+ // cmd is not a POSIX shell and takes no `-c`: naming it keeps Node's
167
+ // own cmd path, which is how a Windows machine with Git Bash runs the
168
+ // tool as it did before bash was preferred.
169
+ if (probe.platform === 'win32' && name === 'cmd')
170
+ return { path: undefined, dialect: 'cmd', source: 'override' }
171
+ return {
172
+ path: override,
173
+ dialect: name === 'bash' ? 'bash' : 'sh',
174
+ source: 'override',
175
+ ...(probe.platform === 'win32' ? { windows: true as const } : {}),
176
+ }
177
+ }
178
+ // Looking `bash` up on the Windows PATH can find WSL's launcher, which
179
+ // runs the command in another system, so only Git for Windows' own
180
+ // install is used. Without it Node's platform shell (cmd) runs the line.
181
+ if (probe.platform === 'win32') {
182
+ const gitBash = findGitBash(probe)
183
+ return gitBash === undefined
184
+ ? { path: undefined, dialect: 'cmd', source: 'platform' }
185
+ : { path: gitBash, dialect: 'bash', source: 'bash', windows: true }
80
186
  }
81
- // Windows keeps Node's platform shell. Looking `bash` up on its PATH can
82
- // find WSL's launcher, which runs the command in another system.
83
- if (probe.platform === 'win32') return { path: undefined, dialect: 'cmd', source: 'platform' }
84
187
  for (const directory of (probe.env.PATH ?? '').split(delimiter)) {
85
188
  if (directory === '' || !directory.startsWith('/')) continue
86
189
  const candidate = join(directory, 'bash')
@@ -92,6 +195,55 @@ export function findCommandShell(probe: CommandShellProbe): CommandShell {
92
195
  return { path: '/bin/sh', dialect: 'sh', source: 'sh' }
93
196
  }
94
197
 
198
+ /**
199
+ * Said once, in the session, when a Windows machine has no Git Bash and the
200
+ * `bash` tool runs cmd.exe. Undefined everywhere else.
201
+ */
202
+ export const WINDOWS_CMD_FALLBACK_NOTICE =
203
+ 'Git for Windows not found — shell commands run in cmd.exe and each one asks for approval. Install Git for Windows for full support.'
204
+
205
+ export function windowsShellNotice(shell: CommandShell = hostCommandShell()): string | undefined {
206
+ return shell.source === 'platform' && shell.path === undefined && shell.dialect === 'cmd'
207
+ ? WINDOWS_CMD_FALLBACK_NOTICE
208
+ : undefined
209
+ }
210
+
211
+ /**
212
+ * Is the host `bash` tool Git Bash on Windows? The model is then told so, and
213
+ * the Windows-only spawn details apply.
214
+ */
215
+ export function isWindowsBash(shell: CommandShell = hostCommandShell()): boolean {
216
+ return shell.windows === true && shell.dialect === 'bash'
217
+ }
218
+
219
+ /** What a model needs to know when the `bash` tool is Git Bash on Windows, appended to the tool's description. */
220
+ export const WINDOWS_BASH_GUIDANCE =
221
+ 'This tool runs Git Bash (POSIX sh), not cmd.exe or PowerShell. Use Unix shell syntax: `/dev/null` not `NUL`, forward slashes, `$VAR` not `%VAR%` or `$env:VAR`. To run PowerShell, call `powershell -NoProfile -Command "…"`.'
222
+
223
+ /**
224
+ * Rewrite a redirect to Windows' `NUL` device (`>nul`, `2>NUL`, `&>nul`) to
225
+ * `/dev/null`. In bash `nul` is an ordinary file name, so the line would
226
+ * create a file called `nul` that Windows then cannot delete. A line with
227
+ * `<`, `$` or a backtick is left alone: it may be a heredoc or a string that
228
+ * only mentions the text.
229
+ */
230
+ export function rewriteWindowsNullRedirect(command: string): string {
231
+ if (/[<$`]/.test(command)) return command
232
+ return command.replace(/(\d?&?>+[ \t]*)nul(?=\s|$|[|&;)\n])/gi, '$1/dev/null')
233
+ }
234
+
235
+ /** `where.exe git`, run from the system directory by its absolute path, so PATH cannot stand in for it. */
236
+ function whereGit(): readonly string[] {
237
+ const root = process.env.SystemRoot ?? process.env.windir ?? 'C:\\Windows'
238
+ const output = execFileSync(win32.join(root, 'System32', 'where.exe'), ['git'], {
239
+ encoding: 'utf8',
240
+ windowsHide: true,
241
+ stdio: ['ignore', 'pipe', 'ignore'],
242
+ timeout: 5000,
243
+ })
244
+ return output.split(/\r?\n/)
245
+ }
246
+
95
247
  /**
96
248
  * An explicitly requested interpreter for a host script. Unlike the live
97
249
  * `bash` tool's automatic choice, this never substitutes bash for sh (or sh
@@ -144,7 +296,12 @@ let resolved: CommandShell | undefined
144
296
  */
145
297
  export function hostCommandShell(): CommandShell {
146
298
  if (resolved === undefined) {
147
- resolved = findCommandShell({ env: process.env, platform: process.platform, isExecutable })
299
+ resolved = findCommandShell({
300
+ env: process.env,
301
+ platform: process.platform,
302
+ isExecutable,
303
+ whereGit,
304
+ })
148
305
  }
149
306
  return resolved
150
307
  }
@@ -180,7 +337,17 @@ export function hostShellSpawn(
180
337
  } {
181
338
  if (shell.path === undefined) return { file: undefined, args: [command], env }
182
339
  if (shell.dialect !== 'bash') return { file: shell.path, args: ['-c', command], env }
183
- return { file: shell.path, args: ['-c', command], env: withoutBashStartup(env) }
340
+ const clean = withoutBashStartup(env)
341
+ // Git for Windows: the line is read as bash, so `>nul` must already have
342
+ // become `/dev/null` (the tool's input does that before the rules read it;
343
+ // this covers a caller that did not), and git must never open an editor.
344
+ if (shell.windows === true)
345
+ return {
346
+ file: shell.path,
347
+ args: ['-c', rewriteWindowsNullRedirect(command)],
348
+ env: { ...clean, GIT_EDITOR: 'true' },
349
+ }
350
+ return { file: shell.path, args: ['-c', command], env: clean }
184
351
  }
185
352
 
186
353
  /** Why a command could not be started, in the words a model or operator reads. */
@@ -283,15 +450,31 @@ export function spawnHostShell(
283
450
  },
284
451
  ): ChildProcess {
285
452
  const spawnFn = options.spawnImpl ?? nodeSpawn
286
- const resolved = hostShellSpawn(command, options.env, options.shell)
453
+ const shell = options.shell ?? hostCommandShell()
454
+ const spawned = hostShellSpawn(command, options.env, shell)
455
+ // Git Bash: tag the shell so a kill can find what a forked command left.
456
+ const tag = shell.windows === true && shell.path !== undefined ? randomUUID() : undefined
457
+ const resolved =
458
+ tag === undefined ? spawned : { ...spawned, env: { ...spawned.env, [MSYS_TAG_VARIABLE]: tag } }
287
459
  const common: SpawnOptions = {
288
460
  cwd: options.cwd,
289
461
  env: resolved.env,
290
462
  detached: options.detached ?? (options.platform ?? process.platform) !== 'win32',
291
463
  windowsHide: true,
292
- ...(options.stdio === undefined ? {} : { stdio: options.stdio }),
464
+ // Git Bash reads no terminal: a command that asks for input gets
465
+ // end-of-file (the null device) instead of waiting for a timeout.
466
+ ...(options.stdio === undefined
467
+ ? resolved.file !== undefined && shell.windows === true
468
+ ? { stdio: ['ignore', 'pipe', 'pipe'] as SpawnOptions['stdio'] }
469
+ : {}
470
+ : { stdio: options.stdio }),
471
+ }
472
+ if (resolved.file !== undefined) {
473
+ const child = spawnFn(resolved.file, [...resolved.args], common)
474
+ if (tag !== undefined && child !== null && typeof child === 'object')
475
+ registerMsysShell(child, { bash: resolved.file, tag })
476
+ return child
293
477
  }
294
- if (resolved.file !== undefined) return spawnFn(resolved.file, [...resolved.args], common)
295
478
  const windows = windowsCmdSpawn(command, resolved.env)
296
479
  return spawnFn(windows.file, [...windows.args], {
297
480
  ...common,