gentle-pi 4.2.0 → 4.3.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/README.md +336 -137
- package/assets/agents/gentle-ai-explore.md +1 -0
- package/assets/agents/gentle-ai-verify.md +6 -4
- package/assets/agents/gentle-ai-worker.md +7 -1
- package/assets/orchestrator-delegation-reference.md +163 -0
- package/assets/orchestrator-delegation.md +25 -176
- package/assets/orchestrator-tracking.md +1 -1
- package/assets/orchestrator-verification.md +14 -8
- package/assets/orchestrator-writer.md +5 -1
- package/assets/orchestrator.md +9 -9
- package/bin/gentle-shell.mjs +170 -22
- package/contracts/review-provider-contract-mirror/provider-contract.lock.json +8 -7
- package/contracts/review-provider-contract-mirror/{v1.3.0 → v1.4.0}/bundle/README.md +7 -0
- package/contracts/review-provider-contract-mirror/{v1.3.0 → v1.4.0}/bundle/manifest.json +11 -2
- package/contracts/review-provider-contract-mirror/v1.4.0/bundle/routing/pi.md +92 -0
- package/contracts/review-provider-contract-mirror/{v1.3.0 → v1.4.0}/generated/provider-capabilities.baseline.json +8 -1
- package/contracts/review-provider-contract-mirror/{v1.3.0 → v1.4.0}/generated/provider-roles.baseline.json +1 -1
- package/docs/assets/diagrams/agent-orchestration.svg +54 -15
- package/docs/assets/diagrams/gentleman-workflow.svg +69 -11
- package/docs/assets/diagrams/measured-routing.svg +49 -0
- package/docs/assets/diagrams/native-review.svg +52 -12
- package/docs/assets/diagrams/odd-workflow.svg +128 -66
- package/docs/assets/diagrams/permission-modes.svg +51 -0
- package/docs/assets/features/audio-notifications.png +0 -0
- package/docs/assets/features/command-palette.png +0 -0
- package/docs/bundled-install.md +33 -3
- package/docs/delegated-verification.md +10 -8
- package/docs/gentle-agents-activity.md +11 -5
- package/docs/gentle-shell-uninstall.md +8 -1
- package/docs/gentle-shell.md +5 -4
- package/docs/install-wizard.md +8 -1
- package/docs/permission-modes.md +1 -1
- package/docs/readme-reference.md +107 -19
- package/docs/yolo-mode.md +21 -8
- package/extensions/codegraph-tools.ts +68 -6
- package/extensions/gentle-agents.ts +99 -12
- package/extensions/gentle-ai.ts +284 -52
- package/extensions/gentle-herdr-activity.ts +2 -3
- package/extensions/gentle-shell.ts +27 -12
- package/extensions/gentle-todo.ts +43 -9
- package/extensions/quiet-tools.ts +1 -1
- package/extensions/session-scratch.ts +365 -0
- package/extensions/startup-banner.ts +2 -3
- package/lib/agent-home.ts +8 -3
- package/lib/agents-protocol.ts +3 -0
- package/lib/agents-runner.ts +55 -6
- package/lib/agents-view.ts +3 -1
- package/lib/bounded-writer-admission.ts +51 -0
- package/lib/command-palette-catalog.ts +0 -4
- package/lib/commit-slice-signal.ts +100 -0
- package/lib/context-backstop.ts +84 -0
- package/lib/delivered-test-lint.ts +80 -0
- package/lib/destructive-command-guard.ts +120 -9
- package/lib/gauge-theme.ts +146 -0
- package/lib/gentle-ai-binary.ts +28 -7
- package/lib/gentle-shell-bundled.ts +39 -3
- package/lib/gentle-shell-import-pi.ts +233 -0
- package/lib/gentle-shell-launcher.ts +145 -11
- package/lib/gentle-shell-uninstall.ts +18 -1
- package/lib/herdr-activity.ts +24 -0
- package/lib/native-review-cli.ts +9 -1
- package/lib/orchestrator-discovery.ts +5 -1
- package/lib/orchestrator-presence.ts +9 -0
- package/lib/orchestrator-scope.ts +21 -3
- package/lib/provider-contract-bundle.ts +99 -53
- package/lib/review-candidate-view-owner.ts +29 -19
- package/lib/review-candidate-view.ts +36 -2
- package/lib/review-risk-assessment.ts +43 -38
- package/lib/review-session-standing-permission.ts +3 -1
- package/lib/session-profile-freeze.ts +30 -1
- package/lib/session-scratch.ts +474 -0
- package/lib/shell-bar.ts +25 -25
- package/lib/shell-changes-view.ts +80 -13
- package/lib/shell-gauge.ts +49 -6
- package/lib/shell-todo.ts +23 -4
- package/lib/shell-usage-view.ts +5 -1
- package/lib/shell-usage.ts +7 -7
- package/lib/verifier-admission.ts +29 -0
- package/lib/yolo-temp-scratch.ts +69 -0
- package/package.json +1 -2
- package/runtime/gentle-ai-binary.mjs +28 -7
- package/runtime/gentle-shell-bundled.mjs +38 -2
- package/runtime/gentle-shell-import-pi.mjs +234 -0
- package/runtime/gentle-shell-launcher.mjs +143 -9
- package/runtime/gentle-shell-uninstall.mjs +18 -1
- package/runtime/native-review-cli.mjs +8 -0
- package/runtime/review-risk-assessment.mjs +43 -38
- package/scripts/build-runtime-modules.mjs +1 -0
- package/scripts/bundled-engram-migration.mjs +125 -0
- package/scripts/gentle-ai-installer.mjs +10 -10
- package/scripts/installer-server.mjs +14 -4
- package/scripts/main-channel.mjs +3 -2
- package/scripts/run-test-suite.mjs +5 -0
- package/scripts/verify-package-files.mjs +19 -13
- package/skills/gentle-ai/SKILL.md +3 -3
- package/skills/work-unit-commits/SKILL.md +5 -3
- package/tests/agent-risk-escalation.test.ts +37 -10
- package/tests/agents-runner.test.ts +260 -3
- package/tests/autonomous-guard.test.ts +122 -0
- package/tests/bounded-writer-admission.test.ts +11 -3
- package/tests/card-content-policy.test.ts +19 -9
- package/tests/child-safety.test.ts +39 -0
- package/tests/codegraph-tools.test.ts +123 -2
- package/tests/command-palette.test.ts +14 -4
- package/tests/commit-slice-signal.test.ts +162 -0
- package/tests/context-backstop.test.ts +159 -0
- package/tests/delegate-for-reason-contract.test.ts +91 -24
- package/tests/delivered-test-lint.test.ts +105 -0
- package/tests/destructive-command-guard.test.ts +85 -1
- package/tests/engram-safety.test.ts +1 -1
- package/tests/fixtures/primary-session-prompt/gentleman.golden.md +143 -0
- package/tests/fixtures/provider-contract-bundle/v1.4.0/README.md +29 -0
- package/tests/fixtures/provider-contract-bundle/v1.4.0/manifest.json +83 -0
- package/tests/fixtures/provider-contract-bundle/v1.4.0/orchestration/pi.md +70 -0
- package/tests/fixtures/provider-contract-bundle/v1.4.0/routing/pi.md +92 -0
- package/tests/fixtures/provider-contract-bundle/v1.4.0/schemas/lens.schema.json +16 -0
- package/tests/fixtures/provider-contract-bundle/v1.4.0/schemas/refuter.schema.json +1 -0
- package/tests/fixtures/provider-contract-bundle/v1.4.0/schemas/targeted-validator.schema.json +1 -0
- package/tests/fixtures/provider-contract-bundle/v1.4.0/vectors/lens.json +1 -0
- package/tests/fixtures/provider-contract-bundle/v1.4.0/vectors/refuter.json +1 -0
- package/tests/fixtures/provider-contract-bundle/v1.4.0/vectors/targeted-validator.json +1 -0
- package/tests/gauge-theme.test.ts +120 -0
- package/tests/gentle-agents.test.ts +282 -3
- package/tests/gentle-ai-binary.test.ts +1 -1
- package/tests/gentle-ai-child-guards.test.ts +61 -0
- package/tests/gentle-ai-doctor-herdr.test.ts +94 -0
- package/tests/gentle-ai-installer.test.ts +51 -51
- package/tests/gentle-shell-bin.test.ts +163 -0
- package/tests/gentle-shell-bundled.test.ts +169 -6
- package/tests/gentle-shell-import-pi.test.ts +262 -0
- package/tests/gentle-shell-launcher.test.ts +79 -0
- package/tests/gentle-shell-uninstall.test.ts +30 -0
- package/tests/gentle-shell.test.ts +42 -9
- package/tests/gentle-todo.test.ts +179 -0
- package/tests/headless-delivery-question.test.ts +58 -0
- package/tests/herdr-activity.test.ts +34 -1
- package/tests/installer-server.test.ts +10 -2
- package/tests/installer-windows-bootstrap.test.ts +33 -4
- package/tests/native-review-capability-contract.test.ts +7 -1
- package/tests/native-review-parity.test.ts +22 -1
- package/tests/no-self-pause-contract.test.ts +5 -2
- package/tests/odd-integration.test.ts +5 -1
- package/tests/odd-routing-canonical-ratchet.test.ts +127 -162
- package/tests/odd-routing-contract.test.ts +115 -30
- package/tests/orchestrator-budget.test.ts +19 -8
- package/tests/orchestrator-discovery.test.ts +29 -0
- package/tests/orchestrator-rdd-ownership.test.ts +3 -1
- package/tests/orchestrator-scope.test.ts +66 -0
- package/tests/orchestrator-status-publication-contract.test.ts +12 -8
- package/tests/package-manifest.test.ts +24 -20
- package/tests/pi-update-launcher.test.ts +21 -2
- package/tests/primary-session-prompt.test.ts +101 -0
- package/tests/provider-contract-bundle.test.ts +118 -0
- package/tests/provider-contract-mirror.test.ts +24 -0
- package/tests/quiet-tool-rendering.test.ts +23 -0
- package/tests/rdd-aware-verification-contract.test.ts +29 -11
- package/tests/rdd-status-line.test.ts +79 -0
- package/tests/review-agent-end-preflight.test.ts +6 -0
- package/tests/review-candidate-view.test.ts +49 -0
- package/tests/review-contract-prompt.test.ts +5 -2
- package/tests/review-controller-native-routing.test.ts +14 -1
- package/tests/review-controller.test.ts +1 -1
- package/tests/review-host-relay-routing.test.ts +59 -2
- package/tests/review-loadout-rdd-off-sdk.test.ts +237 -0
- package/tests/review-owner-identity.test.ts +132 -0
- package/tests/review-risk-assessment.test.ts +163 -76
- package/tests/review-session-standing-permission-controller.test.ts +14 -1
- package/tests/review-session-standing-permission.test.ts +27 -1
- package/tests/run-test-suite.test.ts +15 -0
- package/tests/runtime-harness.mjs +16 -6
- package/tests/session-changes-shell.test.ts +1 -1
- package/tests/session-profile-freeze.test.ts +23 -0
- package/tests/session-scratch.test.ts +945 -0
- package/tests/session-subject-routing-contract.test.ts +2 -1
- package/tests/shell-bar.test.ts +7 -0
- package/tests/shell-changes-view.test.ts +137 -1
- package/tests/shell-gauge.test.ts +42 -0
- package/tests/small-task-inline-contract.test.ts +37 -0
- package/tests/standalone-config-home.test.ts +324 -0
- package/tests/startup-banner.test.ts +5 -4
- package/tests/startup-home-repo.test.ts +34 -0
- package/tests/support/orchestrator-modules.ts +1 -0
- package/tests/task-size-routing-contract.test.ts +69 -9
- package/tests/test-scratch-contract.test.ts +102 -0
- package/tests/test-strength-contract.test.ts +36 -1
- package/tests/tool-batching-contract.test.ts +64 -0
- package/tests/verification-proportionality.test.ts +46 -0
- package/tests/verifier-admission.test.ts +137 -0
- package/tests/verify-agent-contract.test.ts +8 -8
- package/tests/windows-hidden-processes.test.ts +5 -0
- package/tests/writer-edit-surface-scope.test.ts +3 -0
- package/tests/writer-feature-document-admission.test.ts +156 -0
- package/tests/yolo-mode.test.ts +18 -0
- package/tests/yolo-temp-scratch.test.ts +71 -0
- package/scripts/mirror-odd-routing.mjs +0 -242
- /package/contracts/review-provider-contract-mirror/{v1.3.0 → v1.4.0}/bundle/orchestration/pi.md +0 -0
- /package/contracts/review-provider-contract-mirror/{v1.3.0 → v1.4.0}/bundle/schemas/lens.schema.json +0 -0
- /package/contracts/review-provider-contract-mirror/{v1.3.0 → v1.4.0}/bundle/schemas/refuter.schema.json +0 -0
- /package/contracts/review-provider-contract-mirror/{v1.3.0 → v1.4.0}/bundle/schemas/targeted-validator.schema.json +0 -0
- /package/contracts/review-provider-contract-mirror/{v1.3.0 → v1.4.0}/bundle/vectors/lens.json +0 -0
- /package/contracts/review-provider-contract-mirror/{v1.3.0 → v1.4.0}/bundle/vectors/refuter.json +0 -0
- /package/contracts/review-provider-contract-mirror/{v1.3.0 → v1.4.0}/bundle/vectors/targeted-validator.json +0 -0
|
@@ -5,15 +5,17 @@ import { createGentleAiExtension } from "../extensions/gentle-ai.ts";
|
|
|
5
5
|
import { NATIVE_REVIEW_MODE_SOURCE, type NativeReviewCli } from "../lib/native-review-cli.ts";
|
|
6
6
|
import {
|
|
7
7
|
decodeAgentRiskEscalation,
|
|
8
|
-
escalatedRisk,
|
|
9
8
|
HIGH_RISK_ITEMS,
|
|
10
9
|
REVIEW_ASSESSMENT_SCHEMA,
|
|
11
10
|
VERIFICATION_TIER,
|
|
11
|
+
verificationRisk,
|
|
12
12
|
} from "../lib/review-risk-assessment.ts";
|
|
13
13
|
|
|
14
14
|
// gentle-shell#1494 S6: the agent may raise a candidate's risk to high by
|
|
15
15
|
// citing one item of the shared high-risk list, never lower it. The raise
|
|
16
|
-
// travels inside the existing assess call; no new operation.
|
|
16
|
+
// travels inside the existing assess call; no new operation. Verification by
|
|
17
|
+
// named risk: that escalation is now the only way to get the independent
|
|
18
|
+
// verifier; a native high or unassessable tier informs only.
|
|
17
19
|
|
|
18
20
|
test("the high-risk list has exactly the six items of the always-on Task Size section", () => {
|
|
19
21
|
assert.deepEqual(Object.keys(HIGH_RISK_ITEMS).map(Number), [1, 2, 3, 4, 5, 6]);
|
|
@@ -45,23 +47,25 @@ test("decodeAgentRiskEscalation rejects unknown items, empty or oversized reason
|
|
|
45
47
|
}
|
|
46
48
|
});
|
|
47
49
|
|
|
48
|
-
|
|
50
|
+
// Replaces the escalatedRisk test: the plan risk is high only with an
|
|
51
|
+
// escalation; without one a native high or unassessable tier becomes medium.
|
|
52
|
+
test("verificationRisk makes every escalated tier high and maps the rest to passive or medium", () => {
|
|
49
53
|
const escalation = { item: 3 as const, reason: "changes a published export" };
|
|
50
|
-
assert.equal(
|
|
51
|
-
assert.equal(
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
54
|
+
for (const tier of Object.values(VERIFICATION_TIER)) assert.equal(verificationRisk(tier, escalation), VERIFICATION_TIER.HIGH, tier);
|
|
55
|
+
assert.equal(verificationRisk(VERIFICATION_TIER.PASSIVE, undefined), VERIFICATION_TIER.PASSIVE);
|
|
56
|
+
for (const tier of [VERIFICATION_TIER.MEDIUM, VERIFICATION_TIER.HIGH, VERIFICATION_TIER.UNASSESSABLE]) {
|
|
57
|
+
assert.equal(verificationRisk(tier, undefined), VERIFICATION_TIER.MEDIUM, tier);
|
|
58
|
+
}
|
|
55
59
|
});
|
|
56
60
|
|
|
57
|
-
function assessTool(): { execute: (id: string, params: Record<string, unknown>, signal: AbortSignal | undefined, onUpdate: undefined, ctx: ExtensionContext) => Promise<{ details: unknown }> } {
|
|
61
|
+
function assessTool(nativeRisk: "passive" | "medium" | "high" = "medium"): { execute: (id: string, params: Record<string, unknown>, signal: AbortSignal | undefined, onUpdate: undefined, ctx: ExtensionContext) => Promise<{ details: unknown }> } {
|
|
58
62
|
const tools = new Map<string, any>();
|
|
59
63
|
const pi = { on() {}, registerCommand() {}, registerTool(tool: { name: string }) { tools.set(tool.name, tool); } } as unknown as ExtensionAPI;
|
|
60
64
|
const nativeReviewCli: Partial<NativeReviewCli> = {
|
|
61
65
|
reviewMode: async () => ({ operation: "status", scope: "clone", status: { global: "off", cloneLocal: "off", effective: "off", source: NATIVE_REVIEW_MODE_SOURCE.GLOBAL } }),
|
|
62
66
|
assess: async () => ({
|
|
63
67
|
schema: REVIEW_ASSESSMENT_SCHEMA,
|
|
64
|
-
risk:
|
|
68
|
+
risk: nativeRisk,
|
|
65
69
|
reasons: [],
|
|
66
70
|
changedPaths: 1,
|
|
67
71
|
changedLines: 3,
|
|
@@ -121,3 +125,26 @@ test("gentle_review assess: agentEscalation says whether the escalation changed
|
|
|
121
125
|
const { details } = await tool.execute("esc-5", { operation: "assess", input }, undefined, undefined, ctx);
|
|
122
126
|
assert.equal((details as { agentEscalation: { applied: boolean } }).agentEscalation.applied, true, "medium raised to high is applied");
|
|
123
127
|
});
|
|
128
|
+
|
|
129
|
+
test("gentle_review assess: a native high without an escalation informs only and adds no verifier", async () => {
|
|
130
|
+
const tool = assessTool("high");
|
|
131
|
+
for (const writer of [{ writerModelId: "openai/gpt-6.1-sol", writerEffort: "high" }, { writerModelId: "openai/gpt-5-mini", writerEffort: "low" }]) {
|
|
132
|
+
const { details } = await tool.execute("esc-6", { operation: "assess", input: JSON.stringify(writer) }, undefined, undefined, ctx);
|
|
133
|
+
const plan = details as { risk: string; nativeRisk: string; plan: { independentVerifier: boolean; writerSelfVerification: boolean } };
|
|
134
|
+
assert.equal(plan.nativeRisk, VERIFICATION_TIER.HIGH);
|
|
135
|
+
assert.equal(plan.risk, VERIFICATION_TIER.MEDIUM, writer.writerModelId);
|
|
136
|
+
assert.equal(plan.plan.writerSelfVerification, true);
|
|
137
|
+
assert.equal(plan.plan.independentVerifier, false, `${writer.writerModelId}: neither a native high nor the writer's model adds a verifier`);
|
|
138
|
+
}
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
test("gentle_review assess: an escalation over a native high is what adds the verifier", async () => {
|
|
142
|
+
const tool = assessTool("high");
|
|
143
|
+
const input = JSON.stringify({ escalate: { item: 1, reason: "rewrites stored records" } });
|
|
144
|
+
const { details } = await tool.execute("esc-7", { operation: "assess", input }, undefined, undefined, ctx);
|
|
145
|
+
const plan = details as { risk: string; nativeRisk: string; agentEscalation: { applied: boolean }; plan: { independentVerifier: boolean } };
|
|
146
|
+
assert.equal(plan.nativeRisk, VERIFICATION_TIER.HIGH);
|
|
147
|
+
assert.equal(plan.risk, VERIFICATION_TIER.HIGH);
|
|
148
|
+
assert.equal(plan.agentEscalation.applied, true, "without the escalation the plan would be medium");
|
|
149
|
+
assert.equal(plan.plan.independentVerifier, true);
|
|
150
|
+
});
|
|
@@ -2,8 +2,11 @@ import assert from "node:assert/strict";
|
|
|
2
2
|
import test from "node:test";
|
|
3
3
|
import fs, { existsSync, readFileSync, statSync } from "node:fs";
|
|
4
4
|
import { syncBuiltinESMExports } from "node:module";
|
|
5
|
-
import {
|
|
5
|
+
import { tmpdir } from "node:os";
|
|
6
|
+
import { basename, dirname, join } from "node:path";
|
|
6
7
|
import { PassThrough } from "node:stream";
|
|
8
|
+
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
9
|
+
import { createSessionScratchExtension } from "../extensions/session-scratch.ts";
|
|
7
10
|
import { AGENT_MODE, parseAgentsConfig, resolveAgentProfile, type AgentDefinition } from "../lib/agents-config.ts";
|
|
8
11
|
import { MISSING_TOOLS_NOTE_PREFIX, TASK_STATUS, TaskStore, THREAD_ITEM, type TaskRecord } from "../lib/agents-protocol.ts";
|
|
9
12
|
import { AgentRunner, childArguments, JsonLines, piCommand, abortReasonText, REQUESTED_TOOLS_ENV, type ChildLike, type RunnerDeps, type RunnerHooks, type TaskRequest } from "../lib/agents-runner.ts";
|
|
@@ -618,6 +621,21 @@ test("runner captures resolved model and effort, retaining omitted launch values
|
|
|
618
621
|
h.runner.cancel(task.id);
|
|
619
622
|
});
|
|
620
623
|
|
|
624
|
+
test("record creation copies the launch-layer model source next to the model (#2180)", () => {
|
|
625
|
+
const sourced = harness({ state: { model: { provider: "anthropic", id: "resolved-model" } } });
|
|
626
|
+
const sourcedTask = sourced.runner.run(request({ modelSource: "binding" }));
|
|
627
|
+
assert.equal(sourced.store.get(sourcedTask.id)?.modelSource, "binding", "the record keeps the host-computed layer next to the resolved model");
|
|
628
|
+
sourced.runner.cancel(sourcedTask.id);
|
|
629
|
+
|
|
630
|
+
// The absence contract: a request without a model source (older callers,
|
|
631
|
+
// peers) records no field at all, so the card renders exactly as before.
|
|
632
|
+
const plain = harness({ state: { model: { provider: "anthropic", id: "resolved-model" } } });
|
|
633
|
+
const plainTask = plain.runner.run(request());
|
|
634
|
+
assert.equal(plain.store.get(plainTask.id)?.modelSource, undefined);
|
|
635
|
+
assert.equal("modelSource" in (plain.store.get(plainTask.id) as TaskRecord), false, "no modelSource key is recorded when the launch did not name a layer");
|
|
636
|
+
plain.runner.cancel(plainTask.id);
|
|
637
|
+
});
|
|
638
|
+
|
|
621
639
|
test("runner delivers each response combination once at finish, never attributing launch selection", async () => {
|
|
622
640
|
const snapshots: NonNullable<Parameters<NonNullable<RunnerHooks["onFinish"]>>[1]>[] = [];
|
|
623
641
|
const h = harness({ exitOnKill: false, state: { model: { provider: "anthropic", id: "launch" }, thinkingLevel: "max" },
|
|
@@ -1029,10 +1047,13 @@ for (const [platform, detached] of [["win32", false], ["linux", true]] as const)
|
|
|
1029
1047
|
await tick();
|
|
1030
1048
|
const ownedIpc = launches[0]?.options.env.GENTLE_PI_AGENTS_OWNED_IPC;
|
|
1031
1049
|
assert.match(ownedIpc ?? "", /^\d+-[a-z0-9]+$/, "the runner creates an opaque owned-IPC marker");
|
|
1050
|
+
const scratch = launches[0]?.options.env.TMPDIR;
|
|
1051
|
+
assert.match(basename(scratch ?? ""), /^ga[A-Za-z0-9]{6}$/, "the child gets its own TMPDIR");
|
|
1052
|
+
const scratchEnv = platform === "win32" ? { TMPDIR: scratch, TMP: scratch, TEMP: scratch } : { TMPDIR: scratch };
|
|
1032
1053
|
assert.deepEqual(launches, [{
|
|
1033
1054
|
command: "pi-fixture",
|
|
1034
1055
|
args: ["--from-host", "--mode", "rpc", "--session-dir", "/sessions", "--model", "openai-codex/gpt-5.6-terra:high", "--tools", "read,grep,subagent_parent_message", "--append-system-prompt", "You map things."],
|
|
1035
|
-
options: { cwd: "/repo", env: { PATH: "/fixture", KEEP: "yes", GENTLE_PI_AGENTS_CHILD: "1", GENTLE_PI_AGENTS_OWNED_IPC: ownedIpc, [REQUESTED_TOOLS_ENV]: "read,grep,subagent_parent_message" }, detached, windowsHide: true, stdio: ["pipe", "pipe", "pipe", "ipc"] },
|
|
1056
|
+
options: { cwd: "/repo", env: { PATH: "/fixture", KEEP: "yes", GENTLE_PI_AGENTS_CHILD: "1", GENTLE_PI_AGENTS_OWNED_IPC: ownedIpc, [REQUESTED_TOOLS_ENV]: "read,grep,subagent_parent_message", ...scratchEnv }, detached, windowsHide: true, stdio: ["pipe", "pipe", "pipe", "ipc"] },
|
|
1036
1057
|
}]);
|
|
1037
1058
|
child.emit({ type: "agent_end", messages: [{ role: "assistant", content: [{ type: "text", text: "platform checked" }], stopReason: "stop" }] });
|
|
1038
1059
|
child.emit({ type: "agent_settled" });
|
|
@@ -1115,7 +1136,8 @@ test("AgentRunner retains permission broker fd3 and assigns messaging IPC to fd4
|
|
|
1115
1136
|
const launch = spawnOptions[0];
|
|
1116
1137
|
const permissionChannelStdio = process.platform === "win32" ? "overlapped" : "pipe";
|
|
1117
1138
|
assert.match(launch?.env.GENTLE_PI_AGENTS_OWNED_IPC ?? "", /^\d+-[a-z0-9]+$/, "the owned-IPC marker has the runner's opaque shape");
|
|
1118
|
-
assert.deepEqual(launch?.env, { GENTLE_PI_AGENTS_CHILD: "1", GENTLE_PI_AGENTS_OWNED_IPC: launch?.env.GENTLE_PI_AGENTS_OWNED_IPC, GENTLE_PI_AGENTS_PARENT_PERMISSION_FD: "3", [REQUESTED_TOOLS_ENV]: "read,grep,subagent_parent_message" });
|
|
1139
|
+
assert.deepEqual(launch?.env, { GENTLE_PI_AGENTS_CHILD: "1", GENTLE_PI_AGENTS_OWNED_IPC: launch?.env.GENTLE_PI_AGENTS_OWNED_IPC, GENTLE_PI_AGENTS_PARENT_PERMISSION_FD: "3", [REQUESTED_TOOLS_ENV]: "read,grep,subagent_parent_message", TMPDIR: launch?.env.TMPDIR });
|
|
1140
|
+
assert.match(basename(launch?.env.TMPDIR ?? ""), /^ga[A-Za-z0-9]{6}$/);
|
|
1119
1141
|
assert.deepEqual(launch?.stdio, ["pipe", "pipe", "pipe", permissionChannelStdio, "ipc"]);
|
|
1120
1142
|
assert.equal(launch?.stdio?.length, 5);
|
|
1121
1143
|
children[0].emit({ type: "agent_end", messages: [{ role: "assistant", content: [{ type: "text", text: "channel checked" }], stopReason: "stop" }] });
|
|
@@ -1778,3 +1800,238 @@ test("temporary instructions transport file is cleaned up if child emits an earl
|
|
|
1778
1800
|
assert.ok(!existsSync(capturedPromptPath), "temporary transport file must be cleaned up on early child error");
|
|
1779
1801
|
assert.ok(!existsSync(dirname(capturedPromptPath)), "temporary transport directory must be cleaned up on early child error");
|
|
1780
1802
|
});
|
|
1803
|
+
|
|
1804
|
+
// Harness roadmap T3 (R1): asking the parent to remove a child's scratch failed
|
|
1805
|
+
// in 3 of 6 bench cells, so each child gets its own TMPDIR that the runtime
|
|
1806
|
+
// removes when the child ends.
|
|
1807
|
+
function scratchRunner(spawnImpl: (env: NodeJS.ProcessEnv) => ChildLike) {
|
|
1808
|
+
let clock = 1000;
|
|
1809
|
+
const deps: RunnerDeps = {
|
|
1810
|
+
spawn: (_command, _args, options) => spawnImpl((options as { env: NodeJS.ProcessEnv }).env),
|
|
1811
|
+
now: () => (clock += 1),
|
|
1812
|
+
schedule: (_fn, _ms) => () => {},
|
|
1813
|
+
pi: { command: "pi", args: [] },
|
|
1814
|
+
};
|
|
1815
|
+
return new AgentRunner(new TaskStore(), { maxConcurrency: 1, stallTimeoutMs: 10_000 }, deps, { askUser: async () => ({ value: "yes" }) });
|
|
1816
|
+
}
|
|
1817
|
+
|
|
1818
|
+
test("each child gets its own TMPDIR under the parent's temp dir, removed with its contents on exit", async () => {
|
|
1819
|
+
const fake = fakeChild();
|
|
1820
|
+
let childTmp = "";
|
|
1821
|
+
const runner = scratchRunner((env) => { childTmp = env.TMPDIR ?? ""; return fake.child; });
|
|
1822
|
+
const task = runner.run(request({ env: { ...process.env, TMPDIR: tmpdir() } }));
|
|
1823
|
+
await tick();
|
|
1824
|
+
assert.ok(childTmp.startsWith(tmpdir()) && childTmp !== tmpdir(), `child TMPDIR ${childTmp} is a fresh dir under ${tmpdir()}`);
|
|
1825
|
+
assert.ok(statSync(childTmp).isDirectory());
|
|
1826
|
+
const scratch = fs.mkdtempSync(`${childTmp}/check-`);
|
|
1827
|
+
fs.writeFileSync(`${scratch}/state.json`, "{}");
|
|
1828
|
+
fake.exit(0);
|
|
1829
|
+
await runner.waitFor(task.id);
|
|
1830
|
+
assert.equal(existsSync(childTmp), false, "the child's TMPDIR and everything mktemp created in it are removed");
|
|
1831
|
+
});
|
|
1832
|
+
|
|
1833
|
+
test("a child TMPDIR is removed when spawn throws", async () => {
|
|
1834
|
+
let childTmp = "";
|
|
1835
|
+
const runner = scratchRunner((env) => { childTmp = env.TMPDIR ?? ""; throw new Error("spawn failed intentionally"); });
|
|
1836
|
+
const task = runner.run(request());
|
|
1837
|
+
await tick();
|
|
1838
|
+
assert.equal((await runner.waitFor(task.id)).status, TASK_STATUS.FAILED);
|
|
1839
|
+
assert.ok(childTmp, "the failed launch still received a child TMPDIR");
|
|
1840
|
+
assert.equal(existsSync(childTmp), false);
|
|
1841
|
+
});
|
|
1842
|
+
|
|
1843
|
+
test("a quarantined child keeps its TMPDIR, since a descendant may still use it", async () => {
|
|
1844
|
+
const timers: Array<{ fn: () => void; ms: number }> = [];
|
|
1845
|
+
let now = 1_000;
|
|
1846
|
+
let childTmp = "";
|
|
1847
|
+
let fake: FakeChild | undefined;
|
|
1848
|
+
const store = new TaskStore();
|
|
1849
|
+
const runner = new AgentRunner(store, { maxConcurrency: 1, stallTimeoutMs: 10_000 }, {
|
|
1850
|
+
spawn: (_command, _args, options) => { childTmp = (options as { env: NodeJS.ProcessEnv }).env.TMPDIR ?? ""; return (fake = fakeChild({ exitOnKill: false, pid: 91 })).child; },
|
|
1851
|
+
now: () => now,
|
|
1852
|
+
schedule: (fn, ms) => { timers.push({ fn, ms }); return () => {}; },
|
|
1853
|
+
pi: { command: "pi", args: [] },
|
|
1854
|
+
process: { platform: "win32", kill: () => {} },
|
|
1855
|
+
}, { askUser: async () => ({ value: "yes" }) });
|
|
1856
|
+
const task = runner.run(request());
|
|
1857
|
+
await tick();
|
|
1858
|
+
runner.cancel(task.id);
|
|
1859
|
+
timers.find((timer) => timer.ms === 250)!.fn();
|
|
1860
|
+
now = 5_000;
|
|
1861
|
+
timers.filter((timer) => timer.ms === 25).at(-1)!.fn();
|
|
1862
|
+
await tick();
|
|
1863
|
+
assert.match(store.get(task.id)?.error ?? "", /capacity quarantined/);
|
|
1864
|
+
assert.equal(existsSync(childTmp), true, "an unconfirmed exit must not delete files a live descendant may hold");
|
|
1865
|
+
fake!.exit(0);
|
|
1866
|
+
await tick();
|
|
1867
|
+
assert.equal(existsSync(childTmp), false, "the proven exit removes the quarantined child's TMPDIR");
|
|
1868
|
+
});
|
|
1869
|
+
|
|
1870
|
+
// R1 review advisory: a quarantined POSIX child whose own exit arrived while a
|
|
1871
|
+
// descendant still held its group was never probed again, so its TMPDIR and
|
|
1872
|
+
// slot stayed for the rest of the session after the group ended.
|
|
1873
|
+
test("a quarantined child whose exit is seen before its group ends frees its TMPDIR and slot once the group is gone", async () => {
|
|
1874
|
+
const timers: Array<{ fn: () => void; ms: number; cancelled: boolean }> = [];
|
|
1875
|
+
let now = 1_000;
|
|
1876
|
+
let groupAlive = true;
|
|
1877
|
+
let launches = 0;
|
|
1878
|
+
const childTmps: string[] = [];
|
|
1879
|
+
const store = new TaskStore();
|
|
1880
|
+
let fake: FakeChild | undefined;
|
|
1881
|
+
const runner = new AgentRunner(store, { maxConcurrency: 1, stallTimeoutMs: 10_000 }, {
|
|
1882
|
+
spawn: (_command, _args, options) => {
|
|
1883
|
+
childTmps.push((options as { env: NodeJS.ProcessEnv }).env.TMPDIR ?? "");
|
|
1884
|
+
return (fake = fakeChild({ exitOnKill: false, pid: 90 + (launches += 1) })).child;
|
|
1885
|
+
},
|
|
1886
|
+
now: () => now,
|
|
1887
|
+
schedule: (fn, ms) => {
|
|
1888
|
+
const timer = { fn, ms, cancelled: false };
|
|
1889
|
+
timers.push(timer);
|
|
1890
|
+
return () => { timer.cancelled = true; };
|
|
1891
|
+
},
|
|
1892
|
+
pi: { command: "pi", args: [] },
|
|
1893
|
+
process: { platform: "linux", kill: (_pid, signal) => {
|
|
1894
|
+
if (signal === 0 && !groupAlive) throw Object.assign(new Error("group probe"), { code: "ESRCH" });
|
|
1895
|
+
} },
|
|
1896
|
+
}, { askUser: async () => ({ value: "yes" }) });
|
|
1897
|
+
const first = runner.run(request());
|
|
1898
|
+
const second = runner.run(request({ prompt: "queued" }));
|
|
1899
|
+
await tick();
|
|
1900
|
+
const quarantined = fake!;
|
|
1901
|
+
runner.cancel(first.id);
|
|
1902
|
+
timers.find((timer) => timer.ms === 250)!.fn();
|
|
1903
|
+
now = 5_000;
|
|
1904
|
+
timers.filter((timer) => timer.ms === 25).at(-1)!.fn();
|
|
1905
|
+
await tick();
|
|
1906
|
+
assert.match(store.get(first.id)?.error ?? "", /capacity quarantined/);
|
|
1907
|
+
quarantined.exit(0);
|
|
1908
|
+
await tick();
|
|
1909
|
+
assert.equal(existsSync(childTmps[0]), true, "a descendant still holds the group, so the TMPDIR stays");
|
|
1910
|
+
assert.equal(store.get(second.id)?.status, TASK_STATUS.QUEUED, "the slot stays quarantined while the group lives");
|
|
1911
|
+
groupAlive = false;
|
|
1912
|
+
const probe = timers.filter((timer) => !timer.cancelled && timer.ms === 1_000).at(-1);
|
|
1913
|
+
assert.ok(probe, "the observed exit keeps probing the remaining group");
|
|
1914
|
+
probe.fn();
|
|
1915
|
+
await tick();
|
|
1916
|
+
assert.equal(existsSync(childTmps[0]), false, "the gone group removes the quarantined child's TMPDIR");
|
|
1917
|
+
assert.equal(store.get(second.id)?.status, TASK_STATUS.RUNNING, "the freed slot starts queued work");
|
|
1918
|
+
assert.equal(store.get(first.id)?.error, `process cleanup unconfirmed after 1000ms; capacity quarantined`, "the recorded outcome is unchanged");
|
|
1919
|
+
runner.cancel(second.id);
|
|
1920
|
+
fake!.exit(0);
|
|
1921
|
+
await tick();
|
|
1922
|
+
});
|
|
1923
|
+
|
|
1924
|
+
test("a relative inherited TMPDIR falls back to the system temp dir, so the child's path is absolute", async () => {
|
|
1925
|
+
const fake = fakeChild();
|
|
1926
|
+
let childTmp = "";
|
|
1927
|
+
const runner = scratchRunner((env) => { childTmp = env.TMPDIR ?? ""; return fake.child; });
|
|
1928
|
+
const task = runner.run(request({ env: { TMPDIR: "reltmp" } }));
|
|
1929
|
+
await tick();
|
|
1930
|
+
assert.ok(childTmp.startsWith(join(tmpdir(), "ga")), childTmp);
|
|
1931
|
+
fake.exit(0);
|
|
1932
|
+
await runner.waitFor(task.id);
|
|
1933
|
+
assert.equal(existsSync(childTmp), false);
|
|
1934
|
+
});
|
|
1935
|
+
|
|
1936
|
+
// odd/tasks/session-scratch-ledger.md S9: TMPDIR nests the session scratch and the
|
|
1937
|
+
// subagent's own TMPDIR, and a Unix socket path (104 bytes with the NUL on macOS)
|
|
1938
|
+
// under it must still fit, as Python's `pymp-XXXXXXXX/listener-XXXXXXXX` (32 bytes).
|
|
1939
|
+
test("S9: under a 48-byte inherited TMPDIR, a subagent TMPDIR leaves room for a 32-byte socket name", async (t) => {
|
|
1940
|
+
const INHERITED_BYTES = 48;
|
|
1941
|
+
const SOCKET_NAME = "/pymp-XXXXXXXX/listener-XXXXXXXX";
|
|
1942
|
+
// An unresolved parent, like macOS `/var/folders/xx/<30 chars>/T`, padded to 48 bytes.
|
|
1943
|
+
const parent = [tmpdir(), "/tmp"].map((root) => { try { return fs.mkdtempSync(join(root, "b")); } catch { return undefined; } })
|
|
1944
|
+
.find((dir): dir is string => dir !== undefined && Buffer.byteLength(dir) + 2 <= INHERITED_BYTES);
|
|
1945
|
+
if (!parent) { t.skip("no temp directory short enough to build a 48-byte path"); return; }
|
|
1946
|
+
t.after(() => fs.rmSync(parent, { recursive: true, force: true }));
|
|
1947
|
+
const inherited = join(parent, "x".repeat(INHERITED_BYTES - Buffer.byteLength(parent) - 1));
|
|
1948
|
+
fs.mkdirSync(inherited);
|
|
1949
|
+
assert.equal(Buffer.byteLength(inherited), INHERITED_BYTES);
|
|
1950
|
+
const env: NodeJS.ProcessEnv = { TMPDIR: inherited, GENTLE_PI_CONFIG_HOME: join(parent, "config") };
|
|
1951
|
+
const handlers = new Map<string, (event: unknown, ctx: unknown) => unknown>();
|
|
1952
|
+
createSessionScratchExtension({ env })({ on: (name: string, handler: (event: unknown, ctx: unknown) => unknown) => { handlers.set(name, handler); }, registerCommand: () => {} } as unknown as ExtensionAPI);
|
|
1953
|
+
const ctx = { cwd: parent, hasUI: false, sessionManager: { getSessionId: () => "a-session-id-much-longer-than-the-scratch-name" } };
|
|
1954
|
+
handlers.get("session_start")?.({ type: "session_start", reason: "startup" }, ctx);
|
|
1955
|
+
t.after(() => handlers.get("session_shutdown")?.({ type: "session_shutdown", reason: "quit" }, ctx));
|
|
1956
|
+
assert.equal(dirname(env.TMPDIR!), inherited, "the session scratch keeps the inherited spelling");
|
|
1957
|
+
const fake = fakeChild();
|
|
1958
|
+
let childTmp = "";
|
|
1959
|
+
const runner = scratchRunner((childEnv) => { childTmp = childEnv.TMPDIR ?? ""; return fake.child; });
|
|
1960
|
+
const task = runner.run(request({ env: { TMPDIR: env.TMPDIR } }));
|
|
1961
|
+
await tick();
|
|
1962
|
+
assert.equal(dirname(childTmp), env.TMPDIR, "the subagent TMPDIR nests under the session scratch");
|
|
1963
|
+
const socketPath = Buffer.byteLength(`${childTmp}${SOCKET_NAME}`);
|
|
1964
|
+
assert.ok(socketPath <= 103, `${childTmp}${SOCKET_NAME} is ${socketPath} bytes`);
|
|
1965
|
+
fake.exit(0);
|
|
1966
|
+
await runner.waitFor(task.id);
|
|
1967
|
+
});
|
|
1968
|
+
|
|
1969
|
+
// odd/tasks/scratch-review-advisories.md S2 (native review advisories of #2300):
|
|
1970
|
+
// session shutdown removed the whole scratch, including the TMPDIR of a child
|
|
1971
|
+
// still running or quarantined, whose descendants may still hold files there.
|
|
1972
|
+
test("advisories S2: session shutdown keeps a running or quarantined subagent's TMPDIR, removes the rest, and leaves the scratch to the sweep", async (t) => {
|
|
1973
|
+
const root = fs.realpathSync(fs.mkdtempSync(join(tmpdir(), "gentle-pi-advisories-s2-")));
|
|
1974
|
+
t.after(() => fs.rmSync(root, { recursive: true, force: true }));
|
|
1975
|
+
const base = join(root, "tmp");
|
|
1976
|
+
fs.mkdirSync(base);
|
|
1977
|
+
const configHome = join(root, "config");
|
|
1978
|
+
const env: NodeJS.ProcessEnv = { TMPDIR: base, GENTLE_PI_CONFIG_HOME: configHome };
|
|
1979
|
+
const scratchSession = (sessionId: string, options: { isProcessAlive?: (pid: number) => boolean; now?: () => number } = {}) => {
|
|
1980
|
+
const handlers = new Map<string, (event: unknown, ctx: unknown) => unknown>();
|
|
1981
|
+
createSessionScratchExtension({ env, ...options })({ on: (name: string, handler: (event: unknown, ctx: unknown) => unknown) => { handlers.set(name, handler); }, registerCommand: () => {} } as unknown as ExtensionAPI);
|
|
1982
|
+
const ctx = { cwd: root, hasUI: false, sessionManager: { getSessionId: () => sessionId } };
|
|
1983
|
+
return {
|
|
1984
|
+
start: () => handlers.get("session_start")?.({ type: "session_start", reason: "startup" }, ctx),
|
|
1985
|
+
shutdown: () => handlers.get("session_shutdown")?.({ type: "session_shutdown", reason: "quit" }, ctx),
|
|
1986
|
+
};
|
|
1987
|
+
};
|
|
1988
|
+
const parent = scratchSession("s-1");
|
|
1989
|
+
parent.start();
|
|
1990
|
+
const scratch = env.TMPDIR!;
|
|
1991
|
+
assert.equal(dirname(scratch), base);
|
|
1992
|
+
const timers: Array<{ fn: () => void; ms: number }> = [];
|
|
1993
|
+
let now = 1_000;
|
|
1994
|
+
const fakes: FakeChild[] = [];
|
|
1995
|
+
const childTmps: string[] = [];
|
|
1996
|
+
const store = new TaskStore();
|
|
1997
|
+
const runner = new AgentRunner(store, { maxConcurrency: 2, stallTimeoutMs: 10_000 }, {
|
|
1998
|
+
spawn: (_command, _args, options) => {
|
|
1999
|
+
childTmps.push((options as { env: NodeJS.ProcessEnv }).env.TMPDIR ?? "");
|
|
2000
|
+
const fake = fakeChild({ exitOnKill: false, pid: 80 + fakes.length });
|
|
2001
|
+
fakes.push(fake);
|
|
2002
|
+
return fake.child;
|
|
2003
|
+
},
|
|
2004
|
+
now: () => now,
|
|
2005
|
+
schedule: (fn, ms) => { timers.push({ fn, ms }); return () => {}; },
|
|
2006
|
+
pi: { command: "pi", args: [] },
|
|
2007
|
+
process: { platform: "win32", kill: () => {} },
|
|
2008
|
+
}, { askUser: async () => ({ value: "yes" }) });
|
|
2009
|
+
const quarantined = runner.run(request({ env: { TMPDIR: scratch } }));
|
|
2010
|
+
const running = runner.run(request({ prompt: "running", env: { TMPDIR: scratch } }));
|
|
2011
|
+
await tick();
|
|
2012
|
+
runner.cancel(quarantined.id);
|
|
2013
|
+
timers.find((timer) => timer.ms === 250)!.fn();
|
|
2014
|
+
now = 5_000;
|
|
2015
|
+
timers.filter((timer) => timer.ms === 25).at(-1)!.fn();
|
|
2016
|
+
await tick();
|
|
2017
|
+
assert.match(store.get(quarantined.id)?.error ?? "", /capacity quarantined/);
|
|
2018
|
+
assert.deepEqual(childTmps.map((dir) => dirname(dir)), [scratch, scratch]);
|
|
2019
|
+
fs.writeFileSync(join(childTmps[0], "held.txt"), "x");
|
|
2020
|
+
fs.writeFileSync(join(scratch, "junk.txt"), "x");
|
|
2021
|
+
parent.shutdown();
|
|
2022
|
+
assert.ok(existsSync(join(childTmps[0], "held.txt")), "a quarantined child's TMPDIR stays");
|
|
2023
|
+
assert.ok(existsSync(childTmps[1]), "a running child's TMPDIR stays");
|
|
2024
|
+
assert.equal(existsSync(join(scratch, "junk.txt")), false, "the rest of the scratch is removed");
|
|
2025
|
+
assert.equal(env.TMPDIR, base, "the inherited TMPDIR is restored");
|
|
2026
|
+
const ledger = JSON.parse(readFileSync(join(configHome, "scratch", "s-1.json"), "utf8")) as { scratchDir: string | null };
|
|
2027
|
+
assert.equal(ledger.scratchDir, scratch, "the ledger keeps the scratch for a later sweep");
|
|
2028
|
+
fakes[0].exit(0);
|
|
2029
|
+
runner.cancel(running.id);
|
|
2030
|
+
fakes[1].exit(0);
|
|
2031
|
+
await tick();
|
|
2032
|
+
assert.ok(childTmps.every((dir) => !existsSync(dir)), "each proven exit removes its TMPDIR");
|
|
2033
|
+
const next = scratchSession("next", { isProcessAlive: () => false, now: () => Date.now() + 2 * 3_600_000 });
|
|
2034
|
+
next.start();
|
|
2035
|
+
assert.equal(existsSync(scratch), false, "the startup sweep removes the kept scratch once its owner ended");
|
|
2036
|
+
next.shutdown();
|
|
2037
|
+
});
|
|
@@ -40,6 +40,84 @@ test("YOLO waives reversible push and rebase segments after full evaluation (#21
|
|
|
40
40
|
]) assert.equal(isReversibleYoloCommand(command, evaluateGuardedCommand(command, config)), false, command);
|
|
41
41
|
});
|
|
42
42
|
|
|
43
|
+
// YOLO also waives recursive removal of the session's own temp scratch: one
|
|
44
|
+
// literal path component under a temp root whose admission-time lstat is
|
|
45
|
+
// missing, a symlink, an own file or an own mktemp directory. Every other
|
|
46
|
+
// effect in the command keeps its ordinary guard.
|
|
47
|
+
test("YOLO waives recursive removal of own temp scratch only", () => {
|
|
48
|
+
const config = { autonomousMode: false, guardedCommands: {} };
|
|
49
|
+
const entry = (type: "dir" | "file" | "link", uid = 1000) => ({
|
|
50
|
+
uid, isDirectory: () => type === "dir", isFile: () => type === "file", isSymbolicLink: () => type === "link",
|
|
51
|
+
});
|
|
52
|
+
const entries = new Map([
|
|
53
|
+
["/tmp/tmp.8phya1BpFr", entry("dir")], ["/tmp/tmp.LKd1kBX8Rw", entry("dir")], ["/tmp/tmp.C94OVzf01o", entry("dir")],
|
|
54
|
+
["/tmp/ctx-backstop-pnpm-test.log", entry("file")], ["/tmp/tmp.abcdef1234", entry("dir")], ["/tmp/latest", entry("link", 0)],
|
|
55
|
+
["/var/tmp/tmp.zzzzzz", entry("dir")], ["/tmp/cache", entry("dir")], ["/tmp/tmp.other1234", entry("dir", 0)],
|
|
56
|
+
["/tmp/shared.log", entry("file", 0)],
|
|
57
|
+
]);
|
|
58
|
+
const fs = {
|
|
59
|
+
uid: 1000, tmpdir: "/tmp", home: "/home/user",
|
|
60
|
+
lstat(path: string) {
|
|
61
|
+
if (path === "/tmp/tmp.denied99") throw Object.assign(new Error("EACCES"), { code: "EACCES" });
|
|
62
|
+
const found = entries.get(path);
|
|
63
|
+
if (found === undefined) throw Object.assign(new Error(`ENOENT: ${path}`), { code: "ENOENT" });
|
|
64
|
+
return found;
|
|
65
|
+
},
|
|
66
|
+
};
|
|
67
|
+
const waived = (command: string) => isReversibleYoloCommand(command, evaluateGuardedCommand(command, config), fs);
|
|
68
|
+
for (const command of [
|
|
69
|
+
"rm -rf /tmp/tmp.8phya1BpFr /tmp/tmp.LKd1kBX8Rw /tmp/tmp.C94OVzf01o /tmp/ctx-backstop-pnpm-test.log; cd /home/gentleman/work/gentle-pi-worktrees/harness-r1 && git log --oneline -1",
|
|
70
|
+
"rm -rf /tmp/tmp.abcdef1234", "rm -rf /tmp/ctx-backstop-pnpm-test.log", "rm -rf /tmp/latest", "rm -rf /tmp/tmp.gone1234",
|
|
71
|
+
"rm -r -f -- /var/tmp/tmp.zzzzzz", "rm -rf /tmp/tmp.abcdef1234 && git push -u origin feature/x",
|
|
72
|
+
"rm -rf /tmp/tmp.abcdef1234 && rm -rf /tmp/tmp.gone1234\nrm -rf /tmp/latest || git log --oneline -1",
|
|
73
|
+
]) assert.equal(waived(command), true, command);
|
|
74
|
+
for (const command of [
|
|
75
|
+
"rm -rf /tmp", "rm -rf /tmp/", "rm -rf /tmp/*", "rm -rf /tmp/tmp.x/", "rm -rf /tmp/tmp.x/../..", "rm -rf /tmp/./tmp.x",
|
|
76
|
+
"rm -rf /tmp/a/b", `rm -rf "$TMPDIR/x"`, "rm -rf $TMPDIR/x", "rm -rf /tmp/tmp.{a,b}", "rm -rf /tmp/tmp.abc?", "rm -rf tmp.abcdef1234",
|
|
77
|
+
"sudo rm -rf /tmp/tmp.abcdef1234", "xargs rm -rf /tmp/tmp.abcdef1234", "rm -rf --no-preserve-root /tmp/tmp.abcdef1234",
|
|
78
|
+
"rm -rf /tmp/tmp.abcdef1234 ~/foo", "rm -rf /tmp/tmp.abcdef1234 && rm -rf data", "rm -rf /tmp/tmp.abcdef1234 data",
|
|
79
|
+
"rm -rf /tmp/tmp.abcdef1234 && find /tmp/x -delete", "rm -rf /tmp/tmp.abcdef1234 && psql -c 'DROP TABLE users'",
|
|
80
|
+
"rm -rf /tmp/tmp.abcdef1234 && git push --delete origin main", "rm -rf /tmp/tmp.abcdef1234 && npm publish",
|
|
81
|
+
"rm -rf /tmp/tmp.abcdef1234 && git stash drop", "rm -rf /tmp/cache", "rm -rf /tmp/tmp.other1234", "rm -rf /tmp/shared.log",
|
|
82
|
+
"rm -rf /tmp/tmp.denied99", "rm -rf /home/user/tmp.abcdef1234", "sh -c 'rm -rf /tmp/tmp.abcdef1234'",
|
|
83
|
+
"find /tmp -maxdepth 1 -name 'tmp.*' -exec rm -rf {} +", "rm -rf /tmp/tmp.gone1234>important.txt", "rm -rf /tmp/tmp.gone1234 2>/dev/null",
|
|
84
|
+
// Ordering: an earlier segment can move data into a target the lstat saw as scratch.
|
|
85
|
+
"mv /home/u/data /tmp/tmp.missingZZ619976 && rm -rf /tmp/tmp.missingZZ619976", "mv /home/u/data /tmp/tmp.abcdef1234 && rm -rf /tmp/tmp.abcdef1234",
|
|
86
|
+
"cp -r x /tmp/tmp.abcdef1234; rm -rf /tmp/tmp.abcdef1234", "echo hi; rm -rf /tmp/tmp.abcdef1234", "ln -s /home/u/data /tmp/tmp.gone1234 && rm -rf /tmp/tmp.gone1234",
|
|
87
|
+
"(mv /home/u/data /tmp/tmp.abcdef1234) && rm -rf /tmp/tmp.abcdef1234", "(rm -rf /tmp/tmp.abcdef1234)",
|
|
88
|
+
"rm -rf /tmp/tmp.abcdef1234 & mv /home/u/data /tmp/tmp.abcdef1234", "rm -rf /tmp/tmp.abcdef1234 | cat",
|
|
89
|
+
"rm -rf /tmp/tmp.gone1234; mv /home/u/data /tmp/tmp.abcdef1234; rm -rf /tmp/tmp.abcdef1234",
|
|
90
|
+
]) assert.equal(waived(command), false, command);
|
|
91
|
+
// A tmpdir under home would make top-level repository files look like scratch.
|
|
92
|
+
const underHome = { ...fs, home: "/home/gentleman", tmpdir: "/home/gentleman/work/repo", lstat: () => entry("file") };
|
|
93
|
+
assert.equal(isReversibleYoloCommand("rm -rf /home/gentleman/work/repo/README.md", evaluateGuardedCommand("rm -rf /home/gentleman/work/repo/README.md", config), underHome), false);
|
|
94
|
+
// A missing uid (no POSIX ownership) never proves own scratch.
|
|
95
|
+
assert.equal(isReversibleYoloCommand("rm -rf /tmp/tmp.abcdef1234", evaluateGuardedCommand("rm -rf /tmp/tmp.abcdef1234", config), { ...fs, uid: undefined }), false);
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
// An eligible push or rebase stays waived with fd or /dev/null redirections
|
|
99
|
+
// and a pipe into a read-only filter; any other redirection or consumer asks.
|
|
100
|
+
test("YOLO waives push and rebase segments with harmless redirections and read-only filters", () => {
|
|
101
|
+
const config = { autonomousMode: false, guardedCommands: {} };
|
|
102
|
+
const entry = { uid: 1000, isDirectory: () => true, isFile: () => false, isSymbolicLink: () => false };
|
|
103
|
+
const fs = { uid: 1000, tmpdir: "/tmp", home: "/home/user", lstat: () => entry };
|
|
104
|
+
const waived = (command: string) => isReversibleYoloCommand(command, evaluateGuardedCommand(command, config), fs);
|
|
105
|
+
for (const command of [
|
|
106
|
+
"git push origin main 2>&1", "git push -u origin feat/x 2>&1 | tail -5", "git push origin main >/dev/null", "git push origin main 2>/dev/null",
|
|
107
|
+
"git push origin main &>/dev/null", "git push origin main > /dev/null 2>&1", "git push origin main 2>&1 | grep -v hint | head -20",
|
|
108
|
+
"git push origin main | cat", "git push origin main 2>&1 | wc -l && gh pr create --fill", "git push 2>&1 | grep 'remote: '",
|
|
109
|
+
"git rebase origin/main 2>&1 | tail -3", "git fetch && git rebase origin/main >/dev/null && git push 2>&1 | tail -5",
|
|
110
|
+
"rm -rf /tmp/tmp.abcdef1234; git push origin main 2>&1 | tail -5",
|
|
111
|
+
]) assert.equal(waived(command), true, command);
|
|
112
|
+
for (const command of [
|
|
113
|
+
"git push origin main > out.txt", "git push origin main 2>err.log", "git push origin main >> /dev/null.txt", "git push origin main &>log",
|
|
114
|
+
"git push origin main | sh", "git push origin main | tee out", "git push origin main 2>&1 | tail -5 | sh", "git push origin main | tail -5 > out",
|
|
115
|
+
"git push origin main | catx", "git push origin main 2>&1 &", "git push origin main | tail -5 &", "git push origin main | grep \"$(id)\"",
|
|
116
|
+
"git push --force origin main 2>&1", "git push --delete origin main 2>&1 | tail -5", "git push origin :main 2>&1", "git push --mirror origin 2>&1",
|
|
117
|
+
"git rebase -i main 2>&1 | tail", "git push origin main 2>&1 | xargs echo",
|
|
118
|
+
]) assert.equal(waived(command), false, command);
|
|
119
|
+
});
|
|
120
|
+
|
|
43
121
|
// gentle-shell#2174: a guarded word inside an argument of a known data command
|
|
44
122
|
// (search, print, view, non-executing edit) is text, not an operation. Every
|
|
45
123
|
// opaque form keeps today's lexical match.
|
|
@@ -53,6 +131,11 @@ test("guarded words inside data-command arguments are not guarded operations (#2
|
|
|
53
131
|
"cat > probe.mts <<'EOF'\nconst x = \"git push --force origin main\";\nEOF",
|
|
54
132
|
"grep -rn 'git reset --hard' docs",
|
|
55
133
|
"env LC_ALL=C grep 'pi remove' notes.md",
|
|
134
|
+
"grep -l \"git push\" a.jsonl",
|
|
135
|
+
// A data-only command substitution or a plain assignment keeps the exemption.
|
|
136
|
+
"f=$(ls); grep -l \"git push\" $f",
|
|
137
|
+
"cd x; f=$(ls -t */*.jsonl | head -40); grep -l \"git push\" $f 2>/dev/null | head -5",
|
|
138
|
+
"grep -l 'git push' $(ls *.jsonl)", "f=1; grep 'git push' x", "grep \"git push $(pwd)\" x", "f=$(ls); grep -rn 'git reset --hard' docs",
|
|
56
139
|
]) assert.equal(classifyGuardedCommand(command, config), "not-guarded", command);
|
|
57
140
|
for (const [command, expected] of [
|
|
58
141
|
["git push origin main", "confirm"],
|
|
@@ -61,6 +144,15 @@ test("guarded words inside data-command arguments are not guarded operations (#2
|
|
|
61
144
|
["bash <<'EOF'\ngit push origin main\nEOF", "confirm"],
|
|
62
145
|
["python3 -c \"import os; os.system('git push')\"", "confirm"],
|
|
63
146
|
["echo $(git push origin main)", "confirm"],
|
|
147
|
+
["x=$(ls); git push origin main", "confirm"],
|
|
148
|
+
["x=$(git push origin main); grep 'git push' $x", "confirm"],
|
|
149
|
+
["f=$(ls); $f push origin main; grep 'git push' x", "confirm"],
|
|
150
|
+
["grep 'git push' $(cat x | sh)", "confirm"],
|
|
151
|
+
["grep 'git push' $(echo $(git push))", "confirm"],
|
|
152
|
+
["grep 'git push' $((1+2))", "confirm"],
|
|
153
|
+
["cat <<EOF\ndon't $(git push origin main)\nEOF", "confirm"],
|
|
154
|
+
["PATH=/x; git push origin main", "confirm"],
|
|
155
|
+
["x=$(ls); git reset --hard", "block"],
|
|
64
156
|
["grep 'git push origin main' f | sh", "confirm"],
|
|
65
157
|
["time git push origin main", "confirm"],
|
|
66
158
|
["sed 's/x/git push/e' file", "confirm"],
|
|
@@ -145,6 +237,36 @@ test("quoted separator arguments cannot waive hard denial or invent destruction"
|
|
|
145
237
|
}
|
|
146
238
|
});
|
|
147
239
|
|
|
240
|
+
// gentle-pi#2285: compound bodies, backticks and eval reach the data-loss
|
|
241
|
+
// boundary, while the YOLO scratch waiver still covers leading direct `rm` only.
|
|
242
|
+
test("removal after reserved words, in backticks and in eval needs fresh confirmation and is never scratch-waived", () => {
|
|
243
|
+
const config = { autonomousMode: true, guardedCommands: { gitPush: "allow" as const } };
|
|
244
|
+
for (const command of [`if true; then rm -rf data; fi`, `for d in a b; do rm -rf "$d"; done`, "echo `rm -rf data`", `eval 'rm -rf data'`]) {
|
|
245
|
+
const evaluation = evaluateGuardedCommand(command, config);
|
|
246
|
+
assert.equal(evaluation.action, "confirm", command);
|
|
247
|
+
assert.equal(evaluation.dataLoss, true, command);
|
|
248
|
+
}
|
|
249
|
+
for (const command of [`if true; then rm -rf ~; fi`, "echo `rm -rf /`", `eval 'rm -rf "$HOME"'`]) {
|
|
250
|
+
assert.equal(classifyGuardedCommand(command, config), "block", command);
|
|
251
|
+
}
|
|
252
|
+
for (const command of [`if [ -f x ]; then echo ok; fi`, `for f in *; do cat "$f"; done`, "echo `date`", `eval "$(ssh-agent -s)"`, `grep "then rm -rf" x`]) {
|
|
253
|
+
assert.equal(classifyGuardedCommand(command, config), "not-guarded", command);
|
|
254
|
+
}
|
|
255
|
+
const entry = { uid: 1000, isDirectory: () => true, isFile: () => false, isSymbolicLink: () => false };
|
|
256
|
+
const fs = { uid: 1000, tmpdir: "/tmp", home: "/home/user", lstat: () => entry };
|
|
257
|
+
const normal = { autonomousMode: false, guardedCommands: {} };
|
|
258
|
+
assert.equal(isReversibleYoloCommand("rm -rf /tmp/tmp.abcdef1234", evaluateGuardedCommand("rm -rf /tmp/tmp.abcdef1234", normal), fs), true);
|
|
259
|
+
for (const command of [
|
|
260
|
+
`if true; then rm -rf /tmp/tmp.abcdef1234; fi`, `for d in a; do rm -rf /tmp/tmp.abcdef1234; done`, `{ rm -rf /tmp/tmp.abcdef1234; }`,
|
|
261
|
+
`! rm -rf /tmp/tmp.abcdef1234`, "echo `rm -rf /tmp/tmp.abcdef1234`", `eval 'rm -rf /tmp/tmp.abcdef1234'`, `eval rm -rf /tmp/tmp.abcdef1234`,
|
|
262
|
+
"rm -rf /tmp/tmp.abcdef1234 && echo `rm -rf data`", `rm -rf /tmp/tmp.abcdef1234; eval 'rm -rf data'`,
|
|
263
|
+
]) {
|
|
264
|
+
const evaluation = evaluateGuardedCommand(command, normal);
|
|
265
|
+
assert.equal(evaluation.action, "confirm", command);
|
|
266
|
+
assert.equal(isReversibleYoloCommand(command, evaluation, fs), false, command);
|
|
267
|
+
}
|
|
268
|
+
});
|
|
269
|
+
|
|
148
270
|
// ---------------------------------------------------------------------------
|
|
149
271
|
// Helpers
|
|
150
272
|
// ---------------------------------------------------------------------------
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import assert from "node:assert/strict";
|
|
2
2
|
import { mkdirSync, mkdtempSync, realpathSync, renameSync, rmSync, symlinkSync } from "node:fs";
|
|
3
3
|
import { execFileSync } from "node:child_process";
|
|
4
|
-
import { tmpdir } from "node:os";
|
|
4
|
+
import { homedir, tmpdir } from "node:os";
|
|
5
5
|
import { join } from "node:path";
|
|
6
6
|
import test from "node:test";
|
|
7
7
|
import { __testing } from "../extensions/gentle-ai.ts";
|
|
@@ -82,9 +82,17 @@ test("explicit review protects canonical HOME aliases, filesystem root and sensi
|
|
|
82
82
|
mkdirSync(home);
|
|
83
83
|
mkdirSync(join(home, ".ssh"));
|
|
84
84
|
symlinkSync(home, alias);
|
|
85
|
-
const
|
|
85
|
+
const previousHome = process.env.HOME;
|
|
86
|
+
const previousProfile = process.env.USERPROFILE;
|
|
87
|
+
t.after(() => {
|
|
88
|
+
if (previousHome === undefined) delete process.env.HOME; else process.env.HOME = previousHome;
|
|
89
|
+
if (previousProfile === undefined) delete process.env.USERPROFILE; else process.env.USERPROFILE = previousProfile;
|
|
90
|
+
rmSync(root, { recursive: true, force: true });
|
|
91
|
+
});
|
|
92
|
+
// Node resolves the home from USERPROFILE on Windows and HOME on POSIX.
|
|
86
93
|
process.env.HOME = home;
|
|
87
|
-
|
|
94
|
+
process.env.USERPROFILE = home;
|
|
95
|
+
assert.equal(realpathSync(homedir()), home, "the protected home must be the isolated fixture");
|
|
88
96
|
let calls = 0;
|
|
89
97
|
const native = { reviewMode: async () => ({ operation: "status", status: { effective: "on", source: "global", global: "on", cloneLocal: "" } }), targetStatus: async () => { calls++; throw new Error("protected native invocation"); } } as unknown as NativeReviewCli;
|
|
90
98
|
for (const target of [home, alias, "/", join(home, ".ssh")]) {
|
|
@@ -42,16 +42,26 @@ test("invalid or unreadable preference files read as default and are never overw
|
|
|
42
42
|
const dir = home();
|
|
43
43
|
mkdirSync(join(dir, "card-content.json"));
|
|
44
44
|
assert.equal(resolveCardContent({ gentlePiConfigHome: dir }).malformed, true, "a directory in its place is unreadable, not missing");
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
45
|
+
assert.throws(() => writeCardContent(CARD_CONTENT.MINIMAL, { gentlePiConfigHome: dir }), /Cannot update malformed or unreadable card content preference/);
|
|
46
|
+
assert.deepEqual(readdirSync(dir), ["card-content.json"], "the rejected write must not create temporary files");
|
|
47
|
+
assert.deepEqual(readdirSync(join(dir, "card-content.json")), [], "the unreadable directory is preserved");
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
// chmod cannot deny reads on Windows; retain the portable directory case above.
|
|
51
|
+
test("POSIX unreadable preference files read as default and are never overwritten", {
|
|
52
|
+
skip: process.platform === "win32" || process.getuid?.() === 0 ? "requires POSIX permissions and a non-root user" : false,
|
|
53
|
+
}, () => {
|
|
54
|
+
const locked = home();
|
|
55
|
+
writeCardContent(CARD_CONTENT.MINIMAL, { gentlePiConfigHome: locked });
|
|
56
|
+
chmodSync(join(locked, "card-content.json"), 0o000);
|
|
57
|
+
try {
|
|
58
|
+
assert.deepEqual({ ...resolveCardContent({ gentlePiConfigHome: locked }), globalFile: "" }, { content: "default", source: "global_file", malformed: true, globalFile: "" });
|
|
59
|
+
assert.throws(() => writeCardContent(CARD_CONTENT.DEFAULT, { gentlePiConfigHome: locked }), /Cannot update malformed or unreadable card content preference/);
|
|
60
|
+
} finally {
|
|
61
|
+
chmodSync(join(locked, "card-content.json"), 0o600);
|
|
54
62
|
}
|
|
63
|
+
assert.equal(resolveCardContent({ gentlePiConfigHome: locked }).content, CARD_CONTENT.MINIMAL, "the rejected write preserves the stored preference");
|
|
64
|
+
assert.deepEqual(readdirSync(locked), ["card-content.json"], "the rejected write leaves no temporary files");
|
|
55
65
|
});
|
|
56
66
|
|
|
57
67
|
test("the live slot shares one content level per process and an unset slot reads default", (t) => {
|