Skip to content

Commit a095eff

Browse files
committed
fix(cli): isolate source development profile
1 parent f0ba51a commit a095eff

22 files changed

Lines changed: 674 additions & 109 deletions

CONTRIBUTING.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -94,8 +94,8 @@ Architecture is documented in [ARCHITECTURE.md](./ARCHITECTURE.md).
9494
npm run dev # desktop app with HMR
9595
npm run dev:full # full build, then launch the desktop app
9696

97-
npm --workspace maka-agent exec -- maka # TUI
98-
npm --workspace maka-agent exec -- maka run "" # one non-interactive turn
97+
npm run cli:dev # TUI with the Maka Dev profile
98+
npm run cli:dev -- run "" # one non-interactive turn
9999
```
100100

101101
Evaluation commands and contracts live in [`packages/eval`](./packages/eval).

README.md

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -124,18 +124,20 @@ npm run build
124124
Then start the TUI or run one Turn:
125125

126126
```sh
127-
npm --workspace maka-agent exec -- maka
128-
npm --workspace maka-agent exec -- maka run "Summarize this repository and identify its most important risk"
129-
npm --workspace maka-agent exec -- maka run --graph "Implement two independent slices, integrate them, then review the result"
130-
npm --workspace maka-agent exec -- maka --help
127+
npm run cli:dev
128+
npm run cli:dev -- run "Summarize this repository and identify its most important risk"
129+
npm run cli:dev -- run --graph "Implement two independent slices, integrate them, then review the result"
130+
npm run cli:dev -- --help
131131
```
132132

133133
The TUI also accepts `/graph on`, `/graph off`, and `/graph <task>`. Non-interactive
134134
`--graph` runs wait for the durable Graph to finish before printing the final
135135
supervisor output. Graph implementation operators use isolated Git worktrees, so
136136
the source project must be a clean Git worktree.
137137

138-
The CLI reads the same model connections and workspace configuration written by Desktop. Evaluation specs and adapters live in [`packages/eval`](./packages/eval).
138+
The repository CLI uses the same `Maka Dev` profile as a development Desktop build. The
139+
released `maka` binary continues to use the `Maka` profile; the two profiles are not copied or
140+
synchronized automatically. Evaluation specs and adapters live in [`packages/eval`](./packages/eval).
139141

140142
## Architecture
141143

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@
3232
"test:dist:serial": "node scripts/run-workspace-tests-parallel.mjs --serial",
3333
"dev": "npm --workspace @maka/desktop run dev:hmr --",
3434
"dev:full": "npm run build && npm --workspace @maka/desktop run start",
35+
"cli:dev": "node packages/cli/dist/dev-cli.js",
3536
"build": "npm --workspace @maka/code-mode run build && npm --workspace @maka/core run build && npm --workspace @maka/storage run build && npm --workspace @maka/mcp run build && npm --workspace @maka/runtime run build && npm --workspace @maka/runtime-host run build && npm --workspace @maka/computer-use run build && npm --workspace @maka/eval run build && npm --workspace maka-agent run build && npm --workspace @maka/ui run build && npm --workspace @maka/desktop run build",
3637
"build:test": "npm run clean && npm --workspace @maka/code-mode run build && npm --workspace @maka/core run build && npm --workspace @maka/storage run build && npm --workspace @maka/mcp run build && npm --workspace @maka/runtime run build && npm --workspace @maka/runtime-host run build && npm --workspace @maka/computer-use run build && npm --workspace @maka/eval run build && npm --workspace maka-agent run build && npm --workspace @maka/ui run build && npm --workspace @maka/desktop run build:test",
3738
"clean": "node scripts/clean-build.mjs",
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
import assert from 'node:assert/strict';
2+
import { describe, test } from 'node:test';
3+
import { formatMakaResumeCommand, formatMakaResumeHint } from '../cli-invocation.js';
4+
5+
describe('Maka CLI invocation copy', () => {
6+
test('keeps release resume instructions on the release launcher', () => {
7+
assert.equal(
8+
formatMakaResumeHint('maka', 'session-1'),
9+
'Resume this session with:\n maka --resume session-1',
10+
);
11+
});
12+
13+
test('keeps development remote and cwd retries on the development launcher', () => {
14+
assert.equal(
15+
formatMakaResumeHint('npm run cli:dev --', 'session-2', { hostProfileId: 'office' }),
16+
'Resume this session with:\n npm run cli:dev -- --resume session-2 --host office',
17+
);
18+
assert.equal(
19+
formatMakaResumeCommand('npm run cli:dev --', 'session-2', { cwd: '<new-path>' }),
20+
'npm run cli:dev -- --resume session-2 --cwd <new-path>',
21+
);
22+
});
23+
});

packages/cli/src/__tests__/cli.test.ts

Lines changed: 229 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,13 @@
11
import assert from 'node:assert/strict';
22
import { spawn } from 'node:child_process';
3+
import { createHash } from 'node:crypto';
34
import { once } from 'node:events';
5+
import { access, mkdtemp, mkdir, readFile, readdir, rm, writeFile } from 'node:fs/promises';
6+
import { tmpdir } from 'node:os';
7+
import { join } from 'node:path';
48
import { describe, test } from 'node:test';
5-
import { parseMakaCliArgs } from '../cli.js';
9+
import { fileURLToPath } from 'node:url';
10+
import { parseMakaCliArgs, runMakaCli } from '../cli.js';
611

712
describe('Maka CLI args', () => {
813
test('selects a Runtime Host and Project for TUI startup', () => {
@@ -13,6 +18,34 @@ describe('Maka CLI args', () => {
1318
});
1419
});
1520

21+
test('uses the active launcher in development help and rejects an empty profile name', async () => {
22+
const help = parseMakaCliArgs(['--help'], '0.1.0', 'npm run cli:dev --');
23+
assert.equal(help.kind, 'help');
24+
if (help.kind === 'help') {
25+
assert.match(help.text, /^Usage: npm run cli:dev --$/m);
26+
assert.match(
27+
help.text,
28+
/^ npm run cli:dev -- runtime-host access issue --principal <id> --preset /m,
29+
);
30+
assert.match(help.text, /^ npm run cli:dev -- runtime-host project list /m);
31+
assert.match(
32+
help.text,
33+
/^ npm run cli:dev -- runtime-host profile set .*--ssh-destination /m,
34+
);
35+
assert.match(help.text, /^ npm run cli:dev -- runtime-host profile set .*--plaintext-url /m);
36+
assert.doesNotMatch(help.text, /^ maka runtime-host /m);
37+
assert.doesNotMatch(help.text, /maka-agent/);
38+
}
39+
await assert.rejects(
40+
runMakaCli(['--version'], {
41+
dataProfileName: '',
42+
cliCommand: 'invalid',
43+
capabilityProviderIdentityScope: 'client-data-root',
44+
}),
45+
/profile name must be a non-empty path segment/,
46+
);
47+
});
48+
1649
test('establishes the fatal exit before reporting can throw', async () => {
1750
const cliUrl = new URL('../cli.js', import.meta.url).href;
1851
const childSource = `
@@ -29,4 +62,199 @@ describe('Maka CLI args', () => {
2962
assert.equal(signal, null);
3063
assert.equal(code, 1);
3164
});
65+
66+
test('compiled release and repository entries isolate profile writes', async (t) => {
67+
const root = await mkdtemp(join(tmpdir(), 'maka-cli-launch-profile-'));
68+
t.after(() => rm(root, { recursive: true, force: true }));
69+
const home = join(root, 'home');
70+
const applicationData = join(root, 'application-data');
71+
const releaseRoot = platformProfileRoot(home, applicationData, 'Maka');
72+
const developmentRoot = platformProfileRoot(home, applicationData, 'Maka Dev');
73+
const env = {
74+
...process.env,
75+
HOME: home,
76+
USERPROFILE: home,
77+
APPDATA: applicationData,
78+
XDG_CONFIG_HOME: applicationData,
79+
MAKA_TEST_RUNTIME_HOST_CREDENTIAL: 'opaque-token',
80+
};
81+
const profileArgs = [
82+
'runtime-host',
83+
'profile',
84+
'set',
85+
'--id',
86+
'office',
87+
'--name',
88+
'Office',
89+
'--tls-url',
90+
'wss://runtime.example.com/runtime-host',
91+
'--expected-root',
92+
'a'.repeat(64),
93+
'--credential-env',
94+
'MAKA_TEST_RUNTIME_HOST_CREDENTIAL',
95+
];
96+
97+
await mkdir(releaseRoot, { recursive: true });
98+
await writeFile(join(releaseRoot, 'sentinel.txt'), 'release-data\n', 'utf8');
99+
const development = await runCompiledCli('dev-cli.js', profileArgs, env);
100+
assert.equal(development.signal, null);
101+
assert.equal(development.code, 0, development.stderr);
102+
await assertProfileFiles(developmentRoot);
103+
assert.deepEqual(await readdir(releaseRoot), ['sentinel.txt']);
104+
assert.equal(await readFile(join(releaseRoot, 'sentinel.txt'), 'utf8'), 'release-data\n');
105+
await assert.rejects(access(join(developmentRoot, 'sentinel.txt')), { code: 'ENOENT' });
106+
107+
const developmentProfile = await readFile(
108+
join(developmentRoot, 'runtime-host-profiles.json'),
109+
'utf8',
110+
);
111+
const release = await runCompiledCli('cli.js', profileArgs, env);
112+
assert.equal(release.signal, null);
113+
assert.equal(release.code, 0, release.stderr);
114+
await assertProfileFiles(releaseRoot);
115+
assert.equal(
116+
await readFile(join(developmentRoot, 'runtime-host-profiles.json'), 'utf8'),
117+
developmentProfile,
118+
);
119+
assert.equal(await readFile(join(releaseRoot, 'sentinel.txt'), 'utf8'), 'release-data\n');
120+
});
121+
122+
test('compiled launchers isolate capability-provider identities', async (t) => {
123+
const root = await mkdtemp(join(tmpdir(), 'maka-cli-provider-identity-'));
124+
t.after(() => rm(root, { recursive: true, force: true }));
125+
const home = join(root, 'home');
126+
const applicationData = join(root, 'application-data');
127+
const releaseRoot = platformProfileRoot(home, applicationData, 'Maka');
128+
const developmentRoot = platformProfileRoot(home, applicationData, 'Maka Dev');
129+
const configPath = join(root, 'mcp.json');
130+
const url = 'https://invalid.example/runtime-host';
131+
const identityFile = providerIdentityFileName(url, configPath);
132+
const developmentIdentityPath = join(
133+
developmentRoot,
134+
'runtime-host-capability-providers',
135+
identityFile,
136+
);
137+
const releaseIdentityPath = join(
138+
home,
139+
'.maka',
140+
'runtime-host-capability-providers',
141+
identityFile,
142+
);
143+
const releaseProfileIdentityPath = join(
144+
releaseRoot,
145+
'runtime-host-capability-providers',
146+
identityFile,
147+
);
148+
const explicitUrl = 'https://explicit.invalid/runtime-host';
149+
const explicitDefaultIdentityPath = join(
150+
developmentRoot,
151+
'runtime-host-capability-providers',
152+
providerIdentityFileName(explicitUrl, configPath),
153+
);
154+
const explicitIdentityPath = join(root, 'explicit-provider-identity.json');
155+
const env = {
156+
...process.env,
157+
HOME: home,
158+
USERPROFILE: home,
159+
APPDATA: applicationData,
160+
XDG_CONFIG_HOME: applicationData,
161+
MAKA_TEST_RUNTIME_HOST_CREDENTIAL: 'opaque-token',
162+
};
163+
await writeFile(configPath, '{"mcpServers": {}}\n', 'utf8');
164+
const providerArgs = [
165+
'runtime-host',
166+
'capability-provider',
167+
'serve',
168+
'--url',
169+
url,
170+
'--mcp-config',
171+
configPath,
172+
'--expected-root',
173+
'b'.repeat(64),
174+
'--credential-env',
175+
'MAKA_TEST_RUNTIME_HOST_CREDENTIAL',
176+
];
177+
178+
const development = await runCompiledCli('dev-cli.js', providerArgs, env);
179+
assert.equal(development.signal, null);
180+
assert.equal(development.code, 1);
181+
const developmentIdentity = await readClientInstanceId(developmentIdentityPath);
182+
await assert.rejects(access(releaseIdentityPath), { code: 'ENOENT' });
183+
184+
const release = await runCompiledCli('cli.js', providerArgs, env);
185+
assert.equal(release.signal, null);
186+
assert.equal(release.code, 1);
187+
const releaseIdentity = await readClientInstanceId(releaseIdentityPath);
188+
assert.notEqual(releaseIdentity, developmentIdentity);
189+
await assert.rejects(access(releaseProfileIdentityPath), { code: 'ENOENT' });
190+
191+
const developmentIdentityBeforeOverride = await readFile(developmentIdentityPath, 'utf8');
192+
const explicitProviderArgs = providerArgs.map((arg) => (arg === url ? explicitUrl : arg));
193+
const explicit = await runCompiledCli(
194+
'dev-cli.js',
195+
[...explicitProviderArgs, '--client-identity', explicitIdentityPath],
196+
env,
197+
);
198+
assert.equal(explicit.signal, null);
199+
assert.equal(explicit.code, 1);
200+
const explicitIdentity = await readClientInstanceId(explicitIdentityPath);
201+
assert.notEqual(explicitIdentity, developmentIdentity);
202+
assert.notEqual(explicitIdentity, releaseIdentity);
203+
await assert.rejects(access(explicitDefaultIdentityPath), { code: 'ENOENT' });
204+
assert.equal(
205+
await readFile(developmentIdentityPath, 'utf8'),
206+
developmentIdentityBeforeOverride,
207+
);
208+
});
32209
});
210+
211+
async function runCompiledCli(
212+
entrypoint: string,
213+
args: readonly string[],
214+
env: NodeJS.ProcessEnv,
215+
): Promise<{ code: number | null; signal: NodeJS.Signals | null; stderr: string }> {
216+
const child = spawn(
217+
process.execPath,
218+
[fileURLToPath(new URL(`../${entrypoint}`, import.meta.url)), ...args],
219+
{ env, stdio: ['ignore', 'ignore', 'pipe'], timeout: 15_000, killSignal: 'SIGKILL' },
220+
);
221+
let stderr = '';
222+
child.stderr.setEncoding('utf8');
223+
child.stderr.on('data', (chunk: string) => {
224+
stderr += chunk;
225+
});
226+
const [code, signal] = (await once(child, 'close')) as [number | null, NodeJS.Signals | null];
227+
return { code, signal, stderr };
228+
}
229+
230+
function platformProfileRoot(home: string, applicationData: string, profileName: string): string {
231+
if (process.platform === 'darwin') {
232+
return join(home, 'Library', 'Application Support', profileName);
233+
}
234+
return join(applicationData, profileName);
235+
}
236+
237+
async function assertProfileFiles(root: string): Promise<void> {
238+
await access(join(root, 'runtime-host-profiles.json'));
239+
await access(join(root, 'runtime-host-client', 'credentials.json'));
240+
}
241+
242+
function providerIdentityFileName(url: string, configPath: string): string {
243+
const identity = createHash('sha256')
244+
.update(`runtime-host-capability-provider\0${url}\0${configPath}`)
245+
.digest('hex')
246+
.slice(0, 24);
247+
return `${identity}.json`;
248+
}
249+
250+
async function readClientInstanceId(path: string): Promise<string> {
251+
const document = JSON.parse(await readFile(path, 'utf8')) as {
252+
schemaVersion?: unknown;
253+
clientInstanceId?: unknown;
254+
};
255+
assert.equal(document.schemaVersion, 1);
256+
if (typeof document.clientInstanceId !== 'string') {
257+
assert.fail('Expected a persisted Client instance id');
258+
}
259+
return document.clientInstanceId;
260+
}

packages/cli/src/__tests__/runtime-host-cli-context.test.ts

Lines changed: 62 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,12 @@
11
import assert from 'node:assert/strict';
2-
import { basename } from 'node:path';
2+
import { mkdtemp, rm } from 'node:fs/promises';
3+
import { tmpdir } from 'node:os';
4+
import { basename, join } from 'node:path';
35
import { test } from 'node:test';
46
import { fileURLToPath } from 'node:url';
57
import {
68
connectRemoteRuntimeHostProfile,
9+
createClientRuntimeHostProfileCatalog,
710
RuntimeHostStartupError,
811
type RuntimeHostConnection,
912
} from '@maka/runtime-host/client';
@@ -192,6 +195,64 @@ test('remote CLI profiles pin root identity and resolve credential outside the p
192195
await context.close();
193196
});
194197

198+
test('remote CLI profile state and Client identity use the explicit Client Data Root', async (t) => {
199+
const clientDataRoot = await mkdtemp(join(tmpdir(), 'maka-cli-client-root-'));
200+
t.after(() => rm(clientDataRoot, { recursive: true, force: true }));
201+
const rootId = 'b'.repeat(64);
202+
await createClientRuntimeHostProfileCatalog(clientDataRoot).save(
203+
{
204+
id: 'office',
205+
name: 'Office',
206+
kind: 'remote',
207+
transport: { kind: 'tls', url: 'wss://runtime.example.com/runtime-host' },
208+
rootId,
209+
},
210+
'opaque-token',
211+
);
212+
let identityPath: string | undefined;
213+
let credential: string | undefined;
214+
const connection = {
215+
rootId,
216+
hostEpoch: 'host-remote',
217+
connectionId: 'connection-remote',
218+
selectedProtocol: 0,
219+
closed: new Promise<void>(() => {}),
220+
status: async () => ({ state: 'ready' }),
221+
subscribeConfigurationChanges: () => () => {},
222+
subscribeProjectCatalogChanges: () => () => {},
223+
subscribeSessionCatalogChanges: () => () => {},
224+
subscribeScheduledTaskChanges: () => () => {},
225+
close: async () => {},
226+
} as unknown as RuntimeHostConnection;
227+
228+
const context = await connectRuntimeHostCli(
229+
{
230+
rootPath: '/unused-local-root',
231+
clientDataRoot,
232+
surface: 'run',
233+
profileId: 'office',
234+
},
235+
{
236+
connectOrSpawn: async () => {
237+
throw new Error('remote profile must not use local discovery');
238+
},
239+
connectRemoteProfile: async (input) => {
240+
credential = input.credential;
241+
return connection;
242+
},
243+
loadClientInstanceId: async (path) => {
244+
identityPath = path;
245+
return '22222222-2222-4222-8222-222222222222';
246+
},
247+
readConnectionCatalog: async () => ({ revision: 1, defaultTarget: null, connections: [] }),
248+
},
249+
);
250+
251+
assert.equal(credential, 'opaque-token');
252+
assert.equal(identityPath, join(clientDataRoot, 'runtime-host-client.json'));
253+
await context.close();
254+
});
255+
195256
function hostRegistration(overrides: Partial<{ compatibilityEpoch: number }> = {}) {
196257
return {
197258
kind: 'maka-runtime-host' as const,

0 commit comments

Comments
 (0)