chatflux-gestao-agente 0.2.6 → 0.2.7

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/README.md CHANGED
@@ -231,3 +231,23 @@ reparo. Não apague journal/locks para repetir uma abertura incerta: preserve os
231
231
  arquivos, confirme que a instância terminou e recupere o bloqueio explicitamente.
232
232
  Uma falha parcial informa se o rollback não foi confirmado; não significa que o
233
233
  início automático está funcionando.
234
+
235
+ Se `status` mostrar **escrita local ocupada** mesmo depois de encerrar todos os
236
+ processos do Gestão (incluindo MCP e residente), rode:
237
+
238
+ ```sh
239
+ gestao computador recuperar-bloqueio
240
+ ```
241
+
242
+ O comando só age se o canal do residente não responder, o PID registrado estiver
243
+ encerrado e a listagem do sistema não encontrar outro processo do programa.
244
+ Ele desconsidera seu próprio processo e os wrappers ancestrais npx/npm exec.
245
+ Só renomeia diretórios sem alterações há pelo menos 2 minutos (pelo `mtime`); bloqueios mais recentes são mantidos, com orientação para aguardar e rodar de novo.
246
+ Se não conseguir confirmar essas condições, recusa sem alterar nada.
247
+ Cada marcador vazio é renomeado no mesmo diretório para
248
+ `<nome>.recuperado-AAAAMMDD-HHMMSS`, sem apagar seu conteúdo. Arquivos, symlinks
249
+ e diretórios não vazios são mantidos e relatados. O comando sai com código 1
250
+ se recusar ou mantiver algum bloqueio; sai com 0 se recuperar tudo ou mostrar
251
+ “Nenhum bloqueio local pendente.” Journal, identidade, login e `controle.json`
252
+ são preservados. Depois da recuperação, confira `gestao status` e use
253
+ `gestao ligar` se quiser iniciar o computador novamente.
package/bin/gestao.mjs CHANGED
@@ -18,6 +18,7 @@ const AJUDA = `gestao — portal Gestão pela linha de comando e por agente (MCP
18
18
  gestao ligar instala início automático e associa este computador ao navegador
19
19
  gestao desligar remove início automático e interrompe o computador, mesmo offline
20
20
  gestao computador executar residente técnico (usado pelo login do SO)
21
+ gestao computador recuperar-bloqueio preserva bloqueios locais com o Gestão parado
21
22
  gestao logout suspende o residente e esquece a credencial desta máquina
22
23
  gestao mcp roda o servidor MCP (é o que o Claude Code/Desktop chama)
23
24
 
@@ -98,7 +99,12 @@ try {
98
99
  break;
99
100
  }
100
101
  case "computador": {
101
- if (process.argv[3] !== "executar" || process.argv.length !== 4) throw new Error("Use gestao computador executar.");
102
+ if (!["executar", "recuperar-bloqueio"].includes(process.argv[3]) || process.argv.length !== 4) throw new Error("Use gestao computador executar ou gestao computador recuperar-bloqueio.");
103
+ if (process.argv[3] === "recuperar-bloqueio") {
104
+ const { recuperarBloqueioComputer } = await import("../lib/computador-cli.mjs");
105
+ await recuperarBloqueioComputer();
106
+ break;
107
+ }
102
108
  const { executarComputer } = await import("../lib/computador-cli.mjs");
103
109
  const resident = await executarComputer();
104
110
  const stop = () => { void resident.close(); };
@@ -19,8 +19,29 @@ const VERSION = JSON.parse(fs.readFileSync(new URL('../package.json', import.met
19
19
  const hash = value => createHash('sha256').update(value).digest('hex');
20
20
  const wait = ms => new Promise(resolve => setTimeout(resolve, ms));
21
21
  const fail = message => new Error(message);
22
+ const LOCAL_BUSY_STATE = 'escrita local ocupada · se continuar com tudo parado, rode gestao computador recuperar-bloqueio';
22
23
  const claims = data => { try { return JSON.parse(Buffer.from(data.token.split('.')[1], 'base64url')); } catch { return {}; } };
23
24
 
25
+ async function listLocalProcesses(platform, execFileImpl = execFile) {
26
+ const windows = platform === 'win32';
27
+ const binary = windows ? path.win32.join(process.env.SystemRoot || 'C:\\Windows', 'System32/WindowsPowerShell/v1.0/powershell.exe') : 'ps';
28
+ const script = "$ErrorActionPreference='Stop'; Get-CimInstance Win32_Process | Select-Object ProcessId,ParentProcessId,CommandLine | ConvertTo-Json -Compress";
29
+ const args = windows ? ['-NoLogo', '-NoProfile', '-NonInteractive', '-EncodedCommand', Buffer.from(script, 'utf16le').toString('base64')]
30
+ : ['-axo', 'pid=,ppid=,command='];
31
+ const output = await new Promise((resolve, reject) => execFileImpl(binary, args,
32
+ { shell: false, windowsHide: true, encoding: 'utf8', timeout: 10000, maxBuffer: 16 * 1024 * 1024 },
33
+ (error, stdout) => error ? reject(error) : resolve(stdout)));
34
+ if (windows) {
35
+ const rows = JSON.parse(output);
36
+ return (Array.isArray(rows) ? rows : [rows]).map(p => ({ pid: p.ProcessId, ppid: p.ParentProcessId, command: p.CommandLine ?? '' }));
37
+ }
38
+ return output.trim().split(/\r?\n/).map(line => {
39
+ const match = /^\s*(\d+)\s+(\d+)\s+(.+)$/.exec(line);
40
+ if (!match) throw fail('Listagem de processos incompleta.');
41
+ return { pid: Number(match[1]), ppid: Number(match[2]), command: match[3] };
42
+ });
43
+ }
44
+
24
45
  export async function computerName({ platform, hostname = os.hostname, execFileImpl = execFile }) {
25
46
  if (platform === 'darwin') {
26
47
  try {
@@ -35,17 +56,17 @@ export async function computerName({ platform, hostname = os.hostname, execFileI
35
56
  }
36
57
 
37
58
  /** No browser-origin requests, no payload/command dispatcher, no deviceSecret. */
38
- export function createLocalControl({ httpImpl = http } = {}) {
59
+ export function createLocalControl({ httpImpl = http, killImpl = process.kill.bind(process) } = {}) {
39
60
  const fileFor = identity => path.join(identity.directory, 'controle.json');
40
- function metadata(identity) {
41
- const data = readUserJson(fileFor(identity));
61
+ function metadata(identity, protect) {
62
+ const data = readUserJson(fileFor(identity), protect);
42
63
  if (data && (data.version !== 1 || !Number.isSafeInteger(data.port) || data.port < 1 || data.port > 65535 ||
43
64
  !Number.isSafeInteger(data.pid) || data.pid < 1 || !/^[a-f0-9]{64}$/.test(data.key))) throw recoveryError();
44
65
  return data;
45
66
  }
46
- async function send(identity, verb) {
67
+ async function send(identity, verb, { readOnly = false } = {}) {
47
68
  if (!['status', 'stop', 'pause'].includes(verb)) throw recoveryError();
48
- const data = metadata(identity); if (!data) return null;
69
+ const data = metadata(identity, readOnly ? () => {} : undefined); if (!data) return null;
49
70
  return new Promise((resolve, reject) => {
50
71
  const req = httpImpl.request({ host: '127.0.0.1', port: data.port, path: `/${verb}`, method: 'POST',
51
72
  headers: { Authorization: `Bearer ${data.key}` }, timeout: 15000, agent: false }, res => {
@@ -60,7 +81,7 @@ export function createLocalControl({ httpImpl = http } = {}) {
60
81
  req.on('error', e => {
61
82
  // Never steal an ambiguous/live PID or send it a termination signal.
62
83
  if (e.code === 'ECONNREFUSED') {
63
- try { process.kill(data.pid, 0); } catch (dead) { if (dead.code === 'ESRCH') return resolve(null); }
84
+ try { killImpl(data.pid, 0); } catch (dead) { if (dead.code === 'ESRCH') return resolve(null); }
64
85
  }
65
86
  reject(Object.assign(fail('Instância local não confirmou o controle autenticado. Confira gestao status.'),
66
87
  { code: ['EPERM', 'EACCES', 'ETIMEDOUT'].includes(e.code) ? e.code : 'LOCAL_CONTROL_UNAVAILABLE' }));
@@ -70,7 +91,7 @@ export function createLocalControl({ httpImpl = http } = {}) {
70
91
  }
71
92
  async function start(identity, handlers) {
72
93
  const gate = path.join(identity.directory, '.controle-lock');
73
- try { fs.mkdirSync(gate, { mode: 0o700 }); } catch { throw fail('Controle local ocupado; preserve o bloqueio e recupere explicitamente.'); }
94
+ try { fs.mkdirSync(gate, { mode: 0o700 }); } catch { throw fail('Controle local ocupado; se continuar com tudo parado, rode gestao computador recuperar-bloqueio.'); }
74
95
  try {
75
96
  if (await send(identity, 'status')) throw fail('Já existe uma instância neste ambiente.');
76
97
  const key = randomBytes(32).toString('hex');
@@ -143,7 +164,7 @@ export function createComputerCli(options = {}) {
143
164
  ? readUserJson(path.join(os.homedir(), '.config/gestao/token.json')) : lerToken());
144
165
  const eraseToken = options.eraseToken ?? apagarToken;
145
166
  const service = () => options.service ?? process.env.GESTAO_SERVICO;
146
- const startup = options.startup ?? createStartupManager(), control = options.control ?? createLocalControl();
167
+ const startup = options.startup ?? createStartupManager(), control = options.control ?? createLocalControl({ killImpl: options.killImpl });
147
168
  const announce = options.announce ?? console.log;
148
169
  const scope = subject => ({ env, subject, baseDir });
149
170
  const localFile = identity => path.join(identity.directory, 'cli.json');
@@ -152,10 +173,10 @@ export function createComputerCli(options = {}) {
152
173
  if (!Number.isSafeInteger(data.generation) || data.generation < 1 || !(data.pausedTokenHash === null || /^[a-f0-9]{64}$/.test(data.pausedTokenHash))) throw recoveryError();
153
174
  return data;
154
175
  }
155
- function active() {
156
- const holder = readUserJson(activeFile); if (!holder) return null;
176
+ function active(protect) {
177
+ const holder = readUserJson(activeFile, protect); if (!holder) return null;
157
178
  if (holder.version !== 1 || typeof holder.subject !== 'string') throw recoveryError();
158
- const identity = identityFor(scope(holder.subject));
179
+ const identity = identityFor({ ...scope(holder.subject), protect });
159
180
  if (!identity?.computerId) throw recoveryError(); // Lost identity is never silently recreated/inherited.
160
181
  return identity;
161
182
  }
@@ -183,7 +204,7 @@ export function createComputerCli(options = {}) {
183
204
  async function exclusive(work) {
184
205
  ensureUserDirectory(baseDir); ensureUserDirectory(directory);
185
206
  const gate = path.join(directory, '.cli-lock');
186
- try { fs.mkdirSync(gate, { mode: 0o700 }); } catch { throw fail('Outra operação local está em curso; preserve o bloqueio após falha e recupere explicitamente.'); }
207
+ try { fs.mkdirSync(gate, { mode: 0o700 }); } catch { throw fail('Outra operação local está em curso; se continuar com tudo parado, rode gestao computador recuperar-bloqueio.'); }
187
208
  try { return await work(); } finally { fs.rmdirSync(gate); }
188
209
  }
189
210
  async function flush(identity) {
@@ -305,9 +326,11 @@ export function createComputerCli(options = {}) {
305
326
  const startupState = ['darwin', 'win32'].includes(platform) ? await startup.startupStatus({ env, platform }) : { installed: false, healthy: false };
306
327
  let state = 'não ligado', capabilities;
307
328
  if (identity) {
308
- const journal = openJournal(identity.directory).read();
309
- if (journal.stopped) state = journal.revocationPending ? 'desligado · revogação pendente' : 'desligado';
310
- else {
329
+ let journal;
330
+ try { journal = openJournal(identity.directory).read(); }
331
+ catch (e) { if (e.code !== 'LOCAL_BUSY') throw e; state = LOCAL_BUSY_STATE; }
332
+ if (journal?.stopped) state = journal.revocationPending ? 'desligado · revogação pendente' : 'desligado';
333
+ else if (journal) {
311
334
  try { await credential(identity.subject); state = local(identity).pausedTokenHash ? 'pausado · reconecte com gestao login' : 'aguardando residente'; }
312
335
  catch { state = 'reconectar · mesma conta e ambiente com gestao login'; }
313
336
  try { const live = await control.send(identity, 'status'); if (live?.state) state = live.state; capabilities = live?.capabilities; } catch { state = 'controle local indisponível'; }
@@ -317,6 +340,93 @@ export function createComputerCli(options = {}) {
317
340
  return { env, computerId: identity?.computerId ?? null, startupInstalled: startupState.installed,
318
341
  startupHealthy: startupState.healthy, state, ...(capabilities ? { capabilities } : {}) };
319
342
  }
343
+ async function recuperarBloqueio() {
344
+ personalOnly();
345
+ // As conferências de recuperação não reescrevem permissões nem arquivos.
346
+ const identity = active(() => {});
347
+ if (identity) {
348
+ let live;
349
+ try { live = await control.send(identity, 'status', { readOnly: true }); }
350
+ catch { throw fail('Não foi possível confirmar se o residente terminou pelo canal local; nada foi alterado. Encerre o Gestão e tente novamente.'); }
351
+ if (live) throw fail('O residente do Gestão está respondendo; nada foi alterado. Encerre-o antes de recuperar os bloqueios.');
352
+ }
353
+ const instance = readUserJson(path.join(directory, 'residente-lock.json'), () => {});
354
+ if (instance) {
355
+ if (!Number.isSafeInteger(instance.pid) || instance.pid < 1) throw fail('Não foi possível confirmar o PID do residente; nada foi alterado.');
356
+ let dead = false;
357
+ try { (options.killImpl ?? process.kill.bind(process))(instance.pid, 0); }
358
+ catch (e) { dead = e.code === 'ESRCH'; }
359
+ if (!dead) throw fail(`O residente (PID ${instance.pid}) está vivo ou não foi possível confirmar seu encerramento; nada foi alterado. Encerre-o e tente novamente.`);
360
+ }
361
+ let processes;
362
+ try {
363
+ processes = await (options.listProcesses ?? (() => listLocalProcesses(platform, options.execFileImpl)))();
364
+ if (!Array.isArray(processes) || !processes.length || processes.some(p => !p || !Number.isSafeInteger(p.pid) || p.pid < 0 ||
365
+ !Number.isSafeInteger(p.ppid) || p.ppid < 0 || typeof p.command !== 'string') || !processes.some(p => p.pid === process.pid)) throw fail('Listagem incompleta.');
366
+ } catch { throw fail('Não foi possível listar todos os processos para confirmar que o Gestão está parado; nada foi alterado.'); }
367
+ const byPid = new Map(processes.map(p => [p.pid, p])), excluded = new Set();
368
+ let pid = process.pid;
369
+ while (pid > 0 && !excluded.has(pid)) { excluded.add(pid); pid = byPid.get(pid)?.ppid ?? 0; }
370
+ const running = processes.filter(p => !excluded.has(p.pid) && (/chatflux-gestao-agente|gestao\.mjs/i.test(p.command) ||
371
+ p.command.split(/\s+/).some(token => /(?:[/\\]gestao|^gestao|gestao\.cmd|gestao\.ps1)$/i.test(token.replace(/["']/g, '')))));
372
+ if (running.length) throw fail(`Há outro processo do Gestão rodando (PID ${running.map(p => p.pid).join(', ')}); nada foi alterado. Encerre-o e tente novamente.`);
373
+
374
+ const startupDirectory = ['darwin', 'win32'].includes(platform) ? startup.lockDirectory?.({ env, platform }) : null;
375
+ const identityDirectories = new Set(identity ? [identity.directory] : []);
376
+ let names;
377
+ try { names = fs.readdirSync(directory); }
378
+ catch (e) {
379
+ if (e.code !== 'ENOENT') throw fail('Não foi possível conferir as pastas de identidade; nada foi alterado.');
380
+ names = [];
381
+ }
382
+ for (const name of names) {
383
+ if (name.endsWith('-lock') || name.includes('.recuperado-')) continue;
384
+ const candidate = path.join(directory, name);
385
+ let stat;
386
+ try { stat = fs.lstatSync(candidate); }
387
+ catch { throw fail('Não foi possível conferir as pastas de identidade; nada foi alterado.'); }
388
+ if (stat.isDirectory() && !stat.isSymbolicLink()) identityDirectories.add(candidate);
389
+ }
390
+ const gates = [
391
+ ...[...identityDirectories].flatMap(dir => [path.join(dir, '.escrita-lock'), path.join(dir, '.controle-lock')]),
392
+ path.join(directory, '.escrita-lock'), path.join(directory, '.cli-lock'),
393
+ ...(startupDirectory ? [path.join(startupDirectory, '.escrita-lock')] : []),
394
+ ];
395
+ const date = new Date((options.clock?.now ?? Date.now)());
396
+ const pad = n => String(n).padStart(2, '0');
397
+ const stamp = `${date.getFullYear()}${pad(date.getMonth() + 1)}${pad(date.getDate())}-${pad(date.getHours())}${pad(date.getMinutes())}${pad(date.getSeconds())}`;
398
+ let recovered = 0, kept = 0;
399
+ for (const gate of gates) {
400
+ let stat;
401
+ try { stat = fs.lstatSync(gate); }
402
+ catch (e) {
403
+ if (e.code === 'ENOENT') continue;
404
+ announce(`Mantido: ${gate} · não foi possível conferir o bloqueio.`); kept++; continue;
405
+ }
406
+ if (!stat.isDirectory() || stat.isSymbolicLink()) {
407
+ announce(`Mantido: ${gate} · não é um diretório vazio comum.`); kept++; continue;
408
+ }
409
+ if ((options.clock?.now ?? Date.now)() - stat.mtimeMs < 120000) {
410
+ announce(`Mantido: ${gate} · criado há menos de 2 minutos; aguarde e rode de novo.`); kept++; continue;
411
+ }
412
+ try {
413
+ if (fs.readdirSync(gate).length) {
414
+ announce(`Mantido: ${gate} · diretório não vazio.`); kept++; continue;
415
+ }
416
+ const preservedAs = `${gate}.recuperado-${stamp}`;
417
+ let exists = false;
418
+ try { fs.lstatSync(preservedAs); exists = true; } catch (e) { if (e.code !== 'ENOENT') throw e; }
419
+ if (exists) {
420
+ announce(`Mantido: ${gate} · já existe uma cópia preservada neste horário; tente novamente.`); kept++; continue;
421
+ }
422
+ fs.renameSync(gate, preservedAs);
423
+ announce(`Recuperado: ${gate} · preservado como ${preservedAs}`); recovered++;
424
+ } catch { announce(`Mantido: ${gate} · não foi possível preservar por renomeação.`); kept++; }
425
+ }
426
+ if (!recovered && !kept) announce('Nenhum bloqueio local pendente.');
427
+ if (kept) throw fail('Os bloqueios mantidos acima não foram alterados.');
428
+ return { recovered };
429
+ }
320
430
  async function afterLogin() {
321
431
  personalOnly();
322
432
  try {
@@ -397,18 +507,19 @@ export function createComputerCli(options = {}) {
397
507
  runtime = await (options.makeRuntime ?? composeRuntime)(identity, portal, controller.signal, readToken);
398
508
  if (closed || controller.signal.aborted) await pause(); else currentState = 'residente iniciado';
399
509
  }
400
- } catch { await pause(); backoff(); }
510
+ } catch (e) { await pause(); if (e.code === 'LOCAL_BUSY') currentState = LOCAL_BUSY_STATE; backoff(); }
401
511
  finally { if (!closed) timer = later(schedule, nextDelay); }
402
512
  }
403
513
  function schedule() { work = tick(); }
404
514
  schedule();
405
515
  return { close, done };
406
516
  }
407
- return { ligar, desligar, logout, status, executar, afterLogin };
517
+ return { ligar, desligar, logout, status, recuperarBloqueio, executar, afterLogin };
408
518
  }
409
519
  export const ligarComputer = deps => createComputerCli(deps).ligar();
410
520
  export const desligarComputer = deps => createComputerCli(deps).desligar();
411
521
  export const logoutComputer = deps => createComputerCli(deps).logout();
412
522
  export const statusComputer = deps => createComputerCli(deps).status();
523
+ export const recuperarBloqueioComputer = deps => createComputerCli(deps).recuperarBloqueio();
413
524
  export const executarComputer = deps => createComputerCli(deps).executar();
414
525
  export const afterComputerLogin = deps => createComputerCli(deps).afterLogin();
@@ -11,7 +11,7 @@
11
11
  import fs from 'node:fs';
12
12
  import path from 'node:path';
13
13
  import { randomUUID } from 'node:crypto';
14
- import { identityFor, protectUserFile, withFileGate, recoveryError } from './computador-identidade.mjs';
14
+ import { identityFor, protectUserFile, protectUserFileOnFirstRead, withFileGate, recoveryError } from './computador-identidade.mjs';
15
15
 
16
16
  const TYPES = ['rede', 'portal_indisponivel', 'login', 'git_ocupado', 'git_acesso', 'git_estado', 'caminho', 'precisa_pasta', 'precisa_permissao', 'tempo_esgotado', 'editor', 'plugin', 'startup', 'resultado_incerto', 'outro', 'resume', 'turno_timeout', 'interacao_necessaria', 'historico_ausente', 'historico_incompativel', 'historico_limite'];
17
17
  const PHASES = ['recebido', 'conferindo_pasta', 'selecionando_pasta', 'baixando', 'conferindo_git', 'aguardando_pessoa', 'atualizando_pasta', 'preparar_editor', 'abrindo_pasta', 'aguardando_editor', 'abrindo_conversa', 'atualizando_plugin', 'respondendo_sessao', 'finalizado', 'lendo_historico'];
@@ -70,7 +70,7 @@ export function openDiagnostics(scope, { agentVersion, os, osMajor, currentScope
70
70
  try {
71
71
  const stat = fs.lstatSync(file);
72
72
  if (!stat.isFile() || stat.isSymbolicLink() || stat.nlink !== 1 || stat.size > MAX_BYTES) throw recoveryError();
73
- protect(file);
73
+ protectUserFileOnFirstRead(file, protect);
74
74
  const lines = fs.readFileSync(file, 'utf8').trimEnd().split('\n').map(s => JSON.parse(s));
75
75
  const [header, ...rows] = lines;
76
76
  if (!exact(header, ['version', 'dropped', 'pending']) || header.version !== 1 || !integer(header.dropped) ||
@@ -8,7 +8,6 @@ import os from 'node:os';
8
8
  import path from 'node:path';
9
9
  import { createHash, randomBytes, randomUUID } from 'node:crypto';
10
10
  import { execFileSync } from 'node:child_process';
11
- import { lerToken } from './config.mjs';
12
11
 
13
12
  const ROOT = () => path.join(os.homedir(), '.config', 'gestao', 'computadores');
14
13
  const MAX_USER_JSON_BYTES = 4 * 1024 * 1024;
@@ -25,6 +24,9 @@ export function scopeDirectory({ env, subject, baseDir = ROOT() }) {
25
24
 
26
25
  // Script fixo: caminho é dado por ambiente, nunca interpolado em PowerShell.
27
26
  // ACL nova, protegida de herança, com uma única regra para o SID atual.
27
+ // SetAccessControl persiste só as seções alteradas (dono + DACL). Set-Acl regrava
28
+ // também a SACL e, num arquivo já protegido, falha sem SeSecurityPrivilege
29
+ // (PrivilegeNotHeldException; reproduzido no Windows 11 em 11/11/2026).
28
30
  const ACL = `
29
31
  $ErrorActionPreference = 'Stop'
30
32
  $p = $env:GESTAO_PROTECT_PATH
@@ -40,7 +42,7 @@ if ($item.PSIsContainer) {
40
42
  $rule = New-Object System.Security.AccessControl.FileSystemAccessRule($sid, 'FullControl', 'Allow')
41
43
  }
42
44
  $acl.AddAccessRule($rule)
43
- Set-Acl -LiteralPath $p -AclObject $acl
45
+ $item.SetAccessControl($acl)
44
46
  `;
45
47
  export function protectUserFile(file, { platform = process.platform, execFile = execFileSync } = {}) {
46
48
  try {
@@ -59,6 +61,15 @@ export function protectUserFile(file, { platform = process.platform, execFile =
59
61
  }
60
62
 
61
63
  // Primitivas compartilhadas só pelos módulos locais; falham sem incluir payload/segredo no erro.
64
+ const protectedReads = new WeakMap();
65
+ export function protectUserFileOnFirstRead(file, protect = protectUserFile) {
66
+ const resolved = path.resolve(file);
67
+ let paths = protectedReads.get(protect);
68
+ if (paths?.has(resolved)) return;
69
+ protect(file);
70
+ if (!paths) { paths = new Set(); protectedReads.set(protect, paths); }
71
+ paths.add(resolved);
72
+ }
62
73
  export function ensureUserDirectory(directory, protect = protectUserFile) {
63
74
  fs.mkdirSync(directory, { recursive: true, mode: 0o700 });
64
75
  if (!fs.lstatSync(directory).isDirectory() || fs.lstatSync(directory).isSymbolicLink()) throw recoveryError();
@@ -68,7 +79,7 @@ export function readUserJson(file, protect = protectUserFile) {
68
79
  try {
69
80
  const stat = fs.lstatSync(file);
70
81
  if (!stat.isFile() || stat.isSymbolicLink() || stat.nlink !== 1 || stat.size > MAX_USER_JSON_BYTES) throw recoveryError();
71
- protect(file);
82
+ protectUserFileOnFirstRead(file, protect);
72
83
  const value = JSON.parse(fs.readFileSync(file, 'utf8'));
73
84
  // null é JSON válido, mas não significa arquivo ausente: não recriar identidade/journal.
74
85
  if (!value || typeof value !== 'object' || Array.isArray(value)) throw recoveryError();
@@ -109,11 +120,11 @@ export function withFileGate(directory, run, protect = protectUserFile) {
109
120
  const gate = path.join(directory, '.escrita-lock');
110
121
  try { fs.mkdirSync(gate, { mode: 0o700 }); }
111
122
  catch (e) {
112
- if (e.code === 'EEXIST') throw Object.assign(new Error('Escrita local ocupada; se persistir após encerrar o residente, recupere o bloqueio local.'), { code: 'LOCAL_BUSY' });
123
+ if (e.code === 'EEXIST') throw Object.assign(new Error('Escrita local ocupada; se continuar com tudo parado, rode gestao computador recuperar-bloqueio.'), { code: 'LOCAL_BUSY' });
113
124
  throw e;
114
125
  }
115
126
  // Um crash neste trecho deixa o gate fechado: recuperação humana, nunca roubo por timeout.
116
- try { protect(gate); return run(); }
127
+ try { return run(); }
117
128
  finally { fs.rmdirSync(gate); }
118
129
  }
119
130
 
@@ -156,7 +167,7 @@ export function createIdentity(options) {
156
167
  /** Só pré-validação local; assinatura e papel continuam sendo autoridade do portal.
157
168
  * Relê a fonte em TODA chamada. Não renova/apaga token nem acessa serviço.mjs.
158
169
  */
159
- export async function loadPersonalToken({ env, subject, readToken = lerToken, now = Date.now, service = process.env.GESTAO_SERVICO }) {
170
+ export async function loadPersonalToken({ env, subject, readToken = async () => (await import('./config.mjs')).lerToken(), now = Date.now, service = process.env.GESTAO_SERVICO }) {
160
171
  scopeDirectory({ env, subject });
161
172
  if (service) throw new Error('Modo serviço não pode operar um computador pessoal.');
162
173
  const reconnect = () => Object.assign(new Error('Reconectar com gestao login na mesma conta e ambiente; execução suspensa.'), { code: 'RECONNECT_REQUIRED' });
@@ -221,7 +221,8 @@ export function createStartupManager({
221
221
  return { installed: false };
222
222
  }, protect);
223
223
  }
224
- return { installStartup, removeStartup, startupStatus };
224
+ const lockDirectory = options => locations(options).directory;
225
+ return { installStartup, removeStartup, startupStatus, lockDirectory };
225
226
  }
226
227
  export const installStartup = options => createStartupManager().installStartup(options);
227
228
  export const removeStartup = options => createStartupManager().removeStartup(options);
package/lib/config.mjs CHANGED
@@ -5,6 +5,7 @@ import { execFileSync } from "node:child_process";
5
5
  import fs from "node:fs";
6
6
  import os from "node:os";
7
7
  import path from "node:path";
8
+ import { ensureUserDirectory, protectUserFile } from './computador-identidade.mjs';
8
9
 
9
10
  const AMBIENTES = {
10
11
  prod: {
@@ -60,8 +61,13 @@ export function salvarToken(dados) {
60
61
  /* cai no arquivo */
61
62
  }
62
63
  }
63
- fs.mkdirSync(path.dirname(ARQUIVO), { recursive: true });
64
+ ensureUserDirectory(path.dirname(ARQUIVO));
64
65
  fs.writeFileSync(ARQUIVO, json, { mode: 0o600 });
66
+ try { protectUserFile(ARQUIVO); }
67
+ catch (e) {
68
+ fs.unlinkSync(ARQUIVO);
69
+ throw e;
70
+ }
65
71
  return ARQUIVO;
66
72
  }
67
73
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "chatflux-gestao-agente",
3
- "version": "0.2.6",
3
+ "version": "0.2.7",
4
4
  "type": "module",
5
5
  "description": "Opere o portal Gestão da ChatFlux pelo Claude Code, Claude Desktop ou qualquer harness MCP — com a sua identidade e o seu papel.",
6
6
  "keywords": [