mirror of
https://github.com/Gitlawb/openclaude.git
synced 2026-08-24 10:14:19 -05:00
fix(bg): identify sessions with persisted process markers (#2163)
This commit is contained in:
+384
-2
@@ -6,6 +6,7 @@ import { describe, expect, it } from 'bun:test'
|
|||||||
import {
|
import {
|
||||||
buildBackgroundSessionLaunch,
|
buildBackgroundSessionLaunch,
|
||||||
buildBackgroundChildProcessConfig,
|
buildBackgroundChildProcessConfig,
|
||||||
|
buildBackgroundSessionDisplayCommand,
|
||||||
confirmBackgroundSessionLaunch,
|
confirmBackgroundSessionLaunch,
|
||||||
followLogFile,
|
followLogFile,
|
||||||
killBackgroundSession,
|
killBackgroundSession,
|
||||||
@@ -20,11 +21,19 @@ import {
|
|||||||
BACKGROUND_SESSION_ID_ENV,
|
BACKGROUND_SESSION_ID_ENV,
|
||||||
BACKGROUND_SESSION_LAUNCHER_PID_ENV,
|
BACKGROUND_SESSION_LAUNCHER_PID_ENV,
|
||||||
} from './bgFinalizer.js'
|
} from './bgFinalizer.js'
|
||||||
|
import {
|
||||||
|
BACKGROUND_PROCESS_MARKER_FLAG,
|
||||||
|
backgroundProcessMarkerToken,
|
||||||
|
generateBackgroundProcessMarker,
|
||||||
|
} from './bgRouting.js'
|
||||||
import type {
|
import type {
|
||||||
BackgroundSession,
|
BackgroundSession,
|
||||||
BackgroundSessionProcessIdentity,
|
BackgroundSessionProcessIdentity,
|
||||||
} from './bgRegistry.js'
|
} from './bgRegistry.js'
|
||||||
|
|
||||||
|
const TEST_PROCESS_MARKER = 'a'.repeat(64)
|
||||||
|
const OTHER_PROCESS_MARKER = 'b'.repeat(64)
|
||||||
|
|
||||||
class TestOutput extends EventEmitter {
|
class TestOutput extends EventEmitter {
|
||||||
chunks: Buffer[] = []
|
chunks: Buffer[] = []
|
||||||
destroyed = false
|
destroyed = false
|
||||||
@@ -98,6 +107,57 @@ async function withTempFile<T>(
|
|||||||
}
|
}
|
||||||
|
|
||||||
describe('background session CLI parsing', () => {
|
describe('background session CLI parsing', () => {
|
||||||
|
it('generates a fresh bounded lower-case hex marker from 32 random bytes', () => {
|
||||||
|
const first = generateBackgroundProcessMarker(size => {
|
||||||
|
expect(size).toBe(32)
|
||||||
|
return new Uint8Array(size).fill(0x11)
|
||||||
|
})
|
||||||
|
const second = generateBackgroundProcessMarker(size =>
|
||||||
|
new Uint8Array(size).fill(0x22),
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(first).toBe('11'.repeat(32))
|
||||||
|
expect(second).toBe('22'.repeat(32))
|
||||||
|
expect(second).not.toBe(first)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('strips inherited marker options before -- but preserves prompt text after it', () => {
|
||||||
|
const inline = backgroundProcessMarkerToken(TEST_PROCESS_MARKER)
|
||||||
|
for (const inherited of [
|
||||||
|
[inline],
|
||||||
|
[BACKGROUND_PROCESS_MARKER_FLAG, TEST_PROCESS_MARKER],
|
||||||
|
]) {
|
||||||
|
const parsed = parseBackgroundInvocation([
|
||||||
|
'--bg',
|
||||||
|
...inherited,
|
||||||
|
'--print',
|
||||||
|
'--',
|
||||||
|
inline,
|
||||||
|
])
|
||||||
|
|
||||||
|
expect(parsed.prompt).toBe(inline)
|
||||||
|
expect(parsed.childArgs).toEqual(['--print', '--', inline])
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
it('preserves marker-looking required-option values', () => {
|
||||||
|
const markerLookingValue = backgroundProcessMarkerToken(TEST_PROCESS_MARKER)
|
||||||
|
const parsed = parseBackgroundInvocation([
|
||||||
|
'--bg',
|
||||||
|
'--system-prompt',
|
||||||
|
markerLookingValue,
|
||||||
|
'actual prompt',
|
||||||
|
])
|
||||||
|
|
||||||
|
expect(parsed.prompt).toBe('actual prompt')
|
||||||
|
expect(parsed.childArgs).toEqual([
|
||||||
|
'--system-prompt',
|
||||||
|
markerLookingValue,
|
||||||
|
'--print',
|
||||||
|
'actual prompt',
|
||||||
|
])
|
||||||
|
})
|
||||||
|
|
||||||
it('builds a print-mode child command and preserves provider/model flags', () => {
|
it('builds a print-mode child command and preserves provider/model flags', () => {
|
||||||
const parsed = parseBackgroundInvocation([
|
const parsed = parseBackgroundInvocation([
|
||||||
'--provider',
|
'--provider',
|
||||||
@@ -471,6 +531,7 @@ describe('background session CLI parsing', () => {
|
|||||||
sessionName: 'tests',
|
sessionName: 'tests',
|
||||||
stdoutLogPath: '/tmp/bg.out.log',
|
stdoutLogPath: '/tmp/bg.out.log',
|
||||||
backgroundSessionId: 'bg-tests',
|
backgroundSessionId: 'bg-tests',
|
||||||
|
processMarker: TEST_PROCESS_MARKER,
|
||||||
launcherPid: 700,
|
launcherPid: 700,
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -479,6 +540,7 @@ describe('background session CLI parsing', () => {
|
|||||||
'--max-old-space-size=8192',
|
'--max-old-space-size=8192',
|
||||||
'--expose-gc',
|
'--expose-gc',
|
||||||
'/repo/bin/openclaude',
|
'/repo/bin/openclaude',
|
||||||
|
backgroundProcessMarkerToken(TEST_PROCESS_MARKER),
|
||||||
'--print',
|
'--print',
|
||||||
'fix failing tests',
|
'fix failing tests',
|
||||||
])
|
])
|
||||||
@@ -503,6 +565,7 @@ describe('background session CLI parsing', () => {
|
|||||||
},
|
},
|
||||||
stdoutLogPath: '/tmp/bg.out.log',
|
stdoutLogPath: '/tmp/bg.out.log',
|
||||||
backgroundSessionId: 'bg-no-wrapper',
|
backgroundSessionId: 'bg-no-wrapper',
|
||||||
|
processMarker: TEST_PROCESS_MARKER,
|
||||||
launcherPid: 701,
|
launcherPid: 701,
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -522,6 +585,7 @@ describe('background session CLI parsing', () => {
|
|||||||
processEnv: {},
|
processEnv: {},
|
||||||
stdoutLogPath: '/tmp/bg.out.log',
|
stdoutLogPath: '/tmp/bg.out.log',
|
||||||
backgroundSessionId: 'bg-bun-owner',
|
backgroundSessionId: 'bg-bun-owner',
|
||||||
|
processMarker: TEST_PROCESS_MARKER,
|
||||||
launcherPid: 702,
|
launcherPid: 702,
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -531,6 +595,107 @@ describe('background session CLI parsing', () => {
|
|||||||
expect(config.env[BACKGROUND_SESSION_LAUNCHER_PID_ENV]).toBe('702')
|
expect(config.env[BACKGROUND_SESSION_LAUNCHER_PID_ENV]).toBe('702')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('injects one fresh marker immediately after a spaced entrypoint', () => {
|
||||||
|
const inherited = backgroundProcessMarkerToken(OTHER_PROCESS_MARKER)
|
||||||
|
const promptMarker = backgroundProcessMarkerToken(OTHER_PROCESS_MARKER)
|
||||||
|
const config = buildBackgroundChildProcessConfig({
|
||||||
|
execPath: 'C:\\Program Files\\nodejs\\node.exe',
|
||||||
|
execArgv: ['--expose-gc'],
|
||||||
|
entrypoint: 'C:\\repo path\\dist\\cli.mjs',
|
||||||
|
childArgs: [
|
||||||
|
inherited,
|
||||||
|
'--provider',
|
||||||
|
'openai',
|
||||||
|
'--model',
|
||||||
|
'gpt-5',
|
||||||
|
'--session-id',
|
||||||
|
'550e8400-e29b-41d4-a716-446655440000',
|
||||||
|
'--from-pr',
|
||||||
|
'1642',
|
||||||
|
'--print',
|
||||||
|
'--',
|
||||||
|
promptMarker,
|
||||||
|
],
|
||||||
|
processEnv: {},
|
||||||
|
stdoutLogPath: 'C:\\logs path\\bg.out.log',
|
||||||
|
backgroundSessionId: 'bg-spaced-paths',
|
||||||
|
processMarker: TEST_PROCESS_MARKER,
|
||||||
|
launcherPid: 703,
|
||||||
|
})
|
||||||
|
const markerToken = backgroundProcessMarkerToken(TEST_PROCESS_MARKER)
|
||||||
|
const entrypointIndex = config.args.indexOf('C:\\repo path\\dist\\cli.mjs')
|
||||||
|
|
||||||
|
expect(config.args[entrypointIndex + 1]).toBe(markerToken)
|
||||||
|
expect(config.args.filter(arg => arg === markerToken)).toHaveLength(1)
|
||||||
|
expect(config.args.slice(0, config.args.indexOf('--'))).not.toContain(
|
||||||
|
inherited,
|
||||||
|
)
|
||||||
|
expect(config.args.slice(config.args.indexOf('--'))).toEqual([
|
||||||
|
'--',
|
||||||
|
promptMarker,
|
||||||
|
])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('keeps marker-looking required-option values during defensive injection', () => {
|
||||||
|
const markerLookingValue = backgroundProcessMarkerToken(OTHER_PROCESS_MARKER)
|
||||||
|
const config = buildBackgroundChildProcessConfig({
|
||||||
|
execPath: '/usr/bin/node',
|
||||||
|
execArgv: [],
|
||||||
|
entrypoint: '/repo/bin/openclaude',
|
||||||
|
childArgs: [
|
||||||
|
'--system-prompt',
|
||||||
|
markerLookingValue,
|
||||||
|
'--print',
|
||||||
|
'work',
|
||||||
|
],
|
||||||
|
processEnv: {},
|
||||||
|
stdoutLogPath: '/tmp/bg.out.log',
|
||||||
|
backgroundSessionId: 'bg-marker-looking-value',
|
||||||
|
processMarker: TEST_PROCESS_MARKER,
|
||||||
|
launcherPid: 704,
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(config.args).toEqual([
|
||||||
|
'--max-old-space-size=8192',
|
||||||
|
'--expose-gc',
|
||||||
|
'/repo/bin/openclaude',
|
||||||
|
backgroundProcessMarkerToken(TEST_PROCESS_MARKER),
|
||||||
|
'--system-prompt',
|
||||||
|
markerLookingValue,
|
||||||
|
'--print',
|
||||||
|
'work',
|
||||||
|
])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('omits only the internal marker from the displayed launch command', () => {
|
||||||
|
const markerToken = backgroundProcessMarkerToken(TEST_PROCESS_MARKER)
|
||||||
|
const promptMarker = backgroundProcessMarkerToken(OTHER_PROCESS_MARKER)
|
||||||
|
|
||||||
|
expect(
|
||||||
|
buildBackgroundSessionDisplayCommand(
|
||||||
|
[
|
||||||
|
'node',
|
||||||
|
'/repo path/dist/cli.mjs',
|
||||||
|
markerToken,
|
||||||
|
'--provider',
|
||||||
|
'openai',
|
||||||
|
'--print',
|
||||||
|
'--',
|
||||||
|
promptMarker,
|
||||||
|
],
|
||||||
|
TEST_PROCESS_MARKER,
|
||||||
|
),
|
||||||
|
).toEqual([
|
||||||
|
'node',
|
||||||
|
'/repo path/dist/cli.mjs',
|
||||||
|
'--provider',
|
||||||
|
'openai',
|
||||||
|
'--print',
|
||||||
|
'--',
|
||||||
|
promptMarker,
|
||||||
|
])
|
||||||
|
})
|
||||||
|
|
||||||
it('escalates process-tree termination and waits for exit before returning', async () => {
|
it('escalates process-tree termination and waits for exit before returning', async () => {
|
||||||
const signals: Array<string | number | undefined> = []
|
const signals: Array<string | number | undefined> = []
|
||||||
let aliveChecks = 0
|
let aliveChecks = 0
|
||||||
@@ -679,6 +844,17 @@ describe('background session process termination safety', () => {
|
|||||||
stdoutLogPath: '/tmp/stdout.log',
|
stdoutLogPath: '/tmp/stdout.log',
|
||||||
stderrLogPath: '/tmp/stderr.log',
|
stderrLogPath: '/tmp/stderr.log',
|
||||||
}
|
}
|
||||||
|
const markedSession: BackgroundSession = {
|
||||||
|
...session,
|
||||||
|
processMarker: TEST_PROCESS_MARKER,
|
||||||
|
command: [
|
||||||
|
'node',
|
||||||
|
'openclaude',
|
||||||
|
backgroundProcessMarkerToken(TEST_PROCESS_MARKER),
|
||||||
|
'--session-id',
|
||||||
|
'conversation-safety',
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
function identity(
|
function identity(
|
||||||
state: BackgroundSessionProcessIdentity['state'],
|
state: BackgroundSessionProcessIdentity['state'],
|
||||||
@@ -716,6 +892,30 @@ describe('background session process termination safety', () => {
|
|||||||
])
|
])
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('signals a marked session only after its exact token is freshly verified', async () => {
|
||||||
|
const calls: string[] = []
|
||||||
|
let aliveChecks = 0
|
||||||
|
|
||||||
|
await terminateBackgroundSessionProcessTree(markedSession, {
|
||||||
|
isProcessAlive: () => ++aliveChecks <= 2,
|
||||||
|
getProcessCommand: pid => {
|
||||||
|
calls.push(`verify:${pid}`)
|
||||||
|
return `node openclaude ${backgroundProcessMarkerToken(TEST_PROCESS_MARKER)} --session-id conversation-safety`
|
||||||
|
},
|
||||||
|
killTree: async (pid, signal) => {
|
||||||
|
calls.push(`signal:${pid}:${signal}`)
|
||||||
|
},
|
||||||
|
sleep: async () => {},
|
||||||
|
termGraceMs: 1,
|
||||||
|
pollIntervalMs: 1,
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(calls).toEqual([
|
||||||
|
'verify:4242',
|
||||||
|
'signal:4242:SIGTERM',
|
||||||
|
])
|
||||||
|
})
|
||||||
|
|
||||||
it('refuses a mismatched identity before SIGTERM and does not mark killed', async () => {
|
it('refuses a mismatched identity before SIGTERM and does not mark killed', async () => {
|
||||||
const calls: string[] = []
|
const calls: string[] = []
|
||||||
|
|
||||||
@@ -744,12 +944,37 @@ describe('background session process termination safety', () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
expect(String(refusal)).toContain('refused to signal an unverified process')
|
expect(String(refusal)).toContain('refused to signal an unverified process')
|
||||||
|
expect(String(refusal)).toContain(
|
||||||
|
'This older background session could not be verified safely',
|
||||||
|
)
|
||||||
|
expect(String(refusal)).toContain('terminate PID 4242 manually')
|
||||||
expect(String(refusal)).not.toContain('private prompt value')
|
expect(String(refusal)).not.toContain('private prompt value')
|
||||||
expect(calls).toEqual([])
|
expect(calls).toEqual([])
|
||||||
})
|
})
|
||||||
|
|
||||||
it('does not verify or signal terminal records when a reused PID would match', async () => {
|
it('does not signal or mark a marked session whose token mismatches', async () => {
|
||||||
for (const status of ['stale', 'killed', 'exited', 'failed'] as const) {
|
const calls: string[] = []
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
killBackgroundSession(markedSession, {
|
||||||
|
isProcessAlive: () => true,
|
||||||
|
getProcessCommand: () =>
|
||||||
|
`node openclaude ${backgroundProcessMarkerToken(OTHER_PROCESS_MARKER)} --session-id conversation-safety`,
|
||||||
|
killTree: async (_pid, signal) => {
|
||||||
|
calls.push(`signal:${signal}`)
|
||||||
|
},
|
||||||
|
markKilled: async selected => {
|
||||||
|
calls.push(`mark:${selected.id}`)
|
||||||
|
return { ...selected, status: 'killed' }
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
).rejects.toThrow('refused to signal an unverified process')
|
||||||
|
|
||||||
|
expect(calls).toEqual([])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does not verify or signal authoritative terminal records when a reused PID would match', async () => {
|
||||||
|
for (const status of ['killed', 'exited', 'failed'] as const) {
|
||||||
const calls: string[] = []
|
const calls: string[] = []
|
||||||
|
|
||||||
const killed = await killBackgroundSession(
|
const killed = await killBackgroundSession(
|
||||||
@@ -774,6 +999,110 @@ describe('background session process termination safety', () => {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('fails closed instead of marking a stale session whose identity mismatches', async () => {
|
||||||
|
const calls: string[] = []
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
killBackgroundSession(
|
||||||
|
{ ...markedSession, status: 'stale' },
|
||||||
|
{
|
||||||
|
isProcessAlive: () => true,
|
||||||
|
getProcessCommand: () =>
|
||||||
|
`node openclaude ${backgroundProcessMarkerToken(OTHER_PROCESS_MARKER)} --session-id conversation-safety`,
|
||||||
|
killTree: async (_pid, signal) => {
|
||||||
|
calls.push(`signal:${signal}`)
|
||||||
|
},
|
||||||
|
markKilled: async selected => {
|
||||||
|
calls.push(`mark:${selected.id}`)
|
||||||
|
return { ...selected, status: 'killed' }
|
||||||
|
},
|
||||||
|
},
|
||||||
|
),
|
||||||
|
).rejects.toThrow('refused to signal an unverified process')
|
||||||
|
|
||||||
|
expect(calls).toEqual([])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('refuses a matching legacy identity after the session was already stale', async () => {
|
||||||
|
const calls: string[] = []
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
killBackgroundSession(
|
||||||
|
{ ...session, status: 'stale' },
|
||||||
|
{
|
||||||
|
isProcessAlive: () => true,
|
||||||
|
getProcessCommand: () =>
|
||||||
|
'node openclaude --resume conversation-safety',
|
||||||
|
killTree: async (_pid, signal) => {
|
||||||
|
calls.push(`signal:${signal}`)
|
||||||
|
},
|
||||||
|
markKilled: async selected => {
|
||||||
|
calls.push(`mark:${selected.id}`)
|
||||||
|
return { ...selected, status: 'killed' }
|
||||||
|
},
|
||||||
|
},
|
||||||
|
),
|
||||||
|
).rejects.toThrow('PID ownership cannot be re-established safely')
|
||||||
|
|
||||||
|
expect(calls).toEqual([])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('allows an exact marked identity to authorize a previously stale session', async () => {
|
||||||
|
const calls: string[] = []
|
||||||
|
const states: BackgroundSessionProcessIdentity['state'][] = [
|
||||||
|
'matches',
|
||||||
|
'matches',
|
||||||
|
]
|
||||||
|
|
||||||
|
const killed = await killBackgroundSession(
|
||||||
|
{ ...markedSession, status: 'stale' },
|
||||||
|
{
|
||||||
|
isProcessAlive: () => false,
|
||||||
|
verifySessionIdentity: () => {
|
||||||
|
const state = states.shift()!
|
||||||
|
calls.push(`verify:${state}`)
|
||||||
|
return identity(state)
|
||||||
|
},
|
||||||
|
killTree: async (_pid, signal) => {
|
||||||
|
calls.push(`signal:${signal}`)
|
||||||
|
},
|
||||||
|
markKilled: async selected => {
|
||||||
|
calls.push(`mark:${selected.id}`)
|
||||||
|
return { ...selected, status: 'killed' }
|
||||||
|
},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(killed.status).toBe('killed')
|
||||||
|
expect(calls).toEqual([
|
||||||
|
'verify:matches',
|
||||||
|
'verify:matches',
|
||||||
|
'signal:SIGTERM',
|
||||||
|
'mark:bg-safety',
|
||||||
|
])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('marks a stale session killed without signalling when its PID is gone', async () => {
|
||||||
|
const calls: string[] = []
|
||||||
|
|
||||||
|
const killed = await killBackgroundSession(
|
||||||
|
{ ...session, status: 'stale' },
|
||||||
|
{
|
||||||
|
isProcessAlive: () => false,
|
||||||
|
killTree: async (_pid, signal) => {
|
||||||
|
calls.push(`signal:${signal}`)
|
||||||
|
},
|
||||||
|
markKilled: async selected => {
|
||||||
|
calls.push(`mark:${selected.id}`)
|
||||||
|
return { ...selected, status: 'killed' }
|
||||||
|
},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(killed.status).toBe('killed')
|
||||||
|
expect(calls).toEqual(['mark:bg-safety'])
|
||||||
|
})
|
||||||
|
|
||||||
it('freshly verifies an unknown session that becomes readable before killing', async () => {
|
it('freshly verifies an unknown session that becomes readable before killing', async () => {
|
||||||
const calls: string[] = []
|
const calls: string[] = []
|
||||||
const states: BackgroundSessionProcessIdentity['state'][] = [
|
const states: BackgroundSessionProcessIdentity['state'][] = [
|
||||||
@@ -866,6 +1195,39 @@ describe('background session process termination safety', () => {
|
|||||||
])
|
])
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('does not send SIGKILL when a marked token changes after SIGTERM', async () => {
|
||||||
|
const calls: string[] = []
|
||||||
|
const commands = [
|
||||||
|
`node openclaude ${backgroundProcessMarkerToken(TEST_PROCESS_MARKER)} --session-id conversation-safety`,
|
||||||
|
`node openclaude ${backgroundProcessMarkerToken(OTHER_PROCESS_MARKER)} --session-id conversation-safety`,
|
||||||
|
]
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
terminateBackgroundSessionProcessTree(markedSession, {
|
||||||
|
isProcessAlive: () => true,
|
||||||
|
getProcessCommand: () => {
|
||||||
|
calls.push('verify')
|
||||||
|
return commands.shift()!
|
||||||
|
},
|
||||||
|
killTree: async (_pid, signal) => {
|
||||||
|
calls.push(`signal:${signal}`)
|
||||||
|
},
|
||||||
|
sleep: async () => {
|
||||||
|
calls.push('sleep')
|
||||||
|
},
|
||||||
|
termGraceMs: 1,
|
||||||
|
pollIntervalMs: 1,
|
||||||
|
}),
|
||||||
|
).rejects.toThrow('refused to signal an unverified process')
|
||||||
|
|
||||||
|
expect(calls).toEqual([
|
||||||
|
'verify',
|
||||||
|
'signal:SIGTERM',
|
||||||
|
'sleep',
|
||||||
|
'verify',
|
||||||
|
])
|
||||||
|
})
|
||||||
|
|
||||||
it('fails closed when the live process identity is unreadable', async () => {
|
it('fails closed when the live process identity is unreadable', async () => {
|
||||||
const signals: Array<string | number> = []
|
const signals: Array<string | number> = []
|
||||||
|
|
||||||
@@ -882,6 +1244,26 @@ describe('background session process termination safety', () => {
|
|||||||
expect(signals).toEqual([])
|
expect(signals).toEqual([])
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('does not mark a marked session killed when identity is unreadable', async () => {
|
||||||
|
const calls: string[] = []
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
killBackgroundSession(markedSession, {
|
||||||
|
isProcessAlive: () => true,
|
||||||
|
getProcessCommand: () => null,
|
||||||
|
killTree: async (_pid, signal) => {
|
||||||
|
calls.push(`signal:${signal}`)
|
||||||
|
},
|
||||||
|
markKilled: async selected => {
|
||||||
|
calls.push(`mark:${selected.id}`)
|
||||||
|
return { ...selected, status: 'killed' }
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
).rejects.toThrow('refused to signal an unverified process')
|
||||||
|
|
||||||
|
expect(calls).toEqual([])
|
||||||
|
})
|
||||||
|
|
||||||
it('refuses signalling when liveness becomes unreadable during command lookup', async () => {
|
it('refuses signalling when liveness becomes unreadable during command lookup', async () => {
|
||||||
const calls: string[] = []
|
const calls: string[] = []
|
||||||
let probes = 0
|
let probes = 0
|
||||||
|
|||||||
+52
-6
@@ -25,8 +25,11 @@ import {
|
|||||||
type BackgroundSessionProcessIdentityOptions,
|
type BackgroundSessionProcessIdentityOptions,
|
||||||
} from './bgRegistry.js'
|
} from './bgRegistry.js'
|
||||||
import {
|
import {
|
||||||
|
backgroundProcessMarkerToken,
|
||||||
BACKGROUND_SESSION_ID_ENV,
|
BACKGROUND_SESSION_ID_ENV,
|
||||||
BACKGROUND_SESSION_LAUNCHER_PID_ENV,
|
BACKGROUND_SESSION_LAUNCHER_PID_ENV,
|
||||||
|
generateBackgroundProcessMarker,
|
||||||
|
stripBackgroundProcessMarkerArgs,
|
||||||
} from './bgRouting.js'
|
} from './bgRouting.js'
|
||||||
|
|
||||||
export type ParsedBackgroundInvocation = {
|
export type ParsedBackgroundInvocation = {
|
||||||
@@ -56,6 +59,7 @@ export type BuildBackgroundChildProcessConfigInput = {
|
|||||||
sessionName?: string
|
sessionName?: string
|
||||||
stdoutLogPath: string
|
stdoutLogPath: string
|
||||||
backgroundSessionId: string
|
backgroundSessionId: string
|
||||||
|
processMarker: string
|
||||||
launcherPid?: number
|
launcherPid?: number
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -233,6 +237,7 @@ export function buildBackgroundChildProcessConfig(
|
|||||||
// launcher otherwise relaunches itself before finalizer ownership is checked.
|
// launcher otherwise relaunches itself before finalizer ownership is checked.
|
||||||
// Node-only heap flags are still supplied by safeNodeExecArgvForBackground.
|
// Node-only heap flags are still supplied by safeNodeExecArgvForBackground.
|
||||||
env[HEAP_RELAUNCHED_ENV] = '1'
|
env[HEAP_RELAUNCHED_ENV] = '1'
|
||||||
|
const childArgs = stripBackgroundProcessMarkerArgs(input.childArgs)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
command: input.execPath,
|
command: input.execPath,
|
||||||
@@ -243,7 +248,8 @@ export function buildBackgroundChildProcessConfig(
|
|||||||
input.processEnv,
|
input.processEnv,
|
||||||
),
|
),
|
||||||
input.entrypoint,
|
input.entrypoint,
|
||||||
...input.childArgs,
|
backgroundProcessMarkerToken(input.processMarker),
|
||||||
|
...childArgs,
|
||||||
],
|
],
|
||||||
env,
|
env,
|
||||||
}
|
}
|
||||||
@@ -432,7 +438,7 @@ export async function buildBackgroundSessionLaunch(
|
|||||||
export function parseBackgroundInvocation(
|
export function parseBackgroundInvocation(
|
||||||
args: string[],
|
args: string[],
|
||||||
): ParsedBackgroundInvocation {
|
): ParsedBackgroundInvocation {
|
||||||
let childArgs = stripBackgroundFlag(args)
|
let childArgs = stripBackgroundProcessMarkerArgs(stripBackgroundFlag(args))
|
||||||
const name = findSessionName(childArgs)?.trim() || undefined
|
const name = findSessionName(childArgs)?.trim() || undefined
|
||||||
const promptIndex = findPromptIndex(childArgs)
|
const promptIndex = findPromptIndex(childArgs)
|
||||||
const prompt = promptIndex === -1 ? undefined : childArgs[promptIndex]
|
const prompt = promptIndex === -1 ? undefined : childArgs[promptIndex]
|
||||||
@@ -482,6 +488,21 @@ function formatCommand(command: string[]): string {
|
|||||||
.join(' ')
|
.join(' ')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function buildBackgroundSessionDisplayCommand(
|
||||||
|
command: string[],
|
||||||
|
processMarker: string,
|
||||||
|
): string[] {
|
||||||
|
const markerToken = backgroundProcessMarkerToken(processMarker)
|
||||||
|
const delimiterIndex = command.indexOf('--')
|
||||||
|
const optionEnd = delimiterIndex === -1 ? command.length : delimiterIndex
|
||||||
|
const markerIndex = command.findIndex(
|
||||||
|
(arg, index) => index < optionEnd && arg === markerToken,
|
||||||
|
)
|
||||||
|
return markerIndex === -1
|
||||||
|
? [...command]
|
||||||
|
: [...command.slice(0, markerIndex), ...command.slice(markerIndex + 1)]
|
||||||
|
}
|
||||||
|
|
||||||
function printSessionTable(
|
function printSessionTable(
|
||||||
sessions: Awaited<ReturnType<typeof listBackgroundSessions>>,
|
sessions: Awaited<ReturnType<typeof listBackgroundSessions>>,
|
||||||
): void {
|
): void {
|
||||||
@@ -839,8 +860,12 @@ function unverifiedProcessError(
|
|||||||
session: BackgroundSession,
|
session: BackgroundSession,
|
||||||
reason: string,
|
reason: string,
|
||||||
): Error {
|
): Error {
|
||||||
|
const action =
|
||||||
|
session.processMarker === undefined
|
||||||
|
? `This older background session could not be verified safely. Restart it to use stronger process identity, or terminate PID ${session.pid} manually after confirming ownership.`
|
||||||
|
: 'Re-run `openclaude ps` and retry after confirming the session identity.'
|
||||||
return new Error(
|
return new Error(
|
||||||
`OpenClaude refused to signal an unverified process for background session ${session.id} (PID ${session.pid}): ${reason}. Re-run \`openclaude ps\` and retry after confirming the session identity.`,
|
`OpenClaude refused to signal an unverified process for background session ${session.id} (PID ${session.pid}): ${reason}. ${action}`,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -921,10 +946,23 @@ export async function killBackgroundSession(
|
|||||||
(async (selected: BackgroundSession) =>
|
(async (selected: BackgroundSession) =>
|
||||||
await markBackgroundSessionKilled(selected.id))
|
await markBackgroundSessionKilled(selected.id))
|
||||||
|
|
||||||
if (isTerminalBackgroundSession(session)) return await markKilled(session)
|
if (session.status !== 'stale' && isTerminalBackgroundSession(session)) {
|
||||||
|
return await markKilled(session)
|
||||||
|
}
|
||||||
|
|
||||||
const identity = verifySelectedBackgroundSessionIdentity(session, options)
|
const identity = verifySelectedBackgroundSessionIdentity(session, options)
|
||||||
if (authorizeBackgroundSessionSignal(session, identity) === 'matches') {
|
const authorization = authorizeBackgroundSessionSignal(session, identity)
|
||||||
|
if (
|
||||||
|
authorization === 'matches' &&
|
||||||
|
session.status === 'stale' &&
|
||||||
|
session.processMarker === undefined
|
||||||
|
) {
|
||||||
|
throw unverifiedProcessError(
|
||||||
|
session,
|
||||||
|
'this older session was already stale, so its PID ownership cannot be re-established safely',
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (authorization === 'matches') {
|
||||||
await terminateBackgroundSessionProcessTree(session, options)
|
await terminateBackgroundSessionProcessTree(session, options)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1032,6 +1070,7 @@ export async function handleBgFlag(args: string[]): Promise<void> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const id = backgroundSessionId()
|
const id = backgroundSessionId()
|
||||||
|
const processMarker = generateBackgroundProcessMarker()
|
||||||
const { childArgs, sessionId } = await buildBackgroundSessionLaunch(
|
const { childArgs, sessionId } = await buildBackgroundSessionLaunch(
|
||||||
parsed.childArgs,
|
parsed.childArgs,
|
||||||
randomUUID(),
|
randomUUID(),
|
||||||
@@ -1053,6 +1092,7 @@ export async function handleBgFlag(args: string[]): Promise<void> {
|
|||||||
sessionName: parsed.name,
|
sessionName: parsed.name,
|
||||||
stdoutLogPath: logPaths.stdoutLogPath,
|
stdoutLogPath: logPaths.stdoutLogPath,
|
||||||
backgroundSessionId: id,
|
backgroundSessionId: id,
|
||||||
|
processMarker,
|
||||||
launcherPid: process.pid,
|
launcherPid: process.pid,
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -1108,6 +1148,7 @@ export async function handleBgFlag(args: string[]): Promise<void> {
|
|||||||
provider: findFlagValue(childArgs, '--provider'),
|
provider: findFlagValue(childArgs, '--provider'),
|
||||||
model: findFlagValue(childArgs, '--model'),
|
model: findFlagValue(childArgs, '--model'),
|
||||||
sessionId,
|
sessionId,
|
||||||
|
processMarker,
|
||||||
stdoutLogPath: logPaths.stdoutLogPath,
|
stdoutLogPath: logPaths.stdoutLogPath,
|
||||||
stderrLogPath: logPaths.stderrLogPath,
|
stderrLogPath: logPaths.stderrLogPath,
|
||||||
logFilesPrecreated: true,
|
logFilesPrecreated: true,
|
||||||
@@ -1131,6 +1172,11 @@ export async function handleBgFlag(args: string[]): Promise<void> {
|
|||||||
console.log(`Logs: ${session.stdoutLogPath}`)
|
console.log(`Logs: ${session.stdoutLogPath}`)
|
||||||
console.log(`Follow: openclaude logs ${session.id} -f`)
|
console.log(`Follow: openclaude logs ${session.id} -f`)
|
||||||
console.log(
|
console.log(
|
||||||
`Command: ${formatCommand([basename(childConfig.command), ...childConfig.args])}`,
|
`Command: ${formatCommand(
|
||||||
|
buildBackgroundSessionDisplayCommand([
|
||||||
|
basename(childConfig.command),
|
||||||
|
...childConfig.args,
|
||||||
|
], processMarker),
|
||||||
|
)}`,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,10 +10,16 @@ import {
|
|||||||
prepareBackgroundSessionFinalizer,
|
prepareBackgroundSessionFinalizer,
|
||||||
} from './bgFinalizer.js'
|
} from './bgFinalizer.js'
|
||||||
import { buildBackgroundChildProcessConfig } from './bg.js'
|
import { buildBackgroundChildProcessConfig } from './bg.js'
|
||||||
|
import {
|
||||||
|
BACKGROUND_PROCESS_MARKER_FLAG,
|
||||||
|
backgroundProcessMarkerToken,
|
||||||
|
isValidBackgroundProcessMarker,
|
||||||
|
} from './bgRouting.js'
|
||||||
import {
|
import {
|
||||||
_setBackgroundSessionsRootForTesting,
|
_setBackgroundSessionsRootForTesting,
|
||||||
listBackgroundSessions,
|
listBackgroundSessions,
|
||||||
refreshBackgroundSessionStatuses,
|
refreshBackgroundSessionStatuses,
|
||||||
|
verifyBackgroundSessionProcessIdentity,
|
||||||
type BackgroundSession,
|
type BackgroundSession,
|
||||||
} from './bgRegistry.js'
|
} from './bgRegistry.js'
|
||||||
|
|
||||||
@@ -343,6 +349,7 @@ describe('background session finalizer', () => {
|
|||||||
id: string,
|
id: string,
|
||||||
args: string[],
|
args: string[],
|
||||||
): Promise<number> {
|
): Promise<number> {
|
||||||
|
const processMarker = 'c'.repeat(64)
|
||||||
const processEnv: NodeJS.ProcessEnv = {
|
const processEnv: NodeJS.ProcessEnv = {
|
||||||
...process.env,
|
...process.env,
|
||||||
OPENCLAUDE_CONFIG_DIR: configDir,
|
OPENCLAUDE_CONFIG_DIR: configDir,
|
||||||
@@ -356,6 +363,7 @@ describe('background session finalizer', () => {
|
|||||||
processEnv,
|
processEnv,
|
||||||
stdoutLogPath: join(configDir, `${id}.out.log`),
|
stdoutLogPath: join(configDir, `${id}.out.log`),
|
||||||
backgroundSessionId: id,
|
backgroundSessionId: id,
|
||||||
|
processMarker,
|
||||||
launcherPid: process.pid,
|
launcherPid: process.pid,
|
||||||
})
|
})
|
||||||
const child = spawn(childConfig.command, childConfig.args, {
|
const child = spawn(childConfig.command, childConfig.args, {
|
||||||
@@ -367,7 +375,11 @@ describe('background session finalizer', () => {
|
|||||||
await mkdir(join(sessionsRoot, 'sessions'), { recursive: true })
|
await mkdir(join(sessionsRoot, 'sessions'), { recursive: true })
|
||||||
await writeFile(
|
await writeFile(
|
||||||
join(sessionsRoot, 'sessions', `${id}.json`),
|
join(sessionsRoot, 'sessions', `${id}.json`),
|
||||||
JSON.stringify(ownedSession(id, child.pid)),
|
JSON.stringify({
|
||||||
|
...ownedSession(id, child.pid),
|
||||||
|
processMarker,
|
||||||
|
command: [childConfig.command, ...childConfig.args],
|
||||||
|
}),
|
||||||
)
|
)
|
||||||
const [code] = (await once(child, 'exit')) as [number]
|
const [code] = (await once(child, 'exit')) as [number]
|
||||||
return code
|
return code
|
||||||
@@ -484,11 +496,73 @@ describe('background session finalizer', () => {
|
|||||||
exitCode: expectation.exitCode,
|
exitCode: expectation.exitCode,
|
||||||
terminalReason: 'exit_code',
|
terminalReason: 'exit_code',
|
||||||
})
|
})
|
||||||
|
expect(isValidBackgroundProcessMarker(session.processMarker)).toBe(true)
|
||||||
|
expect(session.command).toContain(
|
||||||
|
backgroundProcessMarkerToken(session.processMarker!),
|
||||||
|
)
|
||||||
|
expect(stdout).not.toContain(BACKGROUND_PROCESS_MARKER_FLAG)
|
||||||
expect(await Bun.file(session.stdoutLogPath).exists()).toBe(true)
|
expect(await Bun.file(session.stdoutLogPath).exists()).toBe(true)
|
||||||
expect(await Bun.file(session.stderrLogPath).exists()).toBe(true)
|
expect(await Bun.file(session.stderrLogPath).exists()).toBe(true)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
it.skipIf(process.platform === 'win32')(
|
||||||
|
'recognizes a live built marked child through the production command probe',
|
||||||
|
async () => {
|
||||||
|
const id = 'bg-built-live-marker'
|
||||||
|
const processMarker = 'e'.repeat(64)
|
||||||
|
const processEnv: NodeJS.ProcessEnv = {
|
||||||
|
...process.env,
|
||||||
|
OPENCLAUDE_CONFIG_DIR: configDir,
|
||||||
|
}
|
||||||
|
delete processEnv.OPENCLAUDE_DISABLE_CLI_ENTRYPOINT_AUTO_RUN
|
||||||
|
const childConfig = buildBackgroundChildProcessConfig({
|
||||||
|
execPath: 'node',
|
||||||
|
execArgv: [],
|
||||||
|
entrypoint: installedLauncherPath,
|
||||||
|
childArgs: ['--version'],
|
||||||
|
processEnv,
|
||||||
|
stdoutLogPath: join(configDir, `${id}.out.log`),
|
||||||
|
backgroundSessionId: id,
|
||||||
|
processMarker,
|
||||||
|
launcherPid: process.pid,
|
||||||
|
})
|
||||||
|
const child = spawn(childConfig.command, childConfig.args, {
|
||||||
|
env: childConfig.env,
|
||||||
|
stdio: ['ignore', 'ignore', 'ignore'],
|
||||||
|
})
|
||||||
|
if (!child.pid) throw new Error('built CLI fixture did not start')
|
||||||
|
const exit = once(child, 'exit')
|
||||||
|
let forceTimer: ReturnType<typeof setTimeout> | undefined
|
||||||
|
|
||||||
|
try {
|
||||||
|
expect(child.kill('SIGSTOP')).toBe(true)
|
||||||
|
const session: BackgroundSession = {
|
||||||
|
...ownedSession(id, child.pid),
|
||||||
|
processMarker,
|
||||||
|
command: [childConfig.command, ...childConfig.args],
|
||||||
|
}
|
||||||
|
await mkdir(join(sessionsRoot, 'sessions'), { recursive: true })
|
||||||
|
await writeFile(
|
||||||
|
join(sessionsRoot, 'sessions', `${id}.json`),
|
||||||
|
JSON.stringify(session),
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(verifyBackgroundSessionProcessIdentity(session).state).toBe(
|
||||||
|
'matches',
|
||||||
|
)
|
||||||
|
expect(await refreshBackgroundSessionStatuses()).toMatchObject([
|
||||||
|
{ id, status: 'running', processMarker },
|
||||||
|
])
|
||||||
|
} finally {
|
||||||
|
child.kill('SIGCONT')
|
||||||
|
forceTimer = setTimeout(() => child.kill('SIGKILL'), 5_000)
|
||||||
|
await exit
|
||||||
|
clearTimeout(forceTimer)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
it('keeps the installed launcher PID stable and shows outcomes truthfully in ps', async () => {
|
it('keeps the installed launcher PID stable and shows outcomes truthfully in ps', async () => {
|
||||||
expect(await runBuiltCliSession('bg-built-success', ['--version'])).toBe(0)
|
expect(await runBuiltCliSession('bg-built-success', ['--version'])).toBe(0)
|
||||||
expect(
|
expect(
|
||||||
@@ -520,6 +594,12 @@ describe('background session finalizer', () => {
|
|||||||
expect(stderr).toBe('')
|
expect(stderr).toBe('')
|
||||||
expect(stdout).toMatch(/bg-built-success\s+exited/)
|
expect(stdout).toMatch(/bg-built-success\s+exited/)
|
||||||
expect(stdout).toMatch(/bg-built-failure\s+failed/)
|
expect(stdout).toMatch(/bg-built-failure\s+failed/)
|
||||||
|
for (const session of await listBackgroundSessions()) {
|
||||||
|
expect(session.processMarker).toBe('c'.repeat(64))
|
||||||
|
expect(session.command).toContain(
|
||||||
|
backgroundProcessMarkerToken(session.processMarker!),
|
||||||
|
)
|
||||||
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
it.skipIf(process.platform === 'win32')(
|
it.skipIf(process.platform === 'win32')(
|
||||||
|
|||||||
+314
-2
@@ -17,6 +17,15 @@ import {
|
|||||||
type BackgroundSession,
|
type BackgroundSession,
|
||||||
} from './bgRegistry.js'
|
} from './bgRegistry.js'
|
||||||
|
|
||||||
|
const TEST_PROCESS_MARKER = 'a'.repeat(64)
|
||||||
|
const OTHER_PROCESS_MARKER = 'b'.repeat(64)
|
||||||
|
// Keep this test-only constructor available on the pre-marker base so the
|
||||||
|
// marker-authoritative regression can prove red there. Production identity
|
||||||
|
// constructs its expected token independently, so token-format drift still
|
||||||
|
// fails the marked-match tests below.
|
||||||
|
const backgroundProcessMarkerToken = (marker: string) =>
|
||||||
|
`--openclaude-bg-session-marker=${marker}`
|
||||||
|
|
||||||
const {
|
const {
|
||||||
recordBackgroundSessionNaturalTermination,
|
recordBackgroundSessionNaturalTermination,
|
||||||
recordBackgroundSessionNaturalTerminationSync,
|
recordBackgroundSessionNaturalTerminationSync,
|
||||||
@@ -82,10 +91,16 @@ describe('background session registry', () => {
|
|||||||
name: 'auth-refactor',
|
name: 'auth-refactor',
|
||||||
pid: 12345,
|
pid: 12345,
|
||||||
cwd: '/repo',
|
cwd: '/repo',
|
||||||
command: ['openclaude', '--print', 'refactor auth'],
|
command: [
|
||||||
|
'openclaude',
|
||||||
|
backgroundProcessMarkerToken(TEST_PROCESS_MARKER),
|
||||||
|
'--print',
|
||||||
|
'refactor auth',
|
||||||
|
],
|
||||||
provider: 'openai',
|
provider: 'openai',
|
||||||
model: 'gpt-5',
|
model: 'gpt-5',
|
||||||
sessionId: 'conversation-1',
|
sessionId: 'conversation-1',
|
||||||
|
processMarker: TEST_PROCESS_MARKER,
|
||||||
now: new Date('2026-06-15T08:00:00.000Z'),
|
now: new Date('2026-06-15T08:00:00.000Z'),
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -98,9 +113,15 @@ describe('background session registry', () => {
|
|||||||
provider: 'openai',
|
provider: 'openai',
|
||||||
model: 'gpt-5',
|
model: 'gpt-5',
|
||||||
sessionId: 'conversation-1',
|
sessionId: 'conversation-1',
|
||||||
|
processMarker: TEST_PROCESS_MARKER,
|
||||||
startedAt: '2026-06-15T08:00:00.000Z',
|
startedAt: '2026-06-15T08:00:00.000Z',
|
||||||
updatedAt: '2026-06-15T08:00:00.000Z',
|
updatedAt: '2026-06-15T08:00:00.000Z',
|
||||||
command: ['openclaude', '--print', 'refactor auth'],
|
command: [
|
||||||
|
'openclaude',
|
||||||
|
backgroundProcessMarkerToken(TEST_PROCESS_MARKER),
|
||||||
|
'--print',
|
||||||
|
'refactor auth',
|
||||||
|
],
|
||||||
})
|
})
|
||||||
expect(session.stdoutLogPath).toBe(
|
expect(session.stdoutLogPath).toBe(
|
||||||
join(configDir, 'bg-sessions', 'logs', 'bg-test-1.out.log'),
|
join(configDir, 'bg-sessions', 'logs', 'bg-test-1.out.log'),
|
||||||
@@ -111,6 +132,87 @@ describe('background session registry', () => {
|
|||||||
|
|
||||||
const sessions = await listBackgroundSessions()
|
const sessions = await listBackgroundSessions()
|
||||||
expect(sessions.map(s => s.id)).toEqual(['bg-test-1'])
|
expect(sessions.map(s => s.id)).toEqual(['bg-test-1'])
|
||||||
|
expect(sessions[0]?.processMarker).toBe(TEST_PROCESS_MARKER)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('loads legacy session metadata without a process marker', async () => {
|
||||||
|
await mkdir(join(configDir, 'bg-sessions', 'sessions'), {
|
||||||
|
recursive: true,
|
||||||
|
})
|
||||||
|
await writeFile(
|
||||||
|
join(configDir, 'bg-sessions', 'sessions', 'bg-legacy.json'),
|
||||||
|
JSON.stringify({
|
||||||
|
id: 'bg-legacy',
|
||||||
|
pid: 123,
|
||||||
|
cwd: '/repo',
|
||||||
|
status: 'running',
|
||||||
|
sessionId: 'conversation-legacy',
|
||||||
|
startedAt: '2026-06-15T08:00:00.000Z',
|
||||||
|
updatedAt: '2026-06-15T08:00:00.000Z',
|
||||||
|
command: ['openclaude', '--print', 'work'],
|
||||||
|
stdoutLogPath: '/tmp/stdout.log',
|
||||||
|
stderrLogPath: '/tmp/stderr.log',
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
const [session] = await listBackgroundSessions()
|
||||||
|
expect(session?.id).toBe('bg-legacy')
|
||||||
|
expect(session?.processMarker).toBeUndefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('rejects malformed process markers on creation', async () => {
|
||||||
|
await expect(
|
||||||
|
createBackgroundSession({
|
||||||
|
id: 'bg-invalid-marker',
|
||||||
|
pid: 123,
|
||||||
|
cwd: '/repo',
|
||||||
|
command: ['openclaude', '--print', 'work'],
|
||||||
|
sessionId: 'conversation-invalid-marker',
|
||||||
|
processMarker: 'not-valid',
|
||||||
|
}),
|
||||||
|
).rejects.toThrow('Invalid background process marker')
|
||||||
|
|
||||||
|
expect(await listBackgroundSessions()).toEqual([])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('ignores metadata containing malformed process markers', async () => {
|
||||||
|
await mkdir(join(configDir, 'bg-sessions', 'sessions'), {
|
||||||
|
recursive: true,
|
||||||
|
})
|
||||||
|
const invalidMarkers = [
|
||||||
|
'',
|
||||||
|
'a'.repeat(63),
|
||||||
|
'a'.repeat(65),
|
||||||
|
'A'.repeat(64),
|
||||||
|
`${'a'.repeat(63)} `,
|
||||||
|
`${'a'.repeat(63)}/`,
|
||||||
|
`${'a'.repeat(63)};`,
|
||||||
|
`${'a'.repeat(63)}\u0000`,
|
||||||
|
]
|
||||||
|
|
||||||
|
await Promise.all(
|
||||||
|
invalidMarkers.map(async (processMarker, index) => {
|
||||||
|
const id = `bg-invalid-marker-${index}`
|
||||||
|
await writeFile(
|
||||||
|
join(configDir, 'bg-sessions', 'sessions', `${id}.json`),
|
||||||
|
JSON.stringify({
|
||||||
|
id,
|
||||||
|
pid: 123 + index,
|
||||||
|
cwd: '/repo',
|
||||||
|
status: 'running',
|
||||||
|
sessionId: `conversation-${index}`,
|
||||||
|
processMarker,
|
||||||
|
startedAt: '2026-06-15T08:00:00.000Z',
|
||||||
|
updatedAt: '2026-06-15T08:00:00.000Z',
|
||||||
|
command: ['openclaude', '--print', 'work'],
|
||||||
|
stdoutLogPath: '/tmp/stdout.log',
|
||||||
|
stderrLogPath: '/tmp/stderr.log',
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(await listBackgroundSessions()).toEqual([])
|
||||||
})
|
})
|
||||||
|
|
||||||
it('resolves exact live names before session id prefixes', async () => {
|
it('resolves exact live names before session id prefixes', async () => {
|
||||||
@@ -1130,6 +1232,108 @@ describe('background session registry', () => {
|
|||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('keeps a marked Windows session running when its exact early token matches', async () => {
|
||||||
|
const markerToken = backgroundProcessMarkerToken(TEST_PROCESS_MARKER)
|
||||||
|
await createBackgroundSession({
|
||||||
|
id: 'bg-marked-windows',
|
||||||
|
name: 'marked-windows',
|
||||||
|
pid: 333,
|
||||||
|
cwd: 'C:\\repo path',
|
||||||
|
command: [
|
||||||
|
'C:\\Program Files\\nodejs\\node.exe',
|
||||||
|
'C:\\repo path\\dist\\cli.mjs',
|
||||||
|
markerToken,
|
||||||
|
'--session-id',
|
||||||
|
'conversation-marked',
|
||||||
|
'--print',
|
||||||
|
'work',
|
||||||
|
],
|
||||||
|
sessionId: 'conversation-marked',
|
||||||
|
processMarker: TEST_PROCESS_MARKER,
|
||||||
|
now: new Date('2026-06-15T08:00:00.000Z'),
|
||||||
|
})
|
||||||
|
|
||||||
|
const refreshed = await refreshBackgroundSessionStatuses({
|
||||||
|
isProcessAlive: () => true,
|
||||||
|
getProcessCommand: () =>
|
||||||
|
`"C:\\Program Files\\nodejs\\node.exe" "C:\\repo path\\dist\\cli.mjs" ${markerToken} --session-id conversation-marked --print work`,
|
||||||
|
now: new Date('2026-06-15T08:05:00.000Z'),
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(refreshed[0]).toMatchObject({
|
||||||
|
id: 'bg-marked-windows',
|
||||||
|
status: 'running',
|
||||||
|
updatedAt: '2026-06-15T08:00:00.000Z',
|
||||||
|
})
|
||||||
|
await expect(
|
||||||
|
createBackgroundSession({
|
||||||
|
id: 'bg-marked-windows-contender',
|
||||||
|
name: 'marked-windows',
|
||||||
|
pid: 334,
|
||||||
|
cwd: '/repo',
|
||||||
|
command: ['openclaude', '--print', 'contender'],
|
||||||
|
sessionId: 'conversation-contender',
|
||||||
|
}),
|
||||||
|
).rejects.toThrow('already exists')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('marks a marked session stale when the same session id has a different marker', async () => {
|
||||||
|
const markerToken = backgroundProcessMarkerToken(TEST_PROCESS_MARKER)
|
||||||
|
await createBackgroundSession({
|
||||||
|
id: 'bg-marked-wrong-marker',
|
||||||
|
pid: 333,
|
||||||
|
cwd: '/repo',
|
||||||
|
command: [
|
||||||
|
'node',
|
||||||
|
'/repo/openclaude',
|
||||||
|
markerToken,
|
||||||
|
'--session-id',
|
||||||
|
'conversation-marked',
|
||||||
|
],
|
||||||
|
sessionId: 'conversation-marked',
|
||||||
|
processMarker: TEST_PROCESS_MARKER,
|
||||||
|
now: new Date('2026-06-15T08:00:00.000Z'),
|
||||||
|
})
|
||||||
|
|
||||||
|
const refreshed = await refreshBackgroundSessionStatuses({
|
||||||
|
isProcessAlive: () => true,
|
||||||
|
getProcessCommand: () =>
|
||||||
|
`node /repo/openclaude ${backgroundProcessMarkerToken(OTHER_PROCESS_MARKER)} --session-id conversation-marked`,
|
||||||
|
now: new Date('2026-06-15T08:05:00.000Z'),
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(refreshed[0]).toMatchObject({
|
||||||
|
id: 'bg-marked-wrong-marker',
|
||||||
|
status: 'stale',
|
||||||
|
updatedAt: '2026-06-15T08:05:00.000Z',
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('keeps a marked session unknown when its live command is unreadable', async () => {
|
||||||
|
const markerToken = backgroundProcessMarkerToken(TEST_PROCESS_MARKER)
|
||||||
|
await createBackgroundSession({
|
||||||
|
id: 'bg-marked-unreadable',
|
||||||
|
pid: 333,
|
||||||
|
cwd: '/repo',
|
||||||
|
command: ['node', '/repo/openclaude', markerToken, '--print', 'work'],
|
||||||
|
sessionId: 'conversation-marked',
|
||||||
|
processMarker: TEST_PROCESS_MARKER,
|
||||||
|
now: new Date('2026-06-15T08:00:00.000Z'),
|
||||||
|
})
|
||||||
|
|
||||||
|
const refreshed = await refreshBackgroundSessionStatuses({
|
||||||
|
isProcessAlive: () => true,
|
||||||
|
getProcessCommand: () => null,
|
||||||
|
now: new Date('2026-06-15T08:05:00.000Z'),
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(refreshed[0]).toMatchObject({
|
||||||
|
id: 'bg-marked-unreadable',
|
||||||
|
status: 'unknown',
|
||||||
|
updatedAt: '2026-06-15T08:05:00.000Z',
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
it('keeps running sessions fresh when their process identity still matches', async () => {
|
it('keeps running sessions fresh when their process identity still matches', async () => {
|
||||||
await createBackgroundSession({
|
await createBackgroundSession({
|
||||||
id: 'bg-running',
|
id: 'bg-running',
|
||||||
@@ -1331,6 +1535,20 @@ describe('isBackgroundSessionProcessAlive process identity', () => {
|
|||||||
stdoutLogPath: '/tmp/stdout.log',
|
stdoutLogPath: '/tmp/stdout.log',
|
||||||
stderrLogPath: '/tmp/stderr.log',
|
stderrLogPath: '/tmp/stderr.log',
|
||||||
}
|
}
|
||||||
|
const markerToken = backgroundProcessMarkerToken(TEST_PROCESS_MARKER)
|
||||||
|
const markedSession: BackgroundSession = {
|
||||||
|
...session,
|
||||||
|
processMarker: TEST_PROCESS_MARKER,
|
||||||
|
command: [
|
||||||
|
'/opt/Open Claude/node',
|
||||||
|
'/repo path/dist/cli.mjs',
|
||||||
|
markerToken,
|
||||||
|
'--session-id',
|
||||||
|
session.sessionId,
|
||||||
|
'--print',
|
||||||
|
'work',
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
it('does not treat a reused PID whose command merely contains the arg as alive (#1770)', () => {
|
it('does not treat a reused PID whose command merely contains the arg as alive (#1770)', () => {
|
||||||
// The live process at this PID is unrelated: its final token "16420" only
|
// The live process at this PID is unrelated: its final token "16420" only
|
||||||
@@ -1360,6 +1578,100 @@ describe('isBackgroundSessionProcessAlive process identity', () => {
|
|||||||
expect(alive).toBe(true)
|
expect(alive).toBe(true)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('does not fall back to the session id when marked process identity is missing', () => {
|
||||||
|
const result = verifyBackgroundSessionProcessIdentity(markedSession, {
|
||||||
|
isProcessAlive: () => true,
|
||||||
|
getProcessCommand: () =>
|
||||||
|
'/opt/Open Claude/node /repo path/dist/cli.mjs --session-id conversation-identity --print work',
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(result.state).toBe('mismatch')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('matches a marked Unix command with spaced executable and entrypoint paths', () => {
|
||||||
|
const result = verifyBackgroundSessionProcessIdentity(markedSession, {
|
||||||
|
isProcessAlive: () => true,
|
||||||
|
getProcessCommand: () =>
|
||||||
|
`/opt/Open Claude/node /repo path/dist/cli.mjs ${markerToken} --session-id conversation-identity --print work`,
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(result.state).toBe('matches')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does not accept a missing, wrong, or shifted marker for a marked session', () => {
|
||||||
|
const prefix = '/opt/Open Claude/node /repo path/dist/cli.mjs'
|
||||||
|
const commands = [
|
||||||
|
`${prefix} --print work --session-id conversation-identity`,
|
||||||
|
`${prefix} ${backgroundProcessMarkerToken(OTHER_PROCESS_MARKER)} --session-id conversation-identity --print work`,
|
||||||
|
`${prefix} --print ${markerToken} --session-id conversation-identity work`,
|
||||||
|
]
|
||||||
|
|
||||||
|
expect(
|
||||||
|
commands.map(
|
||||||
|
command =>
|
||||||
|
verifyBackgroundSessionProcessIdentity(markedSession, {
|
||||||
|
isProcessAlive: () => true,
|
||||||
|
getProcessCommand: () => command,
|
||||||
|
}).state,
|
||||||
|
),
|
||||||
|
).toEqual(['mismatch', 'mismatch', 'mismatch'])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does not match marker prefix, suffix, or prompt-only collisions', () => {
|
||||||
|
const prefix = '/opt/Open Claude/node /repo path/dist/cli.mjs'
|
||||||
|
const commands = [
|
||||||
|
`${prefix} prefix-${markerToken} --session-id conversation-identity`,
|
||||||
|
`${prefix} ${markerToken}-suffix --session-id conversation-identity`,
|
||||||
|
`${prefix} --print -- ${markerToken}`,
|
||||||
|
]
|
||||||
|
|
||||||
|
expect(
|
||||||
|
commands.map(
|
||||||
|
command =>
|
||||||
|
verifyBackgroundSessionProcessIdentity(markedSession, {
|
||||||
|
isProcessAlive: () => true,
|
||||||
|
getProcessCommand: () => command,
|
||||||
|
}).state,
|
||||||
|
),
|
||||||
|
).toEqual(['mismatch', 'mismatch', 'mismatch'])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('distinguishes truncated marked identity from a shorter unrelated command', () => {
|
||||||
|
const prefix = '/opt/Open Claude/node /repo path/dist/cli.mjs'
|
||||||
|
const withinMarker = markerToken.slice(0, -8)
|
||||||
|
const states = [
|
||||||
|
verifyBackgroundSessionProcessIdentity(markedSession, {
|
||||||
|
isProcessAlive: () => true,
|
||||||
|
getProcessCommand: () => prefix,
|
||||||
|
}).state,
|
||||||
|
verifyBackgroundSessionProcessIdentity(markedSession, {
|
||||||
|
isProcessAlive: () => true,
|
||||||
|
getProcessCommand: () => `${prefix} ${withinMarker}`,
|
||||||
|
}).state,
|
||||||
|
verifyBackgroundSessionProcessIdentity(markedSession, {
|
||||||
|
isProcessAlive: () => true,
|
||||||
|
getProcessCommand: () => 'unrelated short command',
|
||||||
|
}).state,
|
||||||
|
]
|
||||||
|
|
||||||
|
expect(states).toEqual(['unreadable', 'unreadable', 'mismatch'])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('treats a marked session whose stored command omits its marker as unreadable', () => {
|
||||||
|
const result = verifyBackgroundSessionProcessIdentity(
|
||||||
|
{
|
||||||
|
...markedSession,
|
||||||
|
command: ['node', 'openclaude', '--print', 'work'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
isProcessAlive: () => true,
|
||||||
|
getProcessCommand: () => 'node openclaude --print work',
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(result.state).toBe('unreadable')
|
||||||
|
})
|
||||||
|
|
||||||
it('does not match the session id as a substring of a larger token (#1770)', () => {
|
it('does not match the session id as a substring of a larger token (#1770)', () => {
|
||||||
// A short id must not match an unrelated live command that merely contains
|
// A short id must not match an unrelated live command that merely contains
|
||||||
// it inside a longer token — the same reused-PID collision class as the
|
// it inside a longer token — the same reused-PID collision class as the
|
||||||
|
|||||||
@@ -27,6 +27,10 @@ import {
|
|||||||
isProcessRunning,
|
isProcessRunning,
|
||||||
} from '../utils/genericProcessUtils.js'
|
} from '../utils/genericProcessUtils.js'
|
||||||
import { jsonParse, jsonStringify } from '../utils/slowOperations.js'
|
import { jsonParse, jsonStringify } from '../utils/slowOperations.js'
|
||||||
|
import {
|
||||||
|
backgroundProcessMarkerToken,
|
||||||
|
isValidBackgroundProcessMarker,
|
||||||
|
} from './bgRouting.js'
|
||||||
|
|
||||||
export type BackgroundSessionStatus =
|
export type BackgroundSessionStatus =
|
||||||
| 'running'
|
| 'running'
|
||||||
@@ -45,6 +49,7 @@ export type BackgroundSession = {
|
|||||||
provider?: string
|
provider?: string
|
||||||
model?: string
|
model?: string
|
||||||
sessionId: string
|
sessionId: string
|
||||||
|
processMarker?: string
|
||||||
startedAt: string
|
startedAt: string
|
||||||
updatedAt: string
|
updatedAt: string
|
||||||
command: string[]
|
command: string[]
|
||||||
@@ -85,6 +90,7 @@ export type CreateBackgroundSessionInput = {
|
|||||||
provider?: string
|
provider?: string
|
||||||
model?: string
|
model?: string
|
||||||
sessionId: string
|
sessionId: string
|
||||||
|
processMarker?: string
|
||||||
now?: Date
|
now?: Date
|
||||||
stdoutLogPath?: string
|
stdoutLogPath?: string
|
||||||
stderrLogPath?: string
|
stderrLogPath?: string
|
||||||
@@ -426,6 +432,8 @@ function isBackgroundSession(
|
|||||||
typeof candidate.provider === 'string') &&
|
typeof candidate.provider === 'string') &&
|
||||||
(candidate.model === undefined || typeof candidate.model === 'string') &&
|
(candidate.model === undefined || typeof candidate.model === 'string') &&
|
||||||
typeof candidate.sessionId === 'string' &&
|
typeof candidate.sessionId === 'string' &&
|
||||||
|
(candidate.processMarker === undefined ||
|
||||||
|
isValidBackgroundProcessMarker(candidate.processMarker)) &&
|
||||||
typeof candidate.startedAt === 'string' &&
|
typeof candidate.startedAt === 'string' &&
|
||||||
typeof candidate.updatedAt === 'string' &&
|
typeof candidate.updatedAt === 'string' &&
|
||||||
isStringArray(candidate.command) &&
|
isStringArray(candidate.command) &&
|
||||||
@@ -682,6 +690,12 @@ export async function createBackgroundSession(
|
|||||||
if (!Number.isInteger(input.pid) || input.pid <= 0) {
|
if (!Number.isInteger(input.pid) || input.pid <= 0) {
|
||||||
throw new Error(`Invalid background session pid: ${input.pid}`)
|
throw new Error(`Invalid background session pid: ${input.pid}`)
|
||||||
}
|
}
|
||||||
|
if (
|
||||||
|
input.processMarker !== undefined &&
|
||||||
|
!isValidBackgroundProcessMarker(input.processMarker)
|
||||||
|
) {
|
||||||
|
throw new Error('Invalid background process marker')
|
||||||
|
}
|
||||||
await assertBackgroundSessionNameAvailable(input.name)
|
await assertBackgroundSessionNameAvailable(input.name)
|
||||||
const timestamp = iso(input.now)
|
const timestamp = iso(input.now)
|
||||||
const logPaths = getBackgroundSessionLogPaths(input.id)
|
const logPaths = getBackgroundSessionLogPaths(input.id)
|
||||||
@@ -694,6 +708,9 @@ export async function createBackgroundSession(
|
|||||||
...(input.provider ? { provider: input.provider } : {}),
|
...(input.provider ? { provider: input.provider } : {}),
|
||||||
...(input.model ? { model: input.model } : {}),
|
...(input.model ? { model: input.model } : {}),
|
||||||
sessionId: input.sessionId,
|
sessionId: input.sessionId,
|
||||||
|
...(input.processMarker
|
||||||
|
? { processMarker: input.processMarker }
|
||||||
|
: {}),
|
||||||
startedAt: timestamp,
|
startedAt: timestamp,
|
||||||
updatedAt: timestamp,
|
updatedAt: timestamp,
|
||||||
command: input.command,
|
command: input.command,
|
||||||
@@ -897,6 +914,35 @@ function commandLineMatchesBackgroundSession(
|
|||||||
return commandLineContainsArgs(commandLine, session.command)
|
return commandLineContainsArgs(commandLine, session.command)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function markedCommandLineIdentity(
|
||||||
|
commandLine: string,
|
||||||
|
session: BackgroundSession,
|
||||||
|
processMarker: string,
|
||||||
|
): 'matches' | 'mismatch' | 'unreadable' {
|
||||||
|
const markerToken = backgroundProcessMarkerToken(processMarker)
|
||||||
|
const storedTokens = session.command.flatMap(tokenizeCommandLine)
|
||||||
|
const expectedIndex = storedTokens.indexOf(markerToken)
|
||||||
|
if (expectedIndex === -1) return 'unreadable'
|
||||||
|
|
||||||
|
const liveTokens = tokenizeCommandLine(commandLine)
|
||||||
|
const comparablePrefixLength = Math.min(expectedIndex, liveTokens.length)
|
||||||
|
for (let index = 0; index < comparablePrefixLength; index += 1) {
|
||||||
|
if (liveTokens[index] !== storedTokens[index]) return 'mismatch'
|
||||||
|
}
|
||||||
|
|
||||||
|
if (liveTokens.length <= expectedIndex) return 'unreadable'
|
||||||
|
const candidate = liveTokens[expectedIndex]!
|
||||||
|
if (candidate === markerToken) return 'matches'
|
||||||
|
if (
|
||||||
|
expectedIndex === liveTokens.length - 1 &&
|
||||||
|
candidate.length > 0 &&
|
||||||
|
markerToken.startsWith(candidate)
|
||||||
|
) {
|
||||||
|
return 'unreadable'
|
||||||
|
}
|
||||||
|
return 'mismatch'
|
||||||
|
}
|
||||||
|
|
||||||
export function verifyBackgroundSessionProcessIdentity(
|
export function verifyBackgroundSessionProcessIdentity(
|
||||||
session: BackgroundSession,
|
session: BackgroundSession,
|
||||||
options?: BackgroundSessionProcessIdentityOptions,
|
options?: BackgroundSessionProcessIdentityOptions,
|
||||||
@@ -928,6 +974,11 @@ export function verifyBackgroundSessionProcessIdentity(
|
|||||||
if (command == null || command.trim() === '') {
|
if (command == null || command.trim() === '') {
|
||||||
return result('unreadable')
|
return result('unreadable')
|
||||||
}
|
}
|
||||||
|
if (session.processMarker !== undefined) {
|
||||||
|
return result(
|
||||||
|
markedCommandLineIdentity(command, session, session.processMarker),
|
||||||
|
)
|
||||||
|
}
|
||||||
return result(
|
return result(
|
||||||
commandLineMatchesBackgroundSession(command, session)
|
commandLineMatchesBackgroundSession(command, session)
|
||||||
? 'matches'
|
? 'matches'
|
||||||
|
|||||||
@@ -1,4 +1,55 @@
|
|||||||
|
import { randomBytes } from 'node:crypto'
|
||||||
|
|
||||||
export const BACKGROUND_SESSION_ID_ENV =
|
export const BACKGROUND_SESSION_ID_ENV =
|
||||||
'OPENCLAUDE_INTERNAL_BACKGROUND_SESSION_ID'
|
'OPENCLAUDE_INTERNAL_BACKGROUND_SESSION_ID'
|
||||||
export const BACKGROUND_SESSION_LAUNCHER_PID_ENV =
|
export const BACKGROUND_SESSION_LAUNCHER_PID_ENV =
|
||||||
'OPENCLAUDE_INTERNAL_BACKGROUND_LAUNCHER_PID'
|
'OPENCLAUDE_INTERNAL_BACKGROUND_LAUNCHER_PID'
|
||||||
|
|
||||||
|
export const BACKGROUND_PROCESS_MARKER_FLAG =
|
||||||
|
'--openclaude-bg-session-marker'
|
||||||
|
|
||||||
|
const BACKGROUND_PROCESS_MARKER_BYTES = 32
|
||||||
|
const BACKGROUND_PROCESS_MARKER_RE = /^[a-f0-9]{64}$/
|
||||||
|
|
||||||
|
export function isValidBackgroundProcessMarker(value: unknown): value is string {
|
||||||
|
return (
|
||||||
|
typeof value === 'string' && BACKGROUND_PROCESS_MARKER_RE.test(value)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function generateBackgroundProcessMarker(
|
||||||
|
getRandomBytes: (size: number) => Uint8Array = randomBytes,
|
||||||
|
): string {
|
||||||
|
const bytes = getRandomBytes(BACKGROUND_PROCESS_MARKER_BYTES)
|
||||||
|
if (bytes.byteLength !== BACKGROUND_PROCESS_MARKER_BYTES) {
|
||||||
|
throw new Error(
|
||||||
|
'Background process marker entropy source returned the wrong length',
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return Buffer.from(bytes).toString('hex')
|
||||||
|
}
|
||||||
|
|
||||||
|
export function backgroundProcessMarkerToken(marker: string): string {
|
||||||
|
if (!isValidBackgroundProcessMarker(marker)) {
|
||||||
|
throw new Error('Invalid background process marker')
|
||||||
|
}
|
||||||
|
return `${BACKGROUND_PROCESS_MARKER_FLAG}=${marker}`
|
||||||
|
}
|
||||||
|
|
||||||
|
export function stripBackgroundProcessMarkerArgs(args: string[]): string[] {
|
||||||
|
const first = args[0]
|
||||||
|
const inlinePrefix = `${BACKGROUND_PROCESS_MARKER_FLAG}=`
|
||||||
|
if (
|
||||||
|
first?.startsWith(inlinePrefix) &&
|
||||||
|
isValidBackgroundProcessMarker(first.slice(inlinePrefix.length))
|
||||||
|
) {
|
||||||
|
return args.slice(1)
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
first === BACKGROUND_PROCESS_MARKER_FLAG &&
|
||||||
|
isValidBackgroundProcessMarker(args[1])
|
||||||
|
) {
|
||||||
|
return args.slice(2)
|
||||||
|
}
|
||||||
|
return [...args]
|
||||||
|
}
|
||||||
|
|||||||
@@ -49,6 +49,7 @@ import type { ToolInputJSONSchema } from './Tool.js';
|
|||||||
import { createSyntheticOutputTool, isSyntheticOutputToolEnabled } from './tools/SyntheticOutputTool/SyntheticOutputTool.js';
|
import { createSyntheticOutputTool, isSyntheticOutputToolEnabled } from './tools/SyntheticOutputTool/SyntheticOutputTool.js';
|
||||||
import { registerTaskReportCommand } from './cli/commands/taskReport.js';
|
import { registerTaskReportCommand } from './cli/commands/taskReport.js';
|
||||||
import { registerAimlapiCommand } from './cli/aimlapiCommand.js';
|
import { registerAimlapiCommand } from './cli/aimlapiCommand.js';
|
||||||
|
import { BACKGROUND_PROCESS_MARKER_FLAG, isValidBackgroundProcessMarker } from './cli/bgRouting.js';
|
||||||
import { getTools } from './tools.js';
|
import { getTools } from './tools.js';
|
||||||
import { canUserConfigureAdvisor, getInitialAdvisorSetting, isAdvisorEnabled, isValidAdvisorModel, modelSupportsAdvisor } from './utils/advisor.js';
|
import { canUserConfigureAdvisor, getInitialAdvisorSetting, isAdvisorEnabled, isValidAdvisorModel, modelSupportsAdvisor } from './utils/advisor.js';
|
||||||
import { isAgentSwarmsEnabled } from './utils/agentSwarmsEnabled.js';
|
import { isAgentSwarmsEnabled } from './utils/agentSwarmsEnabled.js';
|
||||||
@@ -3644,6 +3645,13 @@ async function run(): Promise<CommanderCommand> {
|
|||||||
}
|
}
|
||||||
}).version(`${MACRO.DISPLAY_VERSION ?? MACRO.VERSION} (OpenClaude)`, '-v, --version', 'Output the version number');
|
}).version(`${MACRO.DISPLAY_VERSION ?? MACRO.VERSION} (OpenClaude)`, '-v, --version', 'Output the version number');
|
||||||
|
|
||||||
|
program.addOption(new Option(`${BACKGROUND_PROCESS_MARKER_FLAG} <marker>`, 'Internal background process identity marker').argParser(value => {
|
||||||
|
if (!isValidBackgroundProcessMarker(value)) {
|
||||||
|
throw new InvalidArgumentError('Invalid background process marker');
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
}).hideHelp());
|
||||||
|
|
||||||
// Worktree flags
|
// Worktree flags
|
||||||
program.option('-w, --worktree [name]', 'Create a new git worktree for this session (optionally specify a name)');
|
program.option('-w, --worktree [name]', 'Create a new git worktree for this session (optionally specify a name)');
|
||||||
program.option('--tmux', 'Create a tmux session for the worktree (requires --worktree). Uses iTerm2 native panes when available; use --tmux=classic for traditional tmux.');
|
program.option('--tmux', 'Create a tmux session for the worktree (requires --worktree). Uses iTerm2 native panes when available; use --tmux=classic for traditional tmux.');
|
||||||
|
|||||||
Reference in New Issue
Block a user