···
9
9
"apps/*"
10
10
],
11
11
"scripts": {
12
12
-
"start": "dotenv -- tsx src/index.ts",
13
13
-
"start:local": "NIRI_ENV=local dotenv -- tsx src/index.ts",
14
14
-
"dev": "dotenv -- tsx watch src/index.ts",
15
15
-
"dev:local": "NIRI_ENV=local dotenv -- tsx watch src/index.ts",
12
12
+
"start": "dotenv -o -- tsx src/index.ts",
13
13
+
"start:local": "NIRI_ENV=local dotenv -o -- tsx src/index.ts",
14
14
+
"dev": "dotenv -o -- tsx watch src/index.ts",
15
15
+
"dev:local": "NIRI_ENV=local dotenv -o -- tsx watch src/index.ts",
16
16
"test": "node --import tsx --test src/**/*.test.ts",
17
17
-
"chat": "dotenv -- tsx scripts/chat.ts",
18
18
-
"chat:local": "NIRI_ENV=local dotenv -- tsx scripts/chat.ts",
17
17
+
"chat": "dotenv -o -- tsx scripts/chat.ts",
18
18
+
"chat:local": "NIRI_ENV=local dotenv -o -- tsx scripts/chat.ts",
19
19
"dev:web": "npm run dev --workspace @niri/web",
20
20
"build:web": "npm run build --workspace @niri/web",
21
21
"preview:web": "npm run preview --workspace @niri/web"
···
1
1
+
import test from "node:test"
2
2
+
import assert from "node:assert/strict"
3
3
+
import fs from "node:fs/promises"
4
4
+
import os from "node:os"
5
5
+
import path from "node:path"
6
6
+
import { closeBash } from "./shell.js"
7
7
+
import { runCommand } from "./tools.js"
8
8
+
9
9
+
test.afterEach(() => {
10
10
+
closeBash()
11
11
+
})
12
12
+
13
13
+
test("sudo commands run as one-off commands with stdin closed", async () => {
14
14
+
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "niri-fake-sudo-"))
15
15
+
const fakeSudo = path.join(dir, "sudo")
16
16
+
await fs.writeFile(
17
17
+
fakeSudo,
18
18
+
[
19
19
+
"#!/bin/sh",
20
20
+
"if [ -t 0 ]; then",
21
21
+
" echo stdin_tty",
22
22
+
"else",
23
23
+
" echo stdin_not_tty",
24
24
+
"fi",
25
25
+
'exec "$@"',
26
26
+
"",
27
27
+
].join("\n"),
28
28
+
{ mode: 0o755 },
29
29
+
)
30
30
+
31
31
+
const oldPath = process.env.PATH
32
32
+
process.env.PATH = `${dir}${path.delimiter}${oldPath ?? ""}`
33
33
+
try {
34
34
+
const output = await runCommand("echo before; sudo printf ok; echo after", 0, 5_000)
35
35
+
36
36
+
assert.equal(output, "before\nstdin_not_tty\nokafter")
37
37
+
} finally {
38
38
+
if (oldPath === undefined) {
39
39
+
delete process.env.PATH
40
40
+
} else {
41
41
+
process.env.PATH = oldPath
42
42
+
}
43
43
+
await fs.rm(dir, { recursive: true, force: true })
44
44
+
}
45
45
+
})
···
1
1
import * as pty from "node-pty"
2
2
import { randomBytes } from "crypto"
3
3
+
import { spawn } from "child_process"
3
4
import {
4
5
CONTAINER_NAME,
5
6
CONTAINER_USER,
···
218
219
}, timeoutMs)
219
220
})
220
221
}
222
222
+
223
223
+
/**
224
224
+
* Runs a command as a one-off child process instead of through the persistent
225
225
+
* PTY shell. This follows Codex's shell-tool execution model for commands
226
226
+
* that should not inherit interactive stdin, notably sudo.
227
227
+
*/
228
228
+
export async function runOneOff(command: string, cwd: string, options: RunRawOptions = {}): Promise<string> {
229
229
+
const timeoutMs = normalizeTimeoutMs(options.timeoutMs, DEFAULT_COMMAND_TIMEOUT_MS)
230
230
+
const args = USE_DOCKER_SHELL
231
231
+
? ["exec", "-i", "-u", CONTAINER_USER, "-w", cwd, CONTAINER_NAME, "bash", "-c", command]
232
232
+
: ["-c", command]
233
233
+
const program = USE_DOCKER_SHELL ? "docker" : "bash"
234
234
+
235
235
+
return new Promise((resolve, reject) => {
236
236
+
let raw = ""
237
237
+
let settled = false
238
238
+
const child = spawn(program, args, {
239
239
+
cwd: USE_DOCKER_SHELL ? undefined : cwd,
240
240
+
env: process.env,
241
241
+
stdio: ["ignore", "pipe", "pipe"],
242
242
+
})
243
243
+
244
244
+
child.stdout.on("data", (chunk) => {
245
245
+
raw += chunk.toString("utf8")
246
246
+
})
247
247
+
child.stderr.on("data", (chunk) => {
248
248
+
raw += chunk.toString("utf8")
249
249
+
})
250
250
+
child.on("error", (err) => {
251
251
+
if (settled) return
252
252
+
settled = true
253
253
+
clearTimeout(timer)
254
254
+
reject(err)
255
255
+
})
256
256
+
child.on("close", () => {
257
257
+
if (settled) return
258
258
+
settled = true
259
259
+
clearTimeout(timer)
260
260
+
resolve(cleanOutput(raw).trimEnd())
261
261
+
})
262
262
+
263
263
+
const timer = setTimeout(() => {
264
264
+
if (settled) return
265
265
+
settled = true
266
266
+
child.kill("SIGKILL")
267
267
+
reject(new Error(`Command timed out after ${timeoutMs}ms: ${command}`))
268
268
+
}, timeoutMs)
269
269
+
})
270
270
+
}
···
10
10
normalizeTimeoutMs,
11
11
resolveMaxLines,
12
12
} from "./config.js"
13
13
-
import { currentWorkingDirectory, runRaw } from "./shell.js"
13
13
+
import { currentWorkingDirectory, runOneOff, runRaw } from "./shell.js"
14
14
import type { EditResult, ImageToolPayload, ModelImageInput } from "./types.js"
15
15
+
16
16
+
function invokesSudo(command: string): boolean {
17
17
+
return /(^|[;&|({]\s*)sudo(\s|$)/.test(String(command ?? ""))
18
18
+
}
15
19
16
20
function shouldRedirectStdinToDevNullByDefault(command: string): boolean {
17
21
// This runner executes in a real PTY, but we still want to avoid accidentally
···
76
80
*/
77
81
export async function runCommand(command: string, maxLines?: number, timeoutMs?: number): Promise<string> {
78
82
const cap = resolveMaxLines(command, maxLines)
79
79
-
const raw = await runRaw(command, {
80
80
-
timeoutMs: normalizeTimeoutMs(timeoutMs, DEFAULT_COMMAND_TIMEOUT_MS),
81
81
-
redirectStdinToDevNull: shouldRedirectStdinToDevNullByDefault(command),
82
82
-
})
83
83
+
const opTimeoutMs = normalizeTimeoutMs(timeoutMs, DEFAULT_COMMAND_TIMEOUT_MS)
84
84
+
const raw = invokesSudo(command)
85
85
+
? await runOneOff(command, await currentWorkingDirectory(opTimeoutMs), { timeoutMs: opTimeoutMs })
86
86
+
: await runRaw(command, {
87
87
+
timeoutMs: opTimeoutMs,
88
88
+
redirectStdinToDevNull: shouldRedirectStdinToDevNullByDefault(command),
89
89
+
})
83
90
84
91
if (cap === 0) return raw
85
92