Skip to content

Commit 72c7d7d

Browse files
feat(ios-qa): forward GSTACK_IOS_LAUNCH_ENV to devicectl launches (#1796)
Reimplements only the launch-environment half of #1796 (credit Bmathews721; the session-cache half is superseded by #3132's design and had state-root and symlink-write problems). devicectl.ts parses GSTACK_IOS_LAUNCH_ENV as a JSON object of string to string; launchEnvArgs() returns the --environment-variables argv (empty when unset or {}) and launchApp passes it before the bundle id. Invalid JSON, a non-object, a non-string value, a bad name or a key in the debug bridge's reserved GSTACK_ namespace refuses before any launch with the new result code IOS_LAUNCH_ENV_INVALID (the detail names keys and types, never values). launchEnvArgs is exported for the forced-relaunch path in tunnel-bootstrap.ts. Adds the result-code row (bin/gstack-doctor-components.sh regenerated by gen:skill-docs), a troubleshooting entry under its exact text, the pregate-no-lane entry's explain note, and the how-to section with an example. New test fails on 47d639a (module load: no parseLaunchEnv). Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
1 parent 2744b21 commit 72c7d7d

6 files changed

Lines changed: 155 additions & 3 deletions

File tree

‎bin/gstack-doctor-components.sh‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -196,6 +196,7 @@ gstack_result_anchor() {
196196
BROWSE_TAB_UNRECOVERABLE) printf '%s' 'browse-tab-unrecoverable' ;;
197197
BUN_VERSION_UNREADABLE) printf '%s' 'bun-version-unreadable' ;;
198198
BASH_UNAVAILABLE) printf '%s' 'bash-unavailable' ;;
199+
IOS_LAUNCH_ENV_INVALID) printf '%s' 'ios-launch-env-invalid' ;;
199200
*) return 1 ;;
200201
esac
201202
}

‎docs/howto-ios-testing-with-gstack.md‎

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -128,8 +128,18 @@ Set these env vars to target a specific device or bundle:
128128
GSTACK_IOS_TARGET_UDID=248C3A58-B843-5BDB-8F5D-89ADB7D7BF6A
129129
GSTACK_IOS_TARGET_BUNDLE_ID=com.yourorg.yourapp
130130
GSTACK_IOS_DAEMON_PORT=9099 # loopback listener port; default 9099
131+
GSTACK_IOS_LAUNCH_ENV='{"DEBUG_BRIDGE":"1"}' # optional: env for the app when the daemon launches it
131132
```
132133

134+
`GSTACK_IOS_LAUNCH_ENV` is for apps whose debug bridge (or anything else) is
135+
gated on an environment variable. It is a JSON object of string to string, and
136+
the daemon passes it to `xcrun devicectl device process launch
137+
--environment-variables` on every launch it performs, including the one forced
138+
relaunch below. It only takes effect when the daemon launches the app; an app
139+
that is already running keeps the environment it started with. Invalid JSON, a
140+
non-string value, or a `GSTACK_` key (reserved for the debug bridge) refuses the
141+
launch with `IOS_LAUNCH_ENV_INVALID` instead of starting the app without it.
142+
133143
If `GSTACK_IOS_TARGET_UDID` is unset, the daemon picks the best paired,
134144
available iPhone or iPad, preferring a wired device. When two devices tie (an
135145
iPhone and an iPad both on USB), it refuses to guess: the bootstrap error lists
@@ -230,6 +240,7 @@ Before you ship to TestFlight or the App Store, run `/ios-clean`. It removes the
230240
| `Developer Mode` toggle missing from Settings → Privacy | Connect the device to Xcode → Window → Devices and Simulators once, or try any `devicectl device install` against it. iOS will surface the toggle after the first attempt. |
231241
| `xcrun devicectl device copy from` returns ERROR 7000 | The source path is wrong — boot token lives at `tmp/gstack-ios-qa.token` inside the app's data container (NSTemporaryDirectory), not at the path's root. |
232242
| Bootstrap fails with `multiple_devices` | More than one iPhone/iPad is connected. Run the printed `export GSTACK_IOS_TARGET_UDID=<udid>` and restart the daemon. |
243+
| Launch refused with `(IOS_LAUNCH_ENV_INVALID)` | `GSTACK_IOS_LAUNCH_ENV` is not a JSON object of string to string, or sets a `GSTACK_` key. Fix or unset it and restart the daemon; see [troubleshooting](troubleshooting.md#ios-launch-env-invalid). |
233244
| `boot_token_unavailable ... could not write tmp/gstack-ios-qa.token` | The app could not write its boot-token file. Fix the app's `tmp/` directory and relaunch the app. |
234245
| A SwiftUI `DragGesture` or `Canvas` ignores taps and swipes on iOS 26 | Synthesized in-process touches do not reach SwiftUI gesture recognizers on iOS 26, and `/swipe` only scrolls a `UIScrollView`. See "Known limits" in the `/ios-qa` skill for the input-routing workaround. |
235246
| `/healthz` returns 200 but `/tap` returns ok:true with no UI change | The phone is paired but the StateServer port may have changed across launches. Re-resolve the CoreDevice IPv6 (`dscacheutil -q host -a name '<DeviceName>.coredevice.local'`). |

‎docs/troubleshooting.md‎

Lines changed: 29 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2698,7 +2698,12 @@ string literals, the `bin/<name>` → `test/<name>*.test.ts` convention or a
26982698

26992699
**Fix.** Add a test that names the file, or declare its lane (an explicit `[]`
27002700
for files with no behaviour, such as docs) in `.gstack/pregate.json`. `--explain`
2701-
prints every mapping and why.
2701+
prints every mapping and why. For a file with no lane it also prints the import
2702+
specifiers in test files that resolved to no repo file (for example a
2703+
`tsconfig.json` `paths` alias such as `@/lib/x` whose target is missing) and any
2704+
`extends` hop that was not followed (only relative `extends`, at most 4 hops). If
2705+
a test does cover the file, declare it under `dependencies`; if the resolver
2706+
should have found it, report the specifier as a resolver gap.
27022707

27032708
<a id="pregate-lane-failed"></a>
27042709
### `PREGATE_LANE_FAILED`
@@ -3077,3 +3082,26 @@ writes that state; it comes from a hand edit or an older runner.
30773082

30783083
**Fix.** Answer the gate with `gstack-autoplan answer` until no approval is
30793084
pending, and `revise --incorporates` or `--withdraw` each pending amendment.
3085+
3086+
<a id="ios-launch-env-invalid"></a>
3087+
### `ios-qa launch: GSTACK_IOS_LAUNCH_ENV ... so the app was not launched (<detail>); fix: ... (IOS_LAUNCH_ENV_INVALID)`
3088+
3089+
**Meaning.** `gstack-ios-qa-daemon` forwards `GSTACK_IOS_LAUNCH_ENV` to
3090+
`xcrun devicectl device process launch --environment-variables` whenever it
3091+
launches the app under test. The value must be a JSON object whose keys are
3092+
environment variable names and whose values are strings, and it may not set a
3093+
`GSTACK_` variable (that namespace belongs to the debug bridge). Anything else
3094+
(invalid JSON, an array, a number value, a `GSTACK_` key) refuses the launch,
3095+
because launching without the variables an env-gated debug bridge needs would
3096+
look like a working app with no bridge. The detail names the key and its type,
3097+
never a value. Added in the release after v1.91.101.0.
3098+
3099+
**Fix.** In the shell that starts the daemon (macOS Terminal or Claude Code on
3100+
the Mac with the device):
3101+
3102+
```bash
3103+
export GSTACK_IOS_LAUNCH_ENV='{"DEBUG_BRIDGE":"1"}'
3104+
```
3105+
3106+
or `unset GSTACK_IOS_LAUNCH_ENV`, then restart `gstack-ios-qa-daemon`.
3107+

‎ios-qa/daemon/src/devicectl.ts‎

Lines changed: 39 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import { spawnSync, type SpawnSyncReturns } from 'child_process';
88
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'fs';
99
import { tmpdir } from 'os';
1010
import { join } from 'path';
11+
import { resultLine } from '../../../lib/result-codes';
1112

1213
export interface DeviceEntry {
1314
identifier: string;
@@ -267,17 +268,53 @@ export function isAppRunning(
267268
}
268269
}
269270

271+
/** The debug bridge's own variable namespace; a launch environment may not set it. */
272+
const BRIDGE_ENV_PREFIX = 'GSTACK_';
273+
274+
/**
275+
* Parse GSTACK_IOS_LAUNCH_ENV: a JSON object of string to string (for
276+
* example `{"DEBUG_BRIDGE":"1"}`), or unset. A refusal names keys and types,
277+
* never values, which may be credentials.
278+
*/
279+
export function parseLaunchEnv(raw: string | undefined): { ok: true; env: Record<string, string> | null } | { ok: false; detail: string } {
280+
if (raw === undefined || raw.trim() === '') return { ok: true, env: null };
281+
let value: unknown;
282+
try { value = JSON.parse(raw); } catch { return { ok: false, detail: 'GSTACK_IOS_LAUNCH_ENV is not valid JSON' }; }
283+
if (!value || typeof value !== 'object' || Array.isArray(value)) return { ok: false, detail: 'GSTACK_IOS_LAUNCH_ENV must be a JSON object of string to string' };
284+
for (const [key, v] of Object.entries(value)) {
285+
if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(key)) return { ok: false, detail: `${JSON.stringify(key)} is not an environment variable name` };
286+
if (typeof v !== 'string') return { ok: false, detail: `${key} is a ${v === null ? 'null' : typeof v}, not a string` };
287+
if (key.startsWith(BRIDGE_ENV_PREFIX)) return { ok: false, detail: `${key} collides with the debug bridge's reserved ${BRIDGE_ENV_PREFIX} variables` };
288+
}
289+
return { ok: true, env: Object.keys(value).length ? value as Record<string, string> : null };
290+
}
291+
292+
/**
293+
* The `--environment-variables` argv for a devicectl launch, from
294+
* GSTACK_IOS_LAUNCH_ENV (empty when unset), or the IOS_LAUNCH_ENV_INVALID
295+
* line that must stop the launch. Every launch path uses it, so the app is
296+
* never started without the variables its debug bridge may be gated on.
297+
*/
298+
export function launchEnvArgs(raw = process.env.GSTACK_IOS_LAUNCH_ENV): { ok: true; args: string[] } | { ok: false; error: string } {
299+
const launchEnv = parseLaunchEnv(raw);
300+
if (!launchEnv.ok) return { ok: false, error: resultLine('ios-qa launch', 'IOS_LAUNCH_ENV_INVALID', launchEnv.detail) };
301+
return { ok: true, args: launchEnv.env ? ['--environment-variables', JSON.stringify(launchEnv.env)] : [] };
302+
}
303+
270304
/**
271305
* Launch an app on the device. Returns true on success, false otherwise.
272306
* Locked-device errors (the iPhone needs to be unlocked first) are surfaced
273-
* through the error string.
307+
* through the error string. GSTACK_IOS_LAUNCH_ENV is forwarded (see
308+
* launchEnvArgs); an invalid value refuses before any launch.
274309
*/
275310
export function launchApp(
276311
udid: string,
277312
bundleId: string,
278313
spawn: SpawnImpl = defaultSpawn,
279314
): { ok: boolean; error?: string } {
280-
const r = spawn('xcrun', ['devicectl', 'device', 'process', 'launch', '--device', udid, bundleId]);
315+
const env = launchEnvArgs();
316+
if (!env.ok) return { ok: false, error: env.error };
317+
const r = spawn('xcrun', ['devicectl', 'device', 'process', 'launch', '--device', udid, ...env.args, bundleId]);
281318
if (r.status === 0) return { ok: true };
282319
const err = (r.stderr?.toString() ?? '') + (r.stdout?.toString() ?? '');
283320
if (err.includes('was not, or could not be, unlocked')) {
Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
// GSTACK_IOS_LAUNCH_ENV (split from #1796, credit @Bmathews721): a JSON object
2+
// of string to string forwarded to `devicectl device process launch
3+
// --environment-variables`. An invalid value refuses the launch with
4+
// IOS_LAUNCH_ENV_INVALID instead of launching the app without it.
5+
6+
import { afterEach, beforeEach, describe, expect, test } from 'bun:test';
7+
import type { SpawnSyncReturns } from 'child_process';
8+
import { launchApp, launchEnvArgs, parseLaunchEnv, type SpawnImpl } from '../src/devicectl';
9+
10+
function recorder(status = 0): { spawn: SpawnImpl; calls: string[][] } {
11+
const calls: string[][] = [];
12+
const spawn: SpawnImpl = (cmd, args) => {
13+
calls.push([cmd, ...args]);
14+
return { status, stdout: Buffer.from(''), stderr: Buffer.from(''), pid: 1, output: [], signal: null } as unknown as SpawnSyncReturns<Buffer>;
15+
};
16+
return { spawn, calls };
17+
}
18+
19+
let saved: string | undefined;
20+
beforeEach(() => { saved = process.env.GSTACK_IOS_LAUNCH_ENV; delete process.env.GSTACK_IOS_LAUNCH_ENV; });
21+
afterEach(() => { if (saved === undefined) delete process.env.GSTACK_IOS_LAUNCH_ENV; else process.env.GSTACK_IOS_LAUNCH_ENV = saved; });
22+
23+
describe('GSTACK_IOS_LAUNCH_ENV', () => {
24+
test('unset or empty leaves the launch argv unchanged', () => {
25+
for (const value of [undefined, '', ' ', '{}']) {
26+
if (value === undefined) delete process.env.GSTACK_IOS_LAUNCH_ENV; else process.env.GSTACK_IOS_LAUNCH_ENV = value;
27+
const { spawn, calls } = recorder();
28+
expect(launchApp('UDID', 'com.test', spawn)).toEqual({ ok: true });
29+
expect(calls).toEqual([['xcrun', 'devicectl', 'device', 'process', 'launch', '--device', 'UDID', 'com.test']]);
30+
}
31+
});
32+
33+
test('a JSON object is forwarded before the bundle id', () => {
34+
process.env.GSTACK_IOS_LAUNCH_ENV = '{"DEBUG_BRIDGE":"1","API_BASE":"http://localhost:8080"}';
35+
const launch = recorder();
36+
expect(launchApp('UDID', 'com.test', launch.spawn)).toEqual({ ok: true });
37+
expect(launch.calls[0]).toEqual(['xcrun', 'devicectl', 'device', 'process', 'launch', '--device', 'UDID',
38+
'--environment-variables', '{"DEBUG_BRIDGE":"1","API_BASE":"http://localhost:8080"}', 'com.test']);
39+
expect(launchEnvArgs()).toEqual({ ok: true, args: ['--environment-variables', '{"DEBUG_BRIDGE":"1","API_BASE":"http://localhost:8080"}'] });
40+
expect(launchEnvArgs('')).toEqual({ ok: true, args: [] });
41+
});
42+
43+
test('invalid JSON, a non-object, a non-string value, a bad name or a bridge-reserved key refuses without launching', () => {
44+
const cases: Array<[string, string]> = [
45+
['{"A":', 'is not valid JSON'],
46+
['["A=1"]', 'must be a JSON object of string to string'],
47+
['null', 'must be a JSON object of string to string'],
48+
['{"PORT":8080}', 'PORT is a number, not a string'],
49+
['{"BAD-NAME":"x"}', '"BAD-NAME" is not an environment variable name'],
50+
['{"GSTACK_IOS_QA_PORT":"1"}', 'GSTACK_IOS_QA_PORT collides with the debug bridge\'s reserved GSTACK_ variables'],
51+
];
52+
for (const [value, detail] of cases) {
53+
process.env.GSTACK_IOS_LAUNCH_ENV = value;
54+
const { spawn, calls } = recorder();
55+
const r = launchApp('UDID', 'com.test', spawn);
56+
expect(r.ok).toBe(false);
57+
expect(r.error).toContain(detail);
58+
expect(r.error).toEndWith('(IOS_LAUNCH_ENV_INVALID)');
59+
expect(calls).toEqual([]);
60+
}
61+
});
62+
63+
test('a refusal never echoes a value', () => {
64+
process.env.GSTACK_IOS_LAUNCH_ENV = '{"TOKEN":"sk-secret-value","N":1}';
65+
const r = launchApp('UDID', 'com.test', recorder().spawn);
66+
expect(r.error).not.toContain('sk-secret-value');
67+
expect(parseLaunchEnv('{"TOKEN": "sk-secret-value"')).toEqual({ ok: false, detail: expect.not.stringContaining('sk-secret-value') });
68+
});
69+
});

‎lib/result-codes.ts‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -579,6 +579,12 @@ export const RESULT_CODES = {
579579
summary: 'no Git Bash found to run gstack\'s bash scripts on Windows',
580580
fix: 'install Git for Windows (winget install --id Git.Git -e), or set GSTACK_GIT_BASH to its bash.exe for good: in PowerShell, [Environment]::SetEnvironmentVariable(\'GSTACK_GIT_BASH\', \'C:\\Program Files\\Git\\bin\\bash.exe\', \'User\') and restart Claude Code, or add it to the "env" block of ~/.claude/settings.json',
581581
},
582+
// Fix wave 2026-10-15, PR 3 (item 23, #1796).
583+
IOS_LAUNCH_ENV_INVALID: {
584+
anchor: 'ios-launch-env-invalid',
585+
summary: 'GSTACK_IOS_LAUNCH_ENV is set but is not a JSON object of string to string, or sets a GSTACK_ variable the debug bridge reserves, so the app was not launched',
586+
fix: 'set it to a JSON object such as GSTACK_IOS_LAUNCH_ENV=\'{"DEBUG_BRIDGE":"1"}\' (string values, no GSTACK_ keys), or unset it, then restart gstack-ios-qa-daemon',
587+
},
582588
} as const satisfies Record<string, ResultCode>;
583589

584590
export type ResultCodeName = keyof typeof RESULT_CODES;

0 commit comments

Comments
 (0)