@ctrl-spc/cs 0.7.14 → 0.7.16

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/dist/config.js CHANGED
@@ -1,12 +1,63 @@
1
- import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync, chmodSync } from 'node:fs';
1
+ import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync, chmodSync, openSync, closeSync, fsyncSync, renameSync } from 'node:fs';
2
2
  import { homedir, hostname, platform } from 'node:os';
3
- import { randomBytes, createHash } from 'node:crypto';
4
- import { execSync } from 'node:child_process';
3
+ import { randomBytes, randomUUID, createHash } from 'node:crypto';
4
+ import { execSync, execFileSync } from 'node:child_process';
5
5
  import { join } from 'node:path';
6
6
  /** Own config dir, isolated from the v1 CLI: `~/.config/ctrl-spc-v2`. */
7
7
  export function configDir() {
8
8
  return process.env.CTRL_SPC_V2_CONFIG_DIR || join(homedir(), '.config', 'ctrl-spc-v2');
9
9
  }
10
+ /** Local control and process records are never hosted or shared with harness MCP. */
11
+ export function lifecycleDir() {
12
+ return join(configDir(), 'lifecycle');
13
+ }
14
+ let securedLifecycleDir = null;
15
+ export function ensureLifecycleDir() {
16
+ const path = lifecycleDir();
17
+ if (securedLifecycleDir === path && existsSync(path))
18
+ return path;
19
+ mkdirSync(path, { recursive: true, mode: 0o700 });
20
+ if (process.platform === 'win32') {
21
+ // Set an exact current-user ACL; removing inheritance alone leaves any
22
+ // explicit grants from an existing directory in place.
23
+ const script = "$ErrorActionPreference='Stop'; $sid=[System.Security.Principal.WindowsIdentity]::GetCurrent().User; $acl=New-Object System.Security.AccessControl.DirectorySecurity; $acl.SetOwner($sid); $acl.SetAccessRuleProtection($true,$false); $rule=New-Object System.Security.AccessControl.FileSystemAccessRule($sid,'FullControl','ContainerInherit,ObjectInherit','None','Allow'); $acl.AddAccessRule($rule); Set-Acl -LiteralPath $env:CTRL_SPC_LIFECYCLE_ACL_PATH -AclObject $acl";
24
+ execFileSync('powershell.exe', ['-NoProfile', '-NonInteractive', '-Command', script], {
25
+ stdio: 'ignore', windowsHide: true, timeout: 3000, env: { ...process.env, CTRL_SPC_LIFECYCLE_ACL_PATH: path },
26
+ });
27
+ }
28
+ else
29
+ chmodSync(path, 0o700);
30
+ securedLifecycleDir = path;
31
+ return path;
32
+ }
33
+ export function readLifecycleToken() {
34
+ try {
35
+ return readFileSync(join(lifecycleDir(), 'token'), 'utf8').trim() || null;
36
+ }
37
+ catch (error) {
38
+ if (error.code === 'ENOENT')
39
+ return null;
40
+ throw error;
41
+ }
42
+ }
43
+ export function lifecycleToken() {
44
+ const prior = readLifecycleToken();
45
+ if (prior)
46
+ return prior;
47
+ const path = join(ensureLifecycleDir(), 'token');
48
+ const token = randomBytes(32).toString('hex');
49
+ try {
50
+ writeFileSync(path, token, { flag: 'wx', mode: 0o600 });
51
+ }
52
+ catch (error) {
53
+ if (error.code !== 'EEXIST')
54
+ throw error;
55
+ }
56
+ const result = readLifecycleToken();
57
+ if (!result)
58
+ throw new Error('Local service credential could not be created.');
59
+ return result;
60
+ }
10
61
  function filePath(name) {
11
62
  return join(configDir(), name);
12
63
  }
@@ -145,26 +196,157 @@ export function clearSupersededMachineIds() {
145
196
  }
146
197
  catch { /* leave as-is */ }
147
198
  }
199
+ /** Missing, intentionally signed out, and unreadable storage are different facts. */
200
+ export function readSessionRecord() {
201
+ let text;
202
+ try {
203
+ text = readFileSync(filePath('session.json'), 'utf8');
204
+ }
205
+ catch (error) {
206
+ if (error.code === 'ENOENT')
207
+ return null;
208
+ throw new Error('Local sign-in could not be read.', { cause: error });
209
+ }
210
+ try {
211
+ const value = JSON.parse(text);
212
+ if (!value || typeof value !== 'object')
213
+ throw new Error('Invalid record');
214
+ if (value.schema === undefined && typeof value.access_token === 'string' && value.access_token) {
215
+ if (value.refresh_token !== undefined && typeof value.refresh_token !== 'string')
216
+ throw new Error('Invalid credential');
217
+ // A stable legacy identity permits the first refresh to upgrade in place.
218
+ return { schema: 1, generation: createHash('sha256').update(text).digest('hex'), accountId: null,
219
+ state: 'signed-in', tokens: { access_token: value.access_token, refresh_token: value.refresh_token ?? '' } };
220
+ }
221
+ if (value.schema !== 1 || typeof value.generation !== 'string' || !value.generation
222
+ || !(value.accountId === null || (typeof value.accountId === 'string' && value.accountId)))
223
+ throw new Error('Invalid identity');
224
+ if (value.state === 'signed-out' || value.state === 'rejected') {
225
+ if ('tokens' in value)
226
+ throw new Error('Inactive record contains credentials');
227
+ return { schema: 1, generation: value.generation, accountId: value.accountId, state: value.state };
228
+ }
229
+ if (value.state !== 'signed-in' || typeof value.tokens?.access_token !== 'string' || !value.tokens.access_token
230
+ || typeof value.tokens.refresh_token !== 'string')
231
+ throw new Error('Invalid credentials');
232
+ return { schema: 1, generation: value.generation, accountId: value.accountId, state: 'signed-in',
233
+ tokens: { access_token: value.tokens.access_token, refresh_token: value.tokens.refresh_token } };
234
+ }
235
+ catch (error) {
236
+ throw new Error('Local sign-in storage is incomplete or invalid.', { cause: error });
237
+ }
238
+ }
239
+ /** Compatibility for passive consumers: inactive records never expose tokens. */
148
240
  export function readSession() {
149
- const path = filePath('session.json');
150
- if (!existsSync(path))
151
- return null;
241
+ const record = readSessionRecord();
242
+ return record?.state === 'signed-in' ? record.tokens : null;
243
+ }
244
+ async function changeSession(change) {
245
+ // Dynamic import avoids config -> process helpers -> config initialization.
246
+ const { inspectProcess, processIdentityMatches } = await import('./win-shell.js');
247
+ const deadline = Date.now() + 5000;
248
+ const owner = await inspectProcess(process.pid, deadline);
249
+ if (!owner)
250
+ throw new Error('Cannot verify the sign-in writer process.');
251
+ mkdirSync(configDir(), { recursive: true, mode: 0o700 });
252
+ const lock = filePath('session.lock');
253
+ const nonce = randomUUID();
254
+ let held = false;
255
+ while (Date.now() < deadline) {
256
+ try {
257
+ const fd = openSync(lock, 'wx', 0o600);
258
+ try {
259
+ writeFileSync(fd, JSON.stringify({ nonce, owner }));
260
+ fsyncSync(fd);
261
+ }
262
+ finally {
263
+ closeSync(fd);
264
+ }
265
+ held = true;
266
+ break;
267
+ }
268
+ catch (error) {
269
+ if (error.code !== 'EEXIST')
270
+ throw error;
271
+ }
272
+ let prior;
273
+ try {
274
+ prior = JSON.parse(readFileSync(lock, 'utf8'));
275
+ }
276
+ catch (error) {
277
+ if (!(error instanceof SyntaxError) && error.code !== 'ENOENT')
278
+ throw error;
279
+ }
280
+ if (prior && typeof prior.nonce === 'string' && Number.isInteger(prior.owner?.pid)) {
281
+ const live = await inspectProcess(prior.owner.pid, deadline);
282
+ if (!live || !processIdentityMatches(prior.owner, live)) {
283
+ // Recheck the resource identity after asynchronous process inspection.
284
+ try {
285
+ if (JSON.parse(readFileSync(lock, 'utf8')).nonce === prior.nonce)
286
+ rmSync(lock);
287
+ }
288
+ catch (error) {
289
+ if (error.code !== 'ENOENT')
290
+ throw error;
291
+ }
292
+ }
293
+ }
294
+ await new Promise(resolve => setTimeout(resolve, 25));
295
+ }
296
+ if (!held)
297
+ throw new Error('Local sign-in is busy or its writer cannot be verified. Try again.');
298
+ const temp = filePath('session.' + nonce + '.tmp');
152
299
  try {
153
- return JSON.parse(readFileSync(path, 'utf8'));
300
+ const record = change(readSessionRecord());
301
+ if (record) {
302
+ const fd = openSync(temp, 'wx', 0o600);
303
+ try {
304
+ writeFileSync(fd, JSON.stringify(record));
305
+ fsyncSync(fd);
306
+ }
307
+ finally {
308
+ closeSync(fd);
309
+ }
310
+ renameSync(temp, filePath('session.json'));
311
+ }
312
+ return record;
154
313
  }
155
- catch {
156
- return null;
314
+ finally {
315
+ rmSync(temp, { force: true });
316
+ if (JSON.parse(readFileSync(lock, 'utf8')).nonce === nonce)
317
+ rmSync(lock);
157
318
  }
158
319
  }
159
- export function writeSession(session) {
160
- writeJson('session.json', session);
320
+ export async function writeSession(session, accountId = null, expectedGeneration) {
321
+ if (!session.access_token || !session.refresh_token)
322
+ throw new Error('Sign-in did not return complete credentials.');
323
+ return (await changeSession(prior => {
324
+ if (expectedGeneration !== undefined && (prior?.generation ?? null) !== expectedGeneration)
325
+ throw new Error('The local sign-in changed while this request was being verified. Try again.');
326
+ return { schema: 1, generation: randomUUID(), accountId, state: 'signed-in', tokens: session };
327
+ }));
161
328
  }
162
- export function clearSession() {
163
- const path = filePath('session.json');
164
- if (!existsSync(path))
165
- return false;
166
- rmSync(path);
167
- return true;
329
+ export async function rotateSession(generation, session, accountId) {
330
+ let changed = false;
331
+ await changeSession(prior => {
332
+ if (prior?.state !== 'signed-in' || prior.generation !== generation || (prior.accountId !== null && prior.accountId !== accountId))
333
+ return null;
334
+ changed = true;
335
+ return { ...prior, accountId, tokens: session };
336
+ });
337
+ return changed;
338
+ }
339
+ export async function rejectSession(generation) {
340
+ await changeSession(prior => prior?.state === 'signed-in' && prior.generation === generation
341
+ ? { schema: 1, generation: randomUUID(), accountId: prior.accountId, state: 'rejected' } : null);
342
+ }
343
+ export async function clearSession() {
344
+ let wasSignedIn = false;
345
+ await changeSession(prior => {
346
+ wasSignedIn = prior?.state === 'signed-in';
347
+ return { schema: 1, generation: randomUUID(), accountId: prior?.accountId ?? null, state: 'signed-out' };
348
+ });
349
+ return wasSignedIn;
168
350
  }
169
351
  /** This machine's project mappings, stored locally (not in the cloud) so
170
352
  * absolute paths never leave the machine. Keyed by project id. */