Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions backend/__tests__/unit/models/Session.contextSettings.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
const Session = require('../../../models/Session');

test('session schema has no unused context settings or compaction predicate', () => {
expect(Session.schema.path('contextSettings')).toBeUndefined();
expect(Session.schema.methods.shouldCompact).toBeUndefined();
const session = new Session({
sessionKey: 'agent:pod:session',
podId: '111111111111111111111111',
});
expect(session.toObject()).not.toHaveProperty('contextSettings');
});
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,11 @@ jest.mock('../../../services/runtimeIdentityOwnership', () => ({
...jest.requireActual('../../../services/runtimeIdentityOwnership'),
ownsRuntimeIdentity: jest.fn().mockResolvedValue({ owned: true }),
}));
jest.mock('../../../services/agentProvisionerService', () => ({
...jest.requireActual('../../../services/agentProvisionerService'),
readOpenClawIdentityFile: jest.fn(),
writeWorkspaceIdentityFile: jest.fn().mockResolvedValue(undefined),
}));

const Pod = require('../../../models/Pod');
const { AgentInstallation } = require('../../../models/AgentRegistry');
Expand All @@ -50,6 +55,7 @@ const User = require('../../../models/User');
const AgentIdentityService = require('../../../services/agentIdentityService');
const { ownsRuntimeIdentity } = require('../../../services/runtimeIdentityOwnership');
const { podHasOutsiderWritableGrant } = require('../../../services/nativeOutsiderGrantPolicy');
const { readOpenClawIdentityFile, writeWorkspaceIdentityFile } = require('../../../services/agentProvisionerService');
const registryRoutes = require('../../../routes/registry');

const app = express();
Expand Down Expand Up @@ -98,6 +104,8 @@ describe('agent config PATCH — installer gate', () => {
jest.spyOn(AgentIdentityService, 'renameAgentDisplayName').mockImplementation(async (_name, _instanceId, label) => ({ displayName: label, mirrorSynced: true }));
ownsRuntimeIdentity.mockResolvedValue({ owned: true });
podHasOutsiderWritableGrant.mockResolvedValue(false);
readOpenClawIdentityFile.mockResolvedValue('');
writeWorkspaceIdentityFile.mockResolvedValue(undefined);
setCallerRole('user');
});
afterEach(() => jest.restoreAllMocks());
Expand Down Expand Up @@ -233,6 +241,198 @@ describe('agent config PATCH — installer gate', () => {
);
});

it('keeps the existing OpenClaw persona in IDENTITY.md on a name-only edit', async () => {
const primary = installation({ installedBy: MEMBER, displayName: 'Old name' });
setPod();
AgentInstallation.findOne.mockResolvedValue(primary);
AgentInstallation.find.mockResolvedValue([primary]);
readOpenClawIdentityFile.mockResolvedValue('# IDENTITY.md\n\n- **Name:** Old name\n- **Vibe:** Precise\n- **Domain:** Research\n\n## Notes\n\nKeep detail\n');

const res = await request(app)
.patch('/api/registry/pods/pod-1/agents/openclaw')
.send({ instanceId: 'curator', displayName: 'New name' });

expect(res.status).toBe(200);
await new Promise((resolve) => setImmediate(resolve));
expect(readOpenClawIdentityFile).toHaveBeenCalledWith('curator');
expect(writeWorkspaceIdentityFile).toHaveBeenCalledWith('curator', '# IDENTITY.md\n\n- **Name:** New name\n- **Vibe:** Precise\n- **Domain:** Research\n\n## Notes\n\nKeep detail\n');
expect(AgentProfile.updateMany).toHaveBeenCalledWith(
expect.any(Object),
{ $set: { name: 'New name' } },
{ runValidators: true },
);
});

it('keeps the runtime Name on an OpenClaw Persona-only edit', async () => {
const primary = installation({ installedBy: MEMBER, displayName: 'Scout the researcher' });
setPod();
AgentInstallation.findOne.mockResolvedValue(primary);
AgentInstallation.find.mockResolvedValue([primary]);
readOpenClawIdentityFile.mockResolvedValue('# IDENTITY.md\n\n- **Name:** Scout the researcher\n- **Vibe:** Warm\n');

const persona = { tone: 'professional', specialties: ['research'], boundaries: [], customInstructions: 'Be precise' };
const res = await request(app)
.patch('/api/registry/pods/pod-1/agents/openclaw')
.send({ instanceId: 'curator', persona });

expect(res.status).toBe(200);
await new Promise((resolve) => setImmediate(resolve));
expect(AgentIdentityService.renameAgentDisplayName).not.toHaveBeenCalled();
expect(primary.displayName).toBe('Scout the researcher');
expect(writeWorkspaceIdentityFile).toHaveBeenCalledWith(
'curator',
expect.stringContaining('- **Name:** Scout the researcher\n'),
);
expect(writeWorkspaceIdentityFile).toHaveBeenCalledWith(
'curator',
expect.stringContaining('- **Domain:** research\n'),
);
});

it('awaits a name write and serializes an overlapping persona edit', async () => {
const primary = installation({ installedBy: MEMBER, displayName: 'Old name' });
setPod();
AgentInstallation.findOne.mockResolvedValue(primary);
AgentInstallation.find.mockResolvedValue([primary]);
let content = '# IDENTITY.md\n\n- **Name:** Old name\n- **Vibe:** Warm\n';
readOpenClawIdentityFile.mockImplementation(async () => content);
let releaseRename;
let markRenameStarted;
const renameStarted = new Promise((resolve) => { markRenameStarted = resolve; });
const renameGate = new Promise((resolve) => { releaseRename = resolve; });
writeWorkspaceIdentityFile.mockImplementationOnce(async (_id, next) => {
markRenameStarted();
await renameGate;
content = next;
}).mockImplementation(async (_id, next) => { content = next; });
let renameFinished = false;
const rename = request(app).patch('/api/registry/pods/pod-1/agents/openclaw')
.send({ instanceId: 'curator', displayName: 'New name' })
.then((res) => { renameFinished = true; return res; });
await renameStarted;
const persona = { tone: 'professional', specialties: ['research'], boundaries: [], customInstructions: 'Be precise' };
let markPersonaSaved;
const personaSaved = new Promise((resolve) => { markPersonaSaved = resolve; });
AgentProfile.updateMany.mockImplementationOnce(async () => { markPersonaSaved(); });
const edit = request(app).patch('/api/registry/pods/pod-1/agents/openclaw')
.send({ instanceId: 'curator', persona }).then((res) => res);
await personaSaved;
await new Promise((resolve) => setImmediate(resolve));
expect(renameFinished).toBe(false);
expect(readOpenClawIdentityFile).toHaveBeenCalledTimes(1);
releaseRename();
expect((await rename).status).toBe(200);
expect((await edit).status).toBe(200);
expect(content).toContain('- **Name:** New name\n');
expect(content).toContain('- **Domain:** research\n');
expect(AgentProfile.updateMany).toHaveBeenLastCalledWith(
{ agentId: 'openclaw:curator', podId: { $in: ['pod-1'] } },
{ $set: { persona } },
{ runValidators: true },
);
});

it('reports a failed identity write and permits a subsequent retry', async () => {
const primary = installation({ installedBy: MEMBER, displayName: 'Old name' });
setPod();
AgentInstallation.findOne.mockResolvedValue(primary);
AgentInstallation.find.mockResolvedValue([primary]);
readOpenClawIdentityFile.mockResolvedValue('- **Name:** Old name\n');
writeWorkspaceIdentityFile.mockRejectedValueOnce(new Error('write unavailable'));
const save = () => request(app).patch('/api/registry/pods/pod-1/agents/openclaw')
.send({ instanceId: 'curator', displayName: 'New name' });
const failed = await save();
expect(failed.status).toBe(503);
expect(failed.body.code).toBe('identity_projection_retryable');
expect((await save()).status).toBe(200);
expect(writeWorkspaceIdentityFile).toHaveBeenCalledTimes(2);
});

it('propagates a real identity reader permission failure into a retryable rename response', async () => {
const fs = require('fs');
const actualReader = jest.requireActual('../../../services/agentProvisionerService').readOpenClawIdentityFile;
const previousMode = process.env.AGENT_PROVISIONER_K8S;
process.env.AGENT_PROVISIONER_K8S = '0';
const primary = installation({ installedBy: MEMBER, displayName: 'Old name' });
setPod();
AgentInstallation.findOne.mockResolvedValue(primary);
AgentInstallation.find.mockResolvedValue([primary]);
const originalRead = fs.readFileSync;
const originalExists = fs.existsSync;
const existsSpy = jest.spyOn(fs, 'existsSync').mockImplementation((file) => (
String(file).endsWith('/curator/IDENTITY.md') || originalExists(file)
));
let reachedReader = false;
const readSpy = jest.spyOn(fs, 'readFileSync').mockImplementation((file, ...args) => {
if (String(file).endsWith('/curator/IDENTITY.md')) {
reachedReader = true;
throw Object.assign(new Error('Permission denied'), { code: 'EACCES' });
}
return originalRead(file, ...args);
});
readOpenClawIdentityFile.mockImplementation(actualReader);
try {
const res = await request(app).patch('/api/registry/pods/pod-1/agents/openclaw')
.send({ instanceId: 'curator', displayName: 'New name' });
expect(reachedReader).toBe(true);
expect(res.status).toBe(503);
expect(res.body.code).toBe('identity_projection_retryable');
expect(writeWorkspaceIdentityFile).not.toHaveBeenCalled();
} finally {
readSpy.mockRestore();
existsSpy.mockRestore();
if (previousMode === undefined) delete process.env.AGENT_PROVISIONER_K8S;
else process.env.AGENT_PROVISIONER_K8S = previousMode;
}
});

it.each(['OPENCLAW_WORKSPACE_ROOT', 'CLAWDBOT_WORKSPACE_DIR'])(
'uses the real %s identity file for name and persona edits', async (rootKey) => {
const fs = require('fs');
const os = require('os');
const path = require('path');
const actual = jest.requireActual('../../../services/agentProvisionerService');
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'identity-projection-'));
const envKeys = ['AGENT_PROVISIONER_K8S', 'OPENCLAW_WORKSPACE_ROOT', 'CLAWDBOT_WORKSPACE_DIR', 'OPENCLAW_WORKSPACE_PATH'];
const previous = Object.fromEntries(envKeys.map((key) => [key, process.env[key]]));
envKeys.forEach((key) => { delete process.env[key]; });
process.env.AGENT_PROVISIONER_K8S = '0';
process.env[rootKey] = root;
const primary = installation({ installedBy: MEMBER, displayName: 'Database label' });
setPod();
AgentInstallation.findOne.mockResolvedValue(primary);
AgentInstallation.find.mockResolvedValue([primary]);
readOpenClawIdentityFile.mockImplementation(actual.readOpenClawIdentityFile);
writeWorkspaceIdentityFile.mockImplementation(actual.writeWorkspaceIdentityFile);
const identityPath = path.join(root, 'curator', 'IDENTITY.md');
const original = '# IDENTITY.md\n\n- **Name:** Runtime name\n- **Vibe:** Warm\n- **Domain:** Research\n\n## Notes\nKeep detail\n';
try {
await actual.writeWorkspaceIdentityFile('curator', original);
const renamed = await request(app).patch('/api/registry/pods/pod-1/agents/openclaw')
.send({ instanceId: 'curator', displayName: 'New name' });
expect(renamed.status).toBe(200);
expect(fs.readFileSync(identityPath, 'utf8')).toBe(original.replace('Runtime name', 'New name'));

// Distinct runtime and database labels prove Name came from the file.
await actual.writeWorkspaceIdentityFile('curator', original);
primary.displayName = 'Database label';
const persona = { tone: 'professional', specialties: ['research'], boundaries: [], customInstructions: 'Be precise' };
const edited = await request(app).patch('/api/registry/pods/pod-1/agents/openclaw')
.send({ instanceId: 'curator', persona });
expect(edited.status).toBe(200);
const saved = fs.readFileSync(identityPath, 'utf8');
expect(saved).toContain('- **Name:** Runtime name\n');
expect(saved).toContain('- **Domain:** research\n');
} finally {
envKeys.forEach((key) => {
if (previous[key] === undefined) delete process.env[key];
else process.env[key] = previous[key];
});
fs.rmSync(root, { recursive: true, force: true });
}
},
);

it('refuses an identity rename by an installer who shares that identity', async () => {
const primary = installation({ installedBy: MEMBER });
const peer = installation({ podId: 'pod-2', installedBy: 'other-installer' });
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -34,8 +34,10 @@ const mock = {
// these, so this is the only place a test can assert on the delivered file
// rather than on the source that composes it.
execCommands: [],
execArgv: [],
// What the next exec writes to its stdout sink; empty = no output.
execStdout: '',
execStatus: { status: 'Success' },
};

class KubeConfig {
Expand Down Expand Up @@ -71,13 +73,14 @@ const mock = {
statusCallback,
) {
mock.execCommands.push(Array.isArray(_command) ? _command.join(' ') : String(_command));
mock.execArgv.push(_command);
if (mock.execStdout && _stdout && typeof _stdout.write === 'function') {
_stdout.write(mock.execStdout);
if (typeof statusCallback === 'function') setImmediate(() => statusCallback({ status: 'Success' }));
return Promise.resolve();
}
if (typeof statusCallback === 'function') {
statusCallback({ status: 'Success' });
statusCallback(mock.execStatus);
}
return Promise.resolve();
}
Expand All @@ -94,6 +97,7 @@ const mock = {
const k8s = require('@kubernetes/client-node');
const {
restartAgentRuntime, provisionAgentRuntime, buildPvcVirtualKeyReadScript, issueLiteLLMVirtualKey,
readOpenClawIdentityFile,
} = require('../../../services/agentProvisionerServiceK8s');
const { CYCLES_DIRECTIVE_MARKER } = require('../../../routes/registry/presets');

Expand All @@ -103,6 +107,41 @@ describe('agentProvisionerServiceK8s', () => {
k8s.__mock.replaceNamespacedDeployment.mockClear();
});

it('distinguishes missing identities from read and parent-access failures', async () => {
k8s.__mock.execStatus = { status: 'Failure', message: 'Permission denied' };
try {
await expect(readOpenClawIdentityFile('curator')).rejects.toThrow('Permission denied');
const command = k8s.__mock.execArgv.at(-1);
const fs = require('fs');
const os = require('os');
const path = require('path');
const { spawnSync } = require('child_process');
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'identity-reader-'));
const identity = path.join(dir, 'IDENTITY.md');
const run = () => spawnSync(process.execPath, [...command.slice(1, -1), identity], { encoding: 'utf8' });
try {
expect(run().status).toBe(0);
fs.writeFileSync(identity, 'Name: Curator');
const read = run();
expect(read.status).toBe(0);
expect(read.stdout).toBe('Name: Curator');
fs.chmodSync(dir, 0o000);
const inaccessible = run();
expect(inaccessible.status).not.toBe(0);
expect(inaccessible.stderr).toContain('EACCES');
fs.chmodSync(dir, 0o700);
fs.unlinkSync(identity);
fs.mkdirSync(identity);
expect(run().status).not.toBe(0);
} finally {
fs.chmodSync(dir, 0o700);
fs.rmSync(dir, { recursive: true, force: true });
}
} finally {
k8s.__mock.execStatus = { status: 'Success' };
}
});

it('restarts the shared gateway deployment for OpenClaw runtimes', async () => {
const result = await restartAgentRuntime('moltbot', 'cuz');

Expand Down
17 changes: 0 additions & 17 deletions backend/models/Session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,16 +41,9 @@ export interface ISession extends Document {
};
state: SessionState;
lastActivityAt: Date;
contextSettings: {
maxTokens: number;
compactionThreshold: number;
includeMemory: boolean;
includeSkills: boolean;
};
createdAt: Date;
updatedAt: Date;
appendTranscript(entry: ITranscriptEntry): Promise<ISession>;
shouldCompact(): boolean;
shouldReset(): boolean;
compact(summaryContent: string): Promise<ISession>;
reset(): Promise<ISession>;
Expand Down Expand Up @@ -114,12 +107,6 @@ const SessionSchema = new Schema<ISession>(
default: 'active',
},
lastActivityAt: { type: Date, default: Date.now },
contextSettings: {
maxTokens: { type: Number, default: 8000 },
compactionThreshold: { type: Number, default: 6000 },
includeMemory: { type: Boolean, default: true },
includeSkills: { type: Boolean, default: true },
},
},
{ timestamps: true },
);
Expand Down Expand Up @@ -159,10 +146,6 @@ SessionSchema.methods.appendTranscript = async function (entry: ITranscriptEntry
return this.save();
};

SessionSchema.methods.shouldCompact = function (): boolean {
return this.tokenUsage.context >= this.contextSettings.compactionThreshold;
};

SessionSchema.methods.shouldReset = function (): boolean {
const now = new Date();

Expand Down
Loading
Loading