@fastagent-sh/fastagent 0.19.0 → 0.20.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/dist/atomic-write.d.ts +11 -7
- package/dist/atomic-write.js +16 -11
- package/dist/channels/agentcore-limits.d.ts +9 -0
- package/dist/channels/agentcore-limits.js +9 -0
- package/dist/channels/agentcore-service.d.ts +12 -11
- package/dist/channels/agentcore-service.js +14 -18
- package/dist/channels/agentcore.d.ts +11 -19
- package/dist/channels/agentcore.js +14 -7
- package/dist/channels/control.d.ts +50 -12
- package/dist/channels/control.js +239 -96
- package/dist/channels/discover.d.ts +26 -4
- package/dist/channels/discover.js +12 -23
- package/dist/channels/feishu/feishu-api.js +5 -8
- package/dist/channels/feishu/feishu.js +35 -18
- package/dist/channels/kit/attachment-path.d.ts +12 -0
- package/dist/channels/kit/attachment-path.js +43 -0
- package/dist/channels/kit/context-buffer.d.ts +9 -0
- package/dist/channels/kit/context-buffer.js +11 -0
- package/dist/channels/kit/signature.d.ts +12 -0
- package/dist/channels/kit/signature.js +17 -0
- package/dist/channels/kit/stop-command.js +3 -3
- package/dist/channels/kit/tasks.d.ts +1 -1
- package/dist/channels/kit/tasks.js +10 -4
- package/dist/channels/kit/turn-store.d.ts +19 -3
- package/dist/channels/kit/turn-store.js +29 -2
- package/dist/channels/slack/invoke-turn.d.ts +1 -1
- package/dist/channels/slack/invoke-turn.js +5 -1
- package/dist/channels/slack/onboard.js +5 -5
- package/dist/channels/slack/onboarding-state.d.ts +7 -3
- package/dist/channels/slack/onboarding-state.js +13 -20
- package/dist/channels/slack/register-webhook.js +1 -1
- package/dist/channels/slack/scaffold/slack-send.ts +2 -17
- package/dist/channels/slack/slack-api.js +22 -54
- package/dist/channels/slack/slack.js +13 -13
- package/dist/channels/telegram/parse.d.ts +21 -1
- package/dist/channels/telegram/parse.js +65 -11
- package/dist/channels/telegram/scaffold/channel.ts +7 -3
- package/dist/channels/telegram/telegram-api.js +5 -6
- package/dist/channels/telegram/telegram.d.ts +2 -2
- package/dist/channels/telegram/telegram.js +57 -69
- package/dist/cli/add-slack.js +6 -5
- package/dist/cli/commands/add.js +1 -5
- package/dist/cli/commands/attach.d.ts +2 -4
- package/dist/cli/commands/attach.js +17 -15
- package/dist/cli/commands/deploy.d.ts +50 -5
- package/dist/cli/commands/deploy.js +183 -134
- package/dist/cli/commands/dev.js +4 -31
- package/dist/cli/commands/info.js +1 -2
- package/dist/cli/commands/init.js +1 -2
- package/dist/cli/commands/start.js +31 -41
- package/dist/cli/serve.d.ts +12 -21
- package/dist/cli/serve.js +20 -25
- package/dist/cli/shared.d.ts +40 -10
- package/dist/cli/shared.js +51 -4
- package/dist/deploy/agentcore/logs.d.ts +2 -2
- package/dist/deploy/agentcore/logs.js +2 -2
- package/dist/deploy/agentcore/plan.d.ts +12 -10
- package/dist/deploy/agentcore/plan.js +39 -31
- package/dist/deploy/agentcore/run.d.ts +5 -4
- package/dist/deploy/agentcore/run.js +13 -36
- package/dist/deploy/channel-ingress.d.ts +73 -0
- package/dist/deploy/channel-ingress.js +101 -0
- package/dist/deploy/docker/plan.d.ts +3 -7
- package/dist/deploy/docker/plan.js +4 -15
- package/dist/deploy/docker/run.d.ts +18 -1
- package/dist/deploy/docker/run.js +10 -0
- package/dist/deploy/fly/plan.d.ts +4 -5
- package/dist/deploy/fly/plan.js +7 -22
- package/dist/deploy/fly/run.d.ts +9 -23
- package/dist/deploy/fly/run.js +32 -68
- package/dist/deploy/preflight.d.ts +6 -7
- package/dist/deploy/preflight.js +26 -22
- package/dist/deploy/railway/plan.d.ts +4 -5
- package/dist/deploy/railway/plan.js +11 -26
- package/dist/deploy/railway/run.d.ts +9 -9
- package/dist/deploy/railway/run.js +40 -40
- package/dist/deploy/runner.js +22 -2
- package/dist/deploy/secrets.d.ts +18 -9
- package/dist/deploy/secrets.js +64 -12
- package/dist/dev-supervisor.js +3 -4
- package/dist/engines/pi/agent-session-factory.js +20 -39
- package/dist/engines/pi/auth.js +43 -43
- package/dist/engines/pi/config.d.ts +1 -1
- package/dist/engines/pi/create.d.ts +14 -2
- package/dist/engines/pi/create.js +2 -2
- package/dist/engines/pi/definition.d.ts +1 -1
- package/dist/engines/pi/definition.js +3 -4
- package/dist/engines/pi/login.js +32 -16
- package/dist/engines/pi/open.d.ts +1 -2
- package/dist/engines/pi/open.js +1 -1
- package/dist/engines/pi/search-tools.js +1 -1
- package/dist/engines/pi/service.d.ts +1 -1
- package/dist/engines/pi/service.js +8 -0
- package/dist/engines/pi/session-builder.js +6 -33
- package/dist/engines/pi/session-control.d.ts +19 -6
- package/dist/engines/pi/session-control.js +565 -470
- package/dist/engines/pi/session-inheritance.d.ts +8 -22
- package/dist/engines/pi/session-inheritance.js +95 -76
- package/dist/engines/pi/session-markers.d.ts +48 -0
- package/dist/engines/pi/session-markers.js +59 -0
- package/dist/engines/pi/session-settings.d.ts +4 -4
- package/dist/engines/pi/session-settings.js +7 -4
- package/dist/engines/pi/session-store.d.ts +83 -13
- package/dist/engines/pi/session-store.js +408 -55
- package/dist/engines/pi/tool-context.d.ts +21 -8
- package/dist/engines/pi/tool-context.js +29 -4
- package/dist/engines/pi/tool.js +2 -2
- package/dist/env.js +1 -2
- package/dist/loader.d.ts +49 -7
- package/dist/loader.js +79 -18
- package/dist/paths.d.ts +19 -0
- package/dist/paths.js +27 -2
- package/dist/scaffold/add-channel.js +2 -2
- package/dist/scaffold/init.js +7 -2
- package/dist/scaffold/templates/tools/fetch-url.ts +0 -2
- package/dist/schedule/discover.js +3 -15
- package/dist/schedule/wake-alarm.d.ts +13 -7
- package/dist/schedule/wake-alarm.js +88 -46
- package/dist/schedule/wakeups.d.ts +1 -1
- package/dist/schedule/wakeups.js +10 -7
- package/dist/service.d.ts +1 -2
- package/dist/service.js +27 -5
- package/dist/session-remote.d.ts +7 -2
- package/dist/session-remote.js +162 -92
- package/dist/session.d.ts +226 -92
- package/dist/session.js +61 -23
- package/dist/telegram.d.ts +1 -1
- package/dist/telegram.js +1 -1
- package/dist/tunnel.d.ts +18 -3
- package/dist/tunnel.js +32 -40
- package/package.json +1 -1
package/dist/deploy/secrets.js
CHANGED
|
@@ -2,8 +2,20 @@
|
|
|
2
2
|
* The secret set a deployed agent needs, computed from the definition — host-neutral. Required values
|
|
3
3
|
* gate every target; optional channel values travel only when configured. Only the SET command differs
|
|
4
4
|
* (`fly secrets import` vs `railway variables set`). The runbooks list both classes; `--run` reads local values.
|
|
5
|
+
*
|
|
6
|
+
* Both DIRECTIONS of the credential carry live here: the deploy-time assembly below, and the boot-time
|
|
7
|
+
* seed read at the bottom. They were split across a host driver (`fly/run.ts`), which left `start`
|
|
8
|
+
* — a serving path that deploys nothing, on Fly or anywhere — importing from it to boot a container.
|
|
5
9
|
*/
|
|
6
|
-
import {
|
|
10
|
+
import { CONTROL_TOKEN_ENV } from "../channels/control.js";
|
|
11
|
+
import { CHANNEL_KINDS, channelSetup } from "../scaffold/add-channel.js";
|
|
12
|
+
/** The declared channels this tool has setup metadata for. A custom channel carries its own secrets;
|
|
13
|
+
* nothing here can name them, and guessing would print a runbook line no one can act on. */
|
|
14
|
+
function firstPartyChannels(channels) {
|
|
15
|
+
return channels.flatMap((channel) => CHANNEL_KINDS.includes(channel.name)
|
|
16
|
+
? [{ kind: channel.name, ingress: channel.ingress }]
|
|
17
|
+
: []);
|
|
18
|
+
}
|
|
7
19
|
/**
|
|
8
20
|
* Is this local auth source an env-var API key (→ becomes a deploy secret) vs OAuth / stored / none?
|
|
9
21
|
* Positive match on the UPPER_SNAKE env-var naming shape, NOT a negative exclude of today's sentinel
|
|
@@ -19,20 +31,30 @@ export function isEnvKey(source) {
|
|
|
19
31
|
* deployment prerequisites. An OAuth/stored login has no env key here — it carries as
|
|
20
32
|
* `FASTAGENT_AUTH_SEED` on the `--run` path (see each host's run module), not as a named runbook secret.
|
|
21
33
|
*/
|
|
22
|
-
export function deploymentSecrets(modelAuth, channels, extraSecrets = []
|
|
34
|
+
export function deploymentSecrets(modelAuth, channels, extraSecrets = []) {
|
|
23
35
|
const secrets = [];
|
|
24
36
|
if (isEnvKey(modelAuth))
|
|
25
37
|
secrets.push({ name: modelAuth, hint: "your model provider key", required: true });
|
|
26
|
-
for (const kind of channels) {
|
|
27
|
-
const
|
|
28
|
-
for (const e of channelSetup(kind, setupMode).env) {
|
|
38
|
+
for (const { kind, ingress } of firstPartyChannels(channels)) {
|
|
39
|
+
for (const e of channelSetup(kind, ingress === "long-connection" ? "websocket" : "webhook").env) {
|
|
29
40
|
secrets.push({ name: e.name, hint: e.hint, required: e.required });
|
|
30
41
|
}
|
|
31
42
|
}
|
|
32
43
|
// Dedup: a name already covered by the model key / a channel secret must not appear twice in the runbook.
|
|
33
44
|
for (const name of extraSecrets) {
|
|
34
45
|
if (!secrets.some((s) => s.name === name)) {
|
|
35
|
-
|
|
46
|
+
const control = name === CONTROL_TOKEN_ENV;
|
|
47
|
+
secrets.push({
|
|
48
|
+
name,
|
|
49
|
+
hint: control
|
|
50
|
+
? "the /control/* bearer token — mint one (uuidgen) and give the same value to callers"
|
|
51
|
+
: "declared in fastagent.config deploy.secrets",
|
|
52
|
+
// OPTIONAL, unlike every other extra: unset, the box mints a per-boot token and still serves,
|
|
53
|
+
// and every host with a shell can read it back out of control.json. Gating would stop deploys
|
|
54
|
+
// that work today to enforce a convenience — the pre-flight warning is where that argument
|
|
55
|
+
// belongs.
|
|
56
|
+
required: !control,
|
|
57
|
+
});
|
|
36
58
|
}
|
|
37
59
|
}
|
|
38
60
|
return secrets;
|
|
@@ -74,9 +96,8 @@ export function assembleSecrets(input) {
|
|
|
74
96
|
else {
|
|
75
97
|
needsModelCredential = true; // no env key, no auth.json — `fastagent login` remediation
|
|
76
98
|
}
|
|
77
|
-
for (const kind of input.channels) {
|
|
78
|
-
const
|
|
79
|
-
for (const e of channelSetup(kind, setupMode).env) {
|
|
99
|
+
for (const { kind, ingress } of firstPartyChannels(input.channels)) {
|
|
100
|
+
for (const e of channelSetup(kind, ingress === "long-connection" ? "websocket" : "webhook").env) {
|
|
80
101
|
const v = input.env[e.name];
|
|
81
102
|
if (v)
|
|
82
103
|
secrets[e.name] = v; // optional channel values travel when configured
|
|
@@ -88,7 +109,7 @@ export function assembleSecrets(input) {
|
|
|
88
109
|
// Slack bot-token rotation is an all-or-nothing credential bundle. Its fields remain optional so a
|
|
89
110
|
// manually configured long-lived token works, but a partial bundle must gate before the container
|
|
90
111
|
// reaches slackChannel construction.
|
|
91
|
-
if (input.channels.
|
|
112
|
+
if (input.channels.some((channel) => channel.name === "slack")) {
|
|
92
113
|
const rotation = [
|
|
93
114
|
"SLACK_BOT_REFRESH_TOKEN",
|
|
94
115
|
"SLACK_BOT_TOKEN_EXPIRES_AT",
|
|
@@ -108,8 +129,39 @@ export function assembleSecrets(input) {
|
|
|
108
129
|
const v = input.env[name];
|
|
109
130
|
if (v)
|
|
110
131
|
secrets[name] = v;
|
|
111
|
-
|
|
112
|
-
|
|
132
|
+
// The control token is CARRIED, never gated — see {@link deploymentSecrets}: unset, the box mints
|
|
133
|
+
// one and serves; every other extra is declared as needed, so its absence is a stop.
|
|
134
|
+
else if (name !== CONTROL_TOKEN_ENV)
|
|
135
|
+
missingSecrets.push(name);
|
|
113
136
|
}
|
|
114
137
|
return { secrets, missingSecrets, needsModelCredential };
|
|
115
138
|
}
|
|
139
|
+
/**
|
|
140
|
+
* The bytes to seed to the auth file, or undefined to leave it alone — the pure core of `start`'s
|
|
141
|
+
* FASTAGENT_AUTH_SEED materialization (the read side of {@link assembleSecrets}'s carry). ABSENT-ONLY
|
|
142
|
+
* by design: a present file (a refreshed volume copy) is never overwritten by the stale seed, so a box
|
|
143
|
+
* that ran its own OAuth refresh is not rolled back.
|
|
144
|
+
*/
|
|
145
|
+
export function authSeedBytes(seed, fileExists) {
|
|
146
|
+
return !seed || fileExists ? undefined : Buffer.from(seed, "base64");
|
|
147
|
+
}
|
|
148
|
+
/**
|
|
149
|
+
* Collect the (possibly CHUNKED) auth seed from the environment: `FASTAGENT_AUTH_SEED` plus numbered
|
|
150
|
+
* continuations (`_2`, `_3`, …) concatenated in order. Hosts whose env values carry a small max
|
|
151
|
+
* length (AgentCore: 2048 chars — a real OAuth auth.json's base64 exceeds it) split the seed across
|
|
152
|
+
* them at deploy time; single-var hosts (Fly/Railway) never set a continuation and are unchanged.
|
|
153
|
+
* Collection stops at the first absent/empty continuation — the writer fills them contiguously.
|
|
154
|
+
*/
|
|
155
|
+
export function collectAuthSeed(env) {
|
|
156
|
+
const first = env.FASTAGENT_AUTH_SEED;
|
|
157
|
+
if (!first)
|
|
158
|
+
return undefined;
|
|
159
|
+
let seed = first;
|
|
160
|
+
for (let i = 2;; i++) {
|
|
161
|
+
const part = env[`FASTAGENT_AUTH_SEED_${i}`];
|
|
162
|
+
if (!part)
|
|
163
|
+
break;
|
|
164
|
+
seed += part;
|
|
165
|
+
}
|
|
166
|
+
return seed;
|
|
167
|
+
}
|
package/dist/dev-supervisor.js
CHANGED
|
@@ -13,12 +13,12 @@
|
|
|
13
13
|
import { spawn } from "node:child_process";
|
|
14
14
|
import { relative, sep } from "node:path";
|
|
15
15
|
import { watch as watchTree } from "chokidar";
|
|
16
|
-
import { AGENT_CONFIG_NAMES, AGENT_MODELS_FILE, resolveStateRoot } from "./paths.js";
|
|
17
|
-
import { isUnderDir } from "./paths.js";
|
|
16
|
+
import { AGENT_CONFIG_NAMES, AGENT_MODELS_FILE, resolveStateRoot, isUnderDir, } from "./paths.js";
|
|
18
17
|
import { dotEnvPath } from "./env.js";
|
|
19
18
|
import { log } from "./log.js";
|
|
20
19
|
import { installProxyFetch } from "./proxy.js";
|
|
21
20
|
import { openExternalUrl } from "./open-url.js";
|
|
21
|
+
import { declaredChannels } from "./channels/discover.js";
|
|
22
22
|
import { announceWebhooks, startCloudflareTunnel } from "./tunnel.js";
|
|
23
23
|
/** What the dev watcher restarts on (agent-dir-relative): the process-bound code inputs only. */
|
|
24
24
|
/**
|
|
@@ -108,9 +108,8 @@ export async function runDevSupervisor(placement, options = {}) {
|
|
|
108
108
|
void startCloudflareTunnel(m.port).then((t) => {
|
|
109
109
|
if (t) {
|
|
110
110
|
tunnel = t;
|
|
111
|
-
void announceWebhooks(placement.agentDir, t.url, {
|
|
111
|
+
void announceWebhooks(placement.agentDir, t.url, declaredChannels(m.routeChannels ?? []), {
|
|
112
112
|
openUrl: openExternalUrl,
|
|
113
|
-
routeChannels: m.routeChannels,
|
|
114
113
|
stateRoot: resolveStateRoot(placement.agentDir),
|
|
115
114
|
});
|
|
116
115
|
}
|
|
@@ -18,7 +18,7 @@ import { log } from "../../log.js";
|
|
|
18
18
|
import { isDeferredTool } from "./tool.js";
|
|
19
19
|
import { activePath, resolveSessionSettings } from "./session-settings.js";
|
|
20
20
|
import { DEFAULT_THINKING_LEVEL } from "./models.js";
|
|
21
|
-
import {
|
|
21
|
+
import { agentSessionManager, sessionToolActivation, turnContext } from "./tool-context.js";
|
|
22
22
|
/**
|
|
23
23
|
* The session custom-entry type recording ONE activation delta: `{ names }` — exactly the deferred
|
|
24
24
|
* tools a loader activated in that call.
|
|
@@ -47,37 +47,6 @@ function recordedActivations(session) {
|
|
|
47
47
|
/** Warned once per session+missing set: a fresh session is built per invoke and channel sessions run
|
|
48
48
|
* for weeks, so an un-deduped warn would repeat every turn and dilute its own signal. */
|
|
49
49
|
const warnedDroppedActivations = new Set();
|
|
50
|
-
/**
|
|
51
|
-
* The turn's {@link ToolActivation} over a live session — the same bridge chat uses, so one
|
|
52
|
-
* built-in `search_tools` serves both.
|
|
53
|
-
*
|
|
54
|
-
* Activations are PERSISTED as deltas, so a tool discovered in one turn stays callable in the next.
|
|
55
|
-
* pi's own chat session does not do this (it has no place to put the record); a served session does,
|
|
56
|
-
* because the alternative is an agent that re-discovers the same capability every single turn.
|
|
57
|
-
*/
|
|
58
|
-
function sessionToolActivation(session) {
|
|
59
|
-
// Serialize activations: the read-modify-write below is only race-free while nothing awaits
|
|
60
|
-
// between read and write, and parallel tool calls in one batch would otherwise double-stamp.
|
|
61
|
-
let chain = Promise.resolve([]);
|
|
62
|
-
return {
|
|
63
|
-
active: () => session.getActiveToolNames(),
|
|
64
|
-
registered: () => session.getAllTools().map((t) => ({ name: t.name, description: t.description ?? "" })),
|
|
65
|
-
activate(names) {
|
|
66
|
-
const run = async () => {
|
|
67
|
-
const current = session.getActiveToolNames();
|
|
68
|
-
const added = additiveActivation(session.getAllTools().map((t) => t.name), current, names);
|
|
69
|
-
if (added.length > 0) {
|
|
70
|
-
session.setActiveToolsByName([...current, ...added]);
|
|
71
|
-
session.sessionManager.appendCustomEntry(TOOL_ACTIVATION_ENTRY, { names: added });
|
|
72
|
-
}
|
|
73
|
-
return added;
|
|
74
|
-
};
|
|
75
|
-
const result = chain.then(run, run);
|
|
76
|
-
chain = result.catch(() => []);
|
|
77
|
-
return result;
|
|
78
|
-
},
|
|
79
|
-
};
|
|
80
|
-
}
|
|
81
50
|
/**
|
|
82
51
|
* fastagent's tools as pi tool definitions, bound to ONE session.
|
|
83
52
|
*
|
|
@@ -85,8 +54,12 @@ function sessionToolActivation(session) {
|
|
|
85
54
|
* tool needs the session to reach the turn context. A tool that somehow runs before that binding
|
|
86
55
|
* throws rather than executing outside the turn: a broken lifecycle must not look like a normal
|
|
87
56
|
* out-of-turn call.
|
|
57
|
+
*
|
|
58
|
+
* It carries the whole turn CONTEXT, not just the session: the activation bridge holds the lock that
|
|
59
|
+
* orders concurrent activations, so it has to live as long as the session the activations mutate.
|
|
60
|
+
* Building it here, per call, would hand each parallel tool its own.
|
|
88
61
|
*/
|
|
89
|
-
function toolDefinitions(tools,
|
|
62
|
+
function toolDefinitions(tools, env, bound) {
|
|
90
63
|
return tools.map((tool) => ({
|
|
91
64
|
name: tool.name,
|
|
92
65
|
label: tool.name,
|
|
@@ -96,10 +69,10 @@ function toolDefinitions(tools, cwd, env, sessionId, bound) {
|
|
|
96
69
|
// without it pi's outer active-set diff double-stamps parallel calls.
|
|
97
70
|
executionMode: tool.executionMode,
|
|
98
71
|
execute: (id, params, signal) => {
|
|
99
|
-
const
|
|
100
|
-
if (!
|
|
101
|
-
throw new Error("tool executed before its
|
|
102
|
-
return turnContext.run(
|
|
72
|
+
const context = bound.context;
|
|
73
|
+
if (!context)
|
|
74
|
+
throw new Error("tool executed before its turn context was bound (lifecycle invariant broken)");
|
|
75
|
+
return turnContext.run(context,
|
|
103
76
|
// Lower-level MountedTools may consume the fifth-argument env. Directory coding tools are
|
|
104
77
|
// cwd-bound and ignore it; authored tools read FastAgent's turnContext instead.
|
|
105
78
|
() => tool.execute(id, params, signal, undefined, { env }));
|
|
@@ -252,9 +225,17 @@ export function piAgentSessionFactory(options) {
|
|
|
252
225
|
// Lower-level callers with an explicit list also rely on omitted built-ins staying omitted.
|
|
253
226
|
noTools: "builtin",
|
|
254
227
|
...(excludedToolNames.length > 0 ? { excludeTools: [...excludedToolNames] } : {}),
|
|
255
|
-
customTools: toolDefinitions(tools,
|
|
228
|
+
customTools: toolDefinitions(tools, env, bound),
|
|
256
229
|
});
|
|
257
|
-
|
|
230
|
+
// One context for the whole session: it describes the SESSION, not the call. The activation
|
|
231
|
+
// bridge above all — a tool call has to see what the previous one activated.
|
|
232
|
+
bound.context = {
|
|
233
|
+
cwd,
|
|
234
|
+
sessionManager: agentSessionManager(session, sessionId),
|
|
235
|
+
// A served session HAS somewhere to record the discovery, so it does: the delta is what makes
|
|
236
|
+
// the tool still callable next turn (see {@link TOOL_ACTIVATION_ENTRY}).
|
|
237
|
+
tools: sessionToolActivation(session, (added) => session.sessionManager.appendCustomEntry(TOOL_ACTIVATION_ENTRY, { names: added })),
|
|
238
|
+
};
|
|
258
239
|
// An extension handler that throws is otherwise dropped: pi fans errors out to registered
|
|
259
240
|
// listeners and has none by default, which on a server means a broken extension looks like an
|
|
260
241
|
// extension that simply did nothing.
|
package/dist/engines/pi/auth.js
CHANGED
|
@@ -20,16 +20,20 @@
|
|
|
20
20
|
* Locking is vendored here on `proper-lockfile`, with the same parameters pi's file backend used
|
|
21
21
|
* before pi 0.80.8 stopped exporting it (upstream's stated migration path for SDK consumers is a
|
|
22
22
|
* custom pi-ai `CredentialStore`, which this file is). The lock guards the WRITE path only. `read`
|
|
23
|
-
* is pi-ai's per-request hot path, so it stays UNLOCKED
|
|
24
|
-
*
|
|
25
|
-
*
|
|
23
|
+
* is pi-ai's per-request hot path, so it stays UNLOCKED — safe because every write that carries a
|
|
24
|
+
* CREDENTIAL publishes by rename ({@link writeFileAtomic}): a reader sees one whole version of the
|
|
25
|
+
* file or another, never a partial one, so a rotation has no torn-read window for an unlocked read
|
|
26
|
+
* to absorb. The one in-place write left is the `{}` bootstrap below, whose window an unlocked read
|
|
27
|
+
* observes as an empty file — reported as corrupt, which for a zero-byte auth.json is the right
|
|
28
|
+
* answer either way. The write path refuses to overwrite a corrupt file (never clobbering other
|
|
29
|
+
* providers' credentials).
|
|
26
30
|
*/
|
|
27
|
-
import { chmodSync, existsSync,
|
|
31
|
+
import { chmodSync, existsSync, readFileSync, writeFileSync } from "node:fs";
|
|
28
32
|
import { homedir } from "node:os";
|
|
29
33
|
import { dirname, join } from "node:path";
|
|
30
|
-
import { GLOBAL_HOME_DIR, SECRETS_DIRNAME } from "../../paths.js";
|
|
34
|
+
import { GLOBAL_HOME_DIR, SECRETS_DIRNAME, SECRET_FILE_MODE, ensureSecretsDir } from "../../paths.js";
|
|
35
|
+
import { writeFileAtomic } from "../../atomic-write.js";
|
|
31
36
|
import { log } from "../../log.js";
|
|
32
|
-
import { setTimeout as sleep } from "node:timers/promises";
|
|
33
37
|
import lockfile from "proper-lockfile";
|
|
34
38
|
/**
|
|
35
39
|
* The GLOBAL fastagent credentials file (distinct from pi's `~/.pi`), under the user-global machinery
|
|
@@ -46,7 +50,7 @@ function pick(creds, providerId) {
|
|
|
46
50
|
const cred = creds[providerId];
|
|
47
51
|
return cred && (cred.type === "oauth" || cred.type === "api_key") ? cred : undefined;
|
|
48
52
|
}
|
|
49
|
-
const AUTH_FILE_WRITE_OPTIONS = { encoding: "utf8", mode:
|
|
53
|
+
const AUTH_FILE_WRITE_OPTIONS = { encoding: "utf8", mode: SECRET_FILE_MODE };
|
|
50
54
|
/**
|
|
51
55
|
* Serialized cross-process read-modify-write of the credentials file: exponential-backoff retries,
|
|
52
56
|
* 30s staleness, and compromise detection (the parameters pi's `FileAuthStorageBackend` used).
|
|
@@ -56,13 +60,15 @@ const AUTH_FILE_WRITE_OPTIONS = { encoding: "utf8", mode: 0o600 };
|
|
|
56
60
|
* failed unlock after a successful operation rejects instead of leaving a stale lock silently.
|
|
57
61
|
*/
|
|
58
62
|
async function withLockedAuthFile(authPath, fn) {
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
63
|
+
// 0700 unconditionally, including on a directory an operator named with `--auth-path`: pointing a
|
|
64
|
+
// credential file somewhere is asking for that somewhere to hold a credential. Where the process
|
|
65
|
+
// cannot chmod (a mount it does not own), this raises — the write fails visibly instead of
|
|
66
|
+
// quietly leaving the directory readable, which is the trade this repo takes everywhere else.
|
|
67
|
+
await ensureSecretsDir(dirname(authPath));
|
|
62
68
|
if (!existsSync(authPath)) {
|
|
63
69
|
try {
|
|
64
70
|
writeFileSync(authPath, "{}", { ...AUTH_FILE_WRITE_OPTIONS, flag: "wx" });
|
|
65
|
-
chmodSync(authPath,
|
|
71
|
+
chmodSync(authPath, SECRET_FILE_MODE);
|
|
66
72
|
}
|
|
67
73
|
catch (error) {
|
|
68
74
|
// EEXIST: another process created the file between the existence check and this exclusive
|
|
@@ -89,10 +95,12 @@ async function withLockedAuthFile(authPath, fn) {
|
|
|
89
95
|
const current = existsSync(authPath) ? readFileSync(authPath, "utf8") : undefined;
|
|
90
96
|
const out = await fn(current);
|
|
91
97
|
throwIfCompromised();
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
98
|
+
// Rename, not an in-place rewrite: it is what lets `read` stay unlocked, and it is the only
|
|
99
|
+
// spelling that applies the mode before the content is reachable — `writeFileSync`'s `mode` is
|
|
100
|
+
// a no-op on an existing file, so a chmod after it leaves the new credential briefly readable
|
|
101
|
+
// at whatever mode the old file carried.
|
|
102
|
+
if (out.next !== undefined)
|
|
103
|
+
writeFileAtomic(authPath, out.next, SECRET_FILE_MODE);
|
|
96
104
|
throwIfCompromised();
|
|
97
105
|
result = out.result;
|
|
98
106
|
}
|
|
@@ -139,34 +147,26 @@ function decodeCreds(raw) {
|
|
|
139
147
|
return parsed;
|
|
140
148
|
}
|
|
141
149
|
/**
|
|
142
|
-
*
|
|
143
|
-
*
|
|
144
|
-
*
|
|
145
|
-
*
|
|
146
|
-
*
|
|
150
|
+
* UNLOCKED read of the whole credentials file, shared by `read` and `list`. ONE read: the write
|
|
151
|
+
* publishes by rename, so this observes one whole version or another and has nothing to absorb by
|
|
152
|
+
* re-reading. A missing file reads as undefined silently (normal not-configured); anything that
|
|
153
|
+
* does not decode is a real corruption — a hand-edit — and says so immediately, rather than after
|
|
154
|
+
* retrying a race that cannot happen.
|
|
147
155
|
*/
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
return undefined;
|
|
159
|
-
}
|
|
160
|
-
if (raw !== "") {
|
|
161
|
-
const creds = decodeCreds(raw);
|
|
162
|
-
if (creds !== undefined)
|
|
163
|
-
return creds;
|
|
164
|
-
// A partial read mid-write parses as garbage; fall through and retry. A structurally invalid
|
|
165
|
-
// root lands here too and is reported as corrupt below.
|
|
166
|
-
}
|
|
167
|
-
if (attempt < 2)
|
|
168
|
-
await sleep(2);
|
|
156
|
+
function readCreds(authPath, warn) {
|
|
157
|
+
let raw;
|
|
158
|
+
try {
|
|
159
|
+
raw = readFileSync(authPath, "utf8");
|
|
160
|
+
}
|
|
161
|
+
catch (error) {
|
|
162
|
+
if (error.code === "ENOENT")
|
|
163
|
+
return undefined; // missing/deleted
|
|
164
|
+
warn(`[fastagent] cannot read ${authPath}: ${error.message}`);
|
|
165
|
+
return undefined;
|
|
169
166
|
}
|
|
167
|
+
const creds = raw === "" ? undefined : decodeCreds(raw);
|
|
168
|
+
if (creds !== undefined)
|
|
169
|
+
return creds;
|
|
170
170
|
warn(`[fastagent] corrupt auth file ${authPath}: fix or remove it`);
|
|
171
171
|
return undefined;
|
|
172
172
|
}
|
|
@@ -190,13 +190,13 @@ export function fastagentCredentialStore(authPath = GLOBAL_AUTH_PATH, options =
|
|
|
190
190
|
const warn = options.warn ?? ((message) => log.warn(message));
|
|
191
191
|
return {
|
|
192
192
|
async read(providerId) {
|
|
193
|
-
const creds =
|
|
193
|
+
const creds = readCreds(authPath, warn);
|
|
194
194
|
return creds ? pick(creds, providerId) : undefined;
|
|
195
195
|
},
|
|
196
196
|
async list() {
|
|
197
197
|
// Metadata only, never secrets (the pi-ai `list` contract). Foreign/old entries are filtered
|
|
198
198
|
// with the same validation as `read`, so both surfaces agree on what "configured" means.
|
|
199
|
-
const creds =
|
|
199
|
+
const creds = readCreds(authPath, warn);
|
|
200
200
|
if (!creds)
|
|
201
201
|
return [];
|
|
202
202
|
const infos = [];
|
|
@@ -24,7 +24,7 @@ export interface FastagentConfig {
|
|
|
24
24
|
selfSchedule?: boolean;
|
|
25
25
|
/**
|
|
26
26
|
* Serve the session control plane over HTTP (`/control/*`: state/entries/events + dispatch —
|
|
27
|
-
* steer/abort/compact
|
|
27
|
+
* steer/abort/compact + session properties and lifecycle) for remote consumers: a Web panel, a desktop app, `fastagent
|
|
28
28
|
* attach`. Default off (it is a remote-control surface). When on, `dev`/`start` generate a
|
|
29
29
|
* per-boot bearer token and write `<stateRoot>/control.json` for local discovery. The serve
|
|
30
30
|
* binds all interfaces by default, so the routes are LAN-reachable with the token as the only
|
|
@@ -1,4 +1,17 @@
|
|
|
1
|
-
|
|
1
|
+
/**
|
|
2
|
+
* Agent assembly (configuration-time): the engine assets (tools, prompt) plus the reusable ladder
|
|
3
|
+
* that puts a pi agent together.
|
|
4
|
+
*
|
|
5
|
+
* L2 createPiAgentFromDefinition(dir, options) — load a definition directory, assemble, then L1.
|
|
6
|
+
* L1 createPiAgent(options) — assemble from typed parts (the canonical ctor).
|
|
7
|
+
* L0 createPiAgentFromSession({ sessionFactory }) — in invoke-session.ts (the turn mechanism).
|
|
8
|
+
*
|
|
9
|
+
* Above L2 sits the agent opener createPiAgentFromDir (open.ts), which both `dev` and
|
|
10
|
+
* `start` drive. Each rung calls the one below; options narrow as you go up (L2 owns systemPrompt/skills —
|
|
11
|
+
* they come from the definition; the openers own model/tools — from config resolution).
|
|
12
|
+
*/
|
|
13
|
+
import { type ExecutionEnv, type Skill, type ThinkingLevel } from "@earendil-works/pi-agent-core";
|
|
14
|
+
import { type ModelRuntime } from "@earendil-works/pi-coding-agent";
|
|
2
15
|
import type { Provider } from "@earendil-works/pi-ai";
|
|
3
16
|
import type { Agent } from "../../agent.ts";
|
|
4
17
|
import { type FastagentConfig } from "./config.ts";
|
|
@@ -7,7 +20,6 @@ import type { ModuleLoadFailure } from "../../loader.ts";
|
|
|
7
20
|
import { type ToolCollision, type MountedTool } from "./tool.ts";
|
|
8
21
|
import { type PiAgentSessionFactory } from "./invoke-session.ts";
|
|
9
22
|
import { type AnyModel } from "./models.ts";
|
|
10
|
-
import type { ModelRuntime } from "@earendil-works/pi-coding-agent";
|
|
11
23
|
import { type PiSessionRecordStore } from "./session-store.ts";
|
|
12
24
|
import { type Lease, type SessionObserver } from "./turn-kit.ts";
|
|
13
25
|
/**
|
|
@@ -10,9 +10,9 @@
|
|
|
10
10
|
* `start` drive. Each rung calls the one below; options narrow as you go up (L2 owns systemPrompt/skills —
|
|
11
11
|
* they come from the definition; the openers own model/tools — from config resolution).
|
|
12
12
|
*/
|
|
13
|
-
import { formatSkillsForSystemPrompt } from "@earendil-works/pi-agent-core";
|
|
13
|
+
import { formatSkillsForSystemPrompt, } from "@earendil-works/pi-agent-core";
|
|
14
14
|
import { NodeExecutionEnv } from "@earendil-works/pi-agent-core/node";
|
|
15
|
-
import { createCodingTools, createPowerShellTool, createReadOnlyTools } from "@earendil-works/pi-coding-agent";
|
|
15
|
+
import { createCodingTools, createPowerShellTool, createReadOnlyTools, } from "@earendil-works/pi-coding-agent";
|
|
16
16
|
import { defaultAuthPath, resolveModel } from "./config.js";
|
|
17
17
|
import { resolveSecretsDir } from "../../paths.js";
|
|
18
18
|
import { loadAgentDefinition, loadExtensionPaths } from "./definition.js";
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type
|
|
1
|
+
import { type ExecutionEnv, type Skill, type SkillDiagnostic } from "@earendil-works/pi-agent-core";
|
|
2
2
|
/** A same-name skill collision (the discarded side). Surfaced, never swallowed. */
|
|
3
3
|
export interface SkillCollision {
|
|
4
4
|
name: string;
|
|
@@ -131,11 +131,10 @@ export async function loadExtensionPaths(agentDir, options = {}) {
|
|
|
131
131
|
return paths.sort();
|
|
132
132
|
}
|
|
133
133
|
/**
|
|
134
|
-
* The first candidate that is a REAL file
|
|
135
|
-
* subdirectory's `index.ts` could otherwise point
|
|
136
|
-
* top-level entries already follow.
|
|
134
|
+
* The first candidate that is a REAL file, and whether one was found but REFUSED as a symlink.
|
|
135
|
+
* `exists` would follow the link, which is how a subdirectory's `index.ts` could otherwise point
|
|
136
|
+
* outside the definition and slip past the rule the top-level entries already follow.
|
|
137
137
|
*/
|
|
138
|
-
/** The first real file among the candidates, and whether one was found but REFUSED as a symlink. */
|
|
139
138
|
async function firstRealFile(e, candidates) {
|
|
140
139
|
let refused = false;
|
|
141
140
|
for (const candidate of candidates) {
|
package/dist/engines/pi/login.js
CHANGED
|
@@ -30,6 +30,18 @@ function authCallbacks(io, userSignal, doneSignal) {
|
|
|
30
30
|
signal: userSignal ?? new AbortController().signal,
|
|
31
31
|
prompt: async (p) => {
|
|
32
32
|
if (p.type === "select") {
|
|
33
|
+
// This branch is UNCANCELLABLE: `LoginIO.select` takes no signal, so all three the line
|
|
34
|
+
// below composes — the provider's own, the caller's `loginFlow({ signal })`, and the
|
|
35
|
+
// pending-prompt backstop — are dropped here.
|
|
36
|
+
//
|
|
37
|
+
// It costs nothing against pi 0.84's providers, where every `select` is the FIRST call of
|
|
38
|
+
// `login()` (bedrock, vertex, openai-codex, radius — all of them asking which login method
|
|
39
|
+
// to use). Nothing is racing it: no callback server is up yet, and `doneSignal` fires when
|
|
40
|
+
// `auth.login()` RETURNS, which it cannot do while blocked on this await.
|
|
41
|
+
//
|
|
42
|
+
// That is a property of the providers, not a guarantee, and nothing here holds it. A
|
|
43
|
+
// provider that ever issues a `select` after starting its callback server shows up as the
|
|
44
|
+
// CLI parked on stdin — widen `LoginIO.select` with a signal then.
|
|
33
45
|
const v = await io.select(p.message, p.options.map((o) => ({ value: o.id, label: o.label, hint: o.description })));
|
|
34
46
|
if (v === undefined)
|
|
35
47
|
throw new LoginCancelled("cancelled");
|
|
@@ -94,9 +106,14 @@ async function selectProvider(io, providers, method, store) {
|
|
|
94
106
|
return { value: p.id, label: auth?.name ?? p.name, hint: cred ? `configured (${cred.type})` : undefined };
|
|
95
107
|
}));
|
|
96
108
|
const id = await io.select("Select a provider", options);
|
|
97
|
-
|
|
109
|
+
// Answers the PROVIDER, not its id: the caller needs the object, and resolving it here means the
|
|
110
|
+
// one place that can fail to is the one that just offered the list. Cancel and an id that was
|
|
111
|
+
// never offered are the same answer — nothing was chosen — and `find` gives both, since a
|
|
112
|
+
// provider id is always a string and `undefined` matches none of them.
|
|
113
|
+
const chosen = candidates.find((p) => p.id === id);
|
|
114
|
+
if (!chosen)
|
|
98
115
|
throw new LoginCancelled("no provider selected");
|
|
99
|
-
return
|
|
116
|
+
return chosen;
|
|
100
117
|
}
|
|
101
118
|
/**
|
|
102
119
|
* Resolve method + provider (asking only what is not given), run the login flow, and persist. A no-op
|
|
@@ -106,26 +123,25 @@ export async function loginFlow(io, options = {}) {
|
|
|
106
123
|
const store = options.store ?? fastagentCredentialStore(options.authPath ?? GLOBAL_AUTH_PATH);
|
|
107
124
|
const providers = options.providers ?? builtinProviders();
|
|
108
125
|
let method;
|
|
109
|
-
let
|
|
126
|
+
let provider;
|
|
110
127
|
if (options.provider) {
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
method = options.method ?? (await methodForProvider(io,
|
|
128
|
+
const named = providers.find((x) => x.id === options.provider);
|
|
129
|
+
if (!named)
|
|
130
|
+
throw new Error(`unknown provider "${options.provider}"`);
|
|
131
|
+
provider = named;
|
|
132
|
+
method = options.method ?? (await methodForProvider(io, provider));
|
|
116
133
|
}
|
|
117
134
|
else {
|
|
118
135
|
method = options.method ?? (await selectMethod(io));
|
|
119
|
-
|
|
136
|
+
provider = await selectProvider(io, providers, method, store);
|
|
120
137
|
}
|
|
121
138
|
// Preflight: a no-op modify runs the refuse-corrupt / writability check BEFORE the flow.
|
|
122
|
-
await store.modify(
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
throw new Error(`unknown provider "${providerId}"`);
|
|
139
|
+
await store.modify(provider.id, async () => undefined);
|
|
140
|
+
// Reachable even though both branches above resolved a provider: an explicit `method` bypasses
|
|
141
|
+
// methodForProvider, so `{ provider: "openai-codex", method: "api_key" }` arrives here.
|
|
126
142
|
const auth = method === "oauth" ? provider.auth.oauth : provider.auth.apiKey;
|
|
127
143
|
if (!auth?.login)
|
|
128
|
-
throw new Error(`provider "${
|
|
144
|
+
throw new Error(`provider "${provider.id}" has no ${method} login`);
|
|
129
145
|
// `done` cancels any prompt left pending when login resolves (manual-code race backstop).
|
|
130
146
|
const done = new AbortController();
|
|
131
147
|
let credential;
|
|
@@ -135,6 +151,6 @@ export async function loginFlow(io, options = {}) {
|
|
|
135
151
|
finally {
|
|
136
152
|
done.abort();
|
|
137
153
|
}
|
|
138
|
-
await store.modify(
|
|
139
|
-
return { provider:
|
|
154
|
+
await store.modify(provider.id, async () => credential);
|
|
155
|
+
return { provider: provider.id, method };
|
|
140
156
|
}
|
|
@@ -5,8 +5,7 @@ import type { SessionObserver } from "./turn-kit.ts";
|
|
|
5
5
|
import type { ModuleLoadFailure } from "../../loader.ts";
|
|
6
6
|
import { type LoadedDefinition } from "./definition.ts";
|
|
7
7
|
import { type PiSessionRecordStore } from "./session-store.ts";
|
|
8
|
-
import type { ToolCollision } from "./tool.ts";
|
|
9
|
-
import type { MountedTool } from "./tool.ts";
|
|
8
|
+
import type { ToolCollision, MountedTool } from "./tool.ts";
|
|
10
9
|
export interface CreatePiAgentFromDirOptions {
|
|
11
10
|
/** Model spec override (e.g. the CLI --model flag). Precedence: this > FASTAGENT_MODEL > config.model. */
|
|
12
11
|
model?: string;
|
package/dist/engines/pi/open.js
CHANGED
|
@@ -95,7 +95,7 @@ export async function createPiAgentFromDir(dir, options = {}) {
|
|
|
95
95
|
},
|
|
96
96
|
// The caller tap's boundary-event half: state_changed/compaction_* originate in the hub
|
|
97
97
|
// and never cross the data plane's observer seam — without this, an audit tap wired here
|
|
98
|
-
// would miss exactly the mutations it most needs to see (
|
|
98
|
+
// would miss exactly the mutations it most needs to see (`update({ model })`).
|
|
99
99
|
tap: caller ? (session, event) => caller(session, event) : undefined,
|
|
100
100
|
})
|
|
101
101
|
: undefined;
|
|
@@ -124,7 +124,7 @@ export function makeSearchToolsTool() {
|
|
|
124
124
|
.map((t) => t.name)
|
|
125
125
|
.join(", ")}${more > 0 ? ` … and ${more} more` : ""}.${activeNote ? ` ${activeNote}` : ""}`;
|
|
126
126
|
}
|
|
127
|
-
const activated =
|
|
127
|
+
const activated = ctx.tools.activate(inactiveMatches.map((t) => t.name));
|
|
128
128
|
// Report what actually happened, not what was attempted: a parallel sibling call may have
|
|
129
129
|
// activated the same matches first, leaving nothing new here — an empty "Activated:" would lie.
|
|
130
130
|
if (activated.length === 0) {
|
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
* pi-specific is opening a DIRECTORY into one, which is why this shortcut lives here and the
|
|
7
7
|
* assembly does not. A second engine ships its own opener and reuses `mountAgentService` unchanged.
|
|
8
8
|
*/
|
|
9
|
-
import type
|
|
9
|
+
import { type AgentService, type MountAgentServiceOptions } from "../../service.ts";
|
|
10
10
|
export interface CreateAgentServiceOptions extends MountAgentServiceOptions {
|
|
11
11
|
model?: string;
|
|
12
12
|
authPath?: string;
|
|
@@ -1,3 +1,11 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `createAgentService` — the product as one call, with pi supplying the engine.
|
|
3
|
+
*
|
|
4
|
+
* The ASSEMBLY is engine-neutral and lives in `src/service.ts`: it takes a {@link MountableAgent}
|
|
5
|
+
* (the SPEC contract plus three paths) and knows nothing about how that agent was built. What is
|
|
6
|
+
* pi-specific is opening a DIRECTORY into one, which is why this shortcut lives here and the
|
|
7
|
+
* assembly does not. A second engine ships its own opener and reuses `mountAgentService` unchanged.
|
|
8
|
+
*/
|
|
1
9
|
import { mountAgentService } from "../../service.js";
|
|
2
10
|
import { createPiAgentFromDir } from "./open.js";
|
|
3
11
|
/**
|