@indigoai-us/hq-cli 5.345.147 → 5.345.149
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/CHANGELOG.md +65 -0
- package/README.md +2 -0
- package/assets/bot-workers/setup/worker.yaml +11 -0
- package/dist/command-catalog.generated.d.ts +341 -11
- package/dist/command-catalog.generated.js +421 -11
- package/dist/command-help.js +2 -1
- package/dist/commands/access.d.ts +45 -2
- package/dist/commands/access.js +537 -223
- package/dist/commands/agent-inbox.js +2 -1
- package/dist/commands/billing-usage.d.ts +33 -2
- package/dist/commands/billing-usage.js +75 -4
- package/dist/commands/bot.js +2 -2
- package/dist/commands/channels.d.ts +27 -0
- package/dist/commands/channels.js +108 -4
- package/dist/commands/cloud.js +31 -1
- package/dist/commands/core-refresh-vault-access-native.d.ts +33 -0
- package/dist/commands/core-refresh-vault-access-native.js +356 -28
- package/dist/commands/core-work-mesh-bind-trusted-native.d.ts +9 -0
- package/dist/commands/core-work-mesh-bind-trusted-native.js +26 -19
- package/dist/commands/core.js +9 -0
- package/dist/commands/daemon.d.ts +7 -0
- package/dist/commands/daemon.js +37 -3
- package/dist/commands/dm.js +18 -3
- package/dist/commands/doctor.d.ts +11 -1
- package/dist/commands/doctor.js +56 -1
- package/dist/commands/install-global.d.ts +8 -8
- package/dist/commands/install-global.js +139 -61
- package/dist/commands/lanes-shared.d.ts +3 -1
- package/dist/commands/lanes.d.ts +4 -4
- package/dist/commands/lanes.js +380 -25
- package/dist/commands/library-add.d.ts +31 -1
- package/dist/commands/library-add.js +69 -2
- package/dist/commands/library.js +37 -3
- package/dist/commands/pack-install.d.ts +2 -2
- package/dist/commands/pack-install.js +133 -35
- package/dist/commands/reindex.js +19 -0
- package/dist/commands/sync-manifest.d.ts +2 -9
- package/dist/commands/sync-manifest.js +4 -15
- package/dist/hook-engine-path.d.ts +4 -0
- package/dist/hook-engine-path.js +43 -0
- package/dist/hq-cloud-sync-runner/bin/sync-runner.js +2783 -2354
- package/dist/hq-cloud-sync-runner/package.json +1 -1
- package/dist/index.d.ts +10 -0
- package/dist/index.js +76 -5
- package/dist/lazy-commands.js +2 -0
- package/dist/lib/agent-kit/inbox-redact.d.ts +44 -0
- package/dist/lib/agent-kit/inbox-redact.js +361 -0
- package/dist/lib/agent-kit/inbox-state.js +11 -2
- package/dist/lib/agent-kit/mcp/tools.js +2 -1
- package/dist/lib/agent-kit/run/inbox.js +36 -7
- package/dist/lib/anywhere-runtime-flag-key.d.ts +3 -0
- package/dist/lib/anywhere-runtime-flag-key.js +9 -0
- package/dist/lib/billing/usage-cost.d.ts +31 -1
- package/dist/lib/billing/usage-cost.js +46 -0
- package/dist/lib/company-skill-wrapper.js +6 -4
- package/dist/lib/daemon/install.d.ts +47 -1
- package/dist/lib/daemon/install.js +181 -15
- package/dist/lib/daemon/lock.d.ts +32 -0
- package/dist/lib/daemon/lock.js +55 -2
- package/dist/lib/db/local.d.ts +2 -2
- package/dist/lib/db/local.js +2 -2
- package/dist/lib/db/sql.js +2 -2
- package/dist/lib/db/sqlite-binding.d.ts +22 -0
- package/dist/lib/db/sqlite-binding.js +168 -0
- package/dist/lib/doctor/__testing__/run-doctor.js +4 -0
- package/dist/lib/doctor/checks/anywhere.d.ts +15 -0
- package/dist/lib/doctor/checks/anywhere.js +77 -11
- package/dist/lib/doctor/checks/cli-path.d.ts +12 -0
- package/dist/lib/doctor/checks/cli-path.js +46 -0
- package/dist/lib/doctor/checks/hook-engine.d.ts +4 -0
- package/dist/lib/doctor/checks/hook-engine.js +17 -0
- package/dist/lib/doctor/checks/sync-health.d.ts +7 -0
- package/dist/lib/doctor/checks/sync-health.js +38 -2
- package/dist/lib/doctor/checks/temp-homes.d.ts +19 -0
- package/dist/lib/doctor/checks/temp-homes.js +54 -0
- package/dist/lib/doctor/fix/apply.d.ts +90 -1
- package/dist/lib/doctor/fix/apply.js +176 -10
- package/dist/lib/doctor/fix/json-output.d.ts +4 -1
- package/dist/lib/doctor/fix/json-output.js +6 -0
- package/dist/lib/doctor/fix/remediation.d.ts +7 -1
- package/dist/lib/doctor/fix/remediation.js +23 -1
- package/dist/lib/doctor/registry.js +10 -0
- package/dist/lib/doctor/temp-home-sweeper.d.ts +149 -0
- package/dist/lib/doctor/temp-home-sweeper.js +418 -0
- package/dist/lib/flag-registry-worker-source.generated.d.ts +1 -1
- package/dist/lib/flag-registry-worker-source.generated.js +1 -1
- package/dist/lib/flag-registry.js +9 -1
- package/dist/lib/flag-snapshot-file.js +8 -3
- package/dist/lib/hook-engine/reconcile.d.ts +9 -0
- package/dist/lib/hook-engine/reconcile.js +134 -0
- package/dist/lib/hook-engine/registration.d.ts +17 -0
- package/dist/lib/hook-engine/registration.js +50 -0
- package/dist/lib/hook-engine/resolve.d.ts +31 -0
- package/dist/lib/hook-engine/resolve.js +134 -0
- package/dist/lib/install/claude-targets.d.ts +2 -1
- package/dist/lib/install/claude-targets.js +8 -3
- package/dist/lib/install/codex-targets.d.ts +3 -0
- package/dist/lib/install/codex-targets.js +15 -4
- package/dist/lib/install/hook-target.d.ts +9 -0
- package/dist/lib/install/hook-target.js +39 -0
- package/dist/lib/lanes/admission-host.d.ts +1 -0
- package/dist/lib/lanes/admission-host.js +8 -1
- package/dist/lib/lanes/admission.d.ts +4 -0
- package/dist/lib/lanes/admission.js +6 -2
- package/dist/lib/lanes/envelope-repair-dispatch.d.ts +5 -0
- package/dist/lib/lanes/envelope-repair-dispatch.js +12 -1
- package/dist/lib/lanes/index.d.ts +2 -2
- package/dist/lib/lanes/index.js +1 -1
- package/dist/lib/lanes/keep-alive.js +4 -1
- package/dist/lib/lanes/local-host-identity.d.ts +40 -0
- package/dist/lib/lanes/local-host-identity.js +314 -0
- package/dist/lib/lanes/reaper.d.ts +1 -1
- package/dist/lib/lanes/reaper.js +7 -0
- package/dist/lib/lanes/reconcile.d.ts +4 -0
- package/dist/lib/lanes/reconcile.js +16 -8
- package/dist/lib/lanes/spawn.d.ts +20 -0
- package/dist/lib/lanes/spawn.js +48 -2
- package/dist/lib/lanes/status-fields.js +6 -1
- package/dist/lib/lanes/store.js +8 -1
- package/dist/lib/lanes/throttle.d.ts +49 -6
- package/dist/lib/lanes/throttle.js +154 -17
- package/dist/lib/lanes/types.d.ts +22 -1
- package/dist/lib/lanes/types.js +27 -3
- package/dist/lib/lanes/watcher.js +27 -2
- package/dist/lib/lanes/worker-continuity.d.ts +2 -0
- package/dist/lib/lanes/worker-continuity.js +18 -11
- package/dist/lib/manifest-disabled.d.ts +13 -0
- package/dist/lib/manifest-disabled.js +19 -0
- package/dist/lib/service-manager/launchd.d.ts +1 -0
- package/dist/lib/service-manager/launchd.js +1 -1
- package/dist/lib/windows/console-encoding.d.ts +24 -0
- package/dist/lib/windows/console-encoding.js +83 -0
- package/dist/lib/windows/install-followup.d.ts +35 -0
- package/dist/lib/windows/install-followup.js +142 -0
- package/dist/lib/windows/user-path.d.ts +125 -0
- package/dist/lib/windows/user-path.js +275 -0
- package/dist/lib/work-context/auto-bind.d.ts +1 -1
- package/dist/lib/work-context/auto-bind.js +22 -3
- package/dist/lib/work-context/lane-bind.d.ts +31 -0
- package/dist/lib/work-context/lane-bind.js +101 -0
- package/dist/lib/work-context/organize.js +1 -0
- package/dist/lib/work-context/prd-sync.js +7 -0
- package/dist/lib/work-context/project.d.ts +36 -10
- package/dist/lib/work-context/project.js +130 -41
- package/dist/lib/work-context/reconcile.d.ts +6 -0
- package/dist/lib/work-context/reconcile.js +25 -5
- package/dist/lib/work-context/state.d.ts +6 -0
- package/dist/lib/work-context/state.js +29 -1
- package/dist/main.js +2 -1
- package/dist/unknown-command.d.ts +18 -0
- package/dist/unknown-command.js +88 -0
- package/dist/utils/cognito-session.d.ts +8 -0
- package/dist/utils/cognito-session.js +14 -2
- package/dist/utils/copy-tree.d.ts +61 -0
- package/dist/utils/copy-tree.js +249 -0
- package/dist/utils/environmental-error.js +7 -3
- package/dist/utils/hook-trust.d.ts +9 -0
- package/dist/utils/hook-trust.js +30 -2
- package/dist/utils/npm-invocation.d.ts +27 -0
- package/dist/utils/npm-invocation.js +54 -0
- package/dist/utils/qmd-native-binding-error.js +3 -1
- package/dist/utils/resolve-executable.d.ts +2 -0
- package/dist/utils/resolve-executable.js +6 -2
- package/dist/utils/safe-link.d.ts +50 -0
- package/dist/utils/safe-link.js +300 -0
- package/package.json +17 -5
- package/scripts/hook-engine-manifest.mjs +22 -0
- package/scripts/install-hook-engine.mjs +10 -0
|
@@ -1 +1 @@
|
|
|
1
|
-
{"name":"hq-cli-daemon-sync-runner","type":"module","version":"6.18.
|
|
1
|
+
{"name":"hq-cli-daemon-sync-runner","type":"module","version":"6.18.88","bundleSha256":"7e9ee380eae55e059a474fd5f415e8aeb16088805b761d791318d5299645becd","formatVersion":1}
|
package/dist/index.d.ts
CHANGED
|
@@ -3,6 +3,15 @@ import "./node-preflight.js";
|
|
|
3
3
|
import "./node-network-compat-fast.js";
|
|
4
4
|
import { isVersionRequest } from "./version-request.js";
|
|
5
5
|
import { isFastCoreRequest } from "./fast-scaffold.js";
|
|
6
|
+
/**
|
|
7
|
+
* Whether the install follow-up would read the registry or spawn the mesh
|
|
8
|
+
* daemon. False for fast routes' callers, and for commands that are not run
|
|
9
|
+
* from an npm install: the PATH repair needs the npm shim folder above
|
|
10
|
+
* node_modules, and the daemon step needs the installed package. Inlined so
|
|
11
|
+
* those routes do not load user-path.ts. Keep the skip rules aligned with
|
|
12
|
+
* install-followup.ts.
|
|
13
|
+
*/
|
|
14
|
+
export declare function installFollowupWouldDoWork(argv: readonly string[], env: NodeJS.ProcessEnv, platform: NodeJS.Platform): boolean;
|
|
6
15
|
type PackageUseLeaseLoader = () => Promise<void>;
|
|
7
16
|
type PackageUseUpdateProbe = () => Promise<boolean>;
|
|
8
17
|
/** Resolves true once the command was re-run (exit code already set). */
|
|
@@ -18,6 +27,7 @@ declare function runCliEntry(entryUrl: string, dispatch: () => void, loadLease?:
|
|
|
18
27
|
export declare const __test__: {
|
|
19
28
|
isVersionRequest: typeof isVersionRequest;
|
|
20
29
|
isFastCoreRequest: typeof isFastCoreRequest;
|
|
30
|
+
installFollowupWouldDoWork: typeof installFollowupWouldDoWork;
|
|
21
31
|
dispatchWithPackageUseLease: typeof dispatchWithPackageUseLease;
|
|
22
32
|
waitForUpdateThenReexec: typeof waitForUpdateThenReexec;
|
|
23
33
|
runCliEntry: typeof runCliEntry;
|
package/dist/index.js
CHANGED
|
@@ -16,6 +16,7 @@ import { installWindowsHideDefault } from "./utils/windows-hide-default.js";
|
|
|
16
16
|
import { fileURLToPath } from "node:url";
|
|
17
17
|
import { parseMonitorCheckFastRequest } from "./lanes-monitor-check-argv.js";
|
|
18
18
|
import { createRequire } from "node:module";
|
|
19
|
+
import { hookEnginePathArgs, runHookEnginePath } from "./hook-engine-path.js";
|
|
19
20
|
function cygpathMixedPath(value) {
|
|
20
21
|
// Require only a Node builtin: the package supports Node releases before
|
|
21
22
|
// require(ESM) became available, and this runs in the synchronous dispatcher.
|
|
@@ -155,10 +156,48 @@ async function runMonitorCheckFastWithRecovery(request) {
|
|
|
155
156
|
return;
|
|
156
157
|
await runMainWithInstallTreeRecovery();
|
|
157
158
|
}
|
|
159
|
+
/**
|
|
160
|
+
* Whether the install follow-up would read the registry or spawn the mesh
|
|
161
|
+
* daemon. False for fast routes' callers, and for commands that are not run
|
|
162
|
+
* from an npm install: the PATH repair needs the npm shim folder above
|
|
163
|
+
* node_modules, and the daemon step needs the installed package. Inlined so
|
|
164
|
+
* those routes do not load user-path.ts. Keep the skip rules aligned with
|
|
165
|
+
* install-followup.ts.
|
|
166
|
+
*/
|
|
167
|
+
export function installFollowupWouldDoWork(argv, env, platform) {
|
|
168
|
+
if (env.HQ_INSTALL_FOLLOWUP === "1" || env.HQ_STAGED_INSTALL === "1")
|
|
169
|
+
return false;
|
|
170
|
+
const args = argv.slice(2).filter((arg) => arg !== "--verbose");
|
|
171
|
+
if (args[0] === "mesh" && args[1] === "daemon")
|
|
172
|
+
return false;
|
|
173
|
+
if (args[0] === "daemon" && (args[1] === "run" || args[1] === "install"))
|
|
174
|
+
return false;
|
|
175
|
+
if (platform === "win32")
|
|
176
|
+
return /[/\\]node_modules[/\\]/i.test(argv[1] ?? "");
|
|
177
|
+
return /node_modules[/\\]@indigoai-us[/\\]hq-cli[/\\]/i.test(argv[1] ?? "");
|
|
178
|
+
}
|
|
158
179
|
function dispatchCliEntry() {
|
|
159
180
|
// Before anything spawns: a background hq on Windows (no terminal) hides the
|
|
160
181
|
// console window of every child it starts (feedback_351a40d5).
|
|
161
182
|
installWindowsHideDefault();
|
|
183
|
+
const engineArgs = hookEnginePathArgs(process.argv);
|
|
184
|
+
if (engineArgs !== null) {
|
|
185
|
+
void runHookEnginePath(engineArgs);
|
|
186
|
+
return;
|
|
187
|
+
}
|
|
188
|
+
// cmd.exe code page 437 prints em dashes as mojibake. Pipes stay UTF-8.
|
|
189
|
+
// Loaded only on Windows so Linux fast routes do not pay for the module.
|
|
190
|
+
if (process.platform === "win32") {
|
|
191
|
+
void import("./lib/windows/console-encoding.js").then(({ installWindowsConsoleEncoding }) => {
|
|
192
|
+
installWindowsConsoleEncoding();
|
|
193
|
+
}).catch(() => undefined).then(() => {
|
|
194
|
+
dispatchCliRoutes();
|
|
195
|
+
});
|
|
196
|
+
return;
|
|
197
|
+
}
|
|
198
|
+
dispatchCliRoutes();
|
|
199
|
+
}
|
|
200
|
+
function dispatchCliRoutes() {
|
|
162
201
|
if (process.platform === "win32" && coreCommandHasPathOperands(process.argv)) {
|
|
163
202
|
// Keep cygpath off non-Windows and non-path command routes.
|
|
164
203
|
process.argv = normalizeCorePathOperands(process.argv, "win32", cygpathMixedPath);
|
|
@@ -166,6 +205,37 @@ function dispatchCliEntry() {
|
|
|
166
205
|
const versionRequest = isVersionRequest(process.argv);
|
|
167
206
|
const helpRequest = !versionRequest && isCommandHelpRequest(process.argv);
|
|
168
207
|
const parsedFastCore = versionRequest || helpRequest ? null : parseFastCore(process.argv);
|
|
208
|
+
const monitorRequest = versionRequest || helpRequest ? null : parseMonitorCheckFastRequest(process.argv);
|
|
209
|
+
const secretsRequest = versionRequest || helpRequest || monitorRequest || parsedFastCore
|
|
210
|
+
? null
|
|
211
|
+
: parseSecretsEnvFastRequest(process.argv);
|
|
212
|
+
const mcpRequest = !versionRequest &&
|
|
213
|
+
!helpRequest &&
|
|
214
|
+
!monitorRequest &&
|
|
215
|
+
!parsedFastCore &&
|
|
216
|
+
!secretsRequest &&
|
|
217
|
+
isMcpServeRequest(process.argv);
|
|
218
|
+
// `--version`, hook, MCP, and secrets fast routes must not pay for PATH or
|
|
219
|
+
// daemon work, so they never start PowerShell or reg.exe. The module stays
|
|
220
|
+
// unloaded when there is nothing to do, so a Linux fast route does not
|
|
221
|
+
// import it.
|
|
222
|
+
if (!versionRequest && !monitorRequest && !parsedFastCore && !secretsRequest && !mcpRequest
|
|
223
|
+
&& installFollowupWouldDoWork(process.argv, process.env, process.platform)) {
|
|
224
|
+
void import("./lib/windows/install-followup.js").then(({ runWindowsInstallFollowup }) => {
|
|
225
|
+
try {
|
|
226
|
+
runWindowsInstallFollowup({ argv: process.argv });
|
|
227
|
+
}
|
|
228
|
+
catch {
|
|
229
|
+
// Install repair must not turn into a failed command.
|
|
230
|
+
}
|
|
231
|
+
}).catch(() => undefined).then(() => {
|
|
232
|
+
dispatchCliCommand(versionRequest, helpRequest, parsedFastCore, monitorRequest, secretsRequest, mcpRequest);
|
|
233
|
+
});
|
|
234
|
+
return;
|
|
235
|
+
}
|
|
236
|
+
dispatchCliCommand(versionRequest, helpRequest, parsedFastCore, monitorRequest, secretsRequest, mcpRequest);
|
|
237
|
+
}
|
|
238
|
+
function dispatchCliCommand(versionRequest, helpRequest, parsedFastCore, monitorRequest, secretsRequest, mcpRequest) {
|
|
169
239
|
if (versionRequest) {
|
|
170
240
|
// Bare `--version` is the CLI semver only (the contract field updaters
|
|
171
241
|
// compare). `hq version` and `--json` print the labelled HQ/CLI report.
|
|
@@ -196,11 +266,11 @@ function dispatchCliEntry() {
|
|
|
196
266
|
await runMainWithInstallTreeRecovery();
|
|
197
267
|
});
|
|
198
268
|
}
|
|
199
|
-
else if (
|
|
269
|
+
else if (monitorRequest) {
|
|
200
270
|
// The Stop hook runs this every turn in every session. Answer the common
|
|
201
271
|
// no-active-lane case from the lane-record directory alone; anything the
|
|
202
272
|
// fast route cannot prove falls back to the full command graph.
|
|
203
|
-
void runMonitorCheckFastWithRecovery(
|
|
273
|
+
void runMonitorCheckFastWithRecovery(monitorRequest);
|
|
204
274
|
}
|
|
205
275
|
else if (parsedFastCore?.entry.kind === "native" &&
|
|
206
276
|
(process.env.HQ_FLAG_HQ_CORE_NATIVE_FAST_ROUTE === "true" ||
|
|
@@ -216,8 +286,8 @@ function dispatchCliEntry() {
|
|
|
216
286
|
// existing full-graph route.
|
|
217
287
|
void runNativeFastRouteWithRecovery(parsedFastCore);
|
|
218
288
|
}
|
|
219
|
-
else if (
|
|
220
|
-
const request =
|
|
289
|
+
else if (secretsRequest) {
|
|
290
|
+
const request = secretsRequest;
|
|
221
291
|
void import("./secrets-env-fast-route.js").then(async (route) => {
|
|
222
292
|
await route.dispatchSecretsEnvFastRequest(request, {
|
|
223
293
|
isEnabled: () => route.secretsEnvFastRouteEnabled(request),
|
|
@@ -231,7 +301,7 @@ function dispatchCliEntry() {
|
|
|
231
301
|
});
|
|
232
302
|
});
|
|
233
303
|
}
|
|
234
|
-
else if (
|
|
304
|
+
else if (mcpRequest) {
|
|
235
305
|
// Keep the minimal startup route; runMcpServe applies the hq-flags gate.
|
|
236
306
|
void import("./commands/mcp-serve.js").then(({ runMcpServe }) => runMcpServe());
|
|
237
307
|
}
|
|
@@ -367,6 +437,7 @@ void runCliEntry(import.meta.url, dispatchCliEntry);
|
|
|
367
437
|
export const __test__ = {
|
|
368
438
|
isVersionRequest,
|
|
369
439
|
isFastCoreRequest,
|
|
440
|
+
installFollowupWouldDoWork,
|
|
370
441
|
dispatchWithPackageUseLease,
|
|
371
442
|
waitForUpdateThenReexec,
|
|
372
443
|
runCliEntry,
|
package/dist/lazy-commands.js
CHANGED
|
@@ -3,6 +3,7 @@ import { existsSync } from "node:fs";
|
|
|
3
3
|
import { homedir } from "node:os";
|
|
4
4
|
import { dirname, join, resolve } from "node:path";
|
|
5
5
|
import { COMMAND_CATALOG } from "./command-catalog.generated.js";
|
|
6
|
+
import { installUnknownCommandSuggestions } from "./unknown-command.js";
|
|
6
7
|
const commandCatalog = COMMAND_CATALOG;
|
|
7
8
|
/**
|
|
8
9
|
* One entry for every root, including groups that are assembled by several
|
|
@@ -105,6 +106,7 @@ export function findRuntimeHqRoot(start, parentOf = dirname, isRoot = (directory
|
|
|
105
106
|
export function registerCommandCatalog(program) {
|
|
106
107
|
for (const command of commandCatalog)
|
|
107
108
|
program.addCommand(commandDefinition(command));
|
|
109
|
+
installUnknownCommandSuggestions(program);
|
|
108
110
|
}
|
|
109
111
|
function commandForToken(token) {
|
|
110
112
|
return commandCatalog.find((candidate) => candidate.name === token || candidate.aliases.includes(token));
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Owner STS credentials ride on an agent inbox item as
|
|
3
|
+
* `actingContext.credentials`. The mirror files are readable by whatever
|
|
4
|
+
* model has file tools, so those credentials are removed before a write and
|
|
5
|
+
* again on read. Only `mode`, `ownerPersonUid`, `ownerRole`, and `expiresAt`
|
|
6
|
+
* stay on `actingContext`. Any other key under that object is dropped.
|
|
7
|
+
*
|
|
8
|
+
* A scrub that cannot write (EPERM, ENOSPC, and the same class) is counted
|
|
9
|
+
* and skipped. It does not throw into the poller or a reader. The next poll
|
|
10
|
+
* or read tries again. Failure logs are a count and errno codes, never a
|
|
11
|
+
* path or a file byte.
|
|
12
|
+
*
|
|
13
|
+
* `hq dm` output drops the rest of `actingContext` as well. That stricter
|
|
14
|
+
* stdout rule lives with the command; credential removal has this one
|
|
15
|
+
* implementation.
|
|
16
|
+
*/
|
|
17
|
+
import type { AgentKitPaths } from "./paths.js";
|
|
18
|
+
export interface InboxScrubReport {
|
|
19
|
+
scrubbed: number;
|
|
20
|
+
failures: number;
|
|
21
|
+
/** Errno names only. Never paths, file names, or file contents. */
|
|
22
|
+
codes: string[];
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* One writer at a time for the mirror directory. The journal is rewritten
|
|
26
|
+
* in place when credentials are scrubbed and appended when a message arrives;
|
|
27
|
+
* those two must not overlap or the rewrite can drop the append.
|
|
28
|
+
* Returns undefined when the directory is missing or the lock is not acquired.
|
|
29
|
+
*/
|
|
30
|
+
export declare function withInboxMirrorLock<T>(inboxDir: string, waitMs: number, fn: () => T): T | undefined;
|
|
31
|
+
/**
|
|
32
|
+
* Remove credential and unknown `actingContext` keys. Allowed delegation
|
|
33
|
+
* fields are copied through. Does not mutate `item`.
|
|
34
|
+
*/
|
|
35
|
+
export declare function redactAgentInboxItem<T>(item: T): T;
|
|
36
|
+
/**
|
|
37
|
+
* Rewrite mirror files that still contain delegated credentials, drop torn
|
|
38
|
+
* journal lines that contain them, and delete stale `*.json.tmp.*` files.
|
|
39
|
+
* A clean directory is left untouched, so repeating the call is a no-op.
|
|
40
|
+
* Returns how many records were rewritten or removed. Write and rename
|
|
41
|
+
* errors are counted on the report and are not thrown.
|
|
42
|
+
*/
|
|
43
|
+
export declare function scrubInboxMirror(paths: Pick<AgentKitPaths, "inboxDir">, log?: (report: InboxScrubReport) => void): number;
|
|
44
|
+
//# sourceMappingURL=inbox-redact.d.ts.map
|
|
@@ -0,0 +1,361 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Owner STS credentials ride on an agent inbox item as
|
|
3
|
+
* `actingContext.credentials`. The mirror files are readable by whatever
|
|
4
|
+
* model has file tools, so those credentials are removed before a write and
|
|
5
|
+
* again on read. Only `mode`, `ownerPersonUid`, `ownerRole`, and `expiresAt`
|
|
6
|
+
* stay on `actingContext`. Any other key under that object is dropped.
|
|
7
|
+
*
|
|
8
|
+
* A scrub that cannot write (EPERM, ENOSPC, and the same class) is counted
|
|
9
|
+
* and skipped. It does not throw into the poller or a reader. The next poll
|
|
10
|
+
* or read tries again. Failure logs are a count and errno codes, never a
|
|
11
|
+
* path or a file byte.
|
|
12
|
+
*
|
|
13
|
+
* `hq dm` output drops the rest of `actingContext` as well. That stricter
|
|
14
|
+
* stdout rule lives with the command; credential removal has this one
|
|
15
|
+
* implementation.
|
|
16
|
+
*/
|
|
17
|
+
import * as fs from "node:fs";
|
|
18
|
+
import * as path from "node:path";
|
|
19
|
+
import { writePrivateFile } from "./private-file.js";
|
|
20
|
+
const INBOX_JSONL_NAME = "inbox.jsonl";
|
|
21
|
+
const STATE_FILES = new Set(["seen-ids.json", "done-ids.json"]);
|
|
22
|
+
const LOCK_DIR_NAME = ".inbox-write.lock";
|
|
23
|
+
const LOCK_STALE_MS = 30_000;
|
|
24
|
+
const ACTING_CONTEXT_KEYS = ["mode", "ownerPersonUid", "ownerRole", "expiresAt"];
|
|
25
|
+
const ALLOWED_ACTING_KEYS = new Set(ACTING_CONTEXT_KEYS);
|
|
26
|
+
/** Substrings that mark a torn line or temp. The values themselves are not logged. */
|
|
27
|
+
const CREDENTIAL_MARKERS = ["\"credentials\"", "secretAccessKey", "sessionToken", "accessKeyId"];
|
|
28
|
+
function emptyTally() {
|
|
29
|
+
return { scrubbed: 0, failures: 0, codes: [] };
|
|
30
|
+
}
|
|
31
|
+
function errnoCode(err) {
|
|
32
|
+
const code = err && typeof err === "object" && "code" in err ? err.code : undefined;
|
|
33
|
+
return typeof code === "string" && /^[A-Z][A-Z0-9]{1,15}$/.test(code) ? code : "ERR";
|
|
34
|
+
}
|
|
35
|
+
function noteFailure(tally, err) {
|
|
36
|
+
tally.failures += 1;
|
|
37
|
+
const code = errnoCode(err);
|
|
38
|
+
if (!tally.codes.includes(code))
|
|
39
|
+
tally.codes.push(code);
|
|
40
|
+
}
|
|
41
|
+
function sleepMs(ms) {
|
|
42
|
+
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* One writer at a time for the mirror directory. The journal is rewritten
|
|
46
|
+
* in place when credentials are scrubbed and appended when a message arrives;
|
|
47
|
+
* those two must not overlap or the rewrite can drop the append.
|
|
48
|
+
* Returns undefined when the directory is missing or the lock is not acquired.
|
|
49
|
+
*/
|
|
50
|
+
export function withInboxMirrorLock(inboxDir, waitMs, fn) {
|
|
51
|
+
let dirStat;
|
|
52
|
+
try {
|
|
53
|
+
dirStat = fs.lstatSync(inboxDir);
|
|
54
|
+
}
|
|
55
|
+
catch {
|
|
56
|
+
return undefined;
|
|
57
|
+
}
|
|
58
|
+
if (!dirStat.isDirectory())
|
|
59
|
+
return undefined;
|
|
60
|
+
const lockDir = path.join(inboxDir, LOCK_DIR_NAME);
|
|
61
|
+
const deadline = Date.now() + Math.max(0, waitMs);
|
|
62
|
+
for (;;) {
|
|
63
|
+
try {
|
|
64
|
+
fs.mkdirSync(lockDir);
|
|
65
|
+
}
|
|
66
|
+
catch (err) {
|
|
67
|
+
if (err.code !== "EEXIST")
|
|
68
|
+
return undefined;
|
|
69
|
+
try {
|
|
70
|
+
if (Date.now() - fs.statSync(lockDir).mtimeMs > LOCK_STALE_MS)
|
|
71
|
+
fs.rmdirSync(lockDir);
|
|
72
|
+
}
|
|
73
|
+
catch {
|
|
74
|
+
/* the holder released it, or it is not a directory */
|
|
75
|
+
}
|
|
76
|
+
if (Date.now() >= deadline)
|
|
77
|
+
return undefined;
|
|
78
|
+
sleepMs(20);
|
|
79
|
+
continue;
|
|
80
|
+
}
|
|
81
|
+
try {
|
|
82
|
+
return fn();
|
|
83
|
+
}
|
|
84
|
+
finally {
|
|
85
|
+
try {
|
|
86
|
+
fs.rmdirSync(lockDir);
|
|
87
|
+
}
|
|
88
|
+
catch {
|
|
89
|
+
/* already released */
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
function actingContextRecord(value) {
|
|
95
|
+
if (!value || typeof value !== "object" || Array.isArray(value))
|
|
96
|
+
return null;
|
|
97
|
+
const acting = value.actingContext;
|
|
98
|
+
if (!acting || typeof acting !== "object" || Array.isArray(acting))
|
|
99
|
+
return null;
|
|
100
|
+
return acting;
|
|
101
|
+
}
|
|
102
|
+
function actingNeedsRewrite(acting) {
|
|
103
|
+
return Object.keys(acting).some((key) => !ALLOWED_ACTING_KEYS.has(key));
|
|
104
|
+
}
|
|
105
|
+
function allowlistedActing(acting) {
|
|
106
|
+
const kept = {};
|
|
107
|
+
for (const key of ACTING_CONTEXT_KEYS) {
|
|
108
|
+
if (Object.prototype.hasOwnProperty.call(acting, key))
|
|
109
|
+
kept[key] = acting[key];
|
|
110
|
+
}
|
|
111
|
+
return kept;
|
|
112
|
+
}
|
|
113
|
+
function textHasCredentialMaterial(text) {
|
|
114
|
+
return CREDENTIAL_MARKERS.some((marker) => text.includes(marker));
|
|
115
|
+
}
|
|
116
|
+
function isStaleJsonTemp(name) {
|
|
117
|
+
return (name.includes(".json.tmp.") ||
|
|
118
|
+
name.includes(".jsonl.tmp.") ||
|
|
119
|
+
name.endsWith(".json.tmp") ||
|
|
120
|
+
name.endsWith(".jsonl.tmp"));
|
|
121
|
+
}
|
|
122
|
+
/**
|
|
123
|
+
* Remove credential and unknown `actingContext` keys. Allowed delegation
|
|
124
|
+
* fields are copied through. Does not mutate `item`.
|
|
125
|
+
*/
|
|
126
|
+
export function redactAgentInboxItem(item) {
|
|
127
|
+
const acting = actingContextRecord(item);
|
|
128
|
+
if (!acting || !actingNeedsRewrite(acting))
|
|
129
|
+
return item;
|
|
130
|
+
const record = item;
|
|
131
|
+
const kept = allowlistedActing(acting);
|
|
132
|
+
if (Object.keys(kept).length === 0) {
|
|
133
|
+
const { actingContext: _actingContext, ...without } = record;
|
|
134
|
+
return without;
|
|
135
|
+
}
|
|
136
|
+
return { ...record, actingContext: kept };
|
|
137
|
+
}
|
|
138
|
+
/**
|
|
139
|
+
* One journal record per line. A torn line cannot be parsed, and an
|
|
140
|
+
* interrupted append can still hold a credential on that line. Drop those
|
|
141
|
+
* lines. A torn line with no credential marker stays, so a truncated
|
|
142
|
+
* message that is not a secret is not thrown away.
|
|
143
|
+
*/
|
|
144
|
+
function redactJsonl(text) {
|
|
145
|
+
let changed = 0;
|
|
146
|
+
const kept = [];
|
|
147
|
+
for (const line of text.split("\n")) {
|
|
148
|
+
if (!line.trim()) {
|
|
149
|
+
kept.push(line);
|
|
150
|
+
continue;
|
|
151
|
+
}
|
|
152
|
+
let parsed;
|
|
153
|
+
try {
|
|
154
|
+
parsed = JSON.parse(line);
|
|
155
|
+
}
|
|
156
|
+
catch {
|
|
157
|
+
if (!textHasCredentialMaterial(line)) {
|
|
158
|
+
kept.push(line);
|
|
159
|
+
continue;
|
|
160
|
+
}
|
|
161
|
+
changed += 1;
|
|
162
|
+
continue;
|
|
163
|
+
}
|
|
164
|
+
const acting = actingContextRecord(parsed);
|
|
165
|
+
if (!acting || !actingNeedsRewrite(acting)) {
|
|
166
|
+
kept.push(line);
|
|
167
|
+
continue;
|
|
168
|
+
}
|
|
169
|
+
changed += 1;
|
|
170
|
+
kept.push(JSON.stringify(redactAgentInboxItem(parsed)));
|
|
171
|
+
}
|
|
172
|
+
return { text: kept.join("\n"), changed };
|
|
173
|
+
}
|
|
174
|
+
function scrubJsonFile(file, tally) {
|
|
175
|
+
let text;
|
|
176
|
+
try {
|
|
177
|
+
const st = fs.lstatSync(file);
|
|
178
|
+
if (!st.isFile())
|
|
179
|
+
return;
|
|
180
|
+
text = fs.readFileSync(file, "utf8");
|
|
181
|
+
}
|
|
182
|
+
catch (err) {
|
|
183
|
+
if (err.code === "ENOENT")
|
|
184
|
+
return;
|
|
185
|
+
noteFailure(tally, err);
|
|
186
|
+
return;
|
|
187
|
+
}
|
|
188
|
+
let parsed;
|
|
189
|
+
try {
|
|
190
|
+
parsed = JSON.parse(text);
|
|
191
|
+
}
|
|
192
|
+
catch {
|
|
193
|
+
if (!textHasCredentialMaterial(text))
|
|
194
|
+
return;
|
|
195
|
+
try {
|
|
196
|
+
fs.unlinkSync(file);
|
|
197
|
+
tally.scrubbed += 1;
|
|
198
|
+
}
|
|
199
|
+
catch (err) {
|
|
200
|
+
noteFailure(tally, err);
|
|
201
|
+
}
|
|
202
|
+
return;
|
|
203
|
+
}
|
|
204
|
+
const acting = actingContextRecord(parsed);
|
|
205
|
+
if (!acting || !actingNeedsRewrite(acting))
|
|
206
|
+
return;
|
|
207
|
+
try {
|
|
208
|
+
// The temp writePrivateFile creates is the redacted body, not the original.
|
|
209
|
+
writePrivateFile(file, `${JSON.stringify(redactAgentInboxItem(parsed), null, 2)}\n`, 0o600);
|
|
210
|
+
tally.scrubbed += 1;
|
|
211
|
+
}
|
|
212
|
+
catch (err) {
|
|
213
|
+
noteFailure(tally, err);
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
function redactedTempBody(text) {
|
|
217
|
+
try {
|
|
218
|
+
return `${JSON.stringify(redactAgentInboxItem(JSON.parse(text)), null, 2)}\n`;
|
|
219
|
+
}
|
|
220
|
+
catch {
|
|
221
|
+
return "\n";
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
/** Delete a leftover `*.json.tmp.*` from a crashed atomic write. Rewrite it if it cannot be removed. */
|
|
225
|
+
function discardStaleTemp(file, tally) {
|
|
226
|
+
let text;
|
|
227
|
+
try {
|
|
228
|
+
const st = fs.lstatSync(file);
|
|
229
|
+
if (!st.isFile())
|
|
230
|
+
return;
|
|
231
|
+
text = fs.readFileSync(file, "utf8");
|
|
232
|
+
}
|
|
233
|
+
catch (err) {
|
|
234
|
+
if (err.code === "ENOENT")
|
|
235
|
+
return;
|
|
236
|
+
text = undefined;
|
|
237
|
+
}
|
|
238
|
+
const poisoned = text !== undefined && textHasCredentialMaterial(text);
|
|
239
|
+
try {
|
|
240
|
+
fs.unlinkSync(file);
|
|
241
|
+
if (poisoned)
|
|
242
|
+
tally.scrubbed += 1;
|
|
243
|
+
return;
|
|
244
|
+
}
|
|
245
|
+
catch (err) {
|
|
246
|
+
if (err.code === "ENOENT")
|
|
247
|
+
return;
|
|
248
|
+
if (text === undefined) {
|
|
249
|
+
noteFailure(tally, err);
|
|
250
|
+
return;
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
if (text === undefined)
|
|
254
|
+
return;
|
|
255
|
+
try {
|
|
256
|
+
writePrivateFile(file, redactedTempBody(text), 0o600);
|
|
257
|
+
if (poisoned)
|
|
258
|
+
tally.scrubbed += 1;
|
|
259
|
+
}
|
|
260
|
+
catch (err) {
|
|
261
|
+
noteFailure(tally, err);
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
function scrubJournal(journal, tally) {
|
|
265
|
+
for (let attempt = 0; attempt < 3; attempt++) {
|
|
266
|
+
let before;
|
|
267
|
+
try {
|
|
268
|
+
before = fs.readFileSync(journal, "utf8");
|
|
269
|
+
}
|
|
270
|
+
catch (err) {
|
|
271
|
+
if (err.code === "ENOENT")
|
|
272
|
+
return;
|
|
273
|
+
noteFailure(tally, err);
|
|
274
|
+
return;
|
|
275
|
+
}
|
|
276
|
+
const redacted = redactJsonl(before);
|
|
277
|
+
if (redacted.changed === 0)
|
|
278
|
+
return;
|
|
279
|
+
let latest;
|
|
280
|
+
try {
|
|
281
|
+
latest = fs.readFileSync(journal, "utf8");
|
|
282
|
+
}
|
|
283
|
+
catch (err) {
|
|
284
|
+
noteFailure(tally, err);
|
|
285
|
+
return;
|
|
286
|
+
}
|
|
287
|
+
if (latest !== before)
|
|
288
|
+
continue;
|
|
289
|
+
try {
|
|
290
|
+
writePrivateFile(journal, redacted.text, 0o600);
|
|
291
|
+
}
|
|
292
|
+
catch (err) {
|
|
293
|
+
noteFailure(tally, err);
|
|
294
|
+
return;
|
|
295
|
+
}
|
|
296
|
+
try {
|
|
297
|
+
if (fs.readFileSync(journal, "utf8") === redacted.text) {
|
|
298
|
+
tally.scrubbed += redacted.changed;
|
|
299
|
+
return;
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
catch {
|
|
303
|
+
/* the next pass, or the next read, rewrites whatever landed */
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
noteFailure(tally, Object.assign(new Error("inbox scrub did not land"), { code: "EAGAIN" }));
|
|
307
|
+
}
|
|
308
|
+
function scanInboxDir(inboxDir) {
|
|
309
|
+
const tally = emptyTally();
|
|
310
|
+
let names;
|
|
311
|
+
try {
|
|
312
|
+
names = fs.readdirSync(inboxDir);
|
|
313
|
+
}
|
|
314
|
+
catch (err) {
|
|
315
|
+
noteFailure(tally, err);
|
|
316
|
+
return tally;
|
|
317
|
+
}
|
|
318
|
+
for (const name of names) {
|
|
319
|
+
if (isStaleJsonTemp(name)) {
|
|
320
|
+
discardStaleTemp(path.join(inboxDir, name), tally);
|
|
321
|
+
continue;
|
|
322
|
+
}
|
|
323
|
+
if (!name.endsWith(".json") || STATE_FILES.has(name))
|
|
324
|
+
continue;
|
|
325
|
+
scrubJsonFile(path.join(inboxDir, name), tally);
|
|
326
|
+
}
|
|
327
|
+
scrubJournal(path.join(inboxDir, INBOX_JSONL_NAME), tally);
|
|
328
|
+
return tally;
|
|
329
|
+
}
|
|
330
|
+
function defaultScrubLog(report) {
|
|
331
|
+
if (report.scrubbed > 0) {
|
|
332
|
+
console.error(`inbox: scrubbed ${report.scrubbed} mirrored record(s) containing delegated credentials`);
|
|
333
|
+
}
|
|
334
|
+
if (report.failures > 0) {
|
|
335
|
+
const codes = report.codes.length > 0 ? report.codes.join(",") : "ERR";
|
|
336
|
+
console.error(`inbox: scrub failed for ${report.failures} file(s) (${codes})`);
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
/**
|
|
340
|
+
* Rewrite mirror files that still contain delegated credentials, drop torn
|
|
341
|
+
* journal lines that contain them, and delete stale `*.json.tmp.*` files.
|
|
342
|
+
* A clean directory is left untouched, so repeating the call is a no-op.
|
|
343
|
+
* Returns how many records were rewritten or removed. Write and rename
|
|
344
|
+
* errors are counted on the report and are not thrown.
|
|
345
|
+
*/
|
|
346
|
+
export function scrubInboxMirror(paths, log = defaultScrubLog) {
|
|
347
|
+
let tally = emptyTally();
|
|
348
|
+
try {
|
|
349
|
+
// Missing directory: do not create it. Lock not acquired: leave the files
|
|
350
|
+
// for the next read rather than rewriting the journal without the lock.
|
|
351
|
+
tally = withInboxMirrorLock(paths.inboxDir, 2_000, () => scanInboxDir(paths.inboxDir)) ?? tally;
|
|
352
|
+
}
|
|
353
|
+
catch (err) {
|
|
354
|
+
noteFailure(tally, err);
|
|
355
|
+
}
|
|
356
|
+
if (tally.scrubbed > 0 || tally.failures > 0) {
|
|
357
|
+
log({ scrubbed: tally.scrubbed, failures: tally.failures, codes: tally.codes });
|
|
358
|
+
}
|
|
359
|
+
return tally.scrubbed;
|
|
360
|
+
}
|
|
361
|
+
//# sourceMappingURL=inbox-redact.js.map
|
|
@@ -10,6 +10,7 @@
|
|
|
10
10
|
*/
|
|
11
11
|
import * as fs from "node:fs";
|
|
12
12
|
import * as path from "node:path";
|
|
13
|
+
import { redactAgentInboxItem, scrubInboxMirror } from "./inbox-redact.js";
|
|
13
14
|
import { writePrivateFile } from "./private-file.js";
|
|
14
15
|
const DONE_IDS_NAME = "done-ids.json";
|
|
15
16
|
const MAX_DONE_IDS = 5000;
|
|
@@ -37,6 +38,14 @@ export function markInboxDone(paths, ids) {
|
|
|
37
38
|
}
|
|
38
39
|
/** Every mirrored entry, oldest first, de-duplicated by id (last write wins). */
|
|
39
40
|
export function readMirroredInbox(paths) {
|
|
41
|
+
// Files written before credential redaction are rewritten here. A failed
|
|
42
|
+
// rewrite is retried on the next read. It must not hide mail already on disk.
|
|
43
|
+
try {
|
|
44
|
+
scrubInboxMirror(paths);
|
|
45
|
+
}
|
|
46
|
+
catch {
|
|
47
|
+
/* scrubInboxMirror records write failures and does not throw them */
|
|
48
|
+
}
|
|
40
49
|
let text;
|
|
41
50
|
try {
|
|
42
51
|
text = fs.readFileSync(path.join(paths.inboxDir, INBOX_JSONL), "utf8");
|
|
@@ -49,7 +58,7 @@ export function readMirroredInbox(paths) {
|
|
|
49
58
|
if (!line.trim())
|
|
50
59
|
continue;
|
|
51
60
|
try {
|
|
52
|
-
const entry = JSON.parse(line);
|
|
61
|
+
const entry = redactAgentInboxItem(JSON.parse(line));
|
|
53
62
|
if (typeof entry.id === "string" && entry.id) {
|
|
54
63
|
byId.delete(entry.id);
|
|
55
64
|
byId.set(entry.id, entry);
|
|
@@ -144,7 +153,7 @@ export async function mergeAgentInbox(paths, fetchServerInbox) {
|
|
|
144
153
|
const raw = m;
|
|
145
154
|
const id = typeof raw.id === "string" ? raw.id : typeof raw.messageId === "string" ? raw.messageId : null;
|
|
146
155
|
if (id && !byId.has(id))
|
|
147
|
-
byId.set(id, { ...raw, id });
|
|
156
|
+
byId.set(id, redactAgentInboxItem({ ...raw, id }));
|
|
148
157
|
}
|
|
149
158
|
}
|
|
150
159
|
else {
|
|
@@ -18,6 +18,7 @@
|
|
|
18
18
|
import { CLI_VERSION } from "../../../cli-version.js";
|
|
19
19
|
import { peekIdToken } from "../../../utils/id-token.js";
|
|
20
20
|
import { isRecipientUid } from "../../../lib/notify-recipient.js";
|
|
21
|
+
import { redactAgentInboxItem } from "../inbox-redact.js";
|
|
21
22
|
import { markInboxDone, mergeAgentInbox, readDoneIds, summarizeInboxEntry } from "../inbox-state.js";
|
|
22
23
|
import { AttachmentCompanyUnresolvedError, fetchChatAttachment } from "./attachments.js";
|
|
23
24
|
import { errorResult, McpToolInputError, textResult } from "./jsonrpc.js";
|
|
@@ -287,7 +288,7 @@ export function buildAgentMcpTools(clients) {
|
|
|
287
288
|
const rows = [...byId.values()]
|
|
288
289
|
.filter((e) => includeDone || !done.has(e.id))
|
|
289
290
|
.slice(0, limit)
|
|
290
|
-
.map((e) => summarizeInboxEntry(e, done.has(e.id)));
|
|
291
|
+
.map((e) => summarizeInboxEntry(redactAgentInboxItem(e), done.has(e.id)));
|
|
291
292
|
const hasFiles = rows.some((r) => r.attachments?.length);
|
|
292
293
|
return textResult(clampOutput(JSON.stringify({
|
|
293
294
|
count: rows.length,
|