chatflux-gestao-agente 0.2.5 → 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 +20 -0
- package/bin/gestao.mjs +7 -1
- package/lib/computador-cli.mjs +128 -17
- package/lib/computador-diagnostico.mjs +2 -2
- package/lib/computador-identidade.mjs +17 -6
- package/lib/computador-startup.mjs +2 -1
- package/lib/config.mjs +7 -1
- package/lib/configurar.mjs +7 -3
- package/package.json +1 -1
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]
|
|
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(); };
|
package/lib/computador-cli.mjs
CHANGED
|
@@ -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 {
|
|
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;
|
|
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;
|
|
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
|
-
|
|
309
|
-
|
|
310
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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 {
|
|
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
|
-
|
|
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
|
-
|
|
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/lib/configurar.mjs
CHANGED
|
@@ -37,8 +37,13 @@ export async function configurar(flags = [], options = {}) {
|
|
|
37
37
|
const fsImpl = options.fsImpl ?? fs, spawnImpl = options.spawnImpl ?? spawn;
|
|
38
38
|
const home = options.home ?? os.homedir(), env = options.env ?? process.env;
|
|
39
39
|
const environment = env.GESTAO_ENV === 'dev' ? 'dev' : 'prod';
|
|
40
|
-
const commandEnv = { ...env, GESTAO_ENV: environment };
|
|
41
40
|
const nodePath = options.nodePath ?? process.execPath, entryPath = fsImpl.realpathSync(options.entryPath ?? ENTRY);
|
|
41
|
+
const separator = platform === 'win32' ? ';' : ':';
|
|
42
|
+
// O residente roda pelo launchd/login do SO com PATH mínimo; ferramentas `#!/usr/bin/env node`
|
|
43
|
+
// (ex.: codex) e as do Homebrew precisam destes diretórios também nos filhos.
|
|
44
|
+
const searchDirs = [...new Set([...(env.PATH ?? env.Path ?? '').split(separator),
|
|
45
|
+
p.dirname(nodePath), p.join(home, '.local/bin'), ...(platform === 'darwin' ? ['/opt/homebrew/bin', '/usr/local/bin'] : [])])].filter(Boolean);
|
|
46
|
+
const commandEnv = { ...env, GESTAO_ENV: environment, [platform === 'win32' && env.Path !== undefined && env.PATH === undefined ? 'Path' : 'PATH']: searchDirs.join(separator) };
|
|
42
47
|
const version = options.version ?? VERSION, now = options.now ?? Date.now;
|
|
43
48
|
const announce = options.announce ?? console.log, confirm = options.confirm ?? ask;
|
|
44
49
|
const readToken = options.readToken ?? lerToken;
|
|
@@ -55,8 +60,7 @@ export async function configurar(flags = [], options = {}) {
|
|
|
55
60
|
}
|
|
56
61
|
const json = file => { const text = read(file); return text === null ? null : JSON.parse(text); };
|
|
57
62
|
function resolveCommand(tool) {
|
|
58
|
-
const directories =
|
|
59
|
-
p.dirname(nodePath), p.join(home, '.local/bin'), ...(platform === 'darwin' ? ['/opt/homebrew/bin', '/usr/local/bin'] : [])])].filter(Boolean);
|
|
63
|
+
const directories = searchDirs;
|
|
60
64
|
for (const directory of directories) {
|
|
61
65
|
for (const ext of platform === 'win32' ? ['.exe', '.cmd'] : ['']) {
|
|
62
66
|
const binary = p.join(directory, `${tool}${ext}`);
|
package/package.json
CHANGED