@saadnvd1/agent-os 0.3.521 → 0.3.524
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 +57 -0
- package/app/api/sessions/[id]/simulator/route.ts +32 -0
- package/app/api/sessions/[id]/simulator/run/route.ts +37 -0
- package/components/Chat/BackgroundTasks.tsx +76 -10
- package/components/Chat/ChatPanel.tsx +90 -34
- package/components/Chat/Subagent.tsx +25 -20
- package/components/Chat/Tools.tsx +70 -39
- package/components/Chat/background.tsx +114 -0
- package/data/chat/useChat.ts +40 -0
- package/lib/chat/activity.ts +4 -2
- package/lib/chat/background.ts +77 -0
- package/lib/chat/driver.ts +6 -0
- package/lib/chat/drivers/claude-mapper.ts +44 -6
- package/lib/chat/drivers/claude.ts +4 -0
- package/lib/chat/events.ts +14 -0
- package/lib/chat/registry.ts +2 -0
- package/lib/chat/runner.ts +20 -0
- package/lib/chat/settings.ts +1 -0
- package/lib/chat/socket.ts +9 -0
- package/lib/chat/worker/client.ts +33 -0
- package/lib/chat/worker/host.ts +13 -0
- package/lib/chat/worker/main.ts +1 -1
- package/lib/chat/worker/protocol.ts +2 -0
- package/lib/db/migrations.ts +19 -0
- package/lib/project-config/agentos.schema.json +19 -0
- package/lib/project-config/brief.ts +3 -0
- package/lib/project-config/schema.ts +10 -0
- package/lib/security/auth.ts +11 -1
- package/lib/sessions/end.ts +3 -0
- package/lib/sessions/stop.ts +3 -0
- package/lib/simulator/agent-device.ts +258 -0
- package/lib/simulator/available.ts +5 -0
- package/lib/simulator/brief.ts +18 -0
- package/lib/simulator/http.ts +16 -0
- package/lib/simulator/index.ts +458 -0
- package/lib/simulator/lease.ts +169 -0
- package/lib/simulator/simctl.ts +167 -0
- package/package.json +1 -1
- package/server.ts +16 -0
package/lib/chat/settings.ts
CHANGED
|
@@ -75,6 +75,7 @@ export function emitCapabilities(session: Session, listener?: Listener): void {
|
|
|
75
75
|
model: chatModel(session),
|
|
76
76
|
access: session.chat_access,
|
|
77
77
|
plan: canPlan(session) ? !!session.chat_plan : null,
|
|
78
|
+
background: chatDriverFor(session.agent_type)?.backgroundTasks === true,
|
|
78
79
|
};
|
|
79
80
|
if (listener) listener(m);
|
|
80
81
|
else emit(session.id, m);
|
package/lib/chat/socket.ts
CHANGED
|
@@ -12,6 +12,7 @@ import {
|
|
|
12
12
|
carryOutPlan,
|
|
13
13
|
chatFileSuggestions,
|
|
14
14
|
chatHistory,
|
|
15
|
+
backgroundChatTask,
|
|
15
16
|
chatTaskOutput,
|
|
16
17
|
chatToolBody,
|
|
17
18
|
deleteQueuedChat,
|
|
@@ -183,6 +184,14 @@ function onChatMessage(
|
|
|
183
184
|
run(carryOutPlan(sessionId, String(msg.id)));
|
|
184
185
|
else if (msg.type === "respond") respondChat(sessionId, msg.id, msg);
|
|
185
186
|
else if (msg.type === "stop_task") stopChatTask(sessionId, msg.taskId);
|
|
187
|
+
else if (msg.type === "background_task")
|
|
188
|
+
run(
|
|
189
|
+
backgroundChatTask(
|
|
190
|
+
sessionId,
|
|
191
|
+
typeof msg.toolUseId === "string" ? msg.toolUseId : undefined,
|
|
192
|
+
reply
|
|
193
|
+
)
|
|
194
|
+
);
|
|
186
195
|
else if (msg.type === "task_output")
|
|
187
196
|
reply({
|
|
188
197
|
type: "task_output",
|
|
@@ -41,6 +41,11 @@ export class WorkerClient {
|
|
|
41
41
|
|
|
42
42
|
private files = new Map<string, (r: FileSuggestion[] | null) => void>();
|
|
43
43
|
|
|
44
|
+
private backgrounds = new Map<
|
|
45
|
+
string,
|
|
46
|
+
(r: { ok?: boolean; error?: string }) => void
|
|
47
|
+
>();
|
|
48
|
+
|
|
44
49
|
private constructor(
|
|
45
50
|
private socket: net.Socket,
|
|
46
51
|
private handlers: WorkerHandlers
|
|
@@ -55,6 +60,9 @@ export class WorkerClient {
|
|
|
55
60
|
this.undos.clear();
|
|
56
61
|
for (const f of this.files.values()) f(null);
|
|
57
62
|
this.files.clear();
|
|
63
|
+
for (const b of this.backgrounds.values())
|
|
64
|
+
b({ error: "The chat worker went away" });
|
|
65
|
+
this.backgrounds.clear();
|
|
58
66
|
handlers.onClose(this.detached);
|
|
59
67
|
});
|
|
60
68
|
socket.on("error", () => socket.destroy());
|
|
@@ -101,6 +109,11 @@ export class WorkerClient {
|
|
|
101
109
|
else u?.reject(new Error(e.error ?? "Undo failed"));
|
|
102
110
|
return;
|
|
103
111
|
}
|
|
112
|
+
if (e.type === "background_result") {
|
|
113
|
+
this.backgrounds.get(e.reqId)?.({ ok: e.ok, error: e.error });
|
|
114
|
+
this.backgrounds.delete(e.reqId);
|
|
115
|
+
return;
|
|
116
|
+
}
|
|
104
117
|
if (e.type === "files_result") {
|
|
105
118
|
this.files.get(e.reqId)?.(e.files ?? null);
|
|
106
119
|
this.files.delete(e.reqId);
|
|
@@ -121,6 +134,26 @@ export class WorkerClient {
|
|
|
121
134
|
});
|
|
122
135
|
}
|
|
123
136
|
|
|
137
|
+
// Moves a blocking call (or all of them) to the background: whether
|
|
138
|
+
// anything moved, or why not (an error, or no answer in time).
|
|
139
|
+
background(
|
|
140
|
+
toolUseId?: string,
|
|
141
|
+
timeoutMs = 5000
|
|
142
|
+
): Promise<{ ok?: boolean; error?: string }> {
|
|
143
|
+
const reqId = randomUUID();
|
|
144
|
+
return new Promise((resolve) => {
|
|
145
|
+
const timer = setTimeout(() => {
|
|
146
|
+
this.backgrounds.delete(reqId);
|
|
147
|
+
resolve({ error: "timeout" });
|
|
148
|
+
}, timeoutMs);
|
|
149
|
+
this.backgrounds.set(reqId, (r) => {
|
|
150
|
+
clearTimeout(timer);
|
|
151
|
+
resolve(r);
|
|
152
|
+
});
|
|
153
|
+
this.command({ type: "background_task", reqId, toolUseId });
|
|
154
|
+
});
|
|
155
|
+
}
|
|
156
|
+
|
|
124
157
|
// The agent's own matches for an @mention; null when it can't say (an
|
|
125
158
|
// older worker, an error, no answer in time).
|
|
126
159
|
fileSuggestions(
|
package/lib/chat/worker/host.ts
CHANGED
|
@@ -712,6 +712,19 @@ export class ChatHost {
|
|
|
712
712
|
return this.conversation.respond(cmd.id, cmd);
|
|
713
713
|
case "stop_task":
|
|
714
714
|
return this.conversation.stopTask(cmd.taskId);
|
|
715
|
+
case "background_task":
|
|
716
|
+
try {
|
|
717
|
+
if (!this.conversation.backgroundTask) throw new Error("unsupported");
|
|
718
|
+
const ok = await this.conversation.backgroundTask(cmd.toolUseId);
|
|
719
|
+
this.emit({ type: "background_result", reqId: cmd.reqId, ok });
|
|
720
|
+
} catch (error) {
|
|
721
|
+
this.emit({
|
|
722
|
+
type: "background_result",
|
|
723
|
+
reqId: cmd.reqId,
|
|
724
|
+
error: error instanceof Error ? error.message : String(error),
|
|
725
|
+
});
|
|
726
|
+
}
|
|
727
|
+
return;
|
|
715
728
|
case "undo":
|
|
716
729
|
try {
|
|
717
730
|
const result = await this.conversation.undo(
|
package/lib/chat/worker/main.ts
CHANGED
|
@@ -76,7 +76,7 @@ async function main(sessionId: string) {
|
|
|
76
76
|
state: host.state,
|
|
77
77
|
compacting: host.compactingNow,
|
|
78
78
|
streaming: [...host.streaming.values()],
|
|
79
|
-
caps: ["plan", "queue", "retire", "fresh"],
|
|
79
|
+
caps: ["plan", "queue", "retire", "fresh", "background"],
|
|
80
80
|
} satisfies WorkerEvent)}\n`
|
|
81
81
|
);
|
|
82
82
|
client.on(
|
|
@@ -55,6 +55,7 @@ export type WorkerCommand =
|
|
|
55
55
|
| ({ type: "respond"; id: string } & ApprovalDecision)
|
|
56
56
|
| { type: "undo"; reqId: string; checkpoint: string; dryRun: boolean }
|
|
57
57
|
| { type: "stop_task"; taskId: string }
|
|
58
|
+
| { type: "background_task"; reqId: string; toolUseId?: string }
|
|
58
59
|
// It runs an older build: it takes no new message into its agent (they
|
|
59
60
|
// wait in the queue) and closes at its next turn boundary, so a current
|
|
60
61
|
// worker resumes the conversation with today's tools.
|
|
@@ -99,6 +100,7 @@ export type WorkerEvent =
|
|
|
99
100
|
// The agent's guess at the next message, or null once it's stale.
|
|
100
101
|
| { type: "suggestion"; text: string | null }
|
|
101
102
|
| { type: "undo_result"; reqId: string; result?: UndoResult; error?: string }
|
|
103
|
+
| { type: "background_result"; reqId: string; ok?: boolean; error?: string }
|
|
102
104
|
| {
|
|
103
105
|
type: "files_result";
|
|
104
106
|
reqId: string;
|
package/lib/db/migrations.ts
CHANGED
|
@@ -1768,6 +1768,25 @@ const migrations: Migration[] = [
|
|
|
1768
1768
|
);
|
|
1769
1769
|
},
|
|
1770
1770
|
},
|
|
1771
|
+
{
|
|
1772
|
+
id: 77,
|
|
1773
|
+
name: "simulator_leases",
|
|
1774
|
+
up: (db) => {
|
|
1775
|
+
// One iOS simulator a task holds (lib/simulator): the device AgentOS
|
|
1776
|
+
// made for it, and where it is in coming up.
|
|
1777
|
+
db.exec(
|
|
1778
|
+
`CREATE TABLE IF NOT EXISTS simulator_leases (
|
|
1779
|
+
session_id TEXT PRIMARY KEY,
|
|
1780
|
+
udid TEXT,
|
|
1781
|
+
name TEXT NOT NULL,
|
|
1782
|
+
state TEXT NOT NULL,
|
|
1783
|
+
error TEXT,
|
|
1784
|
+
created_at INTEGER NOT NULL,
|
|
1785
|
+
last_used_at INTEGER NOT NULL
|
|
1786
|
+
)`
|
|
1787
|
+
);
|
|
1788
|
+
},
|
|
1789
|
+
},
|
|
1771
1790
|
];
|
|
1772
1791
|
|
|
1773
1792
|
// `upTo`: stop after this id (tests that start from an older database).
|
|
@@ -132,6 +132,25 @@
|
|
|
132
132
|
},
|
|
133
133
|
"additionalProperties": false
|
|
134
134
|
},
|
|
135
|
+
"simulator": {
|
|
136
|
+
"type": "object",
|
|
137
|
+
"properties": {
|
|
138
|
+
"start": {
|
|
139
|
+
"type": "string",
|
|
140
|
+
"minLength": 1
|
|
141
|
+
},
|
|
142
|
+
"app": {
|
|
143
|
+
"type": "string",
|
|
144
|
+
"minLength": 1
|
|
145
|
+
},
|
|
146
|
+
"device": {
|
|
147
|
+
"type": "string",
|
|
148
|
+
"minLength": 1
|
|
149
|
+
}
|
|
150
|
+
},
|
|
151
|
+
"required": ["start"],
|
|
152
|
+
"additionalProperties": false
|
|
153
|
+
},
|
|
135
154
|
"database": {
|
|
136
155
|
"type": "object",
|
|
137
156
|
"properties": {
|
|
@@ -13,6 +13,7 @@ import {
|
|
|
13
13
|
type ProjectConfig,
|
|
14
14
|
} from "./index";
|
|
15
15
|
import { databaseBrief, type SessionDatabase } from "./database";
|
|
16
|
+
import { simulatorBrief } from "../simulator/brief";
|
|
16
17
|
|
|
17
18
|
export function runningBrief(
|
|
18
19
|
config: ProjectConfig,
|
|
@@ -49,6 +50,8 @@ export function runningBrief(
|
|
|
49
50
|
lines.push(
|
|
50
51
|
`- \`${browse.map}\` maps every page to its URL: navigate by URL, don't explore.`
|
|
51
52
|
);
|
|
53
|
+
const sim = simulatorBrief(config.simulator);
|
|
54
|
+
if (sim) lines.push(sim);
|
|
52
55
|
for (const note of notesOf(config.notes))
|
|
53
56
|
lines.push(`- ${withPorts(note, ports)}`);
|
|
54
57
|
return lines.length ? `## Running this project\n\n${lines.join("\n")}` : "";
|
|
@@ -60,6 +60,15 @@ const browse = z.strictObject({
|
|
|
60
60
|
port: envName.optional(),
|
|
61
61
|
});
|
|
62
62
|
|
|
63
|
+
// An iOS simulator tasks may drive on a Mac (lib/simulator). `start` runs in
|
|
64
|
+
// the task's worktree with SIMULATOR_UDID set, builds and installs the app
|
|
65
|
+
// on that device and exits 0 once it is up.
|
|
66
|
+
const simulator = z.strictObject({
|
|
67
|
+
start: z.string().min(1),
|
|
68
|
+
app: z.string().min(1).optional(),
|
|
69
|
+
device: z.string().min(1).optional(),
|
|
70
|
+
});
|
|
71
|
+
|
|
63
72
|
const database = z.strictObject({
|
|
64
73
|
from: z.string().min(1),
|
|
65
74
|
env: projectEnvName.optional(),
|
|
@@ -109,6 +118,7 @@ const shape = {
|
|
|
109
118
|
test: z.string().min(1).optional(),
|
|
110
119
|
notes: notes.optional(),
|
|
111
120
|
browse: browse.optional(),
|
|
121
|
+
simulator: simulator.optional(),
|
|
112
122
|
database: database.optional(),
|
|
113
123
|
mcp: z.array(z.string().min(1)).optional(),
|
|
114
124
|
model: z.string().min(1).optional(),
|
package/lib/security/auth.ts
CHANGED
|
@@ -162,8 +162,18 @@ export function authorize(req: AuthRequest, policy: AuthPolicy): AuthResult {
|
|
|
162
162
|
|
|
163
163
|
function decide(req: AuthRequest, policy: AuthPolicy): AuthResult {
|
|
164
164
|
const trusted = req.tunnel ? null : networkTrust(req, policy);
|
|
165
|
-
if (trusted) return { ok: true, via: trusted };
|
|
166
165
|
const token = readToken(req.headers);
|
|
166
|
+
if (trusted) {
|
|
167
|
+
// Identity only: network trust already decided access, so a token that
|
|
168
|
+
// is unknown, revoked or fails to resolve never turns this into a refusal.
|
|
169
|
+
let deviceId: string | undefined;
|
|
170
|
+
try {
|
|
171
|
+
deviceId = (token ? policy.lookup(token) : null)?.id;
|
|
172
|
+
} catch {}
|
|
173
|
+
return deviceId
|
|
174
|
+
? { ok: true, via: trusted, deviceId }
|
|
175
|
+
: { ok: true, via: trusted };
|
|
176
|
+
}
|
|
167
177
|
const device = token ? policy.lookup(token) : null;
|
|
168
178
|
if (device) return { ok: true, via: "device", deviceId: device.id };
|
|
169
179
|
// AGENTOS_AUTH=off is for a login proxy on this side of the network.
|
package/lib/sessions/end.ts
CHANGED
|
@@ -2,6 +2,7 @@ import type { Session } from "../db";
|
|
|
2
2
|
import { stopChat } from "../chat/runner";
|
|
3
3
|
import { waitForExit } from "../chat/worker/client";
|
|
4
4
|
import { endTmuxSession } from "../tmux/end";
|
|
5
|
+
import { releaseOnEnd } from "../simulator";
|
|
5
6
|
|
|
6
7
|
type Agent = Pick<Session, "id" | "host_id" | "tmux_name">;
|
|
7
8
|
|
|
@@ -10,6 +11,8 @@ type Agent = Pick<Session, "id" | "host_id" | "tmux_name">;
|
|
|
10
11
|
// finish share.
|
|
11
12
|
export async function endSessionAgent(s: Agent): Promise<void> {
|
|
12
13
|
stopChat(s.id);
|
|
14
|
+
// The task's simulator goes with it; no row, no work.
|
|
15
|
+
releaseOnEnd(s.id, { remove: true });
|
|
13
16
|
void waitForExit(s.id).catch(() => {});
|
|
14
17
|
await endTmuxSession(s.host_id, s.tmux_name);
|
|
15
18
|
}
|
package/lib/sessions/stop.ts
CHANGED
|
@@ -3,6 +3,7 @@ import { hostLink } from "../hosts/remote-api";
|
|
|
3
3
|
import { stopChatTurn } from "../chat/stop";
|
|
4
4
|
import type { Session } from "../db";
|
|
5
5
|
import { stopHeldTask } from "../tasks/held-remote";
|
|
6
|
+
import { releaseOnEnd } from "../simulator";
|
|
6
7
|
|
|
7
8
|
// Stops a session's agent and keeps everything else, the same for every
|
|
8
9
|
// caller (the orchestrator's stop tool, `aos stop`): a chat's turn ends
|
|
@@ -33,10 +34,12 @@ export async function stopSession(
|
|
|
33
34
|
const out = await stopChatTurn(s.id);
|
|
34
35
|
if (!out.stopped)
|
|
35
36
|
throw new Error(`Couldn't stop ${s.name}: ${out.reason}.`);
|
|
37
|
+
releaseOnEnd(s.id, { remove: false });
|
|
36
38
|
return out.killed
|
|
37
39
|
? `Stopped ${s.name}: its agent didn't answer the interrupt, so it was ended. Its conversation and worktree are kept; your next message runs as its next turn.`
|
|
38
40
|
: `Stopped ${s.name}'s turn. Your next message runs as its next turn.`;
|
|
39
41
|
}
|
|
40
42
|
await endTmuxSession(s.host_id, s.tmux_name);
|
|
43
|
+
releaseOnEnd(s.id, { remove: false });
|
|
41
44
|
return `Stopped ${s.name}: its agent is ended, its worktree and branch are kept.`;
|
|
42
45
|
}
|
|
@@ -0,0 +1,258 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Driving a simulator: the `agent-device` CLI (an XCTest runner behind an
|
|
3
|
+
* accessibility-tree snapshot with element refs), pinned and installed on
|
|
4
|
+
* first use. The task never names a device or session: they are injected
|
|
5
|
+
* here, so it can only drive its own simulator.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { execFile } from "child_process";
|
|
9
|
+
import fs from "fs";
|
|
10
|
+
import os from "os";
|
|
11
|
+
import path from "path";
|
|
12
|
+
import { promisify } from "util";
|
|
13
|
+
|
|
14
|
+
const execFileAsync = promisify(execFile);
|
|
15
|
+
|
|
16
|
+
export const AGENT_DEVICE_VERSION = "0.21.27";
|
|
17
|
+
|
|
18
|
+
const root = () => path.join(os.homedir(), ".agent-os");
|
|
19
|
+
export const toolDir = () =>
|
|
20
|
+
path.join(root(), "tools", `agent-device-${AGENT_DEVICE_VERSION}`);
|
|
21
|
+
export const toolBin = () =>
|
|
22
|
+
path.join(toolDir(), "node_modules/.bin/agent-device");
|
|
23
|
+
// Its daemon, device claims and XCTest runner cache stay apart from any other
|
|
24
|
+
// copy of the tool on this Mac.
|
|
25
|
+
export const stateDir = () => path.join(root(), "agent-device");
|
|
26
|
+
export const simulatorDir = (sessionId: string) =>
|
|
27
|
+
path.join(root(), "simulators", sessionId);
|
|
28
|
+
export const shotsDir = (sessionId: string) =>
|
|
29
|
+
path.join(simulatorDir(sessionId), "shots");
|
|
30
|
+
|
|
31
|
+
export const ALLOWED = new Set([
|
|
32
|
+
"snapshot",
|
|
33
|
+
"click",
|
|
34
|
+
"press",
|
|
35
|
+
"longpress",
|
|
36
|
+
"fill",
|
|
37
|
+
"type",
|
|
38
|
+
"scroll",
|
|
39
|
+
"swipe",
|
|
40
|
+
"back",
|
|
41
|
+
"home",
|
|
42
|
+
"find",
|
|
43
|
+
"get",
|
|
44
|
+
"wait",
|
|
45
|
+
"screenshot",
|
|
46
|
+
"apps",
|
|
47
|
+
"relaunch",
|
|
48
|
+
]);
|
|
49
|
+
|
|
50
|
+
// Flags that pick a device, a session or where the tool keeps its state.
|
|
51
|
+
const REFUSED = new Set([
|
|
52
|
+
"--udid",
|
|
53
|
+
"--device",
|
|
54
|
+
"--serial",
|
|
55
|
+
"--session",
|
|
56
|
+
"--state-dir",
|
|
57
|
+
"--platform",
|
|
58
|
+
"--ios-simulator-device-set",
|
|
59
|
+
"--ios-xctest-derived-data-path",
|
|
60
|
+
]);
|
|
61
|
+
|
|
62
|
+
export interface Target {
|
|
63
|
+
udid: string;
|
|
64
|
+
sessionId: string;
|
|
65
|
+
app?: string | null;
|
|
66
|
+
// The task's worktree: with the shots dir, the only place a screenshot may
|
|
67
|
+
// be written. Relative paths are taken from here.
|
|
68
|
+
cwd?: string | null;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// The tool's session on the task's device, attached to the app.
|
|
72
|
+
export function openArgs(target: Target): string[] {
|
|
73
|
+
return [
|
|
74
|
+
"open",
|
|
75
|
+
target.app ?? "",
|
|
76
|
+
"--platform",
|
|
77
|
+
"ios",
|
|
78
|
+
"--udid",
|
|
79
|
+
target.udid,
|
|
80
|
+
"--session",
|
|
81
|
+
agentSession(target.sessionId),
|
|
82
|
+
];
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export const agentSession = (sessionId: string) =>
|
|
86
|
+
`aos-${sessionId.replace(/-/g, "").slice(0, 8)}`;
|
|
87
|
+
|
|
88
|
+
// The argv for one call, or why it is refused.
|
|
89
|
+
export function buildArgs(
|
|
90
|
+
args: string[],
|
|
91
|
+
target: Target,
|
|
92
|
+
now = () => Date.now()
|
|
93
|
+
): string[] {
|
|
94
|
+
const [sub, ...rest] = args;
|
|
95
|
+
if (!sub || !ALLOWED.has(sub))
|
|
96
|
+
throw new Error(
|
|
97
|
+
`aos sim ${sub ?? ""}: not allowed. Use: ${[...ALLOWED].join(", ")}`
|
|
98
|
+
);
|
|
99
|
+
for (const a of rest) {
|
|
100
|
+
const flag = a.split("=")[0];
|
|
101
|
+
if (REFUSED.has(flag))
|
|
102
|
+
throw new Error(
|
|
103
|
+
`${flag} is set for you: the simulator is this task's own`
|
|
104
|
+
);
|
|
105
|
+
}
|
|
106
|
+
// `aos sim open` is AgentOS's own; the tool's `open` is how a session is
|
|
107
|
+
// attached to the app, which bring-up does and `relaunch` repeats.
|
|
108
|
+
const out =
|
|
109
|
+
sub === "relaunch"
|
|
110
|
+
? ["open", ...(target.app ? [target.app] : []), "--relaunch", ...rest]
|
|
111
|
+
: [sub, ...rest];
|
|
112
|
+
if (sub === "screenshot") {
|
|
113
|
+
const at = shotArg(out);
|
|
114
|
+
if (!at) out.push(path.join(shotsDir(target.sessionId), `${now()}.png`));
|
|
115
|
+
else
|
|
116
|
+
out[at.index] =
|
|
117
|
+
at.prefix + safeShotPath(out[at.index].slice(at.prefix.length), target);
|
|
118
|
+
}
|
|
119
|
+
return [
|
|
120
|
+
...out,
|
|
121
|
+
"--platform",
|
|
122
|
+
"ios",
|
|
123
|
+
"--udid",
|
|
124
|
+
target.udid,
|
|
125
|
+
"--session",
|
|
126
|
+
agentSession(target.sessionId),
|
|
127
|
+
];
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
const within = (dir: string, file: string) => {
|
|
131
|
+
const rel = path.relative(dir, file);
|
|
132
|
+
return rel !== "" && !rel.startsWith("..") && !path.isAbsolute(rel);
|
|
133
|
+
};
|
|
134
|
+
|
|
135
|
+
// Resolves through symlinks as far as the path exists, so a link inside the
|
|
136
|
+
// worktree can't lead the write elsewhere.
|
|
137
|
+
function realish(file: string): string {
|
|
138
|
+
const rest: string[] = [];
|
|
139
|
+
let dir = file;
|
|
140
|
+
for (;;) {
|
|
141
|
+
try {
|
|
142
|
+
return path.join(fs.realpathSync(dir), ...rest.reverse());
|
|
143
|
+
} catch {
|
|
144
|
+
const up = path.dirname(dir);
|
|
145
|
+
if (up === dir) return file;
|
|
146
|
+
rest.push(path.basename(dir));
|
|
147
|
+
dir = up;
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// A screenshot goes under the session's shots dir or the task's worktree, as
|
|
153
|
+
// a .png; anything else is refused.
|
|
154
|
+
function safeShotPath(given: string, target: Target): string {
|
|
155
|
+
const base = target.cwd ?? shotsDir(target.sessionId);
|
|
156
|
+
const full = realish(path.resolve(base, given));
|
|
157
|
+
const roots = [shotsDir(target.sessionId), target.cwd].flatMap((r) =>
|
|
158
|
+
r ? [realish(r)] : []
|
|
159
|
+
);
|
|
160
|
+
if (!full.endsWith(".png") || !roots.some((r) => within(r, full)))
|
|
161
|
+
throw new Error(
|
|
162
|
+
"screenshot: the path must be a .png under this task's worktree or its simulator shots folder"
|
|
163
|
+
);
|
|
164
|
+
return full;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
const VALUE_FLAGS = new Set(["--crop-on", "--pixel-density", "--scale"]);
|
|
168
|
+
|
|
169
|
+
// Where a screenshot argv names its file: a positional path or --out.
|
|
170
|
+
function shotArg(argv: string[]): { index: number; prefix: string } | null {
|
|
171
|
+
for (let i = 1; i < argv.length; i++) {
|
|
172
|
+
const a = argv[i];
|
|
173
|
+
if (a === "--out")
|
|
174
|
+
return argv[i + 1] === undefined ? null : { index: i + 1, prefix: "" };
|
|
175
|
+
if (a.startsWith("--out=")) return { index: i, prefix: "--out=" };
|
|
176
|
+
if (VALUE_FLAGS.has(a)) i++;
|
|
177
|
+
else if (!a.startsWith("-")) return { index: i, prefix: "" };
|
|
178
|
+
}
|
|
179
|
+
return null;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
// The screenshot path a built argv will write, if it is one.
|
|
183
|
+
export const screenshotPath = (argv: string[]): string | null => {
|
|
184
|
+
if (argv[0] !== "screenshot") return null;
|
|
185
|
+
const at = shotArg(argv);
|
|
186
|
+
return at ? argv[at.index].slice(at.prefix.length) : null;
|
|
187
|
+
};
|
|
188
|
+
|
|
189
|
+
// What the tool and its install get: no tokens or project secrets from the
|
|
190
|
+
// server's environment, only what it needs to find Xcode and a home.
|
|
191
|
+
const ENV_KEYS = ["PATH", "HOME", "TMPDIR", "LANG", "DEVELOPER_DIR"];
|
|
192
|
+
export const toolEnv = (): NodeJS.ProcessEnv =>
|
|
193
|
+
({
|
|
194
|
+
...Object.fromEntries(
|
|
195
|
+
ENV_KEYS.filter((k) => process.env[k]).map((k) => [k, process.env[k]])
|
|
196
|
+
),
|
|
197
|
+
AGENT_DEVICE_STATE_DIR: stateDir(),
|
|
198
|
+
}) as unknown as NodeJS.ProcessEnv;
|
|
199
|
+
|
|
200
|
+
let installing: Promise<void> | null = null;
|
|
201
|
+
|
|
202
|
+
export function ensureInstalled(): Promise<void> {
|
|
203
|
+
if (fs.existsSync(toolBin())) return Promise.resolve();
|
|
204
|
+
installing ??= (async () => {
|
|
205
|
+
fs.mkdirSync(toolDir(), { recursive: true });
|
|
206
|
+
await execFileAsync(
|
|
207
|
+
"npm",
|
|
208
|
+
[
|
|
209
|
+
"install",
|
|
210
|
+
"--prefix",
|
|
211
|
+
toolDir(),
|
|
212
|
+
"--no-audit",
|
|
213
|
+
"--no-fund",
|
|
214
|
+
// agent-device has no install scripts; none are run for it or its deps.
|
|
215
|
+
"--ignore-scripts",
|
|
216
|
+
`agent-device@${AGENT_DEVICE_VERSION}`,
|
|
217
|
+
],
|
|
218
|
+
{ timeout: 300_000, env: toolEnv() }
|
|
219
|
+
);
|
|
220
|
+
})().finally(() => {
|
|
221
|
+
installing = null;
|
|
222
|
+
});
|
|
223
|
+
return installing;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
export interface CallResult {
|
|
227
|
+
stdout: string;
|
|
228
|
+
stderr: string;
|
|
229
|
+
code: number;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
export async function callTool(
|
|
233
|
+
argv: string[],
|
|
234
|
+
timeoutMs = 120_000
|
|
235
|
+
): Promise<CallResult> {
|
|
236
|
+
await ensureInstalled();
|
|
237
|
+
fs.mkdirSync(stateDir(), { recursive: true });
|
|
238
|
+
try {
|
|
239
|
+
const { stdout, stderr } = await execFileAsync(toolBin(), argv, {
|
|
240
|
+
env: toolEnv(),
|
|
241
|
+
timeout: timeoutMs,
|
|
242
|
+
maxBuffer: 16 * 1024 * 1024,
|
|
243
|
+
});
|
|
244
|
+
return { stdout, stderr, code: 0 };
|
|
245
|
+
} catch (error) {
|
|
246
|
+
const e = error as {
|
|
247
|
+
stdout?: string;
|
|
248
|
+
stderr?: string;
|
|
249
|
+
code?: number | string;
|
|
250
|
+
message: string;
|
|
251
|
+
};
|
|
252
|
+
return {
|
|
253
|
+
stdout: e.stdout ?? "",
|
|
254
|
+
stderr: e.stderr || e.message,
|
|
255
|
+
code: typeof e.code === "number" ? e.code : 1,
|
|
256
|
+
};
|
|
257
|
+
}
|
|
258
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import type { ProjectConfig } from "../project-config/schema";
|
|
2
|
+
import { simulatorAvailable } from "./available";
|
|
3
|
+
|
|
4
|
+
// What a task is told about the simulator: nothing when the project declares
|
|
5
|
+
// none, one line when this machine can't run one.
|
|
6
|
+
export function simulatorBrief(
|
|
7
|
+
sim: ProjectConfig["simulator"],
|
|
8
|
+
available = simulatorAvailable()
|
|
9
|
+
): string {
|
|
10
|
+
if (!sim) return "";
|
|
11
|
+
if (!available)
|
|
12
|
+
return "- The iOS simulator tools are not available on this machine; skip simulator checks and say so in the PR.";
|
|
13
|
+
return [
|
|
14
|
+
"- iOS simulator (optional; it never gates a PR or a build, and a failure is reported, not a blocker): `aos sim open` makes your own simulator and installs this worktree's build (run it with run_in_background, a cold build takes minutes; `aos sim status` shows where it is).",
|
|
15
|
+
' Then `aos sim snapshot -i` (accessibility tree with @refs), `aos sim click @e3`, `aos sim fill @e5 "text"`, `aos sim screenshot` (prints a PNG path; read it), `aos sim logs --last 2m`. Refs are only valid from the latest snapshot.',
|
|
16
|
+
" Screenshots are written outside the worktree: don't put them in commits or the PR body unless they belong there. `aos sim close` when you're done; it is closed for you when the task ends.",
|
|
17
|
+
].join("\n");
|
|
18
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { NextResponse } from "next/server";
|
|
2
|
+
import { SimulatorError } from "./index";
|
|
3
|
+
|
|
4
|
+
// A simulator call's failure as a response: its own errors carry their status.
|
|
5
|
+
export function simulatorFailure(error: unknown) {
|
|
6
|
+
if (error instanceof SimulatorError)
|
|
7
|
+
return NextResponse.json(
|
|
8
|
+
{ error: error.message },
|
|
9
|
+
{ status: error.status }
|
|
10
|
+
);
|
|
11
|
+
console.error("[simulator]", error);
|
|
12
|
+
return NextResponse.json(
|
|
13
|
+
{ error: error instanceof Error ? error.message : String(error) },
|
|
14
|
+
{ status: 500 }
|
|
15
|
+
);
|
|
16
|
+
}
|