opencode-secrets-obfuscation 0.1.0 → 0.1.2
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.
- package/LICENSE +21 -21
- package/README.md +11 -21
- package/package.json +29 -23
- package/plugin.mjs +3847 -0
- package/.editorconfig +0 -12
- package/.github/workflows/ci.yml +0 -75
- package/.github/workflows/release.yml +0 -50
- package/.node-version +0 -1
- package/AGENTS.md +0 -23
- package/INSTALL.md +0 -104
- package/docs/checkpoint.md +0 -68
- package/docs/history/1-spec-review.md +0 -233
- package/docs/history/10-p0-launch-prerequisites-review.md +0 -186
- package/docs/history/11-p0-job-containment-review.md +0 -188
- package/docs/history/12-p0-api-client.md +0 -15
- package/docs/history/13-p0-status-review.md +0 -122
- package/docs/history/14-p0-containment-final-review.md +0 -84
- package/docs/history/15-config-source-implementation.md +0 -27
- package/docs/history/16-sanitizer-implementation.md +0 -65
- package/docs/history/17-store-policy-implementation.md +0 -36
- package/docs/history/18-pure-mvp-review.md +0 -182
- package/docs/history/19-pure-mvp-re-review.md +0 -142
- package/docs/history/2-p0-runtime-prerequisites.md +0 -163
- package/docs/history/20-pure-mvp-final-review.md +0 -116
- package/docs/history/21-pure-mvp-review-final.md +0 -109
- package/docs/history/22-pure-mvp-signoff-review.md +0 -113
- package/docs/history/23-pure-mvp-signoff-review.md +0 -75
- package/docs/history/24-owned-tools-implementation.md +0 -27
- package/docs/history/25-v2-plugin-integration.md +0 -53
- package/docs/history/26-owned-tools-review.md +0 -138
- package/docs/history/27-owned-tools-final-review.md +0 -93
- package/docs/history/28-owned-tools-signoff.md +0 -98
- package/docs/history/3-p0-harness-foundation.md +0 -105
- package/docs/history/4-p0-source-opening.md +0 -78
- package/docs/history/5-qualification-gate-review.md +0 -133
- package/docs/history/6-p0-infrastructure-review.md +0 -275
- package/docs/history/7-p0-job-containment.md +0 -29
- package/docs/history/8-p0-evidence-support.md +0 -52
- package/docs/history/9-p0-launch-plan.md +0 -88
- package/docs/spec/design.md +0 -111
- package/docs/spec/intent.md +0 -81
- package/docs/spec/plan.md +0 -78
- package/docs/testing.md +0 -157
- package/scripts/build-plugin.mjs +0 -84
- package/scripts/install-opencode-ci.ps1 +0 -39
- package/scripts/install-opencode-ci.sh +0 -27
- package/scripts/smoke.mjs +0 -105
- package/scripts/verify-package.mjs +0 -18
- package/src/README.md +0 -22
- package/src/budgets/index.ts +0 -49
- package/src/config/README.md +0 -4
- package/src/config/options.ts +0 -76
- package/src/diagnostics/README.md +0 -4
- package/src/opencode/v2/filter.ts +0 -350
- package/src/opencode/v2/index.ts +0 -86
- package/src/opencode/v2/runtime.ts +0 -207
- package/src/opencode/v2/transports/README.md +0 -5
- package/src/runtime/http-transport.ts +0 -20
- package/src/sanitize/README.md +0 -4
- package/src/sanitize/index.ts +0 -454
- package/src/sources/README.md +0 -5
- package/src/sources/dotenv.ts +0 -133
- package/src/sources/native/README.md +0 -5
- package/src/store/index.ts +0 -64
- package/tests/README.md +0 -26
- package/tests/contract/plugin-v2.test.ts +0 -29
- package/tests/fixtures/README.md +0 -9
- package/tests/fixtures/job-child.mjs +0 -18
- package/tests/fixtures/plugins/README.md +0 -8
- package/tests/fixtures/process-child.mjs +0 -20
- package/tests/fixtures/services/README.md +0 -10
- package/tests/integration/components.test.ts +0 -5
- package/tests/integration/transparent-runtime.test.ts +0 -97
- package/tests/integration/windows-job.test.ts +0 -71
- package/tests/p0-host/README.md +0 -24
- package/tests/p0-host/linux-job.sh +0 -61
- package/tests/p0-host/windows-job.ps1 +0 -51
- package/tests/p0-native/README.md +0 -33
- package/tests/p0-native/windows-handle-loader.tests.ps1 +0 -191
- package/tests/scaffold/repository.test.ts +0 -105
- package/tests/smoke/README.md +0 -37
- package/tests/smoke/cases.json +0 -17
- package/tests/smoke/qualification.json +0 -20
- package/tests/smoke/runtime-candidate.json +0 -111
- package/tests/support/README.md +0 -32
- package/tests/support/child-process.ts +0 -235
- package/tests/support/host-api.ts +0 -123
- package/tests/support/opencode-environment.ts +0 -239
- package/tests/support/qualification.ts +0 -154
- package/tests/support/recording-endpoint.ts +0 -242
- package/tests/support/run-directory.ts +0 -179
- package/tests/support/smoke-cases.ts +0 -289
- package/tests/unit/budgets.test.ts +0 -35
- package/tests/unit/config.test.ts +0 -40
- package/tests/unit/dotenv.test.ts +0 -72
- package/tests/unit/filter-size.test.ts +0 -132
- package/tests/unit/harness.test.ts +0 -228
- package/tests/unit/host-api.test.ts +0 -122
- package/tests/unit/opencode-environment.test.ts +0 -161
- package/tests/unit/qualification.test.ts +0 -128
- package/tests/unit/recording-endpoint.test.ts +0 -188
- package/tests/unit/sanitize.test.ts +0 -159
- package/tests/unit/smoke-cases.test.ts +0 -20
- package/tsconfig.json +0 -20
|
@@ -1,235 +0,0 @@
|
|
|
1
|
-
import { spawn, type ChildProcess } from "node:child_process"
|
|
2
|
-
import { createHash } from "node:crypto"
|
|
3
|
-
import { lstat, readFile } from "node:fs/promises"
|
|
4
|
-
import { isAbsolute } from "node:path"
|
|
5
|
-
|
|
6
|
-
export type ChildErrorCode =
|
|
7
|
-
| "INVALID_OPTIONS"
|
|
8
|
-
| "EXECUTABLE_MISMATCH"
|
|
9
|
-
| "STARTUP_TIMEOUT"
|
|
10
|
-
| "TIMEOUT"
|
|
11
|
-
| "CANCELLED"
|
|
12
|
-
| "OUTPUT_LIMIT"
|
|
13
|
-
| "SPAWN_FAILED"
|
|
14
|
-
| "EXIT_FAILED"
|
|
15
|
-
| "TEARDOWN_FAILED"
|
|
16
|
-
|
|
17
|
-
export class ChildProcessError extends Error {
|
|
18
|
-
readonly code: ChildErrorCode
|
|
19
|
-
|
|
20
|
-
constructor(code: ChildErrorCode) {
|
|
21
|
-
super(code)
|
|
22
|
-
this.name = "ChildProcessError"
|
|
23
|
-
this.code = code
|
|
24
|
-
}
|
|
25
|
-
}
|
|
26
|
-
|
|
27
|
-
export interface ChildOptions {
|
|
28
|
-
readonly executable: string
|
|
29
|
-
readonly expectedExecutableSha256: string
|
|
30
|
-
readonly args: readonly string[]
|
|
31
|
-
readonly cwd: string
|
|
32
|
-
readonly env: Readonly<Record<string, string>>
|
|
33
|
-
readonly allowedEnv: readonly string[]
|
|
34
|
-
readonly startupTimeoutMs: number
|
|
35
|
-
readonly overallTimeoutMs: number
|
|
36
|
-
readonly stdoutLimitBytes?: number
|
|
37
|
-
readonly stderrLimitBytes?: number
|
|
38
|
-
readonly signal?: AbortSignal
|
|
39
|
-
}
|
|
40
|
-
|
|
41
|
-
export interface ChildResult {
|
|
42
|
-
readonly exitCode: number
|
|
43
|
-
readonly signal: NodeJS.Signals | null
|
|
44
|
-
readonly stdout: string
|
|
45
|
-
readonly stderr: string
|
|
46
|
-
readonly cwd: string
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
const DEFAULT_OUTPUT_LIMIT = 1024 * 1024
|
|
50
|
-
const TERM_GRACE_MS = 100
|
|
51
|
-
const TEARDOWN_LIMIT_MS = 1_000
|
|
52
|
-
|
|
53
|
-
function isObject(value: unknown): value is Record<string, unknown> {
|
|
54
|
-
return typeof value === "object" && value !== null && !Array.isArray(value)
|
|
55
|
-
}
|
|
56
|
-
|
|
57
|
-
function isValid(value: unknown): value is ChildOptions {
|
|
58
|
-
if (!isObject(value)) return false
|
|
59
|
-
const options = value as Partial<ChildOptions>
|
|
60
|
-
if (typeof options.executable !== "string" || !isAbsolute(options.executable)) return false
|
|
61
|
-
if (typeof options.expectedExecutableSha256 !== "string" || !/^[0-9a-f]{64}$/i.test(options.expectedExecutableSha256)) return false
|
|
62
|
-
if (!Array.isArray(options.args) || options.args.some((arg) => typeof arg !== "string")) return false
|
|
63
|
-
if (typeof options.cwd !== "string" || !isAbsolute(options.cwd)) return false
|
|
64
|
-
if (!isObject(options.env) || Object.values(options.env).some((item) => typeof item !== "string")) return false
|
|
65
|
-
if (!Array.isArray(options.allowedEnv) || options.allowedEnv.some((key) => typeof key !== "string" || key.length === 0 || key.includes("="))) return false
|
|
66
|
-
const fold = (key: string): string => process.platform === "win32" ? key.toUpperCase() : key
|
|
67
|
-
const allowed = new Set(options.allowedEnv.map(fold))
|
|
68
|
-
if (allowed.size !== options.allowedEnv.length) return false
|
|
69
|
-
if (Object.keys(options.env).some((key) => !allowed.has(fold(key)) || key.includes("=") || key.length === 0)) return false
|
|
70
|
-
const startup = options.startupTimeoutMs
|
|
71
|
-
const overall = options.overallTimeoutMs
|
|
72
|
-
if (typeof startup !== "number" || !Number.isSafeInteger(startup) || startup <= 0) return false
|
|
73
|
-
if (typeof overall !== "number" || !Number.isSafeInteger(overall) || overall <= 0) return false
|
|
74
|
-
if (options.stdoutLimitBytes !== undefined && (!Number.isSafeInteger(options.stdoutLimitBytes) || options.stdoutLimitBytes < 0)) return false
|
|
75
|
-
if (options.stderrLimitBytes !== undefined && (!Number.isSafeInteger(options.stderrLimitBytes) || options.stderrLimitBytes < 0)) return false
|
|
76
|
-
return options.signal === undefined || options.signal instanceof AbortSignal
|
|
77
|
-
}
|
|
78
|
-
|
|
79
|
-
async function executableMatches(options: ChildOptions): Promise<boolean> {
|
|
80
|
-
try {
|
|
81
|
-
const stat = await lstat(options.executable)
|
|
82
|
-
if (!stat.isFile() || stat.isSymbolicLink() || stat.nlink !== 1) return false
|
|
83
|
-
const digest = createHash("sha256").update(await readFile(options.executable)).digest("hex")
|
|
84
|
-
return digest === options.expectedExecutableSha256.toLowerCase()
|
|
85
|
-
} catch {
|
|
86
|
-
return false
|
|
87
|
-
}
|
|
88
|
-
}
|
|
89
|
-
|
|
90
|
-
export async function runChild(value: unknown): Promise<ChildResult> {
|
|
91
|
-
if (!isValid(value)) throw new ChildProcessError("INVALID_OPTIONS")
|
|
92
|
-
const options = value
|
|
93
|
-
if (options.signal?.aborted) throw new ChildProcessError("CANCELLED")
|
|
94
|
-
if (!(await executableMatches(options))) throw new ChildProcessError("EXECUTABLE_MISMATCH")
|
|
95
|
-
if (options.signal?.aborted) throw new ChildProcessError("CANCELLED")
|
|
96
|
-
|
|
97
|
-
const env = Object.create(null) as Record<string, string>
|
|
98
|
-
for (const key of options.allowedEnv) {
|
|
99
|
-
const actual = Object.keys(options.env).find((candidate) => process.platform === "win32" ? candidate.toUpperCase() === key.toUpperCase() : candidate === key)
|
|
100
|
-
if (actual !== undefined) env[key] = options.env[actual]!
|
|
101
|
-
}
|
|
102
|
-
const stdoutLimit = options.stdoutLimitBytes ?? DEFAULT_OUTPUT_LIMIT
|
|
103
|
-
const stderrLimit = options.stderrLimitBytes ?? DEFAULT_OUTPUT_LIMIT
|
|
104
|
-
|
|
105
|
-
return new Promise((resolve, reject) => {
|
|
106
|
-
let child: ChildProcess
|
|
107
|
-
try {
|
|
108
|
-
child = spawn(options.executable, [...options.args], {
|
|
109
|
-
cwd: options.cwd,
|
|
110
|
-
env,
|
|
111
|
-
shell: false,
|
|
112
|
-
windowsHide: true,
|
|
113
|
-
stdio: ["ignore", "pipe", "pipe"],
|
|
114
|
-
})
|
|
115
|
-
} catch {
|
|
116
|
-
reject(new ChildProcessError("SPAWN_FAILED"))
|
|
117
|
-
return
|
|
118
|
-
}
|
|
119
|
-
|
|
120
|
-
let promiseSettled = false
|
|
121
|
-
let terminating = false
|
|
122
|
-
let spawned = false
|
|
123
|
-
let childClosed = false
|
|
124
|
-
let stdoutClosed = false
|
|
125
|
-
let stderrClosed = false
|
|
126
|
-
let stdoutBytes = 0
|
|
127
|
-
let stderrBytes = 0
|
|
128
|
-
const stdoutChunks: Buffer[] = []
|
|
129
|
-
const stderrChunks: Buffer[] = []
|
|
130
|
-
let failure: ChildErrorCode | undefined
|
|
131
|
-
let startupTimer: NodeJS.Timeout | undefined
|
|
132
|
-
let overallTimer: NodeJS.Timeout | undefined
|
|
133
|
-
let escalationTimer: NodeJS.Timeout | undefined
|
|
134
|
-
let teardownTimer: NodeJS.Timeout | undefined
|
|
135
|
-
|
|
136
|
-
const clearTimers = (): void => {
|
|
137
|
-
if (startupTimer) clearTimeout(startupTimer)
|
|
138
|
-
if (overallTimer) clearTimeout(overallTimer)
|
|
139
|
-
if (escalationTimer) clearTimeout(escalationTimer)
|
|
140
|
-
if (teardownTimer) clearTimeout(teardownTimer)
|
|
141
|
-
}
|
|
142
|
-
const removeAbort = (): void => options.signal?.removeEventListener("abort", onAbort)
|
|
143
|
-
const rejectAfterClose = (): void => {
|
|
144
|
-
if (promiseSettled || !failure || !childClosed) return
|
|
145
|
-
promiseSettled = true
|
|
146
|
-
clearTimers()
|
|
147
|
-
removeAbort()
|
|
148
|
-
reject(new ChildProcessError(failure))
|
|
149
|
-
}
|
|
150
|
-
const forceAndWait = (): void => {
|
|
151
|
-
if (promiseSettled || !terminating) return
|
|
152
|
-
try {
|
|
153
|
-
if (child.pid !== undefined) child.kill("SIGKILL")
|
|
154
|
-
} catch {
|
|
155
|
-
// The bounded teardown timer below remains the authoritative outcome.
|
|
156
|
-
}
|
|
157
|
-
teardownTimer = setTimeout(() => {
|
|
158
|
-
if (!promiseSettled) {
|
|
159
|
-
promiseSettled = true
|
|
160
|
-
clearTimers()
|
|
161
|
-
removeAbort()
|
|
162
|
-
reject(new ChildProcessError("TEARDOWN_FAILED"))
|
|
163
|
-
}
|
|
164
|
-
}, TEARDOWN_LIMIT_MS)
|
|
165
|
-
}
|
|
166
|
-
const beginFailure = (code: ChildErrorCode): void => {
|
|
167
|
-
if (promiseSettled || terminating) return
|
|
168
|
-
failure = code
|
|
169
|
-
terminating = true
|
|
170
|
-
clearTimers()
|
|
171
|
-
removeAbort()
|
|
172
|
-
// This is the PID returned by this spawn call. No process discovery or
|
|
173
|
-
// tree-wide signal is implied; a real host needs a Job Object/group.
|
|
174
|
-
try {
|
|
175
|
-
if (child.pid !== undefined) child.kill()
|
|
176
|
-
} catch {
|
|
177
|
-
// Escalation and the bounded failure path decide whether teardown was proven.
|
|
178
|
-
}
|
|
179
|
-
escalationTimer = setTimeout(forceAndWait, TERM_GRACE_MS)
|
|
180
|
-
rejectAfterClose()
|
|
181
|
-
}
|
|
182
|
-
const onAbort = (): void => beginFailure("CANCELLED")
|
|
183
|
-
|
|
184
|
-
// Register cancellation immediately before the synchronous spawn call. The
|
|
185
|
-
// pre-aborted checks above handle the no-spawn case; this listener owns the
|
|
186
|
-
// cancellation race after a child has been successfully created.
|
|
187
|
-
if (options.signal) options.signal.addEventListener("abort", onAbort, { once: true })
|
|
188
|
-
if (options.signal?.aborted) {
|
|
189
|
-
removeAbort()
|
|
190
|
-
reject(new ChildProcessError("CANCELLED"))
|
|
191
|
-
return
|
|
192
|
-
}
|
|
193
|
-
|
|
194
|
-
startupTimer = setTimeout(() => beginFailure("STARTUP_TIMEOUT"), options.startupTimeoutMs)
|
|
195
|
-
overallTimer = setTimeout(() => beginFailure("TIMEOUT"), options.overallTimeoutMs)
|
|
196
|
-
child.once("spawn", () => {
|
|
197
|
-
spawned = true
|
|
198
|
-
if (startupTimer) clearTimeout(startupTimer)
|
|
199
|
-
})
|
|
200
|
-
child.once("error", () => beginFailure(spawned ? "EXIT_FAILED" : "SPAWN_FAILED"))
|
|
201
|
-
child.stdout?.once("close", () => { stdoutClosed = true; rejectAfterClose() })
|
|
202
|
-
child.stderr?.once("close", () => { stderrClosed = true; rejectAfterClose() })
|
|
203
|
-
child.stdout?.on("data", (chunk: Buffer) => {
|
|
204
|
-
if (terminating) return
|
|
205
|
-
stdoutBytes += chunk.byteLength
|
|
206
|
-
if (stdoutBytes > stdoutLimit) {
|
|
207
|
-
beginFailure("OUTPUT_LIMIT")
|
|
208
|
-
return
|
|
209
|
-
}
|
|
210
|
-
stdoutChunks.push(chunk)
|
|
211
|
-
})
|
|
212
|
-
child.stderr?.on("data", (chunk: Buffer) => {
|
|
213
|
-
if (terminating) return
|
|
214
|
-
stderrBytes += chunk.byteLength
|
|
215
|
-
if (stderrBytes > stderrLimit) {
|
|
216
|
-
beginFailure("OUTPUT_LIMIT")
|
|
217
|
-
return
|
|
218
|
-
}
|
|
219
|
-
stderrChunks.push(chunk)
|
|
220
|
-
})
|
|
221
|
-
child.once("close", (exitCode, signal) => {
|
|
222
|
-
childClosed = true
|
|
223
|
-
if (failure) {
|
|
224
|
-
rejectAfterClose()
|
|
225
|
-
} else if (exitCode === null) {
|
|
226
|
-
beginFailure("EXIT_FAILED")
|
|
227
|
-
} else {
|
|
228
|
-
promiseSettled = true
|
|
229
|
-
clearTimers()
|
|
230
|
-
removeAbort()
|
|
231
|
-
resolve({ exitCode, signal, stdout: Buffer.concat(stdoutChunks).toString("utf8"), stderr: Buffer.concat(stderrChunks).toString("utf8"), cwd: options.cwd })
|
|
232
|
-
}
|
|
233
|
-
})
|
|
234
|
-
})
|
|
235
|
-
}
|
|
@@ -1,123 +0,0 @@
|
|
|
1
|
-
import { request } from "node:http"
|
|
2
|
-
import { isAbsolute, relative, resolve, sep } from "node:path"
|
|
3
|
-
|
|
4
|
-
const MAX_BODY = 64 * 1024
|
|
5
|
-
const MAX_HEADERS = 16 * 1024
|
|
6
|
-
const DEFAULT_TIMEOUT = 2_000
|
|
7
|
-
|
|
8
|
-
export interface HostInfo {
|
|
9
|
-
readonly version: string
|
|
10
|
-
readonly pid: number
|
|
11
|
-
readonly urls: readonly string[]
|
|
12
|
-
readonly paths: Readonly<{ readonly tmp: string }>
|
|
13
|
-
}
|
|
14
|
-
|
|
15
|
-
interface HostApiInput {
|
|
16
|
-
readonly origin: string
|
|
17
|
-
readonly password: string
|
|
18
|
-
readonly expectedVersion: string
|
|
19
|
-
readonly expectedPID?: number
|
|
20
|
-
readonly expectedTmpRoot: string
|
|
21
|
-
readonly timeoutMs?: number
|
|
22
|
-
}
|
|
23
|
-
|
|
24
|
-
function invalid(): never { throw new Error("HOST_API_INVALID") }
|
|
25
|
-
|
|
26
|
-
function fields(value: unknown, required: readonly string[], optional: readonly string[] = []): Record<string, unknown> {
|
|
27
|
-
if (typeof value !== "object" || value === null || Array.isArray(value)) invalid()
|
|
28
|
-
const prototype = Object.getPrototypeOf(value)
|
|
29
|
-
if (prototype !== Object.prototype && prototype !== null) invalid()
|
|
30
|
-
const keys = Reflect.ownKeys(value)
|
|
31
|
-
if (keys.some((key) => typeof key !== "string" || (!required.includes(key) && !optional.includes(key)))) invalid()
|
|
32
|
-
const result: Record<string, unknown> = Object.create(null)
|
|
33
|
-
for (const key of keys) {
|
|
34
|
-
const descriptor = Object.getOwnPropertyDescriptor(value, key)
|
|
35
|
-
if (!descriptor || !("value" in descriptor)) invalid()
|
|
36
|
-
result[key as string] = descriptor.value
|
|
37
|
-
}
|
|
38
|
-
if (required.some((key) => !Object.hasOwn(result, key))) invalid()
|
|
39
|
-
return result
|
|
40
|
-
}
|
|
41
|
-
|
|
42
|
-
function requiredFields(value: unknown, required: readonly string[]): Record<string, unknown> {
|
|
43
|
-
if (typeof value !== "object" || value === null || Array.isArray(value)) invalid()
|
|
44
|
-
const prototype = Object.getPrototypeOf(value)
|
|
45
|
-
if (prototype !== Object.prototype && prototype !== null) invalid()
|
|
46
|
-
const result: Record<string, unknown> = Object.create(null)
|
|
47
|
-
for (const key of required) {
|
|
48
|
-
const descriptor = Object.getOwnPropertyDescriptor(value, key)
|
|
49
|
-
if (!descriptor || !("value" in descriptor)) invalid()
|
|
50
|
-
result[key] = descriptor.value
|
|
51
|
-
}
|
|
52
|
-
return result
|
|
53
|
-
}
|
|
54
|
-
|
|
55
|
-
function parseInput(value: unknown): HostApiInput {
|
|
56
|
-
try {
|
|
57
|
-
const input = fields(value, ["origin", "password", "expectedVersion", "expectedTmpRoot"], ["expectedPID", "timeoutMs"])
|
|
58
|
-
if (typeof input.origin !== "string" || !/^http:\/\/127\.0\.0\.1:\d+$/.test(input.origin)) invalid()
|
|
59
|
-
const url = new URL(input.origin)
|
|
60
|
-
if (url.origin !== input.origin || url.pathname !== "/" || url.search || url.hash || !url.port) invalid()
|
|
61
|
-
if (typeof input.password !== "string" || !/^[a-f0-9]{64}$/.test(input.password)) invalid()
|
|
62
|
-
if (typeof input.expectedVersion !== "string" || input.expectedVersion.length === 0 || input.expectedVersion.length > 128) invalid()
|
|
63
|
-
if (typeof input.expectedTmpRoot !== "string" || !isAbsolute(input.expectedTmpRoot) || input.expectedTmpRoot.length > 4_096) invalid()
|
|
64
|
-
if (input.expectedPID !== undefined && (typeof input.expectedPID !== "number" || !Number.isSafeInteger(input.expectedPID) || input.expectedPID < 0)) invalid()
|
|
65
|
-
if (input.timeoutMs !== undefined && (typeof input.timeoutMs !== "number" || !Number.isInteger(input.timeoutMs) || input.timeoutMs < 1 || input.timeoutMs > 10_000)) invalid()
|
|
66
|
-
return input as unknown as HostApiInput
|
|
67
|
-
} catch (error) {
|
|
68
|
-
if (error instanceof Error && error.message === "HOST_API_INVALID") throw error
|
|
69
|
-
throw new Error("HOST_API_INVALID")
|
|
70
|
-
}
|
|
71
|
-
}
|
|
72
|
-
|
|
73
|
-
function contained(root: string, value: string): boolean {
|
|
74
|
-
const parent = resolve(root)
|
|
75
|
-
const child = resolve(value)
|
|
76
|
-
const rest = relative(parent, child)
|
|
77
|
-
return rest === "" || (rest !== ".." && !rest.startsWith(`..${sep}`) && !isAbsolute(rest))
|
|
78
|
-
}
|
|
79
|
-
|
|
80
|
-
function decode(body: string, input: HostApiInput): HostInfo {
|
|
81
|
-
let value: unknown
|
|
82
|
-
try { value = JSON.parse(body) } catch { throw new Error("HOST_API_SCHEMA") }
|
|
83
|
-
let data: Record<string, unknown>
|
|
84
|
-
try { data = requiredFields(value, ["version", "pid", "urls", "paths"]) } catch { throw new Error("HOST_API_SCHEMA") }
|
|
85
|
-
if (typeof data.version !== "string" || data.version !== input.expectedVersion) throw new Error("HOST_API_VERSION")
|
|
86
|
-
if (typeof data.pid !== "number" || !Number.isSafeInteger(data.pid) || data.pid < 0) throw new Error("HOST_API_SCHEMA")
|
|
87
|
-
if (input.expectedPID !== undefined && data.pid !== input.expectedPID) throw new Error("HOST_API_PID")
|
|
88
|
-
if (!Array.isArray(data.urls) || data.urls.some((url) => typeof url !== "string")) throw new Error("HOST_API_SCHEMA")
|
|
89
|
-
const urls = data.urls as string[]
|
|
90
|
-
for (const advertised of urls) {
|
|
91
|
-
try { if (new URL(advertised).origin !== input.origin) throw new Error() } catch { throw new Error("HOST_API_URL") }
|
|
92
|
-
}
|
|
93
|
-
let paths: Record<string, unknown>
|
|
94
|
-
try { paths = requiredFields(data.paths, ["tmp"]) } catch { throw new Error("HOST_API_SCHEMA") }
|
|
95
|
-
if (typeof paths.tmp !== "string" || !isAbsolute(paths.tmp) || !contained(input.expectedTmpRoot, paths.tmp)) throw new Error("HOST_API_TMP_ROOT")
|
|
96
|
-
return Object.freeze({ version: data.version, pid: data.pid, urls: Object.freeze([...urls]), paths: Object.freeze({ tmp: paths.tmp }) })
|
|
97
|
-
}
|
|
98
|
-
|
|
99
|
-
export function requestHostInfo(value: unknown): Promise<HostInfo> {
|
|
100
|
-
let input: HostApiInput
|
|
101
|
-
try { input = parseInput(value) } catch (error) { return Promise.reject(error instanceof Error && error.message === "HOST_API_INVALID" ? error : new Error("HOST_API_INVALID")) }
|
|
102
|
-
return new Promise((resolveResult, reject) => {
|
|
103
|
-
let settled = false
|
|
104
|
-
const finish = (error?: Error, result?: HostInfo) => {
|
|
105
|
-
if (settled) return
|
|
106
|
-
settled = true
|
|
107
|
-
if (error) reject(error)
|
|
108
|
-
else resolveResult(result as HostInfo)
|
|
109
|
-
}
|
|
110
|
-
const timer = setTimeout(() => { requestHandle.destroy(); finish(new Error("HOST_API_TIMEOUT")) }, input.timeoutMs ?? DEFAULT_TIMEOUT)
|
|
111
|
-
const requestHandle = request({ hostname: "127.0.0.1", port: Number(new URL(input.origin).port), path: "/api/info", method: "GET", auth: `opencode:${input.password}`, maxHeaderSize: MAX_HEADERS }, (response) => {
|
|
112
|
-
if (response.statusCode && response.statusCode >= 300 && response.statusCode < 400) { response.destroy(); clearTimeout(timer); finish(new Error("HOST_API_REDIRECT")); return }
|
|
113
|
-
if (response.statusCode !== 200) { response.destroy(); clearTimeout(timer); finish(new Error("HOST_API_STATUS")); return }
|
|
114
|
-
let size = 0
|
|
115
|
-
const chunks: Buffer[] = []
|
|
116
|
-
response.on("data", (chunk: Buffer) => { size += chunk.length; if (size > MAX_BODY) { response.destroy(); clearTimeout(timer); finish(new Error("HOST_API_BODY")); } else chunks.push(chunk) })
|
|
117
|
-
response.on("end", () => { clearTimeout(timer); try { finish(undefined, decode(Buffer.concat(chunks).toString("utf8"), input)) } catch (error) { finish(error instanceof Error ? error : new Error("HOST_API_SCHEMA")) } })
|
|
118
|
-
response.on("error", () => { clearTimeout(timer); finish(new Error("HOST_API_TRANSPORT")) })
|
|
119
|
-
})
|
|
120
|
-
requestHandle.on("error", () => { clearTimeout(timer); finish(new Error("HOST_API_TRANSPORT")) })
|
|
121
|
-
requestHandle.end()
|
|
122
|
-
})
|
|
123
|
-
}
|
|
@@ -1,239 +0,0 @@
|
|
|
1
|
-
import { isAbsolute, join, relative, resolve, sep } from "node:path"
|
|
2
|
-
|
|
3
|
-
export interface TrustedOsEnvironment {
|
|
4
|
-
readonly userProfile: string
|
|
5
|
-
readonly home: string
|
|
6
|
-
readonly programData: string
|
|
7
|
-
readonly temp: string
|
|
8
|
-
readonly tmp: string
|
|
9
|
-
readonly path: string
|
|
10
|
-
readonly systemRoot: string
|
|
11
|
-
readonly comspec: string
|
|
12
|
-
readonly pathext: string
|
|
13
|
-
}
|
|
14
|
-
|
|
15
|
-
export interface OpenCodeEnvironmentInput {
|
|
16
|
-
readonly root: string
|
|
17
|
-
/** Generate 32 random bytes per run; never source this from user credentials. */
|
|
18
|
-
readonly password: string
|
|
19
|
-
readonly trustedOs: TrustedOsEnvironment
|
|
20
|
-
readonly configContent?: unknown
|
|
21
|
-
/**
|
|
22
|
-
* Host-neutral config keys (plugins, providers, ...) merged into
|
|
23
|
-
* OPENCODE_CONFIG_CONTENT. Deep-validated without evaluating accessors;
|
|
24
|
-
* snapshots/warming/update stay locked to the boot defaults.
|
|
25
|
-
*/
|
|
26
|
-
readonly sandboxConfig?: unknown
|
|
27
|
-
}
|
|
28
|
-
|
|
29
|
-
export interface OpenCodeEnvironment {
|
|
30
|
-
readonly root: string
|
|
31
|
-
readonly cwd: string
|
|
32
|
-
readonly paths: Readonly<{
|
|
33
|
-
readonly data: string
|
|
34
|
-
readonly cache: string
|
|
35
|
-
readonly config: string
|
|
36
|
-
readonly state: string
|
|
37
|
-
readonly temp: string
|
|
38
|
-
readonly database: string
|
|
39
|
-
}>
|
|
40
|
-
readonly allowedEnv: readonly string[]
|
|
41
|
-
readonly env: Readonly<Record<string, string>>
|
|
42
|
-
}
|
|
43
|
-
|
|
44
|
-
const DEFAULT_CONFIG = {
|
|
45
|
-
snapshots: false,
|
|
46
|
-
warming: false,
|
|
47
|
-
update: "disable",
|
|
48
|
-
} as const
|
|
49
|
-
|
|
50
|
-
const ALLOWED_ENV = [
|
|
51
|
-
"USERPROFILE",
|
|
52
|
-
"HOME",
|
|
53
|
-
"ProgramData",
|
|
54
|
-
"PATH",
|
|
55
|
-
"SystemRoot",
|
|
56
|
-
"ComSpec",
|
|
57
|
-
"PATHEXT",
|
|
58
|
-
"TEMP",
|
|
59
|
-
"TMP",
|
|
60
|
-
"XDG_DATA_HOME",
|
|
61
|
-
"XDG_CACHE_HOME",
|
|
62
|
-
"XDG_CONFIG_HOME",
|
|
63
|
-
"XDG_STATE_HOME",
|
|
64
|
-
"OPENCODE_CONFIG_DIR",
|
|
65
|
-
"OPENCODE_CONFIG_PROJECT_DISABLE",
|
|
66
|
-
"OPENCODE_CONFIG_CONTENT",
|
|
67
|
-
"OPENCODE_DB",
|
|
68
|
-
"OPENCODE_PASSWORD",
|
|
69
|
-
"OPENCODE_DISABLE_MODELS_FETCH",
|
|
70
|
-
"OPENCODE_DISABLE_AUTOUPDATE",
|
|
71
|
-
"OPENCODE_DISABLE_FILEWATCHER",
|
|
72
|
-
"OPENCODE_DISABLE_PROJECT_CONFIG",
|
|
73
|
-
] as const
|
|
74
|
-
|
|
75
|
-
function beneath(root: string, value: string): boolean {
|
|
76
|
-
if (!isAbsolute(value)) return false
|
|
77
|
-
const child = resolve(value)
|
|
78
|
-
const parent = resolve(root)
|
|
79
|
-
const rest = relative(parent, child)
|
|
80
|
-
return rest === "" || (rest !== ".." && !rest.startsWith(`..${sep}`) && !isAbsolute(rest))
|
|
81
|
-
}
|
|
82
|
-
|
|
83
|
-
function safeEnvironmentValue(value: unknown): value is string {
|
|
84
|
-
return typeof value === "string" && value.length > 0 && value.length <= 32_768 && !/[\0\r\n]/.test(value)
|
|
85
|
-
}
|
|
86
|
-
|
|
87
|
-
function dataFields(value: unknown, required: readonly string[], optional: readonly string[] = []): Record<string, unknown> {
|
|
88
|
-
if (typeof value !== "object" || value === null || Array.isArray(value)) throw 0
|
|
89
|
-
const prototype = Object.getPrototypeOf(value)
|
|
90
|
-
if (prototype !== Object.prototype && prototype !== null) throw 0
|
|
91
|
-
const keys = Reflect.ownKeys(value)
|
|
92
|
-
if (keys.length > required.length + optional.length) throw 0
|
|
93
|
-
const result: Record<string, unknown> = Object.create(null)
|
|
94
|
-
for (const key of keys) {
|
|
95
|
-
if (typeof key !== "string" || (!required.includes(key) && !optional.includes(key))) throw 0
|
|
96
|
-
const descriptor = Object.getOwnPropertyDescriptor(value, key)
|
|
97
|
-
if (!descriptor || !("value" in descriptor)) throw 0
|
|
98
|
-
result[key] = descriptor.value
|
|
99
|
-
}
|
|
100
|
-
if (required.some((key) => !Object.hasOwn(result, key))) throw 0
|
|
101
|
-
return result
|
|
102
|
-
}
|
|
103
|
-
|
|
104
|
-
function lockedConfig(value: unknown): Readonly<Record<string, boolean | string>> {
|
|
105
|
-
if (value === undefined) return DEFAULT_CONFIG
|
|
106
|
-
const values = dataFields(value, [], ["snapshots", "warming", "update"])
|
|
107
|
-
for (const [key, actual] of Object.entries(values)) {
|
|
108
|
-
const expected = DEFAULT_CONFIG[key as keyof typeof DEFAULT_CONFIG]
|
|
109
|
-
if (actual !== expected) throw 0
|
|
110
|
-
}
|
|
111
|
-
return Object.freeze({ ...DEFAULT_CONFIG })
|
|
112
|
-
}
|
|
113
|
-
|
|
114
|
-
const SANDBOX_MAX_DEPTH = 16
|
|
115
|
-
const SANDBOX_MAX_NODES = 8_192
|
|
116
|
-
const SANDBOX_MAX_STRING = 32_768
|
|
117
|
-
|
|
118
|
-
/**
|
|
119
|
-
* Deep-clones a sandbox config through data descriptors only: accessors are
|
|
120
|
-
* never evaluated, cycles, sparse holes, non-finite numbers, oversized strings
|
|
121
|
-
* and non-plain containers are rejected, and locked boot keys may only carry
|
|
122
|
-
* their locked value. The clone cannot observe later caller mutation, so the
|
|
123
|
-
* final JSON.stringify runs on inert data.
|
|
124
|
-
*/
|
|
125
|
-
function sandboxConfig(value: unknown): Record<string, unknown> | undefined {
|
|
126
|
-
if (value === undefined) return undefined
|
|
127
|
-
if (typeof value !== "object" || value === null || Array.isArray(value)) throw 0
|
|
128
|
-
const rootPrototype = Object.getPrototypeOf(value)
|
|
129
|
-
if (rootPrototype !== Object.prototype && rootPrototype !== null) throw 0
|
|
130
|
-
let nodes = 0
|
|
131
|
-
const seen = new WeakSet<object>()
|
|
132
|
-
const walk = (input: unknown, depth: number): unknown => {
|
|
133
|
-
nodes += 1
|
|
134
|
-
if (nodes > SANDBOX_MAX_NODES || depth > SANDBOX_MAX_DEPTH) throw 0
|
|
135
|
-
if (input === null || typeof input === "boolean") return input
|
|
136
|
-
if (typeof input === "number") {
|
|
137
|
-
if (!Number.isFinite(input)) throw 0
|
|
138
|
-
return input
|
|
139
|
-
}
|
|
140
|
-
if (typeof input === "string") {
|
|
141
|
-
if (input.length > SANDBOX_MAX_STRING) throw 0
|
|
142
|
-
return input
|
|
143
|
-
}
|
|
144
|
-
if (typeof input !== "object") throw 0
|
|
145
|
-
if (seen.has(input)) throw 0
|
|
146
|
-
seen.add(input)
|
|
147
|
-
if (Array.isArray(input)) {
|
|
148
|
-
const result: unknown[] = []
|
|
149
|
-
for (let index = 0; index < input.length; index += 1) {
|
|
150
|
-
const descriptor = Object.getOwnPropertyDescriptor(input, String(index))
|
|
151
|
-
if (!descriptor || !("value" in descriptor)) throw 0
|
|
152
|
-
result.push(walk(descriptor.value, depth + 1))
|
|
153
|
-
}
|
|
154
|
-
return result
|
|
155
|
-
}
|
|
156
|
-
const prototype = Object.getPrototypeOf(input)
|
|
157
|
-
if (prototype !== Object.prototype && prototype !== null) throw 0
|
|
158
|
-
const result: Record<string, unknown> = {}
|
|
159
|
-
for (const key of Reflect.ownKeys(input)) {
|
|
160
|
-
if (typeof key !== "string") throw 0
|
|
161
|
-
const descriptor = Object.getOwnPropertyDescriptor(input, key)
|
|
162
|
-
if (!descriptor || !("value" in descriptor)) throw 0
|
|
163
|
-
const cloned = walk(descriptor.value, depth + 1)
|
|
164
|
-
if (depth === 0) {
|
|
165
|
-
const expected = DEFAULT_CONFIG[key as keyof typeof DEFAULT_CONFIG]
|
|
166
|
-
if (expected !== undefined && cloned !== expected) throw 0
|
|
167
|
-
}
|
|
168
|
-
result[key] = cloned
|
|
169
|
-
}
|
|
170
|
-
return result
|
|
171
|
-
}
|
|
172
|
-
return walk(value, 0) as Record<string, unknown>
|
|
173
|
-
}
|
|
174
|
-
|
|
175
|
-
export function buildOpenCodeEnvironment(value: unknown): OpenCodeEnvironment {
|
|
176
|
-
try {
|
|
177
|
-
const input = dataFields(value, ["root", "trustedOs", "password"], ["configContent", "sandboxConfig"])
|
|
178
|
-
if (!safeEnvironmentValue(input.root)) throw 0
|
|
179
|
-
if (typeof input.password !== "string" || !/^[a-f0-9]{64}$/.test(input.password)) throw 0
|
|
180
|
-
const trusted = dataFields(input.trustedOs, ["userProfile", "home", "programData", "temp", "tmp", "path", "systemRoot", "comspec", "pathext"])
|
|
181
|
-
if (Object.values(trusted).some((field) => !safeEnvironmentValue(field))) throw 0
|
|
182
|
-
if (!isAbsolute(trusted.systemRoot as string) || !isAbsolute(trusted.comspec as string)) throw 0
|
|
183
|
-
return buildValidated({ ...input, trustedOs: trusted } as unknown as OpenCodeEnvironmentInput)
|
|
184
|
-
} catch {
|
|
185
|
-
throw new Error("OPENCODE_ENVIRONMENT_INVALID")
|
|
186
|
-
}
|
|
187
|
-
}
|
|
188
|
-
|
|
189
|
-
function buildValidated(input: OpenCodeEnvironmentInput): OpenCodeEnvironment {
|
|
190
|
-
if (!isAbsolute(input.root)) throw new Error("private root must be absolute")
|
|
191
|
-
const root = resolve(input.root)
|
|
192
|
-
for (const value of Object.values(input.trustedOs)) {
|
|
193
|
-
if (!safeEnvironmentValue(value)) throw new Error("invalid trusted OS environment")
|
|
194
|
-
}
|
|
195
|
-
const paths = {
|
|
196
|
-
data: join(root, "data"),
|
|
197
|
-
cache: join(root, "cache"),
|
|
198
|
-
config: join(root, "config", "opencode"),
|
|
199
|
-
state: join(root, "state"),
|
|
200
|
-
temp: join(root, "temp"),
|
|
201
|
-
database: join(root, "data", "opencode.db"),
|
|
202
|
-
}
|
|
203
|
-
for (const value of Object.values(paths)) {
|
|
204
|
-
if (!beneath(root, value)) throw new Error("OpenCode path escapes private root")
|
|
205
|
-
}
|
|
206
|
-
for (const value of [input.trustedOs.userProfile, input.trustedOs.home, input.trustedOs.programData, input.trustedOs.temp, input.trustedOs.tmp]) {
|
|
207
|
-
if (!beneath(root, value)) throw new Error("trusted OS path escapes private root")
|
|
208
|
-
}
|
|
209
|
-
const config = { ...sandboxConfig(input.sandboxConfig), ...lockedConfig(input.configContent) }
|
|
210
|
-
const env: Record<string, string> = {
|
|
211
|
-
USERPROFILE: input.trustedOs.userProfile,
|
|
212
|
-
HOME: input.trustedOs.home,
|
|
213
|
-
ProgramData: input.trustedOs.programData,
|
|
214
|
-
PATH: input.trustedOs.path,
|
|
215
|
-
SystemRoot: input.trustedOs.systemRoot,
|
|
216
|
-
ComSpec: input.trustedOs.comspec,
|
|
217
|
-
PATHEXT: input.trustedOs.pathext,
|
|
218
|
-
TEMP: input.trustedOs.temp,
|
|
219
|
-
TMP: input.trustedOs.tmp,
|
|
220
|
-
XDG_DATA_HOME: paths.data,
|
|
221
|
-
XDG_CACHE_HOME: paths.cache,
|
|
222
|
-
XDG_CONFIG_HOME: join(root, "config"),
|
|
223
|
-
XDG_STATE_HOME: paths.state,
|
|
224
|
-
OPENCODE_CONFIG_DIR: paths.config,
|
|
225
|
-
OPENCODE_CONFIG_PROJECT_DISABLE: "1",
|
|
226
|
-
OPENCODE_CONFIG_CONTENT: JSON.stringify(config),
|
|
227
|
-
OPENCODE_DB: paths.database,
|
|
228
|
-
OPENCODE_PASSWORD: input.password,
|
|
229
|
-
// Names verified against the pinned opencode binary's own strings.
|
|
230
|
-
OPENCODE_DISABLE_MODELS_FETCH: "1",
|
|
231
|
-
OPENCODE_DISABLE_AUTOUPDATE: "1",
|
|
232
|
-
OPENCODE_DISABLE_FILEWATCHER: "1",
|
|
233
|
-
OPENCODE_DISABLE_PROJECT_CONFIG: "1",
|
|
234
|
-
}
|
|
235
|
-
const frozenPaths = Object.freeze(paths)
|
|
236
|
-
const allowedEnv = Object.freeze([...ALLOWED_ENV])
|
|
237
|
-
const frozenEnv = Object.freeze({ ...env })
|
|
238
|
-
return Object.freeze({ root, cwd: join(root, "cwd"), paths: frozenPaths, allowedEnv, env: frozenEnv })
|
|
239
|
-
}
|