From 37de71f5f8428462d8937aaefed1cc42457763fd Mon Sep 17 00:00:00 2001 From: Mykola Mokhnach Date: Fri, 20 Mar 2026 08:43:50 +0100 Subject: [PATCH 1/8] feat: Add an option to run devicectl without sudo --- README.md | 19 +++++++++++++++++ lib/devicectl.ts | 35 ++++++++++++++++++++++++++++---- lib/types.ts | 17 ++++++++++++++++ test/e2e/sudo-execution-specs.ts | 26 ++++++++++++++++++++++++ test/unit/devicectl-specs.ts | 22 ++++++++++++++++++++ 5 files changed, 115 insertions(+), 4 deletions(-) create mode 100644 test/e2e/sudo-execution-specs.ts diff --git a/README.md b/README.md index 14a4e95..82d6d75 100644 --- a/README.md +++ b/README.md @@ -30,6 +30,25 @@ await devicectl.launchApp('com.example.app', { }); ``` +When Node is running under `sudo`, `node-devicectl` runs `xcrun devicectl` as the original +non-root user by default (`SUDO_UID`/`SUDO_GID`) to avoid CoreDevice provider lookup errors. + +You can disable this globally via constructor options: + +```typescript +const devicectl = new Devicectl('device-udid', { + preferNonRootWhenSudo: false, +}); +``` + +Or override for a single command: + +```typescript +await devicectl.execute(['device', 'info'], { + runAsNonRootWhenSudo: false, +}); +``` + ## Requirements - Xcode 15+ diff --git a/lib/devicectl.ts b/lib/devicectl.ts index fef7200..6886fd0 100644 --- a/lib/devicectl.ts +++ b/lib/devicectl.ts @@ -1,7 +1,7 @@ import {exec, SubProcess} from 'teen_process'; import _ from 'lodash'; import logger from '@appium/logger'; -import type {ExecuteOptions, ExecuteResult} from './types'; +import type {DevicectlOptions, ExecuteOptions, ExecuteResult} from './types'; import * as processMixins from './mixins/process'; import * as infoMixins from './mixins/info'; import * as copyMixins from './mixins/copy'; @@ -20,14 +20,18 @@ const LOG_TAG = 'Devicectl'; export class Devicectl { /** The unique device identifier */ public readonly udid: string; + private readonly preferNonRootWhenSudo: boolean; + private readonly sudoUser: {uid: number; gid: number} | null; /** * Creates a new Devicectl instance * * @param udid - The unique device identifier */ - constructor(udid: string) { + constructor(udid: string, opts?: DevicectlOptions) { this.udid = udid; + this.preferNonRootWhenSudo = opts?.preferNonRootWhenSudo ?? true; + this.sudoUser = this.resolveSudoUser(); } /** @@ -48,6 +52,7 @@ export class Devicectl { noDevice = false, subcommandOptions, timeout, + runAsNonRootWhenSudo = this.preferNonRootWhenSudo, } = opts ?? {}; const finalArgs = ['devicectl', ...subcommand, ...(noDevice ? [] : ['--device', this.udid])]; @@ -63,16 +68,21 @@ export class Devicectl { } const cmdStr = [XCRUN, ...finalArgs].map((arg) => `"${arg}"`).join(' '); + const userOpts = this.shouldRunAsNonRoot(runAsNonRootWhenSudo) && this.sudoUser ? this.sudoUser : {}; logger.debug(LOG_TAG, `Executing ${cmdStr}`); try { if (asynchronous) { - const result = new SubProcess(XCRUN, finalArgs); + const result = new SubProcess(XCRUN, finalArgs, userOpts); await result.start(0); return result as ExecuteResult; } - const result = await exec(XCRUN, finalArgs, ...(_.isNumber(timeout) ? [{timeout}] : [])); + const execOpts = { + ...userOpts, + ...(_.isNumber(timeout) ? {timeout} : {}), + }; + const result = await exec(XCRUN, finalArgs, execOpts); if (logStdout) { logger.debug(LOG_TAG, `Command output: ${result.stdout}`); @@ -95,4 +105,21 @@ export class Devicectl { pullFile = copyMixins.pullFile; listDevices = listMixins.listDevices; + + private resolveSudoUser(): {uid: number; gid: number} | null { + if (!process.geteuid || process.geteuid() !== 0) { + return null; + } + + const uid = Number(process.env.SUDO_UID); + const gid = Number(process.env.SUDO_GID); + if (!Number.isInteger(uid) || !Number.isInteger(gid)) { + return null; + } + return {uid, gid}; + } + + private shouldRunAsNonRoot(runAsNonRootWhenSudo: boolean): boolean { + return runAsNonRootWhenSudo && !!this.sudoUser; + } } diff --git a/lib/types.ts b/lib/types.ts index c83d3f7..9fd7178 100644 --- a/lib/types.ts +++ b/lib/types.ts @@ -66,6 +66,23 @@ export interface ExecuteOptions { subcommandOptions?: string[] | string; /** Timeout in milliseconds */ timeout?: number; + /** + * Whether to run as the original non-root user when current process is running via sudo + * + * If not set, this falls back to `DevicectlOptions.preferNonRootWhenSudo`. + */ + runAsNonRootWhenSudo?: boolean; +} + +/** + * Options for creating a Devicectl instance + */ +export interface DevicectlOptions { + /** + * Whether to run `devicectl` as the original non-root user when parent process runs via sudo + * @default true + */ + preferNonRootWhenSudo?: boolean; } /** diff --git a/test/e2e/sudo-execution-specs.ts b/test/e2e/sudo-execution-specs.ts new file mode 100644 index 0000000..5289e4d --- /dev/null +++ b/test/e2e/sudo-execution-specs.ts @@ -0,0 +1,26 @@ +import {expect} from 'chai'; +import {Devicectl} from '../../lib/devicectl'; + +describe('manual sudo execution e2e', function () { + it('runs devicectl as original non-root user under sudo', async function () { + if (process.env.CI || + process.platform !== 'darwin' || + !process.geteuid || + process.geteuid() !== 0 || + !process.env.SUDO_UID || + !process.env.SUDO_GID + ) { + this.skip(); + return; + } + + const devicectl = new Devicectl(''); + const result = await devicectl.execute(['list', 'devices'], { + noDevice: true, + asJson: true, + }); + + expect(result.stdout).to.be.a('string'); + expect(result.stdout.length).to.be.greaterThan(0); + }); +}); diff --git a/test/unit/devicectl-specs.ts b/test/unit/devicectl-specs.ts index 70ffe4c..3da0042 100644 --- a/test/unit/devicectl-specs.ts +++ b/test/unit/devicectl-specs.ts @@ -12,6 +12,28 @@ describe('Devicectl', function () { it('should create a Devicectl instance with the provided UDID and default logger', function () { expect(devicectl.udid).to.equal('test-device-udid'); }); + + it('should enable non-root sudo execution by default', function () { + expect((devicectl as any).preferNonRootWhenSudo).to.equal(true); + }); + + it('should allow disabling non-root sudo execution in constructor options', function () { + const localDevicectl = new Devicectl('test-device-udid', {preferNonRootWhenSudo: false}); + expect((localDevicectl as any).preferNonRootWhenSudo).to.equal(false); + }); + }); + + describe('sudo behavior', function () { + it('should run as non-root only when override is enabled and sudo identity exists', function () { + (devicectl as any).sudoUser = {uid: 501, gid: 20}; + expect((devicectl as any).shouldRunAsNonRoot(true)).to.equal(true); + expect((devicectl as any).shouldRunAsNonRoot(false)).to.equal(false); + }); + + it('should not run as non-root when sudo identity is unavailable', function () { + (devicectl as any).sudoUser = null; + expect((devicectl as any).shouldRunAsNonRoot(true)).to.equal(false); + }); }); describe('execute', function () { From ccf7c0f0b1b504db0568df84d3a3409ec92250f0 Mon Sep 17 00:00:00 2001 From: Mykola Mokhnach Date: Fri, 20 Mar 2026 09:21:40 +0100 Subject: [PATCH 2/8] feat: Default to execution as non-root --- .mocharc.js | 6 +----- lib/devicectl.ts | 13 ++++++------- package.json | 10 +++++----- tsconfig.json | 4 ++-- 4 files changed, 14 insertions(+), 19 deletions(-) diff --git a/.mocharc.js b/.mocharc.js index 495c6e8..3136ed9 100644 --- a/.mocharc.js +++ b/.mocharc.js @@ -2,9 +2,5 @@ module.exports = { require: ['ts-node/register'], timeout: 60000, exit: true, - recursive: true, - spec: [ - './test/unit/**/*-specs.ts', - './test/e2e/**/*-specs.ts' - ] + recursive: true }; diff --git a/lib/devicectl.ts b/lib/devicectl.ts index 6886fd0..90ad578 100644 --- a/lib/devicectl.ts +++ b/lib/devicectl.ts @@ -9,6 +9,7 @@ import * as listMixins from './mixins/list'; const XCRUN = 'xcrun'; const LOG_TAG = 'Devicectl'; +type SudoUser = {uid: number; gid: number}; /** * Node.js wrapper around Apple's devicectl tool @@ -21,7 +22,7 @@ export class Devicectl { /** The unique device identifier */ public readonly udid: string; private readonly preferNonRootWhenSudo: boolean; - private readonly sudoUser: {uid: number; gid: number} | null; + private readonly sudoUser: SudoUser | null; /** * Creates a new Devicectl instance @@ -67,8 +68,10 @@ export class Devicectl { finalArgs.push('--quiet', '--json-output', '-'); } + const userOpts = runAsNonRootWhenSudo && this.sudoUser + ? this.sudoUser ?? undefined + : undefined; const cmdStr = [XCRUN, ...finalArgs].map((arg) => `"${arg}"`).join(' '); - const userOpts = this.shouldRunAsNonRoot(runAsNonRootWhenSudo) && this.sudoUser ? this.sudoUser : {}; logger.debug(LOG_TAG, `Executing ${cmdStr}`); try { @@ -106,7 +109,7 @@ export class Devicectl { listDevices = listMixins.listDevices; - private resolveSudoUser(): {uid: number; gid: number} | null { + private resolveSudoUser(): SudoUser | null { if (!process.geteuid || process.geteuid() !== 0) { return null; } @@ -118,8 +121,4 @@ export class Devicectl { } return {uid, gid}; } - - private shouldRunAsNonRoot(runAsNonRootWhenSudo: boolean): boolean { - return runAsNonRootWhenSudo && !!this.sudoUser; - } } diff --git a/package.json b/package.json index aa73bf2..d7762d0 100644 --- a/package.json +++ b/package.json @@ -30,9 +30,8 @@ }, "files": [ "lib", - "build", - "CHANGELOG.md", - "!build/test" + "build/lib", + "CHANGELOG.md" ], "dependencies": { "@appium/logger": "^2.0.0-rc.1", @@ -49,8 +48,8 @@ "format": "prettier -w ./lib ./test", "format:check": "prettier --check ./lib ./test", "prepare": "npm run build", - "test": "mocha --exit --timeout 1m \"./test/unit/**/*-specs.*js\"", - "e2e-test": "mocha --exit --timeout 5m \"./test/e2e/**/*-specs.js\"" + "test": "mocha --exit --timeout 1m \"./test/unit/**/*-specs.ts\"", + "e2e-test": "mocha --exit --timeout 5m \"./test/e2e/**/*-specs.ts\"" }, "prettier": { "bracketSpacing": false, @@ -63,6 +62,7 @@ "@appium/types": "^1.0.0-rc.1", "@semantic-release/changelog": "^6.0.1", "@semantic-release/git": "^10.0.1", + "@types/chai": "^5.2.3", "@types/lodash": "^4.14.196", "@types/mocha": "^10.0.1", "@types/node": "^25.0.3", diff --git a/tsconfig.json b/tsconfig.json index c578b3c..499781d 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -5,8 +5,8 @@ "outDir": "build", "checkJs": true, "esModuleInterop": true, - "types": ["node"], + "types": ["node", "mocha"], "strict": true }, - "include": ["lib"] + "include": ["lib", "test"] } From 818403cc17a3bca50cea1752b896c0aeedf84e01 Mon Sep 17 00:00:00 2001 From: Mykola Mokhnach Date: Fri, 20 Mar 2026 09:36:04 +0100 Subject: [PATCH 3/8] format --- lib/devicectl.ts | 5 ++--- test/e2e/sudo-execution-specs.ts | 3 ++- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/lib/devicectl.ts b/lib/devicectl.ts index 90ad578..d743ddf 100644 --- a/lib/devicectl.ts +++ b/lib/devicectl.ts @@ -68,9 +68,8 @@ export class Devicectl { finalArgs.push('--quiet', '--json-output', '-'); } - const userOpts = runAsNonRootWhenSudo && this.sudoUser - ? this.sudoUser ?? undefined - : undefined; + const userOpts = + runAsNonRootWhenSudo && this.sudoUser ? (this.sudoUser ?? undefined) : undefined; const cmdStr = [XCRUN, ...finalArgs].map((arg) => `"${arg}"`).join(' '); logger.debug(LOG_TAG, `Executing ${cmdStr}`); diff --git a/test/e2e/sudo-execution-specs.ts b/test/e2e/sudo-execution-specs.ts index 5289e4d..8a86b99 100644 --- a/test/e2e/sudo-execution-specs.ts +++ b/test/e2e/sudo-execution-specs.ts @@ -3,7 +3,8 @@ import {Devicectl} from '../../lib/devicectl'; describe('manual sudo execution e2e', function () { it('runs devicectl as original non-root user under sudo', async function () { - if (process.env.CI || + if ( + process.env.CI || process.platform !== 'darwin' || !process.geteuid || process.geteuid() !== 0 || From aa97dbe7bfbb25f837314916e4f1c8acea0f6afe Mon Sep 17 00:00:00 2001 From: Mykola Mokhnach Date: Fri, 20 Mar 2026 09:39:25 +0100 Subject: [PATCH 4/8] fix tests --- test/unit/devicectl-specs.ts | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/test/unit/devicectl-specs.ts b/test/unit/devicectl-specs.ts index 3da0042..76338e8 100644 --- a/test/unit/devicectl-specs.ts +++ b/test/unit/devicectl-specs.ts @@ -24,15 +24,15 @@ describe('Devicectl', function () { }); describe('sudo behavior', function () { - it('should run as non-root only when override is enabled and sudo identity exists', function () { - (devicectl as any).sudoUser = {uid: 501, gid: 20}; - expect((devicectl as any).shouldRunAsNonRoot(true)).to.equal(true); - expect((devicectl as any).shouldRunAsNonRoot(false)).to.equal(false); + it('should cache sudo user identity when available', function () { + const localDevicectl = new Devicectl('test-device-udid'); + expect((localDevicectl as any).sudoUser === null || !!(localDevicectl as any).sudoUser).to.equal( + true, + ); }); - it('should not run as non-root when sudo identity is unavailable', function () { - (devicectl as any).sudoUser = null; - expect((devicectl as any).shouldRunAsNonRoot(true)).to.equal(false); + it('should keep constructor default for runAsNonRootWhenSudo behavior', function () { + expect((devicectl as any).preferNonRootWhenSudo).to.equal(true); }); }); From d5d775401fe540c406424015b7774991a804b777 Mon Sep 17 00:00:00 2001 From: Mykola Mokhnach Date: Fri, 20 Mar 2026 09:44:13 +0100 Subject: [PATCH 5/8] format --- test/unit/devicectl-specs.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/test/unit/devicectl-specs.ts b/test/unit/devicectl-specs.ts index 76338e8..207f53f 100644 --- a/test/unit/devicectl-specs.ts +++ b/test/unit/devicectl-specs.ts @@ -26,9 +26,9 @@ describe('Devicectl', function () { describe('sudo behavior', function () { it('should cache sudo user identity when available', function () { const localDevicectl = new Devicectl('test-device-udid'); - expect((localDevicectl as any).sudoUser === null || !!(localDevicectl as any).sudoUser).to.equal( - true, - ); + expect( + (localDevicectl as any).sudoUser === null || !!(localDevicectl as any).sudoUser, + ).to.equal(true); }); it('should keep constructor default for runAsNonRootWhenSudo behavior', function () { From 28d2e731a560c59dba3a612ae86fd85e4856fe9a Mon Sep 17 00:00:00 2001 From: Mykola Mokhnach Date: Fri, 20 Mar 2026 17:24:06 +0100 Subject: [PATCH 6/8] bump teen process --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index d7762d0..b4976bf 100644 --- a/package.json +++ b/package.json @@ -36,7 +36,7 @@ "dependencies": { "@appium/logger": "^2.0.0-rc.1", "lodash": "^4.2.1", - "teen_process": "^4.0.4" + "teen_process": "^4.1.0" }, "scripts": { "build": "tsc -b", From 27ca12f242c8ef754dd2b7b89b4e42e11e433c91 Mon Sep 17 00:00:00 2001 From: Mykola Mokhnach Date: Fri, 20 Mar 2026 19:11:37 +0100 Subject: [PATCH 7/8] address comments --- lib/devicectl.ts | 2 +- test/unit/devicectl-specs.ts | 4 ---- 2 files changed, 1 insertion(+), 5 deletions(-) diff --git a/lib/devicectl.ts b/lib/devicectl.ts index d743ddf..8c258bf 100644 --- a/lib/devicectl.ts +++ b/lib/devicectl.ts @@ -69,7 +69,7 @@ export class Devicectl { } const userOpts = - runAsNonRootWhenSudo && this.sudoUser ? (this.sudoUser ?? undefined) : undefined; + runAsNonRootWhenSudo && this.sudoUser ? this.sudoUser : undefined; const cmdStr = [XCRUN, ...finalArgs].map((arg) => `"${arg}"`).join(' '); logger.debug(LOG_TAG, `Executing ${cmdStr}`); diff --git a/test/unit/devicectl-specs.ts b/test/unit/devicectl-specs.ts index 207f53f..70f1410 100644 --- a/test/unit/devicectl-specs.ts +++ b/test/unit/devicectl-specs.ts @@ -13,10 +13,6 @@ describe('Devicectl', function () { expect(devicectl.udid).to.equal('test-device-udid'); }); - it('should enable non-root sudo execution by default', function () { - expect((devicectl as any).preferNonRootWhenSudo).to.equal(true); - }); - it('should allow disabling non-root sudo execution in constructor options', function () { const localDevicectl = new Devicectl('test-device-udid', {preferNonRootWhenSudo: false}); expect((localDevicectl as any).preferNonRootWhenSudo).to.equal(false); From d500fa62520e6d5f7542c06eb13fcbb516ada17f Mon Sep 17 00:00:00 2001 From: Mykola Mokhnach Date: Fri, 20 Mar 2026 19:14:04 +0100 Subject: [PATCH 8/8] format --- lib/devicectl.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/lib/devicectl.ts b/lib/devicectl.ts index 8c258bf..a1013be 100644 --- a/lib/devicectl.ts +++ b/lib/devicectl.ts @@ -68,8 +68,7 @@ export class Devicectl { finalArgs.push('--quiet', '--json-output', '-'); } - const userOpts = - runAsNonRootWhenSudo && this.sudoUser ? this.sudoUser : undefined; + const userOpts = runAsNonRootWhenSudo && this.sudoUser ? this.sudoUser : undefined; const cmdStr = [XCRUN, ...finalArgs].map((arg) => `"${arg}"`).join(' '); logger.debug(LOG_TAG, `Executing ${cmdStr}`);