@skaile/forge-agent-runtime 1.1.1 → 1.2.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/CHANGELOG.md +12 -0
- package/README.md +52 -7
- package/dist/app-runner-host/index.d.mts +97 -13
- package/dist/app-runner-host/index.d.ts +97 -13
- package/dist/app-runner-host/index.mjs +383 -238
- package/dist/index.d.mts +8 -2
- package/dist/index.d.ts +8 -2
- package/dist/index.mjs +2 -1
- package/dist/shared/{forge-agent-runtime.DlNAoPjs.d.mts → forge-agent-runtime.BqEbyTAQ.d.mts} +1 -1
- package/dist/shared/{forge-agent-runtime.DlNAoPjs.d.ts → forge-agent-runtime.BqEbyTAQ.d.ts} +1 -1
- package/dist/shared/{forge-agent-runtime.B1LZr0Sl.mjs → forge-agent-runtime.ByEe8ijq.mjs} +5 -2
- package/package.json +3 -3
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,17 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 1.2.0
|
|
4
|
+
|
|
5
|
+
- New in `@skaile/forge-agent-runtime/app-runner-host`: `createAgentRunner({ rootDir, onEvent, onRunEnd?, appTools })`, the run lifecycle of the app runner host without HTTP, for an app that runs its agents in-process. `start({ id?, model, registry, credentialEnv?, systemPrompt, mcpAuthorization?, expiresAt })` resolves with the run id; `command(runId, command)`, `lease(runId, expiresAt)`, `stop(runId)` (idempotent), `has(runId)` and `close()`. A run has the same isolation as a host run: own scratch workspace, HOME, `SKAILE_HOME` and provider snapshot, `--no-tools` with `tools.host_allowed`, the minimal child environment and a fresh WebSocket token. Refusals are `AgentRunnerError`s with a `code` (`invalid`, `conflict`, `not_found`, `starting`, `expired`, `ended`, `start_failed`, `closed`). `onRunEnd` reports a started run that is gone: stopped, reaped after its lease expired, exited, or failed event delivery. The caller's `credentialEnv` object is copied, never erased.
|
|
6
|
+
- `createAppRunnerHost` is a thin HTTP wrapper over `createAgentRunner`. Its requests, statuses and isolation are unchanged.
|
|
7
|
+
- The app MCP endpoint is optional: `appTools.mcpName` and `appTools.mcpUrl` are given together or not at all. Without them the run's `skaile.yaml` has no `mcp_servers`, `tools.host_allowed` lists only `browserActionNames` (now documented as the client capabilities), `APP_MCP_AUTHORIZATION` is not set, and `POST /runs` does not read `mcpAuthorization`.
|
|
8
|
+
- Static `headers` of registry entries are validated (names to strings, `400` otherwise) and written to the run's `.skaile/providers.yml` unchanged, so a run can route its model calls through a header-driven proxy such as `@skaile/transform-proxy`.
|
|
9
|
+
|
|
10
|
+
## 1.1.2
|
|
11
|
+
|
|
12
|
+
- Busy-port retry: `AgentProcessManager.create` now also retries on a fresh port when `skaile serve` prints `<host>:<port> is already in use: another process listens there` (the text `@skaile/workspaces` 5.12 and later print instead of `EADDRINUSE`, and exit 1). Before, the retry never ran for those versions and the session failed on the first port clash. `EADDRINUSE` still matches for older versions; the check is the exported `isPortConflict`.
|
|
13
|
+
- Accepts `@skaile/workspaces` 7: dependency range `^6.11.0 || ^7.0.0` (checked against 7.1.0; 7.0.0 removed model transforms only, which this package does not use). `@skaile/forge-settings` raised to `^1.1.1`, the first release that accepts `@skaile/workspaces` 7, so one copy of `@skaile/workspaces` is installed.
|
|
14
|
+
|
|
3
15
|
## 1.1.1
|
|
4
16
|
|
|
5
17
|
- `app-runner-host`: a run whose `skaile serve` child exits, or whose connection is found dead on a command (`410`), is torn down immediately: credentials erased, scratch directory removed, listener closed, later requests `404`. The final error event is still delivered to `onEvent`. Before, the run stayed registered until its lease expired.
|
package/README.md
CHANGED
|
@@ -67,11 +67,14 @@ createServer((req, res) => void host.handler(req, res)).listen(8081, "0.0.0.0");
|
|
|
67
67
|
```
|
|
68
68
|
|
|
69
69
|
`appTools` is startup configuration. A request can never name a tool or a
|
|
70
|
-
destination. `host.close()` terminates every run.
|
|
70
|
+
destination. `host.close()` terminates every run. `mcpName` and `mcpUrl` are
|
|
71
|
+
optional but go together: without them a run has no MCP server, its only host
|
|
72
|
+
tools are the client capabilities in `browserActionNames`, and `POST /runs` does
|
|
73
|
+
not read `mcpAuthorization`.
|
|
71
74
|
|
|
72
75
|
| Request | Behaviour |
|
|
73
76
|
|---|---|
|
|
74
|
-
| `POST /runs` | Body `{ id, model, registry, credentialEnv, systemPrompt, mcpAuthorization, expiresAt }`. `id` is a UUID, `expiresAt` an ISO-8601 time or epoch milliseconds in the future, `mcpAuthorization` the full `Authorization` header value for the app MCP endpoint. `400` invalid, including a `model` whose `<providerId>` is not an enabled `llm` entry of the `registry` (refused before any directory or process exists), `409` id already active, `503` the runtime did not start, `201` started. |
|
|
77
|
+
| `POST /runs` | Body `{ id, model, registry, credentialEnv, systemPrompt, mcpAuthorization, expiresAt }`. `id` is a UUID, `expiresAt` an ISO-8601 time or epoch milliseconds in the future, `mcpAuthorization` the full `Authorization` header value for the app MCP endpoint (only with an MCP endpoint). `400` invalid, including a `model` whose `<providerId>` is not an enabled `llm` entry of the `registry` (refused before any directory or process exists), `409` id already active, `503` the runtime did not start, `201` started. |
|
|
75
78
|
| `POST /runs/:id/commands` | One Workspaces command: `prompt`, `cancel`, `reply`, `capability_register`, `capability_deregister` or `capability_result`. `400` any other type or a malformed field, `404` no such run, `410` lease expired (except `cancel`) or the run's process has ended, `202` sent. `capability_register` accepts only the declared `browserActionNames`. |
|
|
76
79
|
| `PATCH /runs/:id/lease` | Body `{ expiresAt }`. `400` not in the future, `404` no such run, `410` already expired. |
|
|
77
80
|
| `DELETE /runs/:id` | Idempotent `204`. Stops the process and its listener, erases the run's credentials and removes its scratch directory. |
|
|
@@ -87,17 +90,59 @@ What a run is:
|
|
|
87
90
|
- **Files.** `<rootDir>/<id>/` holds the workspace (`skaile.yaml`, `agent/agent.yaml`, `.skaile/providers.yml`),
|
|
88
91
|
`home/`, `skaile-home/` and `tmp/`. No secret is written to any of them.
|
|
89
92
|
- **Environment.** Exactly `PATH`, `LANG`, `HOME`, `SKAILE_HOME`, `TMPDIR`, one `SKAILE_AIP_<ID>_KEY` per
|
|
90
|
-
provider, `APP_MCP_AUTHORIZATION` and `SKAILE_WS_AUTH_TOKEN`.
|
|
93
|
+
provider, `APP_MCP_AUTHORIZATION` (only with an MCP endpoint) and `SKAILE_WS_AUTH_TOKEN`.
|
|
91
94
|
- **Providers.** Only enabled `llm` entries are kept. Each credential must be an `env:NAME` reference; its value is
|
|
92
95
|
read from `credentialEnv` and handed to the child under the canonical `SKAILE_AIP_<ID>_KEY`, and the written
|
|
93
96
|
reference is rewritten to that name. `cmd:`, `keyring:` and `platform:` references, and `${NAME}` interpolation in
|
|
94
|
-
`base_url`, `headers` and `query_params`, are refused with `400`.
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
97
|
+
`base_url`, `headers` and `query_params`, are refused with `400`. Static `headers` (names to strings) are written
|
|
98
|
+
unchanged, so an app can route a run's model calls through a header-driven proxy.
|
|
99
|
+
- **Tools.** Native omp tools are disabled. The host tool allowlist is `mcp__<mcpName>__*` (with an MCP endpoint)
|
|
100
|
+
plus the client capability names; the app MCP endpoint is the only MCP server and no agent instances are
|
|
101
|
+
declared. This needs `@skaile/workspaces` 6.11 (`tools.host_allowed`).
|
|
98
102
|
|
|
99
103
|
A throwaway proof against the real `omp` binary is in `scripts/proof-app-runner-host.ts`.
|
|
100
104
|
|
|
105
|
+
### In-process runner
|
|
106
|
+
|
|
107
|
+
`createAgentRunner` is the same run lifecycle without HTTP, for an app that hosts
|
|
108
|
+
its agents itself and implements its tools as client capabilities. The host is a
|
|
109
|
+
thin wrapper over it.
|
|
110
|
+
|
|
111
|
+
```ts
|
|
112
|
+
import { createAgentRunner } from "@skaile/forge-agent-runtime/app-runner-host";
|
|
113
|
+
|
|
114
|
+
const runner = createAgentRunner({
|
|
115
|
+
rootDir: "/var/lib/app/runs",
|
|
116
|
+
onEvent: async (runId, event) => {
|
|
117
|
+
// e.g. answer `capability_invoked` with runner.command(runId, { type: "capability_result", ... })
|
|
118
|
+
},
|
|
119
|
+
onRunEnd: (runId) => {
|
|
120
|
+
// stopped, reaped after its lease expired, or its process exited
|
|
121
|
+
},
|
|
122
|
+
appTools: { browserActionNames: ["editor.document_outline", "editor.edit_document"] },
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
const runId = await runner.start({
|
|
126
|
+
model: "local/qwen",
|
|
127
|
+
registry, // AIProviderRegistryView; only enabled llm entries are kept
|
|
128
|
+
credentialEnv: { LOCAL_KEY: "…" }, // values of the registry's env: references
|
|
129
|
+
systemPrompt: "…",
|
|
130
|
+
expiresAt: Date.now() + 15 * 60_000,
|
|
131
|
+
});
|
|
132
|
+
await runner.command(runId, { type: "capability_register", capabilities: [/* client capabilities */] });
|
|
133
|
+
await runner.command(runId, { type: "prompt", prompt: "…" });
|
|
134
|
+
runner.lease(runId, Date.now() + 15 * 60_000);
|
|
135
|
+
await runner.stop(runId);
|
|
136
|
+
```
|
|
137
|
+
|
|
138
|
+
A run has the same isolation as a host run. `start` checks everything first and
|
|
139
|
+
creates nothing for an invalid input. Refusals are `AgentRunnerError`s whose
|
|
140
|
+
`code` is `invalid`, `conflict`, `not_found`, `starting`, `expired`, `ended`,
|
|
141
|
+
`start_failed` or `closed` (the host answers them `400`, `409`, `404`, `409`,
|
|
142
|
+
`410`, `410`, `503`, `503`). `capability_register` may name only
|
|
143
|
+
`browserActionNames`. `has(runId)` tells whether a run is still active;
|
|
144
|
+
`close()` stops every run.
|
|
145
|
+
|
|
101
146
|
## Migrating from `@skaile/forge-common/server`
|
|
102
147
|
|
|
103
148
|
Replace the import specifier; names are unchanged.
|
|
@@ -1,26 +1,97 @@
|
|
|
1
1
|
import { IncomingMessage, ServerResponse } from 'node:http';
|
|
2
|
-
import {
|
|
3
|
-
import '
|
|
2
|
+
import { AIProviderRegistryView } from '@skaile/forge-settings/server';
|
|
3
|
+
import { AgentCommand, AgentEvent } from '../index.mjs';
|
|
4
4
|
|
|
5
5
|
/**
|
|
6
6
|
* Trusted startup configuration naming what a run may reach. It is never taken
|
|
7
7
|
* from a request: a caller of the host can start and steer runs, it cannot widen
|
|
8
8
|
* what they are allowed to call.
|
|
9
|
+
*
|
|
10
|
+
* `mcpName` and `mcpUrl` are given together or not at all. Without them a run has
|
|
11
|
+
* no MCP server and its only host tools are the client capabilities.
|
|
9
12
|
*/
|
|
10
13
|
interface AppRunnerTools {
|
|
11
14
|
/**
|
|
12
15
|
* Name of the one generated MCP server. Its tools are offered to the model as
|
|
13
16
|
* the host tools `mcp__<mcpName>__<tool>`. Letters, digits, `_` and `-`; no `__`.
|
|
14
17
|
*/
|
|
15
|
-
mcpName
|
|
18
|
+
mcpName?: string;
|
|
16
19
|
/** `http(s)` URL of the app's MCP endpoint. */
|
|
17
|
-
mcpUrl
|
|
20
|
+
mcpUrl?: string;
|
|
18
21
|
/**
|
|
19
|
-
*
|
|
20
|
-
* `capability_register
|
|
22
|
+
* Client capabilities: exact names of the tools a client may register with
|
|
23
|
+
* `capability_register` and answer with `capability_result` (browser actions,
|
|
24
|
+
* or tools an in-process host implements itself). Wildcards are not accepted here.
|
|
21
25
|
*/
|
|
22
26
|
browserActionNames: readonly string[];
|
|
23
27
|
}
|
|
28
|
+
/** Options of {@link createAgentRunner}: the run lifecycle without HTTP. */
|
|
29
|
+
interface AgentRunnerOptions {
|
|
30
|
+
/** Private directory each run's scratch workspace is created under. */
|
|
31
|
+
rootDir: string;
|
|
32
|
+
/**
|
|
33
|
+
* Receives every agent event of a run, in order, one at a time. A rejection
|
|
34
|
+
* terminates the run: events are never replayed.
|
|
35
|
+
*/
|
|
36
|
+
onEvent(runId: string, event: AgentEvent): void | Promise<void>;
|
|
37
|
+
/**
|
|
38
|
+
* Called once when a run is gone for good: stopped, its lease expired and the
|
|
39
|
+
* grace period passed, its child exited, or its event delivery failed. Not
|
|
40
|
+
* called for a run whose start failed (`start` rejects instead).
|
|
41
|
+
*/
|
|
42
|
+
onRunEnd?(runId: string): void;
|
|
43
|
+
appTools: AppRunnerTools;
|
|
44
|
+
}
|
|
45
|
+
/** Input of {@link AgentRunner.start}. */
|
|
46
|
+
interface AgentRunStart {
|
|
47
|
+
/** UUID of the run. Absent: a random one. */
|
|
48
|
+
id?: string;
|
|
49
|
+
/** `<providerId>/<modelId>`; the provider must be an enabled `llm` entry of `registry`. */
|
|
50
|
+
model: string;
|
|
51
|
+
/** Provider registry the run's snapshot is built from (only enabled `llm` entries are kept). */
|
|
52
|
+
registry: AIProviderRegistryView;
|
|
53
|
+
/** Values of the `env:NAME` credential references of `registry`. Absent: none. */
|
|
54
|
+
credentialEnv?: Record<string, string>;
|
|
55
|
+
systemPrompt: string;
|
|
56
|
+
/**
|
|
57
|
+
* Full `Authorization` header value for the app MCP endpoint. Required when
|
|
58
|
+
* `appTools` names one; not used otherwise.
|
|
59
|
+
*/
|
|
60
|
+
mcpAuthorization?: string;
|
|
61
|
+
/** End of the lease, epoch milliseconds in the future. */
|
|
62
|
+
expiresAt: number;
|
|
63
|
+
}
|
|
64
|
+
interface AgentRunner {
|
|
65
|
+
/**
|
|
66
|
+
* Start one isolated run and resolve with its id once the runtime is connected.
|
|
67
|
+
* Rejects with an {@link AgentRunnerError}: `invalid` (nothing was created),
|
|
68
|
+
* `conflict` (id already active), `closed`, or `start_failed` (scratch removed).
|
|
69
|
+
*/
|
|
70
|
+
start(input: AgentRunStart): Promise<string>;
|
|
71
|
+
/**
|
|
72
|
+
* Send one Workspaces command to a run. `capability_register` may name only the
|
|
73
|
+
* client capabilities. Rejects with `not_found`, `starting`, `expired` (except
|
|
74
|
+
* `cancel`), `ended` (the run's process is gone; the run is torn down) or `invalid`.
|
|
75
|
+
*/
|
|
76
|
+
command(runId: string, command: AgentCommand): Promise<void>;
|
|
77
|
+
/** Move a run's lease end to `expiresAt` (epoch milliseconds in the future). */
|
|
78
|
+
lease(runId: string, expiresAt: number): void;
|
|
79
|
+
/**
|
|
80
|
+
* Stop the process, erase the run's credentials and remove its scratch.
|
|
81
|
+
* Idempotent; an unknown id resolves.
|
|
82
|
+
*/
|
|
83
|
+
stop(runId: string): Promise<void>;
|
|
84
|
+
/** Whether the run is active: started or starting, and not stopped, expired-and-reaped or exited. */
|
|
85
|
+
has(runId: string): boolean;
|
|
86
|
+
/** Stops every run and refuses new starts. */
|
|
87
|
+
close(): Promise<void>;
|
|
88
|
+
}
|
|
89
|
+
type AgentRunnerErrorCode = "invalid" | "conflict" | "not_found" | "starting" | "expired" | "ended" | "start_failed" | "closed";
|
|
90
|
+
/** A refused runner call. The message is safe to show to the caller. */
|
|
91
|
+
declare class AgentRunnerError extends Error {
|
|
92
|
+
readonly code: AgentRunnerErrorCode;
|
|
93
|
+
constructor(code: AgentRunnerErrorCode, message: string);
|
|
94
|
+
}
|
|
24
95
|
interface AppRunnerHostOptions {
|
|
25
96
|
/** Private directory each run's scratch workspace is created under. */
|
|
26
97
|
rootDir: string;
|
|
@@ -48,14 +119,27 @@ interface AppRunnerHost {
|
|
|
48
119
|
}
|
|
49
120
|
|
|
50
121
|
/**
|
|
51
|
-
* Create the isolated app runner host
|
|
122
|
+
* Create the isolated app runner host: authenticated HTTP over
|
|
123
|
+
* {@link createAgentRunner}.
|
|
52
124
|
*
|
|
53
|
-
* One run is one `skaile serve` child
|
|
54
|
-
*
|
|
55
|
-
*
|
|
56
|
-
*
|
|
125
|
+
* One run is one `skaile serve` child with its own scratch workspace, HOME and
|
|
126
|
+
* provider snapshot. The child binds loopback and requires a fresh WebSocket
|
|
127
|
+
* token. Its only tools are the app MCP endpoint and the trusted browser
|
|
128
|
+
* actions, as host tools.
|
|
57
129
|
*/
|
|
58
130
|
declare function createAppRunnerHost(options: AppRunnerHostOptions): AppRunnerHost;
|
|
59
131
|
|
|
60
|
-
|
|
61
|
-
|
|
132
|
+
/**
|
|
133
|
+
* Create the in-process agent runner: the run lifecycle of the app runner host
|
|
134
|
+
* without HTTP.
|
|
135
|
+
*
|
|
136
|
+
* One run is one `skaile serve` child (through {@link ProcessManagerAdapter}) with
|
|
137
|
+
* its own scratch workspace, HOME, `SKAILE_HOME` and provider snapshot. The child
|
|
138
|
+
* binds loopback, requires a fresh WebSocket token and gets a minimal environment.
|
|
139
|
+
* Its only tools are the app MCP endpoint, if configured, and the trusted client
|
|
140
|
+
* capabilities, as host tools.
|
|
141
|
+
*/
|
|
142
|
+
declare function createAgentRunner(options: AgentRunnerOptions): AgentRunner;
|
|
143
|
+
|
|
144
|
+
export { AgentRunnerError, createAgentRunner, createAppRunnerHost };
|
|
145
|
+
export type { AgentRunStart, AgentRunner, AgentRunnerErrorCode, AgentRunnerOptions, AppRunnerHost, AppRunnerHostOptions, AppRunnerTools };
|
|
@@ -1,26 +1,97 @@
|
|
|
1
1
|
import { IncomingMessage, ServerResponse } from 'node:http';
|
|
2
|
-
import {
|
|
3
|
-
import '
|
|
2
|
+
import { AIProviderRegistryView } from '@skaile/forge-settings/server';
|
|
3
|
+
import { AgentCommand, AgentEvent } from '../index.js';
|
|
4
4
|
|
|
5
5
|
/**
|
|
6
6
|
* Trusted startup configuration naming what a run may reach. It is never taken
|
|
7
7
|
* from a request: a caller of the host can start and steer runs, it cannot widen
|
|
8
8
|
* what they are allowed to call.
|
|
9
|
+
*
|
|
10
|
+
* `mcpName` and `mcpUrl` are given together or not at all. Without them a run has
|
|
11
|
+
* no MCP server and its only host tools are the client capabilities.
|
|
9
12
|
*/
|
|
10
13
|
interface AppRunnerTools {
|
|
11
14
|
/**
|
|
12
15
|
* Name of the one generated MCP server. Its tools are offered to the model as
|
|
13
16
|
* the host tools `mcp__<mcpName>__<tool>`. Letters, digits, `_` and `-`; no `__`.
|
|
14
17
|
*/
|
|
15
|
-
mcpName
|
|
18
|
+
mcpName?: string;
|
|
16
19
|
/** `http(s)` URL of the app's MCP endpoint. */
|
|
17
|
-
mcpUrl
|
|
20
|
+
mcpUrl?: string;
|
|
18
21
|
/**
|
|
19
|
-
*
|
|
20
|
-
* `capability_register
|
|
22
|
+
* Client capabilities: exact names of the tools a client may register with
|
|
23
|
+
* `capability_register` and answer with `capability_result` (browser actions,
|
|
24
|
+
* or tools an in-process host implements itself). Wildcards are not accepted here.
|
|
21
25
|
*/
|
|
22
26
|
browserActionNames: readonly string[];
|
|
23
27
|
}
|
|
28
|
+
/** Options of {@link createAgentRunner}: the run lifecycle without HTTP. */
|
|
29
|
+
interface AgentRunnerOptions {
|
|
30
|
+
/** Private directory each run's scratch workspace is created under. */
|
|
31
|
+
rootDir: string;
|
|
32
|
+
/**
|
|
33
|
+
* Receives every agent event of a run, in order, one at a time. A rejection
|
|
34
|
+
* terminates the run: events are never replayed.
|
|
35
|
+
*/
|
|
36
|
+
onEvent(runId: string, event: AgentEvent): void | Promise<void>;
|
|
37
|
+
/**
|
|
38
|
+
* Called once when a run is gone for good: stopped, its lease expired and the
|
|
39
|
+
* grace period passed, its child exited, or its event delivery failed. Not
|
|
40
|
+
* called for a run whose start failed (`start` rejects instead).
|
|
41
|
+
*/
|
|
42
|
+
onRunEnd?(runId: string): void;
|
|
43
|
+
appTools: AppRunnerTools;
|
|
44
|
+
}
|
|
45
|
+
/** Input of {@link AgentRunner.start}. */
|
|
46
|
+
interface AgentRunStart {
|
|
47
|
+
/** UUID of the run. Absent: a random one. */
|
|
48
|
+
id?: string;
|
|
49
|
+
/** `<providerId>/<modelId>`; the provider must be an enabled `llm` entry of `registry`. */
|
|
50
|
+
model: string;
|
|
51
|
+
/** Provider registry the run's snapshot is built from (only enabled `llm` entries are kept). */
|
|
52
|
+
registry: AIProviderRegistryView;
|
|
53
|
+
/** Values of the `env:NAME` credential references of `registry`. Absent: none. */
|
|
54
|
+
credentialEnv?: Record<string, string>;
|
|
55
|
+
systemPrompt: string;
|
|
56
|
+
/**
|
|
57
|
+
* Full `Authorization` header value for the app MCP endpoint. Required when
|
|
58
|
+
* `appTools` names one; not used otherwise.
|
|
59
|
+
*/
|
|
60
|
+
mcpAuthorization?: string;
|
|
61
|
+
/** End of the lease, epoch milliseconds in the future. */
|
|
62
|
+
expiresAt: number;
|
|
63
|
+
}
|
|
64
|
+
interface AgentRunner {
|
|
65
|
+
/**
|
|
66
|
+
* Start one isolated run and resolve with its id once the runtime is connected.
|
|
67
|
+
* Rejects with an {@link AgentRunnerError}: `invalid` (nothing was created),
|
|
68
|
+
* `conflict` (id already active), `closed`, or `start_failed` (scratch removed).
|
|
69
|
+
*/
|
|
70
|
+
start(input: AgentRunStart): Promise<string>;
|
|
71
|
+
/**
|
|
72
|
+
* Send one Workspaces command to a run. `capability_register` may name only the
|
|
73
|
+
* client capabilities. Rejects with `not_found`, `starting`, `expired` (except
|
|
74
|
+
* `cancel`), `ended` (the run's process is gone; the run is torn down) or `invalid`.
|
|
75
|
+
*/
|
|
76
|
+
command(runId: string, command: AgentCommand): Promise<void>;
|
|
77
|
+
/** Move a run's lease end to `expiresAt` (epoch milliseconds in the future). */
|
|
78
|
+
lease(runId: string, expiresAt: number): void;
|
|
79
|
+
/**
|
|
80
|
+
* Stop the process, erase the run's credentials and remove its scratch.
|
|
81
|
+
* Idempotent; an unknown id resolves.
|
|
82
|
+
*/
|
|
83
|
+
stop(runId: string): Promise<void>;
|
|
84
|
+
/** Whether the run is active: started or starting, and not stopped, expired-and-reaped or exited. */
|
|
85
|
+
has(runId: string): boolean;
|
|
86
|
+
/** Stops every run and refuses new starts. */
|
|
87
|
+
close(): Promise<void>;
|
|
88
|
+
}
|
|
89
|
+
type AgentRunnerErrorCode = "invalid" | "conflict" | "not_found" | "starting" | "expired" | "ended" | "start_failed" | "closed";
|
|
90
|
+
/** A refused runner call. The message is safe to show to the caller. */
|
|
91
|
+
declare class AgentRunnerError extends Error {
|
|
92
|
+
readonly code: AgentRunnerErrorCode;
|
|
93
|
+
constructor(code: AgentRunnerErrorCode, message: string);
|
|
94
|
+
}
|
|
24
95
|
interface AppRunnerHostOptions {
|
|
25
96
|
/** Private directory each run's scratch workspace is created under. */
|
|
26
97
|
rootDir: string;
|
|
@@ -48,14 +119,27 @@ interface AppRunnerHost {
|
|
|
48
119
|
}
|
|
49
120
|
|
|
50
121
|
/**
|
|
51
|
-
* Create the isolated app runner host
|
|
122
|
+
* Create the isolated app runner host: authenticated HTTP over
|
|
123
|
+
* {@link createAgentRunner}.
|
|
52
124
|
*
|
|
53
|
-
* One run is one `skaile serve` child
|
|
54
|
-
*
|
|
55
|
-
*
|
|
56
|
-
*
|
|
125
|
+
* One run is one `skaile serve` child with its own scratch workspace, HOME and
|
|
126
|
+
* provider snapshot. The child binds loopback and requires a fresh WebSocket
|
|
127
|
+
* token. Its only tools are the app MCP endpoint and the trusted browser
|
|
128
|
+
* actions, as host tools.
|
|
57
129
|
*/
|
|
58
130
|
declare function createAppRunnerHost(options: AppRunnerHostOptions): AppRunnerHost;
|
|
59
131
|
|
|
60
|
-
|
|
61
|
-
|
|
132
|
+
/**
|
|
133
|
+
* Create the in-process agent runner: the run lifecycle of the app runner host
|
|
134
|
+
* without HTTP.
|
|
135
|
+
*
|
|
136
|
+
* One run is one `skaile serve` child (through {@link ProcessManagerAdapter}) with
|
|
137
|
+
* its own scratch workspace, HOME, `SKAILE_HOME` and provider snapshot. The child
|
|
138
|
+
* binds loopback, requires a fresh WebSocket token and gets a minimal environment.
|
|
139
|
+
* Its only tools are the app MCP endpoint, if configured, and the trusted client
|
|
140
|
+
* capabilities, as host tools.
|
|
141
|
+
*/
|
|
142
|
+
declare function createAgentRunner(options: AgentRunnerOptions): AgentRunner;
|
|
143
|
+
|
|
144
|
+
export { AgentRunnerError, createAgentRunner, createAppRunnerHost };
|
|
145
|
+
export type { AgentRunStart, AgentRunner, AgentRunnerErrorCode, AgentRunnerOptions, AppRunnerHost, AppRunnerHostOptions, AppRunnerTools };
|
|
@@ -1,9 +1,9 @@
|
|
|
1
|
-
import { randomBytes } from 'node:crypto';
|
|
2
|
-
import { join, resolve } from 'node:path';
|
|
3
1
|
import { consola } from 'consola';
|
|
4
|
-
import { P as ProcessManagerAdapter } from '../shared/forge-agent-runtime.B1LZr0Sl.mjs';
|
|
5
|
-
import { registryEntries, isEntryEnabled, entryPolicyRefusal, providerCredentialEnvName, parseCredentialRef, writeProviderLayer } from '@skaile/forge-settings/server';
|
|
6
2
|
import { rm, mkdir, writeFile } from 'node:fs/promises';
|
|
3
|
+
import { join, resolve } from 'node:path';
|
|
4
|
+
import { registryEntries, isEntryEnabled, entryPolicyRefusal, providerCredentialEnvName, parseCredentialRef, writeProviderLayer } from '@skaile/forge-settings/server';
|
|
5
|
+
import { randomUUID, randomBytes } from 'node:crypto';
|
|
6
|
+
import { P as ProcessManagerAdapter } from '../shared/forge-agent-runtime.ByEe8ijq.mjs';
|
|
7
7
|
import 'node:events';
|
|
8
8
|
import 'node:child_process';
|
|
9
9
|
import 'node:fs';
|
|
@@ -13,22 +13,38 @@ import 'picocolors';
|
|
|
13
13
|
import '@skaile/workspaces/sdk/client';
|
|
14
14
|
import '@skaile/workspaces/sdk/transport/ws/client';
|
|
15
15
|
|
|
16
|
+
class AgentRunnerError extends Error {
|
|
17
|
+
constructor(code, message) {
|
|
18
|
+
super(message);
|
|
19
|
+
this.code = code;
|
|
20
|
+
this.name = "AgentRunnerError";
|
|
21
|
+
}
|
|
22
|
+
}
|
|
16
23
|
const APP_MCP_AUTHORIZATION_ENV = "APP_MCP_AUTHORIZATION";
|
|
17
24
|
const MCP_NAME = /^[A-Za-z0-9_-]+$/;
|
|
25
|
+
function appMcpOf(tools) {
|
|
26
|
+
if (tools.mcpName === void 0 || tools.mcpUrl === void 0) return void 0;
|
|
27
|
+
return { name: tools.mcpName, url: tools.mcpUrl };
|
|
28
|
+
}
|
|
18
29
|
function assertAppTools(tools) {
|
|
19
|
-
if (
|
|
20
|
-
throw new TypeError(
|
|
21
|
-
"appTools.mcpName must contain only letters, digits, '_' and '-', and no '__'"
|
|
22
|
-
);
|
|
23
|
-
}
|
|
24
|
-
let url;
|
|
25
|
-
try {
|
|
26
|
-
url = new URL(tools.mcpUrl);
|
|
27
|
-
} catch {
|
|
28
|
-
throw new TypeError("appTools.mcpUrl must be an absolute URL");
|
|
30
|
+
if (tools.mcpName === void 0 !== (tools.mcpUrl === void 0)) {
|
|
31
|
+
throw new TypeError("appTools.mcpName and appTools.mcpUrl must be given together");
|
|
29
32
|
}
|
|
30
|
-
if (
|
|
31
|
-
|
|
33
|
+
if (tools.mcpName !== void 0) {
|
|
34
|
+
if (!MCP_NAME.test(tools.mcpName) || tools.mcpName.includes("__")) {
|
|
35
|
+
throw new TypeError(
|
|
36
|
+
"appTools.mcpName must contain only letters, digits, '_' and '-', and no '__'"
|
|
37
|
+
);
|
|
38
|
+
}
|
|
39
|
+
let url;
|
|
40
|
+
try {
|
|
41
|
+
url = new URL(tools.mcpUrl);
|
|
42
|
+
} catch {
|
|
43
|
+
throw new TypeError("appTools.mcpUrl must be an absolute URL");
|
|
44
|
+
}
|
|
45
|
+
if (url.protocol !== "http:" && url.protocol !== "https:") {
|
|
46
|
+
throw new TypeError("appTools.mcpUrl must be an http(s) URL");
|
|
47
|
+
}
|
|
32
48
|
}
|
|
33
49
|
for (const name of tools.browserActionNames) {
|
|
34
50
|
if (typeof name !== "string" || name === "" || name.includes("*") || name.startsWith("mcp__")) {
|
|
@@ -39,40 +55,175 @@ function assertAppTools(tools) {
|
|
|
39
55
|
}
|
|
40
56
|
}
|
|
41
57
|
function hostAllowedFor(tools) {
|
|
42
|
-
|
|
58
|
+
const mcp = appMcpOf(tools);
|
|
59
|
+
return [...mcp ? [`mcp__${mcp.name}__*`] : [], ...tools.browserActionNames];
|
|
43
60
|
}
|
|
44
61
|
|
|
45
62
|
function isRecord(value) {
|
|
46
63
|
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
47
64
|
}
|
|
48
|
-
|
|
49
|
-
class RequestError extends Error {
|
|
50
|
-
constructor(status, message) {
|
|
51
|
-
super(message);
|
|
52
|
-
this.status = status;
|
|
53
|
-
this.name = "RequestError";
|
|
54
|
-
}
|
|
55
|
-
}
|
|
56
65
|
const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
57
66
|
function isUuid(value) {
|
|
58
67
|
return typeof value === "string" && UUID.test(value);
|
|
59
68
|
}
|
|
60
|
-
function
|
|
61
|
-
|
|
69
|
+
function isModelArgument(model) {
|
|
70
|
+
return model !== "" && !/\s/.test(model) && !model.startsWith("-");
|
|
71
|
+
}
|
|
72
|
+
function isSingleHeaderValue(value) {
|
|
73
|
+
return value !== "" && !/[\u0000-\u001f\u007f]/.test(value);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function runPaths(rootDir, runId) {
|
|
77
|
+
const root = join(rootDir, runId);
|
|
78
|
+
return {
|
|
79
|
+
root,
|
|
80
|
+
workspace: join(root, "workspace"),
|
|
81
|
+
home: join(root, "home"),
|
|
82
|
+
skaileHome: join(root, "skaile-home"),
|
|
83
|
+
tmp: join(root, "tmp")
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
async function createRunDirs(paths) {
|
|
87
|
+
await rm(paths.root, { recursive: true, force: true });
|
|
88
|
+
await mkdir(paths.root, { recursive: true, mode: 448 });
|
|
89
|
+
await Promise.all(
|
|
90
|
+
[paths.workspace, paths.home, paths.skaileHome, paths.tmp].map(
|
|
91
|
+
(dir) => mkdir(dir, { mode: 448 })
|
|
92
|
+
)
|
|
93
|
+
);
|
|
94
|
+
}
|
|
95
|
+
async function removeRunDirs(paths) {
|
|
96
|
+
await rm(paths.root, { recursive: true, force: true, maxRetries: 3 });
|
|
97
|
+
}
|
|
98
|
+
class SnapshotError extends Error {
|
|
99
|
+
constructor(message) {
|
|
100
|
+
super(message);
|
|
101
|
+
this.name = "SnapshotError";
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
function assertRegistryShape(registry) {
|
|
105
|
+
if (!isRecord(registry)) throw new SnapshotError("'registry' must be an object");
|
|
62
106
|
if (Array.isArray(registry.order) && !isRecord(registry.providers)) {
|
|
63
|
-
throw new
|
|
107
|
+
throw new SnapshotError("'registry.providers' must be an object");
|
|
64
108
|
}
|
|
65
109
|
const view = registry;
|
|
66
110
|
for (const [id, entry] of registryEntries(view)) {
|
|
67
111
|
if (!isRecord(entry) || typeof entry.kind !== "string") {
|
|
68
|
-
throw new
|
|
112
|
+
throw new SnapshotError(`registry entry '${id}' needs a 'kind'`);
|
|
69
113
|
}
|
|
70
114
|
if (entry.credential !== void 0 && !isRecord(entry.credential)) {
|
|
71
|
-
throw new
|
|
115
|
+
throw new SnapshotError(`registry entry '${id}': 'credential' must be an object`);
|
|
116
|
+
}
|
|
117
|
+
if (entry.headers !== void 0 && (!isRecord(entry.headers) || Object.values(entry.headers).some((value) => typeof value !== "string"))) {
|
|
118
|
+
throw new SnapshotError(`registry entry '${id}': 'headers' must map names to strings`);
|
|
72
119
|
}
|
|
73
120
|
}
|
|
74
121
|
return view;
|
|
75
122
|
}
|
|
123
|
+
function prepareProviderSnapshot(registry, credentialEnv) {
|
|
124
|
+
const providers = {};
|
|
125
|
+
const env = {};
|
|
126
|
+
const names = /* @__PURE__ */ new Set();
|
|
127
|
+
for (const [id, entry] of registryEntries(registry)) {
|
|
128
|
+
if (entry.kind !== "llm" || !isEntryEnabled(entry)) continue;
|
|
129
|
+
const interpolation = entryPolicyRefusal(
|
|
130
|
+
{ ...entry, credential: void 0 },
|
|
131
|
+
{ envNames: [] }
|
|
132
|
+
);
|
|
133
|
+
if (interpolation) throw new SnapshotError(`registry entry '${id}': ${interpolation}`);
|
|
134
|
+
const canonical = providerCredentialEnvName(id);
|
|
135
|
+
if (names.has(canonical)) {
|
|
136
|
+
throw new SnapshotError(
|
|
137
|
+
`registry entry '${id}' collides with another entry's credential name`
|
|
138
|
+
);
|
|
139
|
+
}
|
|
140
|
+
names.add(canonical);
|
|
141
|
+
const ref = entry.credential?.ref;
|
|
142
|
+
if (ref === void 0) {
|
|
143
|
+
providers[id] = { ...entry };
|
|
144
|
+
continue;
|
|
145
|
+
}
|
|
146
|
+
const parsed = parseCredentialRef(ref);
|
|
147
|
+
if (parsed?.form !== "env") {
|
|
148
|
+
throw new SnapshotError(
|
|
149
|
+
`registry entry '${id}': only env: credential references are allowed`
|
|
150
|
+
);
|
|
151
|
+
}
|
|
152
|
+
const secret = credentialEnv[parsed.name];
|
|
153
|
+
if (secret) env[canonical] = secret;
|
|
154
|
+
providers[id] = {
|
|
155
|
+
...entry,
|
|
156
|
+
credential: { ...entry.credential, ref: `env:${canonical}` }
|
|
157
|
+
};
|
|
158
|
+
}
|
|
159
|
+
const providerIds = Object.keys(providers);
|
|
160
|
+
if (providerIds.length === 0) {
|
|
161
|
+
throw new SnapshotError("'registry' has no enabled llm provider");
|
|
162
|
+
}
|
|
163
|
+
return { providers, providerIds, credentialEnv: env };
|
|
164
|
+
}
|
|
165
|
+
function requireModelProvider(model, snapshot) {
|
|
166
|
+
if (!snapshot.providerIds.some((id) => model.startsWith(`${id}/`) && model.length > id.length + 1)) {
|
|
167
|
+
throw new SnapshotError(
|
|
168
|
+
"'model' must name an enabled llm provider of the registry: '<providerId>/<modelId>'"
|
|
169
|
+
);
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
async function writeRunWorkspace(paths, input) {
|
|
173
|
+
const mcp = appMcpOf(input.appTools);
|
|
174
|
+
const skaileYaml = {
|
|
175
|
+
agent: { definition: "./agent", prompt: input.systemPrompt },
|
|
176
|
+
...mcp && {
|
|
177
|
+
mcp_servers: [
|
|
178
|
+
{
|
|
179
|
+
id: mcp.name,
|
|
180
|
+
transport: "http",
|
|
181
|
+
url: mcp.url,
|
|
182
|
+
headers: { Authorization: `env:${APP_MCP_AUTHORIZATION_ENV}` }
|
|
183
|
+
}
|
|
184
|
+
]
|
|
185
|
+
}
|
|
186
|
+
};
|
|
187
|
+
const agentYaml = {
|
|
188
|
+
name: "app-runner",
|
|
189
|
+
version: "1.0.0",
|
|
190
|
+
tools: { allowed: ["WebFetch"], host_allowed: hostAllowedFor(input.appTools) }
|
|
191
|
+
};
|
|
192
|
+
await mkdir(join(paths.workspace, "agent"), { recursive: true, mode: 448 });
|
|
193
|
+
await writeFile(join(paths.workspace, "skaile.yaml"), `${JSON.stringify(skaileYaml, null, 2)}
|
|
194
|
+
`);
|
|
195
|
+
await writeFile(
|
|
196
|
+
join(paths.workspace, "agent", "agent.yaml"),
|
|
197
|
+
`${JSON.stringify(agentYaml, null, 2)}
|
|
198
|
+
`
|
|
199
|
+
);
|
|
200
|
+
writeProviderLayer(join(paths.workspace, ".skaile", "providers.yml"), input.snapshot.providers);
|
|
201
|
+
}
|
|
202
|
+
function baseChildEnv(paths) {
|
|
203
|
+
return {
|
|
204
|
+
PATH: process.env.PATH ?? "/usr/local/bin:/usr/bin:/bin",
|
|
205
|
+
LANG: process.env.LANG ?? "C.UTF-8",
|
|
206
|
+
HOME: paths.home,
|
|
207
|
+
SKAILE_HOME: paths.skaileHome,
|
|
208
|
+
TMPDIR: paths.tmp
|
|
209
|
+
};
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
class RequestError extends Error {
|
|
213
|
+
constructor(status, message) {
|
|
214
|
+
super(message);
|
|
215
|
+
this.status = status;
|
|
216
|
+
this.name = "RequestError";
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
function requireRegistryShape(registry) {
|
|
220
|
+
try {
|
|
221
|
+
return assertRegistryShape(registry);
|
|
222
|
+
} catch (err) {
|
|
223
|
+
if (err instanceof SnapshotError) throw new RequestError(400, err.message);
|
|
224
|
+
throw err;
|
|
225
|
+
}
|
|
226
|
+
}
|
|
76
227
|
function requireRecord(body) {
|
|
77
228
|
if (!isRecord(body)) throw new RequestError(400, "request body must be a JSON object");
|
|
78
229
|
return body;
|
|
@@ -94,12 +245,12 @@ function parseExpiry(value, now) {
|
|
|
94
245
|
if (at <= now) throw new RequestError(400, "'expiresAt' must be in the future");
|
|
95
246
|
return at;
|
|
96
247
|
}
|
|
97
|
-
function parseStartRequest(body, now) {
|
|
248
|
+
function parseStartRequest(body, now, withMcp) {
|
|
98
249
|
const record = requireRecord(body);
|
|
99
250
|
const id = record.id;
|
|
100
251
|
if (!isUuid(id)) throw new RequestError(400, "'id' must be a UUID");
|
|
101
252
|
const model = requireString(record, "model");
|
|
102
|
-
if (
|
|
253
|
+
if (!isModelArgument(model)) {
|
|
103
254
|
throw new RequestError(400, "'model' must be '<providerId>/<modelId>' without whitespace");
|
|
104
255
|
}
|
|
105
256
|
const registry = requireRegistryShape(record.registry);
|
|
@@ -113,9 +264,12 @@ function parseStartRequest(body, now) {
|
|
|
113
264
|
if (typeof record.systemPrompt !== "string") {
|
|
114
265
|
throw new RequestError(400, "'systemPrompt' must be a string");
|
|
115
266
|
}
|
|
116
|
-
|
|
117
|
-
if (
|
|
118
|
-
|
|
267
|
+
let mcpAuthorization;
|
|
268
|
+
if (withMcp) {
|
|
269
|
+
mcpAuthorization = requireString(record, "mcpAuthorization");
|
|
270
|
+
if (!isSingleHeaderValue(mcpAuthorization)) {
|
|
271
|
+
throw new RequestError(400, "'mcpAuthorization' must be a single header value");
|
|
272
|
+
}
|
|
119
273
|
}
|
|
120
274
|
return {
|
|
121
275
|
id,
|
|
@@ -123,7 +277,7 @@ function parseStartRequest(body, now) {
|
|
|
123
277
|
registry,
|
|
124
278
|
credentialEnv: { ...credentialEnv },
|
|
125
279
|
systemPrompt: record.systemPrompt,
|
|
126
|
-
mcpAuthorization,
|
|
280
|
+
...mcpAuthorization !== void 0 && { mcpAuthorization },
|
|
127
281
|
expiresAt: parseExpiry(record.expiresAt, now)
|
|
128
282
|
};
|
|
129
283
|
}
|
|
@@ -205,163 +359,41 @@ function parseCommand(body, browserActionNames) {
|
|
|
205
359
|
}
|
|
206
360
|
}
|
|
207
361
|
|
|
208
|
-
|
|
209
|
-
const root = join(rootDir, runId);
|
|
210
|
-
return {
|
|
211
|
-
root,
|
|
212
|
-
workspace: join(root, "workspace"),
|
|
213
|
-
home: join(root, "home"),
|
|
214
|
-
skaileHome: join(root, "skaile-home"),
|
|
215
|
-
tmp: join(root, "tmp")
|
|
216
|
-
};
|
|
217
|
-
}
|
|
218
|
-
async function createRunDirs(paths) {
|
|
219
|
-
await rm(paths.root, { recursive: true, force: true });
|
|
220
|
-
await mkdir(paths.root, { recursive: true, mode: 448 });
|
|
221
|
-
await Promise.all(
|
|
222
|
-
[paths.workspace, paths.home, paths.skaileHome, paths.tmp].map(
|
|
223
|
-
(dir) => mkdir(dir, { mode: 448 })
|
|
224
|
-
)
|
|
225
|
-
);
|
|
226
|
-
}
|
|
227
|
-
async function removeRunDirs(paths) {
|
|
228
|
-
await rm(paths.root, { recursive: true, force: true, maxRetries: 3 });
|
|
229
|
-
}
|
|
230
|
-
class SnapshotError extends Error {
|
|
231
|
-
constructor(message) {
|
|
232
|
-
super(message);
|
|
233
|
-
this.name = "SnapshotError";
|
|
234
|
-
}
|
|
235
|
-
}
|
|
236
|
-
function prepareProviderSnapshot(registry, credentialEnv) {
|
|
237
|
-
const providers = {};
|
|
238
|
-
const env = {};
|
|
239
|
-
const names = /* @__PURE__ */ new Set();
|
|
240
|
-
for (const [id, entry] of registryEntries(registry)) {
|
|
241
|
-
if (entry.kind !== "llm" || !isEntryEnabled(entry)) continue;
|
|
242
|
-
const interpolation = entryPolicyRefusal(
|
|
243
|
-
{ ...entry, credential: void 0 },
|
|
244
|
-
{ envNames: [] }
|
|
245
|
-
);
|
|
246
|
-
if (interpolation) throw new SnapshotError(`registry entry '${id}': ${interpolation}`);
|
|
247
|
-
const canonical = providerCredentialEnvName(id);
|
|
248
|
-
if (names.has(canonical)) {
|
|
249
|
-
throw new SnapshotError(
|
|
250
|
-
`registry entry '${id}' collides with another entry's credential name`
|
|
251
|
-
);
|
|
252
|
-
}
|
|
253
|
-
names.add(canonical);
|
|
254
|
-
const ref = entry.credential?.ref;
|
|
255
|
-
if (ref === void 0) {
|
|
256
|
-
providers[id] = { ...entry };
|
|
257
|
-
continue;
|
|
258
|
-
}
|
|
259
|
-
const parsed = parseCredentialRef(ref);
|
|
260
|
-
if (parsed?.form !== "env") {
|
|
261
|
-
throw new SnapshotError(
|
|
262
|
-
`registry entry '${id}': only env: credential references are allowed`
|
|
263
|
-
);
|
|
264
|
-
}
|
|
265
|
-
const secret = credentialEnv[parsed.name];
|
|
266
|
-
if (secret) env[canonical] = secret;
|
|
267
|
-
providers[id] = {
|
|
268
|
-
...entry,
|
|
269
|
-
credential: { ...entry.credential, ref: `env:${canonical}` }
|
|
270
|
-
};
|
|
271
|
-
}
|
|
272
|
-
const providerIds = Object.keys(providers);
|
|
273
|
-
if (providerIds.length === 0) {
|
|
274
|
-
throw new SnapshotError("'registry' has no enabled llm provider");
|
|
275
|
-
}
|
|
276
|
-
return { providers, providerIds, credentialEnv: env };
|
|
277
|
-
}
|
|
278
|
-
function requireModelProvider(model, snapshot) {
|
|
279
|
-
if (!snapshot.providerIds.some((id) => model.startsWith(`${id}/`) && model.length > id.length + 1)) {
|
|
280
|
-
throw new SnapshotError(
|
|
281
|
-
"'model' must name an enabled llm provider of the registry: '<providerId>/<modelId>'"
|
|
282
|
-
);
|
|
283
|
-
}
|
|
284
|
-
}
|
|
285
|
-
async function writeRunWorkspace(paths, input) {
|
|
286
|
-
const { appTools } = input;
|
|
287
|
-
const skaileYaml = {
|
|
288
|
-
agent: { definition: "./agent", prompt: input.systemPrompt },
|
|
289
|
-
mcp_servers: [
|
|
290
|
-
{
|
|
291
|
-
id: appTools.mcpName,
|
|
292
|
-
transport: "http",
|
|
293
|
-
url: appTools.mcpUrl,
|
|
294
|
-
headers: { Authorization: `env:${APP_MCP_AUTHORIZATION_ENV}` }
|
|
295
|
-
}
|
|
296
|
-
]
|
|
297
|
-
};
|
|
298
|
-
const agentYaml = {
|
|
299
|
-
name: "app-runner",
|
|
300
|
-
version: "1.0.0",
|
|
301
|
-
tools: { allowed: ["WebFetch"], host_allowed: hostAllowedFor(appTools) }
|
|
302
|
-
};
|
|
303
|
-
await mkdir(join(paths.workspace, "agent"), { recursive: true, mode: 448 });
|
|
304
|
-
await writeFile(join(paths.workspace, "skaile.yaml"), `${JSON.stringify(skaileYaml, null, 2)}
|
|
305
|
-
`);
|
|
306
|
-
await writeFile(
|
|
307
|
-
join(paths.workspace, "agent", "agent.yaml"),
|
|
308
|
-
`${JSON.stringify(agentYaml, null, 2)}
|
|
309
|
-
`
|
|
310
|
-
);
|
|
311
|
-
writeProviderLayer(join(paths.workspace, ".skaile", "providers.yml"), input.snapshot.providers);
|
|
312
|
-
}
|
|
313
|
-
function baseChildEnv(paths) {
|
|
314
|
-
return {
|
|
315
|
-
PATH: process.env.PATH ?? "/usr/local/bin:/usr/bin:/bin",
|
|
316
|
-
LANG: process.env.LANG ?? "C.UTF-8",
|
|
317
|
-
HOME: paths.home,
|
|
318
|
-
SKAILE_HOME: paths.skaileHome,
|
|
319
|
-
TMPDIR: paths.tmp
|
|
320
|
-
};
|
|
321
|
-
}
|
|
322
|
-
|
|
323
|
-
const log = consola.withTag("app-runner-host");
|
|
324
|
-
const MAX_BODY_BYTES = 4 * 1024 * 1024;
|
|
362
|
+
const log$1 = consola.withTag("app-runner-host");
|
|
325
363
|
const MAX_TIMER_MS = 2 ** 31 - 1;
|
|
326
364
|
const EXPIRY_GRACE_MS = 5e3;
|
|
327
365
|
const CHILD_HOST = "127.0.0.1";
|
|
328
|
-
function
|
|
329
|
-
|
|
330
|
-
res.end();
|
|
331
|
-
return;
|
|
332
|
-
}
|
|
333
|
-
if (body === void 0) {
|
|
334
|
-
res.writeHead(status).end();
|
|
335
|
-
return;
|
|
336
|
-
}
|
|
337
|
-
res.writeHead(status, { "content-type": "application/json" }).end(JSON.stringify(body));
|
|
366
|
+
function erase(record) {
|
|
367
|
+
for (const key of Object.keys(record)) delete record[key];
|
|
338
368
|
}
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
369
|
+
function validateStart(input, withMcp, now) {
|
|
370
|
+
if (input.id !== void 0 && !isUuid(input.id)) {
|
|
371
|
+
throw new AgentRunnerError("invalid", "'id' must be a UUID");
|
|
372
|
+
}
|
|
373
|
+
if (typeof input.model !== "string" || !isModelArgument(input.model)) {
|
|
374
|
+
throw new AgentRunnerError(
|
|
375
|
+
"invalid",
|
|
376
|
+
"'model' must be '<providerId>/<modelId>' without whitespace"
|
|
377
|
+
);
|
|
343
378
|
}
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
for await (const chunk of req) {
|
|
347
|
-
const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
|
|
348
|
-
size += buffer.length;
|
|
349
|
-
if (size > MAX_BODY_BYTES) throw new RequestError(413, "request body too large");
|
|
350
|
-
chunks.push(buffer);
|
|
379
|
+
if (typeof input.systemPrompt !== "string") {
|
|
380
|
+
throw new AgentRunnerError("invalid", "'systemPrompt' must be a string");
|
|
351
381
|
}
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
382
|
+
if (withMcp) {
|
|
383
|
+
const value = input.mcpAuthorization;
|
|
384
|
+
if (typeof value !== "string" || !isSingleHeaderValue(value)) {
|
|
385
|
+
throw new AgentRunnerError("invalid", "'mcpAuthorization' must be a single header value");
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
if (!Number.isFinite(input.expiresAt) || input.expiresAt <= now) {
|
|
389
|
+
throw new AgentRunnerError("invalid", "'expiresAt' must be in the future");
|
|
356
390
|
}
|
|
357
391
|
}
|
|
358
|
-
function
|
|
359
|
-
for (const key of Object.keys(record)) delete record[key];
|
|
360
|
-
}
|
|
361
|
-
function createAppRunnerHost(options) {
|
|
392
|
+
function createAgentRunner(options) {
|
|
362
393
|
assertAppTools(options.appTools);
|
|
363
394
|
const rootDir = resolve(options.rootDir);
|
|
364
|
-
const { appTools,
|
|
395
|
+
const { appTools, onEvent, onRunEnd } = options;
|
|
396
|
+
const withMcp = appMcpOf(appTools) !== void 0;
|
|
365
397
|
const runs = /* @__PURE__ */ new Map();
|
|
366
398
|
let closed = false;
|
|
367
399
|
function tearDown(run) {
|
|
@@ -372,7 +404,7 @@ function createAppRunnerHost(options) {
|
|
|
372
404
|
try {
|
|
373
405
|
await run.adapter.disconnect();
|
|
374
406
|
} catch (err) {
|
|
375
|
-
log.warn(`[${run.id}] disconnect failed: ${err.message}`);
|
|
407
|
+
log$1.warn(`[${run.id}] disconnect failed: ${err.message}`);
|
|
376
408
|
}
|
|
377
409
|
await run.pump?.catch(() => {
|
|
378
410
|
});
|
|
@@ -380,9 +412,16 @@ function createAppRunnerHost(options) {
|
|
|
380
412
|
try {
|
|
381
413
|
await removeRunDirs(run.paths);
|
|
382
414
|
} catch (err) {
|
|
383
|
-
log.warn(`[${run.id}] scratch removal failed: ${err.message}`);
|
|
415
|
+
log$1.warn(`[${run.id}] scratch removal failed: ${err.message}`);
|
|
384
416
|
}
|
|
385
417
|
if (runs.get(run.id) === run) runs.delete(run.id);
|
|
418
|
+
if (run.started && onRunEnd) {
|
|
419
|
+
try {
|
|
420
|
+
onRunEnd(run.id);
|
|
421
|
+
} catch (err) {
|
|
422
|
+
log$1.warn(`[${run.id}] onRunEnd failed: ${err.message}`);
|
|
423
|
+
}
|
|
424
|
+
}
|
|
386
425
|
})();
|
|
387
426
|
return run.teardown;
|
|
388
427
|
}
|
|
@@ -398,7 +437,7 @@ function createAppRunnerHost(options) {
|
|
|
398
437
|
await onEvent(run.id, event);
|
|
399
438
|
}
|
|
400
439
|
} catch (err) {
|
|
401
|
-
log.error(`[${run.id}] event delivery failed; terminating: ${err.message}`);
|
|
440
|
+
log$1.error(`[${run.id}] event delivery failed; terminating: ${err.message}`);
|
|
402
441
|
void tearDown(run);
|
|
403
442
|
}
|
|
404
443
|
}
|
|
@@ -416,33 +455,53 @@ function createAppRunnerHost(options) {
|
|
|
416
455
|
async function expire(run) {
|
|
417
456
|
if (run.state !== "running") return;
|
|
418
457
|
run.state = "expired";
|
|
419
|
-
log.info(`[${run.id}] lease expired`);
|
|
458
|
+
log$1.info(`[${run.id}] lease expired`);
|
|
420
459
|
run.reapTimer = setTimeout(() => void tearDown(run), EXPIRY_GRACE_MS);
|
|
421
460
|
run.reapTimer.unref();
|
|
422
461
|
try {
|
|
423
462
|
await run.adapter.abort();
|
|
424
463
|
} catch (err) {
|
|
425
|
-
log.warn(`[${run.id}] cancel on expiry failed: ${err.message}`);
|
|
464
|
+
log$1.warn(`[${run.id}] cancel on expiry failed: ${err.message}`);
|
|
426
465
|
}
|
|
427
466
|
}
|
|
428
|
-
|
|
429
|
-
const
|
|
467
|
+
function findRun(runId) {
|
|
468
|
+
const run = isUuid(runId) ? runs.get(runId) : void 0;
|
|
469
|
+
if (!run || run.teardown) throw new AgentRunnerError("not_found", "no such run");
|
|
470
|
+
return run;
|
|
471
|
+
}
|
|
472
|
+
async function start(input) {
|
|
473
|
+
validateStart(input, withMcp, Date.now());
|
|
474
|
+
if (closed) throw new AgentRunnerError("closed", "runner is closed");
|
|
475
|
+
const id = input.id ?? randomUUID();
|
|
476
|
+
if (runs.has(id))
|
|
477
|
+
throw new AgentRunnerError("conflict", "a run with this id is already active");
|
|
478
|
+
const callerEnv = { ...input.credentialEnv };
|
|
479
|
+
let snapshot;
|
|
480
|
+
try {
|
|
481
|
+
snapshot = prepareProviderSnapshot(assertRegistryShape(input.registry), callerEnv);
|
|
482
|
+
requireModelProvider(input.model, snapshot);
|
|
483
|
+
} catch (err) {
|
|
484
|
+
erase(callerEnv);
|
|
485
|
+
if (err instanceof SnapshotError) throw new AgentRunnerError("invalid", err.message);
|
|
486
|
+
throw err;
|
|
487
|
+
}
|
|
488
|
+
const paths = runPaths(rootDir, id);
|
|
430
489
|
const wsAuthToken = randomBytes(32).toString("base64url");
|
|
431
|
-
const mcpEnv = { [APP_MCP_AUTHORIZATION_ENV]:
|
|
490
|
+
const mcpEnv = withMcp ? { [APP_MCP_AUTHORIZATION_ENV]: input.mcpAuthorization } : {};
|
|
432
491
|
const credentialEnv = { ...baseChildEnv(paths), ...snapshot.credentialEnv };
|
|
433
492
|
const run = {
|
|
434
|
-
id
|
|
493
|
+
id,
|
|
435
494
|
paths,
|
|
436
495
|
state: "starting",
|
|
437
|
-
expiresAt:
|
|
496
|
+
expiresAt: input.expiresAt,
|
|
438
497
|
adapter: new ProcessManagerAdapter(),
|
|
439
|
-
secrets: [
|
|
498
|
+
secrets: [callerEnv, snapshot.credentialEnv, credentialEnv, mcpEnv]
|
|
440
499
|
};
|
|
441
|
-
runs.set(
|
|
500
|
+
runs.set(id, run);
|
|
442
501
|
run.startup = (async () => {
|
|
443
502
|
await createRunDirs(paths);
|
|
444
503
|
await writeRunWorkspace(paths, {
|
|
445
|
-
systemPrompt:
|
|
504
|
+
systemPrompt: input.systemPrompt,
|
|
446
505
|
snapshot,
|
|
447
506
|
appTools
|
|
448
507
|
});
|
|
@@ -450,7 +509,7 @@ function createAppRunnerHost(options) {
|
|
|
450
509
|
await run.adapter.connect({
|
|
451
510
|
projectDir: paths.workspace,
|
|
452
511
|
harness: "omp",
|
|
453
|
-
model:
|
|
512
|
+
model: input.model,
|
|
454
513
|
registry: snapshot.providers,
|
|
455
514
|
providers: snapshot.providerIds,
|
|
456
515
|
credentialEnv,
|
|
@@ -461,73 +520,159 @@ function createAppRunnerHost(options) {
|
|
|
461
520
|
if (run.teardown) throw new Error("run was terminated while starting");
|
|
462
521
|
if (!run.adapter.isConnected()) throw new Error("runtime exited while starting");
|
|
463
522
|
run.adapter.getProcessManager().on(`error:${paths.workspace}`, () => {
|
|
464
|
-
log.warn(`[${run.id}] runtime process exited; tearing the run down`);
|
|
523
|
+
log$1.warn(`[${run.id}] runtime process exited; tearing the run down`);
|
|
465
524
|
run.exited = true;
|
|
466
525
|
void tearDown(run);
|
|
467
526
|
});
|
|
468
527
|
run.state = "running";
|
|
528
|
+
run.started = true;
|
|
469
529
|
armLease(run);
|
|
470
530
|
})();
|
|
471
531
|
try {
|
|
472
532
|
await run.startup;
|
|
473
533
|
} catch (err) {
|
|
474
|
-
log.error(`[${
|
|
534
|
+
log$1.error(`[${id}] start failed: ${err.message}`);
|
|
475
535
|
await tearDown(run);
|
|
476
|
-
throw
|
|
536
|
+
throw new AgentRunnerError("start_failed", "runner could not be started");
|
|
477
537
|
}
|
|
478
|
-
return
|
|
479
|
-
}
|
|
480
|
-
async function
|
|
481
|
-
const
|
|
482
|
-
if (
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
538
|
+
return id;
|
|
539
|
+
}
|
|
540
|
+
async function command(runId, cmd) {
|
|
541
|
+
const run = findRun(runId);
|
|
542
|
+
if (cmd.type === "capability_register") {
|
|
543
|
+
const list = Array.isArray(cmd.capabilities) ? cmd.capabilities : [];
|
|
544
|
+
for (const entry of list) {
|
|
545
|
+
if (!appTools.browserActionNames.includes(entry?.name)) {
|
|
546
|
+
throw new AgentRunnerError(
|
|
547
|
+
"invalid",
|
|
548
|
+
`capability '${String(entry?.name)}' is not a declared client capability`
|
|
549
|
+
);
|
|
550
|
+
}
|
|
551
|
+
}
|
|
552
|
+
}
|
|
553
|
+
if (run.state === "starting") throw new AgentRunnerError("starting", "run is still starting");
|
|
554
|
+
if (run.state === "expired" && cmd.type !== "cancel") {
|
|
555
|
+
throw new AgentRunnerError("expired", "run lease has expired");
|
|
490
556
|
}
|
|
491
557
|
try {
|
|
492
|
-
|
|
493
|
-
json(res, 201, { id: run.id, expiresAt: new Date(run.expiresAt).toISOString() });
|
|
558
|
+
await run.adapter.sendCommand(cmd);
|
|
494
559
|
} catch {
|
|
495
|
-
|
|
560
|
+
void tearDown(run);
|
|
561
|
+
throw new AgentRunnerError("ended", "run process has ended");
|
|
496
562
|
}
|
|
497
563
|
}
|
|
498
|
-
function
|
|
499
|
-
const run =
|
|
500
|
-
if (!
|
|
501
|
-
|
|
564
|
+
function lease(runId, expiresAt) {
|
|
565
|
+
const run = findRun(runId);
|
|
566
|
+
if (!Number.isFinite(expiresAt) || expiresAt <= Date.now()) {
|
|
567
|
+
throw new AgentRunnerError("invalid", "'expiresAt' must be in the future");
|
|
568
|
+
}
|
|
569
|
+
if (run.state === "starting") throw new AgentRunnerError("starting", "run is still starting");
|
|
570
|
+
if (run.state === "expired") throw new AgentRunnerError("expired", "run lease has expired");
|
|
571
|
+
run.expiresAt = expiresAt;
|
|
572
|
+
armLease(run);
|
|
502
573
|
}
|
|
503
|
-
async function
|
|
504
|
-
const run =
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
574
|
+
async function stop(runId) {
|
|
575
|
+
const run = isUuid(runId) ? runs.get(runId) : void 0;
|
|
576
|
+
if (run) await terminate(run);
|
|
577
|
+
}
|
|
578
|
+
return {
|
|
579
|
+
start,
|
|
580
|
+
command,
|
|
581
|
+
lease,
|
|
582
|
+
stop,
|
|
583
|
+
has(runId) {
|
|
584
|
+
const run = isUuid(runId) ? runs.get(runId) : void 0;
|
|
585
|
+
return run !== void 0 && !run.teardown;
|
|
586
|
+
},
|
|
587
|
+
async close() {
|
|
588
|
+
closed = true;
|
|
589
|
+
await Promise.all([...runs.values()].map((run) => terminate(run)));
|
|
509
590
|
}
|
|
591
|
+
};
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
const log = consola.withTag("app-runner-host");
|
|
595
|
+
const MAX_BODY_BYTES = 4 * 1024 * 1024;
|
|
596
|
+
const STATUS = {
|
|
597
|
+
invalid: 400,
|
|
598
|
+
not_found: 404,
|
|
599
|
+
conflict: 409,
|
|
600
|
+
starting: 409,
|
|
601
|
+
expired: 410,
|
|
602
|
+
ended: 410,
|
|
603
|
+
closed: 503,
|
|
604
|
+
start_failed: 503
|
|
605
|
+
};
|
|
606
|
+
function json(res, status, body) {
|
|
607
|
+
if (res.headersSent) {
|
|
608
|
+
res.end();
|
|
609
|
+
return;
|
|
610
|
+
}
|
|
611
|
+
if (body === void 0) {
|
|
612
|
+
res.writeHead(status).end();
|
|
613
|
+
return;
|
|
614
|
+
}
|
|
615
|
+
res.writeHead(status, { "content-type": "application/json" }).end(JSON.stringify(body));
|
|
616
|
+
}
|
|
617
|
+
async function readJson(req) {
|
|
618
|
+
const declared = Number(req.headers["content-length"]);
|
|
619
|
+
if (Number.isFinite(declared) && declared > MAX_BODY_BYTES) {
|
|
620
|
+
throw new RequestError(413, "request body too large");
|
|
621
|
+
}
|
|
622
|
+
const chunks = [];
|
|
623
|
+
let size = 0;
|
|
624
|
+
for await (const chunk of req) {
|
|
625
|
+
const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
|
|
626
|
+
size += buffer.length;
|
|
627
|
+
if (size > MAX_BODY_BYTES) throw new RequestError(413, "request body too large");
|
|
628
|
+
chunks.push(buffer);
|
|
629
|
+
}
|
|
630
|
+
try {
|
|
631
|
+
return JSON.parse(Buffer.concat(chunks).toString("utf8"));
|
|
632
|
+
} catch {
|
|
633
|
+
throw new RequestError(400, "request body is not valid JSON");
|
|
634
|
+
}
|
|
635
|
+
}
|
|
636
|
+
async function viaRunner(call) {
|
|
637
|
+
try {
|
|
638
|
+
return await call();
|
|
639
|
+
} catch (err) {
|
|
640
|
+
if (err instanceof AgentRunnerError) throw new RequestError(STATUS[err.code], err.message);
|
|
641
|
+
throw err;
|
|
642
|
+
}
|
|
643
|
+
}
|
|
644
|
+
function createAppRunnerHost(options) {
|
|
645
|
+
const { appTools, authenticate, onEvent } = options;
|
|
646
|
+
const runner = createAgentRunner({ rootDir: options.rootDir, onEvent, appTools });
|
|
647
|
+
const withMcp = appMcpOf(appTools) !== void 0;
|
|
648
|
+
let closed = false;
|
|
649
|
+
async function startRun(req, res) {
|
|
650
|
+
const request = parseStartRequest(await readJson(req), Date.now(), withMcp);
|
|
510
651
|
try {
|
|
511
|
-
await
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
652
|
+
const id = await viaRunner(() => runner.start(request));
|
|
653
|
+
json(res, 201, { id, expiresAt: new Date(request.expiresAt).toISOString() });
|
|
654
|
+
} finally {
|
|
655
|
+
for (const key of Object.keys(request.credentialEnv)) delete request.credentialEnv[key];
|
|
515
656
|
}
|
|
657
|
+
}
|
|
658
|
+
function requireRun(id) {
|
|
659
|
+
if (!runner.has(id)) throw new RequestError(404, "no such run");
|
|
660
|
+
}
|
|
661
|
+
async function sendCommand(req, res, id) {
|
|
662
|
+
requireRun(id);
|
|
663
|
+
const command = parseCommand(await readJson(req), appTools.browserActionNames);
|
|
664
|
+
await viaRunner(() => runner.command(id, command));
|
|
516
665
|
json(res, 202, { accepted: true });
|
|
517
666
|
}
|
|
518
667
|
async function renewLease(req, res, id) {
|
|
519
|
-
|
|
668
|
+
requireRun(id);
|
|
520
669
|
const expiresAt = parseLeaseRequest(await readJson(req), Date.now());
|
|
521
|
-
|
|
522
|
-
if (run.state === "expired") throw new RequestError(410, "run lease has expired");
|
|
523
|
-
run.expiresAt = expiresAt;
|
|
524
|
-
armLease(run);
|
|
670
|
+
await viaRunner(() => runner.lease(id, expiresAt));
|
|
525
671
|
json(res, 200, { expiresAt: new Date(expiresAt).toISOString() });
|
|
526
672
|
}
|
|
527
673
|
async function deleteRun(_req, res, id) {
|
|
528
674
|
if (!isUuid(id)) throw new RequestError(404, "no such run");
|
|
529
|
-
|
|
530
|
-
if (run) await terminate(run);
|
|
675
|
+
await runner.stop(id);
|
|
531
676
|
json(res, 204);
|
|
532
677
|
}
|
|
533
678
|
const collectionRoutes = /* @__PURE__ */ new Map([["POST", startRun]]);
|
|
@@ -578,9 +723,9 @@ function createAppRunnerHost(options) {
|
|
|
578
723
|
},
|
|
579
724
|
async close() {
|
|
580
725
|
closed = true;
|
|
581
|
-
await
|
|
726
|
+
await runner.close();
|
|
582
727
|
}
|
|
583
728
|
};
|
|
584
729
|
}
|
|
585
730
|
|
|
586
|
-
export { createAppRunnerHost };
|
|
731
|
+
export { AgentRunnerError, createAgentRunner, createAppRunnerHost };
|
package/dist/index.d.mts
CHANGED
|
@@ -2,7 +2,7 @@ import { EventEmitter } from 'node:events';
|
|
|
2
2
|
import { AgentClient } from '@skaile/workspaces/sdk/client';
|
|
3
3
|
import { AgentCommand, AgentEvent as AgentEvent$1, MessageV2 } from '@skaile/workspaces/sdk/types';
|
|
4
4
|
import { AIProviderRegistryView } from '@skaile/forge-settings/server';
|
|
5
|
-
import {
|
|
5
|
+
import { b as AgentAdapter, c as AgentAdapterConfig, A as AgentCommand$1, a as AgentEvent, d as AgentStatus } from './shared/forge-agent-runtime.BqEbyTAQ.mjs';
|
|
6
6
|
import { SubscriberTransport, SessionDispatcher, MessageStore } from '@skaile/workspaces/sdk/session';
|
|
7
7
|
|
|
8
8
|
/**
|
|
@@ -87,6 +87,12 @@ interface AgentProcessConfig {
|
|
|
87
87
|
*/
|
|
88
88
|
wsAuthToken?: string;
|
|
89
89
|
}
|
|
90
|
+
/**
|
|
91
|
+
* True when the child's stderr says its port was taken. Two texts exist:
|
|
92
|
+
* `EADDRINUSE` (the raw Node error, skaile before 5.12) and
|
|
93
|
+
* `<host>:<port> is already in use: ...` (`skaile serve` 5.12+, which exits 1).
|
|
94
|
+
*/
|
|
95
|
+
declare function isPortConflict(stderrLines: readonly string[]): boolean;
|
|
90
96
|
declare class AgentProcessManager extends EventEmitter {
|
|
91
97
|
private processes;
|
|
92
98
|
constructor();
|
|
@@ -471,5 +477,5 @@ declare class FanoutTransport {
|
|
|
471
477
|
get subscriberCount(): number;
|
|
472
478
|
}
|
|
473
479
|
|
|
474
|
-
export { AgentAdapter, AgentAdapterConfig, AgentCommand$1 as AgentCommand, AgentEvent, AgentProcessManager, AgentStatus, BaseAgentManager, EventEmitterSubscriberTransport, FanoutTransport, JsonlMessageStore, ProcessManagerAdapter, createAdapterForHarness, listRegisteredHarnesses, registerHarness };
|
|
480
|
+
export { AgentAdapter, AgentAdapterConfig, AgentCommand$1 as AgentCommand, AgentEvent, AgentProcessManager, AgentStatus, BaseAgentManager, EventEmitterSubscriberTransport, FanoutTransport, JsonlMessageStore, ProcessManagerAdapter, createAdapterForHarness, isPortConflict, listRegisteredHarnesses, registerHarness };
|
|
475
481
|
export type { AgentProcessConfig, ManagedSession };
|
package/dist/index.d.ts
CHANGED
|
@@ -2,7 +2,7 @@ import { EventEmitter } from 'node:events';
|
|
|
2
2
|
import { AgentClient } from '@skaile/workspaces/sdk/client';
|
|
3
3
|
import { AgentCommand, AgentEvent as AgentEvent$1, MessageV2 } from '@skaile/workspaces/sdk/types';
|
|
4
4
|
import { AIProviderRegistryView } from '@skaile/forge-settings/server';
|
|
5
|
-
import {
|
|
5
|
+
import { b as AgentAdapter, c as AgentAdapterConfig, A as AgentCommand$1, a as AgentEvent, d as AgentStatus } from './shared/forge-agent-runtime.BqEbyTAQ.js';
|
|
6
6
|
import { SubscriberTransport, SessionDispatcher, MessageStore } from '@skaile/workspaces/sdk/session';
|
|
7
7
|
|
|
8
8
|
/**
|
|
@@ -87,6 +87,12 @@ interface AgentProcessConfig {
|
|
|
87
87
|
*/
|
|
88
88
|
wsAuthToken?: string;
|
|
89
89
|
}
|
|
90
|
+
/**
|
|
91
|
+
* True when the child's stderr says its port was taken. Two texts exist:
|
|
92
|
+
* `EADDRINUSE` (the raw Node error, skaile before 5.12) and
|
|
93
|
+
* `<host>:<port> is already in use: ...` (`skaile serve` 5.12+, which exits 1).
|
|
94
|
+
*/
|
|
95
|
+
declare function isPortConflict(stderrLines: readonly string[]): boolean;
|
|
90
96
|
declare class AgentProcessManager extends EventEmitter {
|
|
91
97
|
private processes;
|
|
92
98
|
constructor();
|
|
@@ -471,5 +477,5 @@ declare class FanoutTransport {
|
|
|
471
477
|
get subscriberCount(): number;
|
|
472
478
|
}
|
|
473
479
|
|
|
474
|
-
export { AgentAdapter, AgentAdapterConfig, AgentCommand$1 as AgentCommand, AgentEvent, AgentProcessManager, AgentStatus, BaseAgentManager, EventEmitterSubscriberTransport, FanoutTransport, JsonlMessageStore, ProcessManagerAdapter, createAdapterForHarness, listRegisteredHarnesses, registerHarness };
|
|
480
|
+
export { AgentAdapter, AgentAdapterConfig, AgentCommand$1 as AgentCommand, AgentEvent, AgentProcessManager, AgentStatus, BaseAgentManager, EventEmitterSubscriberTransport, FanoutTransport, JsonlMessageStore, ProcessManagerAdapter, createAdapterForHarness, isPortConflict, listRegisteredHarnesses, registerHarness };
|
|
475
481
|
export type { AgentProcessConfig, ManagedSession };
|
package/dist/index.mjs
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import { P as ProcessManagerAdapter, A as AgentProcessManager } from './shared/forge-agent-runtime.
|
|
1
|
+
import { P as ProcessManagerAdapter, A as AgentProcessManager } from './shared/forge-agent-runtime.ByEe8ijq.mjs';
|
|
2
|
+
export { i as isPortConflict } from './shared/forge-agent-runtime.ByEe8ijq.mjs';
|
|
2
3
|
import { EventEmitter } from 'node:events';
|
|
3
4
|
import { mkdir, appendFile, readFile } from 'node:fs/promises';
|
|
4
5
|
import { join } from 'node:path';
|
package/dist/shared/{forge-agent-runtime.DlNAoPjs.d.mts → forge-agent-runtime.BqEbyTAQ.d.mts}
RENAMED
|
@@ -119,4 +119,4 @@ interface AgentAdapter {
|
|
|
119
119
|
disconnect(): Promise<void>;
|
|
120
120
|
}
|
|
121
121
|
|
|
122
|
-
export type {
|
|
122
|
+
export type { AgentCommand as A, AgentEvent as a, AgentAdapter as b, AgentAdapterConfig as c, AgentStatus as d };
|
|
@@ -119,4 +119,4 @@ interface AgentAdapter {
|
|
|
119
119
|
disconnect(): Promise<void>;
|
|
120
120
|
}
|
|
121
121
|
|
|
122
|
-
export type {
|
|
122
|
+
export type { AgentCommand as A, AgentEvent as a, AgentAdapter as b, AgentAdapterConfig as c, AgentStatus as d };
|
|
@@ -36,6 +36,9 @@ async function allocatePort() {
|
|
|
36
36
|
}
|
|
37
37
|
throw new Error("Could not find a free port for skaile serve");
|
|
38
38
|
}
|
|
39
|
+
function isPortConflict(stderrLines) {
|
|
40
|
+
return stderrLines.some((l) => l.includes("EADDRINUSE") || l.includes("is already in use"));
|
|
41
|
+
}
|
|
39
42
|
function dialHost(host) {
|
|
40
43
|
if (!host) return "127.0.0.1";
|
|
41
44
|
return host.includes(":") && !host.startsWith("[") ? `[${host}]` : host;
|
|
@@ -210,7 +213,7 @@ class AgentProcessManager extends EventEmitter {
|
|
|
210
213
|
try {
|
|
211
214
|
await this.waitForReady(id, managed, 2e4);
|
|
212
215
|
} catch (err) {
|
|
213
|
-
const portConflict = managed.stderrBuffer
|
|
216
|
+
const portConflict = isPortConflict(managed.stderrBuffer);
|
|
214
217
|
try {
|
|
215
218
|
if (managed.child.exitCode === null) managed.child.kill("SIGKILL");
|
|
216
219
|
} catch {
|
|
@@ -535,4 +538,4 @@ class ProcessManagerAdapter {
|
|
|
535
538
|
}
|
|
536
539
|
}
|
|
537
540
|
|
|
538
|
-
export { AgentProcessManager as A, ProcessManagerAdapter as P };
|
|
541
|
+
export { AgentProcessManager as A, ProcessManagerAdapter as P, isPortConflict as i };
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@skaile/forge-agent-runtime",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.2.0",
|
|
4
4
|
"description": "Agent process lifecycle for Skaile forge apps: spawns `skaile serve`, connects over WebSocket, and exposes the AgentAdapter, harness registry and session manager. Node-only.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"packageManager": "bun@1.3.9",
|
|
@@ -42,8 +42,8 @@
|
|
|
42
42
|
"lint": "biome lint --fix --unsafe src tests"
|
|
43
43
|
},
|
|
44
44
|
"dependencies": {
|
|
45
|
-
"@skaile/forge-settings": "^1.
|
|
46
|
-
"@skaile/workspaces": "^6.11.0",
|
|
45
|
+
"@skaile/forge-settings": "^1.1.1",
|
|
46
|
+
"@skaile/workspaces": "^6.11.0 || ^7.0.0",
|
|
47
47
|
"consola": "^3.4.2",
|
|
48
48
|
"picocolors": "^1.1.1"
|
|
49
49
|
},
|