@teamvibe/poller 0.8.6 → 0.8.7-rc.1
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/bin/tv-git-credential.mjs +39 -0
- package/dist/auth-provider.js +8 -1
- package/dist/bg-task-registry.d.ts +12 -0
- package/dist/bg-task-registry.js +65 -0
- package/dist/brain-manager.d.ts +4 -0
- package/dist/brain-manager.js +87 -14
- package/dist/brains-bootstrap.js +6 -4
- package/dist/claude-spawner.d.ts +3 -0
- package/dist/claude-spawner.js +54 -21
- package/dist/connections-client.d.ts +66 -0
- package/dist/connections-client.js +128 -0
- package/dist/git-credential-env.d.ts +3 -0
- package/dist/git-credential-env.js +37 -0
- package/dist/git-credential-session.d.ts +33 -0
- package/dist/git-credential-session.js +265 -0
- package/dist/idle-restart.d.ts +1 -1
- package/dist/idle-restart.js +7 -62
- package/dist/maintenance-cron.d.ts +6 -0
- package/dist/poller.js +3 -1
- package/dist/pool-telemetry.js +1 -0
- package/dist/redact-text.d.ts +3 -1
- package/dist/redact-text.js +17 -1
- package/dist/session-instance.d.ts +9 -0
- package/dist/session-instance.js +36 -0
- package/dist/worktree-manager.d.ts +7 -4
- package/dist/worktree-manager.js +94 -36
- package/npm-shrinkwrap.json +2 -2
- package/package.json +2 -1
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
export type GitCredential = {
|
|
2
|
+
source: 'platform';
|
|
3
|
+
host: 'github.com';
|
|
4
|
+
username: 'x-access-token';
|
|
5
|
+
token: string;
|
|
6
|
+
connectionId: string;
|
|
7
|
+
credentialVersion: string;
|
|
8
|
+
bindingVersion: number;
|
|
9
|
+
expiresAt?: string;
|
|
10
|
+
kind?: 'github_token' | 'github_app';
|
|
11
|
+
} | {
|
|
12
|
+
source: 'none';
|
|
13
|
+
legacyAllowed?: boolean;
|
|
14
|
+
};
|
|
15
|
+
export interface BindingTuple {
|
|
16
|
+
connectionId?: string;
|
|
17
|
+
credentialVersion?: string;
|
|
18
|
+
bindingVersion?: number;
|
|
19
|
+
}
|
|
20
|
+
type Scope = 'brain' | 'channel';
|
|
21
|
+
export declare class ConnectionFetchError extends Error {
|
|
22
|
+
readonly code: string;
|
|
23
|
+
readonly status: number;
|
|
24
|
+
constructor(code: string, status: number);
|
|
25
|
+
}
|
|
26
|
+
/** Cache keys include consumer scope. Binding tuples arrive at startup and
|
|
27
|
+
* each brains refresh; a changed tuple evicts immediately. */
|
|
28
|
+
export declare class ConnectionsClient {
|
|
29
|
+
private options;
|
|
30
|
+
private entries;
|
|
31
|
+
private tuples;
|
|
32
|
+
private legacyBrains;
|
|
33
|
+
private serverHasConnections;
|
|
34
|
+
private lastLegacyNotice;
|
|
35
|
+
constructor(options: {
|
|
36
|
+
apiUrl: string;
|
|
37
|
+
pollerToken: string;
|
|
38
|
+
fetch?: typeof fetch;
|
|
39
|
+
now?: () => number;
|
|
40
|
+
});
|
|
41
|
+
private key;
|
|
42
|
+
private now;
|
|
43
|
+
refreshTuples(brains: ReadonlyArray<{
|
|
44
|
+
brainId: string;
|
|
45
|
+
channelId: string;
|
|
46
|
+
connectionId?: string;
|
|
47
|
+
credentialVersion?: string;
|
|
48
|
+
bindingVersion?: number;
|
|
49
|
+
channelConnectionId?: string;
|
|
50
|
+
channelCredentialVersion?: string;
|
|
51
|
+
channelBindingVersion?: number;
|
|
52
|
+
}>, channels?: ReadonlyArray<{
|
|
53
|
+
channelId: string;
|
|
54
|
+
connectionId?: string;
|
|
55
|
+
credentialVersion?: string;
|
|
56
|
+
bindingVersion?: number;
|
|
57
|
+
}>): void;
|
|
58
|
+
invalidate(scope: Scope, id: string): void;
|
|
59
|
+
recordBrainSource(brainId: string, source: 'platform' | 'legacy'): void;
|
|
60
|
+
get legacyGitBrains(): number;
|
|
61
|
+
private noticeLegacyServer;
|
|
62
|
+
get(scope: Scope, id: string, force?: boolean): Promise<GitCredential>;
|
|
63
|
+
}
|
|
64
|
+
export declare const connectionsClient: ConnectionsClient;
|
|
65
|
+
export declare function refreshConnectionTuples(pollerId: string): Promise<void>;
|
|
66
|
+
export {};
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
import { config } from './config.js';
|
|
2
|
+
import { registerConfiguredToken } from './redact-text.js';
|
|
3
|
+
export class ConnectionFetchError extends Error {
|
|
4
|
+
code;
|
|
5
|
+
status;
|
|
6
|
+
constructor(code, status) {
|
|
7
|
+
super(code);
|
|
8
|
+
this.code = code;
|
|
9
|
+
this.status = status;
|
|
10
|
+
}
|
|
11
|
+
}
|
|
12
|
+
/** Cache keys include consumer scope. Binding tuples arrive at startup and
|
|
13
|
+
* each brains refresh; a changed tuple evicts immediately. */
|
|
14
|
+
export class ConnectionsClient {
|
|
15
|
+
options;
|
|
16
|
+
entries = new Map();
|
|
17
|
+
tuples = new Map();
|
|
18
|
+
legacyBrains = new Set();
|
|
19
|
+
serverHasConnections = true;
|
|
20
|
+
lastLegacyNotice = -Infinity;
|
|
21
|
+
constructor(options) {
|
|
22
|
+
this.options = options;
|
|
23
|
+
}
|
|
24
|
+
key(scope, id) { return `${scope}:${id}`; }
|
|
25
|
+
now() { return (this.options.now ?? Date.now)(); }
|
|
26
|
+
refreshTuples(brains, channels) {
|
|
27
|
+
if (brains.some(brain => Object.hasOwn(brain, 'bindingVersion')) || channels?.some(channel => Object.hasOwn(channel, 'bindingVersion'))) {
|
|
28
|
+
this.serverHasConnections = true;
|
|
29
|
+
}
|
|
30
|
+
else if ((brains.length > 0 || (channels?.length ?? 0) > 0) && brains.every(brain => !Object.hasOwn(brain, 'bindingVersion') && !Object.hasOwn(brain, 'connectionId') && !Object.hasOwn(brain, 'credentialVersion'))) {
|
|
31
|
+
this.serverHasConnections = false;
|
|
32
|
+
this.entries.clear();
|
|
33
|
+
this.noticeLegacyServer();
|
|
34
|
+
}
|
|
35
|
+
const next = new Map();
|
|
36
|
+
for (const brain of brains) {
|
|
37
|
+
next.set(this.key('brain', brain.brainId), JSON.stringify([brain.connectionId, brain.credentialVersion, brain.bindingVersion ?? 0]));
|
|
38
|
+
next.set(this.key('channel', brain.channelId), JSON.stringify([brain.channelConnectionId, brain.channelCredentialVersion, brain.channelBindingVersion ?? 0]));
|
|
39
|
+
}
|
|
40
|
+
for (const channel of channels ?? [])
|
|
41
|
+
next.set(this.key('channel', channel.channelId), JSON.stringify([channel.connectionId, channel.credentialVersion, channel.bindingVersion ?? 0]));
|
|
42
|
+
for (const [key, entry] of this.entries)
|
|
43
|
+
if (next.get(key) !== entry.tuple)
|
|
44
|
+
this.entries.delete(key);
|
|
45
|
+
for (const brainId of this.legacyBrains)
|
|
46
|
+
if (!next.has(this.key('brain', brainId)))
|
|
47
|
+
this.legacyBrains.delete(brainId);
|
|
48
|
+
this.tuples = next;
|
|
49
|
+
}
|
|
50
|
+
invalidate(scope, id) { this.entries.delete(this.key(scope, id)); }
|
|
51
|
+
recordBrainSource(brainId, source) {
|
|
52
|
+
if (source === 'legacy')
|
|
53
|
+
this.legacyBrains.add(brainId);
|
|
54
|
+
else
|
|
55
|
+
this.legacyBrains.delete(brainId);
|
|
56
|
+
}
|
|
57
|
+
get legacyGitBrains() { return this.legacyBrains.size; }
|
|
58
|
+
noticeLegacyServer() {
|
|
59
|
+
if (this.now() - this.lastLegacyNotice >= 3600_000) {
|
|
60
|
+
console.warn('Connections API unavailable; using legacy git credentials');
|
|
61
|
+
this.lastLegacyNotice = this.now();
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
async get(scope, id, force = false) {
|
|
65
|
+
if (!this.serverHasConnections) {
|
|
66
|
+
this.noticeLegacyServer();
|
|
67
|
+
return { source: 'none', legacyAllowed: true };
|
|
68
|
+
}
|
|
69
|
+
const key = this.key(scope, id);
|
|
70
|
+
const tuple = this.tuples.get(key);
|
|
71
|
+
const entry = this.entries.get(key);
|
|
72
|
+
if (!force && entry && entry.tuple === (tuple ?? '') && entry.validUntil > this.now())
|
|
73
|
+
return entry.value;
|
|
74
|
+
this.entries.delete(key);
|
|
75
|
+
if (!/^[A-Za-z0-9_-]{1,128}$/.test(id))
|
|
76
|
+
throw new ConnectionFetchError('invalid_request', 400);
|
|
77
|
+
const url = new URL(scope === 'brain' ? `/brains/${encodeURIComponent(id)}/git-credential` : '/connections/git/spawn', this.options.apiUrl);
|
|
78
|
+
if (scope === 'channel')
|
|
79
|
+
url.searchParams.set('channelId', id);
|
|
80
|
+
const response = await (this.options.fetch ?? fetch)(url, { headers: { Authorization: `Bearer ${this.options.pollerToken}`, 'Cache-Control': 'no-store' }, redirect: 'error' });
|
|
81
|
+
if (!response.ok) {
|
|
82
|
+
if (response.status === 405 || response.status === 404 && !(await response.clone().json().catch(() => ({}))).code) {
|
|
83
|
+
this.serverHasConnections = false;
|
|
84
|
+
this.noticeLegacyServer();
|
|
85
|
+
return { source: 'none', legacyAllowed: true };
|
|
86
|
+
}
|
|
87
|
+
const code = { 400: 'invalid_request', 401: 'unauthorized', 403: 'forbidden', 404: 'connection_unbound', 409: 'connection_disabled', 410: 'connection_deleted' }[response.status] ?? 'credential_store_unavailable';
|
|
88
|
+
throw new ConnectionFetchError(code, response.status);
|
|
89
|
+
}
|
|
90
|
+
const body = await response.json();
|
|
91
|
+
let value;
|
|
92
|
+
if (body.source === 'none')
|
|
93
|
+
value = { source: 'none', ...(body.legacyAllowed === true ? { legacyAllowed: true } : {}) };
|
|
94
|
+
else if (body.source === 'platform' && body.host === 'github.com' && body.username === 'x-access-token' &&
|
|
95
|
+
typeof body.token === 'string' && body.token && typeof body.connectionId === 'string' &&
|
|
96
|
+
typeof body.credentialVersion === 'string' && typeof body.bindingVersion === 'number') {
|
|
97
|
+
value = body;
|
|
98
|
+
registerConfiguredToken(body.token);
|
|
99
|
+
}
|
|
100
|
+
else
|
|
101
|
+
throw new ConnectionFetchError('invalid_response', 503);
|
|
102
|
+
const validUntil = value.source === 'platform' && value.expiresAt
|
|
103
|
+
? Date.parse(value.expiresAt) - 5 * 60_000 : this.now() + 10 * 60_000;
|
|
104
|
+
if (validUntil > this.now())
|
|
105
|
+
this.entries.set(key, { value, tuple: tuple ?? '', validUntil });
|
|
106
|
+
return value;
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
export const connectionsClient = new ConnectionsClient({ apiUrl: config.TEAMVIBE_API_URL, pollerToken: config.TEAMVIBE_POLLER_TOKEN ?? '' });
|
|
110
|
+
export async function refreshConnectionTuples(pollerId) {
|
|
111
|
+
if (!pollerId || !config.TEAMVIBE_API_URL || !config.TEAMVIBE_POLLER_TOKEN)
|
|
112
|
+
return;
|
|
113
|
+
const url = new URL('/poller/brains', config.TEAMVIBE_API_URL);
|
|
114
|
+
url.searchParams.set('pollerId', pollerId);
|
|
115
|
+
const controller = new AbortController();
|
|
116
|
+
const timer = setTimeout(() => controller.abort(), 30_000);
|
|
117
|
+
try {
|
|
118
|
+
const response = await fetch(url, { headers: { Authorization: `Bearer ${config.TEAMVIBE_POLLER_TOKEN}` }, signal: controller.signal });
|
|
119
|
+
if (!response.ok)
|
|
120
|
+
return;
|
|
121
|
+
const body = await response.json();
|
|
122
|
+
if (Array.isArray(body.brains))
|
|
123
|
+
connectionsClient.refreshTuples(body.brains, body.channels);
|
|
124
|
+
}
|
|
125
|
+
finally {
|
|
126
|
+
clearTimeout(timer);
|
|
127
|
+
}
|
|
128
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { fileURLToPath } from 'node:url';
|
|
2
|
+
import { registerConfiguredToken } from './redact-text.js';
|
|
3
|
+
// Works from both src/ (tests) and dist/ (published package).
|
|
4
|
+
export const GIT_CREDENTIAL_HELPER = fileURLToPath(new URL('../bin/tv-git-credential.mjs', import.meta.url));
|
|
5
|
+
export function buildGitCredentialEnv(base, credential, tokenFile) {
|
|
6
|
+
const env = { ...base };
|
|
7
|
+
const legacy = credential.source === 'none' && credential.legacyAllowed === true;
|
|
8
|
+
for (const key of Object.keys(env)) {
|
|
9
|
+
// Do not inherit a prior session's helper/file, even on eligible legacy.
|
|
10
|
+
if (key.startsWith('TV_GIT_') || !legacy && (/^GIT_(?:CONFIG|CREDENTIAL|TRACE|SSL|HTTP|SSH)(?:_|$)/.test(key) ||
|
|
11
|
+
['GIT_ASKPASS', 'GIT_EXEC_PATH', 'GIT_PROXY_COMMAND', 'GIT_DIR', 'GIT_COMMON_DIR', 'GIT_WORK_TREE', 'GIT_NAMESPACE', 'GIT_TEMPLATE_DIR', 'GIT_PROTOCOL', 'GIT_ALLOW_PROTOCOL', 'SSH_ASKPASS'].includes(key) ||
|
|
12
|
+
['GITHUB_TOKEN', 'GH_TOKEN', 'GITLAB_TOKEN'].includes(key)))
|
|
13
|
+
delete env[key];
|
|
14
|
+
}
|
|
15
|
+
if (legacy)
|
|
16
|
+
return env;
|
|
17
|
+
env['GIT_TERMINAL_PROMPT'] = '0';
|
|
18
|
+
env['GIT_CONFIG_COUNT'] = credential.source === 'platform' ? '3' : '1';
|
|
19
|
+
env['GIT_CONFIG_KEY_0'] = 'credential.helper';
|
|
20
|
+
env['GIT_CONFIG_VALUE_0'] = '';
|
|
21
|
+
if (credential.source === 'platform') {
|
|
22
|
+
if (!tokenFile)
|
|
23
|
+
throw new Error('Git credential token file required');
|
|
24
|
+
registerConfiguredToken(credential.token);
|
|
25
|
+
env['TV_GIT_TOKEN_FILE'] = tokenFile;
|
|
26
|
+
env['GIT_CONFIG_KEY_1'] = `credential.https://${credential.host}.helper`;
|
|
27
|
+
// Strip CLI snapshots before the helper process starts. Git appends only
|
|
28
|
+
// get/store/erase; the token remains exclusively in the private file.
|
|
29
|
+
const helperPath = "'" + GIT_CREDENTIAL_HELPER.replace(/'/g, "'\\''") + "'";
|
|
30
|
+
env['GIT_CONFIG_VALUE_1'] = `!env -u GITHUB_TOKEN -u GH_TOKEN -u GITLAB_TOKEN ${helperPath}`;
|
|
31
|
+
env['GIT_CONFIG_KEY_2'] = `credential.https://${credential.host}.useHttpPath`;
|
|
32
|
+
env['GIT_CONFIG_VALUE_2'] = 'true';
|
|
33
|
+
env['GH_TOKEN'] = credential.token;
|
|
34
|
+
env['GITHUB_TOKEN'] = credential.token;
|
|
35
|
+
}
|
|
36
|
+
return env;
|
|
37
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import type { GitCredential } from './connections-client.js';
|
|
2
|
+
import { type SessionInstance } from './session-instance.js';
|
|
3
|
+
type PlatformCredential = Extract<GitCredential, {
|
|
4
|
+
source: 'platform';
|
|
5
|
+
}>;
|
|
6
|
+
export interface GitCredentialSession extends SessionInstance {
|
|
7
|
+
dir: string;
|
|
8
|
+
tokenFile: string;
|
|
9
|
+
tasksFile: string;
|
|
10
|
+
}
|
|
11
|
+
export interface AgentGitDelivery {
|
|
12
|
+
instance?: SessionInstance;
|
|
13
|
+
credential?: GitCredential;
|
|
14
|
+
session?: GitCredentialSession;
|
|
15
|
+
}
|
|
16
|
+
export declare function gitCredentialRoot(): string;
|
|
17
|
+
export declare function gitCredentialKind(credential: PlatformCredential): 'github_app' | 'github_token';
|
|
18
|
+
export declare function writeGitTokenFile(dir: string, credential: PlatformCredential): void;
|
|
19
|
+
export declare function createGitCredentialSession(credential: PlatformCredential, options: {
|
|
20
|
+
root?: string;
|
|
21
|
+
channelId: string;
|
|
22
|
+
threadId: string;
|
|
23
|
+
instance?: SessionInstance;
|
|
24
|
+
}): GitCredentialSession;
|
|
25
|
+
export declare function setGitCredentialSessionPid(session: GitCredentialSession, pid: number | undefined): void;
|
|
26
|
+
export declare function isGitCredentialInstanceRetained(instance?: SessionInstance, root?: string, threadId?: string): boolean;
|
|
27
|
+
export declare function isGitCredentialInstanceKnownDead(instance: SessionInstance, root?: string): boolean;
|
|
28
|
+
export declare function registeredWorktreeOwner(threadId: string, root?: string): SessionInstance | undefined;
|
|
29
|
+
export declare function finishGitCredentialSession(session: GitCredentialSession, cleanup?: () => void): void;
|
|
30
|
+
export declare function registeredGitCredentialTasks(root?: string): ReadonlySet<string>;
|
|
31
|
+
export declare function sweepGitCredentialSessions(root?: string): void;
|
|
32
|
+
export declare function startGitCredentialJanitor(): void;
|
|
33
|
+
export {};
|
|
@@ -0,0 +1,265 @@
|
|
|
1
|
+
import { execFileSync } from 'node:child_process';
|
|
2
|
+
import { chmodSync, existsSync, mkdirSync, mkdtempSync, readFileSync, renameSync, rmSync, statSync, writeFileSync } from 'node:fs';
|
|
3
|
+
import { randomUUID } from 'node:crypto';
|
|
4
|
+
import { tmpdir } from 'node:os';
|
|
5
|
+
import { join, resolve } from 'node:path';
|
|
6
|
+
import { config } from './config.js';
|
|
7
|
+
import { logger } from './logger.js';
|
|
8
|
+
import { emitError } from './event-queue.js';
|
|
9
|
+
import { lifecycleOf, listDirs, parseStatus, pidAlive } from './bg-task-registry.js';
|
|
10
|
+
import { registerConfiguredToken } from './redact-text.js';
|
|
11
|
+
import { createSessionInstance, readSessionOwner, writeSessionOwner, sameSessionInstance, reportSessionOwnerMismatch } from './session-instance.js';
|
|
12
|
+
const deferredCleanup = new Map();
|
|
13
|
+
// PID alone is insufficient after container restart (PID 1) or ordinary PID
|
|
14
|
+
// reuse. Linux start ticks include sub-second precision; macOS exposes lstart.
|
|
15
|
+
function processIdentity(pid) {
|
|
16
|
+
try {
|
|
17
|
+
if (process.platform === 'linux') {
|
|
18
|
+
const stat = readFileSync(`/proc/${pid}/stat`, 'utf8');
|
|
19
|
+
return readFileSync('/proc/sys/kernel/random/boot_id', 'utf8').trim() + ':' + stat.slice(stat.lastIndexOf(')') + 2).split(' ')[19];
|
|
20
|
+
}
|
|
21
|
+
return execFileSync('ps', ['-p', String(pid), '-o', 'lstart='], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }).trim() || undefined;
|
|
22
|
+
}
|
|
23
|
+
catch {
|
|
24
|
+
return undefined;
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
function sameProcess(pid, identity) {
|
|
28
|
+
return pidAlive(pid) && (identity === undefined || identity === processIdentity(pid));
|
|
29
|
+
}
|
|
30
|
+
const ownerIdentity = processIdentity(process.pid);
|
|
31
|
+
export function gitCredentialRoot() {
|
|
32
|
+
return resolve(config.PERSISTENT_STORAGE_PATH || tmpdir(), 'teamvibe-git-sessions');
|
|
33
|
+
}
|
|
34
|
+
function atomicWrite(path, contents) {
|
|
35
|
+
const temporary = `${path}.${randomUUID()}.tmp`;
|
|
36
|
+
try {
|
|
37
|
+
writeFileSync(temporary, contents, { mode: 0o600, flag: 'wx' });
|
|
38
|
+
chmodSync(temporary, 0o600);
|
|
39
|
+
renameSync(temporary, path);
|
|
40
|
+
}
|
|
41
|
+
finally {
|
|
42
|
+
rmSync(temporary, { force: true });
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
// The PAT contract omits expiry; installation tokens always carry provider expiry.
|
|
46
|
+
export function gitCredentialKind(credential) {
|
|
47
|
+
return credential.kind ?? (credential.expiresAt ? 'github_app' : 'github_token');
|
|
48
|
+
}
|
|
49
|
+
export function writeGitTokenFile(dir, credential) {
|
|
50
|
+
registerConfiguredToken(credential.token);
|
|
51
|
+
atomicWrite(join(dir, 'git-token'), JSON.stringify({ host: credential.host, token: credential.token, expiresAt: credential.expiresAt, kind: gitCredentialKind(credential) }));
|
|
52
|
+
}
|
|
53
|
+
export function createGitCredentialSession(credential, options) {
|
|
54
|
+
const root = options.root ?? gitCredentialRoot();
|
|
55
|
+
mkdirSync(root, { recursive: true, mode: 0o700 });
|
|
56
|
+
chmodSync(root, 0o700);
|
|
57
|
+
const dir = mkdtempSync(join(root, 'session-'));
|
|
58
|
+
chmodSync(dir, 0o700);
|
|
59
|
+
try {
|
|
60
|
+
const instance = options.instance ?? createSessionInstance();
|
|
61
|
+
writeSessionOwner(join(dir, 'owner.json'), instance);
|
|
62
|
+
writeGitTokenFile(dir, credential);
|
|
63
|
+
const metadata = { ...instance, ownerPid: process.pid, ownerIdentity, active: true, channelId: options.channelId, threadId: options.threadId, connectionId: credential.connectionId, kind: gitCredentialKind(credential) };
|
|
64
|
+
atomicWrite(join(dir, 'session.json'), JSON.stringify(metadata));
|
|
65
|
+
const tasksFile = join(dir, 'bg-tasks');
|
|
66
|
+
writeFileSync(tasksFile, '', { mode: 0o600, flag: 'wx' });
|
|
67
|
+
return { ...instance, dir, tasksFile, tokenFile: join(dir, 'git-token') };
|
|
68
|
+
}
|
|
69
|
+
catch (error) {
|
|
70
|
+
rmSync(dir, { recursive: true, force: true });
|
|
71
|
+
throw error;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
export function setGitCredentialSessionPid(session, pid) {
|
|
75
|
+
const path = join(session.dir, 'session.json');
|
|
76
|
+
const metadata = readMetadata(session.dir);
|
|
77
|
+
if (!metadata || !ownsSession(session.dir, session) || !sameSessionInstance(metadata, session)) {
|
|
78
|
+
reportSessionOwnerMismatch(session, readSessionOwner(join(session.dir, 'owner.json')));
|
|
79
|
+
return;
|
|
80
|
+
}
|
|
81
|
+
metadata.sessionPid = pid;
|
|
82
|
+
metadata.sessionIdentity = pid === undefined ? undefined : processIdentity(pid);
|
|
83
|
+
atomicWrite(path, JSON.stringify(metadata));
|
|
84
|
+
}
|
|
85
|
+
function hasRegisteredTasks(dir) {
|
|
86
|
+
// The launcher appends an existing registry task directory before starting its
|
|
87
|
+
// runner. Status/PID/TTL semantics are shared with idle restart, not duplicated.
|
|
88
|
+
let tasks;
|
|
89
|
+
try {
|
|
90
|
+
tasks = readFileSync(join(dir, 'bg-tasks'), 'utf8').split('\n').filter(Boolean);
|
|
91
|
+
}
|
|
92
|
+
catch (error) {
|
|
93
|
+
if (error.code === 'ENOENT')
|
|
94
|
+
return false;
|
|
95
|
+
throw error; // unreadable registrations retain credentials until retry
|
|
96
|
+
}
|
|
97
|
+
return tasks.some(task => {
|
|
98
|
+
if (!existsSync(task))
|
|
99
|
+
return false;
|
|
100
|
+
let status = '';
|
|
101
|
+
try {
|
|
102
|
+
status = readFileSync(join(task, 'status'), 'utf8');
|
|
103
|
+
}
|
|
104
|
+
catch { }
|
|
105
|
+
const state = lifecycleOf(parseStatus(status), Date.now());
|
|
106
|
+
// A new directory has no status until its runner starts; bound the grace
|
|
107
|
+
// period so a crashed launcher cannot keep credentials forever.
|
|
108
|
+
if (state === 'unknown')
|
|
109
|
+
return Date.now() - statSync(task).mtimeMs < 60_000;
|
|
110
|
+
return state === 'running';
|
|
111
|
+
});
|
|
112
|
+
}
|
|
113
|
+
// Shared retention decision table (always evaluated BEFORE ownership/age):
|
|
114
|
+
// live session OR live registered task => retain, even with a bad/missing marker.
|
|
115
|
+
// Otherwise: matching known-dead owner => delete; missing/malformed owner =>
|
|
116
|
+
// delete only after orphan age; different live owner => retain; unknown owner
|
|
117
|
+
// => retain until its death can be established. Deferred cleanup is stricter:
|
|
118
|
+
// only the exact scheduling owner may delete, with no age/file-identity fallback.
|
|
119
|
+
function isSessionRetained(dir, metadata) {
|
|
120
|
+
return !!(metadata?.active && (sameProcess(metadata.ownerPid, metadata.ownerIdentity) ||
|
|
121
|
+
metadata.sessionPid !== undefined && sameProcess(metadata.sessionPid, metadata.sessionIdentity))) || hasRegisteredTasks(dir);
|
|
122
|
+
}
|
|
123
|
+
function readMetadata(dir) {
|
|
124
|
+
try {
|
|
125
|
+
return JSON.parse(readFileSync(join(dir, 'session.json'), 'utf8'));
|
|
126
|
+
}
|
|
127
|
+
catch {
|
|
128
|
+
return undefined;
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
export function isGitCredentialInstanceRetained(instance, root = gitCredentialRoot(), threadId) {
|
|
132
|
+
for (const name of listDirs(root)) {
|
|
133
|
+
if (!name.startsWith('session-'))
|
|
134
|
+
continue;
|
|
135
|
+
const dir = join(root, name);
|
|
136
|
+
const metadata = readMetadata(dir);
|
|
137
|
+
// Metadata associates the worktree's reusable thread path with its live
|
|
138
|
+
// session even when either ownership marker is damaged or replaced.
|
|
139
|
+
if (metadata && (sameSessionInstance(metadata, instance) || threadId !== undefined && metadata.threadId === threadId)) {
|
|
140
|
+
if (isSessionRetained(dir, metadata))
|
|
141
|
+
return true;
|
|
142
|
+
}
|
|
143
|
+
else if (instance && ownsSession(dir, instance) && (!metadata || isSessionRetained(dir, metadata)))
|
|
144
|
+
return true;
|
|
145
|
+
}
|
|
146
|
+
return false;
|
|
147
|
+
}
|
|
148
|
+
export function isGitCredentialInstanceKnownDead(instance, root = gitCredentialRoot()) {
|
|
149
|
+
let known = false;
|
|
150
|
+
for (const name of listDirs(root)) {
|
|
151
|
+
if (!name.startsWith('session-'))
|
|
152
|
+
continue;
|
|
153
|
+
const dir = join(root, name);
|
|
154
|
+
const metadata = readMetadata(dir);
|
|
155
|
+
if (!sameSessionInstance(metadata, instance))
|
|
156
|
+
continue;
|
|
157
|
+
if (isSessionRetained(dir, metadata))
|
|
158
|
+
return false;
|
|
159
|
+
known = true;
|
|
160
|
+
}
|
|
161
|
+
return known;
|
|
162
|
+
}
|
|
163
|
+
export function registeredWorktreeOwner(threadId, root = gitCredentialRoot()) {
|
|
164
|
+
for (const name of listDirs(root)) {
|
|
165
|
+
if (!name.startsWith('session-'))
|
|
166
|
+
continue;
|
|
167
|
+
const dir = join(root, name);
|
|
168
|
+
const metadata = readMetadata(dir);
|
|
169
|
+
if (metadata?.threadId === threadId && isSessionRetained(dir, metadata))
|
|
170
|
+
return metadata;
|
|
171
|
+
}
|
|
172
|
+
return undefined;
|
|
173
|
+
}
|
|
174
|
+
function ownsSession(dir, instance) {
|
|
175
|
+
return sameSessionInstance(readSessionOwner(join(dir, 'owner.json')), instance);
|
|
176
|
+
}
|
|
177
|
+
const ORPHAN_AGE_MS = 24 * 60 * 60 * 1000;
|
|
178
|
+
function sweepSession(dir) {
|
|
179
|
+
const path = join(dir, 'session.json');
|
|
180
|
+
const metadata = readMetadata(dir);
|
|
181
|
+
const retained = isSessionRetained(dir, metadata);
|
|
182
|
+
const deferred = deferredCleanup.get(dir);
|
|
183
|
+
const expected = deferred?.session ?? metadata;
|
|
184
|
+
const current = readSessionOwner(join(dir, 'owner.json'));
|
|
185
|
+
const matches = sameSessionInstance(current, expected) && (!deferred || sameSessionInstance(metadata, expected));
|
|
186
|
+
if (expected && !matches)
|
|
187
|
+
reportSessionOwnerMismatch(expected, current);
|
|
188
|
+
const events = join(dir, 'git-credential-events');
|
|
189
|
+
if (metadata && matches && existsSync(events)) {
|
|
190
|
+
if (!metadata.expiryReported) {
|
|
191
|
+
emitError('session', `git_credential_expired kind=${metadata.kind}`, { channelId: metadata.channelId, context: { code: 'git_credential_expired', kind: metadata.kind, connectionId: metadata.connectionId, threadId: metadata.threadId } });
|
|
192
|
+
metadata.expiryReported = true;
|
|
193
|
+
atomicWrite(path, JSON.stringify(metadata));
|
|
194
|
+
}
|
|
195
|
+
rmSync(events, { force: true });
|
|
196
|
+
}
|
|
197
|
+
// Liveness is independent of marker integrity, including registered tasks
|
|
198
|
+
// whose owner died or whose session metadata is temporarily unreadable.
|
|
199
|
+
if (retained)
|
|
200
|
+
return false;
|
|
201
|
+
if (deferred && !matches) {
|
|
202
|
+
// Keep the guard for subsequent janitor ticks; losing it would turn the
|
|
203
|
+
// next tick into an age-based orphan deletion of the same unproven owner.
|
|
204
|
+
return false; // Never run the old callback against unproven ownership.
|
|
205
|
+
}
|
|
206
|
+
if (current && isGitCredentialInstanceRetained(current, resolve(dir, '..')))
|
|
207
|
+
return false;
|
|
208
|
+
if (!matches) {
|
|
209
|
+
if (current ? !isGitCredentialInstanceKnownDead(current, resolve(dir, '..')) : Date.now() - statSync(dir).mtimeMs < ORPHAN_AGE_MS)
|
|
210
|
+
return false;
|
|
211
|
+
}
|
|
212
|
+
rmSync(dir, { recursive: true, force: true });
|
|
213
|
+
const cleanup = deferredCleanup.get(dir);
|
|
214
|
+
deferredCleanup.delete(dir);
|
|
215
|
+
cleanup?.cleanup?.();
|
|
216
|
+
return true;
|
|
217
|
+
}
|
|
218
|
+
export function finishGitCredentialSession(session, cleanup) {
|
|
219
|
+
const path = join(session.dir, 'session.json');
|
|
220
|
+
const metadata = readMetadata(session.dir);
|
|
221
|
+
if (!metadata || !ownsSession(session.dir, session) || !sameSessionInstance(metadata, session)) {
|
|
222
|
+
reportSessionOwnerMismatch(session, readSessionOwner(join(session.dir, 'owner.json')));
|
|
223
|
+
return;
|
|
224
|
+
}
|
|
225
|
+
metadata.active = false;
|
|
226
|
+
atomicWrite(path, JSON.stringify(metadata));
|
|
227
|
+
deferredCleanup.set(session.dir, { session, cleanup });
|
|
228
|
+
try {
|
|
229
|
+
sweepSession(session.dir);
|
|
230
|
+
}
|
|
231
|
+
catch {
|
|
232
|
+
logger.warn('[GitCredential] Session cleanup deferred; will retry');
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
export function registeredGitCredentialTasks(root = gitCredentialRoot()) {
|
|
236
|
+
const tasks = new Set();
|
|
237
|
+
for (const name of listDirs(root)) {
|
|
238
|
+
if (!name.startsWith('session-'))
|
|
239
|
+
continue;
|
|
240
|
+
try {
|
|
241
|
+
for (const task of readFileSync(join(root, name, 'bg-tasks'), 'utf8').split('\n').filter(Boolean))
|
|
242
|
+
tasks.add(task);
|
|
243
|
+
}
|
|
244
|
+
catch { /* incomplete session directory; next sweep retries */ }
|
|
245
|
+
}
|
|
246
|
+
return tasks;
|
|
247
|
+
}
|
|
248
|
+
// Startup and periodic cleanup only: v1 never re-mints or rewrites git-token.
|
|
249
|
+
export function sweepGitCredentialSessions(root = gitCredentialRoot()) {
|
|
250
|
+
for (const name of listDirs(root)) {
|
|
251
|
+
if (!name.startsWith('session-'))
|
|
252
|
+
continue;
|
|
253
|
+
try {
|
|
254
|
+
sweepSession(join(root, name));
|
|
255
|
+
}
|
|
256
|
+
catch {
|
|
257
|
+
logger.warn('[GitCredential] Session cleanup deferred; will retry');
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
export function startGitCredentialJanitor() {
|
|
262
|
+
sweepGitCredentialSessions();
|
|
263
|
+
const timer = setInterval(() => sweepGitCredentialSessions(), 1000);
|
|
264
|
+
timer.unref();
|
|
265
|
+
}
|
package/dist/idle-restart.d.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
export declare function readReclaimableFileBytes(path?: string): number | null;
|
|
2
2
|
export declare function findLiveBgTask(now?: number): string | null;
|
|
3
|
-
export declare function reconcileBgTasksOnBoot(): void;
|
|
3
|
+
export declare function reconcileBgTasksOnBoot(preserveTasks?: ReadonlySet<string>): void;
|
|
4
4
|
export interface CheckIdleRestartDeps {
|
|
5
5
|
now?: number;
|
|
6
6
|
startTime?: number;
|
package/dist/idle-restart.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { bgTaskRoot, listDirs, parseStatus, lifecycleOf } from './bg-task-registry.js';
|
|
2
|
+
import { appendFileSync, readFileSync } from 'fs';
|
|
2
3
|
import { join } from 'path';
|
|
3
4
|
import { config } from './config.js';
|
|
4
5
|
import { logger } from './logger.js';
|
|
@@ -38,66 +39,6 @@ export function readReclaimableFileBytes(path = MEMORY_STAT_PATH) {
|
|
|
38
39
|
// skills/background-task/scripts/bg-task.mjs) rather than a raw /proc scan, which
|
|
39
40
|
// false-negatives on interpreter/path variations and can't distinguish "stuck past TTL"
|
|
40
41
|
// from "healthy" the way the status file's own started/ttl fields can.
|
|
41
|
-
const ABANDON_GRACE_SEC = 60; // mirrors bg-task.mjs's ABANDON_GRACE_SEC
|
|
42
|
-
function parseStatus(text) {
|
|
43
|
-
const kv = {};
|
|
44
|
-
for (const line of text.split('\n')) {
|
|
45
|
-
const i = line.indexOf('=');
|
|
46
|
-
if (i > 0)
|
|
47
|
-
kv[line.slice(0, i)] = line.slice(i + 1);
|
|
48
|
-
}
|
|
49
|
-
return kv;
|
|
50
|
-
}
|
|
51
|
-
function pidAlive(pid) {
|
|
52
|
-
try {
|
|
53
|
-
process.kill(pid, 0);
|
|
54
|
-
return true;
|
|
55
|
-
}
|
|
56
|
-
catch {
|
|
57
|
-
return false;
|
|
58
|
-
}
|
|
59
|
-
}
|
|
60
|
-
// Same four-way classification as bg-task.mjs's lifecycleOf, minus the tests-only
|
|
61
|
-
// `alive` injection point (this module has its own dedicated tests instead).
|
|
62
|
-
function lifecycleOf(kv, now) {
|
|
63
|
-
// Trust the runner's own claim, whatever it says (bg-task.mjs:139) — not just the two
|
|
64
|
-
// values the runner writes today (`finished`/`timed-out`, bg-task-runner.mjs:403).
|
|
65
|
-
// Mirroring the check itself, not just its current output, means the two
|
|
66
|
-
// implementations of this format can't quietly diverge if the runner ever starts
|
|
67
|
-
// writing a state value this list doesn't happen to enumerate — hardcoding a closed
|
|
68
|
-
// set here would silently fall through to the pid/TTL heuristics below instead.
|
|
69
|
-
if (kv.state)
|
|
70
|
-
return kv.state;
|
|
71
|
-
if (kv.runner_crashed)
|
|
72
|
-
return 'crashed';
|
|
73
|
-
if (Object.keys(kv).length === 0)
|
|
74
|
-
return 'unknown';
|
|
75
|
-
const pid = Number(kv.pid);
|
|
76
|
-
if (!Number.isInteger(pid) || pid <= 0 || !pidAlive(pid))
|
|
77
|
-
return 'abandoned';
|
|
78
|
-
const started = kv.started ? Date.parse(kv.started) : NaN;
|
|
79
|
-
const ttl = Number(String(kv.ttl ?? '').replace(/s$/, ''));
|
|
80
|
-
if (Number.isFinite(started) && Number.isFinite(ttl) && now - started > (ttl + ABANDON_GRACE_SEC) * 1000) {
|
|
81
|
-
return 'abandoned';
|
|
82
|
-
}
|
|
83
|
-
return 'running';
|
|
84
|
-
}
|
|
85
|
-
function bgTaskRoot() {
|
|
86
|
-
// Mirrors bg-task.mjs's taskRoot() default exactly, minus the per-channel
|
|
87
|
-
// namespace — the poller checks liveness across every channel it serves, not just
|
|
88
|
-
// one, so it walks the parent directory instead of joining a single channel ID.
|
|
89
|
-
return process.env['BG_TASK_ROOT'] || join(config.PERSISTENT_STORAGE_PATH || '/tmp', 'bg-tasks');
|
|
90
|
-
}
|
|
91
|
-
function listDirs(path) {
|
|
92
|
-
try {
|
|
93
|
-
return readdirSync(path, { withFileTypes: true })
|
|
94
|
-
.filter((entry) => entry.isDirectory())
|
|
95
|
-
.map((entry) => entry.name);
|
|
96
|
-
}
|
|
97
|
-
catch {
|
|
98
|
-
return [];
|
|
99
|
-
}
|
|
100
|
-
}
|
|
101
42
|
// Returns a "channel/taskId" label for the first live (running or unknown-state) task
|
|
102
43
|
// found, or null if none. `unknown` (no status written yet) is treated as live —
|
|
103
44
|
// deliberately conservative, per tv#317: a task a few milliseconds from its first
|
|
@@ -131,12 +72,16 @@ export function findLiveBgTask(now = Date.now()) {
|
|
|
131
72
|
// a stale recorded `pid=`, so pidAlive() alone isn't reliable evidence either. Called
|
|
132
73
|
// once at boot so every pre-restart dir is unambiguously stamped before the idle-restart
|
|
133
74
|
// gate (or `--list`) ever reads it again.
|
|
134
|
-
export function reconcileBgTasksOnBoot() {
|
|
75
|
+
export function reconcileBgTasksOnBoot(preserveTasks = new Set()) {
|
|
135
76
|
const root = bgTaskRoot();
|
|
136
77
|
let stamped = 0;
|
|
137
78
|
for (const channel of listDirs(root)) {
|
|
138
79
|
const channelPath = join(root, channel);
|
|
139
80
|
for (const taskDir of listDirs(channelPath)) {
|
|
81
|
+
// A registered task can survive a service restart on macOS. Credential
|
|
82
|
+
// startup cleanup has already removed registrations with no live owner/task.
|
|
83
|
+
if (preserveTasks.has(join(channelPath, taskDir)))
|
|
84
|
+
continue;
|
|
140
85
|
const statusPath = join(channelPath, taskDir, 'status');
|
|
141
86
|
let statusText;
|
|
142
87
|
try {
|
|
@@ -37,6 +37,12 @@ export interface BootstrapBrainInfo {
|
|
|
37
37
|
botId: string;
|
|
38
38
|
botToken: string;
|
|
39
39
|
channelId: string;
|
|
40
|
+
connectionId?: string;
|
|
41
|
+
credentialVersion?: string;
|
|
42
|
+
bindingVersion?: number;
|
|
43
|
+
channelConnectionId?: string;
|
|
44
|
+
channelCredentialVersion?: string;
|
|
45
|
+
channelBindingVersion?: number;
|
|
40
46
|
}
|
|
41
47
|
/**
|
|
42
48
|
* Seed trackedBrains at startup from the backend's authoritative brain list
|
package/dist/poller.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { startGitCredentialJanitor, registeredGitCredentialTasks } from './git-credential-session.js';
|
|
1
2
|
import { isUsageProbe, handleUsageProbe } from './usage-probe-job.js';
|
|
2
3
|
import { fetchSubscriptionPool } from './subscription-pool-client.js';
|
|
3
4
|
import { formatPoolUsageLimitMessage } from './usage-limit-messages.js';
|
|
@@ -1232,7 +1233,8 @@ export async function startPoller() {
|
|
|
1232
1233
|
logger.error(`Startup base brain failed permanently: ${startupFailureDetail(error, 'BaseBrain', [config.TEAMVIBE_POLLER_TOKEN ?? '', config.BASE_BRAIN_REPO])}`);
|
|
1233
1234
|
process.exit(1);
|
|
1234
1235
|
}
|
|
1235
|
-
|
|
1236
|
+
startGitCredentialJanitor();
|
|
1237
|
+
reconcileBgTasksOnBoot(registeredGitCredentialTasks());
|
|
1236
1238
|
if (config.IDLE_RESTART_ENABLED) {
|
|
1237
1239
|
// Defaults off (tv#317 follow-up, DevGuru review): a silent disabled gate and an
|
|
1238
1240
|
// enabled gate that just hasn't hit its threshold yet look identical in production
|