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
99 changes: 99 additions & 0 deletions src/cli/mm.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { cosmiconfig } from 'cosmiconfig';
import * as fs from 'node:fs/promises';
import { createRequire } from 'node:module';
import * as path from 'node:path';
import { Readable } from 'node:stream';
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import type { MockInstance } from 'vitest';

Expand All @@ -19,6 +20,7 @@ import {
parseIntFlag,
parseStringFlag,
parseLaunchArgs,
readSelfOnboardingPhrase,
printHelp,
resolveRuntime,
sendRequest,
Expand Down Expand Up @@ -433,6 +435,49 @@ describe('normalizeMockNetworkAddPayload', () => {
});
});

const RECOVERY_PHRASE = Array.from({ length: 12 }, () => 'abandon').join(' ');

describe('readSelfOnboardingPhrase', () => {
it('returns a normalized 12 word phrase from stdin', async () => {
expect(
await readSelfOnboardingPhrase(Readable.from([`${RECOVERY_PHRASE}\n`])),
).toBe(RECOVERY_PHRASE);
});

it('reads the phrase from Buffer chunks', async () => {
expect(
await readSelfOnboardingPhrase(
Readable.from([Buffer.from(`${RECOVERY_PHRASE}\n`)]),
),
).toBe(RECOVERY_PHRASE);
});

it('refuses a terminal and does not echo the phrase', async () => {
const source = Readable.from([RECOVERY_PHRASE]);
Object.defineProperty(source, 'isTTY', { value: true });

await expect(readSelfOnboardingPhrase(source)).rejects.toThrowError(
'process.exit',
);
expect(stderrSpy).toHaveBeenCalledWith(
'Error: --self-onboarding requires the secret recovery phrase on stdin\n',
);
const written = stderrSpy.mock.calls
.map((call) => String(call[0]))
.join('');
expect(written).not.toContain(RECOVERY_PHRASE);
});

it('refuses a phrase that is not 12 or 24 words without echoing it', async () => {
await expect(
readSelfOnboardingPhrase(Readable.from(['one two three\n'])),
).rejects.toThrowError('process.exit');
expect(stderrSpy).toHaveBeenCalledWith(
'Error: self-onboarding requires a 12 or 24 word secret recovery phrase\n',
);
});
});

describe('parseLaunchArgs', () => {
it('returns empty object for no args', () => {
expect(parseLaunchArgs([])).toStrictEqual({});
Expand All @@ -442,6 +487,15 @@ describe('parseLaunchArgs', () => {
expect(parseLaunchArgs(['--force'])).toStrictEqual({ force: true });
});

it('parses --self-onboarding without reading a phrase from argv', () => {
expect(
parseLaunchArgs(['--self-onboarding', '--context', 'prod']),
).toStrictEqual({
selfOnboarding: true,
context: 'prod',
});
});

it('parses --state value', () => {
expect(parseLaunchArgs(['--state', 'onboarding'])).toStrictEqual({
stateMode: 'onboarding',
Expand Down Expand Up @@ -2799,6 +2853,51 @@ describe('main', () => {
process.argv = origArgv;
});

it('sends the stdin recovery phrase with a self-onboarding launch', async () => {
const { readDaemonState, isDaemonAlive, isDaemonVersionMatch } =
await import('../server/daemon-state.js');
const mockState = {
port: 3000,
pid: 123,
nonce: 'abc',
startedAt: '2024-01-01',
version: '1.0.0',
subPorts: { anvil: 8545, fixture: 8546, mock: 8547 },
};
vi.mocked(readDaemonState).mockResolvedValueOnce(mockState);
vi.mocked(isDaemonAlive).mockResolvedValueOnce(true);
vi.mocked(isDaemonVersionMatch).mockReturnValueOnce(true);

vi.spyOn(globalThis, 'fetch').mockResolvedValue({
ok: true,
json: async () => ({ ok: true, result: 'launched' }),
} as Response);

const origArgv = process.argv;
const origStdin = Object.getOwnPropertyDescriptor(process, 'stdin');
process.argv = ['node', 'mm', 'launch', '--self-onboarding'];
Object.defineProperty(process, 'stdin', {
value: Readable.from([`${RECOVERY_PHRASE}\n`]),
configurable: true,
});

try {
await main();
} finally {
process.argv = origArgv;
if (origStdin) {
Object.defineProperty(process, 'stdin', origStdin);
}
}

expect(globalThis.fetch).toHaveBeenCalledWith(
'http://127.0.0.1:3000/launch',
expect.objectContaining({
body: JSON.stringify({ selfOnboarding: true, srp: RECOVERY_PHRASE }),
}),
);
});

it('resolves a relative extension path from the worktree root', async () => {
const { readDaemonState, isDaemonAlive, isDaemonVersionMatch } =
await import('../server/daemon-state.js');
Expand Down
46 changes: 45 additions & 1 deletion src/cli/mm.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { execSync, spawn } from 'node:child_process';
import * as fs from 'node:fs/promises';
import { createRequire } from 'node:module';
import * as path from 'node:path';
import type { Readable } from 'node:stream';

import pkg from '../../package.json';
import {
Expand Down Expand Up @@ -181,6 +182,9 @@ export async function main(): Promise<void> {
launchArgs.extensionPath,
);
}
if (launchArgs.selfOnboarding === true) {
launchArgs.srp = await readSelfOnboardingPhrase(process.stdin);
}
await sendRequest(daemonState.port, 'POST', '/launch', launchArgs);
return;
}
Expand Down Expand Up @@ -1553,6 +1557,42 @@ export function normalizeMockNetworkAddPayload(
return { rule: payload };
}

/**
* Reads the secret recovery phrase for `mm launch --self-onboarding`.
* A terminal stdin is refused. The phrase is returned as 12 or 24
* single-spaced words and is never written to a process stream.
*
* @param source - The stream carrying the phrase. Production uses `process.stdin`.
* @returns The normalized secret recovery phrase.
*/
export async function readSelfOnboardingPhrase(
source: Readable,
): Promise<string> {
if ('isTTY' in source && source.isTTY === true) {
process.stderr.write(
'Error: --self-onboarding requires the secret recovery phrase on stdin\n',
);
process.exit(1);
}

const chunks: Buffer[] = [];
for await (const chunk of source) {
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(String(chunk)));
}
const words = Buffer.concat(chunks)
.toString('utf8')
.trim()
.split(/\s+/u)
.filter((word) => word.length > 0);
if (words.length !== 12 && words.length !== 24) {
process.stderr.write(
'Error: self-onboarding requires a 12 or 24 word secret recovery phrase\n',
);
process.exit(1);
}
return words.join(' ');
}

/**
* Parses launch command arguments into a key-value object.
*
Expand All @@ -1575,6 +1615,7 @@ export function parseLaunchArgs(args: string[]): Record<string, unknown> {
'--reinstall',
'--reset-app-data',
'--allow-fox-code-mismatch',
'--self-onboarding',
]);

for (let i = 0; i < args.length; i++) {
Expand Down Expand Up @@ -1666,6 +1707,8 @@ export function parseLaunchArgs(args: string[]): Record<string, unknown> {
result.resetAppData = true;
} else if (arg === '--allow-fox-code-mismatch') {
result.allowFoxCodeMismatch = true;
} else if (arg === '--self-onboarding') {
result.selfOnboarding = true;
} else if (arg.startsWith('--') && !knownFlags.has(arg)) {
process.stderr.write(`Warning: unknown launch flag '${arg}'\n`);
}
Expand All @@ -1690,8 +1733,9 @@ Environment Variables:
Falls back to the current git worktree root.

Lifecycle:
mm launch [--context e2e|prod] [--state default|onboarding|custom] [--extension-path <path>] [--goal <text>] [--force] [--flow-tags <tags>] [--platform browser|ios|android] [--device-id <id>]
mm launch [--context e2e|prod] [--state default|onboarding|custom] [--self-onboarding] [--extension-path <path>] [--goal <text>] [--force] [--flow-tags <tags>] [--platform browser|ios|android] [--device-id <id>]
mm launch --platform ios|android [--device-id <id>] [--app-bundle <path>] [--metro-port <port>] [--reinstall] [--reset-app-data] [--allow-fox-code-mismatch]
mm launch --self-onboarding reads the secret recovery phrase from stdin.
mm cleanup [--shutdown]
mm status
mm stop [--force]
Expand Down
42 changes: 42 additions & 0 deletions src/knowledge-store/knowledge-store.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -316,6 +316,48 @@ describe('core', () => {
expect(writtenData.tool.input.text).toBe('[REDACTED]');
});

it('sanitizes sensitive fields inside run_steps steps', async () => {
const store = new KnowledgeStore({
rootDir: '/test/knowledge',
});
const srp = Array.from({ length: 12 }, () => 'abandon').join(' ');

await store.recordStep({
sessionId: 'session-step-003b',
toolName: 'run_steps',
input: {
steps: [
{ tool: 'launch', args: { selfOnboarding: true, srp } },
{
tool: 'type',
args: { testId: 'password-input', text: 'my-secret-password' },
},
{ tool: 'click', args: { testId: 'confirm-btn' } },
{ tool: 'get_state' },
],
},
outcome: { ok: true },
observation: createObservation(),
});

const writeCall = vi.mocked(fs.writeFile).mock.calls[0];
const written = writeCall[1] as string;
const writtenData = JSON.parse(written);

expect(writtenData.tool.input.steps).toStrictEqual([
{ tool: 'launch', args: { selfOnboarding: true, srp: '[REDACTED]' } },
{
tool: 'type',
args: { testId: 'password-input', text: '[REDACTED]' },
},
{ tool: 'click', args: { testId: 'confirm-btn' } },
{ tool: 'get_state' },
]);
expect(writtenData.tool.textRedacted).toBe(true);
expect(written).not.toContain('abandon');
expect(written).not.toContain('my-secret-password');
});

it('records step with target information', async () => {
const store = new KnowledgeStore({
rootDir: '/test/knowledge',
Expand Down
21 changes: 20 additions & 1 deletion src/knowledge-store/knowledge-store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -812,7 +812,26 @@ export class KnowledgeStore {
const typeToolName = 'type';

for (const [key, value] of Object.entries(input)) {
if (toolName === typeToolName && key === 'text') {
if (toolName === 'run_steps' && key === 'steps' && Array.isArray(value)) {
const steps: unknown[] = [];
for (const step of value as unknown[]) {
const { tool, args } = (step ?? {}) as {
tool?: unknown;
args?: unknown;
};
if (typeof tool !== 'string' || typeof args !== 'object') {
steps.push(step);
continue;
}
const stepSanitized = this.#sanitizeInput(
tool,
args as Record<string, unknown> | undefined,
);
textRedacted ||= stepSanitized.textRedacted === true;
steps.push({ ...(step as object), args: stepSanitized.input });
}
sanitized[key] = steps;
} else if (toolName === typeToolName && key === 'text') {
const textValue = String(value);
const targetTestId = input.testId as string | undefined;
const targetSelector = input.selector as string | undefined;
Expand Down
26 changes: 26 additions & 0 deletions src/server/create-server.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -534,6 +534,32 @@ describe('createServer integration', () => {
expect(body.ok).toBe(true);
});

it('omits the recovery phrase from the recorded launch step', async () => {
const { KnowledgeStore } =
await import('../knowledge-store/knowledge-store.js');
const mockStore = vi.mocked(KnowledgeStore).mock.results.at(-1)?.value as {
recordStep: ReturnType<typeof vi.fn>;
};
mockStore.recordStep.mockClear();
const srp = Array.from({ length: 12 }, () => 'abandon').join(' ');

await httpRequest(`http://127.0.0.1:${state.port}/launch`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ selfOnboarding: true, srp }),
});

expect(mockStore.recordStep).toHaveBeenCalled();
const recorded = mockStore.recordStep.mock.calls[0][0] as {
input: Record<string, unknown>;
};
expect(recorded.input.selfOnboarding).toBe(true);
expect(recorded.input).not.toHaveProperty('srp');
expect(JSON.stringify(mockStore.recordStep.mock.calls)).not.toContain(
'abandon',
);
});

it('pOST /cleanup delegates to session manager', async () => {
const res = await httpRequest(`http://127.0.0.1:${state.port}/cleanup`, {
method: 'POST',
Expand Down
21 changes: 19 additions & 2 deletions src/server/create-server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -355,7 +355,8 @@ export function createServer(config: ServerConfig): ServerInstance {
return;
}

const target = extractTargetFromInput(validatedInput);
const recordedInput = omitLaunchSecret(validatedInput);
const target = extractTargetFromInput(recordedInput);
const screenshotInfo = extractScreenshotInfo(toolName, toolResult);

let executionContext: 'e2e' | 'prod' | undefined;
Expand All @@ -368,7 +369,7 @@ export function createServer(config: ServerConfig): ServerInstance {
await knowledgeStore.recordStep({
sessionId,
toolName,
input: validatedInput as Record<string, unknown>,
input: recordedInput as Record<string, unknown>,
target,
outcome,
observation:
Expand Down Expand Up @@ -865,6 +866,22 @@ export function createServer(config: ServerConfig): ServerInstance {
return instance;
}

/**
* Removes the secret recovery phrase from a launch payload before it is
* written to the knowledge store.
*
* @param input - The tool input about to be recorded.
* @returns A shallow copy without `srp`, or the original value when it is not an object.
*/
function omitLaunchSecret(input: unknown): unknown {
if (typeof input !== 'object' || input === null || !('srp' in input)) {
return input;
}
const copy = { ...(input as Record<string, unknown>) };
delete copy.srp;
return copy;
}

/**
* Appends a timestamped line to the daemon log file.
*
Expand Down
5 changes: 4 additions & 1 deletion src/tools/launch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,10 @@ export async function launchTool(

return createToolSuccess({
...result,
...(isProdMode && { prerequisites: PROD_MODE_PREREQUISITES }),
...(isProdMode &&
input.selfOnboarding !== true && {
prerequisites: PROD_MODE_PREREQUISITES,
}),
});
} catch (error) {
const message = extractErrorMessage(error);
Expand Down
Loading
Loading