@fastagent-sh/fastagent 0.13.0 → 0.15.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +5 -5
- package/dist/agent.d.ts +20 -0
- package/dist/agent.js +9 -0
- package/dist/channels/context-buffer.d.ts +30 -0
- package/dist/channels/context-buffer.js +98 -0
- package/dist/channels/control.d.ts +28 -0
- package/dist/channels/control.js +214 -0
- package/dist/channels/feishu/card.js +1 -1
- package/dist/channels/feishu/context-buffer.d.ts +43 -0
- package/dist/channels/feishu/context-buffer.js +72 -0
- package/dist/channels/feishu/crypto.d.ts +4 -2
- package/dist/channels/feishu/crypto.js +4 -2
- package/dist/channels/feishu/feishu-api.d.ts +15 -7
- package/dist/channels/feishu/feishu-api.js +22 -4
- package/dist/channels/feishu/feishu.d.ts +38 -16
- package/dist/channels/feishu/feishu.js +286 -151
- package/dist/channels/feishu/invoke-turn.d.ts +24 -31
- package/dist/channels/feishu/invoke-turn.js +61 -62
- package/dist/channels/feishu/model.d.ts +98 -0
- package/dist/channels/feishu/model.js +9 -0
- package/dist/channels/feishu/normalize.d.ts +23 -0
- package/dist/channels/feishu/normalize.js +132 -0
- package/dist/channels/feishu/owned-threads.d.ts +7 -0
- package/dist/channels/feishu/owned-threads.js +47 -0
- package/dist/channels/feishu/parse.d.ts +21 -103
- package/dist/channels/feishu/parse.js +35 -145
- package/dist/channels/feishu/preview.d.ts +4 -7
- package/dist/channels/feishu/preview.js +26 -142
- package/dist/channels/feishu/register-app.d.ts +2 -1
- package/dist/channels/feishu/register-webhook.d.ts +2 -1
- package/dist/channels/feishu/scaffold/channel.ts +10 -3
- package/dist/channels/feishu/setup-mode.d.ts +8 -0
- package/dist/channels/feishu/setup-mode.js +2 -0
- package/dist/channels/feishu/ws-ingress.d.ts +28 -0
- package/dist/channels/feishu/ws-ingress.js +136 -0
- package/dist/channels/github/github.js +8 -6
- package/dist/channels/http.d.ts +14 -0
- package/dist/channels/http.js +35 -2
- package/dist/channels/invoke-turn-kit.d.ts +65 -0
- package/dist/channels/invoke-turn-kit.js +87 -0
- package/dist/channels/lark/lark.d.ts +4 -2
- package/dist/channels/lark/lark.js +4 -1
- package/dist/channels/lark/onboard.d.ts +8 -4
- package/dist/channels/lark/onboard.js +8 -0
- package/dist/channels/lark/scaffold/channel.ts +10 -3
- package/dist/channels/preview-kit.d.ts +109 -0
- package/dist/channels/preview-kit.js +183 -0
- package/dist/channels/seen.d.ts +5 -0
- package/dist/channels/seen.js +35 -0
- package/dist/channels/slack/bot-auth.d.ts +15 -0
- package/dist/channels/slack/bot-auth.js +146 -0
- package/dist/channels/slack/config-api.d.ts +60 -0
- package/dist/channels/slack/config-api.js +149 -0
- package/dist/channels/slack/context-buffer.d.ts +24 -0
- package/dist/channels/slack/context-buffer.js +37 -0
- package/dist/channels/slack/invoke-turn.d.ts +19 -0
- package/dist/channels/slack/invoke-turn.js +63 -0
- package/dist/channels/slack/manifest.d.ts +49 -0
- package/dist/channels/slack/manifest.js +69 -0
- package/dist/channels/slack/model.d.ts +67 -0
- package/dist/channels/slack/model.js +2 -0
- package/dist/channels/slack/onboard.d.ts +41 -0
- package/dist/channels/slack/onboard.js +120 -0
- package/dist/channels/slack/onboarding-state.d.ts +31 -0
- package/dist/channels/slack/onboarding-state.js +69 -0
- package/dist/channels/slack/owned-threads.d.ts +6 -0
- package/dist/channels/slack/owned-threads.js +43 -0
- package/dist/channels/slack/parse.d.ts +23 -0
- package/dist/channels/slack/parse.js +81 -0
- package/dist/channels/slack/preview.d.ts +24 -0
- package/dist/channels/slack/preview.js +359 -0
- package/dist/channels/slack/reaction.d.ts +24 -0
- package/dist/channels/slack/reaction.js +62 -0
- package/dist/channels/slack/register-webhook.d.ts +10 -0
- package/dist/channels/slack/register-webhook.js +49 -0
- package/dist/channels/slack/scaffold/channel.ts +33 -0
- package/dist/channels/slack/scaffold/slack-send.ts +171 -0
- package/dist/channels/slack/setup-server.d.ts +17 -0
- package/dist/channels/slack/setup-server.js +103 -0
- package/dist/channels/slack/slack-api.d.ts +77 -0
- package/dist/channels/slack/slack-api.js +415 -0
- package/dist/channels/slack/slack.d.ts +58 -0
- package/dist/channels/slack/slack.js +451 -0
- package/dist/channels/slack/welcomed.d.ts +5 -0
- package/dist/channels/slack/welcomed.js +32 -0
- package/dist/channels/state.js +3 -3
- package/dist/channels/stop-command.d.ts +6 -0
- package/dist/channels/stop-command.js +36 -0
- package/dist/channels/tasks.d.ts +13 -0
- package/dist/channels/tasks.js +10 -0
- package/dist/channels/telegram/context-buffer.d.ts +8 -17
- package/dist/channels/telegram/context-buffer.js +6 -85
- package/dist/channels/telegram/invoke-turn.d.ts +5 -22
- package/dist/channels/telegram/invoke-turn.js +11 -58
- package/dist/channels/telegram/preview.d.ts +4 -7
- package/dist/channels/telegram/preview.js +24 -142
- package/dist/channels/telegram/telegram.js +23 -9
- package/dist/channels/{feishu/text.d.ts → text.d.ts} +1 -1
- package/dist/channels/{feishu/text.js → text.js} +1 -1
- package/dist/channels/turn-queue.js +1 -1
- package/dist/channels/turn-store.d.ts +1 -1
- package/dist/channels/turn-store.js +2 -3
- package/dist/cli/add-feishu.d.ts +27 -0
- package/dist/{cli-add-feishu.js → cli/add-feishu.js} +167 -44
- package/dist/cli/add-slack.d.ts +10 -0
- package/dist/cli/add-slack.js +204 -0
- package/dist/{cli-auth.d.ts → cli/auth-view.d.ts} +1 -1
- package/dist/{cli-auth.js → cli/auth-view.js} +1 -1
- package/dist/cli/commands/add.d.ts +13 -0
- package/dist/cli/commands/add.js +274 -0
- package/dist/cli/commands/attach.d.ts +82 -0
- package/dist/cli/commands/attach.js +559 -0
- package/dist/cli/commands/chat.d.ts +4 -0
- package/dist/cli/commands/chat.js +21 -0
- package/dist/cli/commands/deploy.d.ts +15 -0
- package/dist/cli/commands/deploy.js +394 -0
- package/dist/cli/commands/dev.d.ts +11 -0
- package/dist/cli/commands/dev.js +82 -0
- package/dist/cli/commands/fire.d.ts +7 -0
- package/dist/cli/commands/fire.js +45 -0
- package/dist/cli/commands/info.d.ts +7 -0
- package/dist/cli/commands/info.js +108 -0
- package/dist/cli/commands/init.d.ts +8 -0
- package/dist/cli/commands/init.js +81 -0
- package/dist/cli/commands/invoke.d.ts +7 -0
- package/dist/cli/commands/invoke.js +28 -0
- package/dist/cli/commands/login.d.ts +6 -0
- package/dist/cli/commands/login.js +52 -0
- package/dist/cli/commands/models.d.ts +1 -0
- package/dist/cli/commands/models.js +15 -0
- package/dist/cli/commands/schedule.d.ts +12 -0
- package/dist/cli/commands/schedule.js +89 -0
- package/dist/cli/commands/start.d.ts +10 -0
- package/dist/cli/commands/start.js +97 -0
- package/dist/cli/commands/tool.d.ts +1 -0
- package/dist/cli/commands/tool.js +38 -0
- package/dist/cli/fail.d.ts +17 -0
- package/dist/cli/fail.js +32 -0
- package/dist/{invoke-stream.d.ts → cli/invoke-stream.d.ts} +1 -1
- package/dist/{invoke-stream.js → cli/invoke-stream.js} +4 -0
- package/dist/cli/kernel.d.ts +90 -0
- package/dist/cli/kernel.js +190 -0
- package/dist/cli/models-view.d.ts +21 -0
- package/dist/cli/models-view.js +66 -0
- package/dist/cli/program.d.ts +11 -0
- package/dist/cli/program.js +479 -0
- package/dist/cli/serve.d.ts +48 -0
- package/dist/cli/serve.js +248 -0
- package/dist/cli/shared.d.ts +44 -0
- package/dist/cli/shared.js +246 -0
- package/dist/cli.js +8 -1329
- package/dist/collect.d.ts +14 -3
- package/dist/collect.js +24 -0
- package/dist/core.d.ts +3 -1
- package/dist/core.js +2 -0
- package/dist/deploy/docker/plan.d.ts +45 -0
- package/dist/deploy/docker/plan.js +141 -0
- package/dist/deploy/docker/run.d.ts +40 -0
- package/dist/deploy/docker/run.js +126 -0
- package/dist/deploy/fly/plan.d.ts +3 -1
- package/dist/deploy/fly/plan.js +12 -7
- package/dist/deploy/fly/run.d.ts +5 -4
- package/dist/deploy/fly/run.js +16 -5
- package/dist/deploy/preflight.d.ts +10 -4
- package/dist/deploy/preflight.js +57 -12
- package/dist/deploy/railway/plan.d.ts +3 -1
- package/dist/deploy/railway/plan.js +12 -4
- package/dist/deploy/railway/run.d.ts +5 -4
- package/dist/deploy/railway/run.js +15 -5
- package/dist/deploy/runner.d.ts +6 -2
- package/dist/deploy/runner.js +1 -0
- package/dist/deploy/secrets.d.ts +2 -1
- package/dist/deploy/secrets.js +23 -3
- package/dist/dev-supervisor.d.ts +0 -2
- package/dist/dev-supervisor.js +7 -3
- package/dist/engines/pi/auth.js +160 -46
- package/dist/engines/pi/channel.d.ts +22 -16
- package/dist/engines/pi/channel.js +90 -60
- package/dist/engines/pi/chat.d.ts +4 -16
- package/dist/engines/pi/chat.js +8 -188
- package/dist/engines/pi/config.d.ts +23 -9
- package/dist/engines/pi/config.js +35 -5
- package/dist/engines/pi/create.d.ts +36 -7
- package/dist/engines/pi/create.js +63 -22
- package/dist/engines/pi/harness.d.ts +65 -1
- package/dist/engines/pi/harness.js +166 -2
- package/dist/engines/pi/invoke.d.ts +56 -3
- package/dist/engines/pi/invoke.js +340 -20
- package/dist/engines/pi/login.d.ts +11 -0
- package/dist/engines/pi/login.js +17 -5
- package/dist/engines/pi/models.d.ts +56 -10
- package/dist/engines/pi/models.js +61 -23
- package/dist/engines/pi/search-tools.d.ts +10 -0
- package/dist/engines/pi/search-tools.js +138 -0
- package/dist/engines/pi/session-builder.d.ts +16 -0
- package/dist/engines/pi/session-builder.js +308 -0
- package/dist/engines/pi/session-control.d.ts +50 -0
- package/dist/engines/pi/session-control.js +604 -0
- package/dist/engines/pi/sessions.d.ts +17 -2
- package/dist/engines/pi/sessions.js +9 -0
- package/dist/engines/pi/tool-context.d.ts +46 -11
- package/dist/engines/pi/tool-context.js +11 -9
- package/dist/engines/pi/tool.d.ts +35 -6
- package/dist/engines/pi/tool.js +47 -1
- package/dist/engines/pi/wake-tool.d.ts +0 -3
- package/dist/engines/pi/wake-tool.js +9 -7
- package/dist/engines/pi/workspace.d.ts +56 -1
- package/dist/engines/pi/workspace.js +75 -16
- package/dist/feishu.d.ts +1 -1
- package/dist/feishu.js +1 -1
- package/dist/host/node.d.ts +23 -6
- package/dist/host/node.js +5 -4
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/lark.d.ts +1 -1
- package/dist/lark.js +1 -1
- package/dist/observe.js +3 -0
- package/dist/pi.d.ts +6 -4
- package/dist/pi.js +2 -1
- package/dist/scaffold/add-channel.d.ts +19 -6
- package/dist/scaffold/add-channel.js +127 -16
- package/dist/scaffold/templates/fastagent.config.mjs +5 -3
- package/dist/schedule/wakeups.d.ts +0 -3
- package/dist/schedule/wakeups.js +1 -1
- package/dist/session-remote.d.ts +53 -0
- package/dist/session-remote.js +336 -0
- package/dist/session.d.ts +265 -0
- package/dist/session.js +37 -0
- package/dist/slack.d.ts +2 -0
- package/dist/slack.js +2 -0
- package/dist/tunnel.d.ts +7 -4
- package/dist/tunnel.js +21 -10
- package/package.json +22 -7
- package/dist/channels/feishu/seen.d.ts +0 -5
- package/dist/channels/feishu/seen.js +0 -47
- package/dist/cli-add-feishu.d.ts +0 -8
- package/dist/cli-models.d.ts +0 -11
- package/dist/cli-models.js +0 -20
package/dist/deploy/preflight.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* The host-NEUTRAL deploy pre-flight: everything `fastagent deploy <host>` computes and checks BEFORE
|
|
3
|
-
* the
|
|
3
|
+
* the target branch (Docker / Fly / Railway). Model-travel gate, channel discovery, model-auth probe, the
|
|
4
4
|
* container facts + their warnings, and the hand-written-Dockerfile apt warning are identical on every
|
|
5
5
|
* host — so they live here, out of the CLI dispatcher, testable in isolation (call it against a temp dir
|
|
6
6
|
* and assert the gate / messages / facts). The CLI stays thin: run this, print the messages, branch by host.
|
|
@@ -11,8 +11,8 @@
|
|
|
11
11
|
*/
|
|
12
12
|
import { readFile } from "node:fs/promises";
|
|
13
13
|
import { join, relative, sep } from "node:path";
|
|
14
|
-
import {
|
|
15
|
-
import {
|
|
14
|
+
import { resolveAuthPath } from "../engines/pi/config.js";
|
|
15
|
+
import { inspectChannels } from "../engines/pi/channel.js";
|
|
16
16
|
import { discoverScheduleFiles } from "../schedule/discover.js";
|
|
17
17
|
import { createPiModels, probeAuthSource } from "../engines/pi/models.js";
|
|
18
18
|
import { CHANNEL_KINDS } from "../scaffold/add-channel.js";
|
|
@@ -25,7 +25,7 @@ import { isGeneratedDockerfile } from "./container.js";
|
|
|
25
25
|
* provider) — the CLI wraps the call in its `failStartup` so the fault surfaces and exits, never silently.
|
|
26
26
|
*/
|
|
27
27
|
export async function preflightDeploy(input) {
|
|
28
|
-
const { target, agentDir, config, modelSpec, run, force,
|
|
28
|
+
const { target, agentDir, config, modelSpec, run, force, authPathFlag } = input;
|
|
29
29
|
const messages = [];
|
|
30
30
|
// The deployed box resolves the model from fastagent.config.ts ONLY (in the image); a model set via
|
|
31
31
|
// env/flag/.env doesn't travel. `--run` would ship a known crash-loop — hard gate; generate-only warns.
|
|
@@ -35,20 +35,52 @@ export async function preflightDeploy(input) {
|
|
|
35
35
|
return { ok: false, gate: modelIssue };
|
|
36
36
|
messages.push({ level: "warn", text: modelIssue });
|
|
37
37
|
}
|
|
38
|
+
// The control plane on a deployed box: `start` honors `sessionControl: true`, so `/control/*`
|
|
39
|
+
// (steer/abort/set_model) rides the PUBLIC host URL — protected only by a per-boot bearer token
|
|
40
|
+
// minted INSIDE the container (`<stateRoot>/control.json`), which external consumers cannot read.
|
|
41
|
+
// Publicly reachable yet unusable is the worst of both; the tunnel path warns loudly and deploy
|
|
42
|
+
// must not be the silent second way to break the loopback trust story.
|
|
43
|
+
if (config.sessionControl === true) {
|
|
44
|
+
messages.push({
|
|
45
|
+
level: "warn",
|
|
46
|
+
text: `sessionControl: true — the deployed box serves /control/* (steer/abort/set_model) at its public URL, ` +
|
|
47
|
+
`protected only by a per-boot token written inside the container. Read the TOKEN from ` +
|
|
48
|
+
`<stateRoot>/control.json on the box (its url field is container-loopback — pair the token with the ` +
|
|
49
|
+
`public host URL: attach --url <public-url> --token …), or front the endpoint with real auth (design §14)`,
|
|
50
|
+
});
|
|
51
|
+
}
|
|
38
52
|
// Known channel kinds only — a custom channel's secrets/webhook are unknown to us; note and let the
|
|
39
53
|
// author wire them.
|
|
40
|
-
const
|
|
54
|
+
const inspected = await inspectChannels(agentDir);
|
|
55
|
+
if (inspected.failures.length > 0) {
|
|
56
|
+
throw new Error(`cannot inspect channel modules: ${inspected.failures.map((failure) => `${failure.label}: ${failure.message}`).join("; ")}`);
|
|
57
|
+
}
|
|
58
|
+
const discovered = inspected.channels;
|
|
41
59
|
const channels = discovered.filter((c) => CHANNEL_KINDS.includes(c));
|
|
60
|
+
const routeChannels = inspected.routeChannels;
|
|
61
|
+
const longConnectionChannels = inspected.longConnectionChannels;
|
|
42
62
|
for (const c of discovered) {
|
|
43
|
-
if (
|
|
44
|
-
|
|
45
|
-
|
|
63
|
+
if (channels.includes(c))
|
|
64
|
+
continue;
|
|
65
|
+
messages.push({
|
|
66
|
+
level: "note",
|
|
67
|
+
text: longConnectionChannels.includes(c)
|
|
68
|
+
? `long-connection channel "${c}" is custom — configure its secrets yourself; generated deploy plans keep the process running and skip webhook registration`
|
|
69
|
+
: `route channel "${c}" is custom — configure its secrets and webhook yourself`,
|
|
70
|
+
});
|
|
46
71
|
}
|
|
47
72
|
// Time triggers (static schedules or self-scheduling) need a machine kept running — unlike a webhook,
|
|
48
73
|
// nothing external wakes a scale-to-zero box for a cron instant or a wake-up. The note is CONDITIONAL
|
|
49
74
|
// ("the generated plan…"): in KEEP mode an existing fly.toml is not rewritten — the CLI warns separately
|
|
50
75
|
// when a kept fly.toml still scales to zero.
|
|
51
76
|
const hasTimeTriggers = (await discoverScheduleFiles(agentDir)).length > 0 || !!config.selfSchedule;
|
|
77
|
+
if (longConnectionChannels.length > 0) {
|
|
78
|
+
messages.push({
|
|
79
|
+
level: "note",
|
|
80
|
+
text: `long-connection channel present (${longConnectionChannels.join(", ")}) — a GENERATED plan keeps one machine running ` +
|
|
81
|
+
`(an outbound connection cannot wake a scaled-to-zero service).`,
|
|
82
|
+
});
|
|
83
|
+
}
|
|
52
84
|
if (hasTimeTriggers) {
|
|
53
85
|
messages.push({
|
|
54
86
|
level: "note",
|
|
@@ -58,7 +90,7 @@ export async function preflightDeploy(input) {
|
|
|
58
90
|
}
|
|
59
91
|
// Probe auth from the SAME project-level file the opener/login use — not the global default, which would
|
|
60
92
|
// miss a `fastagent login` credential and falsely report "none configured".
|
|
61
|
-
const authPath =
|
|
93
|
+
const authPath = resolveAuthPath(target, authPathFlag);
|
|
62
94
|
const modelAuth = modelSpec ? await probeAuthSource(createPiModels({ authPath }), modelSpec) : undefined;
|
|
63
95
|
// Container facts (shared by every host) + the warnings that follow. Repo-as-workspace layout
|
|
64
96
|
// (agentDir ≠ target): the facts describe the KIT — its package.json/runtime/lockfile drive the
|
|
@@ -68,8 +100,9 @@ export async function preflightDeploy(input) {
|
|
|
68
100
|
const kitDir = agentDir === target ? undefined : relative(target, agentDir).split(sep).join("/");
|
|
69
101
|
const factsDir = kitDir ? agentDir : target;
|
|
70
102
|
if (kitDir && run) {
|
|
71
|
-
//
|
|
72
|
-
//
|
|
103
|
+
// The repo-as-workspace deployment shape remains experimental for every target. Generation +
|
|
104
|
+
// runbook are supported; automated runners stay gated until an explicit end-to-end smoke validates
|
|
105
|
+
// context packing, ignore rules, installed deps, state, and write-back for this layout.
|
|
73
106
|
return {
|
|
74
107
|
ok: false,
|
|
75
108
|
gate: `--run is not yet supported for the agentDir layout — run the same deploy without --run and follow the printed runbook`,
|
|
@@ -172,7 +205,19 @@ export async function preflightDeploy(input) {
|
|
|
172
205
|
});
|
|
173
206
|
}
|
|
174
207
|
}
|
|
175
|
-
return {
|
|
208
|
+
return {
|
|
209
|
+
ok: true,
|
|
210
|
+
messages,
|
|
211
|
+
channels,
|
|
212
|
+
routeChannels,
|
|
213
|
+
longConnectionChannels,
|
|
214
|
+
hasTimeTriggers,
|
|
215
|
+
modelAuth,
|
|
216
|
+
authPath,
|
|
217
|
+
container,
|
|
218
|
+
port,
|
|
219
|
+
extraSecrets,
|
|
220
|
+
};
|
|
176
221
|
}
|
|
177
222
|
/**
|
|
178
223
|
* Why the resolved model won't reach the deployed box, or undefined if it will — host-neutral. `fastagent.config.ts`
|
|
@@ -29,8 +29,10 @@ export interface RailwayPlanInput extends ContainerInput {
|
|
|
29
29
|
serviceName: string;
|
|
30
30
|
/** What satisfies model auth locally: an env-var name, an OAuth/stored label, or undefined. */
|
|
31
31
|
modelAuth: string | undefined;
|
|
32
|
-
/**
|
|
32
|
+
/** Known first-party channels — each contributes its secret metadata + webhook step. */
|
|
33
33
|
channels: ChannelKind[];
|
|
34
|
+
/** All long-connection channel basenames, including custom channels — no App Sleeping. */
|
|
35
|
+
longConnectionChannels?: string[];
|
|
34
36
|
/** Extra secret env-var names (fastagent.config deploy.secrets) — added to the runbook's secret list. */
|
|
35
37
|
extraSecrets?: string[];
|
|
36
38
|
/** Time triggers present (schedules/ or selfSchedule) — the runbook forbids App Sleeping: cron/wake has
|
|
@@ -23,7 +23,7 @@ export function planRailwayDeploy(input) {
|
|
|
23
23
|
{ path: configPath, content: railwayJson(input.kitDir) },
|
|
24
24
|
...containerArtifacts(input),
|
|
25
25
|
];
|
|
26
|
-
const secrets = deploymentSecrets(modelAuth, channels, input.extraSecrets);
|
|
26
|
+
const secrets = deploymentSecrets(modelAuth, channels, input.extraSecrets, input.longConnectionChannels);
|
|
27
27
|
const requiredSecrets = secrets.filter((secret) => secret.required);
|
|
28
28
|
const optionalSecrets = secrets.filter((secret) => !secret.required);
|
|
29
29
|
// Order matters, not cosmetics: `railway init` creates a PROJECT with no service, but the volume and
|
|
@@ -76,7 +76,10 @@ export function planRailwayDeploy(input) {
|
|
|
76
76
|
// The public URL is minted, not deterministic (unlike Fly's <app>.fly.dev) — ONE mint step, then each
|
|
77
77
|
// channel's webhook uses that domain (mint once even when both channels are present).
|
|
78
78
|
const hasFeishuCloudChannel = ["feishu", "lark"].some((kind) => channels.includes(kind));
|
|
79
|
-
if (channels.includes("telegram") ||
|
|
79
|
+
if (channels.includes("telegram") ||
|
|
80
|
+
channels.includes("github") ||
|
|
81
|
+
channels.includes("slack") ||
|
|
82
|
+
hasFeishuCloudChannel) {
|
|
80
83
|
runbook.push(``, `# Public URL — Railway mints a *.up.railway.app domain (NOT deterministic). Generate it, then read`, `# the printed https URL and use it as <your-domain> in the webhook step(s) below:`, `railway domain`);
|
|
81
84
|
}
|
|
82
85
|
if (channels.includes("telegram")) {
|
|
@@ -85,8 +88,11 @@ export function planRailwayDeploy(input) {
|
|
|
85
88
|
if (channels.includes("github")) {
|
|
86
89
|
runbook.push(`# Set the GitHub webhook (repo Settings → Webhooks). Default route POST /webhook; if you remapped it`, `# in channels/github.ts, use your path:`, `# Payload URL = https://<your-domain>/webhook, content type application/json, secret = GITHUB_WEBHOOK_SECRET`);
|
|
87
90
|
}
|
|
91
|
+
if (channels.includes("slack")) {
|
|
92
|
+
runbook.push(`# Set Slack Event Subscriptions → Request URL (default route POST /slack; the running service`, `# answers Slack's challenge), and match scopes/subscriptions to channels/slack.ts groupBehavior:`, `# Request URL = https://<your-domain>/slack`);
|
|
93
|
+
}
|
|
88
94
|
for (const kind of ["feishu", "lark"]) {
|
|
89
|
-
if (!channels.includes(kind))
|
|
95
|
+
if (!channels.includes(kind) || input.longConnectionChannels?.includes(kind))
|
|
90
96
|
continue;
|
|
91
97
|
const label = kind === "feishu" ? "Feishu" : "Lark";
|
|
92
98
|
runbook.push(`# Set the ${label} event Request URL (developer console → Events & Callbacks). Default route`, `# POST /${kind}; the service must be RUNNING when you save (the console verifies with a challenge):`, `# Request URL = https://<your-domain>/${kind}`);
|
|
@@ -98,6 +104,8 @@ export function planRailwayDeploy(input) {
|
|
|
98
104
|
? `# Scale-to-zero: do NOT enable App Sleeping — github turns have no replay, a sleep mid-review is lost.`
|
|
99
105
|
: input.hasTimeTriggers
|
|
100
106
|
? `# Scale-to-zero: do NOT enable App Sleeping — schedules/wake-ups have no external wake-up; a sleeping service sleeps through them.`
|
|
101
|
-
:
|
|
107
|
+
: (input.longConnectionChannels?.length ?? 0) > 0
|
|
108
|
+
? `# Scale-to-zero: do NOT enable App Sleeping — a long-connection channel must remain connected.`
|
|
109
|
+
: `# Scale-to-zero (optional, dashboard-only — no CLI/API): Settings → Deploy → Serverless → App Sleeping.`, `# Keep this a SINGLE service: the ${MOUNT} volume is tied to one service; extra replicas split state.`);
|
|
102
110
|
return { artifacts, runbook };
|
|
103
111
|
}
|
|
@@ -38,6 +38,7 @@ export interface RailwayRunPlan {
|
|
|
38
38
|
/** Required secret names with NO local value — the run gates on these before any side effect. */
|
|
39
39
|
missingSecrets: string[];
|
|
40
40
|
channels: ChannelKind[];
|
|
41
|
+
longConnectionChannels?: string[];
|
|
41
42
|
/** Opt-in (CLI `--into-linked`) to provision INTO the project this directory is already linked to. Off
|
|
42
43
|
* by default so `--run` only creates on an unlinked dir and never deploys into a pre-existing (possibly
|
|
43
44
|
* unrelated/production) project; the flag is the operator's explicit "yes, this project". */
|
|
@@ -76,9 +77,9 @@ export declare function parseDomainUrl(stdout: string): string | undefined;
|
|
|
76
77
|
*/
|
|
77
78
|
export declare function parseHasVolume(stdout: string, mountPath: string): boolean;
|
|
78
79
|
/**
|
|
79
|
-
* Run the deploy through `railway`. `log` reports progress;
|
|
80
|
-
*
|
|
81
|
-
*
|
|
80
|
+
* Run the deploy through `railway`. `log` reports progress; the injected Telegram/Feishu/Slack
|
|
81
|
+
* registrars perform post-deploy webhook steps from the builder machine (Slack's control credential
|
|
82
|
+
* never travels to the host). Absent, the manual console
|
|
82
83
|
* instruction is printed. Every gate is fail-visible.
|
|
83
84
|
*/
|
|
84
|
-
export declare function deployRailwayRun(plan: RailwayRunPlan, railway: CliRunner, log: (msg: string) => void, registerTelegram: (baseUrl: string) => Promise<RegistrationOutcome>, registerFeishu?: (baseUrl: string, kind: "feishu" | "lark") => Promise<RegistrationOutcome>): Promise<RailwayRunOutcome>;
|
|
85
|
+
export declare function deployRailwayRun(plan: RailwayRunPlan, railway: CliRunner, log: (msg: string) => void, registerTelegram: (baseUrl: string) => Promise<RegistrationOutcome>, registerFeishu?: (baseUrl: string, kind: "feishu" | "lark") => Promise<RegistrationOutcome>, registerSlack?: (baseUrl: string) => Promise<RegistrationOutcome>): Promise<RailwayRunOutcome>;
|
|
@@ -62,12 +62,12 @@ export function parseHasVolume(stdout, mountPath) {
|
|
|
62
62
|
return jsonStrings(stdout).includes(mountPath);
|
|
63
63
|
}
|
|
64
64
|
/**
|
|
65
|
-
* Run the deploy through `railway`. `log` reports progress;
|
|
66
|
-
*
|
|
67
|
-
*
|
|
65
|
+
* Run the deploy through `railway`. `log` reports progress; the injected Telegram/Feishu/Slack
|
|
66
|
+
* registrars perform post-deploy webhook steps from the builder machine (Slack's control credential
|
|
67
|
+
* never travels to the host). Absent, the manual console
|
|
68
68
|
* instruction is printed. Every gate is fail-visible.
|
|
69
69
|
*/
|
|
70
|
-
export async function deployRailwayRun(plan, railway, log, registerTelegram, registerFeishu) {
|
|
70
|
+
export async function deployRailwayRun(plan, railway, log, registerTelegram, registerFeishu, registerSlack) {
|
|
71
71
|
const gate = (g) => ({ ok: false, gate: g });
|
|
72
72
|
// Every --service below targets plan.name — the name this tool gives BOTH the project and the service
|
|
73
73
|
// (`init --name` + `add --service`). On a fresh create they match; on `--into-linked` into a hand-made
|
|
@@ -179,8 +179,18 @@ export async function deployRailwayRun(plan, railway, log, registerTelegram, reg
|
|
|
179
179
|
log(`github: set the webhook in the repo (Settings → Webhooks) → ${url}/webhook`);
|
|
180
180
|
reg.track("github", "manual"); // always a human step — re-surface it after the registrar output
|
|
181
181
|
}
|
|
182
|
+
if (plan.channels.includes("slack")) {
|
|
183
|
+
if (registerSlack) {
|
|
184
|
+
log("registering slack event URL…");
|
|
185
|
+
reg.track("slack", await registerSlack(url));
|
|
186
|
+
}
|
|
187
|
+
else {
|
|
188
|
+
log(`slack: set Event Subscriptions → Request URL → ${url}/slack`);
|
|
189
|
+
reg.track("slack", "manual");
|
|
190
|
+
}
|
|
191
|
+
}
|
|
182
192
|
for (const kind of ["feishu", "lark"]) {
|
|
183
|
-
if (!plan.channels.includes(kind))
|
|
193
|
+
if (!plan.channels.includes(kind) || plan.longConnectionChannels?.includes(kind))
|
|
184
194
|
continue;
|
|
185
195
|
if (registerFeishu) {
|
|
186
196
|
log(`registering ${kind} event URL…`);
|
package/dist/deploy/runner.d.ts
CHANGED
|
@@ -1,14 +1,17 @@
|
|
|
1
|
-
|
|
1
|
+
interface RunResult {
|
|
2
2
|
code: number;
|
|
3
3
|
/** Captured stdout (for `--json` queries); empty when the command streamed to the terminal. The CLI's
|
|
4
4
|
* stderr is always inherited straight to the terminal, so it is not a field here. */
|
|
5
5
|
stdout: string;
|
|
6
6
|
}
|
|
7
7
|
/** Run `bin args`: `capture` collects stdout (for `--json` queries), else the command streams to the
|
|
8
|
-
* terminal (create/deploy) and stdout is empty; `input` is fed to stdin (secrets over stdin, never argv).
|
|
8
|
+
* terminal (create/deploy) and stdout is empty; `input` is fed to stdin (secrets over stdin, never argv).
|
|
9
|
+
* `env` adds child-only environment values — Docker Compose interpolates secrets from it without putting
|
|
10
|
+
* values in argv or mutating the long-lived CLI process. */
|
|
9
11
|
export type CliRunner = (args: string[], opts?: {
|
|
10
12
|
capture?: boolean;
|
|
11
13
|
input?: string;
|
|
14
|
+
env?: NodeJS.ProcessEnv;
|
|
12
15
|
}) => Promise<RunResult>;
|
|
13
16
|
/**
|
|
14
17
|
* Production {@link CliRunner}: spawn `bin` in `cwd` (the workspace, so a build/upload context is the
|
|
@@ -16,3 +19,4 @@ export type CliRunner = (args: string[], opts?: {
|
|
|
16
19
|
* ENOENT (the CLI not on PATH) resolves to code 127 so the caller can gate with an install hint.
|
|
17
20
|
*/
|
|
18
21
|
export declare function spawnRunner(bin: string, cwd: string): CliRunner;
|
|
22
|
+
export {};
|
package/dist/deploy/runner.js
CHANGED
|
@@ -13,6 +13,7 @@ export function spawnRunner(bin, cwd) {
|
|
|
13
13
|
return (args, opts) => new Promise((res) => {
|
|
14
14
|
const child = spawn(bin, args, {
|
|
15
15
|
cwd,
|
|
16
|
+
env: opts?.env ? { ...process.env, ...opts.env } : process.env,
|
|
16
17
|
stdio: [opts?.input ? "pipe" : "inherit", opts?.capture ? "pipe" : "inherit", "inherit"],
|
|
17
18
|
});
|
|
18
19
|
let out = "";
|
package/dist/deploy/secrets.d.ts
CHANGED
|
@@ -17,7 +17,7 @@ export declare function isEnvKey(source: string | undefined): source is string;
|
|
|
17
17
|
* deployment prerequisites. An OAuth/stored login has no env key here — it carries as
|
|
18
18
|
* `FASTAGENT_AUTH_SEED` on the `--run` path (see each host's run module), not as a named runbook secret.
|
|
19
19
|
*/
|
|
20
|
-
export declare function deploymentSecrets(modelAuth: string | undefined, channels: ChannelKind[], extraSecrets?: string[]): {
|
|
20
|
+
export declare function deploymentSecrets(modelAuth: string | undefined, channels: ChannelKind[], extraSecrets?: string[], longConnectionChannels?: string[]): {
|
|
21
21
|
name: string;
|
|
22
22
|
hint: string;
|
|
23
23
|
required: boolean;
|
|
@@ -40,6 +40,7 @@ export declare function assembleSecrets(input: {
|
|
|
40
40
|
modelAuth: string | undefined;
|
|
41
41
|
authFile: Buffer | undefined;
|
|
42
42
|
channels: ChannelKind[];
|
|
43
|
+
longConnectionChannels?: string[];
|
|
43
44
|
/** Extra secret env-var names from `fastagent.config` deploy.secrets — carried like channel secrets. */
|
|
44
45
|
extraSecrets?: string[];
|
|
45
46
|
env: NodeJS.ProcessEnv;
|
package/dist/deploy/secrets.js
CHANGED
|
@@ -19,13 +19,15 @@ export function isEnvKey(source) {
|
|
|
19
19
|
* deployment prerequisites. An OAuth/stored login has no env key here — it carries as
|
|
20
20
|
* `FASTAGENT_AUTH_SEED` on the `--run` path (see each host's run module), not as a named runbook secret.
|
|
21
21
|
*/
|
|
22
|
-
export function deploymentSecrets(modelAuth, channels, extraSecrets = []) {
|
|
22
|
+
export function deploymentSecrets(modelAuth, channels, extraSecrets = [], longConnectionChannels = []) {
|
|
23
23
|
const secrets = [];
|
|
24
24
|
if (isEnvKey(modelAuth))
|
|
25
25
|
secrets.push({ name: modelAuth, hint: "your model provider key", required: true });
|
|
26
26
|
for (const kind of channels) {
|
|
27
|
-
|
|
27
|
+
const setupMode = longConnectionChannels.includes(kind) ? "websocket" : "webhook";
|
|
28
|
+
for (const e of channelSetup(kind, setupMode).env) {
|
|
28
29
|
secrets.push({ name: e.name, hint: e.hint, required: e.required });
|
|
30
|
+
}
|
|
29
31
|
}
|
|
30
32
|
// Dedup: a name already covered by the model key / a channel secret must not appear twice in the runbook.
|
|
31
33
|
for (const name of extraSecrets) {
|
|
@@ -67,7 +69,8 @@ export function assembleSecrets(input) {
|
|
|
67
69
|
needsModelCredential = true; // no env key, no auth.json — `fastagent login` remediation
|
|
68
70
|
}
|
|
69
71
|
for (const kind of input.channels) {
|
|
70
|
-
|
|
72
|
+
const setupMode = input.longConnectionChannels?.includes(kind) ? "websocket" : "webhook";
|
|
73
|
+
for (const e of channelSetup(kind, setupMode).env) {
|
|
71
74
|
const v = input.env[e.name];
|
|
72
75
|
if (v)
|
|
73
76
|
secrets[e.name] = v; // optional channel values travel when configured
|
|
@@ -76,6 +79,23 @@ export function assembleSecrets(input) {
|
|
|
76
79
|
}
|
|
77
80
|
}
|
|
78
81
|
}
|
|
82
|
+
// Slack bot-token rotation is an all-or-nothing credential bundle. Its fields remain optional so a
|
|
83
|
+
// manually configured long-lived token works, but a partial bundle must gate before the container
|
|
84
|
+
// reaches slackChannel construction.
|
|
85
|
+
if (input.channels.includes("slack")) {
|
|
86
|
+
const rotation = [
|
|
87
|
+
"SLACK_BOT_REFRESH_TOKEN",
|
|
88
|
+
"SLACK_BOT_TOKEN_EXPIRES_AT",
|
|
89
|
+
"SLACK_CLIENT_ID",
|
|
90
|
+
"SLACK_CLIENT_SECRET",
|
|
91
|
+
];
|
|
92
|
+
if (rotation.some((name) => !!input.env[name])) {
|
|
93
|
+
for (const name of rotation) {
|
|
94
|
+
if (!input.env[name] && !missingSecrets.includes(name))
|
|
95
|
+
missingSecrets.push(name);
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
}
|
|
79
99
|
for (const name of input.extraSecrets ?? []) {
|
|
80
100
|
if (name in secrets || missingSecrets.includes(name))
|
|
81
101
|
continue; // already covered by model/channel — no dup
|
package/dist/dev-supervisor.d.ts
CHANGED
|
@@ -1,5 +1,3 @@
|
|
|
1
|
-
/** What the dev watcher restarts on (workspace-relative): the process-bound code inputs only. */
|
|
2
|
-
export declare const WATCHED_HINT = "tools/, channels/, package.json (agent dir), fastagent.config.*, .env (run root)";
|
|
3
1
|
/**
|
|
4
2
|
* chokidar `ignored` matcher for the narrow watch scope (true = ignore). Ignoring a directory prunes
|
|
5
3
|
* the whole subtree, so everything outside the allowlist — .fastagent state, node_modules, .git, and
|
package/dist/dev-supervisor.js
CHANGED
|
@@ -13,13 +13,13 @@
|
|
|
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 { loadConfig, resolveAgentDir } from "./engines/pi/config.js";
|
|
16
|
+
import { loadConfig, resolveAgentDir, resolveStateRoot } from "./engines/pi/config.js";
|
|
17
17
|
import { log } from "./log.js";
|
|
18
18
|
import { installProxyFetch } from "./proxy.js";
|
|
19
19
|
import { openExternalUrl } from "./open-url.js";
|
|
20
20
|
import { announceWebhooks, startCloudflareTunnel } from "./tunnel.js";
|
|
21
21
|
/** What the dev watcher restarts on (workspace-relative): the process-bound code inputs only. */
|
|
22
|
-
|
|
22
|
+
const WATCHED_HINT = "tools/, channels/, package.json (agent dir), fastagent.config.*, .env (run root)";
|
|
23
23
|
/**
|
|
24
24
|
* chokidar `ignored` matcher for the narrow watch scope (true = ignore). Ignoring a directory prunes
|
|
25
25
|
* the whole subtree, so everything outside the allowlist — .fastagent state, node_modules, .git, and
|
|
@@ -100,7 +100,11 @@ export async function runDevSupervisor(dir, options = {}) {
|
|
|
100
100
|
void startCloudflareTunnel(m.port).then((t) => {
|
|
101
101
|
if (t) {
|
|
102
102
|
tunnel = t;
|
|
103
|
-
void announceWebhooks(
|
|
103
|
+
void announceWebhooks(dir, t.url, {
|
|
104
|
+
openUrl: openExternalUrl,
|
|
105
|
+
routeChannels: m.routeChannels,
|
|
106
|
+
stateRoot: resolveStateRoot(dir),
|
|
107
|
+
});
|
|
104
108
|
}
|
|
105
109
|
});
|
|
106
110
|
}
|
package/dist/engines/pi/auth.js
CHANGED
|
@@ -12,22 +12,24 @@
|
|
|
12
12
|
* token and persisting the new one elsewhere leaves global stale for every other consumer.
|
|
13
13
|
*
|
|
14
14
|
* Sharing is still SAFE the right way: point everything at ONE file (`FASTAGENT_AUTH_PATH` → the
|
|
15
|
-
* global path). One file means one refresh lifecycle under
|
|
16
|
-
*
|
|
15
|
+
* global path). One file means one refresh lifecycle under the store's cross-process write lock
|
|
16
|
+
* (refresh re-reads the latest token under the lock), the documented same-machine pattern.
|
|
17
17
|
* fastagent's store stays SEPARATE from the pi CLI's `~/.pi/agent/auth.json` for the same single-
|
|
18
18
|
* lifecycle reason: two uncoordinated files over one grant would each rotate and break the other.
|
|
19
19
|
*
|
|
20
|
-
*
|
|
21
|
-
*
|
|
22
|
-
*
|
|
23
|
-
* path
|
|
20
|
+
* Locking is vendored here on `proper-lockfile`, with the same parameters pi's file backend used
|
|
21
|
+
* before pi 0.80.8 stopped exporting it (upstream's stated migration path for SDK consumers is a
|
|
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; the in-place locked write opens only a
|
|
24
|
+
* sub-millisecond torn-read window, which `read` absorbs by re-reading. The write path refuses to
|
|
25
|
+
* overwrite a corrupt file (never clobbering other providers' credentials).
|
|
24
26
|
*/
|
|
25
|
-
import { existsSync, readFileSync } from "node:fs";
|
|
27
|
+
import { chmodSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
26
28
|
import { homedir } from "node:os";
|
|
27
|
-
import { join } from "node:path";
|
|
29
|
+
import { dirname, join } from "node:path";
|
|
28
30
|
import { log } from "../../log.js";
|
|
29
31
|
import { setTimeout as sleep } from "node:timers/promises";
|
|
30
|
-
import
|
|
32
|
+
import lockfile from "proper-lockfile";
|
|
31
33
|
/**
|
|
32
34
|
* The GLOBAL fastagent credentials file (distinct from pi's `~/.pi`). The project-level default is
|
|
33
35
|
* `<dir>/.fastagent/auth.json` (computed by the opener and by `fastagent login`); this is only the
|
|
@@ -42,57 +44,169 @@ function pick(creds, providerId) {
|
|
|
42
44
|
const cred = creds[providerId];
|
|
43
45
|
return cred && (cred.type === "oauth" || cred.type === "api_key") ? cred : undefined;
|
|
44
46
|
}
|
|
47
|
+
const AUTH_FILE_WRITE_OPTIONS = { encoding: "utf8", mode: 0o600 };
|
|
45
48
|
/**
|
|
46
|
-
*
|
|
47
|
-
*
|
|
49
|
+
* Serialized cross-process read-modify-write of the credentials file: exponential-backoff retries,
|
|
50
|
+
* 30s staleness, and compromise detection (the parameters pi's `FileAuthStorageBackend` used).
|
|
51
|
+
* Ensures the file exists first (0700 dir, 0600 file, EXCLUSIVE create: a concurrent first write
|
|
52
|
+
* must never be clobbered by the init) because `proper-lockfile` locks an existing path. A
|
|
53
|
+
* compromised lock aborts before the write rather than clobbering a concurrent writer, and a
|
|
54
|
+
* failed unlock after a successful operation rejects instead of leaving a stale lock silently.
|
|
48
55
|
*/
|
|
49
|
-
function
|
|
50
|
-
|
|
51
|
-
|
|
56
|
+
async function withLockedAuthFile(authPath, fn) {
|
|
57
|
+
const dir = dirname(authPath);
|
|
58
|
+
if (!existsSync(dir))
|
|
59
|
+
mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
60
|
+
if (!existsSync(authPath)) {
|
|
61
|
+
try {
|
|
62
|
+
writeFileSync(authPath, "{}", { ...AUTH_FILE_WRITE_OPTIONS, flag: "wx" });
|
|
63
|
+
chmodSync(authPath, 0o600);
|
|
64
|
+
}
|
|
65
|
+
catch (error) {
|
|
66
|
+
// EEXIST: another process created the file between the existence check and this exclusive
|
|
67
|
+
// create; its content (possibly already-written credentials) must not be clobbered.
|
|
68
|
+
if (error.code !== "EEXIST")
|
|
69
|
+
throw error;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
let compromised;
|
|
73
|
+
const throwIfCompromised = () => {
|
|
74
|
+
if (compromised)
|
|
75
|
+
throw compromised;
|
|
76
|
+
};
|
|
77
|
+
const release = await lockfile.lock(authPath, {
|
|
78
|
+
retries: { retries: 10, factor: 2, minTimeout: 100, maxTimeout: 10_000, randomize: true },
|
|
79
|
+
stale: 30_000,
|
|
80
|
+
onCompromised: (error) => {
|
|
81
|
+
compromised = error;
|
|
82
|
+
},
|
|
83
|
+
});
|
|
84
|
+
let result;
|
|
85
|
+
try {
|
|
86
|
+
throwIfCompromised();
|
|
87
|
+
const current = existsSync(authPath) ? readFileSync(authPath, "utf8") : undefined;
|
|
88
|
+
const out = await fn(current);
|
|
89
|
+
throwIfCompromised();
|
|
90
|
+
if (out.next !== undefined) {
|
|
91
|
+
writeFileSync(authPath, out.next, AUTH_FILE_WRITE_OPTIONS);
|
|
92
|
+
chmodSync(authPath, 0o600);
|
|
93
|
+
}
|
|
94
|
+
throwIfCompromised();
|
|
95
|
+
result = out.result;
|
|
96
|
+
}
|
|
97
|
+
catch (error) {
|
|
98
|
+
// The primary failure stays the signal; unlock noise must not mask it.
|
|
99
|
+
try {
|
|
100
|
+
await release();
|
|
101
|
+
}
|
|
102
|
+
catch {
|
|
103
|
+
// Secondary: a compromised or stale-reclaimed lock often cannot release cleanly.
|
|
104
|
+
}
|
|
105
|
+
throw error;
|
|
106
|
+
}
|
|
107
|
+
// Success path: a failed release is a real cleanup failure (the leftover auth.json.lock stalls
|
|
108
|
+
// the next writer for the staleness window with zero diagnostics), so it surfaces instead of
|
|
109
|
+
// resolving a silently degraded operation. A compromise detected after the last in-band check
|
|
110
|
+
// surfaces here too.
|
|
111
|
+
try {
|
|
112
|
+
await release();
|
|
113
|
+
}
|
|
114
|
+
catch (releaseError) {
|
|
115
|
+
if (compromised === undefined)
|
|
116
|
+
throw releaseError;
|
|
117
|
+
}
|
|
118
|
+
throwIfCompromised();
|
|
119
|
+
return result;
|
|
120
|
+
}
|
|
121
|
+
/**
|
|
122
|
+
* Decode the credentials JSON, shared by the read and write paths. The root must be a plain
|
|
123
|
+
* non-null, non-array object: `[]`, `null`, and scalar roots pass JSON.parse but break the record
|
|
124
|
+
* semantics (an array root even swallows writes, since JSON.stringify drops string keys on arrays).
|
|
125
|
+
* Structurally invalid = corrupt, exactly like unparsable text.
|
|
126
|
+
*/
|
|
127
|
+
function decodeCreds(raw) {
|
|
128
|
+
let parsed;
|
|
52
129
|
try {
|
|
53
|
-
|
|
130
|
+
parsed = JSON.parse(raw);
|
|
54
131
|
}
|
|
55
132
|
catch {
|
|
56
|
-
|
|
133
|
+
return undefined;
|
|
57
134
|
}
|
|
135
|
+
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed))
|
|
136
|
+
return undefined;
|
|
137
|
+
return parsed;
|
|
138
|
+
}
|
|
139
|
+
/**
|
|
140
|
+
* Tolerant UNLOCKED read of the whole credentials file, shared by `read` and `list`. The only race
|
|
141
|
+
* is a sub-millisecond in-place write during an OAuth rotation, which can yield an empty/partial
|
|
142
|
+
* file; re-read a few times before concluding it is corrupt. A missing file reads as undefined
|
|
143
|
+
* silently (normal not-configured); a valid file returns immediately, so the common case costs one
|
|
144
|
+
* read.
|
|
145
|
+
*/
|
|
146
|
+
async function readCreds(authPath, warn) {
|
|
147
|
+
for (let attempt = 0; attempt < 3; attempt++) {
|
|
148
|
+
let raw;
|
|
149
|
+
try {
|
|
150
|
+
raw = readFileSync(authPath, "utf8");
|
|
151
|
+
}
|
|
152
|
+
catch (error) {
|
|
153
|
+
if (error.code === "ENOENT")
|
|
154
|
+
return undefined; // missing/deleted
|
|
155
|
+
warn(`[fastagent] cannot read ${authPath}: ${error.message}`);
|
|
156
|
+
return undefined;
|
|
157
|
+
}
|
|
158
|
+
if (raw !== "") {
|
|
159
|
+
const creds = decodeCreds(raw);
|
|
160
|
+
if (creds !== undefined)
|
|
161
|
+
return creds;
|
|
162
|
+
// A partial read mid-write parses as garbage; fall through and retry. A structurally invalid
|
|
163
|
+
// root lands here too and is reported as corrupt below.
|
|
164
|
+
}
|
|
165
|
+
if (attempt < 2)
|
|
166
|
+
await sleep(2);
|
|
167
|
+
}
|
|
168
|
+
warn(`[fastagent] corrupt auth file ${authPath}: fix or remove it`);
|
|
169
|
+
return undefined;
|
|
170
|
+
}
|
|
171
|
+
/**
|
|
172
|
+
* Parse the credentials JSON for a WRITE: a corrupt file must THROW, because serializing `{}` over
|
|
173
|
+
* it would wipe every other provider's credentials. The throw aborts the locked write, leaving the
|
|
174
|
+
* file intact.
|
|
175
|
+
*/
|
|
176
|
+
function parseForWrite(raw, where) {
|
|
177
|
+
if (!raw)
|
|
178
|
+
return {};
|
|
179
|
+
const creds = decodeCreds(raw);
|
|
180
|
+
if (creds === undefined) {
|
|
181
|
+
throw new Error(`refusing to overwrite corrupt auth file ${where}: fix or remove it`);
|
|
182
|
+
}
|
|
183
|
+
return creds;
|
|
58
184
|
}
|
|
59
185
|
/** A read-write `CredentialStore` backed by the given credentials file (default {@link GLOBAL_AUTH_PATH};
|
|
60
186
|
* the directory opener passes the project-level `<dir>/.fastagent/auth.json`). */
|
|
61
187
|
export function fastagentCredentialStore(authPath = GLOBAL_AUTH_PATH, options = {}) {
|
|
62
188
|
const warn = options.warn ?? ((message) => log.warn(message));
|
|
63
|
-
const backend = new FileAuthStorageBackend(authPath);
|
|
64
189
|
return {
|
|
65
190
|
async read(providerId) {
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
}
|
|
80
|
-
if (raw !== "") {
|
|
81
|
-
try {
|
|
82
|
-
return pick(JSON.parse(raw), providerId);
|
|
83
|
-
}
|
|
84
|
-
catch {
|
|
85
|
-
// A partial read mid-write parses as garbage — fall through and retry.
|
|
86
|
-
}
|
|
191
|
+
const creds = await readCreds(authPath, warn);
|
|
192
|
+
return creds ? pick(creds, providerId) : undefined;
|
|
193
|
+
},
|
|
194
|
+
async list() {
|
|
195
|
+
// Metadata only, never secrets (the pi-ai `list` contract). Foreign/old entries are filtered
|
|
196
|
+
// with the same validation as `read`, so both surfaces agree on what "configured" means.
|
|
197
|
+
const creds = await readCreds(authPath, warn);
|
|
198
|
+
if (!creds)
|
|
199
|
+
return [];
|
|
200
|
+
const infos = [];
|
|
201
|
+
for (const [providerId, cred] of Object.entries(creds)) {
|
|
202
|
+
if (cred && (cred.type === "oauth" || cred.type === "api_key")) {
|
|
203
|
+
infos.push({ providerId, type: cred.type });
|
|
87
204
|
}
|
|
88
|
-
if (attempt < 2)
|
|
89
|
-
await sleep(2);
|
|
90
205
|
}
|
|
91
|
-
|
|
92
|
-
return undefined;
|
|
206
|
+
return infos;
|
|
93
207
|
},
|
|
94
208
|
modify(providerId, fn) {
|
|
95
|
-
return
|
|
209
|
+
return withLockedAuthFile(authPath, async (current) => {
|
|
96
210
|
const creds = parseForWrite(current, authPath); // corrupt → throw → no clobber
|
|
97
211
|
const next = await fn(pick(creds, providerId));
|
|
98
212
|
if (next === undefined)
|
|
@@ -102,11 +216,11 @@ export function fastagentCredentialStore(authPath = GLOBAL_AUTH_PATH, options =
|
|
|
102
216
|
});
|
|
103
217
|
},
|
|
104
218
|
async delete(providerId) {
|
|
105
|
-
// No-op when nothing is stored: do NOT take the lock (which would create the file
|
|
106
|
-
//
|
|
219
|
+
// No-op when nothing is stored: do NOT take the lock (which would create the file) on a
|
|
220
|
+
// machine that never stored this provider.
|
|
107
221
|
if (!existsSync(authPath))
|
|
108
222
|
return;
|
|
109
|
-
await
|
|
223
|
+
await withLockedAuthFile(authPath, async (current) => {
|
|
110
224
|
const creds = parseForWrite(current, authPath);
|
|
111
225
|
if (!(providerId in creds))
|
|
112
226
|
return { result: undefined }; // absent: no write
|