nanocoai/nanoclaw · error · Error

cwd must be ./path, ${PLUGIN_ROOT}[/path], or ${PLUGIN_DATA}

Error message

cwd must be ./path, ${PLUGIN_ROOT}[/path], or ${PLUGIN_DATA}[/path]

What it means

parseCwd accepts only three fixed shapes for a stdio MCP server's working directory: `./path`, `${PLUGIN_ROOT}[/path]`, or `${PLUGIN_DATA}[/path]` — literally those placeholder strings, lexically checked by CWD_FORM_RE. Anything else (absolute paths, `..`, other variables) fails this first check.

Source

Thrown at src/container-config.ts:217

function parseStringRecord(value: unknown, flag: string): Record<string, string> | undefined {
  if (value === undefined) return undefined;
  if (typeof value !== 'object' || value === null || Array.isArray(value)) {
    throw new Error(`${flag} must be a JSON object with string values`);
  }
  const record: Record<string, string> = {};
  for (const [key, entry] of Object.entries(value)) {
    if (typeof entry !== 'string') throw new Error(`${flag} must be a JSON object with string values`);
    record[key] = entry;
  }
  return record;
}

/** Accept only the spec's fixed cwd shapes, lexically contained (no ".." segments). */
function parseCwd(value: unknown): string | undefined {
  if (value === undefined) return undefined;
  if (typeof value !== 'string' || !CWD_FORM_RE.test(value)) {
    throw new Error('cwd must be ./path, ${PLUGIN_ROOT}[/path], or ${PLUGIN_DATA}[/path]');
  }
  // rest === '' is the bare form (`${PLUGIN_DATA}`, `./`); empty segments in a
  // non-empty rest are rejected for symmetry with the command validator.
  const rest = value.startsWith('./') ? value.slice(2) : value.replace(CWD_FORM_RE, '');
  if (
    rest.includes('${') ||
    rest.includes('\\') ||
    (rest !== '' && rest.split('/').some((s) => s === '..' || s === ''))
  ) {
    throw new Error('cwd escapes the plugin root');
  }
  return value;
}

export interface AdditionalMountConfig {
  hostPath: string;
  containerPath: string;
  readonly?: boolean;

View on GitHub (pinned to 294ef2aee8)

Solutions

  1. Use ./relative/path for plugin-relative dirs
  2. Use the literal ${PLUGIN_ROOT} or ${PLUGIN_DATA} placeholder plus an optional /sub/path
  3. Omit cwd entirely if the default working directory is fine

Example fix

// before
{"command":"srv","cwd":"/opt/data"}
// after
{"command":"srv","cwd":"${PLUGIN_DATA}/work"}
Defensive patterns

Strategy: validation

Validate before calling

if (entry.cwd !== undefined && !/^(\.\/[^]*|\$\{PLUGIN_ROOT\}(\/.*)?|\$\{PLUGIN_DATA\}(\/.*)?)$/.test(entry.cwd)) throw new UserError('cwd must be ./path, ${PLUGIN_ROOT}[/path], or ${PLUGIN_DATA}[/path]');

Type guard

const CWD_FORM = /^(\.\/.+|\$\{PLUGIN_(ROOT|DATA)\}(\/.+)?)$/;
const isValidCwdShape = (v: unknown): v is string => typeof v === 'string' && CWD_FORM.test(v);

Try / catch

catch (err) { if (err.message.includes('cwd must be ./path')) reformatCwd(); else throw err; }

Prevention

When it happens

Trigger: cwd values like "/srv/app", "../up", "${HOME}/x", "src/tools" (no ./ prefix), or a non-string value in a stdio MCP entry.

Common situations: Using an absolute host path out of habit; referencing other template variables; forgetting the leading ./ for relative paths.

Related errors


AI-assisted analysis of nanocoai/nanoclaw@294ef2aee8 (2026-08-28). Data as JSON: /api/errors/306a9b868dc5bb44. Report an issue: GitHub.