@skrr-ai/cli 0.1.24 → 0.1.25

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.
@@ -64,7 +64,6 @@ exports.__resetKekCacheForTest = __resetKekCacheForTest;
64
64
  * cleaning up the corresponding Secret Service entry when finished.
65
65
  */
66
66
  const node_child_process_1 = require("node:child_process");
67
- const node_util_1 = require("node:util");
68
67
  const node_crypto_1 = __importDefault(require("node:crypto"));
69
68
  const node_os_1 = __importDefault(require("node:os"));
70
69
  const node_fs_1 = __importDefault(require("node:fs"));
@@ -104,7 +103,6 @@ const HKDF_INFO = Buffer.from('oversky-cred-kek-v1');
104
103
  /** Filesystem paths for Tier-3 IKM components. */
105
104
  const PATH_LOGINUID = '/proc/self/loginuid';
106
105
  const PATH_MACHINE_ID = '/etc/machine-id';
107
- const execFileAsync = (0, node_util_1.promisify)(node_child_process_1.execFile);
108
106
  let _execFileForTest = null;
109
107
  /**
110
108
  * @internal Test seam — inject a mock execFile for hermetic tests so the
@@ -118,23 +116,38 @@ function __setExecFileForTest(fn) {
118
116
  _secretToolProbeCache = undefined;
119
117
  _tierSelection = null;
120
118
  }
121
- /** Default execFile wrapper used when no test override is installed. */
122
- const defaultExec = async (file, args, options) => {
123
- const result = await execFileAsync(file, args, {
124
- encoding: 'utf-8',
125
- ...(options ?? {}),
119
+ /**
120
+ * Default execFile wrapper used when no test override is installed.
121
+ *
122
+ * `input` is WRITTEN to the child's stdin here, and stdin is always closed. The
123
+ * previous version forwarded `input` as an execFile option — but only the SYNC
124
+ * child_process functions honour `input`; async `execFile` ignores it. So
125
+ * `secret-tool store`, which reads the secret from stdin, got neither the secret
126
+ * nor EOF and waited forever: Tier-2 enrollment could not complete on any Linux
127
+ * desktop with a working Secret Service. Closing stdin also means no other call
128
+ * can block on a tool that unexpectedly reads it.
129
+ */
130
+ const defaultExec = (file, args, options) => new Promise((resolve, reject) => {
131
+ const { input, ...execOptions } = options ?? {};
132
+ const child = (0, node_child_process_1.execFile)(file, args, { encoding: 'utf-8', ...execOptions }, (err, stdout, stderr) => {
133
+ // Defensive coercion — when callers override the encoding, output may
134
+ // surface as Buffer; normalize both shapes.
135
+ const out = typeof stdout === 'string' ? stdout : Buffer.from(stdout).toString('utf-8');
136
+ const errOut = typeof stderr === 'string' ? stderr : Buffer.from(stderr).toString('utf-8');
137
+ if (err) {
138
+ // Same shape promisify(execFile) produced, which the probe reads.
139
+ reject(Object.assign(err, { stdout: out, stderr: errOut }));
140
+ return;
141
+ }
142
+ resolve({ stdout: out, stderr: errOut });
126
143
  });
127
- // Defensive coercion — when callers override the encoding, stdout may
128
- // surface as Buffer/Uint8Array; the typeof guard keeps both shapes safe.
129
- return {
130
- stdout: typeof result.stdout === 'string'
131
- ? result.stdout
132
- : Buffer.from(result.stdout).toString('utf-8'),
133
- stderr: typeof result.stderr === 'string'
134
- ? result.stderr
135
- : Buffer.from(result.stderr).toString('utf-8'),
136
- };
137
- };
144
+ if (child.stdin) {
145
+ // A child that exits without reading stdin makes the write fail with EPIPE;
146
+ // the exit status is what reports that, not this stream.
147
+ child.stdin.on('error', () => undefined);
148
+ child.stdin.end(input ?? '');
149
+ }
150
+ });
138
151
  function exec(file, args, options) {
139
152
  const impl = _execFileForTest ?? defaultExec;
140
153
  return impl(file, args, { encoding: 'utf-8', ...options });
@@ -166,6 +179,41 @@ function readFileTrimmed(path) {
166
179
  // ---------------------------------------------------------------------------
167
180
  /** `undefined` = not yet probed; `boolean` = cached probe outcome. */
168
181
  let _secretToolProbeCache;
182
+ /**
183
+ * True when `secret-tool` never started, as opposed to starting and exiting
184
+ * non-zero.
185
+ *
186
+ * `execFile` reports the two differently: a process that ran and failed carries
187
+ * its numeric exit code, and a spawn that failed carries a STRING errno. Only
188
+ * `ENOENT` used to count as "missing", so every other spawn failure read as a
189
+ * working binary — and the probe then picked the libsecret tier, whose `store`
190
+ * cannot succeed either, which disables the credential envelope and makes the CLI
191
+ * refuse to persist any credential at all.
192
+ *
193
+ * The case that found it: a PATH entry the process may not search. execvp
194
+ * reports EACCES rather than ENOENT once it has met one, and a systemd unit that
195
+ * makes /snap inaccessible while PATH still lists /snap/bin (the Dedicated Runtime
196
+ * daemon sandbox did) turns every missing binary into EACCES.
197
+ */
198
+ function secretToolDidNotRun(err) {
199
+ const { code, status } = err;
200
+ return (typeof code === 'string' && SECRET_TOOL_UNRUNNABLE.has(code)) || status === 127;
201
+ }
202
+ /**
203
+ * Spawn errnos that mean the binary cannot be executed from here, permanently
204
+ * for this process. Deliberately NOT every string errno: EAGAIN, EMFILE or ENOMEM
205
+ * are transient, and caching "absent" on one of those would move a machine whose
206
+ * libsecret works onto the machine-id tier for the rest of the process — where the
207
+ * existing wrapped key cannot be opened and would be re-enrolled over.
208
+ */
209
+ const SECRET_TOOL_UNRUNNABLE = new Set([
210
+ 'ENOENT',
211
+ 'EACCES',
212
+ 'ENOTDIR',
213
+ 'ELOOP',
214
+ 'ENOEXEC',
215
+ 'EPERM',
216
+ ]);
169
217
  /**
170
218
  * Probe secret-tool availability. Two checks:
171
219
  * 1. `secret-tool --version` — ENOENT or exit 127 means binary missing.
@@ -183,9 +231,7 @@ async function probeSecretTool() {
183
231
  await exec('secret-tool', ['--version']);
184
232
  }
185
233
  catch (err) {
186
- const code = err.code;
187
- const status = err.status;
188
- if (code === 'ENOENT' || status === 127) {
234
+ if (secretToolDidNotRun(err)) {
189
235
  _secretToolProbeCache = false;
190
236
  return false;
191
237
  }
@@ -199,6 +245,10 @@ async function probeSecretTool() {
199
245
  _secretToolProbeCache = true;
200
246
  }
201
247
  catch (err) {
248
+ if (secretToolDidNotRun(err)) {
249
+ _secretToolProbeCache = false;
250
+ return false;
251
+ }
202
252
  const stderr = err.stderr ?? '';
203
253
  const isTransportError = DBUS_TRANSPORT_ERRORS.some((s) => stderr.includes(s));
204
254
  if (isTransportError) {
@@ -54,7 +54,6 @@
54
54
  * cleaning up the corresponding Secret Service entry when finished.
55
55
  */
56
56
  import { execFile } from 'node:child_process';
57
- import { promisify } from 'node:util';
58
57
  import crypto from 'node:crypto';
59
58
  import os from 'node:os';
60
59
  import fs from 'node:fs';
@@ -94,7 +93,6 @@ const HKDF_INFO = Buffer.from('oversky-cred-kek-v1');
94
93
  /** Filesystem paths for Tier-3 IKM components. */
95
94
  const PATH_LOGINUID = '/proc/self/loginuid';
96
95
  const PATH_MACHINE_ID = '/etc/machine-id';
97
- const execFileAsync = promisify(execFile);
98
96
  let _execFileForTest = null;
99
97
  /**
100
98
  * @internal Test seam — inject a mock execFile for hermetic tests so the
@@ -108,23 +106,38 @@ export function __setExecFileForTest(fn) {
108
106
  _secretToolProbeCache = undefined;
109
107
  _tierSelection = null;
110
108
  }
111
- /** Default execFile wrapper used when no test override is installed. */
112
- const defaultExec = async (file, args, options) => {
113
- const result = await execFileAsync(file, args, {
114
- encoding: 'utf-8',
115
- ...(options ?? {}),
109
+ /**
110
+ * Default execFile wrapper used when no test override is installed.
111
+ *
112
+ * `input` is WRITTEN to the child's stdin here, and stdin is always closed. The
113
+ * previous version forwarded `input` as an execFile option — but only the SYNC
114
+ * child_process functions honour `input`; async `execFile` ignores it. So
115
+ * `secret-tool store`, which reads the secret from stdin, got neither the secret
116
+ * nor EOF and waited forever: Tier-2 enrollment could not complete on any Linux
117
+ * desktop with a working Secret Service. Closing stdin also means no other call
118
+ * can block on a tool that unexpectedly reads it.
119
+ */
120
+ const defaultExec = (file, args, options) => new Promise((resolve, reject) => {
121
+ const { input, ...execOptions } = options ?? {};
122
+ const child = execFile(file, args, { encoding: 'utf-8', ...execOptions }, (err, stdout, stderr) => {
123
+ // Defensive coercion — when callers override the encoding, output may
124
+ // surface as Buffer; normalize both shapes.
125
+ const out = typeof stdout === 'string' ? stdout : Buffer.from(stdout).toString('utf-8');
126
+ const errOut = typeof stderr === 'string' ? stderr : Buffer.from(stderr).toString('utf-8');
127
+ if (err) {
128
+ // Same shape promisify(execFile) produced, which the probe reads.
129
+ reject(Object.assign(err, { stdout: out, stderr: errOut }));
130
+ return;
131
+ }
132
+ resolve({ stdout: out, stderr: errOut });
116
133
  });
117
- // Defensive coercion — when callers override the encoding, stdout may
118
- // surface as Buffer/Uint8Array; the typeof guard keeps both shapes safe.
119
- return {
120
- stdout: typeof result.stdout === 'string'
121
- ? result.stdout
122
- : Buffer.from(result.stdout).toString('utf-8'),
123
- stderr: typeof result.stderr === 'string'
124
- ? result.stderr
125
- : Buffer.from(result.stderr).toString('utf-8'),
126
- };
127
- };
134
+ if (child.stdin) {
135
+ // A child that exits without reading stdin makes the write fail with EPIPE;
136
+ // the exit status is what reports that, not this stream.
137
+ child.stdin.on('error', () => undefined);
138
+ child.stdin.end(input ?? '');
139
+ }
140
+ });
128
141
  function exec(file, args, options) {
129
142
  const impl = _execFileForTest ?? defaultExec;
130
143
  return impl(file, args, { encoding: 'utf-8', ...options });
@@ -156,6 +169,41 @@ function readFileTrimmed(path) {
156
169
  // ---------------------------------------------------------------------------
157
170
  /** `undefined` = not yet probed; `boolean` = cached probe outcome. */
158
171
  let _secretToolProbeCache;
172
+ /**
173
+ * True when `secret-tool` never started, as opposed to starting and exiting
174
+ * non-zero.
175
+ *
176
+ * `execFile` reports the two differently: a process that ran and failed carries
177
+ * its numeric exit code, and a spawn that failed carries a STRING errno. Only
178
+ * `ENOENT` used to count as "missing", so every other spawn failure read as a
179
+ * working binary — and the probe then picked the libsecret tier, whose `store`
180
+ * cannot succeed either, which disables the credential envelope and makes the CLI
181
+ * refuse to persist any credential at all.
182
+ *
183
+ * The case that found it: a PATH entry the process may not search. execvp
184
+ * reports EACCES rather than ENOENT once it has met one, and a systemd unit that
185
+ * makes /snap inaccessible while PATH still lists /snap/bin (the Dedicated Runtime
186
+ * daemon sandbox did) turns every missing binary into EACCES.
187
+ */
188
+ function secretToolDidNotRun(err) {
189
+ const { code, status } = err;
190
+ return (typeof code === 'string' && SECRET_TOOL_UNRUNNABLE.has(code)) || status === 127;
191
+ }
192
+ /**
193
+ * Spawn errnos that mean the binary cannot be executed from here, permanently
194
+ * for this process. Deliberately NOT every string errno: EAGAIN, EMFILE or ENOMEM
195
+ * are transient, and caching "absent" on one of those would move a machine whose
196
+ * libsecret works onto the machine-id tier for the rest of the process — where the
197
+ * existing wrapped key cannot be opened and would be re-enrolled over.
198
+ */
199
+ const SECRET_TOOL_UNRUNNABLE = new Set([
200
+ 'ENOENT',
201
+ 'EACCES',
202
+ 'ENOTDIR',
203
+ 'ELOOP',
204
+ 'ENOEXEC',
205
+ 'EPERM',
206
+ ]);
159
207
  /**
160
208
  * Probe secret-tool availability. Two checks:
161
209
  * 1. `secret-tool --version` — ENOENT or exit 127 means binary missing.
@@ -173,9 +221,7 @@ async function probeSecretTool() {
173
221
  await exec('secret-tool', ['--version']);
174
222
  }
175
223
  catch (err) {
176
- const code = err.code;
177
- const status = err.status;
178
- if (code === 'ENOENT' || status === 127) {
224
+ if (secretToolDidNotRun(err)) {
179
225
  _secretToolProbeCache = false;
180
226
  return false;
181
227
  }
@@ -189,6 +235,10 @@ async function probeSecretTool() {
189
235
  _secretToolProbeCache = true;
190
236
  }
191
237
  catch (err) {
238
+ if (secretToolDidNotRun(err)) {
239
+ _secretToolProbeCache = false;
240
+ return false;
241
+ }
192
242
  const stderr = err.stderr ?? '';
193
243
  const isTransportError = DBUS_TRANSPORT_ERRORS.some((s) => stderr.includes(s));
194
244
  if (isTransportError) {
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@skrr-ai/auth-core",
3
- "version": "0.1.5",
3
+ "version": "0.1.6",
4
4
  "main": "dist/cjs/index.js",
5
5
  "types": "dist/esm/index.d.ts",
6
6
  "exports": {