@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.
@@ -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,3 @@
1
+ import type { GitCredential } from './connections-client.js';
2
+ export declare const GIT_CREDENTIAL_HELPER: string;
3
+ export declare function buildGitCredentialEnv(base: NodeJS.ProcessEnv, credential: GitCredential, tokenFile?: string): NodeJS.ProcessEnv;
@@ -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
+ }
@@ -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;
@@ -1,4 +1,5 @@
1
- import { appendFileSync, readdirSync, readFileSync } from 'fs';
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
- reconcileBgTasksOnBoot();
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