@stigmer/runner 3.1.7 → 3.1.8
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/README.md +1 -1
- package/dist/.build-fingerprint +1 -1
- package/dist/activities/discover-mcp-server.js +1 -0
- package/dist/activities/discover-mcp-server.js.map +1 -1
- package/dist/activities/execute-cursor/__test-utils__/cursor-hook-harness.d.ts +5 -2
- package/dist/activities/execute-cursor/__test-utils__/cursor-hook-harness.js.map +1 -1
- package/dist/activities/execute-cursor/approval-state.d.ts +58 -10
- package/dist/activities/execute-cursor/approval-state.js +37 -8
- package/dist/activities/execute-cursor/approval-state.js.map +1 -1
- package/dist/activities/execute-cursor/env-resolver.js +7 -1
- package/dist/activities/execute-cursor/env-resolver.js.map +1 -1
- package/dist/activities/execute-cursor/hook-script.d.ts +16 -3
- package/dist/activities/execute-cursor/hook-script.js +66 -21
- package/dist/activities/execute-cursor/hook-script.js.map +1 -1
- package/dist/activities/execute-cursor/index.js +59 -0
- package/dist/activities/execute-cursor/index.js.map +1 -1
- package/dist/activities/execute-cursor/message-translator.d.ts +52 -0
- package/dist/activities/execute-cursor/message-translator.js +107 -1
- package/dist/activities/execute-cursor/message-translator.js.map +1 -1
- package/dist/activities/execute-cursor/turn-boundary.d.ts +25 -3
- package/dist/activities/execute-cursor/turn-boundary.js +46 -8
- package/dist/activities/execute-cursor/turn-boundary.js.map +1 -1
- package/dist/activities/execute-cursor/turn-stream.js +6 -2
- package/dist/activities/execute-cursor/turn-stream.js.map +1 -1
- package/dist/activities/execute-cursor/workspace-setup.d.ts +16 -2
- package/dist/activities/execute-cursor/workspace-setup.js +22 -12
- package/dist/activities/execute-cursor/workspace-setup.js.map +1 -1
- package/dist/activities/execute-deep-agent/environment.js +7 -1
- package/dist/activities/execute-deep-agent/environment.js.map +1 -1
- package/dist/activities/execute-deep-agent/hitl.d.ts +23 -14
- package/dist/activities/execute-deep-agent/hitl.js +42 -37
- package/dist/activities/execute-deep-agent/hitl.js.map +1 -1
- package/dist/activities/execute-deep-agent/index.js +35 -26
- package/dist/activities/execute-deep-agent/index.js.map +1 -1
- package/dist/activities/execute-deep-agent/setup.d.ts +7 -0
- package/dist/activities/execute-deep-agent/setup.js +9 -2
- package/dist/activities/execute-deep-agent/setup.js.map +1 -1
- package/dist/activities/hydrate-workflow-execution.js +8 -1
- package/dist/activities/hydrate-workflow-execution.js.map +1 -1
- package/dist/client/stigmer-client.d.ts +69 -1
- package/dist/client/stigmer-client.js +108 -4
- package/dist/client/stigmer-client.js.map +1 -1
- package/dist/client/token-claims.d.ts +22 -0
- package/dist/client/token-claims.js +40 -0
- package/dist/client/token-claims.js.map +1 -0
- package/dist/config.d.ts +11 -1
- package/dist/config.js +5 -1
- package/dist/config.js.map +1 -1
- package/dist/middleware/approval-gate.js +9 -2
- package/dist/middleware/approval-gate.js.map +1 -1
- package/dist/runner-manager.d.ts +4 -2
- package/dist/runner-manager.js +20 -7
- package/dist/runner-manager.js.map +1 -1
- package/dist/runner.d.ts +2 -2
- package/dist/runner.js +1 -1
- package/dist/shared/checkpointer/factory.d.ts +11 -7
- package/dist/shared/checkpointer/factory.js +21 -8
- package/dist/shared/checkpointer/factory.js.map +1 -1
- package/dist/shared/checkpointer/sqlite-saver.d.ts +54 -0
- package/dist/shared/checkpointer/sqlite-saver.js +272 -0
- package/dist/shared/checkpointer/sqlite-saver.js.map +1 -0
- package/dist/shared/checkpointer/types.d.ts +8 -4
- package/dist/shared/tool-row.js +14 -1
- package/dist/shared/tool-row.js.map +1 -1
- package/dist/shared/workspace/platform-dir.d.ts +18 -0
- package/dist/shared/workspace/platform-dir.js +23 -0
- package/dist/shared/workspace/platform-dir.js.map +1 -1
- package/package.json +3 -3
- package/src/__test-utils__/mock-client.ts +3 -0
- package/src/activities/__tests__/hydrate-workflow-execution.test.ts +2 -0
- package/src/activities/discover-mcp-server.ts +1 -0
- package/src/activities/execute-cursor/__test-utils__/cursor-hook-harness.ts +5 -3
- package/src/activities/execute-cursor/__tests__/hitl-ledger.test.ts +272 -14
- package/src/activities/execute-cursor/__tests__/hook-script.test.ts +72 -16
- package/src/activities/execute-cursor/__tests__/message-translator.test.ts +65 -0
- package/src/activities/execute-cursor/__tests__/turn-boundary.test.ts +128 -1
- package/src/activities/execute-cursor/__tests__/workspace-setup.test.ts +95 -0
- package/src/activities/execute-cursor/approval-state.ts +75 -11
- package/src/activities/execute-cursor/env-resolver.ts +8 -1
- package/src/activities/execute-cursor/hook-script.ts +66 -21
- package/src/activities/execute-cursor/index.ts +61 -0
- package/src/activities/execute-cursor/message-translator.ts +124 -1
- package/src/activities/execute-cursor/turn-boundary.ts +79 -6
- package/src/activities/execute-cursor/turn-stream.ts +6 -2
- package/src/activities/execute-cursor/workspace-setup.ts +42 -14
- package/src/activities/execute-deep-agent/__tests__/environment.test.ts +5 -0
- package/src/activities/execute-deep-agent/__tests__/hitl-reject.test.ts +314 -0
- package/src/activities/execute-deep-agent/__tests__/hitl.test.ts +64 -46
- package/src/activities/execute-deep-agent/environment.ts +8 -1
- package/src/activities/execute-deep-agent/hitl.ts +43 -45
- package/src/activities/execute-deep-agent/index.ts +36 -27
- package/src/activities/execute-deep-agent/setup.ts +15 -2
- package/src/activities/hydrate-workflow-execution.ts +9 -1
- package/src/client/__tests__/stigmer-client.test.ts +239 -2
- package/src/client/__tests__/token-claims.test.ts +45 -0
- package/src/client/stigmer-client.ts +152 -3
- package/src/client/token-claims.ts +42 -0
- package/src/config.ts +16 -3
- package/src/middleware/__tests__/approval-gate.test.ts +8 -1
- package/src/middleware/approval-gate.ts +9 -2
- package/src/runner-manager.ts +22 -8
- package/src/runner.ts +3 -3
- package/src/shared/__tests__/tool-row.test.ts +35 -0
- package/src/shared/checkpointer/__tests__/factory.test.ts +27 -1
- package/src/shared/checkpointer/__tests__/sqlite-saver.test.ts +212 -0
- package/src/shared/checkpointer/factory.ts +25 -8
- package/src/shared/checkpointer/sqlite-saver.ts +384 -0
- package/src/shared/checkpointer/types.ts +8 -4
- package/src/shared/tool-row.ts +14 -1
- package/src/shared/workspace/platform-dir.ts +25 -0
|
@@ -66,6 +66,29 @@ export async function ensureHitlGateDir(workspaceRoot) {
|
|
|
66
66
|
export function getPlatformDir(sessionId) {
|
|
67
67
|
return join(getSessionDir(sessionId), "platform");
|
|
68
68
|
}
|
|
69
|
+
/**
|
|
70
|
+
* Compute the durable-checkpoint database path for a session — the SQLite file
|
|
71
|
+
* backing the local LangGraph checkpointer (see shared/checkpointer/sqlite-saver.ts).
|
|
72
|
+
*
|
|
73
|
+
* Session-scoped by design: a session has exactly one LangGraph thread
|
|
74
|
+
* (`thread-{sessionId}`), so one DB per session keeps checkpoint lifetime bound
|
|
75
|
+
* to session lifetime (retention becomes "remove the session dir") and mirrors
|
|
76
|
+
* the sibling `platform/` and `hitl/` trees. Pure function — performs no I/O.
|
|
77
|
+
*/
|
|
78
|
+
export function getCheckpointDbPath(sessionId) {
|
|
79
|
+
return join(getSessionDir(sessionId), "checkpoints.db");
|
|
80
|
+
}
|
|
81
|
+
/**
|
|
82
|
+
* Ensure the session directory exists and return the checkpoint DB path.
|
|
83
|
+
*
|
|
84
|
+
* Creates the session directory tree if needed (the SQLite driver requires the
|
|
85
|
+
* parent directory to exist before opening the file). Idempotent — safe across
|
|
86
|
+
* executions, HITL reinvocations, and activity retries.
|
|
87
|
+
*/
|
|
88
|
+
export async function ensureCheckpointDbPath(sessionId) {
|
|
89
|
+
await mkdir(getSessionDir(sessionId), { recursive: true });
|
|
90
|
+
return getCheckpointDbPath(sessionId);
|
|
91
|
+
}
|
|
69
92
|
/**
|
|
70
93
|
* Ensure the platform directory exists and return its path.
|
|
71
94
|
*
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"platform-dir.js","sourceRoot":"","sources":["../../../src/shared/workspace/platform-dir.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;GAeG;AAEH,OAAO,EAAE,IAAI,EAAE,MAAM,WAAW,CAAC;AACjC,OAAO,EAAE,KAAK,EAAE,MAAM,kBAAkB,CAAC;AACzC,OAAO,EAAE,OAAO,EAAE,MAAM,SAAS,CAAC;AAClC,OAAO,EAAE,UAAU,EAAE,MAAM,aAAa,CAAC;AAEzC,qFAAqF;AACrF,SAAS,cAAc;IACrB,OAAO,OAAO,CAAC,GAAG,CAAC,IAAI,IAAI,OAAO,CAAC,GAAG,CAAC,WAAW,IAAI,OAAO,EAAE,CAAC;AAClE,CAAC;AAED,+EAA+E;AAC/E,SAAS,aAAa,CAAC,SAAiB;IACtC,OAAO,IAAI,CAAC,cAAc,EAAE,EAAE,UAAU,EAAE,UAAU,EAAE,SAAS,CAAC,CAAC;AACnE,CAAC;AAED;;;;;;;;;;;;;;;;;;GAkBG;AACH,MAAM,UAAU,cAAc,CAAC,aAAqB;IAClD,MAAM,GAAG,GAAG,UAAU,CAAC,QAAQ,CAAC,CAAC,MAAM,CAAC,aAAa,CAAC,CAAC,MAAM,CAAC,KAAK,CAAC,CAAC,KAAK,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC;IAClF,OAAO,IAAI,CAAC,cAAc,EAAE,EAAE,UAAU,EAAE,WAAW,EAAE,GAAG,CAAC,CAAC;AAC9D,CAAC;AAED;;;GAGG;AACH,MAAM,CAAC,KAAK,UAAU,iBAAiB,CAAC,aAAqB;IAC3D,MAAM,GAAG,GAAG,cAAc,CAAC,aAAa,CAAC,CAAC;IAC1C,MAAM,KAAK,CAAC,GAAG,EAAE,EAAE,SAAS,EAAE,IAAI,EAAE,CAAC,CAAC;IACtC,OAAO,GAAG,CAAC;AACb,CAAC;AAED;;;;GAIG;AACH,MAAM,UAAU,cAAc,CAAC,SAAiB;IAC9C,OAAO,IAAI,CAAC,aAAa,CAAC,SAAS,CAAC,EAAE,UAAU,CAAC,CAAC;AACpD,CAAC;AAED;;;;;GAKG;AACH,MAAM,CAAC,KAAK,UAAU,iBAAiB,CAAC,SAAiB;IACvD,MAAM,GAAG,GAAG,cAAc,CAAC,SAAS,CAAC,CAAC;IACtC,MAAM,KAAK,CAAC,GAAG,EAAE,EAAE,SAAS,EAAE,IAAI,EAAE,CAAC,CAAC;IACtC,OAAO,GAAG,CAAC;AACb,CAAC;AAED;;;;;;;GAOG;AACH,MAAM,UAAU,UAAU,CAAC,SAAiB;IAC1C,OAAO,IAAI,CAAC,aAAa,CAAC,SAAS,CAAC,EAAE,MAAM,CAAC,CAAC;AAChD,CAAC;AAED;;;;;;GAMG;AACH,MAAM,CAAC,KAAK,UAAU,aAAa,CAAC,SAAiB;IACnD,MAAM,GAAG,GAAG,UAAU,CAAC,SAAS,CAAC,CAAC;IAClC,MAAM,KAAK,CAAC,GAAG,EAAE,EAAE,SAAS,EAAE,IAAI,EAAE,CAAC,CAAC;IACtC,OAAO,GAAG,CAAC;AACb,CAAC"}
|
|
1
|
+
{"version":3,"file":"platform-dir.js","sourceRoot":"","sources":["../../../src/shared/workspace/platform-dir.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;GAeG;AAEH,OAAO,EAAE,IAAI,EAAE,MAAM,WAAW,CAAC;AACjC,OAAO,EAAE,KAAK,EAAE,MAAM,kBAAkB,CAAC;AACzC,OAAO,EAAE,OAAO,EAAE,MAAM,SAAS,CAAC;AAClC,OAAO,EAAE,UAAU,EAAE,MAAM,aAAa,CAAC;AAEzC,qFAAqF;AACrF,SAAS,cAAc;IACrB,OAAO,OAAO,CAAC,GAAG,CAAC,IAAI,IAAI,OAAO,CAAC,GAAG,CAAC,WAAW,IAAI,OAAO,EAAE,CAAC;AAClE,CAAC;AAED,+EAA+E;AAC/E,SAAS,aAAa,CAAC,SAAiB;IACtC,OAAO,IAAI,CAAC,cAAc,EAAE,EAAE,UAAU,EAAE,UAAU,EAAE,SAAS,CAAC,CAAC;AACnE,CAAC;AAED;;;;;;;;;;;;;;;;;;GAkBG;AACH,MAAM,UAAU,cAAc,CAAC,aAAqB;IAClD,MAAM,GAAG,GAAG,UAAU,CAAC,QAAQ,CAAC,CAAC,MAAM,CAAC,aAAa,CAAC,CAAC,MAAM,CAAC,KAAK,CAAC,CAAC,KAAK,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC;IAClF,OAAO,IAAI,CAAC,cAAc,EAAE,EAAE,UAAU,EAAE,WAAW,EAAE,GAAG,CAAC,CAAC;AAC9D,CAAC;AAED;;;GAGG;AACH,MAAM,CAAC,KAAK,UAAU,iBAAiB,CAAC,aAAqB;IAC3D,MAAM,GAAG,GAAG,cAAc,CAAC,aAAa,CAAC,CAAC;IAC1C,MAAM,KAAK,CAAC,GAAG,EAAE,EAAE,SAAS,EAAE,IAAI,EAAE,CAAC,CAAC;IACtC,OAAO,GAAG,CAAC;AACb,CAAC;AAED;;;;GAIG;AACH,MAAM,UAAU,cAAc,CAAC,SAAiB;IAC9C,OAAO,IAAI,CAAC,aAAa,CAAC,SAAS,CAAC,EAAE,UAAU,CAAC,CAAC;AACpD,CAAC;AAED;;;;;;;;GAQG;AACH,MAAM,UAAU,mBAAmB,CAAC,SAAiB;IACnD,OAAO,IAAI,CAAC,aAAa,CAAC,SAAS,CAAC,EAAE,gBAAgB,CAAC,CAAC;AAC1D,CAAC;AAED;;;;;;GAMG;AACH,MAAM,CAAC,KAAK,UAAU,sBAAsB,CAAC,SAAiB;IAC5D,MAAM,KAAK,CAAC,aAAa,CAAC,SAAS,CAAC,EAAE,EAAE,SAAS,EAAE,IAAI,EAAE,CAAC,CAAC;IAC3D,OAAO,mBAAmB,CAAC,SAAS,CAAC,CAAC;AACxC,CAAC;AAED;;;;;GAKG;AACH,MAAM,CAAC,KAAK,UAAU,iBAAiB,CAAC,SAAiB;IACvD,MAAM,GAAG,GAAG,cAAc,CAAC,SAAS,CAAC,CAAC;IACtC,MAAM,KAAK,CAAC,GAAG,EAAE,EAAE,SAAS,EAAE,IAAI,EAAE,CAAC,CAAC;IACtC,OAAO,GAAG,CAAC;AACb,CAAC;AAED;;;;;;;GAOG;AACH,MAAM,UAAU,UAAU,CAAC,SAAiB;IAC1C,OAAO,IAAI,CAAC,aAAa,CAAC,SAAS,CAAC,EAAE,MAAM,CAAC,CAAC;AAChD,CAAC;AAED;;;;;;GAMG;AACH,MAAM,CAAC,KAAK,UAAU,aAAa,CAAC,SAAiB;IACnD,MAAM,GAAG,GAAG,UAAU,CAAC,SAAS,CAAC,CAAC;IAClC,MAAM,KAAK,CAAC,GAAG,EAAE,EAAE,SAAS,EAAE,IAAI,EAAE,CAAC,CAAC;IACtC,OAAO,GAAG,CAAC;AACb,CAAC"}
|
package/package.json
CHANGED
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@stigmer/runner",
|
|
3
|
-
"version": "3.1.
|
|
3
|
+
"version": "3.1.8",
|
|
4
4
|
"description": "Embeddable Temporal worker for the Stigmer AI agent platform — handles agent execution, workflow orchestration, and MCP server management",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"type": "module",
|
|
7
7
|
"engines": {
|
|
8
|
-
"node": ">=
|
|
8
|
+
"node": ">=22.13"
|
|
9
9
|
},
|
|
10
10
|
"bin": {
|
|
11
11
|
"stigmer-runner": "./dist/main.js"
|
|
@@ -85,7 +85,7 @@
|
|
|
85
85
|
"@opentelemetry/sdk-metrics": "^2.0.0",
|
|
86
86
|
"@opentelemetry/sdk-trace-base": "^2.0.0",
|
|
87
87
|
"@opentelemetry/sdk-trace-node": "^2.0.0",
|
|
88
|
-
"@stigmer/protos": "3.1.
|
|
88
|
+
"@stigmer/protos": "3.1.8",
|
|
89
89
|
"@temporalio/activity": "^1.11.0",
|
|
90
90
|
"@temporalio/client": "^1.11.0",
|
|
91
91
|
"@temporalio/common": "^1.11.0",
|
|
@@ -21,6 +21,9 @@ export function mockStigmerClient(overrides: MockMethods = {}): StigmerClient {
|
|
|
21
21
|
}),
|
|
22
22
|
getExecution: vi.fn().mockResolvedValue({}),
|
|
23
23
|
getExecutionContextByExecutionId: vi.fn().mockResolvedValue({}),
|
|
24
|
+
// No scoped token by default — the OSS/local shape (no runner credential).
|
|
25
|
+
acquireScopedRunnerToken: vi.fn().mockResolvedValue(undefined),
|
|
26
|
+
getRunnerScopedToken: vi.fn().mockResolvedValue(undefined),
|
|
24
27
|
getSession: vi.fn().mockResolvedValue({}),
|
|
25
28
|
updateSession: vi.fn().mockResolvedValue({}),
|
|
26
29
|
getAgent: vi.fn().mockResolvedValue({}),
|
|
@@ -57,6 +57,8 @@ function makeMockClient(opts: {
|
|
|
57
57
|
: vi.fn().mockResolvedValue(opts.workflowInstance ?? {
|
|
58
58
|
spec: { workflowId: "wfl_resolved-from-instance" },
|
|
59
59
|
}),
|
|
60
|
+
// No scoped token — the OSS/local shape (no runner credential to exchange).
|
|
61
|
+
acquireScopedRunnerToken: vi.fn().mockResolvedValue(undefined),
|
|
60
62
|
getExecutionContextByExecutionId: opts.executionContextError
|
|
61
63
|
? vi.fn().mockRejectedValue(opts.executionContextError)
|
|
62
64
|
: vi.fn().mockResolvedValue(opts.executionContext ?? {
|
|
@@ -447,6 +447,7 @@ export function createDiscoverMcpServerActivities(config: Config) {
|
|
|
447
447
|
endpoint: config.stigmerBackendEndpoint,
|
|
448
448
|
token: config.stigmerToken,
|
|
449
449
|
tokenRef: config.stigmerTokenRef,
|
|
450
|
+
runnerTokenRef: config.stigmerRunnerTokenRef,
|
|
450
451
|
});
|
|
451
452
|
|
|
452
453
|
return {
|
|
@@ -45,10 +45,12 @@ export interface CursorHookHarness {
|
|
|
45
45
|
/** Run the hook against a single hook-input payload and report its decision. */
|
|
46
46
|
decide(input: object): { permission: string; raw: string };
|
|
47
47
|
/**
|
|
48
|
-
* The denial ledger entries the hook has appended this turn. `
|
|
49
|
-
*
|
|
48
|
+
* The denial ledger entries the hook has appended this turn. `kind` is the
|
|
49
|
+
* attribution taxonomy (approval/secret/capture-error/fail-closed); `input`
|
|
50
|
+
* is the base64(JSON(tool_input)) the hook captures on APPROVAL-kind entries
|
|
51
|
+
* only (decode + JSON.parse to inspect).
|
|
50
52
|
*/
|
|
51
|
-
ledger(): Array<{ toolName: string; token: string; input?: string }>;
|
|
53
|
+
ledger(): Array<{ toolName: string; token: string; kind?: string; input?: string }>;
|
|
52
54
|
/** Truncate the denial ledger (a fresh turn). */
|
|
53
55
|
resetLedger(): void;
|
|
54
56
|
/**
|
|
@@ -52,8 +52,17 @@ import {
|
|
|
52
52
|
buildApprovalGrants,
|
|
53
53
|
grantToken,
|
|
54
54
|
watchDenialLedger,
|
|
55
|
+
approvalDenials,
|
|
56
|
+
denialKindOf,
|
|
55
57
|
} from "../approval-state.js";
|
|
56
|
-
import {
|
|
58
|
+
import {
|
|
59
|
+
reconcileDeniedToolCalls,
|
|
60
|
+
clearProvisionalPostDenialNarration,
|
|
61
|
+
collapseRedundantToolCallTwins,
|
|
62
|
+
toolCallIdentityToken,
|
|
63
|
+
detectUnattributedHookBlocks,
|
|
64
|
+
HOOK_BLOCK_ERROR_MARKERS,
|
|
65
|
+
} from "../message-translator.js";
|
|
57
66
|
import { mockWorkspaceBackend } from "../../../__test-utils__/mock-workspace.js";
|
|
58
67
|
import type { WorkspaceBackend } from "../../../shared/workspace/types.js";
|
|
59
68
|
import { generateHookScript } from "../hook-script.js";
|
|
@@ -162,6 +171,54 @@ describe("denial ledger reset/read", () => {
|
|
|
162
171
|
expect(entries[1].input).toBeUndefined();
|
|
163
172
|
expect(entries[2].input).toBeUndefined();
|
|
164
173
|
});
|
|
174
|
+
|
|
175
|
+
it("parses the kind tag, defaulting an absent/garbled kind to approval", async () => {
|
|
176
|
+
const ws = makeWorkspace();
|
|
177
|
+
await resetDenialLedger(ws);
|
|
178
|
+
await writeFile(
|
|
179
|
+
denialLedgerPath(ws),
|
|
180
|
+
// 1) pre-kind format (no kind field), 2) explicit approval, 3) secret,
|
|
181
|
+
// 4) garbled kind (non-string), 5) unknown future kind.
|
|
182
|
+
`{"toolName":"Write","token":"${grantToken("write", "a.txt")}"}\n` +
|
|
183
|
+
`{"toolName":"Shell","token":"${grantToken("shell", "rm x")}","kind":"approval"}\n` +
|
|
184
|
+
`{"toolName":"Write","token":"${grantToken("write", ".env")}","kind":"secret"}\n` +
|
|
185
|
+
`{"toolName":"Write","token":"${grantToken("write", "b.txt")}","kind":42}\n` +
|
|
186
|
+
`{"toolName":"Write","token":"${grantToken("write", "c.txt")}","kind":"quarantine"}\n`,
|
|
187
|
+
"utf-8",
|
|
188
|
+
);
|
|
189
|
+
|
|
190
|
+
const entries = await readDenialLedger(ws);
|
|
191
|
+
expect(entries.map((e) => denialKindOf(e))).toEqual([
|
|
192
|
+
"approval", // absent → the pre-kind format
|
|
193
|
+
"approval",
|
|
194
|
+
"secret",
|
|
195
|
+
"approval", // garbled (non-string) degrades to approval
|
|
196
|
+
"quarantine", // an unknown kind is preserved, not coerced
|
|
197
|
+
]);
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
it("approvalDenials selects only the entries allowed to pause the run", async () => {
|
|
201
|
+
const ws = makeWorkspace();
|
|
202
|
+
await resetDenialLedger(ws);
|
|
203
|
+
await writeFile(
|
|
204
|
+
denialLedgerPath(ws),
|
|
205
|
+
`{"toolName":"Write","token":"${grantToken("write", "a.txt")}","kind":"approval"}\n` +
|
|
206
|
+
`{"toolName":"Write","token":"${grantToken("write", ".env")}","kind":"secret"}\n` +
|
|
207
|
+
`{"toolName":"Write","token":"${grantToken("write", "app.log")}","kind":"capture-error"}\n` +
|
|
208
|
+
`{"toolName":"Write","token":"${grantToken("write", "b.txt")}","kind":"fail-closed"}\n` +
|
|
209
|
+
// Pre-kind format counts as approval; an unknown kind must NOT pause.
|
|
210
|
+
`{"toolName":"Shell","token":"${grantToken("shell", "rm x")}"}\n` +
|
|
211
|
+
`{"toolName":"Write","token":"${grantToken("write", "c.txt")}","kind":"quarantine"}\n`,
|
|
212
|
+
"utf-8",
|
|
213
|
+
);
|
|
214
|
+
|
|
215
|
+
const entries = await readDenialLedger(ws);
|
|
216
|
+
expect(entries).toHaveLength(6);
|
|
217
|
+
expect(approvalDenials(entries).map((e) => e.token)).toEqual([
|
|
218
|
+
grantToken("write", "a.txt"),
|
|
219
|
+
grantToken("shell", "rm x"),
|
|
220
|
+
]);
|
|
221
|
+
});
|
|
165
222
|
});
|
|
166
223
|
|
|
167
224
|
describe("reconcileDeniedToolCalls", () => {
|
|
@@ -1415,7 +1472,7 @@ describe("first-denial stop contract", () => {
|
|
|
1415
1472
|
| {
|
|
1416
1473
|
kind: "tool";
|
|
1417
1474
|
tool: ToolCall;
|
|
1418
|
-
denyToken?: { name: string; token: string };
|
|
1475
|
+
denyToken?: { name: string; token: string; kind?: string };
|
|
1419
1476
|
// When the hook's ledger append lands relative to the loop's read for
|
|
1420
1477
|
// THIS event. "before-read" is the common case (the hook adjudicates as
|
|
1421
1478
|
// the tool_call event surfaces); "after-read" reproduces the production
|
|
@@ -1426,9 +1483,11 @@ describe("first-denial stop contract", () => {
|
|
|
1426
1483
|
|
|
1427
1484
|
// Faithfully mirrors the index.ts loop rule: process each event, then read
|
|
1428
1485
|
// the denial ledger when the fs watcher flagged it dirty OR the event is a
|
|
1429
|
-
// tool_call (the backstop) — the instant
|
|
1430
|
-
// run and stop consuming the stream
|
|
1431
|
-
//
|
|
1486
|
+
// tool_call (the backstop) — the instant an APPROVAL-kind denial appears,
|
|
1487
|
+
// cancel the run and stop consuming the stream (non-approval kinds — a secret
|
|
1488
|
+
// hard-block, a capture error — are attributable but never pause). The
|
|
1489
|
+
// watcher is simulated by flipping the dirty flag as the append happens (the
|
|
1490
|
+
// real fs.watch notification).
|
|
1432
1491
|
async function runTurnWithFirstDenialStop(
|
|
1433
1492
|
hitlDir: string,
|
|
1434
1493
|
events: SimEvent[],
|
|
@@ -1438,12 +1497,13 @@ describe("first-denial stop contract", () => {
|
|
|
1438
1497
|
let cancelled = false;
|
|
1439
1498
|
let consumed = 0;
|
|
1440
1499
|
let ledgerDirty = false;
|
|
1441
|
-
let pendingAppend: { name: string; token: string } | undefined;
|
|
1500
|
+
let pendingAppend: { name: string; token: string; kind?: string } | undefined;
|
|
1442
1501
|
|
|
1443
|
-
const appendDenial = async (deny: { name: string; token: string }) => {
|
|
1502
|
+
const appendDenial = async (deny: { name: string; token: string; kind?: string }) => {
|
|
1503
|
+
const kindField = deny.kind ? `,"kind":"${deny.kind}"` : "";
|
|
1444
1504
|
await writeFile(
|
|
1445
1505
|
denialLedgerPath(hitlDir),
|
|
1446
|
-
`{"toolName":"${deny.name}","token":"${deny.token}"}\n`,
|
|
1506
|
+
`{"toolName":"${deny.name}","token":"${deny.token}"${kindField}}\n`,
|
|
1447
1507
|
{ flag: "a" },
|
|
1448
1508
|
);
|
|
1449
1509
|
ledgerDirty = true;
|
|
@@ -1470,7 +1530,7 @@ describe("first-denial stop contract", () => {
|
|
|
1470
1530
|
}
|
|
1471
1531
|
if (ledgerDirty || ev.kind === "tool") {
|
|
1472
1532
|
ledgerDirty = false;
|
|
1473
|
-
const denials = await readDenialLedger(hitlDir);
|
|
1533
|
+
const denials = approvalDenials(await readDenialLedger(hitlDir));
|
|
1474
1534
|
if (denials.length > 0) {
|
|
1475
1535
|
cancelled = true;
|
|
1476
1536
|
break;
|
|
@@ -1527,6 +1587,30 @@ describe("first-denial stop contract", () => {
|
|
|
1527
1587
|
expect(consumed).toBe(3); // the whole turn is consumed
|
|
1528
1588
|
});
|
|
1529
1589
|
|
|
1590
|
+
it("does NOT stop on a non-approval denial (secret hard-block: the agent continues)", async () => {
|
|
1591
|
+
// The kinded ledger now records a secret hard-block for attribution (issue
|
|
1592
|
+
// #205), and the append fires the fs watcher — but the model was told to
|
|
1593
|
+
// move on, so the run must NOT be cancelled and no approval may surface.
|
|
1594
|
+
const ws = makeWorkspace();
|
|
1595
|
+
const secretWrite = toolCall({
|
|
1596
|
+
id: "c1", name: "edit", status: ToolCallStatus.TOOL_CALL_FAILED,
|
|
1597
|
+
error: "blocked by a hook", args: { path: ".env" },
|
|
1598
|
+
});
|
|
1599
|
+
const read = toolCall({ id: "c2", name: "read", status: ToolCallStatus.TOOL_CALL_COMPLETED, args: { path: "a.txt" } });
|
|
1600
|
+
|
|
1601
|
+
const { cancelled, consumed } = await runTurnWithFirstDenialStop(ws, [
|
|
1602
|
+
{ kind: "tool", tool: secretWrite, denyToken: { name: "Write", token: grantToken("write", ".env"), kind: "secret" } },
|
|
1603
|
+
{ kind: "text", content: "That file is protected — moving on." },
|
|
1604
|
+
{ kind: "tool", tool: read },
|
|
1605
|
+
]);
|
|
1606
|
+
|
|
1607
|
+
expect(cancelled).toBe(false);
|
|
1608
|
+
expect(consumed).toBe(3); // the whole turn is consumed
|
|
1609
|
+
// And the boundary reconcile manufactures no gate from it either.
|
|
1610
|
+
const denied = await reconcileDeniedToolCalls([], await readDenialLedger(ws));
|
|
1611
|
+
expect(denied).toEqual([]);
|
|
1612
|
+
});
|
|
1613
|
+
|
|
1530
1614
|
it("stops on the NEXT event of any type when the denial lands after the tool_call read (the watcher rule)", async () => {
|
|
1531
1615
|
// The production race (aex_01kwj07f7g23c3wp9sn8496z5g): the hook's ledger
|
|
1532
1616
|
// append landed just after the gated tool's own tool_call-event read, so the
|
|
@@ -1594,8 +1678,173 @@ describe("watchDenialLedger", () => {
|
|
|
1594
1678
|
});
|
|
1595
1679
|
});
|
|
1596
1680
|
|
|
1681
|
+
// Issue #205: a FOREIGN `.cursor/hooks.json` hook (preserved by the merge) can
|
|
1682
|
+
// deny the runner's tools without writing our denial ledger; the SDK has no
|
|
1683
|
+
// structured "denied by hook" signal, so the only stream trace is Cursor's
|
|
1684
|
+
// generic hook-block error text on the FAILED call. The detector matches this
|
|
1685
|
+
// turn's hook-blocked FAILED rows against the FULL ledger (all kinds) and
|
|
1686
|
+
// reports the leftovers — which the activity surfaces as EXECUTION_FAILED
|
|
1687
|
+
// instead of the silent completion the issue describes. These pin the whole
|
|
1688
|
+
// attribution matrix.
|
|
1689
|
+
describe("detectUnattributedHookBlocks (issue #205)", () => {
|
|
1690
|
+
// Cursor's generic replacement text for a hook deny (Phase 0 ground truth).
|
|
1691
|
+
const HOOK_BLOCK = "Command blocked by a hook. Check the hooks configuration.";
|
|
1692
|
+
|
|
1693
|
+
const failedShell = (id: string, command: string, error: string = HOOK_BLOCK) =>
|
|
1694
|
+
toolCall({ id, name: "shell", status: ToolCallStatus.TOOL_CALL_FAILED, error, args: { command } });
|
|
1695
|
+
const failedEdit = (id: string, path: string, error: string = HOOK_BLOCK) =>
|
|
1696
|
+
toolCall({ id, name: "edit", status: ToolCallStatus.TOOL_CALL_FAILED, error, args: { path } });
|
|
1697
|
+
|
|
1698
|
+
it("detects a hook-blocked call when the ledger is EMPTY (the reproduce-first #205 shape)", () => {
|
|
1699
|
+
const edit = failedEdit("f1", "notes.md");
|
|
1700
|
+
const blocks = detectUnattributedHookBlocks([aiMessageWith([edit])], 0, []);
|
|
1701
|
+
expect(blocks).toEqual([{ toolCallId: "f1", toolName: "edit", error: HOOK_BLOCK }]);
|
|
1702
|
+
});
|
|
1703
|
+
|
|
1704
|
+
it("matches the marker case-insensitively (single-sourced marker family)", () => {
|
|
1705
|
+
expect(HOOK_BLOCK_ERROR_MARKERS).toContain("blocked by a hook");
|
|
1706
|
+
const edit = failedEdit("f1", "notes.md", "Tool was Blocked By A Hook.");
|
|
1707
|
+
expect(detectUnattributedHookBlocks([aiMessageWith([edit])], 0, [])).toHaveLength(1);
|
|
1708
|
+
});
|
|
1709
|
+
|
|
1710
|
+
it("attributes our own denial by exact token — not reported", () => {
|
|
1711
|
+
const shell = failedShell("s1", "rm -rf build");
|
|
1712
|
+
const ledger = [{ toolName: "Shell", token: toolCallIdentityToken(shell), kind: "approval" }];
|
|
1713
|
+
expect(detectUnattributedHookBlocks([aiMessageWith([shell])], 0, ledger)).toEqual([]);
|
|
1714
|
+
});
|
|
1715
|
+
|
|
1716
|
+
it("attributes a secret hard-block via its kind:'secret' entry — not reported", () => {
|
|
1717
|
+
// The agent continues past a secret block; its FAILED row must never be
|
|
1718
|
+
// misdiagnosed as a foreign hook (the false positive the kinded ledger
|
|
1719
|
+
// exists to prevent).
|
|
1720
|
+
const secretWrite = failedEdit("e1", ".env");
|
|
1721
|
+
const ledger = [{ toolName: "Write", token: toolCallIdentityToken(secretWrite), kind: "secret" }];
|
|
1722
|
+
expect(detectUnattributedHookBlocks([aiMessageWith([secretWrite])], 0, ledger)).toEqual([]);
|
|
1723
|
+
});
|
|
1724
|
+
|
|
1725
|
+
it("attributes a capture-error deny via its kind:'capture-error' entry — not reported", () => {
|
|
1726
|
+
const write = failedEdit("e1", "app.log");
|
|
1727
|
+
const ledger = [{ toolName: "Write", token: toolCallIdentityToken(write), kind: "capture-error" }];
|
|
1728
|
+
expect(detectUnattributedHookBlocks([aiMessageWith([write])], 0, ledger)).toEqual([]);
|
|
1729
|
+
});
|
|
1730
|
+
|
|
1731
|
+
it("attributes across abs-vs-rel path drift via the workspace-normalized salient", () => {
|
|
1732
|
+
// The hook records the RAW absolute path Cursor handed it; the stream row
|
|
1733
|
+
// carries the relative one. Exact tokens differ; the normalized (category,
|
|
1734
|
+
// path) must still attribute — the same drift reconcile's second pass heals.
|
|
1735
|
+
const edit = failedEdit("e1", "notes.md");
|
|
1736
|
+
const ledger = [{ toolName: "Write", token: grantToken("write", `${ROOT}/notes.md`), kind: "secret" }];
|
|
1737
|
+
// Without a workspace root the raw tokens cannot be reconciled…
|
|
1738
|
+
expect(detectUnattributedHookBlocks([aiMessageWith([edit])], 0, ledger)).toHaveLength(1);
|
|
1739
|
+
// …with it, the normalized pass attributes the block to our own entry.
|
|
1740
|
+
expect(detectUnattributedHookBlocks([aiMessageWith([edit])], 0, ledger, ROOT)).toEqual([]);
|
|
1741
|
+
});
|
|
1742
|
+
|
|
1743
|
+
it("treats a fail-closed turn as attributed — the broken gate denied everything it saw", () => {
|
|
1744
|
+
// With the state file missing, EVERY gated tool was denied by OUR OWN
|
|
1745
|
+
// (broken) hook; per-call correlation is meaningless and none of it is a
|
|
1746
|
+
// foreign hook. The boundary logs the broken-gate condition separately.
|
|
1747
|
+
const shell = failedShell("s1", "make build");
|
|
1748
|
+
const edit = failedEdit("e1", "other.txt");
|
|
1749
|
+
const ledger = [{ toolName: "Shell", token: grantToken("shell", "make build"), kind: "fail-closed" }];
|
|
1750
|
+
expect(
|
|
1751
|
+
detectUnattributedHookBlocks([aiMessageWith([shell, edit])], 0, ledger),
|
|
1752
|
+
).toEqual([]);
|
|
1753
|
+
});
|
|
1754
|
+
|
|
1755
|
+
it("scopes to THIS turn only — seeded prior-turn rows never re-trigger", () => {
|
|
1756
|
+
const priorTurnRow = failedShell("old-1", "yarn deploy");
|
|
1757
|
+
const thisTurnRow = failedShell("new-1", "yarn build");
|
|
1758
|
+
const messages = [aiMessageWith([priorTurnRow]), aiMessageWith([thisTurnRow])];
|
|
1759
|
+
const blocks = detectUnattributedHookBlocks(messages, 1, []);
|
|
1760
|
+
expect(blocks.map((b) => b.toolCallId)).toEqual(["new-1"]);
|
|
1761
|
+
});
|
|
1762
|
+
|
|
1763
|
+
it("mixed turn: reports the foreign block even when our own anchor denial is present", () => {
|
|
1764
|
+
// The caller pauses on the anchor (a pausing turn is never silent) and only
|
|
1765
|
+
// logs this — but the detector itself must still see the foreign block.
|
|
1766
|
+
const ourEdit = failedEdit("ours", "gated.txt");
|
|
1767
|
+
const foreignShell = failedShell("theirs", "terraform apply");
|
|
1768
|
+
const ledger = [{ toolName: "Write", token: toolCallIdentityToken(ourEdit), kind: "approval" }];
|
|
1769
|
+
const blocks = detectUnattributedHookBlocks(
|
|
1770
|
+
[aiMessageWith([ourEdit, foreignShell])], 0, ledger,
|
|
1771
|
+
);
|
|
1772
|
+
expect(blocks.map((b) => b.toolCallId)).toEqual(["theirs"]);
|
|
1773
|
+
});
|
|
1774
|
+
|
|
1775
|
+
it("ignores ordinary tool failures without hook-block text", () => {
|
|
1776
|
+
const failed = failedShell("s1", "make test", "exit code 2: tests failed");
|
|
1777
|
+
const noError = toolCall({ id: "s2", name: "shell", status: ToolCallStatus.TOOL_CALL_FAILED, error: "", args: { command: "ls" } });
|
|
1778
|
+
expect(detectUnattributedHookBlocks([aiMessageWith([failed, noError])], 0, [])).toEqual([]);
|
|
1779
|
+
});
|
|
1780
|
+
|
|
1781
|
+
it("ignores non-FAILED rows even when their output mentions the marker", () => {
|
|
1782
|
+
// Adversarial: the marker appearing in a tool's legitimate RESULT (e.g. the
|
|
1783
|
+
// agent grepped these very words) must never trigger — only a FAILED row's
|
|
1784
|
+
// error text reads as a hook block.
|
|
1785
|
+
const completed = toolCall({
|
|
1786
|
+
id: "c1", name: "shell", status: ToolCallStatus.TOOL_CALL_COMPLETED,
|
|
1787
|
+
result: 'found "blocked by a hook" in docs/hitl.md', args: { command: "grep -r hook docs" },
|
|
1788
|
+
});
|
|
1789
|
+
const waiting = toolCall({
|
|
1790
|
+
id: "w1", name: "edit", status: ToolCallStatus.TOOL_CALL_WAITING_APPROVAL, args: { path: "gated.txt" },
|
|
1791
|
+
});
|
|
1792
|
+
expect(detectUnattributedHookBlocks([aiMessageWith([completed, waiting])], 0, [])).toEqual([]);
|
|
1793
|
+
});
|
|
1794
|
+
|
|
1795
|
+
it("reports every distinct foreign block in the turn", () => {
|
|
1796
|
+
const one = failedEdit("f1", "a.txt");
|
|
1797
|
+
const two = failedShell("f2", "rm -rf build");
|
|
1798
|
+
const blocks = detectUnattributedHookBlocks([aiMessageWith([one]), aiMessageWith([two])], 0, []);
|
|
1799
|
+
expect(blocks.map((b) => b.toolCallId)).toEqual(["f1", "f2"]);
|
|
1800
|
+
});
|
|
1801
|
+
});
|
|
1802
|
+
|
|
1803
|
+
// The reconcile itself must never manufacture a pause from a non-approval
|
|
1804
|
+
// denial, regardless of caller discipline (defense-in-depth for the kind split).
|
|
1805
|
+
describe("reconcileDeniedToolCalls — non-approval kinds never gate", () => {
|
|
1806
|
+
it("returns no gates and leaves the transcript untouched for secret/capture-error/fail-closed entries", async () => {
|
|
1807
|
+
const secretRow = toolCall({
|
|
1808
|
+
id: "e1", name: "edit", status: ToolCallStatus.TOOL_CALL_FAILED,
|
|
1809
|
+
error: "blocked by a hook", args: { path: ".env" },
|
|
1810
|
+
});
|
|
1811
|
+
const messages = [aiMessageWith([secretRow])];
|
|
1812
|
+
const denied = await reconcileDeniedToolCalls(messages, [
|
|
1813
|
+
{ toolName: "Write", token: toolCallIdentityToken(secretRow), kind: "secret" },
|
|
1814
|
+
{ toolName: "Write", token: grantToken("write", "app.log"), kind: "capture-error" },
|
|
1815
|
+
{ toolName: "Shell", token: grantToken("shell", "make"), kind: "fail-closed" },
|
|
1816
|
+
]);
|
|
1817
|
+
expect(denied).toEqual([]);
|
|
1818
|
+
// No overlay, no collapse, no synthesized placeholder appended.
|
|
1819
|
+
expect(secretRow.status).toBe(ToolCallStatus.TOOL_CALL_FAILED);
|
|
1820
|
+
expect(messages).toHaveLength(1);
|
|
1821
|
+
expect(messages[0].toolCalls).toHaveLength(1);
|
|
1822
|
+
});
|
|
1823
|
+
|
|
1824
|
+
it("gates the approval entry while ignoring a non-approval sibling in the same ledger", async () => {
|
|
1825
|
+
const gated = toolCall({
|
|
1826
|
+
id: "g1", name: "edit", status: ToolCallStatus.TOOL_CALL_FAILED,
|
|
1827
|
+
error: "blocked by a hook", args: { path: "gated.txt" },
|
|
1828
|
+
});
|
|
1829
|
+
const secretRow = toolCall({
|
|
1830
|
+
id: "e1", name: "edit", status: ToolCallStatus.TOOL_CALL_FAILED,
|
|
1831
|
+
error: "blocked by a hook", args: { path: ".env" },
|
|
1832
|
+
});
|
|
1833
|
+
const messages = [aiMessageWith([gated, secretRow])];
|
|
1834
|
+
const denied = await reconcileDeniedToolCalls(messages, [
|
|
1835
|
+
// Ledger order puts the secret FIRST: the anchor must still be the first
|
|
1836
|
+
// APPROVAL entry, never a non-approval kind.
|
|
1837
|
+
{ toolName: "Write", token: toolCallIdentityToken(secretRow), kind: "secret" },
|
|
1838
|
+
{ toolName: "Write", token: toolCallIdentityToken(gated), kind: "approval" },
|
|
1839
|
+
]);
|
|
1840
|
+
expect(denied).toHaveLength(1);
|
|
1841
|
+
expect(gated.status).toBe(ToolCallStatus.TOOL_CALL_WAITING_APPROVAL);
|
|
1842
|
+
expect(secretRow.status).toBe(ToolCallStatus.TOOL_CALL_FAILED);
|
|
1843
|
+
});
|
|
1844
|
+
});
|
|
1845
|
+
|
|
1597
1846
|
describe("generateHookScript ledger wiring", () => {
|
|
1598
|
-
it("bakes the active-turn pointer, derives the ledger from it, and records
|
|
1847
|
+
it("bakes the active-turn pointer, derives the ledger from it, and records on EVERY deny arm", () => {
|
|
1599
1848
|
const script = generateHookScript("/gate/active.json");
|
|
1600
1849
|
|
|
1601
1850
|
// Stable script: it bakes the pointer path (not per-turn state/ledger), and
|
|
@@ -1604,9 +1853,18 @@ describe("generateHookScript ledger wiring", () => {
|
|
|
1604
1853
|
expect(script).not.toContain('LEDGER_FILE="/');
|
|
1605
1854
|
expect(script).toContain('>> "$LEDGER_FILE"');
|
|
1606
1855
|
expect(script).toContain("record_denial()");
|
|
1607
|
-
//
|
|
1608
|
-
//
|
|
1609
|
-
|
|
1610
|
-
expect(
|
|
1856
|
+
// Every deny arm records a kind-tagged entry (issue #205 attribution):
|
|
1857
|
+
// the approval gates (built-in + MCP), both secret hard-block arms, the
|
|
1858
|
+
// CAS staging-error fail-closed deny, and the missing-state-file failsafe.
|
|
1859
|
+
expect(script).toContain('record_denial "$PRIMARY_TOKEN" "approval"');
|
|
1860
|
+
expect(script).toContain('record_denial "$MCP_TOKEN" "approval"');
|
|
1861
|
+
expect(script.split('"secret"').length - 1).toBeGreaterThanOrEqual(2);
|
|
1862
|
+
expect(script).toContain('record_denial "$PRIMARY_TOKEN" "capture-error"');
|
|
1863
|
+
expect(script).toContain('record_denial "$PRIMARY_TOKEN" "fail-closed"');
|
|
1864
|
+
expect(script).toContain('record_denial "$MCP_TOKEN" "fail-closed"');
|
|
1865
|
+
// The hoisting guard: under `set -euo pipefail` with our failClosed
|
|
1866
|
+
// hooks.json registration, a call-before-define would emit no decision and
|
|
1867
|
+
// block EVERY tool — so the definition must precede the first call.
|
|
1868
|
+
expect(script.indexOf("record_denial()")).toBeLessThan(script.indexOf('record_denial "'));
|
|
1611
1869
|
});
|
|
1612
1870
|
});
|