staysfixed 0.11.1 → 0.13.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.
- package/CHANGELOG.md +108 -2
- package/README.md +77 -19
- package/docs/design-v2.md +8 -7
- package/docs/getting-started.md +5 -3
- package/docs/guards.md +18 -0
- package/docs/how-v2-works.md +43 -10
- package/docs/mcp.md +6 -4
- package/docs/settings.md +11 -2
- package/package.json +1 -1
- package/src/cli/approve.js +4 -1
- package/src/cli/flake.js +4 -1
- package/src/cli/mark.js +5 -1
- package/src/cli/status.js +53 -1
- package/src/cli/trace.js +27 -2
- package/src/core/config.js +136 -25
- package/src/core/stop-tree.js +109 -0
- package/src/drive/browser.js +20 -31
- package/src/drive/page.js +74 -2
- package/src/guard/api.js +14 -9
- package/src/types.js +1 -1
- package/src/v2/adapters/android.js +220 -11
- package/src/v2/adapters/child.js +15 -17
- package/src/v2/adapters/contract.js +122 -1
- package/src/v2/adapters/extension.js +1988 -0
- package/src/v2/adapters/http.js +152 -30
- package/src/v2/adapters/ios-driver.js +95 -12
- package/src/v2/adapters/ios.js +220 -10
- package/src/v2/adapters/isolate.js +169 -14
- package/src/v2/adapters/linux-driver.js +1028 -0
- package/src/v2/adapters/linux.js +1324 -0
- package/src/v2/adapters/macos-driver.js +913 -0
- package/src/v2/adapters/macos.js +1374 -0
- package/src/v2/adapters/process.js +72 -8
- package/src/v2/adapters/source.js +254 -7
- package/src/v2/adapters/web.js +69 -19
- package/src/v2/browsers.js +145 -25
- package/src/v2/cause.js +46 -5
- package/src/v2/check.js +465 -47
- package/src/v2/cli.js +21 -1
- package/src/v2/coverage.js +556 -19
- package/src/v2/detect.js +742 -42
- package/src/v2/doctor.js +125 -18
- package/src/v2/escalate.js +57 -11
- package/src/v2/init.js +574 -23
- package/src/v2/journeys/answers-probe.js +376 -0
- package/src/v2/journeys/from-exports.js +456 -0
- package/src/v2/journeys/from-suite.js +9 -1
- package/src/v2/journeys/index.js +3 -3
- package/src/v2/journeys/record-session.js +839 -0
- package/src/v2/journeys/record.js +12 -0
- package/src/v2/mcp/tools.js +193 -27
- package/src/v2/observation.js +145 -0
- package/src/v2/run.js +133 -9
- package/src/v2/selfcheck.js +297 -11
- package/src/v2/store.js +16 -1
- package/src/v2/types.js +1 -1
- package/src/v2/watch/events.js +6 -0
package/src/v2/adapters/ios.js
CHANGED
|
@@ -26,6 +26,30 @@
|
|
|
26
26
|
* never on screen during a run.
|
|
27
27
|
* - COUNTERS AND PICTURES, coarse and last.
|
|
28
28
|
*
|
|
29
|
+
* IS A PAIRED RUN POSSIBLE HERE? YES, AND IT HAS NOW BEEN MEASURED.
|
|
30
|
+
*
|
|
31
|
+
* A paired run means the old build is put back on this machine and walked minutes before the
|
|
32
|
+
* new one, so nothing that drifted in between — the weather, a dependency, the clock — can be
|
|
33
|
+
* mistaken for somebody's change. On a phone that means one device, two builds one after the
|
|
34
|
+
* other, and the device put back in between. Until 2026-08-31 this was not offered, because
|
|
35
|
+
* nobody had ever checked whether the device really does come back to the same place.
|
|
36
|
+
*
|
|
37
|
+
* It has been checked. On an Apple Silicon Mac, on 2026-08-31, against Terminal Deck's own
|
|
38
|
+
* iPhone app (0.15.0, build 2608221311) on a simulator this adapter made for itself — an
|
|
39
|
+
* iPhone 17 Pro on iOS 27.0 — ONE build was walked ten times, with the device put back
|
|
40
|
+
* between every walk exactly the way it is put back between two builds: the app and
|
|
41
|
+
* everything it had written removed, and every permission it had been granted taken back.
|
|
42
|
+
*
|
|
43
|
+
* Five pairs. 725 addresses in each walk, 215 of them read out of the RUNNING app and the
|
|
44
|
+
* rest out of the bundle and the source. 725 of 725 agreed, in all five pairs — 3,625
|
|
45
|
+
* comparisons and not one disagreement. The Mac was carrying a load average of about 500 at
|
|
46
|
+
* the time, which makes that the harsher version of the result rather than the flattering
|
|
47
|
+
* one.
|
|
48
|
+
*
|
|
49
|
+
* So a paired iOS run is offered. What actually limits it is not the simulator — it is
|
|
50
|
+
* getting hold of the OLD build's app bundle, because a `.app` is a build output and a
|
|
51
|
+
* checkout of the old commit does not contain one. See `prepare` and `ios.reference`.
|
|
52
|
+
*
|
|
29
53
|
* WHAT IT CANNOT SEE, and these are not hedges.
|
|
30
54
|
*
|
|
31
55
|
* - A REAL iPHONE. Nothing here touches a device somebody is holding. A paired run means
|
|
@@ -101,6 +125,111 @@ import {
|
|
|
101
125
|
/** Everything prepared, per build, so `run` can be called many times without re-installing. */
|
|
102
126
|
const ready = new Map();
|
|
103
127
|
|
|
128
|
+
/**
|
|
129
|
+
* Which app bundle each half of a comparison was walked from, remembered across the run.
|
|
130
|
+
*
|
|
131
|
+
* It lives out here rather than on a prepared build because the engine prepares one build,
|
|
132
|
+
* walks one journey against it and throws it away before the next: nothing kept on a
|
|
133
|
+
* prepared build survives long enough to notice that the old build and the new build were
|
|
134
|
+
* the same folder on disk.
|
|
135
|
+
*
|
|
136
|
+
* And that is the thing worth noticing. `ios.app` in the settings is usually an absolute
|
|
137
|
+
* path — Xcode writes into DerivedData, which is nowhere near the project — and an absolute
|
|
138
|
+
* path does not move when the old commit is checked out somewhere else. So both halves of a
|
|
139
|
+
* paired run would read the SAME bundle, find nothing different, and the run would say
|
|
140
|
+
* "nothing that worked has changed" about a comparison that never took place. That is the
|
|
141
|
+
* one failure this tool exists to prevent, arriving through the front door. Anything caught
|
|
142
|
+
* here is reported as a hole on every journey; see `run`.
|
|
143
|
+
*
|
|
144
|
+
* Keyed by role — 'candidate' or 'reference' — and emptied by `teardown`.
|
|
145
|
+
*
|
|
146
|
+
* @type {Map<string, string>}
|
|
147
|
+
*/
|
|
148
|
+
const walkedFrom = new Map();
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* The build ids that turned out to be the other half of the comparison, and the sentence
|
|
152
|
+
* that says so.
|
|
153
|
+
*
|
|
154
|
+
* Separate from `ready` above because `ready` only holds builds that reached a simulator,
|
|
155
|
+
* and the warning has to survive a build that did not: a reference half that failed to
|
|
156
|
+
* prepare AND was the same bundle as the candidate is two pieces of bad news, and losing the
|
|
157
|
+
* second one is how a comparison comes back green for the wrong reason.
|
|
158
|
+
*
|
|
159
|
+
* @type {Map<string, string>}
|
|
160
|
+
*/
|
|
161
|
+
const sameBundleFor = new Map();
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* Which bundle this really is: the path with the links and the `..`s taken out.
|
|
165
|
+
*
|
|
166
|
+
* A symlink, a `./` or a `..` must not be able to make one bundle look like two, because one
|
|
167
|
+
* bundle looking like two is the whole failure being guarded against here.
|
|
168
|
+
*
|
|
169
|
+
* @param {string} appPath
|
|
170
|
+
* @returns {Promise<string>}
|
|
171
|
+
*/
|
|
172
|
+
async function bundleIdentity(appPath) {
|
|
173
|
+
try {
|
|
174
|
+
return await fsp.realpath(appPath);
|
|
175
|
+
} catch {
|
|
176
|
+
// A path that will not resolve is still worth remembering exactly as it was typed. The
|
|
177
|
+
// question below is whether the two halves agree, not whether the bundle is there.
|
|
178
|
+
return appPath;
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* Did this half of the comparison read the same app bundle as the other half?
|
|
184
|
+
*
|
|
185
|
+
* Returns the sentence to put in front of a reader, or null when the two halves really are
|
|
186
|
+
* two different bundles.
|
|
187
|
+
*
|
|
188
|
+
* WHAT IT DOES NOT CATCH, said here rather than left to be discovered: two different paths
|
|
189
|
+
* holding the same build. Somebody whose release script copies today's build to
|
|
190
|
+
* `builds/latest/App.app` and points `reference` at it is comparing one build against itself
|
|
191
|
+
* with two names, and nothing here notices. That was left alone on purpose — two different
|
|
192
|
+
* bundles are usually two builds somebody produced on purpose, and refusing them on a guess
|
|
193
|
+
* would block real comparisons to prevent an unusual one.
|
|
194
|
+
*
|
|
195
|
+
* @param {'reference'|'candidate'} role
|
|
196
|
+
* @param {string} mine
|
|
197
|
+
* @returns {string|null}
|
|
198
|
+
*/
|
|
199
|
+
function sameBundleAsTheOtherHalf(role, mine) {
|
|
200
|
+
// Only ever asked about the OLD build's half, and that is not squeamishness — it is the
|
|
201
|
+
// only answer that stays the same from one journey to the next. The engine prepares a
|
|
202
|
+
// build, walks ONE journey against it and throws it away, and it always does the new build
|
|
203
|
+
// first: new-run-a, new-run-b, old-run-a, old-run-b, then the same four again for the next
|
|
204
|
+
// journey. So from the second journey onwards the new build's half would find the previous
|
|
205
|
+
// journey's old half sitting in the map and flag itself as well — the warning would be
|
|
206
|
+
// absent on the first journey and doubled on every one after it, which reads like a bug in
|
|
207
|
+
// the tool rather than a fact about the run.
|
|
208
|
+
if (role !== 'reference') return null;
|
|
209
|
+
const other = walkedFrom.get('candidate');
|
|
210
|
+
if (!other || other !== mine) return null;
|
|
211
|
+
return `Both halves of this comparison were walked from the same app bundle: ${mine}. Nothing in it is older or newer than anything else in it, so no difference between the two builds could possibly show up.`;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/**
|
|
215
|
+
* Where a kept copy of the OLD build's app bundle lives, if the settings name one.
|
|
216
|
+
*
|
|
217
|
+
* Two spellings, because both read naturally and neither is worth an argument:
|
|
218
|
+
* `{"reference": "builds/0.14.0/YourApp.app"}` and
|
|
219
|
+
* `{"reference": {"app": "builds/0.14.0/YourApp.app"}}`. A relative path is resolved against
|
|
220
|
+
* whatever `findAppBundle` is given, which for the reference half is the checkout of the old
|
|
221
|
+
* commit — so a project that DOES commit a simulator build can leave this out entirely.
|
|
222
|
+
*
|
|
223
|
+
* @param {Record<string, any>} config
|
|
224
|
+
* @returns {string|undefined}
|
|
225
|
+
*/
|
|
226
|
+
export function referenceBundle(config) {
|
|
227
|
+
const said = config?.reference;
|
|
228
|
+
if (typeof said === 'string' && said.trim() !== '') return said;
|
|
229
|
+
if (said && typeof said === 'object' && typeof said.app === 'string' && said.app.trim() !== '') return said.app;
|
|
230
|
+
return undefined;
|
|
231
|
+
}
|
|
232
|
+
|
|
104
233
|
// ---------------------------------------------------------------------------
|
|
105
234
|
// Reading the doors out of the source
|
|
106
235
|
// ---------------------------------------------------------------------------
|
|
@@ -542,6 +671,13 @@ export const iosAdapter = defineAdapter({
|
|
|
542
671
|
/** @type {string[]} */
|
|
543
672
|
const notes = [];
|
|
544
673
|
|
|
674
|
+
// What was MEASURED goes on before the machine is asked, so it survives every early
|
|
675
|
+
// return below. These notes are what `doctor` prints and what an agent reads, and on a
|
|
676
|
+
// machine that is not a Mac they were dropped entirely — so somebody planning where to
|
|
677
|
+
// run their checks was told only "not here", never what a Mac would actually do or what
|
|
678
|
+
// it would need from them. A fact that exists only in a comment is a fact nobody sees.
|
|
679
|
+
notes.push('Putting the device back really does put it back, and that is measured rather than assumed. On 2026-08-31 one build was walked ten times on an iOS 27.0 simulator with the app removed and every permission taken back between walks: 725 of 725 addresses agreed in all five pairs — 3,625 comparisons, no disagreements. So a paired run is offered here. What it needs from you is a copy of the OLD build\'s app bundle, because a .app is a build output and a checkout of the old commit does not contain one: name it with {"reference": "path/to/TheOld.app"} under "ios" in the settings.');
|
|
680
|
+
|
|
545
681
|
const machine = await readMachine();
|
|
546
682
|
if (!machine.isMac) {
|
|
547
683
|
return {
|
|
@@ -667,23 +803,61 @@ export const iosAdapter = defineAdapter({
|
|
|
667
803
|
const scratch = path.join(ctx.scratchDir, `ios-${build.id.slice(0, 12).replace(/[^A-Za-z0-9_-]/g, '-')}`);
|
|
668
804
|
await fsp.mkdir(scratch, { recursive: true });
|
|
669
805
|
|
|
806
|
+
// Worked out a few lines below, and captured here so that even a build which could not
|
|
807
|
+
// be got ready still says it. A reference half that failed AND was the same bundle as the
|
|
808
|
+
// candidate is two separate pieces of bad news, and the second one is the one that would
|
|
809
|
+
// otherwise be lost — the run would report "could not be prepared", somebody would fix
|
|
810
|
+
// that, and the comparison would come back green for the wrong reason.
|
|
811
|
+
/** @type {string|null} */
|
|
812
|
+
let sameBundle = null;
|
|
813
|
+
|
|
670
814
|
/** @param {string} why */
|
|
671
815
|
const notReady = (why) => ({
|
|
672
816
|
build,
|
|
673
817
|
root: scratch,
|
|
674
818
|
ready: false,
|
|
675
|
-
why,
|
|
819
|
+
why: sameBundle ? `${why} ${sameBundle}` : why,
|
|
820
|
+
...(build.role === 'reference' ? { facts: { paired: sameBundle === null } } : {}),
|
|
676
821
|
dispose: async () => {
|
|
822
|
+
sameBundleFor.delete(build.id);
|
|
677
823
|
await fsp.rm(scratch, { recursive: true, force: true });
|
|
678
824
|
},
|
|
679
825
|
});
|
|
680
826
|
|
|
827
|
+
// WHICH bundle this half of the comparison walks.
|
|
828
|
+
//
|
|
829
|
+
// For the build you have, that is whatever the settings point at. For the build you were
|
|
830
|
+
// happy with it is different, and the difference is the whole of paired mode on a phone:
|
|
831
|
+
// the engine hands over a checkout of the old commit, and a `.app` is a BUILD OUTPUT that
|
|
832
|
+
// nobody commits, so a checkout of the old commit contains no app at all. `ios.reference`
|
|
833
|
+
// is where a kept copy of the old build's bundle goes, and it is looked at first for the
|
|
834
|
+
// reference half and never for the candidate.
|
|
835
|
+
const forThisHalf = build.role === 'reference' ? { ...config, app: referenceBundle(config) ?? config.app } : config;
|
|
836
|
+
const found = await findAppBundle(build.root, forThisHalf);
|
|
837
|
+
if (!found.ok) {
|
|
838
|
+
return notReady(
|
|
839
|
+
found.why +
|
|
840
|
+
(build.role === 'reference'
|
|
841
|
+
? ' A paired run walks the OLD build here, and a .app is a build output that a repository does not commit — so a checkout of the old commit has no app in it. Keep a copy of each release\'s simulator build and point at it with {"reference": "path/to/TheOld.app"} under "ios" in the settings, and this becomes a real comparison. Without it this journey falls back to the record the old build left the last time it ran, which is weaker and says so.'
|
|
842
|
+
: ''),
|
|
843
|
+
);
|
|
844
|
+
}
|
|
845
|
+
|
|
846
|
+
// Two halves, one bundle. Worked out here, said on every journey — see `run`.
|
|
847
|
+
const mine = await bundleIdentity(found.appPath);
|
|
848
|
+
sameBundle = sameBundleAsTheOtherHalf(/** @type {'reference'|'candidate'} */ (build.role), mine);
|
|
849
|
+
walkedFrom.set(build.role, mine);
|
|
850
|
+
if (sameBundle) sameBundleFor.set(build.id, sameBundle);
|
|
851
|
+
|
|
852
|
+
// The machine is asked AFTER all of that, and the order is the point. Working out which
|
|
853
|
+
// bundle each half walks needs nothing but the filesystem, and it is true on every
|
|
854
|
+
// machine. Asking the machine first meant that anywhere an iPhone app cannot run — any
|
|
855
|
+
// Linux box, any Windows box, a Mac without Xcode — the reference half returned before
|
|
856
|
+
// the check and reported `paired: true`, which is a claim about a comparison it had not
|
|
857
|
+
// made. Caught by CI on Linux against a green Mac suite, 2026-08-31.
|
|
681
858
|
const machine = await readMachine({ signal: ctx.signal });
|
|
682
859
|
if (!machine.ok) return notReady(machine.why);
|
|
683
860
|
|
|
684
|
-
const found = await findAppBundle(build.root, config);
|
|
685
|
-
if (!found.ok) return notReady(found.why);
|
|
686
|
-
|
|
687
861
|
const facts = await readAppBundle(found.appPath);
|
|
688
862
|
if (!facts.ok) return notReady(facts.why);
|
|
689
863
|
|
|
@@ -720,7 +894,7 @@ export const iosAdapter = defineAdapter({
|
|
|
720
894
|
build,
|
|
721
895
|
root: scratch,
|
|
722
896
|
ready: true,
|
|
723
|
-
why: `${facts.name} ${facts.version} (${facts.build}) is on the simulator called ${device.device.name}, running ${device.device.runtimeName}. ${device.why} ${probe.ok ? 'The screen can be read by meaning.' : `The screen CANNOT be read by meaning: ${probe.why} Only pictures, logs, crashes and the files it writes are being checked, which is much less than it sounds.`}`,
|
|
897
|
+
why: `${facts.name} ${facts.version} (${facts.build}) is on the simulator called ${device.device.name}, running ${device.device.runtimeName}. ${device.why} ${probe.ok ? 'The screen can be read by meaning.' : `The screen CANNOT be read by meaning: ${probe.why} Only pictures, logs, crashes and the files it writes are being checked, which is much less than it sounds.`}${sameBundle ? ` ${sameBundle}` : ''}`,
|
|
724
898
|
facts: {
|
|
725
899
|
device: device.device.name,
|
|
726
900
|
udid: device.device.udid,
|
|
@@ -729,10 +903,15 @@ export const iosAdapter = defineAdapter({
|
|
|
729
903
|
version: facts.version,
|
|
730
904
|
readsMeaning: probe.ok,
|
|
731
905
|
weBootedIt: device.device.weBootedIt,
|
|
906
|
+
app: found.appPath,
|
|
907
|
+
// Only ever set on the OLD build's half, because that is the half the question is
|
|
908
|
+
// about: was there really a second build here, or did both halves read one bundle.
|
|
909
|
+
...(build.role === 'reference' ? { paired: sameBundle === null } : {}),
|
|
732
910
|
},
|
|
733
911
|
dispose: async () => {
|
|
734
912
|
const kept = ready.get(build.id);
|
|
735
913
|
ready.delete(build.id);
|
|
914
|
+
sameBundleFor.delete(build.id);
|
|
736
915
|
if (kept?.device) await releaseDevice(kept.device, { signal: ctx.signal });
|
|
737
916
|
await fsp.rm(scratch, { recursive: true, force: true });
|
|
738
917
|
},
|
|
@@ -748,28 +927,53 @@ export const iosAdapter = defineAdapter({
|
|
|
748
927
|
* @returns {Promise<Observation[]>}
|
|
749
928
|
*/
|
|
750
929
|
async run(journey, build, ctx) {
|
|
930
|
+
// FIRST, IN FRONT OF EVERY OTHER ANSWER THIS FUNCTION CAN GIVE.
|
|
931
|
+
//
|
|
932
|
+
// Said on every journey rather than once at the start, which is the same rule the web
|
|
933
|
+
// adapter follows for an app read at a fixed address. A run that compared one bundle
|
|
934
|
+
// against itself finds no differences, and "no differences" is the sentence this whole
|
|
935
|
+
// tool is believed for.
|
|
936
|
+
//
|
|
937
|
+
// It has to come before the "was this build ever got ready" answer below and not after
|
|
938
|
+
// it, because a reference half can fail to prepare AND have been the same bundle as the
|
|
939
|
+
// candidate. Say only the first and somebody fixes the preparation, runs it again, and
|
|
940
|
+
// gets a clean comparison of one bundle against itself with nothing anywhere to say so.
|
|
941
|
+
const sameBundle = sameBundleFor.get(build.build.id);
|
|
942
|
+
/** @type {Observation[]} */
|
|
943
|
+
const sameBundleSaid = sameBundle
|
|
944
|
+
? [notCovered({
|
|
945
|
+
channel: 'meaning',
|
|
946
|
+
path: joinPath('screen', journey.name, 'which build this was'),
|
|
947
|
+
reason: 'not supported here',
|
|
948
|
+
says:
|
|
949
|
+
`${sameBundle} A .app is a build output, so a checkout of the old commit does not contain one and the settings' own path was used for both halves. ` +
|
|
950
|
+
'Keep a copy of the simulator build you shipped and name it with {"reference": "path/to/TheOld.app"} under "ios" in the settings, and this becomes a real comparison.',
|
|
951
|
+
})]
|
|
952
|
+
: [];
|
|
953
|
+
|
|
751
954
|
const kept = ready.get(build.build.id);
|
|
752
955
|
if (!kept) {
|
|
753
|
-
return [notCovered({
|
|
956
|
+
return [...sameBundleSaid, notCovered({
|
|
754
957
|
channel: 'meaning',
|
|
755
958
|
path: joinPath('screen', journey.name, 'walked'),
|
|
756
959
|
reason: 'not supported here',
|
|
757
|
-
says:
|
|
960
|
+
says: `This build was never got ready, so nothing about it could be walked. ${build.why}`,
|
|
758
961
|
})];
|
|
759
962
|
}
|
|
760
963
|
if (journey.skip) {
|
|
761
|
-
return [notCovered({
|
|
964
|
+
return [...sameBundleSaid, notCovered({
|
|
762
965
|
channel: 'meaning',
|
|
763
966
|
path: joinPath('screen', journey.name, 'walked'),
|
|
764
967
|
reason: 'missing tool',
|
|
765
968
|
says: journey.skip,
|
|
766
969
|
})];
|
|
767
970
|
}
|
|
971
|
+
|
|
768
972
|
if (journey.name === 'what-the-app-declares') {
|
|
769
|
-
return declaredObservations(kept.facts, kept.doors, journey.name, kept.limits ?? []);
|
|
973
|
+
return [...sameBundleSaid, ...declaredObservations(kept.facts, kept.doors, journey.name, kept.limits ?? [])];
|
|
770
974
|
}
|
|
771
975
|
|
|
772
|
-
return walkObservations(journey, kept, ctx);
|
|
976
|
+
return [...sameBundleSaid, ...(await walkObservations(journey, kept, ctx))];
|
|
773
977
|
},
|
|
774
978
|
|
|
775
979
|
async teardown() {
|
|
@@ -777,6 +981,12 @@ export const iosAdapter = defineAdapter({
|
|
|
777
981
|
if (kept?.device) await releaseDevice(kept.device);
|
|
778
982
|
ready.delete(id);
|
|
779
983
|
}
|
|
984
|
+
// One run's memory of which bundle each half was walked from. It must not survive into
|
|
985
|
+
// the next run in the same process — the MCP server and the watch panel both call
|
|
986
|
+
// check() more than once — or a second run would report the first run's bundles as its
|
|
987
|
+
// own.
|
|
988
|
+
walkedFrom.clear();
|
|
989
|
+
sameBundleFor.clear();
|
|
780
990
|
},
|
|
781
991
|
});
|
|
782
992
|
|
|
@@ -30,6 +30,7 @@
|
|
|
30
30
|
*/
|
|
31
31
|
|
|
32
32
|
import crypto from 'node:crypto';
|
|
33
|
+
import fs from 'node:fs';
|
|
33
34
|
import fsp from 'node:fs/promises';
|
|
34
35
|
import net from 'node:net';
|
|
35
36
|
import os from 'node:os';
|
|
@@ -141,11 +142,20 @@ function announce(app) {
|
|
|
141
142
|
*/
|
|
142
143
|
export function appNameFor(binary) {
|
|
143
144
|
const text = String(binary ?? '');
|
|
144
|
-
|
|
145
|
+
// Both separators, not just this machine's. A path is a fact about the machine it came
|
|
146
|
+
// from, and this one is asked about a Mac app bundle — so on Windows, where `path.sep` is a
|
|
147
|
+
// backslash, `/Applications/Widget.app/Contents/MacOS/Widget` did not split at all and the
|
|
148
|
+
// app was announced to the person's screen as "Widget" only by luck, or as the wrong name
|
|
149
|
+
// when the two differ. Measured on a real Windows 11 machine on 2026-08-31, where it
|
|
150
|
+
// answered "Electron" for an app called Terminal Deck.
|
|
151
|
+
const parts = text.split(/[\\/]/);
|
|
145
152
|
for (let i = parts.length - 1; i >= 0; i -= 1) {
|
|
146
153
|
if (parts[i].toLowerCase().endsWith('.app')) return parts[i].slice(0, -4);
|
|
147
154
|
}
|
|
148
|
-
|
|
155
|
+
// The last part of the same split, rather than `path.basename`, so the whole function reads
|
|
156
|
+
// a path the same way from end to end. `path.basename` only knows this machine's separator,
|
|
157
|
+
// and half a function that understands both is worse than either.
|
|
158
|
+
const base = parts[parts.length - 1] ?? '';
|
|
149
159
|
const dot = base.lastIndexOf('.');
|
|
150
160
|
return dot > 0 ? base.slice(0, dot) : base;
|
|
151
161
|
}
|
|
@@ -264,6 +274,52 @@ export async function takePort() {
|
|
|
264
274
|
// Who is holding what
|
|
265
275
|
// ---------------------------------------------------------------------------
|
|
266
276
|
|
|
277
|
+
/**
|
|
278
|
+
* The whole process table, on Windows: who is running, who started them, and with what.
|
|
279
|
+
*
|
|
280
|
+
* This is Windows' `ps -axo pid=,ppid=,command=`. There is no `ps` there, and the two callers
|
|
281
|
+
* below used to hand back an empty list rather than an answer — which reads exactly like
|
|
282
|
+
* "nothing is running", the most dangerous wrong answer either of them could give. PowerShell
|
|
283
|
+
* is on every Windows machine and answers all three columns in one call; JSON rather than a
|
|
284
|
+
* table, because a command line is full of spaces and quotes and columns cannot survive it.
|
|
285
|
+
*
|
|
286
|
+
* An unanswerable question still returns an empty list, because a sweep that cannot be done is
|
|
287
|
+
* not a reason to fail somebody's check — but it is now the rare case rather than every case.
|
|
288
|
+
*
|
|
289
|
+
* @returns {Promise<{pid: number, ppid: number, command: string}[]>}
|
|
290
|
+
*/
|
|
291
|
+
async function windowsProcessTable() {
|
|
292
|
+
try {
|
|
293
|
+
const { stdout } = await execFileAsync(
|
|
294
|
+
'powershell.exe',
|
|
295
|
+
[
|
|
296
|
+
'-NoProfile',
|
|
297
|
+
'-NonInteractive',
|
|
298
|
+
'-ExecutionPolicy',
|
|
299
|
+
'Bypass',
|
|
300
|
+
'-Command',
|
|
301
|
+
'Get-CimInstance Win32_Process | Select-Object ProcessId,ParentProcessId,CommandLine | ConvertTo-Json -Compress -Depth 2',
|
|
302
|
+
],
|
|
303
|
+
{ timeout: 20_000, maxBuffer: 32 * 1024 * 1024, windowsHide: true },
|
|
304
|
+
);
|
|
305
|
+
const text = String(stdout).trim();
|
|
306
|
+
if (text === '') return [];
|
|
307
|
+
const parsed = JSON.parse(text);
|
|
308
|
+
// One process comes back as an object rather than a list of one, which is PowerShell being
|
|
309
|
+
// helpful in a way that would otherwise crash the loop below.
|
|
310
|
+
const rows = Array.isArray(parsed) ? parsed : [parsed];
|
|
311
|
+
return rows
|
|
312
|
+
.map((row) => ({
|
|
313
|
+
pid: Number(row?.ProcessId ?? 0),
|
|
314
|
+
ppid: Number(row?.ParentProcessId ?? 0),
|
|
315
|
+
command: String(row?.CommandLine ?? ''),
|
|
316
|
+
}))
|
|
317
|
+
.filter((row) => Number.isInteger(row.pid) && row.pid > 0);
|
|
318
|
+
} catch {
|
|
319
|
+
return [];
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
|
|
267
323
|
/**
|
|
268
324
|
* Every process on this machine whose command line contains `marker`.
|
|
269
325
|
*
|
|
@@ -276,6 +332,15 @@ export async function takePort() {
|
|
|
276
332
|
*/
|
|
277
333
|
export async function whoIsUsing(marker) {
|
|
278
334
|
if (!marker || marker.length < 8) return [];
|
|
335
|
+
// Windows has no `ps`, and asking it nothing at all was the same as answering "nobody is
|
|
336
|
+
// using this folder" — which is the one answer this function must never invent, because it
|
|
337
|
+
// is what the run trusts when it decides it is alone. Measured on a real Windows 11 machine
|
|
338
|
+
// on 2026-08-31: every call here failed silently and returned an empty list.
|
|
339
|
+
if (process.platform === 'win32') {
|
|
340
|
+
return (await windowsProcessTable())
|
|
341
|
+
.filter((row) => row.pid !== process.pid && row.command.includes(marker))
|
|
342
|
+
.map((row) => ({ pid: row.pid, command: row.command }));
|
|
343
|
+
}
|
|
279
344
|
try {
|
|
280
345
|
const { stdout } = await execFileAsync('/bin/ps', ['-axo', 'pid=,command='], {
|
|
281
346
|
timeout: 8000,
|
|
@@ -321,19 +386,29 @@ export async function descendantsOf(pids) {
|
|
|
321
386
|
if (roots.length === 0) return [];
|
|
322
387
|
/** @type {Map<number, number[]>} */
|
|
323
388
|
const childrenOf = new Map();
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
389
|
+
/** @param {number} pid @param {number} parent */
|
|
390
|
+
const note = (pid, parent) => {
|
|
391
|
+
const list = childrenOf.get(parent) ?? [];
|
|
392
|
+
list.push(pid);
|
|
393
|
+
childrenOf.set(parent, list);
|
|
394
|
+
};
|
|
395
|
+
// The same table, asked for in Windows' words. Returning nothing here meant the app's own
|
|
396
|
+
// children — the renderer processes a browser or an Electron app starts — were never found
|
|
397
|
+
// and never closed, so every check on Windows left a handful of them behind. Measured on a
|
|
398
|
+
// real Windows 11 machine on 2026-08-31.
|
|
399
|
+
if (process.platform === 'win32') {
|
|
400
|
+
for (const row of await windowsProcessTable()) note(row.pid, row.ppid);
|
|
401
|
+
} else {
|
|
402
|
+
try {
|
|
403
|
+
const { stdout } = await execFileAsync('/bin/ps', ['-axo', 'pid=,ppid='], { timeout: 8000, maxBuffer: 8 * 1024 * 1024 });
|
|
404
|
+
for (const line of stdout.split('\n')) {
|
|
405
|
+
const match = line.trim().match(/^(\d+)\s+(\d+)$/);
|
|
406
|
+
if (!match) continue;
|
|
407
|
+
note(Number(match[1]), Number(match[2]));
|
|
408
|
+
}
|
|
409
|
+
} catch {
|
|
410
|
+
return [];
|
|
334
411
|
}
|
|
335
|
-
} catch {
|
|
336
|
-
return [];
|
|
337
412
|
}
|
|
338
413
|
/** @type {Set<number>} */
|
|
339
414
|
const found = new Set();
|
|
@@ -379,6 +454,21 @@ function refuseIfNotScratch(scratchDir, dir) {
|
|
|
379
454
|
'A run always gets its own throwaway settings folder — never a real one.',
|
|
380
455
|
);
|
|
381
456
|
}
|
|
457
|
+
// Anything inside the machine's own temp folder is throwaway by definition, and the
|
|
458
|
+
// settings check below is skipped for it.
|
|
459
|
+
//
|
|
460
|
+
// This is not a loophole, it is the difference between the two operating systems. On
|
|
461
|
+
// Windows the temp folder lives INSIDE the settings folder — `C:\Users\me\AppData\Local\Temp`
|
|
462
|
+
// sits under `C:\Users\me\AppData` — so the rule "refuse anything under AppData" refused
|
|
463
|
+
// every scratch folder the tool makes for itself. Measured on a real Windows 11 machine on
|
|
464
|
+
// 2026-08-31: all 18 isolation cases failed with "that is where real settings live" about a
|
|
465
|
+
// folder the tool had just created for its own use, which means isolation, and therefore
|
|
466
|
+
// every check that opens an app, could never have worked on Windows at all.
|
|
467
|
+
//
|
|
468
|
+
// The guard itself is unchanged everywhere else, and is still the strict one: the folder
|
|
469
|
+
// has to be inside the scratch folder the engine handed us, and if it is not somewhere the
|
|
470
|
+
// operating system itself calls temporary, it may not be anywhere near real settings.
|
|
471
|
+
if (isUnderTemp(inside)) return;
|
|
382
472
|
for (const real of [
|
|
383
473
|
path.join(os.homedir(), 'Library', 'Application Support'),
|
|
384
474
|
path.join(os.homedir(), '.config'),
|
|
@@ -390,6 +480,28 @@ function refuseIfNotScratch(scratchDir, dir) {
|
|
|
390
480
|
}
|
|
391
481
|
}
|
|
392
482
|
|
|
483
|
+
/**
|
|
484
|
+
* Is this path inside the folder the operating system itself hands out for throwaway files?
|
|
485
|
+
*
|
|
486
|
+
* Both spellings are compared, because Windows hands the same folder out under two names: the
|
|
487
|
+
* long one and the old eight-character one (`C:\Users\RUNNER~1\...`), and a path that came
|
|
488
|
+
* back from one call can be spelled the other way from the next.
|
|
489
|
+
*
|
|
490
|
+
* @param {string} resolvedWithSeparator An already-resolved path, ending in a separator.
|
|
491
|
+
* @returns {boolean}
|
|
492
|
+
*/
|
|
493
|
+
function isUnderTemp(resolvedWithSeparator) {
|
|
494
|
+
const temp = os.tmpdir();
|
|
495
|
+
/** @type {string[]} */
|
|
496
|
+
const spellings = [temp];
|
|
497
|
+
try {
|
|
498
|
+
spellings.push(fs.realpathSync.native(temp));
|
|
499
|
+
} catch {
|
|
500
|
+
// No second spelling available, which is the ordinary case everywhere but Windows.
|
|
501
|
+
}
|
|
502
|
+
return spellings.some((t) => resolvedWithSeparator.startsWith(path.resolve(t) + path.sep));
|
|
503
|
+
}
|
|
504
|
+
|
|
393
505
|
/**
|
|
394
506
|
* The flags that make one desktop app run alone, and paint the same way twice.
|
|
395
507
|
*
|
|
@@ -423,6 +535,41 @@ export function isolationArgs(isolation, opts = {}) {
|
|
|
423
535
|
];
|
|
424
536
|
}
|
|
425
537
|
|
|
538
|
+
/**
|
|
539
|
+
* The Windows half of "its own settings folder".
|
|
540
|
+
*
|
|
541
|
+
* Everything in the list above is POSIX — HOME, TMPDIR, the XDG folders — and a Windows
|
|
542
|
+
* program reads none of it. It reads USERPROFILE, APPDATA and LOCALAPPDATA. So on Windows the
|
|
543
|
+
* promise at the very top of this file was quietly not being kept: the run got its own
|
|
544
|
+
* folders, and the app carried on writing into the person's real ones. Found on 2026-08-31,
|
|
545
|
+
* the first day anything in this file had ever run on Windows.
|
|
546
|
+
*
|
|
547
|
+
* The second group is not isolation, it is the machine. The environment handed to a child here
|
|
548
|
+
* REPLACES the child's own rather than adding to it, and these are the variables a Windows
|
|
549
|
+
* program is entitled to assume are there — SystemRoot above all, which is where it looks for
|
|
550
|
+
* the libraries that open a socket. `node` and `npm` were measured starting perfectly well
|
|
551
|
+
* without them on 2026-08-31, so this is not a fault anybody has hit; it is a hole left open
|
|
552
|
+
* that a real desktop app is much more likely to fall into than a command-line tool is. They
|
|
553
|
+
* are copied from this process rather than invented, because they describe the machine and
|
|
554
|
+
* not the run.
|
|
555
|
+
*
|
|
556
|
+
* @param {string} homeDir The throwaway home this run was given.
|
|
557
|
+
* @returns {Record<string, string>}
|
|
558
|
+
*/
|
|
559
|
+
function windowsIsolationEnv(homeDir) {
|
|
560
|
+
/** @type {Record<string, string>} */
|
|
561
|
+
const env = {
|
|
562
|
+
USERPROFILE: homeDir,
|
|
563
|
+
APPDATA: path.join(homeDir, 'AppData', 'Roaming'),
|
|
564
|
+
LOCALAPPDATA: path.join(homeDir, 'AppData', 'Local'),
|
|
565
|
+
};
|
|
566
|
+
for (const name of ['SystemRoot', 'windir', 'SystemDrive', 'COMSPEC', 'PATHEXT', 'NUMBER_OF_PROCESSORS', 'PROCESSOR_ARCHITECTURE']) {
|
|
567
|
+
const value = process.env[name];
|
|
568
|
+
if (value) env[name] = value;
|
|
569
|
+
}
|
|
570
|
+
return env;
|
|
571
|
+
}
|
|
572
|
+
|
|
426
573
|
/**
|
|
427
574
|
* Set one run up: its own folders, its own ports, its own identity.
|
|
428
575
|
*
|
|
@@ -458,6 +605,13 @@ export async function reserveIsolation(opts) {
|
|
|
458
605
|
for (const folder of [userDataDir, homeDir, tmpDir, cacheDir, crashDir]) {
|
|
459
606
|
await fsp.mkdir(folder, { recursive: true });
|
|
460
607
|
}
|
|
608
|
+
// The two folders a Windows program expects to find already there. It is handed a home of
|
|
609
|
+
// its own below, and a home with no AppData in it is not one any Windows app has ever seen.
|
|
610
|
+
if (process.platform === 'win32') {
|
|
611
|
+
for (const folder of [path.join(homeDir, 'AppData', 'Roaming'), path.join(homeDir, 'AppData', 'Local')]) {
|
|
612
|
+
await fsp.mkdir(folder, { recursive: true });
|
|
613
|
+
}
|
|
614
|
+
}
|
|
461
615
|
|
|
462
616
|
const debugPort = await takePort();
|
|
463
617
|
const inspectPort = await takePort();
|
|
@@ -516,6 +670,7 @@ export async function reserveIsolation(opts) {
|
|
|
516
670
|
// Nothing being checked should be phoning home about itself.
|
|
517
671
|
ELECTRON_NO_ATTACH_CONSOLE: '1',
|
|
518
672
|
ELECTRON_ENABLE_LOGGING: '1',
|
|
673
|
+
...(process.platform === 'win32' ? windowsIsolationEnv(homeDir) : {}),
|
|
519
674
|
...identityEnv,
|
|
520
675
|
...opts.env,
|
|
521
676
|
},
|