mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-02 13:39:41 +02:00
Merge pull request #353 from timkjr/omp-mode
feat: add OMP (Oh My Pi) as a new session backend
This commit is contained in:
@@ -401,7 +401,7 @@ describe('Session Manager unified list', () => {
|
||||
const [historyRecord, , historyOptions] = app._buildHistoryItem.mock.calls[1];
|
||||
expect(historyRecord).toMatchObject({ sessionId: 'conv-uuid-1', sizeBytes: 2048, firstPrompt: 'old prompt' });
|
||||
historyOptions.onActivate();
|
||||
expect(app.resumeHistorySession).toHaveBeenCalledWith('conv-uuid-1', '/repo/old');
|
||||
expect(app.resumeHistorySession).toHaveBeenCalledWith('conv-uuid-1', '/repo/old', undefined, undefined);
|
||||
});
|
||||
|
||||
it('surfaces an error message instead of an empty list when the endpoint fails', async () => {
|
||||
|
||||
@@ -329,6 +329,32 @@ describe('resolveDockerCredentialArtifacts (isolated codex/gemini/gcloud/opencod
|
||||
expect(mounts).toEqual([]);
|
||||
expect(seedCopies).toEqual([]);
|
||||
});
|
||||
|
||||
it('omp: shares sessions/ RW (host-side history/resume reads), seeds config files only', () => {
|
||||
mkdirSync(join(home, '.omp', 'agent', 'sessions'), { recursive: true });
|
||||
writeFileSync(join(home, '.omp', 'agent', 'config.yml'), '');
|
||||
writeFileSync(join(home, '.omp', 'agent', 'mcp.json'), '{}');
|
||||
writeFileSync(join(home, '.omp', 'agent', 'models.yml'), '');
|
||||
writeFileSync(join(home, '.omp', 'agent', 'settings.yml'), '');
|
||||
// Regenerable local state that must NOT be seeded (mirrors the pi/grok exclusions).
|
||||
writeFileSync(join(home, '.omp', 'agent', 'agent.db'), '');
|
||||
mkdirSync(join(home, '.omp', 'agent', 'terminal-sessions'), { recursive: true });
|
||||
|
||||
const { mounts, seedCopies } = resolveDockerCredentialArtifacts(home);
|
||||
expect(mounts).toContainEqual({
|
||||
src: join(home, '.omp', 'agent', 'sessions'),
|
||||
dst: '/home/agent/.omp/agent/sessions',
|
||||
});
|
||||
const dests = seedCopies.map((s) => s.to);
|
||||
expect(dests).toContain('/home/agent/.omp/agent/config.yml');
|
||||
expect(dests).toContain('/home/agent/.omp/agent/mcp.json');
|
||||
expect(dests).toContain('/home/agent/.omp/agent/models.yml');
|
||||
expect(dests).toContain('/home/agent/.omp/agent/settings.yml');
|
||||
expect(dests).not.toContain('/home/agent/.omp/agent/agent.db');
|
||||
expect(mounts.some((m) => m.dst === '/home/agent/.omp/agent/terminal-sessions')).toBe(false);
|
||||
// seed copies of individual files are NOT recursive
|
||||
expect(seedCopies.filter((s) => s.to.startsWith('/home/agent/.omp')).every((s) => !s.recursive)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolveDockerClaudeArtifacts (isolated claude state)', () => {
|
||||
|
||||
@@ -433,6 +433,7 @@ describe('mobile overview run picker (CLI availability gating)', () => {
|
||||
'pi',
|
||||
'grok',
|
||||
'deepseek',
|
||||
'omp',
|
||||
'shell',
|
||||
]);
|
||||
});
|
||||
@@ -447,7 +448,7 @@ describe('mobile overview run picker (CLI availability gating)', () => {
|
||||
src.indexOf('];', src.indexOf('const MOBILE_OVERVIEW_RUN_MODES')) + 2
|
||||
);
|
||||
const offered = [...modesBlock.matchAll(/mode: '([^']+)'/g)].map((m) => m[1]);
|
||||
expect(offered).toContain('antigravity');
|
||||
expect(offered).toContain('omp');
|
||||
const fn = src.slice(src.indexOf('_buildMobileOverviewRunMenu() {'));
|
||||
const gate = fn.slice(0, fn.indexOf('const header'));
|
||||
expect(gate).toContain('isCliAvailable');
|
||||
|
||||
@@ -86,6 +86,7 @@ export function createMockRouteContext(options?: {
|
||||
getSession: vi.fn(),
|
||||
setSession: vi.fn(),
|
||||
removeSession: vi.fn(),
|
||||
demoteOrRemoveSession: vi.fn(() => 'removed' as const),
|
||||
getSettings: vi.fn(() => ({})),
|
||||
setSettings: vi.fn(),
|
||||
getRalphLoopState: vi.fn(() => ({})),
|
||||
|
||||
@@ -0,0 +1,132 @@
|
||||
/**
|
||||
* @fileoverview Tests for the OMP CLI resolver wrapper.
|
||||
*
|
||||
* OMP is a resolver with a version probe: `omp` is a short binary name, so a
|
||||
* resolved path is only accepted once `omp --version` prints an `omp/<semver>`
|
||||
* string (e.g. `omp/17.4.0`). The probe EXECUTES the candidate, which is
|
||||
* exactly why it must never run under vitest — the hermeticity test below pins
|
||||
* that gate with a real executable fixture that would make the test fail
|
||||
* loudly if the gate were deleted again.
|
||||
*/
|
||||
import { chmodSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import { createOmpResolverForTest } from '../src/utils/omp-cli-resolver.js';
|
||||
import {
|
||||
cliResolveRetryDelayMs,
|
||||
createProductionCliResolverHost,
|
||||
type CliResolverHost,
|
||||
} from '../src/utils/cli-executable-resolver.js';
|
||||
|
||||
const temporaryDirectories: string[] = [];
|
||||
|
||||
afterEach(() => {
|
||||
for (const directory of temporaryDirectories.splice(0)) {
|
||||
rmSync(directory, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
function createHost(
|
||||
options: {
|
||||
processPathResult?: string | null;
|
||||
loginShellResults?: Array<string | null>;
|
||||
existingPaths?: string[];
|
||||
} = {}
|
||||
): CliResolverHost {
|
||||
const loginShellResults = [...(options.loginShellResults ?? [])];
|
||||
const existingPaths = new Set(options.existingPaths ?? []);
|
||||
return {
|
||||
processPath: '/service/bin',
|
||||
shellPath: '/bin/zsh',
|
||||
shellArgs: ['-l'],
|
||||
findOnProcessPath: () => options.processPathResult ?? null,
|
||||
findInLoginShell: () => loginShellResults.shift() ?? null,
|
||||
exists: (path) => existingPaths.has(path),
|
||||
};
|
||||
}
|
||||
|
||||
describe('OMP CLI resolver', () => {
|
||||
it('accepts a candidate the version probe verifies and carries the version as metadata', () => {
|
||||
const binaryPath = '/service/bin/omp';
|
||||
const probe = vi.fn(() => '17.4.0');
|
||||
const resolver = createOmpResolverForTest(
|
||||
createHost({ processPathResult: binaryPath, existingPaths: [binaryPath] }),
|
||||
probe
|
||||
);
|
||||
|
||||
expect(resolver.resolve()).toMatchObject({
|
||||
binaryPath,
|
||||
directory: '/service/bin',
|
||||
source: 'process-path',
|
||||
metadata: '17.4.0',
|
||||
});
|
||||
expect(probe).toHaveBeenCalledWith(binaryPath);
|
||||
});
|
||||
|
||||
it('rejects a candidate the probe refuses and falls through to a later one', () => {
|
||||
// An unrelated `omp` on the service PATH (probe returns null) must not mask
|
||||
// the real coding agent found by the login shell.
|
||||
const impostor = '/service/bin/omp';
|
||||
const genuine = '/login-shell/bin/omp';
|
||||
const probe = vi.fn((binPath: string) => (binPath === genuine ? '17.4.0' : null));
|
||||
const resolver = createOmpResolverForTest(
|
||||
createHost({
|
||||
processPathResult: impostor,
|
||||
loginShellResults: [genuine],
|
||||
existingPaths: [impostor, genuine],
|
||||
}),
|
||||
probe
|
||||
);
|
||||
|
||||
expect(resolver.resolve()).toMatchObject({ binaryPath: genuine, source: 'login-shell', metadata: '17.4.0' });
|
||||
});
|
||||
|
||||
it('negative-caches a miss and retries only after the backoff elapses', () => {
|
||||
const binaryPath = '/late/bin/omp';
|
||||
let now = 0;
|
||||
const probe = vi.fn(() => '17.4.0');
|
||||
const resolver = createOmpResolverForTest(
|
||||
createHost({ loginShellResults: [null, binaryPath], existingPaths: [binaryPath] }),
|
||||
probe,
|
||||
() => now
|
||||
);
|
||||
|
||||
expect(resolver.resolve()).toBeNull();
|
||||
expect(resolver.resolve()).toBeNull(); // within the backoff: no re-run
|
||||
expect(probe).not.toHaveBeenCalled();
|
||||
now = cliResolveRetryDelayMs(1);
|
||||
expect(resolver.resolve()?.metadata).toBe('17.4.0');
|
||||
expect(resolver.resolve()?.binaryPath).toBe(binaryPath);
|
||||
});
|
||||
|
||||
it('never executes an omp candidate under vitest (the ambient probe is VITEST-gated)', () => {
|
||||
// A REAL executable fixture that prints a valid version. If the guard in
|
||||
// probeOmpVersion is ever removed again, the probe runs this script, the
|
||||
// resolution SUCCEEDS, and this test fails — pinning hermeticity by
|
||||
// behavior rather than by source text. (The suites must never execute
|
||||
// whatever `omp` binary the machine running them happens to carry.)
|
||||
const root = mkdtempSync(join(tmpdir(), 'codeman-omp-vitest-gate-'));
|
||||
temporaryDirectories.push(root);
|
||||
const binaryPath = join(root, 'omp');
|
||||
writeFileSync(binaryPath, '#!/bin/sh\necho omp/0.99.0\n');
|
||||
chmodSync(binaryPath, 0o755);
|
||||
const hostOptions = {
|
||||
processPath: root,
|
||||
shellPath: '/bin/bash',
|
||||
shellArgs: ['-i', '-l'] as string[],
|
||||
runCommand: () => '',
|
||||
isExecutableFile: (path: string) => path === binaryPath,
|
||||
};
|
||||
|
||||
// Default (ambient) probe: the candidate is found but never executed, so
|
||||
// the VITEST gate reports it unusable and resolution misses.
|
||||
const gated = createOmpResolverForTest(createProductionCliResolverHost(hostOptions));
|
||||
expect(gated.resolve()).toBeNull();
|
||||
|
||||
// Control: identical setup with an injected probe resolves, proving the
|
||||
// null above comes from the gate, not from the fixture or the host.
|
||||
const control = createOmpResolverForTest(createProductionCliResolverHost(hostOptions), () => '0.99.0');
|
||||
expect(control.resolve()).toMatchObject({ binaryPath, metadata: '0.99.0' });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,129 @@
|
||||
/**
|
||||
* @fileoverview Pins the "Run OMP always resumes" bug found live 2026-08-27,
|
||||
* and its follow-on fix for the sibling-aliasing bug found in upstream PR
|
||||
* review (Ark0N/Codeman#353).
|
||||
*
|
||||
* Session._pinOmpRespawnId() resolves-and-pins the newest on-disk omp
|
||||
* conversation as a side effect on `this._ompConfig`. That is correct ONLY
|
||||
* immediately before an ACTUAL respawn (a confirmed-dead pane, or a genuine
|
||||
* remote reattach) — never while merely building options that might not
|
||||
* lead to one. It used to run eagerly inside `_buildRespawnPaneOptions()`,
|
||||
* which startInteractive() calls unconditionally (including for a genuinely
|
||||
* brand-new session, and for a boot-recovery reattach to a pane that turns
|
||||
* out to still be alive): a fresh "Run OMP" click in a working directory
|
||||
* with any prior omp history silently launched `--resume <old-id>` instead
|
||||
* of a clean `omp` invocation, and — with two omp tabs in the same case dir
|
||||
* — a live pane's `_ompConfig`/`claudeSessionId` could get mis-pinned to
|
||||
* whichever sibling's file happened to be newest on disk, even though
|
||||
* nothing was actually being respawned. Resolution now happens only inside
|
||||
* `_pinOmpRespawnId()`, called by a caller that has already confirmed a
|
||||
* real respawn is happening.
|
||||
*/
|
||||
import { mkdirSync, rmSync, writeFileSync } from 'node:fs';
|
||||
import { homedir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { afterEach, describe, expect, it } from 'vitest';
|
||||
import { Session } from '../src/session.js';
|
||||
import { TmuxManager } from '../src/tmux-manager.js';
|
||||
import type { MuxSession } from '../src/types.js';
|
||||
|
||||
describe('OMP: fresh session vs. reattach must not share resumeSessionId resolution', () => {
|
||||
const workingDir = join(homedir(), 'codeman-cases', 'resume-test');
|
||||
const sessionDir = join(homedir(), '.omp', 'agent', 'sessions', '-codeman-cases-resume-test');
|
||||
const sessions: Session[] = [];
|
||||
|
||||
afterEach(() => {
|
||||
for (const s of sessions.splice(0)) s.stop();
|
||||
rmSync(join(homedir(), '.omp'), { recursive: true, force: true });
|
||||
});
|
||||
|
||||
function seedOmpSessionFile(id: string) {
|
||||
mkdirSync(workingDir, { recursive: true });
|
||||
mkdirSync(sessionDir, { recursive: true });
|
||||
// resolveAndClaimOmpSessionId() verifies the file's own header (not just
|
||||
// the filename), mirroring the real `omp` session-file shape — the
|
||||
// header's `cwd` must match `workingDir` for the candidate to count.
|
||||
const header = `${JSON.stringify({ type: 'session', id, cwd: workingDir })}\n`;
|
||||
writeFileSync(join(sessionDir, `2026-08-27T17-31-08-001Z_${id}.jsonl`), header);
|
||||
}
|
||||
|
||||
it('a brand-new session (no prior mux session) never inherits an on-disk conversation', async () => {
|
||||
seedOmpSessionFile('old-conversation-id');
|
||||
|
||||
const session = new Session({
|
||||
workingDir,
|
||||
mode: 'omp',
|
||||
mux: new TmuxManager(),
|
||||
useMux: true,
|
||||
});
|
||||
sessions.push(session);
|
||||
|
||||
await session.startInteractive();
|
||||
const state = session.toState();
|
||||
|
||||
expect(state.ompConfig).toBeUndefined();
|
||||
expect(session.claudeSessionId).toBe(session.id);
|
||||
});
|
||||
|
||||
it('a plain reattach to an existing mux session (pane still alive) does NOT pin', async () => {
|
||||
// Regression for the sibling-aliasing bug: pinning must never be a side
|
||||
// effect of merely building respawn options for a pane that might still
|
||||
// be alive (isPaneDead is unconditionally false under IS_TEST_MODE,
|
||||
// which is what a real "just reattaching, nothing died" boot recovery
|
||||
// looks like from Session's perspective).
|
||||
seedOmpSessionFile('sibling-conversation-id');
|
||||
|
||||
const muxSession: MuxSession = {
|
||||
sessionId: 'placeholder',
|
||||
muxName: 'codeman-deadbeef',
|
||||
pid: 1,
|
||||
createdAt: Date.now(),
|
||||
workingDir,
|
||||
mode: 'omp',
|
||||
attached: false,
|
||||
};
|
||||
|
||||
const session = new Session({
|
||||
workingDir,
|
||||
mode: 'omp',
|
||||
mux: new TmuxManager(),
|
||||
useMux: true,
|
||||
muxSession,
|
||||
});
|
||||
sessions.push(session);
|
||||
|
||||
await session.startInteractive();
|
||||
const state = session.toState();
|
||||
|
||||
expect(state.ompConfig?.resumeSessionId).toBeUndefined();
|
||||
expect(session.claudeSessionId).toBe(session.id);
|
||||
});
|
||||
|
||||
it('_pinOmpRespawnId() resolves and pins the real id once a respawn is confirmed', () => {
|
||||
seedOmpSessionFile('real-omp-uuid');
|
||||
|
||||
const muxSession: MuxSession = {
|
||||
sessionId: 'placeholder',
|
||||
muxName: 'codeman-deadbeef',
|
||||
pid: 1,
|
||||
createdAt: Date.now(),
|
||||
workingDir,
|
||||
mode: 'omp',
|
||||
attached: false,
|
||||
};
|
||||
|
||||
const session = new Session({
|
||||
workingDir,
|
||||
mode: 'omp',
|
||||
mux: new TmuxManager(),
|
||||
useMux: true,
|
||||
muxSession,
|
||||
});
|
||||
sessions.push(session);
|
||||
|
||||
(session as unknown as { _pinOmpRespawnId(): void })._pinOmpRespawnId();
|
||||
|
||||
expect(session.toState().ompConfig?.resumeSessionId).toBe('real-omp-uuid');
|
||||
expect(session.claudeSessionId).toBe('real-omp-uuid');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,165 @@
|
||||
import { describe, expect, it, beforeEach, afterEach } from 'vitest';
|
||||
import { CreateSessionSchema, QuickStartSchema } from '../src/web/schemas.js';
|
||||
import { buildSpawnCommand } from '../src/tmux-manager.js';
|
||||
import { defaultDockerCommandForMode } from '../src/docker-hosts.js';
|
||||
import { defaultRemoteCommandForMode } from '../src/remote-hosts.js';
|
||||
import { isExternalCliMode, isAltScreenStripMode } from '../src/session.js';
|
||||
import { _clampEnvOverridesForOwner } from '../src/web/routes/session-routes.js';
|
||||
|
||||
describe('OMP mode schemas', () => {
|
||||
it('accepts OMP session creation config', () => {
|
||||
const parsed = CreateSessionSchema.parse({
|
||||
workingDir: '/tmp',
|
||||
mode: 'omp',
|
||||
ompConfig: {
|
||||
model: 'crof/glm-5.2',
|
||||
},
|
||||
});
|
||||
|
||||
expect(parsed.mode).toBe('omp');
|
||||
expect(parsed.ompConfig).toEqual({
|
||||
model: 'crof/glm-5.2',
|
||||
});
|
||||
});
|
||||
|
||||
it('accepts OMP quick-start config', () => {
|
||||
const parsed = QuickStartSchema.parse({
|
||||
caseName: 'omp-case',
|
||||
mode: 'omp',
|
||||
ompConfig: {
|
||||
resumeSessionId: 'session-1234abcd',
|
||||
},
|
||||
});
|
||||
|
||||
expect(parsed.mode).toBe('omp');
|
||||
expect(parsed.ompConfig?.resumeSessionId).toBe('session-1234abcd');
|
||||
});
|
||||
|
||||
it('rejects unsafe OMP model strings', () => {
|
||||
expect(() =>
|
||||
CreateSessionSchema.parse({
|
||||
workingDir: '/tmp',
|
||||
mode: 'omp',
|
||||
ompConfig: { model: 'omp; rm -rf /' },
|
||||
})
|
||||
).toThrow();
|
||||
});
|
||||
|
||||
it('allows OMP_* env overrides and still rejects unknown prefixes', () => {
|
||||
const parsed = CreateSessionSchema.parse({
|
||||
workingDir: '/tmp',
|
||||
mode: 'omp',
|
||||
envOverrides: { OMP_PROFILE: 'work' },
|
||||
});
|
||||
expect(parsed.envOverrides).toEqual({ OMP_PROFILE: 'work' });
|
||||
|
||||
expect(() =>
|
||||
CreateSessionSchema.parse({
|
||||
workingDir: '/tmp',
|
||||
envOverrides: { RANDOM_PREFIX_KEY: 'x' },
|
||||
})
|
||||
).toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('OMP spawn command', () => {
|
||||
it('builds a bare omp command when no config is sent', () => {
|
||||
const cmd = buildSpawnCommand({ mode: 'omp', sessionId: 'abc12345' });
|
||||
expect(cmd).toBe('omp');
|
||||
});
|
||||
|
||||
it('passes --model and --resume, and drops unsafe ids', () => {
|
||||
expect(
|
||||
buildSpawnCommand({
|
||||
mode: 'omp',
|
||||
sessionId: 'abc12345',
|
||||
ompConfig: { model: 'crof/glm-5.2', resumeSessionId: 'session-99' },
|
||||
})
|
||||
).toBe('omp --model crof/glm-5.2 --resume session-99');
|
||||
|
||||
expect(
|
||||
buildSpawnCommand({
|
||||
mode: 'omp',
|
||||
sessionId: 'abc12345',
|
||||
ompConfig: { resumeSessionId: 'x; rm -rf /' },
|
||||
})
|
||||
).toBe('omp');
|
||||
});
|
||||
|
||||
it('continues the most recent session when no explicit resume id is given', () => {
|
||||
expect(
|
||||
buildSpawnCommand({
|
||||
mode: 'omp',
|
||||
sessionId: 'abc12345',
|
||||
ompConfig: { continueSession: true },
|
||||
})
|
||||
).toBe('omp --continue');
|
||||
});
|
||||
|
||||
it('prefers an explicit --resume id over --continue', () => {
|
||||
expect(
|
||||
buildSpawnCommand({
|
||||
mode: 'omp',
|
||||
sessionId: 'abc12345',
|
||||
ompConfig: { resumeSessionId: 'session-99', continueSession: true },
|
||||
})
|
||||
).toBe('omp --resume session-99');
|
||||
});
|
||||
|
||||
it('drops unsafe model strings from the spawn command', () => {
|
||||
expect(
|
||||
buildSpawnCommand({
|
||||
mode: 'omp',
|
||||
sessionId: 'abc12345',
|
||||
ompConfig: { model: 'a`b' },
|
||||
})
|
||||
).toBe('omp');
|
||||
});
|
||||
});
|
||||
|
||||
describe('OMP mode gates', () => {
|
||||
it('is an external CLI mode (readiness/ralph/respawn gating)', () => {
|
||||
expect(isExternalCliMode('omp')).toBe(true);
|
||||
});
|
||||
|
||||
it('is NOT an alt-screen strip mode (unverified TUI, like opencode/antigravity)', () => {
|
||||
expect(isAltScreenStripMode('omp')).toBe(false);
|
||||
});
|
||||
|
||||
it('has docker/remote default commands', () => {
|
||||
expect(defaultDockerCommandForMode('omp')).toBe('exec omp');
|
||||
// Routed through an interactive login shell so per-user PATH entries resolve —
|
||||
// same fix as the other remote agent CLIs (see defaultRemoteCommandForMode).
|
||||
expect(defaultRemoteCommandForMode('omp')).toBe('exec "${SHELL:-/bin/sh}" -i -l -c \'omp\'');
|
||||
});
|
||||
});
|
||||
|
||||
describe('OMP multi-user clamp: the env-var half', () => {
|
||||
// Unlike DeepSeek, omp has no permission FLAG or CONFIG for the clamp to
|
||||
// gate (buildOmpCommand() only ever emits --model/--resume/--continue), so
|
||||
// the only privilege surface is the two credential-resolution env vars the
|
||||
// OMP_* prefix admits.
|
||||
const ORIGINAL = process.env.CODEMAN_MULTIUSER;
|
||||
beforeEach(() => {
|
||||
process.env.CODEMAN_MULTIUSER = '1';
|
||||
});
|
||||
afterEach(() => {
|
||||
if (ORIGINAL === undefined) delete process.env.CODEMAN_MULTIUSER;
|
||||
else process.env.CODEMAN_MULTIUSER = ORIGINAL;
|
||||
});
|
||||
|
||||
it('strips OMP_AUTH_BROKER_URL and OMP_AUTH_BROKER_TOKEN, leaving unrelated overrides alone', async () => {
|
||||
const out = await _clampEnvOverridesForOwner('nobody', {
|
||||
OMP_AUTH_BROKER_URL: 'https://attacker.example/broker',
|
||||
OMP_AUTH_BROKER_TOKEN: 'stolen-token',
|
||||
OMP_PROFILE: 'default',
|
||||
});
|
||||
expect(out).toEqual({ OMP_PROFILE: 'default' });
|
||||
});
|
||||
|
||||
it('is a no-op in single-user mode', async () => {
|
||||
delete process.env.CODEMAN_MULTIUSER;
|
||||
const input = { OMP_AUTH_BROKER_URL: 'https://attacker.example/broker' };
|
||||
expect(await _clampEnvOverridesForOwner(undefined, input)).toBe(input);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,128 @@
|
||||
/**
|
||||
* @fileoverview Tests for OMP session-id resolution from disk.
|
||||
*
|
||||
* Pins the home-relative directory mangling bug found 2026-08-27: omp
|
||||
* collapses a home-relative workingDir to its home-relative remainder BEFORE
|
||||
* dash-replacing (`/home/user/dev/foo` -> `-dev-foo`), unlike Claude Code's
|
||||
* `~/.claude/projects/*` convention (`-home-user-dev-foo`) this module was
|
||||
* originally written to mirror. Getting this wrong doesn't throw — it just
|
||||
* makes findLatestOmpSessionId() silently return null for every case under
|
||||
* $HOME (virtually all real Codeman cases), so continuation pinning quietly
|
||||
* degraded to omp's own ambiguous `--continue` while appearing to work in
|
||||
* manual testing done entirely under /tmp (which sits outside $HOME and was
|
||||
* mangled correctly by coincidence).
|
||||
*
|
||||
* test/setup.ts gives this file its own temp $HOME, so homedir() below is
|
||||
* already sandboxed — writing real files under it is safe and exercises the
|
||||
* exact home-relative path the bug hid behind.
|
||||
*/
|
||||
import { mkdirSync, rmSync, utimesSync, writeFileSync } from 'node:fs';
|
||||
import { homedir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { afterEach, describe, expect, it } from 'vitest';
|
||||
import { findLatestOmpSessionId, mangleOmpWorkingDir } from '../src/utils/omp-session-resolver.js';
|
||||
import { resolveOmpConfigForCreate } from '../src/web/routes/session-routes.js';
|
||||
|
||||
describe('mangleOmpWorkingDir', () => {
|
||||
it('strips the home prefix before dash-replacing a home-relative path', () => {
|
||||
const home = homedir();
|
||||
expect(mangleOmpWorkingDir(join(home, 'codeman-cases', 'testcase'))).toBe('-codeman-cases-testcase');
|
||||
});
|
||||
|
||||
it('dash-replaces a path outside $HOME as-is', () => {
|
||||
expect(mangleOmpWorkingDir('/tmp/omp-verify-case')).toBe('-tmp-omp-verify-case');
|
||||
});
|
||||
|
||||
it('treats workingDir === home as the empty remainder', () => {
|
||||
expect(mangleOmpWorkingDir(homedir())).toBe('');
|
||||
});
|
||||
|
||||
it('does not false-positive on a sibling directory sharing a prefix with $HOME', () => {
|
||||
const sibling = `${homedir()}-other/dev/foo`;
|
||||
expect(mangleOmpWorkingDir(sibling)).toBe(sibling.replace(/\//g, '-'));
|
||||
});
|
||||
});
|
||||
|
||||
describe('findLatestOmpSessionId', () => {
|
||||
const sessionDir = join(homedir(), '.omp', 'agent', 'sessions', '-codeman-cases-testcase');
|
||||
|
||||
afterEach(() => {
|
||||
rmSync(join(homedir(), '.omp'), { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('finds the newest session file under a home-relative workingDir', () => {
|
||||
const workingDir = join(homedir(), 'codeman-cases', 'testcase');
|
||||
mkdirSync(sessionDir, { recursive: true });
|
||||
writeFileSync(join(sessionDir, '2026-08-27T17-15-57-989Z_older-id.jsonl'), '{}');
|
||||
const newer = join(sessionDir, '2026-08-27T17-31-08-001Z_newer-id.jsonl');
|
||||
writeFileSync(newer, '{}');
|
||||
// Force a deterministic mtime order regardless of filesystem timestamp resolution.
|
||||
const now = Date.now() / 1000;
|
||||
utimesSync(join(sessionDir, '2026-08-27T17-15-57-989Z_older-id.jsonl'), now, now);
|
||||
utimesSync(newer, now + 1, now + 1);
|
||||
|
||||
expect(findLatestOmpSessionId(workingDir)).toBe('newer-id');
|
||||
});
|
||||
|
||||
it('returns null when the mangled directory does not exist', () => {
|
||||
expect(findLatestOmpSessionId(join(homedir(), 'never-launched'))).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolveOmpConfigForCreate', () => {
|
||||
// The exact pipeline "resume this OMP row from the history list" drives:
|
||||
// POST /api/sessions with mode:'omp' + ompConfig:{continueSession:true}
|
||||
// must come back with resumeSessionId PINNED to the real omp transcript
|
||||
// uuid, not left as the ambiguous continueSession flag alone. This was the
|
||||
// one path flagged by review as having zero coverage despite being the
|
||||
// exact mechanism the whole resolver module exists to serve.
|
||||
const workingDir = join(homedir(), 'codeman-cases', 'resume-test');
|
||||
const sessionDir = join(homedir(), '.omp', 'agent', 'sessions', '-codeman-cases-resume-test');
|
||||
|
||||
afterEach(() => {
|
||||
rmSync(join(homedir(), '.omp'), { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('pins resumeSessionId from disk when resuming with only continueSession set', () => {
|
||||
mkdirSync(sessionDir, { recursive: true });
|
||||
writeFileSync(join(sessionDir, '2026-08-27T17-31-08-001Z_real-omp-uuid.jsonl'), '{}');
|
||||
|
||||
const resolved = resolveOmpConfigForCreate('omp', workingDir, { continueSession: true });
|
||||
|
||||
expect(resolved).toEqual({ continueSession: true, resumeSessionId: 'real-omp-uuid' });
|
||||
});
|
||||
|
||||
it('does not attempt resolution when resumeSessionId is already explicit', () => {
|
||||
mkdirSync(sessionDir, { recursive: true });
|
||||
writeFileSync(join(sessionDir, '2026-08-27T17-31-08-001Z_disk-uuid.jsonl'), '{}');
|
||||
|
||||
const resolved = resolveOmpConfigForCreate('omp', workingDir, {
|
||||
continueSession: true,
|
||||
resumeSessionId: 'already-pinned',
|
||||
});
|
||||
|
||||
// Must return the caller's id unchanged, never overwrite it with whatever
|
||||
// happens to be newest on disk.
|
||||
expect(resolved).toEqual({ continueSession: true, resumeSessionId: 'already-pinned' });
|
||||
});
|
||||
|
||||
it('leaves ompConfig unchanged when continueSession is not set', () => {
|
||||
const resolved = resolveOmpConfigForCreate('omp', workingDir, {});
|
||||
expect(resolved).toEqual({});
|
||||
});
|
||||
|
||||
it('leaves ompConfig unchanged when nothing is on disk to resolve', () => {
|
||||
const resolved = resolveOmpConfigForCreate('omp', join(homedir(), 'never-launched'), {
|
||||
continueSession: true,
|
||||
});
|
||||
expect(resolved).toEqual({ continueSession: true });
|
||||
});
|
||||
|
||||
it('returns undefined for a non-omp mode regardless of ompConfig', () => {
|
||||
expect(resolveOmpConfigForCreate('claude', workingDir, { continueSession: true })).toBeUndefined();
|
||||
});
|
||||
|
||||
it('returns undefined when ompConfig is undefined', () => {
|
||||
expect(resolveOmpConfigForCreate('omp', workingDir, undefined)).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -20,6 +20,7 @@ import { isAntigravityAvailable } from '../src/utils/antigravity-cli-resolver.js
|
||||
import { isPiAvailable } from '../src/utils/pi-cli-resolver.js';
|
||||
import { isGrokAvailable } from '../src/utils/grok-cli-resolver.js';
|
||||
import { isDeepSeekAvailable, isDeepSeekRunnable } from '../src/utils/deepseek-cli-resolver.js';
|
||||
import { isOmpAvailable } from '../src/utils/omp-cli-resolver.js';
|
||||
import { isCloudflaredAvailable } from '../src/utils/cloudflared-resolver.js';
|
||||
import { isGitAvailable } from '../src/git-clone.js';
|
||||
|
||||
@@ -66,6 +67,10 @@ vi.mock('../src/utils/deepseek-cli-resolver.js', () => ({
|
||||
listDeepSeekProfiles: vi.fn(() => []),
|
||||
resolveDefaultDeepSeekProfile: vi.fn(() => null),
|
||||
}));
|
||||
vi.mock('../src/utils/omp-cli-resolver.js', () => ({
|
||||
isOmpAvailable: vi.fn(() => false),
|
||||
resolveOmpDir: vi.fn(() => null),
|
||||
}));
|
||||
vi.mock('../src/utils/cloudflared-resolver.js', () => ({
|
||||
isCloudflaredAvailable: vi.fn(() => false),
|
||||
resolveCloudflaredPath: vi.fn(() => null),
|
||||
@@ -156,6 +161,9 @@ describe('WebServer.renderIndexHtml', () => {
|
||||
vi.mocked(isAntigravityAvailable).mockReturnValue(false);
|
||||
vi.mocked(isPiAvailable).mockReturnValue(true);
|
||||
vi.mocked(isGrokAvailable).mockReturnValue(false);
|
||||
vi.mocked(isDeepSeekAvailable).mockReturnValue(false);
|
||||
vi.mocked(isDeepSeekRunnable).mockReturnValue(false);
|
||||
vi.mocked(isOmpAvailable).mockReturnValue(true);
|
||||
vi.mocked(isCloudflaredAvailable).mockReturnValue(true);
|
||||
vi.mocked(isGitAvailable).mockReturnValue(true);
|
||||
const { server } = makeServer({});
|
||||
@@ -173,6 +181,7 @@ describe('WebServer.renderIndexHtml', () => {
|
||||
grok: false,
|
||||
deepseek: false,
|
||||
deepseekBinary: false,
|
||||
omp: true,
|
||||
cloudflared: true,
|
||||
git: true,
|
||||
});
|
||||
@@ -191,6 +200,7 @@ describe('WebServer.renderIndexHtml', () => {
|
||||
isGrokAvailable,
|
||||
isDeepSeekAvailable,
|
||||
isDeepSeekRunnable,
|
||||
isOmpAvailable,
|
||||
isCloudflaredAvailable,
|
||||
isGitAvailable,
|
||||
]) {
|
||||
|
||||
@@ -0,0 +1,143 @@
|
||||
/**
|
||||
* @fileoverview Upstream review fix (Ark0N/Codeman#353, PR #3): resumeHistorySession()
|
||||
* threads the row's own mode through session creation via a `modeConfigKey` map
|
||||
* (opencode/pi/grok/omp → `continueSession: true`), then retires the old row via
|
||||
* DELETE. codex/gemini/antigravity were missing from that map, so resuming one of
|
||||
* their rows created a session with NO continuation while still deleting the row
|
||||
* it came from — data loss dressed as a fix. The correction: only retire the row
|
||||
* when the new session actually continues something.
|
||||
*
|
||||
* Loaded via `vm` against a stub CodemanApp, same harness as resume-name.test.ts.
|
||||
* `fetch` is a shared mutable stub so each test can inspect exactly which requests
|
||||
* fired without a real network/server.
|
||||
*/
|
||||
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { resolve } from 'node:path';
|
||||
import vm from 'node:vm';
|
||||
import { describe, expect, it, vi, beforeEach } from 'vitest';
|
||||
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
|
||||
/** The fetch the vm's shipping code calls; swapped per test (see beforeEach). */
|
||||
let currentFetch: (...args: unknown[]) => unknown = () => {
|
||||
throw new Error('fetch not stubbed for this test');
|
||||
};
|
||||
|
||||
function loadTerminalUiPrototype(): Record<string, (...args: unknown[]) => unknown> {
|
||||
const source = readFileSync(resolve(import.meta.dirname, '../src/web/public/terminal-ui.js'), 'utf8');
|
||||
const context = vm.createContext({
|
||||
console,
|
||||
CodemanApp: class CodemanApp {},
|
||||
setInterval: vi.fn(),
|
||||
clearInterval: vi.fn(),
|
||||
setTimeout,
|
||||
clearTimeout,
|
||||
requestAnimationFrame: vi.fn(),
|
||||
document: { addEventListener: vi.fn(), getElementById: vi.fn(() => null) },
|
||||
window: { addEventListener: vi.fn(), removeEventListener: vi.fn() },
|
||||
fetch: (...args: unknown[]) => currentFetch(...args),
|
||||
});
|
||||
vm.runInContext(`${source}\nglobalThis.__proto = CodemanApp.prototype;`, context);
|
||||
return (context as { __proto: Record<string, (...args: unknown[]) => unknown> }).__proto;
|
||||
}
|
||||
|
||||
const proto = loadTerminalUiPrototype();
|
||||
|
||||
function makeApp() {
|
||||
return {
|
||||
terminal: { clear: vi.fn(), writeln: vi.fn(), focus: vi.fn() },
|
||||
cases: [],
|
||||
resumeHistorySession: proto.resumeHistorySession as (...args: unknown[]) => Promise<void>,
|
||||
_closeFolderHistoryModal: vi.fn(),
|
||||
_resolveResumeName: () => 'w1-case',
|
||||
loadAppSettingsFromStorage: () => ({}),
|
||||
getCaseSettings: () => ({}),
|
||||
buildEnvOverrides: () => ({}),
|
||||
getEffortSetting: () => undefined,
|
||||
selectSession: vi.fn(async () => {}),
|
||||
};
|
||||
}
|
||||
|
||||
/** DELETE calls the fetch mock recorded. */
|
||||
function deleteCalls(fetchMock: ReturnType<typeof vi.fn>): string[] {
|
||||
return fetchMock.mock.calls
|
||||
.filter(([, opts]: [string, { method?: string }]) => opts?.method === 'DELETE')
|
||||
.map(([url]: [string]) => url);
|
||||
}
|
||||
|
||||
/** POST /api/sessions body the fetch mock recorded. */
|
||||
function createBody(fetchMock: ReturnType<typeof vi.fn>): any {
|
||||
const call = fetchMock.mock.calls.find(([url]: [string]) => url === '/api/sessions');
|
||||
return call ? JSON.parse((call[1] as { body: string }).body) : undefined;
|
||||
}
|
||||
|
||||
function stubFetch(newSessionId: string): ReturnType<typeof vi.fn> {
|
||||
const fetchMock = vi.fn(async (url: string) => {
|
||||
if (url === '/api/sessions') {
|
||||
return { json: async () => ({ success: true, data: { session: { id: newSessionId } } }) };
|
||||
}
|
||||
return { json: async () => ({ success: true }) };
|
||||
});
|
||||
currentFetch = fetchMock;
|
||||
return fetchMock;
|
||||
}
|
||||
|
||||
describe('resumeHistorySession: row retirement is gated on actual continuation', () => {
|
||||
let fetchMock: ReturnType<typeof vi.fn>;
|
||||
|
||||
beforeEach(() => {
|
||||
fetchMock = stubFetch('new-session-id');
|
||||
});
|
||||
|
||||
it.each(['codex', 'gemini', 'antigravity'])(
|
||||
'does NOT retire the old row for %s (no continuation is wired for it)',
|
||||
async (mode) => {
|
||||
const app = makeApp();
|
||||
await app.resumeHistorySession.call(app, 'old-id', '/repo', 'w1-repo', mode);
|
||||
|
||||
expect(createBody(fetchMock)).toMatchObject({ mode });
|
||||
expect(createBody(fetchMock).codexConfig).toBeUndefined();
|
||||
expect(createBody(fetchMock).geminiConfig).toBeUndefined();
|
||||
expect(createBody(fetchMock).antigravityConfig).toBeUndefined();
|
||||
expect(deleteCalls(fetchMock)).toEqual([]);
|
||||
}
|
||||
);
|
||||
|
||||
it.each([
|
||||
['opencode', 'openCodeConfig'],
|
||||
['pi', 'piConfig'],
|
||||
['grok', 'grokConfig'],
|
||||
['omp', 'ompConfig'],
|
||||
])('retires the old row for %s (continueSession is wired via %s)', async (mode, configKey) => {
|
||||
const app = makeApp();
|
||||
await app.resumeHistorySession.call(app, 'old-id', '/repo', 'w1-repo', mode);
|
||||
|
||||
expect(createBody(fetchMock)[configKey]).toEqual({ continueSession: true });
|
||||
expect(deleteCalls(fetchMock)).toEqual(['/api/sessions/old-id?killMux=true']);
|
||||
});
|
||||
|
||||
it('retires the old row for deepseek (resumeSession is wired)', async () => {
|
||||
const app = makeApp();
|
||||
await app.resumeHistorySession.call(app, 'old-id', '/repo', 'w1-repo', 'deepseek');
|
||||
|
||||
expect(createBody(fetchMock).deepSeekConfig).toEqual({ resumeSession: true });
|
||||
expect(deleteCalls(fetchMock)).toEqual(['/api/sessions/old-id?killMux=true']);
|
||||
});
|
||||
|
||||
it('never retires a claude row (resumeSessionId is a claudeSessionId, not a Codeman row id)', async () => {
|
||||
const app = makeApp();
|
||||
await app.resumeHistorySession.call(app, 'claude-uuid', '/repo', 'w1-repo', 'claude');
|
||||
|
||||
expect(createBody(fetchMock)).toMatchObject({ mode: 'claude', resumeSessionId: 'claude-uuid' });
|
||||
expect(deleteCalls(fetchMock)).toEqual([]);
|
||||
});
|
||||
|
||||
it('never retires when the new session id equals the old one (no-op resume)', async () => {
|
||||
fetchMock = stubFetch('same-id');
|
||||
const app = makeApp();
|
||||
await app.resumeHistorySession.call(app, 'same-id', '/repo', 'w1-repo', 'omp');
|
||||
|
||||
expect(deleteCalls(fetchMock)).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -341,6 +341,36 @@ describe('session-routes', () => {
|
||||
const body = JSON.parse(res.body);
|
||||
expect(body.success).toBe(false);
|
||||
});
|
||||
|
||||
it('removes a persisted-only session (not live) via the state store, without touching cleanupSession', async () => {
|
||||
vi.mocked(harness.ctx.store.getSession).mockReturnValueOnce({
|
||||
id: 'ghost-session',
|
||||
owner: undefined,
|
||||
} as never);
|
||||
const res = await harness.app.inject({
|
||||
method: 'DELETE',
|
||||
url: '/api/sessions/ghost-session',
|
||||
});
|
||||
expect(res.statusCode).toBe(200);
|
||||
const body = JSON.parse(res.body);
|
||||
expect(body.success).toBe(true);
|
||||
expect(harness.ctx.store.demoteOrRemoveSession).toHaveBeenCalledWith('ghost-session');
|
||||
expect(harness.ctx.cleanupSession).not.toHaveBeenCalled();
|
||||
// Ark0N/Codeman#353 review: the persisted-only branch used to demote/remove
|
||||
// with no broadcast, so other open tabs kept showing the retired row until
|
||||
// their next unrelated fetch.
|
||||
expect(harness.ctx.broadcast).toHaveBeenCalledWith('session:deleted', { id: 'ghost-session' });
|
||||
});
|
||||
|
||||
it('404s a persisted-only session id the state store does not recognize either', async () => {
|
||||
vi.mocked(harness.ctx.store.getSession).mockReturnValueOnce(null);
|
||||
const res = await harness.app.inject({
|
||||
method: 'DELETE',
|
||||
url: '/api/sessions/truly-nonexistent',
|
||||
});
|
||||
expect(res.statusCode).toBe(404);
|
||||
expect(harness.ctx.store.demoteOrRemoveSession).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
// ========== DELETE /api/sessions (delete all) ==========
|
||||
|
||||
@@ -81,6 +81,16 @@ describe('run mode UI', () => {
|
||||
expect(app.runMode).toBe('antigravity');
|
||||
expect(runBtnLabel.textContent).toBe('Run AG');
|
||||
});
|
||||
|
||||
it('accepts OMP mode from server sync and updates the run button label', async () => {
|
||||
const { app, storage, runBtnLabel } = loadRunModeHarness();
|
||||
|
||||
storage.set('codeman_runMode', 'claude');
|
||||
await app.loadAppSettingsFromServer(Promise.resolve({ runMode: 'omp' }));
|
||||
|
||||
expect(app.runMode).toBe('omp');
|
||||
expect(runBtnLabel.textContent).toBe('Run OMP');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Run launch synchronization', () => {
|
||||
@@ -367,12 +377,13 @@ describe('Codex quick start settings', () => {
|
||||
'welcomeGeminiBtn',
|
||||
'welcomePiBtn',
|
||||
'welcomeGrokBtn',
|
||||
'welcomeOmpBtn',
|
||||
'welcomeTunnelBtn',
|
||||
]) {
|
||||
welcomeBtns[id] = { style: { display: 'PRISTINE' } };
|
||||
}
|
||||
const modeBtns: Record<string, { style: { display: string } }> = {};
|
||||
for (const mode of ['claude', 'opencode', 'codex', 'gemini', 'antigravity', 'pi', 'grok', 'shell']) {
|
||||
for (const mode of ['claude', 'opencode', 'codex', 'gemini', 'antigravity', 'pi', 'grok', 'omp', 'shell']) {
|
||||
modeBtns[mode] = { style: { display: 'PRISTINE' } };
|
||||
}
|
||||
const menu = {
|
||||
@@ -405,6 +416,7 @@ describe('Codex quick start settings', () => {
|
||||
antigravity: false,
|
||||
pi: false,
|
||||
grok: false,
|
||||
omp: false,
|
||||
cloudflared: false,
|
||||
};
|
||||
|
||||
@@ -442,16 +454,23 @@ describe('Codex quick start settings', () => {
|
||||
withAgy.app.applyWelcomeCliVisibility();
|
||||
expect(withAgy.welcomeBtns.welcomeAntigravityBtn.style.display).toBe('flex');
|
||||
expect(withAgy.welcomeBtns.welcomeClaudeBtn.style.display).toBe('none');
|
||||
|
||||
// OMP is a first-class welcome action, gated on `omp` like the rest.
|
||||
const withOmp = loadUi({ ...ALL_OFF, omp: true });
|
||||
withOmp.app.applyWelcomeCliVisibility();
|
||||
expect(withOmp.welcomeBtns.welcomeOmpBtn.style.display).toBe('flex');
|
||||
expect(withOmp.welcomeBtns.welcomeClaudeBtn.style.display).toBe('none');
|
||||
});
|
||||
|
||||
it('gates every run mode in the dropdown, antigravity included, and never shell', () => {
|
||||
const { app, modeBtns, menu } = loadUi({ ...ALL_OFF, claude: true, antigravity: true });
|
||||
const { app, modeBtns, menu } = loadUi({ ...ALL_OFF, claude: true, antigravity: true, omp: true });
|
||||
app._refreshRunModeAvailability(menu);
|
||||
expect(modeBtns.claude.style.display).toBe('flex');
|
||||
expect(modeBtns.antigravity.style.display).toBe('flex');
|
||||
expect(modeBtns.opencode.style.display).toBe('none');
|
||||
expect(modeBtns.codex.style.display).toBe('none');
|
||||
expect(modeBtns.gemini.style.display).toBe('none');
|
||||
expect(modeBtns.omp.style.display).toBe('flex');
|
||||
// Shell needs no external CLI, and leaving it alone is what guarantees the
|
||||
// menu is never empty on a box with nothing installed.
|
||||
expect(modeBtns.shell.style.display).toBe('PRISTINE');
|
||||
@@ -468,6 +487,7 @@ describe('Codex quick start settings', () => {
|
||||
expect(offered).toContain('antigravity');
|
||||
expect(offered).toContain('pi');
|
||||
expect(offered).toContain('grok');
|
||||
expect(offered).toContain('omp');
|
||||
const src = readFileSync(resolve(import.meta.dirname, '../src/web/public/session-ui.js'), 'utf8');
|
||||
// Anchor on the DEFINITION, not the earlier call site in toggleRunModeMenu.
|
||||
const fn = src.slice(src.indexOf('_refreshRunModeAvailability(menu) {'));
|
||||
|
||||
Reference in New Issue
Block a user