moflo 4.8.70 → 4.8.71

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "moflo",
3
- "version": "4.8.70",
3
+ "version": "4.8.71",
4
4
  "description": "MoFlo — AI agent orchestration for Claude Code. Forked from ruflo/claude-flow with patches applied to source, plus feature-level orchestration.",
5
5
  "main": "dist/index.js",
6
6
  "type": "module",
@@ -112,7 +112,7 @@
112
112
  "@types/js-yaml": "^4.0.9",
113
113
  "@types/node": "^20.19.37",
114
114
  "eslint": "^8.0.0",
115
- "moflo": "^4.8.69",
115
+ "moflo": "^4.8.70",
116
116
  "tsx": "^4.21.0",
117
117
  "typescript": "^5.9.3",
118
118
  "vitest": "^4.0.0"
@@ -1332,37 +1332,67 @@ export const doctorCommand = {
1332
1332
  return { name: 'Spell Engine', status: 'warn', message: 'Unable to check spell engine' };
1333
1333
  }
1334
1334
  }
1335
- // Check sandbox tier — reports which OS-level sandbox is available (#412)
1335
+ // Check sandbox tier — reports OS sandbox capability AND, if the project
1336
+ // has `sandbox.enabled: true`, whether the effective sandbox would
1337
+ // actually start (e.g. Windows Docker image pulled and configured).
1336
1338
  async function checkSandboxTier() {
1337
1339
  try {
1338
- // Walk up to CLI package root, then resolve sibling spells package
1339
- // (works from both src/ and dist/src/ locations)
1340
1340
  const __doctorDir = dirname(fileURLToPath(import.meta.url));
1341
1341
  let cliPkgRoot = __doctorDir;
1342
1342
  while (cliPkgRoot !== dirname(cliPkgRoot) && !existsSync(join(cliPkgRoot, 'package.json'))) {
1343
1343
  cliPkgRoot = dirname(cliPkgRoot);
1344
1344
  }
1345
1345
  const sandboxPath = resolve(cliPkgRoot, '..', 'spells', 'dist', 'core', 'platform-sandbox.js');
1346
- const { detectSandboxCapability } = await import(pathToFileURL(sandboxPath).href);
1346
+ const sandboxModule = await import(pathToFileURL(sandboxPath).href);
1347
+ const { detectSandboxCapability, loadSandboxConfigFromProject, resolveEffectiveSandbox, } = sandboxModule;
1347
1348
  const cap = detectSandboxCapability();
1348
- if (cap.available) {
1349
+ const config = await loadSandboxConfigFromProject(process.cwd());
1350
+ // If sandboxing isn't enabled in moflo.yaml, just report capability.
1351
+ if (!config.enabled) {
1352
+ if (cap.available) {
1353
+ return {
1354
+ name: 'Sandbox Tier',
1355
+ status: 'pass',
1356
+ message: `${cap.tool} available (${cap.platform}) — sandboxing off in moflo.yaml`,
1357
+ };
1358
+ }
1359
+ const offHint = {
1360
+ win32: 'Install Docker Desktop and set sandbox.dockerImage in moflo.yaml to enable sandboxing',
1361
+ linux: 'Install bubblewrap: sudo apt install bubblewrap',
1362
+ darwin: 'sandbox-exec should be available on macOS — check /usr/bin/sandbox-exec',
1363
+ };
1349
1364
  return {
1350
1365
  name: 'Sandbox Tier',
1351
1366
  status: 'pass',
1352
- message: `${cap.tool} (${cap.platform})`,
1367
+ message: `sandboxing off (${cap.platform}, denylist active)`,
1368
+ fix: offHint[cap.platform],
1369
+ };
1370
+ }
1371
+ // Sandboxing is enabled — run the real resolver and surface any error.
1372
+ try {
1373
+ const effective = resolveEffectiveSandbox(config);
1374
+ if (effective.useOsSandbox) {
1375
+ const imageHint = effective.config.dockerImage ? `, ${effective.config.dockerImage}` : '';
1376
+ return {
1377
+ name: 'Sandbox Tier',
1378
+ status: 'pass',
1379
+ message: `${cap.tool} ready (${cap.platform}${imageHint})`,
1380
+ };
1381
+ }
1382
+ return {
1383
+ name: 'Sandbox Tier',
1384
+ status: 'warn',
1385
+ message: `denylist only (${cap.platform})`,
1386
+ };
1387
+ }
1388
+ catch (err) {
1389
+ return {
1390
+ name: 'Sandbox Tier',
1391
+ status: 'warn',
1392
+ message: `sandboxing enabled but not ready (${cap.platform})`,
1393
+ fix: err instanceof Error ? err.message : String(err),
1353
1394
  };
1354
1395
  }
1355
- const platformHint = {
1356
- win32: 'Windows has no OS-level sandbox — denylist and capability gateway still active',
1357
- linux: 'Install bubblewrap: sudo apt install bubblewrap',
1358
- darwin: 'sandbox-exec should be available on macOS — check /usr/bin/sandbox-exec',
1359
- };
1360
- return {
1361
- name: 'Sandbox Tier',
1362
- status: 'warn',
1363
- message: `denylist only (${cap.platform})`,
1364
- fix: platformHint[cap.platform] ?? 'No OS sandbox available for this platform',
1365
- };
1366
1396
  }
1367
1397
  catch (err) {
1368
1398
  return {
@@ -2,5 +2,5 @@
2
2
  * Auto-generated by build. Do not edit manually.
3
3
  * Source of truth: root package.json → scripts/sync-version.mjs
4
4
  */
5
- export const VERSION = '4.8.70';
5
+ export const VERSION = '4.8.71';
6
6
  //# sourceMappingURL=version.js.map
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@moflo/cli",
3
- "version": "4.8.70",
3
+ "version": "4.8.71",
4
4
  "type": "module",
5
5
  "main": "dist/src/index.js",
6
6
  "types": "dist/src/index.d.ts",
@@ -10,6 +10,7 @@ import { resolvePermissions } from '../core/permission-resolver.js';
10
10
  import { checkDestructivePatterns, checkDestructivePatternsScoped, formatDestructiveError, validateDestructiveScope, formatScopeViolation, } from './destructive-pattern-checker.js';
11
11
  import { wrapWithSandboxExec } from '../core/sandbox-profile.js';
12
12
  import { wrapWithBwrap } from '../core/bwrap-sandbox.js';
13
+ import { wrapWithDocker } from '../core/docker-sandbox.js';
13
14
  export const bashCommand = {
14
15
  type: 'bash',
15
16
  description: 'Run a shell command and capture output',
@@ -155,6 +156,19 @@ export const bashCommand = {
155
156
  permissionLevel: context.permissionLevel,
156
157
  });
157
158
  }
159
+ else if (tool === 'docker') {
160
+ const image = context.sandbox.config.dockerImage;
161
+ if (!image) {
162
+ // Defence-in-depth — resolveEffectiveSandbox should have thrown
163
+ // already when the image is missing. If we somehow get here,
164
+ // fall through to unsandboxed rather than crashing the step.
165
+ throw new Error('Docker sandboxing enabled but no dockerImage configured');
166
+ }
167
+ sandboxWrap = wrapWithDocker(command, caps, projectRoot, {
168
+ image,
169
+ permissionLevel: context.permissionLevel,
170
+ });
171
+ }
158
172
  }
159
173
  catch (err) {
160
174
  console.log(`[bash] ${tool} wrapping failed, running unsandboxed: ${err.message}`);
@@ -0,0 +1,195 @@
1
+ /**
2
+ * Windows Docker Sandbox Wrapper
3
+ *
4
+ * Wraps a bash command with `docker run` for execution inside a Linux
5
+ * container. Mirrors the bwrap/sandbox-exec interface so `bash-command.ts`
6
+ * can dispatch to any platform's sandbox identically.
7
+ *
8
+ * Design notes vs bwrap:
9
+ * - Paths must be translated: Windows `C:\path` won't exist in the
10
+ * container. projectRoot mounts at `/workspace` (the container's CWD);
11
+ * scopes inside projectRoot translate to `/workspace/<rel>`; absolute
12
+ * scopes outside the project are skipped with a log line.
13
+ * - Tool home paths mount under `/root/<rel>` so that claude/gh/git inside
14
+ * the container read and write the same config files the user edits on
15
+ * Windows.
16
+ * - `--network none` is the default; `net` cap or elevated/autonomous omit
17
+ * it (default bridge) — mirrors the bwrap policy.
18
+ * - `--rm` handles cleanup automatically.
19
+ *
20
+ * @see https://github.com/eric-cielo/moflo/issues/412
21
+ */
22
+ import { homedir } from 'node:os';
23
+ import { posix, isAbsolute, relative } from 'node:path';
24
+ /** Container path where projectRoot is mounted. */
25
+ const CONTAINER_WORKSPACE = '/workspace';
26
+ /** Container path where the user's home is projected (for tool home mounts). */
27
+ const CONTAINER_HOME = '/root';
28
+ /**
29
+ * Tool home paths mounted rw for elevated/autonomous steps so claude, gh,
30
+ * git, npm can persist their config/credentials/cache. Mirrors the bwrap
31
+ * allowlist; Windows-specific Application Support paths aren't included
32
+ * because the Linux tools inside the container look at POSIX paths.
33
+ */
34
+ const TOOL_HOME_PATHS = [
35
+ // Claude Code
36
+ '.claude',
37
+ '.claude.json',
38
+ // GitHub CLI
39
+ '.config/gh',
40
+ // git
41
+ '.gitconfig',
42
+ '.git-credentials',
43
+ // npm
44
+ '.npmrc',
45
+ '.npm',
46
+ // Shared XDG locations
47
+ '.config',
48
+ '.cache',
49
+ '.local/share',
50
+ '.local/state',
51
+ ];
52
+ function needsToolHomeAccess(level) {
53
+ return level === 'elevated' || level === 'autonomous';
54
+ }
55
+ /**
56
+ * Normalise a host path into the form Docker Desktop accepts on `-v` mounts.
57
+ * Docker for Windows accepts either native Windows paths or the `/c/...`
58
+ * form — we pass the native path through untouched since `execFile`-style
59
+ * spawning avoids shell-quoting issues.
60
+ */
61
+ function normaliseHostPath(p) {
62
+ return p;
63
+ }
64
+ /**
65
+ * Translate a host scope path into a container path.
66
+ *
67
+ * - If inside `projectRoot` → mount at `/workspace/<relative>` (no extra bind)
68
+ * - If outside → return null (caller will add a dedicated bind at the same
69
+ * POSIX-form path and log that the path is being projected)
70
+ */
71
+ function translateScopePath(scopePath, projectRoot) {
72
+ if (!isAbsolute(scopePath)) {
73
+ const cleaned = scopePath.replace(/^\.\/+/, '');
74
+ return {
75
+ containerPath: posix.join(CONTAINER_WORKSPACE, cleaned),
76
+ needsBind: false,
77
+ };
78
+ }
79
+ const rel = relative(projectRoot, scopePath);
80
+ const isInside = !rel.startsWith('..') && !isAbsolute(rel);
81
+ if (isInside) {
82
+ return {
83
+ containerPath: posix.join(CONTAINER_WORKSPACE, rel.replace(/\\/g, '/')),
84
+ needsBind: false,
85
+ };
86
+ }
87
+ // Outside project — give it a dedicated mount at a synthetic container path.
88
+ // We use a hash-free, deterministic name based on the leaf so repeated
89
+ // scopes produce stable paths.
90
+ return {
91
+ containerPath: `/mnt/sandbox/${toSafeSegment(scopePath)}`,
92
+ needsBind: true,
93
+ };
94
+ }
95
+ function toSafeSegment(hostPath) {
96
+ return hostPath
97
+ .replace(/[\\:]+/g, '_')
98
+ .replace(/\//g, '_')
99
+ .replace(/^_+|_+$/g, '');
100
+ }
101
+ /**
102
+ * Build `docker run` arguments from step capabilities.
103
+ *
104
+ * Default posture: project mounted read-only at `/workspace`, no network,
105
+ * no tool home access. Capabilities grant additional permissions:
106
+ * - fs:read scoped → `-v <host>:<container>:ro` for each outside-project
107
+ * path; inside-project paths are already covered by
108
+ * the workspace bind
109
+ * - fs:read unscoped → no-op (workspace is already read-only)
110
+ * - fs:write scoped → promotes scoped paths to rw
111
+ * - fs:write unscoped → promotes `/workspace` to rw
112
+ * - net → omit `--network none`
113
+ *
114
+ * When `permissionLevel` is `elevated` or `autonomous`:
115
+ * - Mount tool home paths rw at `/root/<rel>`
116
+ * - Share the host network (omit `--network none`)
117
+ */
118
+ export function buildDockerArgs(command, capabilities, projectRoot, options) {
119
+ const args = ['run', '--rm', '-i'];
120
+ // ── Workspace mount (read-only by default) ──────────────────────────
121
+ const fsWrite = capabilities.find(c => c.type === 'fs:write');
122
+ const projectRw = Boolean(fsWrite && (!fsWrite.scope || fsWrite.scope.length === 0));
123
+ args.push('-v', `${normaliseHostPath(projectRoot)}:${CONTAINER_WORKSPACE}${projectRw ? '' : ':ro'}`);
124
+ args.push('-w', CONTAINER_WORKSPACE);
125
+ // Track container paths we've already bound so we don't double-mount.
126
+ const mountedContainerPaths = new Set([CONTAINER_WORKSPACE]);
127
+ // ── fs:write scoped — rw binds for each path ────────────────────────
128
+ // Inside-project scopes use overlay binds: Docker Desktop honours a second
129
+ // `-v` whose container target is a subpath of the first, and the second
130
+ // mount's mode wins for its subtree. That gives us per-scope rw without
131
+ // opening the whole workspace — the same posture bwrap provides via
132
+ // `--ro-bind /` + `--bind <scope>`.
133
+ if (fsWrite && fsWrite.scope && fsWrite.scope.length > 0) {
134
+ for (const scopePath of fsWrite.scope) {
135
+ const resolved = isAbsolute(scopePath) ? scopePath : posix.join(projectRoot, scopePath.replace(/^\.\/+/, ''));
136
+ const { containerPath } = translateScopePath(resolved, projectRoot);
137
+ if (mountedContainerPaths.has(containerPath))
138
+ continue;
139
+ args.push('-v', `${normaliseHostPath(resolved)}:${containerPath}`);
140
+ mountedContainerPaths.add(containerPath);
141
+ }
142
+ }
143
+ // ── fs:read scoped — ro binds for outside-project paths ─────────────
144
+ const fsRead = capabilities.find(c => c.type === 'fs:read');
145
+ if (fsRead && fsRead.scope && fsRead.scope.length > 0) {
146
+ for (const scopePath of fsRead.scope) {
147
+ const resolved = isAbsolute(scopePath) ? scopePath : posix.join(projectRoot, scopePath.replace(/^\.\/+/, ''));
148
+ const { containerPath, needsBind } = translateScopePath(resolved, projectRoot);
149
+ if (!needsBind)
150
+ continue;
151
+ if (mountedContainerPaths.has(containerPath))
152
+ continue;
153
+ args.push('-v', `${normaliseHostPath(resolved)}:${containerPath}:ro`);
154
+ mountedContainerPaths.add(containerPath);
155
+ }
156
+ }
157
+ // ── Tool home paths (elevated/autonomous only) ──────────────────────
158
+ if (needsToolHomeAccess(options.permissionLevel)) {
159
+ const home = options.homeDir ?? homedir();
160
+ if (home) {
161
+ for (const rel of TOOL_HOME_PATHS) {
162
+ const hostPath = posix.join(home.replace(/\\/g, '/'), rel);
163
+ const containerPath = posix.join(CONTAINER_HOME, rel);
164
+ if (mountedContainerPaths.has(containerPath))
165
+ continue;
166
+ args.push('-v', `${normaliseHostPath(hostPath)}:${containerPath}`);
167
+ mountedContainerPaths.add(containerPath);
168
+ }
169
+ }
170
+ }
171
+ // ── Network isolation ───────────────────────────────────────────────
172
+ const hasNet = capabilities.some(c => c.type === 'net');
173
+ if (!hasNet && !needsToolHomeAccess(options.permissionLevel)) {
174
+ args.push('--network', 'none');
175
+ }
176
+ // ── Image + command ─────────────────────────────────────────────────
177
+ args.push(options.image);
178
+ args.push('bash', '-c', command);
179
+ return args;
180
+ }
181
+ /**
182
+ * Wrap a bash command for execution inside a Docker container.
183
+ *
184
+ * Returns a `SandboxWrapResult` identical in shape to the bwrap/sandbox-exec
185
+ * wrappers. `--rm` handles container cleanup, so `cleanup()` is a no-op.
186
+ */
187
+ export function wrapWithDocker(command, capabilities, projectRoot, options) {
188
+ const args = buildDockerArgs(command, capabilities, projectRoot, options);
189
+ return {
190
+ bin: options.dockerBin ?? 'docker',
191
+ args,
192
+ cleanup: () => { },
193
+ };
194
+ }
195
+ //# sourceMappingURL=docker-sandbox.js.map
@@ -19,6 +19,8 @@ export const DEFAULT_SANDBOX_CONFIG = {
19
19
  enabled: false,
20
20
  tier: 'auto',
21
21
  };
22
+ /** Recommended image for first-time Windows Docker sandbox setup. */
23
+ export const RECOMMENDED_DOCKER_IMAGE = 'node:20-bookworm-slim';
22
24
  // ============================================================================
23
25
  // Detection (cached)
24
26
  // ============================================================================
@@ -122,9 +124,14 @@ export function resolveSandboxConfig(raw) {
122
124
  return DEFAULT_SANDBOX_CONFIG;
123
125
  const enabled = raw.enabled;
124
126
  const tier = raw.tier;
127
+ const dockerImageRaw = raw.dockerImage ?? raw.docker_image;
128
+ const dockerImage = typeof dockerImageRaw === 'string' && dockerImageRaw.trim().length > 0
129
+ ? dockerImageRaw.trim()
130
+ : undefined;
125
131
  return {
126
132
  enabled: typeof enabled === 'boolean' ? enabled : DEFAULT_SANDBOX_CONFIG.enabled,
127
133
  tier: isValidTier(tier) ? tier : DEFAULT_SANDBOX_CONFIG.tier,
134
+ ...(dockerImage ? { dockerImage } : {}),
128
135
  };
129
136
  }
130
137
  function isValidTier(value) {
@@ -167,10 +174,25 @@ export function resolveEffectiveSandbox(config, capability = detectSandboxCapabi
167
174
  displayStatus: `OS sandbox: disabled (denylist active)`,
168
175
  };
169
176
  }
170
- // tier: full require OS sandbox
177
+ // Windows: give beginner-friendly setup instructions when sandboxing is
178
+ // enabled but Docker isn't ready. Runs before the generic "not available"
179
+ // branch so Windows users see actionable guidance instead of a terse
180
+ // "not available (win32)" message.
181
+ if (capability.platform === 'win32') {
182
+ if (!capability.available) {
183
+ throw new Error(formatWindowsDockerNotReadyMessage());
184
+ }
185
+ if (!config.dockerImage) {
186
+ throw new Error(formatWindowsDockerImageMissingMessage());
187
+ }
188
+ if (!dockerImageExists(config.dockerImage)) {
189
+ throw new Error(formatWindowsDockerImageNotPulledMessage(config.dockerImage));
190
+ }
191
+ }
192
+ // tier: full — require OS sandbox on non-Windows platforms
171
193
  if (config.tier === 'full' && !capability.available) {
172
194
  throw new Error(`Sandbox tier "full" requires an OS sandbox but none was detected on ${capability.platform}. ` +
173
- `Install bubblewrap (Linux), or Docker Desktop (Windows), or set sandbox.tier to "auto".`);
195
+ `Install bubblewrap (Linux) or set sandbox.tier to "auto".`);
174
196
  }
175
197
  if (!capability.available) {
176
198
  return {
@@ -187,6 +209,83 @@ export function resolveEffectiveSandbox(config, capability = detectSandboxCapabi
187
209
  displayStatus: `OS sandbox: ${capability.tool} (${capability.platform})`,
188
210
  };
189
211
  }
212
+ /**
213
+ * Check whether a Docker image is available locally (already pulled).
214
+ * Returns false on any error (daemon down, image missing, docker not in PATH).
215
+ */
216
+ function dockerImageExists(image) {
217
+ try {
218
+ execSync(`docker image inspect ${shellQuote(image)}`, { stdio: 'ignore', timeout: 5000 });
219
+ return true;
220
+ }
221
+ catch {
222
+ return false;
223
+ }
224
+ }
225
+ /** Minimal shell quoting for image names — keeps the execSync call safe. */
226
+ function shellQuote(value) {
227
+ return `"${value.replace(/"/g, '\\"')}"`;
228
+ }
229
+ // ── Beginner-friendly setup messages (Windows) ──────────────────────────
230
+ function formatWindowsDockerNotReadyMessage() {
231
+ return [
232
+ 'Sandboxing is enabled, but Docker Desktop is not ready on this machine.',
233
+ '',
234
+ 'Windows sandboxing runs your spell steps inside a Docker container so',
235
+ 'they cannot touch the rest of your system. This is a one-time setup:',
236
+ '',
237
+ ' 1. Install Docker Desktop (free):',
238
+ ' https://www.docker.com/products/docker-desktop/',
239
+ '',
240
+ ' 2. After installing, start Docker Desktop from the Start menu.',
241
+ ' Wait for the whale icon in your system tray to stop animating —',
242
+ ' that means Docker is ready.',
243
+ '',
244
+ ' 3. Open PowerShell (or any terminal) and pull the recommended image:',
245
+ ` docker pull ${RECOMMENDED_DOCKER_IMAGE}`,
246
+ '',
247
+ ' 4. Add this to your moflo.yaml:',
248
+ ' sandbox:',
249
+ ' enabled: true',
250
+ ` dockerImage: ${RECOMMENDED_DOCKER_IMAGE}`,
251
+ '',
252
+ 'Not ready to set this up? You can turn sandboxing off instead by setting',
253
+ '`sandbox.enabled: false` in moflo.yaml.',
254
+ ].join('\n');
255
+ }
256
+ function formatWindowsDockerImageMissingMessage() {
257
+ return [
258
+ 'Sandboxing is enabled, but no Docker image is configured.',
259
+ '',
260
+ 'Docker is ready on this machine — it just needs to know which image to',
261
+ 'run your spell steps inside. This is a one-time setup:',
262
+ '',
263
+ ' 1. Open PowerShell (or any terminal) and pull the recommended image:',
264
+ ` docker pull ${RECOMMENDED_DOCKER_IMAGE}`,
265
+ '',
266
+ ' 2. Add this to your moflo.yaml:',
267
+ ' sandbox:',
268
+ ' enabled: true',
269
+ ` dockerImage: ${RECOMMENDED_DOCKER_IMAGE}`,
270
+ '',
271
+ `The recommended image (${RECOMMENDED_DOCKER_IMAGE}) includes node, npm,`,
272
+ 'bash, git, and curl. Any image with bash will work.',
273
+ ].join('\n');
274
+ }
275
+ function formatWindowsDockerImageNotPulledMessage(image) {
276
+ return [
277
+ `Sandboxing is enabled, but the Docker image "${image}" is not available`,
278
+ 'on this machine yet.',
279
+ '',
280
+ 'To fix this, open PowerShell (or any terminal) and run:',
281
+ ` docker pull ${image}`,
282
+ '',
283
+ 'This only needs to happen once — Docker caches the image afterwards.',
284
+ '',
285
+ 'If Docker Desktop is not running, start it from the Start menu first',
286
+ 'and wait for the whale icon in your system tray to stop animating.',
287
+ ].join('\n');
288
+ }
190
289
  /**
191
290
  * Format a one-line log message for spell startup.
192
291
  */
@@ -135,8 +135,12 @@ export function generateSandboxProfile(capabilities, projectRoot, options = {})
135
135
  }
136
136
  }
137
137
  // ── net ──────────────────────────────────────────────────────────────
138
+ // Elevated/autonomous steps spawn CLI tools (claude, gh, git, npm) that
139
+ // need network to reach their APIs. Mirror the tool-home-paths policy
140
+ // and bwrap's host-network share so those tools can reach
141
+ // api.anthropic.com, api.github.com, etc. without DNS/TLS failures.
138
142
  const hasNet = capabilities.some(c => c.type === 'net');
139
- if (hasNet) {
143
+ if (hasNet || needsToolHomeAccess(options.permissionLevel)) {
140
144
  lines.push('');
141
145
  lines.push('; Network access');
142
146
  lines.push('(allow network*)');