465 lines
20 KiB
TypeScript
465 lines
20 KiB
TypeScript
/**
|
||
* Claudex launch composition (P2–P4 of `mosaic yolo claudex`).
|
||
*
|
||
* Builds the isolated launch environment for running GPT models inside the
|
||
* Claude Code harness via `raine/claude-code-proxy` (ChatGPT-subscription OAuth).
|
||
* PR-1 (`claudex-proxy.ts`) owns the proxy preflight/lifecycle; this module owns
|
||
* the *composition* the launcher hands to Claude Code:
|
||
*
|
||
* P2 isolated CLAUDE_CONFIG_DIR (provably never the real ~/.claude) + env
|
||
* injection that leaks ZERO token material;
|
||
* P3 the model-tier map (primary → gpt-5.6-sol, small/fast → gpt-5.6-luna,
|
||
* operator env values win);
|
||
* P4 the EXPERIMENTAL classification banner + composed-contract note.
|
||
*
|
||
* Two hard security invariants (secrev-enforced):
|
||
* REQ 1 — Provable isolation. {@link assertIsolatedConfigDir} makes the
|
||
* CLAUDE_CONFIG_DIR seam incapable of resolving to `~/.claude` (or any
|
||
* descendant of it); it canonicalizes both sides, rejects descendants,
|
||
* and — after ensuring the dir — re-checks and rejects a symlinked
|
||
* target (TOCTOU). Fails CLOSED on any uncertainty.
|
||
* REQ 2 — Zero token leakage. This module never reads the proxy's
|
||
* `auth.json`; {@link buildClaudexEnv} strips the ENTIRE
|
||
* credential-bearing env family and hands Claude Code only
|
||
* `ANTHROPIC_AUTH_TOKEN=unused`. The proxy holds the real credential.
|
||
*
|
||
* Every side-effecting boundary is dependency-injected so the launch path is
|
||
* unit-testable without spawning Claude Code or touching a real config dir.
|
||
*/
|
||
|
||
import { lstatSync, mkdirSync, realpathSync } from 'node:fs';
|
||
import { homedir } from 'node:os';
|
||
import { dirname, isAbsolute, join, relative, resolve } from 'node:path';
|
||
import {
|
||
CLAUDEX_PROXY_URL,
|
||
ensureProxyRunning,
|
||
runDeviceReauth,
|
||
runProxyPreflight,
|
||
type EnsureProxyResult,
|
||
type PreflightReport,
|
||
} from './claudex-proxy.js';
|
||
|
||
const MOSAIC_HOME = process.env['MOSAIC_HOME'] ?? join(homedir(), '.config', 'mosaic');
|
||
|
||
// ─── Isolated CLAUDE_CONFIG_DIR (HARD SECURITY REQ 1) ─────────────────────────
|
||
|
||
/** Dedicated override env for the isolated config dir. The ambient
|
||
* `CLAUDE_CONFIG_DIR` is deliberately NOT honored — it may already point at the
|
||
* real `~/.claude` of the launching session. */
|
||
export const CLAUDEX_CONFIG_DIR_ENV = 'MOSAIC_CLAUDEX_CONFIG_DIR';
|
||
|
||
/** The default isolated config dir — structurally under the mosaic home, so it
|
||
* can never equal `~/.claude`. */
|
||
export function defaultClaudexConfigDir(mosaicHome: string = MOSAIC_HOME): string {
|
||
return join(mosaicHome, 'claudex', 'home');
|
||
}
|
||
|
||
export interface ConfigDirDeps {
|
||
/** The real Claude state dir to protect (default `~/.claude`). */
|
||
realClaudeDir?: string;
|
||
/** Resolve a path to canonical form, resolving symlinks on the longest
|
||
* existing ancestor (so a not-yet-created dir still canonicalizes). */
|
||
canonicalize?: (p: string) => string;
|
||
/** Ensure the isolated dir exists (mkdir -p, owner-only 0700). */
|
||
mkdir?: (p: string) => void;
|
||
/** Whether a path is itself a symlink (lstat). */
|
||
isSymlink?: (p: string) => boolean;
|
||
}
|
||
|
||
/** Resolve a path to canonical form, resolving symlinks on the LONGEST EXISTING
|
||
* ancestor and re-appending the not-yet-existing tail. A symlinked ancestor that
|
||
* points into `~/.claude` is therefore caught even before the leaf exists. */
|
||
function defaultCanonicalizeIntended(p: string): string {
|
||
const abs = resolve(p);
|
||
let existing = abs;
|
||
const tail: string[] = [];
|
||
// Walk up until we hit an existing ancestor (or the filesystem root).
|
||
for (;;) {
|
||
try {
|
||
const real = realpathSync(existing);
|
||
return tail.length > 0 ? join(real, ...tail) : real;
|
||
} catch (err) {
|
||
// ONLY a genuine "does not exist yet" (ENOENT) justifies walking up to an
|
||
// existing ancestor. Any other errno (ELOOP, EACCES, ENOTDIR, …) means we
|
||
// cannot establish the canonical form — fail CLOSED rather than fall back
|
||
// to a possibly-wrong literal path.
|
||
if ((err as NodeJS.ErrnoException).code !== 'ENOENT') throw err;
|
||
const parent = dirname(existing);
|
||
if (parent === existing) return abs; // reached root without an existing prefix
|
||
tail.unshift(existing.slice(parent.length + 1));
|
||
existing = parent;
|
||
}
|
||
}
|
||
}
|
||
|
||
function defaultMkdir(p: string): void {
|
||
mkdirSync(p, { recursive: true, mode: 0o700 });
|
||
}
|
||
|
||
function defaultIsSymlink(p: string): boolean {
|
||
try {
|
||
return lstatSync(p).isSymbolicLink();
|
||
} catch (err) {
|
||
// A missing path is genuinely "not a symlink"; anything else (EACCES, ELOOP,
|
||
// ENOTDIR, …) is uncertainty the guard must not swallow — fail CLOSED.
|
||
if ((err as NodeJS.ErrnoException).code === 'ENOENT') return false;
|
||
throw err;
|
||
}
|
||
}
|
||
|
||
/** True when `child` is `parent` itself or a descendant of it (path-wise). */
|
||
function isWithin(child: string, parent: string): boolean {
|
||
if (child === parent) return true;
|
||
const rel = relative(parent, child);
|
||
return rel.length > 0 && !rel.startsWith('..') && !isAbsolute(rel);
|
||
}
|
||
|
||
/**
|
||
* Guard: prove that `candidate` is a legitimate ISOLATED config dir and can
|
||
* never be, resolve to, or live under the real `~/.claude`. Canonicalizes BOTH
|
||
* sides (either may be a symlink), rejects `~/.claude` and any descendant, and
|
||
* fails CLOSED (throws) on an empty/relative candidate. Returns the canonical
|
||
* isolated path on success.
|
||
*/
|
||
export function assertIsolatedConfigDir(candidate: string, deps: ConfigDirDeps = {}): string {
|
||
const canonicalize = deps.canonicalize ?? defaultCanonicalizeIntended;
|
||
const realClaudeDir = deps.realClaudeDir ?? join(homedir(), '.claude');
|
||
|
||
if (typeof candidate !== 'string' || candidate.trim() === '') {
|
||
throw new Error('claudex: isolated CLAUDE_CONFIG_DIR must be a non-empty path (fail closed).');
|
||
}
|
||
if (!isAbsolute(candidate)) {
|
||
throw new Error(
|
||
`claudex: isolated CLAUDE_CONFIG_DIR must be an absolute path: ${JSON.stringify(candidate)}`,
|
||
);
|
||
}
|
||
|
||
const canonCandidate = canonicalize(candidate);
|
||
const canonReal = canonicalize(realClaudeDir);
|
||
|
||
// Compare canonical forms AND raw resolved forms — belt and suspenders so a
|
||
// canonicalizer that no-ops on a nonexistent real dir still catches the literal.
|
||
if (isWithin(canonCandidate, canonReal) || isWithin(resolve(candidate), resolve(realClaudeDir))) {
|
||
throw new Error(
|
||
`claudex: refusing to use the real Claude config dir (or a descendant of it) as the ` +
|
||
`isolated CLAUDE_CONFIG_DIR. Resolved to ${JSON.stringify(canonCandidate)}.`,
|
||
);
|
||
}
|
||
|
||
return canonCandidate;
|
||
}
|
||
|
||
/**
|
||
* Resolve the isolated CLAUDE_CONFIG_DIR: pick the dedicated override or the
|
||
* namespaced default, run the pre-create guard, ensure the dir (0700), then
|
||
* RE-CHECK after creation — reject a symlinked target and re-run the guard on
|
||
* the now-existing (fully canonicalizable) path. This closes the pre-created
|
||
* symlink race (TOCTOU). Every failure throws (fail closed).
|
||
*/
|
||
export function resolveClaudexConfigDir(
|
||
env: NodeJS.ProcessEnv = process.env,
|
||
deps: ConfigDirDeps & { mosaicHome?: string } = {},
|
||
): string {
|
||
const mosaicHome = deps.mosaicHome ?? MOSAIC_HOME;
|
||
const mkdir = deps.mkdir ?? defaultMkdir;
|
||
const isSymlink = deps.isSymlink ?? defaultIsSymlink;
|
||
|
||
const override = env[CLAUDEX_CONFIG_DIR_ENV]?.trim();
|
||
const candidate =
|
||
override && override.length > 0 ? override : defaultClaudexConfigDir(mosaicHome);
|
||
|
||
// Pre-create guard (before touching the filesystem).
|
||
assertIsolatedConfigDir(candidate, deps);
|
||
|
||
// Ensure the dir, then re-verify against the post-create reality.
|
||
mkdir(candidate);
|
||
if (isSymlink(candidate)) {
|
||
throw new Error(
|
||
'claudex: refusing to use the isolated CLAUDE_CONFIG_DIR — the target is a symlink ' +
|
||
'(possible pre-created race). Fail closed.',
|
||
);
|
||
}
|
||
// Re-run the guard now that the leaf exists so canonicalization reflects any
|
||
// symlinked ancestor introduced between the pre-check and mkdir.
|
||
return assertIsolatedConfigDir(candidate, deps);
|
||
}
|
||
|
||
// ─── Model-tier map (P3) ──────────────────────────────────────────────────────
|
||
|
||
export const CLAUDEX_DEFAULT_PRIMARY_MODEL = 'gpt-5.6-sol';
|
||
export const CLAUDEX_DEFAULT_SMALL_FAST_MODEL = 'gpt-5.6-luna';
|
||
|
||
export interface ClaudexModels {
|
||
/** Primary tier (opus/sonnet) → ANTHROPIC_MODEL. */
|
||
primary: string;
|
||
/** Small/fast tier (haiku) → ANTHROPIC_SMALL_FAST_MODEL. */
|
||
smallFast: string;
|
||
}
|
||
|
||
/** Resolve the model-tier map. Operator-provided env values WIN over defaults;
|
||
* blank values fall back to the defaults. */
|
||
export function resolveClaudexModels(env: NodeJS.ProcessEnv = process.env): ClaudexModels {
|
||
const primary = env['ANTHROPIC_MODEL']?.trim() || CLAUDEX_DEFAULT_PRIMARY_MODEL;
|
||
const smallFast = env['ANTHROPIC_SMALL_FAST_MODEL']?.trim() || CLAUDEX_DEFAULT_SMALL_FAST_MODEL;
|
||
return { primary, smallFast };
|
||
}
|
||
|
||
// ─── Env injection (HARD SECURITY REQ 2 — zero token leakage) ─────────────────
|
||
|
||
/**
|
||
* Names of env vars considered credential-bearing. The whole family is stripped
|
||
* from the composed env so no real Anthropic key, OAuth token, or third-party /
|
||
* cloud credential can reach the local proxy or be used by Claude Code to bypass
|
||
* it. We then re-add ONLY the safe claudex vars (`ANTHROPIC_MODEL`,
|
||
* `_SMALL_FAST_MODEL`, `_BASE_URL`, `_AUTH_TOKEN=unused`). A name-pattern sweep
|
||
* can't miss a specific var a short denylist forgot, while still preserving the
|
||
* arbitrary non-credential env the harness/MCP/hooks require (PATH, HOME, XDG,
|
||
* terminal, proxies, …), which a strict allowlist would fragilely drop.
|
||
*
|
||
* The cloud-provider families (`AWS_*`, `GOOGLE_APPLICATION_CREDENTIALS`,
|
||
* `GOOGLE_CLOUD_*`, `GCP_*`) are included because Claude Code can route to the
|
||
* real Anthropic API via AWS Bedrock / GCP Vertex using the ambient cloud
|
||
* credential chain — a Claude-capable credential that must never survive into a
|
||
* claudex launch. `_KEY$` / `_SECRET` (not just the `_API_KEY$` tail) close the
|
||
* mid-string gap (`STRIPE_SECRET_KEY`, `SSH_PRIVATE_KEY`, `AWS_SECRET_ACCESS_KEY`).
|
||
*/
|
||
export const CLAUDEX_CREDENTIAL_ENV_RE =
|
||
/^ANTHROPIC_|^CLAUDE_CODE_OAUTH|^AWS_|^GOOGLE_APPLICATION_CREDENTIALS$|^GOOGLE_CLOUD_|^GCP_|_API_?KEY$|_KEY$|_TOKEN$|_SECRET/i;
|
||
|
||
/**
|
||
* Provider ROUTING switches whose mere PRESENCE (independent of any credential)
|
||
* makes Claude Code bypass `ANTHROPIC_BASE_URL` (the loopback proxy) and talk to
|
||
* the real Anthropic API via Bedrock/Vertex. A name-pattern is the wrong model
|
||
* for a boolean switch, so these are force-deleted by exact name — REGARDLESS of
|
||
* value — after the credential sweep. (REQ 2: isolation must hold for any
|
||
* launching env, including a Bedrock/Vertex-configured enterprise host.)
|
||
*/
|
||
export const CLAUDEX_FORCED_UNSET_ENV = [
|
||
'CLAUDE_CODE_USE_BEDROCK',
|
||
'CLAUDE_CODE_USE_VERTEX',
|
||
'CLAUDE_CODE_SKIP_BEDROCK_AUTH',
|
||
'CLAUDE_CODE_SKIP_VERTEX_AUTH',
|
||
] as const;
|
||
|
||
export interface BuildClaudexEnvOptions {
|
||
configDir: string;
|
||
models: ClaudexModels;
|
||
/** Override the proxy base URL (defaults to the PR-1 loopback constant). */
|
||
baseUrl?: string;
|
||
}
|
||
|
||
/**
|
||
* Compose the launch env for Claude Code. Returns a FRESH object (never mutates
|
||
* the base env). Strips the entire credential-bearing family AND force-deletes
|
||
* the Bedrock/Vertex routing switches (REQ 2), then sets the isolated config dir
|
||
* and the proxy routing. Claude Code sees only `ANTHROPIC_AUTH_TOKEN=unused`
|
||
* pointed at the loopback proxy; the proxy holds the real OAuth credential, which
|
||
* this module never reads.
|
||
*/
|
||
export function buildClaudexEnv(
|
||
baseEnv: NodeJS.ProcessEnv,
|
||
opts: BuildClaudexEnvOptions,
|
||
): NodeJS.ProcessEnv {
|
||
const env: NodeJS.ProcessEnv = {};
|
||
for (const [name, value] of Object.entries(baseEnv)) {
|
||
if (CLAUDEX_CREDENTIAL_ENV_RE.test(name)) continue; // drop the whole credential family
|
||
env[name] = value;
|
||
}
|
||
|
||
// Force-delete routing switches by exact name — their presence (not their
|
||
// value) is what would route Claude Code off the proxy to the real API.
|
||
for (const name of CLAUDEX_FORCED_UNSET_ENV) delete env[name];
|
||
|
||
env['CLAUDE_CONFIG_DIR'] = opts.configDir;
|
||
env['ANTHROPIC_BASE_URL'] = opts.baseUrl ?? CLAUDEX_PROXY_URL;
|
||
env['ANTHROPIC_AUTH_TOKEN'] = 'unused';
|
||
env['ANTHROPIC_MODEL'] = opts.models.primary;
|
||
env['ANTHROPIC_SMALL_FAST_MODEL'] = opts.models.smallFast;
|
||
return env;
|
||
}
|
||
|
||
// ─── EXPERIMENTAL classification (P4) ─────────────────────────────────────────
|
||
|
||
/** Console banner shown at launch. Contains no token material by construction. */
|
||
export function buildClaudexBanner(models: ClaudexModels): string {
|
||
return [
|
||
'',
|
||
' ┌─────────────────────────────────────────────────────────────────────┐',
|
||
' │ ⚠ EXPERIMENTAL — mosaic claudex │',
|
||
' └─────────────────────────────────────────────────────────────────────┘',
|
||
` Running GPT models inside the Claude Code harness via claude-code-proxy`,
|
||
` (ChatGPT-subscription OAuth). This is NOT Anthropic Claude.`,
|
||
` primary : ${models.primary}`,
|
||
` small/fast : ${models.smallFast}`,
|
||
` Model behavior, tool use, and output quality may differ from Claude.`,
|
||
'',
|
||
].join('\n');
|
||
}
|
||
|
||
/** Markdown note appended to the composed runtime contract so the model itself
|
||
* knows it is running the EXPERIMENTAL GPT-via-proxy configuration. */
|
||
export function buildClaudexContractNote(models: ClaudexModels): string {
|
||
return [
|
||
'# EXPERIMENTAL Runtime — claudex (GPT via claude-code-proxy)',
|
||
'',
|
||
'You are running in Mosaic **claudex** mode: the Claude Code harness is wired to',
|
||
'GPT models through a local `claude-code-proxy` (ChatGPT-subscription OAuth). This',
|
||
"runtime is NOT Anthropic's Claude API and is not Claude.",
|
||
'',
|
||
`- Primary model: \`${models.primary}\``,
|
||
`- Small/fast model: \`${models.smallFast}\``,
|
||
'',
|
||
'Some Claude-specific harness assumptions may not hold under GPT models — verify',
|
||
'tool output carefully. This classification is EXPERIMENTAL and is not intended for',
|
||
'production delivery without explicit operator sign-off.',
|
||
].join('\n');
|
||
}
|
||
|
||
// ─── Proxy gate ───────────────────────────────────────────────────────────────
|
||
|
||
export interface ProxyGateResult {
|
||
ok: boolean;
|
||
report: PreflightReport;
|
||
/** Non-sensitive problems suitable for surfacing to the operator. */
|
||
problems: string[];
|
||
}
|
||
|
||
export interface ProxyGateDeps {
|
||
preflight?: () => Promise<PreflightReport>;
|
||
ensureProxy?: () => Promise<EnsureProxyResult>;
|
||
reauth?: () => number;
|
||
log?: (message: string) => void;
|
||
}
|
||
|
||
/**
|
||
* Run the proxy readiness gate: preflight → (device reauth if OAuth needs it) →
|
||
* (start the proxy if nothing trusted is live) → re-preflight. Returns `ok` only
|
||
* when the final preflight passes (binary present, OAuth valid, a TRUSTED-live
|
||
* listener — identity verified by PR-1's `verifyListenerIdentity`). All surfaced
|
||
* problems are non-sensitive (port + verdict only; never a token).
|
||
*/
|
||
export async function runClaudexProxyGate(deps: ProxyGateDeps = {}): Promise<ProxyGateResult> {
|
||
const preflight = deps.preflight ?? (() => runProxyPreflight());
|
||
const ensureProxy = deps.ensureProxy ?? (() => ensureProxyRunning());
|
||
const reauth = deps.reauth ?? (() => runDeviceReauth());
|
||
const log = deps.log ?? (() => {});
|
||
|
||
let report = await preflight();
|
||
|
||
// A missing binary is unrecoverable here — don't attempt reauth or a start.
|
||
if (!report.binaryPresent) {
|
||
return { ok: false, report, problems: report.problems };
|
||
}
|
||
|
||
if (report.needsReauth) {
|
||
log('claudex: claude-code-proxy OAuth needs re-authentication — starting device flow…');
|
||
const code = reauth();
|
||
if (code !== 0) {
|
||
return {
|
||
ok: false,
|
||
report,
|
||
problems: [...report.problems, 'claudex: device re-authentication did not complete.'],
|
||
};
|
||
}
|
||
report = await preflight();
|
||
}
|
||
|
||
if (!report.live) {
|
||
log('claudex: no trusted claude-code-proxy responding — starting it…');
|
||
const started = await ensureProxy();
|
||
if (!started.live) {
|
||
return {
|
||
ok: false,
|
||
report,
|
||
problems: [
|
||
...report.problems,
|
||
`claudex: could not bring up a trusted claude-code-proxy (${started.method}).`,
|
||
],
|
||
};
|
||
}
|
||
report = await preflight();
|
||
}
|
||
|
||
return { ok: report.ok, report, problems: report.problems };
|
||
}
|
||
|
||
// ─── Launch orchestration ─────────────────────────────────────────────────────
|
||
|
||
/**
|
||
* The launch.ts-provided seam. Keeps `claudex.ts` free of a circular import back
|
||
* into `launch.ts` while letting the orchestration reuse the harness preflight,
|
||
* the composed runtime contract, and the process-replacing exec.
|
||
*/
|
||
export interface ClaudexHarnessAdapter {
|
||
/** Runs the Claude-harness preflight (mosaic home, SOUL, `claude` on PATH,
|
||
* sequential-thinking). May terminate the process on a hard failure. */
|
||
harnessPreflight: () => void;
|
||
/** Compose the full Claude runtime contract (== `composeContract('claude')`). */
|
||
composePrompt: () => string;
|
||
/** Replace the current process with `claude` using the composed env. */
|
||
exec: (cmd: string, args: string[], env: NodeJS.ProcessEnv) => void;
|
||
}
|
||
|
||
export interface LaunchClaudexDeps {
|
||
baseEnv?: NodeJS.ProcessEnv;
|
||
proxyGate?: () => Promise<ProxyGateResult>;
|
||
resolveConfigDir?: () => string;
|
||
models?: () => ClaudexModels;
|
||
buildEnv?: (base: NodeJS.ProcessEnv, opts: BuildClaudexEnvOptions) => NodeJS.ProcessEnv;
|
||
log?: (message: string) => void;
|
||
errorLog?: (message: string) => void;
|
||
fail?: (code: number) => never;
|
||
}
|
||
|
||
/**
|
||
* Orchestrate a `mosaic [yolo] claudex` launch. Runs the harness preflight, the
|
||
* proxy gate, composes the isolated env (REQ 1 + REQ 2), appends the EXPERIMENTAL
|
||
* note, and exec's Claude Code. FAIL CLOSED: on any gate failure or guard throw
|
||
* it reports non-sensitive detail and exits WITHOUT reaching exec.
|
||
*/
|
||
export async function launchClaudex(
|
||
args: string[],
|
||
yolo: boolean,
|
||
adapter: ClaudexHarnessAdapter,
|
||
deps: LaunchClaudexDeps = {},
|
||
): Promise<void> {
|
||
const log = deps.log ?? ((m: string) => console.log(m));
|
||
const errorLog = deps.errorLog ?? ((m: string) => console.error(m));
|
||
const fail = deps.fail ?? ((code: number) => process.exit(code));
|
||
const baseEnv = deps.baseEnv ?? process.env;
|
||
const proxyGate = deps.proxyGate ?? (() => runClaudexProxyGate({ log }));
|
||
const resolveConfigDir = deps.resolveConfigDir ?? (() => resolveClaudexConfigDir(baseEnv));
|
||
const models = deps.models ?? (() => resolveClaudexModels(baseEnv));
|
||
const buildEnv = deps.buildEnv ?? buildClaudexEnv;
|
||
|
||
try {
|
||
// Harness readiness first (claude on PATH, mosaic home, sequential-thinking).
|
||
adapter.harnessPreflight();
|
||
|
||
// Proxy readiness (binary, OAuth, trusted-live listener).
|
||
const gate = await proxyGate();
|
||
if (!gate.ok) {
|
||
errorLog('[mosaic] claudex preflight failed:');
|
||
for (const problem of gate.problems) errorLog(` - ${problem}`);
|
||
return fail(1);
|
||
}
|
||
|
||
// Compose the isolated launch env (guard throws → caught below, fail closed).
|
||
const resolvedModels = models();
|
||
const configDir = resolveConfigDir();
|
||
const env = buildEnv(baseEnv, { configDir, models: resolvedModels });
|
||
const prompt = `${adapter.composePrompt()}\n\n${buildClaudexContractNote(resolvedModels)}`;
|
||
|
||
log(buildClaudexBanner(resolvedModels));
|
||
|
||
const cliArgs = yolo ? ['--dangerously-skip-permissions'] : [];
|
||
cliArgs.push('--append-system-prompt', prompt, ...args);
|
||
adapter.exec('claude', cliArgs, env);
|
||
} catch (err) {
|
||
errorLog(
|
||
`[mosaic] claudex launch aborted: ${err instanceof Error ? err.message : String(err)}`,
|
||
);
|
||
return fail(1);
|
||
}
|
||
}
|