Skip to content
Open
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
Original file line number Diff line number Diff line change
Expand Up @@ -160,6 +160,7 @@ export class LocalConversationProvider implements ConversationProvider {
providerSessionId: conversation.sessionId ?? undefined,
isResuming: agentSession.isResuming,
model: conversation.model ?? '',
platform: process.platform,
});

const customEnv = providerConfig?.env ?? {};
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -146,6 +146,7 @@ export class SshConversationProvider implements ConversationProvider {
providerSessionId: conversation.sessionId ?? undefined,
isResuming: agentSession.isResuming,
model: conversation.model ?? '',
platform: 'linux',
});

const customEnv = providerConfig?.env ?? {};
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { readFile } from 'node:fs/promises';
import { join } from 'node:path';
import { describe, expect, it, vi } from 'vitest';
import {
buildPromptPointerMessage,
Expand Down Expand Up @@ -41,6 +42,7 @@ describe('spillLargePrompt', () => {

it('spills oversized prompts to a file and returns a pointer message + cleanup', async () => {
const prompt = 'y'.repeat(MAX_INLINE_PROMPT_CHARS + 1);
const expectedPath = join('/tmp/emdash-prompt-abc', 'task-context.md');
const removeTempDir = vi.fn(async () => {});
let writtenPath = '';
let writtenContents = '';
Expand All @@ -54,14 +56,32 @@ describe('spillLargePrompt', () => {
removeTempDir,
});

expect(writtenPath).toBe('/tmp/emdash-prompt-abc/task-context.md');
expect(writtenPath).toBe(expectedPath);
expect(writtenContents).toBe(prompt);
expect(result.prompt).toBe(buildPromptPointerMessage('/tmp/emdash-prompt-abc/task-context.md'));
expect(result.prompt).toBe(buildPromptPointerMessage(expectedPath));

await result.cleanup();
expect(removeTempDir).toHaveBeenCalledWith('/tmp/emdash-prompt-abc');
});

it('spills prompts that exceed the configured byte threshold', async () => {
const maxBytes = 7_000;
const prompt = 'x'.repeat(maxBytes + 1);
const expectedPath = join('/tmp/emdash-prompt-bytes', 'task-context.md');
let writtenContents = '';

const result = await spillLargePrompt(prompt, {
maxBytes,
createTempDir: async () => '/tmp/emdash-prompt-bytes',
writeContextFile: async (_filePath, contents) => {
writtenContents = contents;
},
});

expect(writtenContents).toBe(prompt);
expect(result.prompt).toBe(buildPromptPointerMessage(expectedPath));
});

it('cleans up the temp dir and falls back to inline when writing fails', async () => {
const prompt = 'z'.repeat(MAX_INLINE_PROMPT_CHARS + 1);
const onError = vi.fn();
Expand Down
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { Buffer } from 'node:buffer';
import { mkdtemp, rm, writeFile } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
Expand Down Expand Up @@ -30,6 +31,7 @@ export function buildPromptPointerMessage(filePath: string): string {

export type SpillLargePromptDeps = {
maxChars?: number;
maxBytes?: number;
createTempDir?: () => Promise<string>;
writeContextFile?: (filePath: string, contents: string) => Promise<void>;
removeTempDir?: (dir: string) => Promise<void>;
Expand All @@ -47,6 +49,7 @@ const noopCleanup = (): Promise<void> => Promise.resolve();

const defaultDeps: Required<SpillLargePromptDeps> = {
maxChars: MAX_INLINE_PROMPT_CHARS,
maxBytes: Number.POSITIVE_INFINITY,
createTempDir: () => mkdtemp(join(tmpdir(), TEMP_DIR_PREFIX)),
writeContextFile: (filePath, contents) => writeFile(filePath, contents, 'utf8'),
removeTempDir: (dir) => rm(dir, { recursive: true, force: true }),
Expand All @@ -67,12 +70,14 @@ export async function spillLargePrompt(
prompt: string,
deps: SpillLargePromptDeps = {}
): Promise<SpillLargePromptResult> {
const { maxChars, createTempDir, writeContextFile, removeTempDir, onError } = {
const { maxChars, maxBytes, createTempDir, writeContextFile, removeTempDir, onError } = {
...defaultDeps,
...deps,
};

if (prompt.length <= maxChars) return { prompt, cleanup: noopCleanup };
if (prompt.length <= maxChars && Buffer.byteLength(prompt, 'utf8') <= maxBytes) {
return { prompt, cleanup: noopCleanup };
}

let dir: string | undefined;
try {
Expand Down
2 changes: 2 additions & 0 deletions packages/core/src/agents/plugins/capabilities/prompt.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@ export type CommandContext = {
extraArgs?: string[]; // user-configured in settings
autoApprove: boolean;
initialPrompt?: string;
/** Platform where the command will run. Defaults to the local process platform. */
platform?: NodeJS.Platform;
/** Emdash conversation UUID — used as the session token for providers that track their
* own session across the emdash lifetime (e.g. claude --session-id, opencode --session). */
sessionId?: string;
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,78 @@
import { describe, expect, it } from 'vitest';
import { buildStandardCommand } from './standard-command';
import { buildStandardCommand, wrapWithStdinPipe } from './standard-command';

describe('buildStandardCommand', () => {
it('keeps stdin-piped prompts on the existing bash wrapper for POSIX platforms', () => {
const result = wrapWithStdinPipe(
{
command: '/usr/local/bin/amp',
args: ['--dangerously-allow-all', "it's ok"],
env: { PLUGINS: 'all' },
},
'Fix the bug',
'darwin'
);

expect(result).toEqual({
command: 'bash',
args: [
'-c',
"printf '%s\n' 'Fix the bug' | /usr/local/bin/amp --dangerously-allow-all 'it'\\''s ok'",
],
env: { PLUGINS: 'all' },
});
});

it('wraps stdin-piped prompts with byte-preserving stdin redirection on Windows', () => {
const result = wrapWithStdinPipe(
{
command: 'C:\\Users\\me\\AppData\\Roaming\\npm\\amp.CMD',
args: ['--dangerously-allow-all', "it's ok"],
env: { PLUGINS: 'all' },
},
'Fix the bug',
'win32'
);

expect(result.command).toBe('powershell.exe');
expect(result.env).toEqual({ PLUGINS: 'all' });
expect(result.args.slice(0, 3)).toEqual(['-NoProfile', '-ExecutionPolicy', 'Bypass']);
expect(result.args[3]).toBe('-EncodedCommand');

const script = Buffer.from(result.args[4]!, 'base64').toString('utf16le');
expect(script).toContain('$OutputEncoding = [Text.UTF8Encoding]::new($false)');
expect(script).toContain(
"[IO.File]::WriteAllBytes($promptPath, [Convert]::FromBase64String('Rml4IHRoZSBidWcK'))"
);
expect(script).toContain(`$cmdTail = '"' + "$agentLine < $(Quote-CmdArg $promptPath)" + '"'`);
expect(script).toContain('& $env:ComSpec /d /s /c $cmdTail');
expect(script).toContain('< $(Quote-CmdArg $promptPath)');
expect(script).toContain("'LS1kYW5nZXJvdXNseS1hbGxvdy1hbGw=', 'aXQncyBvaw=='");
expect(script).not.toContain('$prompt |');
expect(script).toContain('exit $LASTEXITCODE');
});

it('normalizes extensionless Windows npm shims to cmd files before invoking them', () => {
const result = wrapWithStdinPipe(
{
command: 'C:\\Users\\me\\AppData\\Roaming\\npm\\amp',
args: [],
env: {},
},
'Fix the bug',
'win32'
);

const script = Buffer.from(result.args[4]!, 'base64').toString('utf16le');
expect(script).toContain('if (-not [IO.Path]::HasExtension($command))');
expect(script).toContain("$cmdShim = $command + '.cmd'");
expect(script).toContain(
'if (Test-Path -LiteralPath $cmdShim -PathType Leaf) { $command = $cmdShim }'
);
expect(script).toContain('$agentLine = @((Quote-CmdArg $command)');
expect(script).not.toContain("'C:\\Users\\me\\AppData\\Roaming\\npm\\amp'");
});

it('splits multi-word resume fallback flags into argv parts', () => {
const result = buildStandardCommand(
{
Expand Down
53 changes: 51 additions & 2 deletions packages/core/src/agents/plugins/helpers/standard-command.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { Buffer } from 'node:buffer';
import type { AgentCommand, CommandContext } from '../capabilities/prompt';

/** Quote a single shell argument safely for POSIX shells. */
Expand All @@ -8,12 +9,60 @@ export function quoteShellArg(arg: string): string {
}

/** Wrap a command with stdin pipe delivery for an initial prompt. */
export function wrapWithStdinPipe(cmd: AgentCommand, prompt: string): AgentCommand {
export function wrapWithStdinPipe(
cmd: AgentCommand,
prompt: string,
platform: NodeJS.Platform = process.platform
): AgentCommand {
if (platform === 'win32') return wrapWithWindowsStdinPipe(cmd, prompt);

const agentLine = [cmd.command, ...cmd.args].map(quoteShellArg).join(' ');
const shellLine = `printf '%s\n' ${quoteShellArg(prompt)} | ${agentLine}`;
return { command: 'bash', args: ['-c', shellLine], env: cmd.env };
}

function quotePowerShellArg(arg: string): string {
return `'${arg.replaceAll("'", "''")}'`;
}

function toBase64Utf8(value: string): string {
return Buffer.from(value, 'utf8').toString('base64');
}

function wrapWithWindowsStdinPipe(cmd: AgentCommand, prompt: string): AgentCommand {
const promptBase64 = toBase64Utf8(`${prompt}\n`);
const commandBase64 = toBase64Utf8(cmd.command);
const argBase64List = cmd.args.map((arg) => quotePowerShellArg(toBase64Utf8(arg))).join(', ');
const script = [
"$ErrorActionPreference = 'Stop'",
'$OutputEncoding = [Text.UTF8Encoding]::new($false)',
'function Decode-Utf8Base64([string] $value) { [Text.Encoding]::UTF8.GetString([Convert]::FromBase64String($value)) }',
`function Quote-CmdArg([string] $value) { '"' + ($value -replace '(["^&|<>()%!])', '^$1') + '"' }`,
`$command = Decode-Utf8Base64 '${commandBase64}'`,
'if (-not [IO.Path]::HasExtension($command)) {',
" $cmdShim = $command + '.cmd'",
' if (Test-Path -LiteralPath $cmdShim -PathType Leaf) { $command = $cmdShim }',
'}',
`$agentArgs = @(${argBase64List}) | ForEach-Object { Decode-Utf8Base64 $_ }`,
'$promptPath = [IO.Path]::GetTempFileName()',
'try {',
` [IO.File]::WriteAllBytes($promptPath, [Convert]::FromBase64String('${promptBase64}'))`,
` $agentLine = @((Quote-CmdArg $command), ($agentArgs | ForEach-Object { Quote-CmdArg $_ })) -join ' '`,
` $cmdTail = '"' + "$agentLine < $(Quote-CmdArg $promptPath)" + '"'`,
' & $env:ComSpec /d /s /c $cmdTail',
' exit $LASTEXITCODE',
'} finally {',
' Remove-Item -LiteralPath $promptPath -Force -ErrorAction SilentlyContinue',
'}',
].join('; ');
const encoded = Buffer.from(script, 'utf16le').toString('base64');
return {
command: 'powershell.exe',
args: ['-NoProfile', '-ExecutionPolicy', 'Bypass', '-EncodedCommand', encoded],
env: cmd.env,
};
}

/**
* Spec for standard command building; mirrors `AgentProviderDefinition` fields
* but typed for use in buildStandardCommand.
Expand Down Expand Up @@ -156,7 +205,7 @@ export function buildStandardCommand(ctx: CommandContext, spec: StandardCommandS

// Wrap with stdin pipe if needed
if (!ctx.isResuming && ctx.initialPrompt && spec.initialPromptViaStdinPipe) {
return wrapWithStdinPipe(command, ctx.initialPrompt);
return wrapWithStdinPipe(command, ctx.initialPrompt, ctx.platform);
}

return command;
Expand Down
Loading