pi-microsandbox 0.1.0
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/LICENSE +21 -0
- package/README.md +120 -0
- package/SECURITY.md +58 -0
- package/docs/commands.md +38 -0
- package/docs/configuration.md +80 -0
- package/docs/development.md +119 -0
- package/docs/getting-started.md +66 -0
- package/docs/images.md +190 -0
- package/docs/safety.md +39 -0
- package/docs/storage.md +57 -0
- package/docs/troubleshooting.md +20 -0
- package/extensions/pi-msb/command.ts +532 -0
- package/extensions/pi-msb/config.ts +771 -0
- package/extensions/pi-msb/control.ts +803 -0
- package/extensions/pi-msb/footer.ts +191 -0
- package/extensions/pi-msb/git.ts +256 -0
- package/extensions/pi-msb/index.ts +156 -0
- package/extensions/pi-msb/labels.ts +321 -0
- package/extensions/pi-msb/locks.ts +292 -0
- package/extensions/pi-msb/operations-exec.ts +434 -0
- package/extensions/pi-msb/operations.ts +321 -0
- package/extensions/pi-msb/prune.ts +232 -0
- package/extensions/pi-msb/sandbox-manager.ts +702 -0
- package/extensions/pi-msb/skill-access.ts +164 -0
- package/extensions/pi-msb/storage.ts +332 -0
- package/extensions/pi-msb/tools.ts +417 -0
- package/extensions/pi-msb/transport.ts +518 -0
- package/extensions/pi-msb/types.ts +436 -0
- package/package.json +74 -0
|
@@ -0,0 +1,417 @@
|
|
|
1
|
+
import { Type } from "typebox";
|
|
2
|
+
import type {
|
|
3
|
+
BashOperations,
|
|
4
|
+
ExtensionAPI,
|
|
5
|
+
ExtensionContext,
|
|
6
|
+
ToolDefinition,
|
|
7
|
+
UserBashEventResult,
|
|
8
|
+
} from "@earendil-works/pi-coding-agent";
|
|
9
|
+
import {
|
|
10
|
+
createBashToolDefinition,
|
|
11
|
+
createEditToolDefinition,
|
|
12
|
+
createFindToolDefinition,
|
|
13
|
+
createGrepToolDefinition,
|
|
14
|
+
createLsToolDefinition,
|
|
15
|
+
createReadToolDefinition,
|
|
16
|
+
createWriteToolDefinition,
|
|
17
|
+
} from "@earendil-works/pi-coding-agent";
|
|
18
|
+
import type {
|
|
19
|
+
Config,
|
|
20
|
+
ExecutionTarget as ContractExecutionTarget,
|
|
21
|
+
GrepFormattingHelpers,
|
|
22
|
+
HostReadAccess,
|
|
23
|
+
RuntimeState,
|
|
24
|
+
SandboxGrepExecute,
|
|
25
|
+
StorageMode,
|
|
26
|
+
ToolOpsProvider,
|
|
27
|
+
} from "./types.ts";
|
|
28
|
+
|
|
29
|
+
export type ExecutionTarget = ContractExecutionTarget;
|
|
30
|
+
|
|
31
|
+
type RoutedToolName = "bash" | "edit" | "find" | "grep" | "ls" | "read" | "write";
|
|
32
|
+
const ROUTED_TOOLS: readonly RoutedToolName[] = ["bash", "edit", "find", "grep", "ls", "read", "write"];
|
|
33
|
+
const ROUTED_TOOL_SET = new Set<string>(ROUTED_TOOLS);
|
|
34
|
+
const EXECUTION_TARGET_DESCRIPTION =
|
|
35
|
+
'Where to execute this tool call. Omit this or use "sandbox" normally. Use "host" only when sandbox execution cannot perform the operation; host execution requires user approval while sandboxing is active.';
|
|
36
|
+
|
|
37
|
+
/** Add the routing control without changing any of the built-in required fields. */
|
|
38
|
+
export function withExecutionTarget<T extends Type.TProperties>(
|
|
39
|
+
schema: Type.TObject<T>,
|
|
40
|
+
): Type.TObject<any> {
|
|
41
|
+
return Type.Object({
|
|
42
|
+
...schema.properties,
|
|
43
|
+
execution_target: Type.Optional(
|
|
44
|
+
Type.Unsafe<ExecutionTarget>({
|
|
45
|
+
type: "string",
|
|
46
|
+
enum: ["sandbox", "host"],
|
|
47
|
+
description: EXECUTION_TARGET_DESCRIPTION,
|
|
48
|
+
}),
|
|
49
|
+
),
|
|
50
|
+
});
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function withoutExecutionTarget<T extends { execution_target?: ExecutionTarget }>(
|
|
54
|
+
value: T,
|
|
55
|
+
): Omit<T, "execution_target"> {
|
|
56
|
+
const { execution_target: _executionTarget, ...withoutTarget } = value;
|
|
57
|
+
return withoutTarget;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function sortForFingerprint(value: unknown): unknown {
|
|
61
|
+
if (Array.isArray(value)) return value.map(sortForFingerprint);
|
|
62
|
+
if (value && typeof value === "object") {
|
|
63
|
+
const sorted: Record<string, unknown> = {};
|
|
64
|
+
for (const key of Object.keys(value as Record<string, unknown>).sort()) {
|
|
65
|
+
sorted[key] = sortForFingerprint((value as Record<string, unknown>)[key]);
|
|
66
|
+
}
|
|
67
|
+
return sorted;
|
|
68
|
+
}
|
|
69
|
+
return value;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function hostRequestFingerprint(
|
|
73
|
+
tool: string,
|
|
74
|
+
input: Record<string, unknown>,
|
|
75
|
+
cwd: string,
|
|
76
|
+
): string {
|
|
77
|
+
return JSON.stringify(sortForFingerprint([tool, cwd, withoutExecutionTarget(input)]));
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export function hostApprovalMessage(
|
|
81
|
+
tool: string,
|
|
82
|
+
input: Record<string, unknown>,
|
|
83
|
+
cwd: string,
|
|
84
|
+
mode?: StorageMode,
|
|
85
|
+
): string {
|
|
86
|
+
const lines = [
|
|
87
|
+
`Tool: ${tool}`,
|
|
88
|
+
`Working directory: ${cwd}`,
|
|
89
|
+
...(mode ? [`Storage mode: ${mode}`] : []),
|
|
90
|
+
"",
|
|
91
|
+
"Exact arguments:",
|
|
92
|
+
JSON.stringify(sortForFingerprint(withoutExecutionTarget(input)), null, 2),
|
|
93
|
+
"",
|
|
94
|
+
"This operation will run outside the selected pi-microsandbox environment with the host process's permissions and environment.",
|
|
95
|
+
];
|
|
96
|
+
if (mode === "git") {
|
|
97
|
+
lines.push(
|
|
98
|
+
"Warning: host-targeted execution bypasses the retained git volume and can mutate the host working tree.",
|
|
99
|
+
);
|
|
100
|
+
}
|
|
101
|
+
return lines.join("\n");
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
export interface RegisterToolsDeps {
|
|
105
|
+
provider: ToolOpsProvider;
|
|
106
|
+
config: Config;
|
|
107
|
+
cwd: string;
|
|
108
|
+
hostReads: HostReadAccess;
|
|
109
|
+
createGrepExecute: (args: {
|
|
110
|
+
provider: ToolOpsProvider;
|
|
111
|
+
cwd: string;
|
|
112
|
+
grepHelpers: GrepFormattingHelpers;
|
|
113
|
+
}) => SandboxGrepExecute;
|
|
114
|
+
grepHelpers: GrepFormattingHelpers;
|
|
115
|
+
systemPromptNote: (state: RuntimeState) => string;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
type AnyToolDefinition = ToolDefinition<any, any, any>;
|
|
119
|
+
type ToolParams = Record<string, any> & { execution_target?: ExecutionTarget };
|
|
120
|
+
|
|
121
|
+
function stateOf(deps: RegisterToolsDeps): RuntimeState {
|
|
122
|
+
return deps.provider.getState();
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function isHostMode(state: RuntimeState, config: Config): boolean {
|
|
126
|
+
return state.status === "off" || state.status === "disabled" || state.status === "host-fallback" ||
|
|
127
|
+
(state.status === "unavailable" && config.fallbackMode === "host");
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function isSandboxActive(state: RuntimeState, provider: ToolOpsProvider): boolean {
|
|
131
|
+
return state.status === "active" && provider.isActive();
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function unavailableError(state: RuntimeState): Error {
|
|
135
|
+
const reason = state.reason ? `: ${state.reason}` : "";
|
|
136
|
+
return new Error(`Sandbox is unavailable${reason}; routed tool execution is blocked.`);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function routeError(tool: string): Error {
|
|
140
|
+
return new Error(`Tool ${tool} is excluded from pi-microsandbox routing by configuration.`);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function asRecord(value: unknown): Record<string, unknown> {
|
|
144
|
+
return value && typeof value === "object" ? value as Record<string, unknown> : {};
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function isHostTarget(value: ToolParams): boolean {
|
|
148
|
+
return value.execution_target === "host";
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function createSandboxDefinition(
|
|
152
|
+
name: RoutedToolName,
|
|
153
|
+
cwd: string,
|
|
154
|
+
provider: ToolOpsProvider,
|
|
155
|
+
hostDefinition: AnyToolDefinition,
|
|
156
|
+
): AnyToolDefinition {
|
|
157
|
+
return {
|
|
158
|
+
...hostDefinition,
|
|
159
|
+
parameters: hostDefinition.parameters,
|
|
160
|
+
execute: async (id, params, signal, onUpdate, ctx) => {
|
|
161
|
+
return provider.withRuntime(async (runtime) => {
|
|
162
|
+
const options = name === "bash"
|
|
163
|
+
? { operations: runtime.operations.bash, exposeSessionEnvironment: false }
|
|
164
|
+
: { operations: runtime.operations[name] };
|
|
165
|
+
|
|
166
|
+
const factory = {
|
|
167
|
+
bash: createBashToolDefinition,
|
|
168
|
+
edit: createEditToolDefinition,
|
|
169
|
+
find: createFindToolDefinition,
|
|
170
|
+
grep: createGrepToolDefinition,
|
|
171
|
+
ls: createLsToolDefinition,
|
|
172
|
+
read: createReadToolDefinition,
|
|
173
|
+
write: createWriteToolDefinition,
|
|
174
|
+
}[name] as (cwd: string, options?: never) => AnyToolDefinition;
|
|
175
|
+
const definition = (factory as any)(cwd, options) as AnyToolDefinition;
|
|
176
|
+
return definition.execute(id, params, signal, onUpdate, ctx);
|
|
177
|
+
});
|
|
178
|
+
},
|
|
179
|
+
};
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
function resultFullOutputPath(result: unknown): string | undefined {
|
|
183
|
+
const details = asRecord(asRecord(result).details);
|
|
184
|
+
return typeof details.fullOutputPath === "string" ? details.fullOutputPath : undefined;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* Register wrappers around Pi's built-ins. The built-in definitions are made once
|
|
189
|
+
* and spread into each override, so renderers, prompt metadata, argument shims,
|
|
190
|
+
* and execution policy are not accidentally replaced.
|
|
191
|
+
*/
|
|
192
|
+
export function registerSandboxTools(pi: ExtensionAPI, deps: RegisterToolsDeps): void {
|
|
193
|
+
const hostDefinitions: Record<RoutedToolName, AnyToolDefinition> = {
|
|
194
|
+
bash: createBashToolDefinition(deps.cwd),
|
|
195
|
+
edit: createEditToolDefinition(deps.cwd),
|
|
196
|
+
find: createFindToolDefinition(deps.cwd),
|
|
197
|
+
grep: createGrepToolDefinition(deps.cwd),
|
|
198
|
+
ls: createLsToolDefinition(deps.cwd),
|
|
199
|
+
read: createReadToolDefinition(deps.cwd),
|
|
200
|
+
write: createWriteToolDefinition(deps.cwd),
|
|
201
|
+
};
|
|
202
|
+
const sandboxGrepExecute = deps.createGrepExecute({
|
|
203
|
+
provider: deps.provider,
|
|
204
|
+
cwd: deps.cwd,
|
|
205
|
+
grepHelpers: deps.grepHelpers,
|
|
206
|
+
});
|
|
207
|
+
const approvedHostCalls = new Map<string, string>();
|
|
208
|
+
|
|
209
|
+
const clearApproval = (id: unknown): void => {
|
|
210
|
+
if (typeof id === "string") approvedHostCalls.delete(id);
|
|
211
|
+
};
|
|
212
|
+
|
|
213
|
+
const requireApprovedHostExecution = (
|
|
214
|
+
tool: string,
|
|
215
|
+
id: string,
|
|
216
|
+
input: Record<string, unknown>,
|
|
217
|
+
mode: StorageMode | undefined,
|
|
218
|
+
): void => {
|
|
219
|
+
const approved = approvedHostCalls.get(id);
|
|
220
|
+
approvedHostCalls.delete(id);
|
|
221
|
+
if (approved !== hostRequestFingerprint(tool, input, deps.cwd)) {
|
|
222
|
+
throw new Error("Host execution was not approved for this exact tool call.");
|
|
223
|
+
}
|
|
224
|
+
// Approval is deliberately consumed here rather than cached; a runtime
|
|
225
|
+
// mode change must never extend a one-call host escape.
|
|
226
|
+
void mode;
|
|
227
|
+
};
|
|
228
|
+
|
|
229
|
+
const executeHost = async (
|
|
230
|
+
definition: AnyToolDefinition,
|
|
231
|
+
id: string,
|
|
232
|
+
input: ToolParams,
|
|
233
|
+
signal: AbortSignal | undefined,
|
|
234
|
+
onUpdate: any,
|
|
235
|
+
ctx: ExtensionContext,
|
|
236
|
+
active: boolean,
|
|
237
|
+
mode: StorageMode | undefined,
|
|
238
|
+
): Promise<any> => {
|
|
239
|
+
if (active) {
|
|
240
|
+
if (!deps.config.allowHostExecution) {
|
|
241
|
+
throw new Error("Host execution is disabled by pi-microsandbox configuration.");
|
|
242
|
+
}
|
|
243
|
+
requireApprovedHostExecution(definition.name, id, input, mode);
|
|
244
|
+
}
|
|
245
|
+
return definition.execute(id, withoutExecutionTarget(input), signal, onUpdate, ctx);
|
|
246
|
+
};
|
|
247
|
+
|
|
248
|
+
for (const name of ROUTED_TOOLS) {
|
|
249
|
+
const hostDefinition = hostDefinitions[name];
|
|
250
|
+
const wrapped: AnyToolDefinition = {
|
|
251
|
+
...hostDefinition,
|
|
252
|
+
parameters: withExecutionTarget(hostDefinition.parameters as Type.TObject<any>),
|
|
253
|
+
execute: async (id, rawParams, signal, onUpdate, ctx) => {
|
|
254
|
+
const params = rawParams as ToolParams;
|
|
255
|
+
const state = stateOf(deps);
|
|
256
|
+
const hostMode = isHostMode(state, deps.config);
|
|
257
|
+
const active = isSandboxActive(state, deps.provider);
|
|
258
|
+
const targetHost = isHostTarget(params);
|
|
259
|
+
|
|
260
|
+
if (!deps.config.routeTools.includes(name)) throw routeError(name);
|
|
261
|
+
if (hostMode) {
|
|
262
|
+
return executeHost(hostDefinition, id, params, signal, onUpdate, ctx, false, undefined);
|
|
263
|
+
}
|
|
264
|
+
if (!active) throw unavailableError(state);
|
|
265
|
+
if (targetHost) {
|
|
266
|
+
return executeHost(hostDefinition, id, params, signal, onUpdate, ctx, true, state.info?.mode);
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
if (name === "read" && deps.config.allowSkillReads) {
|
|
270
|
+
const hostSkillPath = await deps.hostReads.resolve(
|
|
271
|
+
String((params as Record<string, unknown>).path ?? ""),
|
|
272
|
+
deps.cwd,
|
|
273
|
+
);
|
|
274
|
+
if (hostSkillPath) {
|
|
275
|
+
return hostDefinition.execute(
|
|
276
|
+
id,
|
|
277
|
+
{ ...withoutExecutionTarget(params), path: hostSkillPath },
|
|
278
|
+
signal,
|
|
279
|
+
onUpdate,
|
|
280
|
+
ctx,
|
|
281
|
+
);
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
if (name === "grep") {
|
|
286
|
+
const result = await sandboxGrepExecute(
|
|
287
|
+
id,
|
|
288
|
+
withoutExecutionTarget(params),
|
|
289
|
+
signal,
|
|
290
|
+
onUpdate,
|
|
291
|
+
);
|
|
292
|
+
const fullOutputPath = resultFullOutputPath(result);
|
|
293
|
+
if (fullOutputPath) await deps.hostReads.allowGeneratedFile(fullOutputPath);
|
|
294
|
+
return result;
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
const sandboxDefinition = createSandboxDefinition(name, deps.cwd, deps.provider, hostDefinition);
|
|
298
|
+
const result = await sandboxDefinition.execute(
|
|
299
|
+
id,
|
|
300
|
+
withoutExecutionTarget(params),
|
|
301
|
+
signal,
|
|
302
|
+
onUpdate,
|
|
303
|
+
ctx,
|
|
304
|
+
);
|
|
305
|
+
if (name === "bash") {
|
|
306
|
+
const fullOutputPath = resultFullOutputPath(result);
|
|
307
|
+
if (fullOutputPath) await deps.hostReads.allowGeneratedFile(fullOutputPath);
|
|
308
|
+
}
|
|
309
|
+
return result;
|
|
310
|
+
},
|
|
311
|
+
};
|
|
312
|
+
pi.registerTool(wrapped);
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
pi.on("tool_call", async (event, ctx) => {
|
|
316
|
+
const state = stateOf(deps);
|
|
317
|
+
const hostMode = isHostMode(state, deps.config);
|
|
318
|
+
const active = isSandboxActive(state, deps.provider);
|
|
319
|
+
const tool = event.toolName;
|
|
320
|
+
const input = asRecord(event.input);
|
|
321
|
+
|
|
322
|
+
if (ROUTED_TOOL_SET.has(tool)) {
|
|
323
|
+
if (!deps.config.routeTools.includes(tool)) {
|
|
324
|
+
return { block: true, reason: `Tool ${tool} is excluded from pi-microsandbox routing by configuration.` };
|
|
325
|
+
}
|
|
326
|
+
if (hostMode) return;
|
|
327
|
+
if (!active) {
|
|
328
|
+
return { block: true, reason: unavailableError(state).message };
|
|
329
|
+
}
|
|
330
|
+
if (input.execution_target !== "host") return;
|
|
331
|
+
if (!deps.config.allowHostExecution) {
|
|
332
|
+
return { block: true, reason: "Host execution is disabled by pi-microsandbox configuration." };
|
|
333
|
+
}
|
|
334
|
+
if (!ctx.hasUI) {
|
|
335
|
+
return {
|
|
336
|
+
block: true,
|
|
337
|
+
reason: "Host execution requires user approval, but no interactive UI is available.",
|
|
338
|
+
};
|
|
339
|
+
}
|
|
340
|
+
const approved = await ctx.ui.confirm(
|
|
341
|
+
"Allow host execution?",
|
|
342
|
+
hostApprovalMessage(tool, input, deps.cwd, state.info?.mode),
|
|
343
|
+
);
|
|
344
|
+
if (!approved) {
|
|
345
|
+
clearApproval(event.toolCallId);
|
|
346
|
+
return { block: true, reason: "Host execution was denied by the user." };
|
|
347
|
+
}
|
|
348
|
+
approvedHostCalls.set(
|
|
349
|
+
event.toolCallId,
|
|
350
|
+
hostRequestFingerprint(tool, input, deps.cwd),
|
|
351
|
+
);
|
|
352
|
+
return;
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
// Explicit host/off mode is the user's host-mode handoff, so the provenance
|
|
356
|
+
// gate is intentionally inactive there.
|
|
357
|
+
if (hostMode) return;
|
|
358
|
+
|
|
359
|
+
const registered = (typeof (pi as ExtensionAPI).getAllTools === "function"
|
|
360
|
+
? pi.getAllTools()
|
|
361
|
+
: []).find((candidate) => candidate.name === tool);
|
|
362
|
+
const source = registered?.sourceInfo?.source;
|
|
363
|
+
if (source === "builtin") return;
|
|
364
|
+
if (!deps.config.blockThirdParty) return;
|
|
365
|
+
if (deps.config.passThroughTools.includes(tool)) return;
|
|
366
|
+
return {
|
|
367
|
+
block: true,
|
|
368
|
+
reason: `Tool ${tool} is not an approved pass-through tool for the selected pi-microsandbox environment.`,
|
|
369
|
+
};
|
|
370
|
+
});
|
|
371
|
+
|
|
372
|
+
pi.on("tool_execution_end", (event) => {
|
|
373
|
+
clearApproval(event.toolCallId);
|
|
374
|
+
});
|
|
375
|
+
|
|
376
|
+
pi.on("user_bash", (): UserBashEventResult | undefined => {
|
|
377
|
+
const state = stateOf(deps);
|
|
378
|
+
if (isHostMode(state, deps.config)) return undefined;
|
|
379
|
+
if (!isSandboxActive(state, deps.provider)) {
|
|
380
|
+
const error = unavailableError(state);
|
|
381
|
+
const operations: BashOperations = {
|
|
382
|
+
exec: async () => {
|
|
383
|
+
throw error;
|
|
384
|
+
},
|
|
385
|
+
};
|
|
386
|
+
return { operations };
|
|
387
|
+
}
|
|
388
|
+
const operations: BashOperations = {
|
|
389
|
+
exec: async (command, cwd, options) => deps.provider.withRuntime((runtime) =>
|
|
390
|
+
runtime.operations.bash.exec(command, cwd, options),
|
|
391
|
+
),
|
|
392
|
+
};
|
|
393
|
+
return { operations };
|
|
394
|
+
});
|
|
395
|
+
|
|
396
|
+
pi.on("before_agent_start", (event) => {
|
|
397
|
+
const skills = (event.systemPromptOptions?.skills ?? []) as Array<{
|
|
398
|
+
filePath?: string;
|
|
399
|
+
baseDir?: string;
|
|
400
|
+
}>;
|
|
401
|
+
deps.hostReads.updateSkills(
|
|
402
|
+
skills
|
|
403
|
+
.filter((skill): skill is { filePath: string; baseDir: string } =>
|
|
404
|
+
typeof skill.filePath === "string" && typeof skill.baseDir === "string",
|
|
405
|
+
)
|
|
406
|
+
.map(({ filePath, baseDir }) => ({ filePath, baseDir })),
|
|
407
|
+
);
|
|
408
|
+
const state = stateOf(deps);
|
|
409
|
+
const note = deps.systemPromptNote(state);
|
|
410
|
+
return { systemPrompt: note ? `${event.systemPrompt}\n\n${note}` : event.systemPrompt };
|
|
411
|
+
});
|
|
412
|
+
|
|
413
|
+
pi.on("session_shutdown", () => {
|
|
414
|
+
approvedHostCalls.clear();
|
|
415
|
+
deps.hostReads.clear();
|
|
416
|
+
});
|
|
417
|
+
}
|