Skip to content

Commit 3f392d0

Browse files
committed
fix(vscode): respect macOS process attachment policy
1 parent 9fa3b5e commit 3f392d0

4 files changed

Lines changed: 119 additions & 43 deletions

File tree

‎crates/splitscript-process-native/src/lib.rs‎

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,7 @@ struct Process {
4545
impl Process {
4646
fn attach(pid: u32, path: Option<Box<str>>) -> io::Result<Self> {
4747
let native_pid = pid as Pid;
48-
let handle = native_pid.try_into()?;
48+
let handle = native_pid.try_into().map_err(process_attach_error)?;
4949
let now = Instant::now();
5050
Ok(Self {
5151
handle,
@@ -154,6 +154,19 @@ impl Process {
154154
}
155155
}
156156

157+
#[cfg(target_os = "macos")]
158+
fn process_attach_error(_: io::Error) -> io::Error {
159+
io::Error::new(
160+
io::ErrorKind::PermissionDenied,
161+
"macOS task_for_pid denied access; process memory attachment requires debugger authorization and a target that permits inspection",
162+
)
163+
}
164+
165+
#[cfg(not(target_os = "macos"))]
166+
const fn process_attach_error(error: io::Error) -> io::Error {
167+
error
168+
}
169+
157170
struct ProcessList {
158171
system: System,
159172
next_check: Instant,

‎editors/vscode/DEVELOPMENT.md‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,11 @@ For a local build, `build-native.mjs` builds the bridge for the current supporte
5353
host. CI supplies a directory of prebuilt platform folders through
5454
`SPLITSCRIPT_NATIVE_ARTIFACTS`; `SPLITSCRIPT_REQUIRED_NATIVE_PLATFORMS` makes a
5555
missing artifact fail the build rather than silently producing a partial VSIX.
56+
The Windows and Linux jobs probe a spawned fixture end to end. GitHub-hosted
57+
macOS runners cannot provide interactive `task_for_pid` authorization, so the
58+
macOS jobs probe discovery, modules, mapped ranges, and Mach memory reads against
59+
the current Node process instead. This keeps native behavior covered without
60+
pretending that CI can grant permission to inspect another process.
5661

5762
## Worker architecture
5863

‎editors/vscode/README.md‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -99,6 +99,9 @@ virtual workspaces.
9999
coalesced to at most five updates per second. The **Statistics** panel keeps
100100
a bounded window of 2,048 tick timings and provides reset and lazy Wasm-memory
101101
actions without stopping the runtime.
102+
On macOS, attaching to another process uses `task_for_pid` and is subject to
103+
the operating system's debugger authorization and target code-signing rules.
104+
A denied attachment reports that permission boundary explicitly.
102105
- WASI snapshot preview1 (WASI 0.1) is available with a read-only filesystem
103106
below `/mnt`. Arguments and environment variables are deliberately empty,
104107
and filesystem-mutating operations return `NOTCAPABLE`.

‎editors/vscode/scripts/probe-native.mjs‎

Lines changed: 97 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import assert from 'node:assert/strict';
22
import { createRequire } from 'node:module';
3-
import { dirname, resolve } from 'node:path';
3+
import { basename, dirname, resolve } from 'node:path';
44
import { fileURLToPath } from 'node:url';
55
import { spawn } from 'node:child_process';
66
import { createInterface } from 'node:readline';
@@ -29,53 +29,108 @@ const native = require(resolve(
2929
platform,
3030
'splitscript_process_native.node',
3131
));
32-
const fixtureName = process.platform === 'win32'
33-
? 'splitscript-process-fixture.exe'
34-
: 'splitscript-process-fixture';
35-
const fixture = spawn(
36-
resolve(repository, 'target', 'release', fixtureName),
37-
[],
38-
{ stdio: ['pipe', 'pipe', 'inherit'] },
39-
);
4032

41-
try {
42-
const line = await firstLine(fixture.stdout);
43-
const fields = Object.fromEntries(line.split(';').map(field => field.split('=', 2)));
44-
const pid = Number(fields.pid);
45-
const length = Number(fields.length);
46-
assert(Number.isInteger(pid) && pid > 0);
47-
assert(Number.isInteger(length) && length > 0);
33+
if (process.platform === 'darwin') {
34+
probeCurrentProcess();
35+
} else {
36+
await probeFixtureProcess();
37+
}
38+
39+
function probeCurrentProcess() {
40+
// GitHub-hosted macOS runners cannot grant the interactive debugger
41+
// authorization that task_for_pid requires for another process. Attaching
42+
// to the caller is always valid and still exercises the Mach memory path.
43+
const processName = basename(process.execPath);
44+
assert(native.listProcessesByName(processName).includes(process.pid));
4845

49-
assert(native.listProcessesByName(fixtureName).includes(pid));
46+
const handle = native.attachByPid(process.pid);
47+
try {
48+
assert.equal(native.processId(handle), process.pid);
49+
const processPath = native.processPath(handle);
50+
assert.equal(typeof processPath, 'string');
51+
assert.equal(basename(processPath), processName);
52+
assert.equal(native.isOpen(handle), true);
53+
assert(BigInt(native.moduleAddress(handle, processName)) > 0n);
54+
assert(BigInt(native.moduleSize(handle, processName)) > 0n);
55+
assert.equal(basename(native.modulePath(handle, processName)), processName);
56+
57+
const readable = firstReadableRange(native, handle);
58+
const actual = native.readProcessMemory(handle, readable.address, 32);
59+
assert.equal(actual.length, 32);
60+
} finally {
61+
assert.equal(native.detach(handle), true);
62+
assert.equal(native.detach(handle), false);
63+
}
64+
65+
console.log(
66+
`Native ${platform} self-process probe passed: discovery, modules, ranges, and a 32-byte Mach memory read.`,
67+
);
68+
}
5069

51-
const handle = native.attachByPid(pid);
52-
assert.equal(native.processId(handle), pid);
53-
assert.match(native.processPath(handle), new RegExp(`${escapeRegExp(fixtureName)}$`, 'i'));
54-
assert.equal(native.isOpen(handle), true);
55-
assert(BigInt(native.moduleAddress(handle, fixtureName)) > 0n);
56-
assert(BigInt(native.moduleSize(handle, fixtureName)) > 0n);
57-
assert.match(
58-
native.modulePath(handle, fixtureName),
59-
new RegExp(`${escapeRegExp(fixtureName)}$`, 'i'),
70+
async function probeFixtureProcess() {
71+
const fixtureName = process.platform === 'win32'
72+
? 'splitscript-process-fixture.exe'
73+
: 'splitscript-process-fixture';
74+
const fixture = spawn(
75+
resolve(repository, 'target', 'release', fixtureName),
76+
[],
77+
{ stdio: ['pipe', 'pipe', 'inherit'] },
6078
);
79+
80+
try {
81+
const line = await firstLine(fixture.stdout);
82+
const fields = Object.fromEntries(line.split(';').map(field => field.split('=', 2)));
83+
const pid = Number(fields.pid);
84+
const length = Number(fields.length);
85+
assert(Number.isInteger(pid) && pid > 0);
86+
assert(Number.isInteger(length) && length > 0);
87+
88+
assert(native.listProcessesByName(fixtureName).includes(pid));
89+
90+
const handle = native.attachByPid(pid);
91+
assert.equal(native.processId(handle), pid);
92+
assert.match(native.processPath(handle), new RegExp(`${escapeRegExp(fixtureName)}$`, 'i'));
93+
assert.equal(native.isOpen(handle), true);
94+
assert(BigInt(native.moduleAddress(handle, fixtureName)) > 0n);
95+
assert(BigInt(native.moduleSize(handle, fixtureName)) > 0n);
96+
assert.match(
97+
native.modulePath(handle, fixtureName),
98+
new RegExp(`${escapeRegExp(fixtureName)}$`, 'i'),
99+
);
100+
const rangeCount = native.memoryRangeCount(handle);
101+
assert(rangeCount > 0);
102+
assert(BigInt(native.memoryRangeAddress(handle, 0)) > 0n);
103+
assert(BigInt(native.memoryRangeSize(handle, 0)) > 0n);
104+
assert(BigInt(native.memoryRangeFlags(handle, 0)) > 0n);
105+
const actual = native.readProcessMemory(handle, fields.address, length);
106+
assert.equal(Buffer.from(actual).toString('utf8'), fields.expected);
107+
assert.equal(native.detach(handle), true);
108+
assert.equal(native.detach(handle), false);
109+
const namedHandle = native.attachByName(fixtureName);
110+
assert.equal(native.processId(namedHandle), pid);
111+
assert.equal(native.detach(namedHandle), true);
112+
console.log(
113+
`Native ${platform} process probe passed: discovery, modules, ranges, and ${length}-byte read from PID ${pid}.`,
114+
);
115+
} finally {
116+
fixture.stdin.end('\n');
117+
await new Promise(resolvePromise => fixture.once('exit', resolvePromise));
118+
}
119+
}
120+
121+
function firstReadableRange(native, handle) {
61122
const rangeCount = native.memoryRangeCount(handle);
62123
assert(rangeCount > 0);
63-
assert(BigInt(native.memoryRangeAddress(handle, 0)) > 0n);
64-
assert(BigInt(native.memoryRangeSize(handle, 0)) > 0n);
65-
assert(BigInt(native.memoryRangeFlags(handle, 0)) > 0n);
66-
const actual = native.readProcessMemory(handle, fields.address, length);
67-
assert.equal(Buffer.from(actual).toString('utf8'), fields.expected);
68-
assert.equal(native.detach(handle), true);
69-
assert.equal(native.detach(handle), false);
70-
const namedHandle = native.attachByName(fixtureName);
71-
assert.equal(native.processId(namedHandle), pid);
72-
assert.equal(native.detach(namedHandle), true);
73-
console.log(
74-
`Native ${platform} process probe passed: discovery, modules, ranges, and ${length}-byte read from PID ${pid}.`,
75-
);
76-
} finally {
77-
fixture.stdin.end('\n');
78-
await new Promise(resolvePromise => fixture.once('exit', resolvePromise));
124+
for (let index = 0; index < rangeCount; index++) {
125+
const address = native.memoryRangeAddress(handle, index);
126+
const size = BigInt(native.memoryRangeSize(handle, index));
127+
const flags = BigInt(native.memoryRangeFlags(handle, index));
128+
if ((flags & 2n) !== 0n && size >= 32n) {
129+
assert(BigInt(address) > 0n);
130+
return { address };
131+
}
132+
}
133+
assert.fail('the current process has no readable 32-byte memory range');
79134
}
80135

81136
async function firstLine(stream) {

0 commit comments

Comments
 (0)