@saadnvd1/agent-os 0.3.664 → 0.3.671
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/app/api/audits/[kind]/[id]/route.test.ts +2 -2
- package/app/api/mcp-auth/callback/route.test.ts +42 -0
- package/app/api/mcp-auth/callback/route.ts +56 -0
- package/app/api/projects/init/route.test.ts +2 -4
- package/app/api/sessions/[id]/mcp-auth/route.test.ts +86 -0
- package/app/api/sessions/[id]/mcp-auth/route.ts +58 -0
- package/app/api/shares/route.test.ts +3 -4
- package/app/api/workspaces/[id]/route.ts +9 -1
- package/app/api/worktree-sweep/route.test.ts +21 -0
- package/app/api/worktree-sweep/route.ts +24 -4
- package/components/Chat/ChatPanel.tsx +6 -3
- package/components/Chat/McpServers.tsx +61 -24
- package/components/Chat/McpSignIn.tsx +122 -0
- package/components/Chat/session.ts +8 -0
- package/components/Health/SweepRuns.tsx +1 -0
- package/components/Workspaces/WorkspaceSettings.tsx +6 -0
- package/data/chat/mcpAuth.ts +42 -0
- package/data/workspaces/index.ts +6 -0
- package/lib/chat/driver.ts +24 -2
- package/lib/chat/drivers/claude-approvals.ts +7 -0
- package/lib/chat/drivers/claude-mcp-auth.ts +82 -0
- package/lib/chat/drivers/claude.ts +7 -0
- package/lib/chat/events.ts +12 -0
- package/lib/chat/mcp-auth.ts +193 -0
- package/lib/chat/registry.ts +2 -0
- package/lib/chat/runner.ts +18 -0
- package/lib/chat/worker/client.ts +35 -1
- package/lib/chat/worker/host.ts +9 -0
- package/lib/chat/worker/main.ts +1 -1
- package/lib/chat/worker/mcp-auth.ts +119 -0
- package/lib/chat/worker/protocol.ts +19 -0
- package/lib/db/migrations/20261018T1730_mcp_sign_ins.ts +22 -0
- package/lib/db/migrations/20261018T2100_orch_full_access.ts +16 -0
- package/lib/db/types.ts +3 -0
- package/lib/done/testing.ts +2 -2
- package/lib/env-setup.ts +8 -1
- package/lib/health/boots.ts +2 -8
- package/lib/orchestrator/armed.test-helpers.ts +3 -2
- package/lib/orchestrator/brief.ts +14 -9
- package/lib/orchestrator/external-gates.ts +62 -1
- package/lib/orchestrator/full-access.ts +21 -0
- package/lib/orchestrator/quick.ts +47 -4
- package/lib/orchestrator/serve.ts +11 -2
- package/lib/orchestrator/shell-guard.ts +23 -1
- package/lib/orchestrator/signoff.test-helpers.ts +3 -2
- package/lib/orchestrator/sweep-arm.ts +180 -0
- package/lib/orchestrator/tool-names.ts +4 -1
- package/lib/orchestrator/tool-schemas.ts +33 -1
- package/lib/orchestrator/tools.ts +6 -2
- package/lib/orchestrator/write-guard.ts +34 -7
- package/lib/project-config/brief.ts +3 -1
- package/lib/project-config/database.ts +6 -0
- package/lib/project-config/index.ts +108 -21
- package/lib/project-config/schema.ts +10 -4
- package/lib/project-config/session.ts +9 -6
- package/lib/security/net.ts +7 -4
- package/lib/tasks/move-testing.ts +2 -2
- package/lib/test-temp.ts +30 -0
- package/lib/workspaces.ts +6 -2
- package/lib/worktree-sweep/caches.ts +6 -0
- package/lib/worktree-sweep/extras.ts +16 -8
- package/lib/worktree-sweep/report.ts +1 -0
- package/lib/worktree-sweep/settings.ts +2 -2
- package/lib/worktree-sweep/testTemp.ts +113 -0
- package/lib/worktree-sweep/types.ts +2 -0
- package/package.json +1 -1
- package/scripts/autodeploy.test.ts +2 -2
- package/scripts/check-bundle.test.ts +2 -2
- package/scripts/hooks/banned-terms.test.ts +3 -2
- package/scripts/hooks/staged-secrets.test.ts +2 -2
- package/scripts/lib/build-swap.test.ts +2 -3
- package/scripts/lib/wsl.test.ts +2 -3
- package/scripts/update-run.test.ts +2 -2
|
@@ -25,13 +25,16 @@ import { expandHome, forgetPR, prFor, projectPathFor } from "../tasks/session";
|
|
|
25
25
|
import { inBackground } from "../lumifyhub/task-cards";
|
|
26
26
|
import type { CheckRow } from "./checks";
|
|
27
27
|
import { getOrchestrator } from "./home";
|
|
28
|
+
import { fullAccessOn } from "./full-access";
|
|
29
|
+
import { setupWorktree } from "../env-setup";
|
|
28
30
|
import { addNote } from "./notes";
|
|
29
31
|
import { quickWorktrees } from "./quick-store";
|
|
30
32
|
import { findWorkspaceSession } from "./read";
|
|
31
33
|
import { workspaceProject } from "./targets";
|
|
32
34
|
import { recordPr, setTask } from "../db/session-parts";
|
|
33
35
|
|
|
34
|
-
// Open quick changes one orchestrator may hold at once
|
|
36
|
+
// Open quick changes one orchestrator may hold at once, unless its workspace
|
|
37
|
+
// has Orchestrator full access on (the default).
|
|
35
38
|
export const MAX_OPEN_QUICK = 5;
|
|
36
39
|
|
|
37
40
|
// Git with no hooks: a hook is a script in the project (or one it calls,
|
|
@@ -62,7 +65,13 @@ export async function startQuickChange(
|
|
|
62
65
|
workspaceId: string,
|
|
63
66
|
projectRef: string,
|
|
64
67
|
name: string
|
|
65
|
-
): Promise<{
|
|
68
|
+
): Promise<{
|
|
69
|
+
id: string;
|
|
70
|
+
worktreePath: string;
|
|
71
|
+
text: string;
|
|
72
|
+
// Dependencies and the project's setup, running in the background.
|
|
73
|
+
setup: Promise<void>;
|
|
74
|
+
}> {
|
|
66
75
|
const owner = ownerOf(workspaceId);
|
|
67
76
|
const project = workspaceProject(workspaceId, projectRef);
|
|
68
77
|
if (isRemoteHost(project.host_id))
|
|
@@ -70,7 +79,7 @@ export async function startQuickChange(
|
|
|
70
79
|
`${project.name} runs on another machine; quick changes are made on this one only`
|
|
71
80
|
);
|
|
72
81
|
const open = quickWorktrees(owner.id);
|
|
73
|
-
if (open.length >= MAX_OPEN_QUICK)
|
|
82
|
+
if (!fullAccessOn(workspaceId) && open.length >= MAX_OPEN_QUICK)
|
|
74
83
|
throw new Error(
|
|
75
84
|
`You have ${open.length} quick changes open (${MAX_OPEN_QUICK} at most): finish one with quick_pr, or drop it, first`
|
|
76
85
|
);
|
|
@@ -122,13 +131,47 @@ export async function startQuickChange(
|
|
|
122
131
|
workspaceId,
|
|
123
132
|
`Quick change "${label}" in ${project.name} on ${wt.branchName}.`
|
|
124
133
|
);
|
|
134
|
+
const full = fullAccessOn(workspaceId);
|
|
135
|
+
const setup = installDependencies(
|
|
136
|
+
workspaceId,
|
|
137
|
+
label,
|
|
138
|
+
wt.worktreePath,
|
|
139
|
+
projectPath
|
|
140
|
+
);
|
|
141
|
+
inBackground(`setup of quick change ${id}`, () => setup);
|
|
125
142
|
return {
|
|
126
143
|
id,
|
|
127
144
|
worktreePath: wt.worktreePath,
|
|
128
|
-
|
|
145
|
+
setup,
|
|
146
|
+
text: `Quick change "${label}" (id ${id.slice(0, 8)}) in ${project.name}: worktree ${wt.worktreePath} on ${wt.branchName}, cut from ${baseBranch}. Dependencies are installing in the background (a note says when they're ready). Edit files there with Write and Edit; ${full ? "run any git there" : "git there only as `git -C " + wt.worktreePath + " <status|diff|add|...>`"}. Then quick_pr commits, pushes and opens its PR.`,
|
|
129
147
|
};
|
|
130
148
|
}
|
|
131
149
|
|
|
150
|
+
// The project's own worktree setup (dependencies, env files), so tests and
|
|
151
|
+
// the build run in the quick change. One note when it ends.
|
|
152
|
+
async function installDependencies(
|
|
153
|
+
workspaceId: string,
|
|
154
|
+
label: string,
|
|
155
|
+
worktreePath: string,
|
|
156
|
+
projectPath: string
|
|
157
|
+
): Promise<void> {
|
|
158
|
+
try {
|
|
159
|
+
const r = await setupWorktree({ worktreePath, sourcePath: projectPath });
|
|
160
|
+
const failed = r.steps.find((s) => !s.success);
|
|
161
|
+
addNote(
|
|
162
|
+
workspaceId,
|
|
163
|
+
failed
|
|
164
|
+
? `Quick change "${label}": setup failed at ${failed.name}: ${failed.error ?? "see its worktree"}.`
|
|
165
|
+
: `Quick change "${label}": dependencies installed, ready to build and test.`
|
|
166
|
+
);
|
|
167
|
+
} catch (e) {
|
|
168
|
+
addNote(
|
|
169
|
+
workspaceId,
|
|
170
|
+
`Quick change "${label}": setup failed: ${(e as Error).message}.`
|
|
171
|
+
);
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
132
175
|
// A quick change of this workspace's orchestrator, by name, id or PR.
|
|
133
176
|
export function workspaceQuick(workspaceId: string, ref: string): Session {
|
|
134
177
|
const owner = ownerOf(workspaceId);
|
|
@@ -31,6 +31,7 @@ import { addRule, removeRule } from "./rules";
|
|
|
31
31
|
import { askSaad } from "./ask-tool";
|
|
32
32
|
import { askQuestion, resolveQuestion } from "./questions";
|
|
33
33
|
import { listOrchestratorPeers, messageOrchestrator } from "./cross";
|
|
34
|
+
import { sweepArm, sweepDryRun } from "./sweep-arm";
|
|
34
35
|
import { PAUSED_REFUSAL } from "./pause";
|
|
35
36
|
import { readSession } from "./read";
|
|
36
37
|
import { reviewTarget, signOffTarget } from "./external-gates";
|
|
@@ -216,6 +217,12 @@ export async function runTool(
|
|
|
216
217
|
const a = p(tool, raw);
|
|
217
218
|
return moveTaskTo(w, a.task, a.machine);
|
|
218
219
|
}
|
|
220
|
+
case "sweep_dry_run":
|
|
221
|
+
return sweepDryRun(p(tool, raw).machine);
|
|
222
|
+
case "sweep_arm": {
|
|
223
|
+
const a = p(tool, raw);
|
|
224
|
+
return sweepArm(w, a.machine, a.on, a.reason);
|
|
225
|
+
}
|
|
219
226
|
case "stop":
|
|
220
227
|
return stop(w, p(tool, raw).session);
|
|
221
228
|
case "done":
|
|
@@ -265,8 +272,10 @@ export async function runTool(
|
|
|
265
272
|
const a = p(tool, raw);
|
|
266
273
|
return reviewTarget(w, a.target, { fresh: a.fresh });
|
|
267
274
|
}
|
|
268
|
-
case "sign_off":
|
|
269
|
-
|
|
275
|
+
case "sign_off": {
|
|
276
|
+
const a = p(tool, raw);
|
|
277
|
+
return signOffTarget(w, a.task, { direct: a.direct, reason: a.reason });
|
|
278
|
+
}
|
|
270
279
|
case "arm":
|
|
271
280
|
return arm(w, p(tool, raw).task);
|
|
272
281
|
case "disarm":
|
|
@@ -230,7 +230,7 @@ function aosRefusal(args: string[]): string | null {
|
|
|
230
230
|
stop: T.stop,
|
|
231
231
|
};
|
|
232
232
|
if (sub === "sweep" && args.includes("--arm"))
|
|
233
|
-
return
|
|
233
|
+
return `Refused: \`aos sweep --arm\` turns on removing files and databases, which is Saad's call: Health, Clean-up, Turn on. When he has said to, use ${T.sweep_arm} (after ${T.sweep_dry_run}). \`--dry-run\` and \`--disarm\` are fine.`;
|
|
234
234
|
if (sub && tool[sub])
|
|
235
235
|
return `Refused: \`aos ${sub}\` isn't for you; use ${tool[sub]}, which is scoped to this workspace and held by Pause.`;
|
|
236
236
|
return null;
|
|
@@ -617,3 +617,25 @@ export async function shellRefusal(
|
|
|
617
617
|
}
|
|
618
618
|
return null;
|
|
619
619
|
}
|
|
620
|
+
|
|
621
|
+
// The one thing full access still flags: a command that looks like it stops
|
|
622
|
+
// or restarts AgentOS or its tmux, which ends every session it runs, this one
|
|
623
|
+
// included. A warning, never a refusal; null for anything else.
|
|
624
|
+
export async function shellWarning(
|
|
625
|
+
command: unknown,
|
|
626
|
+
ctx: ShellContext
|
|
627
|
+
): Promise<string | null> {
|
|
628
|
+
if (typeof command !== "string") return null;
|
|
629
|
+
const parsed = parseShell(command);
|
|
630
|
+
if (typeof parsed === "string") return null;
|
|
631
|
+
for (const cmd of parsed) {
|
|
632
|
+
const invs = invocations(cmd);
|
|
633
|
+
if (typeof invs === "string") continue;
|
|
634
|
+
for (const inv of invs) {
|
|
635
|
+
const why = await stopRefusal(inv, ctx).catch(() => null);
|
|
636
|
+
if (why)
|
|
637
|
+
return `Warning: ${why.replace(/^Refused: /, "")} It will run anyway.`;
|
|
638
|
+
}
|
|
639
|
+
}
|
|
640
|
+
return null;
|
|
641
|
+
}
|
|
@@ -6,6 +6,7 @@ import { beforeAll, beforeEach, expect, vi } from "vitest";
|
|
|
6
6
|
import type { TaskPR } from "@/lib/tasks/state";
|
|
7
7
|
import type { ClaudeRun } from "./claude-cli";
|
|
8
8
|
import type { SessionFacts } from "./facts";
|
|
9
|
+
import { tempDir } from "@/lib/test-temp";
|
|
9
10
|
|
|
10
11
|
// Shared fixtures for the signoff*.test.ts files: a real repository with a
|
|
11
12
|
// local bare remote, gh faked around it. Import this before anything it mocks.
|
|
@@ -162,7 +163,7 @@ export const askUp = async (
|
|
|
162
163
|
export const POLL = { timeout: 20000, interval: 20 };
|
|
163
164
|
|
|
164
165
|
beforeAll(() => {
|
|
165
|
-
const home =
|
|
166
|
+
const home = tempDir("aos-orch-signoff-");
|
|
166
167
|
vi.spyOn(os, "homedir").mockReturnValue(home);
|
|
167
168
|
});
|
|
168
169
|
beforeEach(() => {
|
|
@@ -212,7 +213,7 @@ export function setup(
|
|
|
212
213
|
} = {}
|
|
213
214
|
) {
|
|
214
215
|
setMergeApprovals(opts.approvals ?? false);
|
|
215
|
-
const root =
|
|
216
|
+
const root = tempDir("aos-orch-repo-");
|
|
216
217
|
const remote = path.join(root, "remote.git");
|
|
217
218
|
const repo = path.join(root, "repo");
|
|
218
219
|
git(root, "init", "-q", "--bare", "-b", "main", remote);
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The orchestrator's way to turn the full disk clean-up on or off, for this
|
|
3
|
+
* machine or a linked one. Turning it on is Saad's call, so the tool says it's
|
|
4
|
+
* for when he has said to, wants the latest dry run of that machine from the
|
|
5
|
+
* last hour (what it would remove is what he agreed to), and logs who and
|
|
6
|
+
* the dry run's totals. A linked machine is armed over ssh to its own
|
|
7
|
+
* loopback, where its AgentOS trusts the caller: its arm endpoint refuses a
|
|
8
|
+
* device token on purpose.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { getHost, hostExec, hostIdNamed, isRemoteHost } from "../hosts";
|
|
12
|
+
import { remotePort } from "../hosts/link";
|
|
13
|
+
import {
|
|
14
|
+
cleanRemoteText,
|
|
15
|
+
hostApi,
|
|
16
|
+
requireHostLink,
|
|
17
|
+
type HostLink,
|
|
18
|
+
} from "../hosts/remote-api";
|
|
19
|
+
import { fromSqliteTime } from "../session-meta";
|
|
20
|
+
import { setReaperArmed } from "../worktree-sweep/settings";
|
|
21
|
+
import { sweepNow } from "../worktree-sweep/start";
|
|
22
|
+
import { sweepState, type SweepState } from "../worktree-sweep/state";
|
|
23
|
+
import type { SweepRun } from "../worktree-sweep/types";
|
|
24
|
+
import { addNote } from "./notes";
|
|
25
|
+
import { PAUSED_REFUSAL } from "./pause";
|
|
26
|
+
import { getWorkspace } from "../workspaces";
|
|
27
|
+
|
|
28
|
+
export const DRY_RUN_FRESH_MS = 60 * 60 * 1000;
|
|
29
|
+
|
|
30
|
+
const TOOL_DRY = "sweep_dry_run";
|
|
31
|
+
const GB = (b: number) => (b / 1024 ** 3).toFixed(2) + " GB";
|
|
32
|
+
|
|
33
|
+
type Verdict = { ok: true; run: SweepRun } | { ok: false; why: string };
|
|
34
|
+
|
|
35
|
+
// The newest dry run decides: an older fresh one doesn't stand in for it.
|
|
36
|
+
export function dryRunVerdict(
|
|
37
|
+
runs: SweepRun[],
|
|
38
|
+
where: string,
|
|
39
|
+
now = Date.now()
|
|
40
|
+
): Verdict {
|
|
41
|
+
const next = `Run ${TOOL_DRY} for ${where} first (it takes a few minutes), then call sweep_arm again.`;
|
|
42
|
+
const run = runs.find((r) => r.dryRun);
|
|
43
|
+
if (!run) return { ok: false, why: `${where} has no dry run. ${next}` };
|
|
44
|
+
if (!run.finishedAt)
|
|
45
|
+
return {
|
|
46
|
+
ok: false,
|
|
47
|
+
why: `The dry run on ${where} is still going. Wait for it, then call sweep_arm again.`,
|
|
48
|
+
};
|
|
49
|
+
if (run.error)
|
|
50
|
+
return {
|
|
51
|
+
ok: false,
|
|
52
|
+
why: `The latest dry run on ${where} failed (${run.error}). ${next}`,
|
|
53
|
+
};
|
|
54
|
+
const age = now - fromSqliteTime(run.finishedAt).getTime();
|
|
55
|
+
if (age > DRY_RUN_FRESH_MS)
|
|
56
|
+
return {
|
|
57
|
+
ok: false,
|
|
58
|
+
why: `The latest dry run on ${where} is ${Math.round(age / 60000)} minutes old; it must be from the last hour. ${next}`,
|
|
59
|
+
};
|
|
60
|
+
return { ok: true, run };
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export function totalsLine(run: SweepRun, now = Date.now()): string {
|
|
64
|
+
const d = run.details;
|
|
65
|
+
const mins = run.finishedAt
|
|
66
|
+
? Math.max(
|
|
67
|
+
0,
|
|
68
|
+
Math.round((now - fromSqliteTime(run.finishedAt).getTime()) / 60000)
|
|
69
|
+
)
|
|
70
|
+
: 0;
|
|
71
|
+
return (
|
|
72
|
+
`dry run #${run.id} (${mins} min ago): would remove ${run.removed} (${GB(run.freedBytes)}), ` +
|
|
73
|
+
`${d?.pendingTotal?.count ?? 0} (${GB(d?.pendingTotal?.bytes ?? 0)}) waiting for the full clean-up, ${run.skipped} kept` +
|
|
74
|
+
(d?.report ? `; list: ${cleanRemoteText(d.report)}` : "")
|
|
75
|
+
);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
interface Machine {
|
|
79
|
+
hostId: string;
|
|
80
|
+
name: string;
|
|
81
|
+
link: HostLink | null;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function machineOf(ref: string): Machine {
|
|
85
|
+
const hostId = hostIdNamed(ref);
|
|
86
|
+
if (!isRemoteHost(hostId))
|
|
87
|
+
return { hostId, name: "this machine", link: null };
|
|
88
|
+
return {
|
|
89
|
+
hostId,
|
|
90
|
+
name: getHost(hostId)?.name ?? ref,
|
|
91
|
+
link: requireHostLink(hostId),
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// Another machine's numbers, read defensively.
|
|
96
|
+
function clean(state: SweepState): SweepState {
|
|
97
|
+
const num = (n: unknown) => (Number.isFinite(Number(n)) ? Number(n) : 0);
|
|
98
|
+
return {
|
|
99
|
+
...state,
|
|
100
|
+
armed: state.armed === true,
|
|
101
|
+
runs: (Array.isArray(state.runs) ? state.runs : []).map((r) => ({
|
|
102
|
+
...r,
|
|
103
|
+
id: num(r.id),
|
|
104
|
+
dryRun: r.dryRun === true,
|
|
105
|
+
removed: num(r.removed),
|
|
106
|
+
freedBytes: num(r.freedBytes),
|
|
107
|
+
skipped: num(r.skipped),
|
|
108
|
+
error: r.error ? cleanRemoteText(r.error) : null,
|
|
109
|
+
})),
|
|
110
|
+
};
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
const readState = async (m: Machine): Promise<SweepState> =>
|
|
114
|
+
m.link
|
|
115
|
+
? clean(await hostApi<SweepState>(m.link, "/api/worktree-sweep"))
|
|
116
|
+
: sweepState();
|
|
117
|
+
|
|
118
|
+
async function setArmed(m: Machine, armed: boolean): Promise<void> {
|
|
119
|
+
if (!m.link) return setReaperArmed(armed);
|
|
120
|
+
if (!armed) {
|
|
121
|
+
await hostApi(m.link, "/api/worktree-sweep", {
|
|
122
|
+
method: "PUT",
|
|
123
|
+
body: { armed: false },
|
|
124
|
+
});
|
|
125
|
+
return;
|
|
126
|
+
}
|
|
127
|
+
const port = remotePort(m.link.url);
|
|
128
|
+
await hostExec(
|
|
129
|
+
m.hostId,
|
|
130
|
+
`curl -fsS -m 10 -X PUT -H 'Content-Type: application/json' -d '{"armed":true}' http://127.0.0.1:${port}/api/worktree-sweep`,
|
|
131
|
+
20000
|
|
132
|
+
).catch((err) => {
|
|
133
|
+
throw new Error(
|
|
134
|
+
`Couldn't turn it on for ${m.name}: ${cleanRemoteText(err instanceof Error ? err.message : err)}`
|
|
135
|
+
);
|
|
136
|
+
});
|
|
137
|
+
if (!(await readState(m)).armed)
|
|
138
|
+
throw new Error(`${m.name} did not turn the full clean-up on`);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
export async function sweepArm(
|
|
142
|
+
workspaceId: string,
|
|
143
|
+
machine: string,
|
|
144
|
+
on: boolean,
|
|
145
|
+
reason: string
|
|
146
|
+
): Promise<string> {
|
|
147
|
+
const m = machineOf(machine);
|
|
148
|
+
const state = await readState(m);
|
|
149
|
+
const latest = state.runs.find((r) => r.dryRun && r.finishedAt && !r.error);
|
|
150
|
+
if (state.armed === on)
|
|
151
|
+
return `The full clean-up is already ${on ? "on" : "off"} for ${m.name}.`;
|
|
152
|
+
if (on) {
|
|
153
|
+
// Turning it off only makes things safer, so Pause doesn't hold it.
|
|
154
|
+
if (getWorkspace(workspaceId)?.orch_paused_at)
|
|
155
|
+
throw new Error(PAUSED_REFUSAL);
|
|
156
|
+
const verdict = dryRunVerdict(state.runs, m.name);
|
|
157
|
+
if (!verdict.ok) throw new Error(verdict.why);
|
|
158
|
+
}
|
|
159
|
+
await setArmed(m, on);
|
|
160
|
+
const totals = latest ? ` Latest ${totalsLine(latest)}.` : "";
|
|
161
|
+
const line = `Orchestrator turned the full disk clean-up ${on ? "on" : "off"} for ${m.name}: ${reason}.${totals}`;
|
|
162
|
+
addNote(workspaceId, line);
|
|
163
|
+
return line;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
export async function sweepDryRun(machine: string): Promise<string> {
|
|
167
|
+
const m = machineOf(machine);
|
|
168
|
+
const started = `Dry run started on ${m.name}; it takes a few minutes and removes nothing. When Saad has said to turn the full clean-up on, call sweep_arm: it reads this run.`;
|
|
169
|
+
if (m.link) {
|
|
170
|
+
await hostApi(m.link, "/api/worktree-sweep/run", {
|
|
171
|
+
body: { dryRun: true },
|
|
172
|
+
});
|
|
173
|
+
return started;
|
|
174
|
+
}
|
|
175
|
+
await new Promise<void>((resolve, reject) => {
|
|
176
|
+
sweepNow(true).then(() => resolve(), reject);
|
|
177
|
+
setTimeout(resolve, 250);
|
|
178
|
+
});
|
|
179
|
+
return started;
|
|
180
|
+
}
|
|
@@ -29,6 +29,8 @@ export const TOOL_NAMES = {
|
|
|
29
29
|
plan_pieces: name("plan_pieces"),
|
|
30
30
|
drop: name("drop"),
|
|
31
31
|
move: name("move"),
|
|
32
|
+
sweep_dry_run: name("sweep_dry_run"),
|
|
33
|
+
sweep_arm: name("sweep_arm"),
|
|
32
34
|
set_priority: name("set_priority"),
|
|
33
35
|
start_now: name("start_now"),
|
|
34
36
|
pause_task: name("pause_task"),
|
|
@@ -67,7 +69,8 @@ export const TOOL_NAMES = {
|
|
|
67
69
|
// task worktree other than its own quick changes, writing AgentOS's state,
|
|
68
70
|
// stopping AgentOS or its tmux, and reading secrets. That catches honest
|
|
69
71
|
// mistakes, not a determined bypass. Its file writes are checked the same
|
|
70
|
-
// way, against a longer list (write-guard.ts).
|
|
72
|
+
// way, against a longer list (write-guard.ts). All of that applies only with
|
|
73
|
+
// the workspace's Orchestrator full access off (full-access.ts).
|
|
71
74
|
export const ORCHESTRATOR_PERMISSIONS: {
|
|
72
75
|
permissionMode: PermissionMode;
|
|
73
76
|
allowedTools: string[];
|
|
@@ -21,6 +21,13 @@ const view = z
|
|
|
21
21
|
"How its agent runs: chat (the default), or terminal only when the job needs a TUI (an interactive program, a full-screen tool)"
|
|
22
22
|
);
|
|
23
23
|
|
|
24
|
+
const sweepMachine = z
|
|
25
|
+
.string()
|
|
26
|
+
.trim()
|
|
27
|
+
.min(1)
|
|
28
|
+
.max(200)
|
|
29
|
+
.describe('A linked machine\'s name (see Machines), or "here" for this one');
|
|
30
|
+
|
|
24
31
|
const modelRef = z
|
|
25
32
|
.string()
|
|
26
33
|
.trim()
|
|
@@ -268,6 +275,17 @@ export const TOOL_SHAPES = {
|
|
|
268
275
|
'A linked machine\'s name (see Machines), or "here" to bring the task back'
|
|
269
276
|
),
|
|
270
277
|
},
|
|
278
|
+
sweep_dry_run: { machine: sweepMachine },
|
|
279
|
+
sweep_arm: {
|
|
280
|
+
machine: sweepMachine,
|
|
281
|
+
on: z.boolean().describe("true turns the full clean-up on, false off"),
|
|
282
|
+
reason: z
|
|
283
|
+
.string()
|
|
284
|
+
.trim()
|
|
285
|
+
.min(1)
|
|
286
|
+
.max(500)
|
|
287
|
+
.describe("What Saad said, and when; it goes in the decision log"),
|
|
288
|
+
},
|
|
271
289
|
set_priority: {
|
|
272
290
|
task: ref("A queued or running task: name, project/name or id"),
|
|
273
291
|
priority: z.enum(PRIORITIES),
|
|
@@ -409,7 +427,21 @@ export const TOOL_SHAPES = {
|
|
|
409
427
|
.optional()
|
|
410
428
|
.describe("Run it again even if this commit has a verdict"),
|
|
411
429
|
},
|
|
412
|
-
sign_off: {
|
|
430
|
+
sign_off: {
|
|
431
|
+
task,
|
|
432
|
+
direct: z
|
|
433
|
+
.boolean()
|
|
434
|
+
.optional()
|
|
435
|
+
.describe(
|
|
436
|
+
"Merge now at the PR's head without the gates; needs Orchestrator full access and a reason"
|
|
437
|
+
),
|
|
438
|
+
reason: z
|
|
439
|
+
.string()
|
|
440
|
+
.trim()
|
|
441
|
+
.max(300)
|
|
442
|
+
.optional()
|
|
443
|
+
.describe("Why the gates are skipped (required with direct)"),
|
|
444
|
+
},
|
|
413
445
|
arm: { task },
|
|
414
446
|
disarm: { task },
|
|
415
447
|
ask_saad: {
|
|
@@ -132,7 +132,7 @@ const DESCRIPTIONS: Record<ToolName, string> = {
|
|
|
132
132
|
start_session:
|
|
133
133
|
'Start an agent session in a project with a prompt, for work that isn\'t a PR task. It runs on the implementation model unless you give model (one "provider/model"; Opus only when the work needs deep reasoning), as a chat unless view is terminal (only for a job that needs a TUI). Refused while a brake holds.',
|
|
134
134
|
quick_change:
|
|
135
|
-
"Make a
|
|
135
|
+
"Make a change yourself (without full access, a small low-risk one: a typo, a config value, a one-file fix): cuts a worktree and branch from the project's default branch and returns its path. Its dependencies install in the background (a note says when). You may write there (Write, Edit) and, with Orchestrator full access, run any git there; without it, only `git -C <path> ...` status, diff, add, rm, mv, restore, reset, log, show, and nowhere else in a project. Without full access, anything needing real work, tests or an investigation is a task (start_task).",
|
|
136
136
|
quick_pr:
|
|
137
137
|
"Commit everything in a quick change's worktree, push its branch (never the default branch) and open its PR, or push to the PR it already has. The PR goes through the same gates as a task's; with no task agent to run /do-code-review, its code-review gate is the independent review of its exact head, which starts now.",
|
|
138
138
|
stack:
|
|
@@ -145,6 +145,10 @@ const DESCRIPTIONS: Record<ToolName, string> = {
|
|
|
145
145
|
"A task whose plan split into pieces: each piece, its repo, what it waits on and its state. With merge or drop (position numbers) it edits the pieces still waiting: a merged piece's slice goes to the piece before it, a dropped one is left out and anything waiting on it is held. It can't add pieces or change reasons.",
|
|
146
146
|
drop: "Reject a task: close its PR and remove its worktree. A task still waiting in the queue is removed by its id. Logged with the reason.",
|
|
147
147
|
move: 'Carry a running task to a linked machine, or back with "here": for heavy test or build work. It keeps its branch, conversation and plan; the gates, events, read, send, stop, done and drop work the same wherever it runs, and the machine it lands on can\'t merge it. Takes a few minutes and runs in the background; an event says when it lands. Refused while paused, for a task waiting on Saad (an escalation or open ask), and for card and stacked tasks.',
|
|
148
|
+
sweep_dry_run:
|
|
149
|
+
"Start a dry run of the disk clean-up on this machine or a linked one: it lists what the full clean-up would remove and removes nothing. Takes a few minutes; sweep_arm reads it.",
|
|
150
|
+
sweep_arm:
|
|
151
|
+
"Turn the full disk clean-up (test leftovers, stale task databases, spares, temp folders, build caches) on or off for this machine or a linked one. Only for when Saad has said to turn it on: put what he said in reason. Turning it on is refused unless that machine's latest dry run finished in the last hour (run sweep_dry_run first, and read its totals). Off is always allowed. Logged in the decision log with the dry run's totals.",
|
|
148
152
|
set_priority:
|
|
149
153
|
"Change the priority of a queued task (it re-ranks the line at once) or a running one (it only matters if it is paused and waits to resume). urgent starts first even on a busy machine; background starts last.",
|
|
150
154
|
start_now:
|
|
@@ -183,7 +187,7 @@ const DESCRIPTIONS: Record<ToolName, string> = {
|
|
|
183
187
|
review:
|
|
184
188
|
"Start an independent read-only review of a PR at its exact head commit, or read the stored verdict for that commit. Takes a task, or any open PR in a workspace repository by #N, owner/repo#N or URL, even one no task owns.",
|
|
185
189
|
sign_off:
|
|
186
|
-
"Merge a PR, with its project's merge method, through the gates: CI green and settled, a passing review of that commit, nothing blocked, in scope, stack parent merged. Takes a task, or any open PR in a workspace repository by #N, owner/repo#N or URL (an external PR). Refuses with the failing gate, or a repository outside the workspace.",
|
|
190
|
+
"Merge a PR, with its project's merge method, through the gates: CI green and settled, a passing review of that commit, nothing blocked, in scope, stack parent merged. Takes a task, or any open PR in a workspace repository by #N, owner/repo#N or URL (an external PR). Refuses with the failing gate, or a repository outside the workspace. With direct true (and a reason) it merges now at the PR's head without the gates; only with Orchestrator full access, and not while paused or while the task waits on Saad.",
|
|
187
191
|
arm: "Arm a task's PR to merge: AgentOS merges it, through the same gates as sign_off, the moment they all pass at its head, and tells you with one event. A gate failure comes as one event and it stays armed for the fix; one that goes to Saad disarms it. Survives restarts. Use it instead of calling sign_off until it passes.",
|
|
188
192
|
disarm:
|
|
189
193
|
"Disarm a task armed to merge: it merges only through sign_off again.",
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Where an orchestrator may write
|
|
2
|
+
* Where an orchestrator may write (unless its workspace has Orchestrator full
|
|
3
|
+
* access on, the default: orchestratorToolRefusal then checks nothing but
|
|
4
|
+
* warns about stopping AgentOS; full-access.ts). Anywhere on the machine except the
|
|
3
5
|
* workspace's project repositories and their task worktrees (code changes go
|
|
4
6
|
* through tasks, so the merge gates keep meaning something), AgentOS's own
|
|
5
7
|
* install and state (all of ~/.agent-os but its own folder), secret files,
|
|
@@ -18,13 +20,15 @@ import fs from "fs";
|
|
|
18
20
|
import os from "os";
|
|
19
21
|
import path from "path";
|
|
20
22
|
import { db, type Session } from "../db";
|
|
23
|
+
import type { ToolGuardResult } from "../chat/driver";
|
|
21
24
|
import { git, GitError } from "../git-exec";
|
|
22
25
|
import { isOrchestratorSecretPath as isSecretPath } from "../secret-paths";
|
|
23
26
|
import { WORKTREES_DIR } from "../worktrees";
|
|
24
27
|
import { orchestratorDir } from "./home";
|
|
25
28
|
import { LIMITS_FILE } from "./usage";
|
|
26
29
|
import { quickWorktrees } from "./quick-store";
|
|
27
|
-
import { shellRefusal } from "./shell-guard";
|
|
30
|
+
import { shellRefusal, shellWarning, type ShellContext } from "./shell-guard";
|
|
31
|
+
import { orchestratorFullAccess } from "./full-access";
|
|
28
32
|
import { liveCheckout } from "../dev-instance/liveServer";
|
|
29
33
|
|
|
30
34
|
// The tools that write a file, and the argument naming it.
|
|
@@ -366,24 +370,47 @@ export async function orchestratorToolRefusal(
|
|
|
366
370
|
session: Session,
|
|
367
371
|
tool: string,
|
|
368
372
|
input: Record<string, unknown>
|
|
369
|
-
): Promise<
|
|
370
|
-
|
|
373
|
+
): Promise<ToolGuardResult> {
|
|
374
|
+
const full = orchestratorFullAccess(session);
|
|
375
|
+
if (tool === "Bash")
|
|
376
|
+
return full
|
|
377
|
+
? commandWarning(session, input.command)
|
|
378
|
+
: commandRefusal(session, input.command);
|
|
371
379
|
const arg = WRITE_TOOLS[tool];
|
|
372
380
|
if (!arg) return null;
|
|
373
381
|
const filePath = input[arg];
|
|
382
|
+
if (full) return null;
|
|
374
383
|
if (typeof filePath !== "string") return "No file path to check.";
|
|
375
384
|
return writeRefusal(filePath, await orchestratorWriteBounds(session));
|
|
376
385
|
}
|
|
377
386
|
|
|
387
|
+
// With full access: only a warning for stopping AgentOS or its tmux.
|
|
388
|
+
async function commandWarning(
|
|
389
|
+
session: Session,
|
|
390
|
+
command: unknown
|
|
391
|
+
): Promise<ToolGuardResult> {
|
|
392
|
+
const warning = await shellWarning(
|
|
393
|
+
command,
|
|
394
|
+
shellContext(session, await orchestratorWriteBounds(session))
|
|
395
|
+
);
|
|
396
|
+
return warning ? { warning } : null;
|
|
397
|
+
}
|
|
398
|
+
|
|
378
399
|
// A shell command, against the deny list (shell-guard.ts), its writes held
|
|
379
400
|
// to the shell's share of the write bounds.
|
|
380
401
|
async function commandRefusal(
|
|
381
402
|
session: Session,
|
|
382
403
|
command: unknown
|
|
383
404
|
): Promise<string | null> {
|
|
384
|
-
|
|
405
|
+
return shellRefusal(
|
|
406
|
+
command,
|
|
407
|
+
shellContext(session, await orchestratorWriteBounds(session))
|
|
408
|
+
);
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
function shellContext(session: Session, bounds: WriteBounds): ShellContext {
|
|
385
412
|
const dbFile = agentOsDbPath();
|
|
386
|
-
return
|
|
413
|
+
return {
|
|
387
414
|
cwd: bounds.cwd,
|
|
388
415
|
writeRefusal: (p) => writeRefusal(p, bounds, { shell: true }),
|
|
389
416
|
isAgentOsState: async (p) => {
|
|
@@ -402,7 +429,7 @@ async function commandRefusal(
|
|
|
402
429
|
},
|
|
403
430
|
checkoutKind: (dir) => checkoutKind(session, dir),
|
|
404
431
|
protectedPids,
|
|
405
|
-
}
|
|
432
|
+
};
|
|
406
433
|
}
|
|
407
434
|
|
|
408
435
|
// Whether a folder is a project checkout of the session's own workspace
|
|
@@ -18,9 +18,11 @@ import { simulatorBrief } from "../simulator/brief";
|
|
|
18
18
|
export function runningBrief(
|
|
19
19
|
config: ProjectConfig,
|
|
20
20
|
ports: Record<string, number>,
|
|
21
|
-
database: SessionDatabase | null = null
|
|
21
|
+
database: SessionDatabase | null = null,
|
|
22
|
+
warning: string | null = null
|
|
22
23
|
): string {
|
|
23
24
|
const lines: string[] = [];
|
|
25
|
+
if (warning) lines.push(`- ${warning}`);
|
|
24
26
|
const names = Object.entries(ports);
|
|
25
27
|
if (names.length)
|
|
26
28
|
lines.push(
|
|
@@ -43,6 +43,12 @@ export type PgRun = (
|
|
|
43
43
|
|
|
44
44
|
export const pgRun: PgRun = (cmd, args, env) =>
|
|
45
45
|
new Promise((resolve) => {
|
|
46
|
+
// A test that reached the real server could list or drop the owner's
|
|
47
|
+
// databases: every test injects its own run.
|
|
48
|
+
if (process.env.VITEST)
|
|
49
|
+
throw new Error(
|
|
50
|
+
"Refusing to run the real Postgres tools under the test runner"
|
|
51
|
+
);
|
|
46
52
|
execFile(
|
|
47
53
|
cmd,
|
|
48
54
|
args,
|