@stratusagent/cli 0.9.2 → 0.11.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 -1
- package/dist/bin.js +4 -1
- package/dist/bin.js.map +1 -1
- package/dist/index.d.ts +214 -4
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1306 -107
- package/dist/index.js.map +1 -1
- package/package.json +22 -22
package/dist/index.js
CHANGED
|
@@ -8,18 +8,19 @@ var __rewriteRelativeImportExtension = (this && this.__rewriteRelativeImportExte
|
|
|
8
8
|
};
|
|
9
9
|
import { randomUUID } from 'node:crypto';
|
|
10
10
|
import { spawn } from 'node:child_process';
|
|
11
|
+
import { fileURLToPath } from 'node:url';
|
|
11
12
|
import { mkdtemp, readdir, readFile, realpath, rm, stat, writeFile } from 'node:fs/promises';
|
|
12
13
|
import os from 'node:os';
|
|
13
14
|
import path from 'node:path';
|
|
14
15
|
import { createInterface } from 'node:readline';
|
|
15
|
-
import { AgentRunner, AllowAllApprovalPolicy, EventBus, SkillRegistry, ToolRegistry, matchesSkillAllowlist, missingSkillRequirements, } from '@stratusagent/core';
|
|
16
|
+
import { AgentRunner, AllowAllApprovalPolicy, EventBus, SkillRegistry, ToolRegistry, matchesSkillAllowlist, missingSkillRequirements, originOf, } from '@stratusagent/core';
|
|
16
17
|
import { createLocalCommandExecutor, } from '@stratusagent/executor-local';
|
|
17
18
|
import { loadPlugins } from '@stratusagent/plugins';
|
|
18
|
-
import { createFileCommandWhitelist, createPermissionPolicy, describeCommandScope, } from '@stratusagent/permissions';
|
|
19
|
+
import { createFileCommandWhitelist, createPermissionPolicy, describeCommandScope, describeOriginScope, } from '@stratusagent/permissions';
|
|
19
20
|
import { DEFAULT_ANTHROPIC_MODEL, redactAnthropicRawTurns, } from '@stratusagent/provider-anthropic';
|
|
20
21
|
import { DEFAULT_CODEX_MODEL } from '@stratusagent/provider-codex';
|
|
21
|
-
import { canonicalDestination, createForgetTool, createRecallTool, createRememberTool, defineAgent, describeCadence, describeSchedule, FORGET_TOOL_NAME, MEMORY_TOOL_NAME, formatSoul, generateAgentName, parseSoul, } from '@stratusagent/agents';
|
|
22
|
-
import { agentsDirPath, claimSoulFile, collectAvailableModels as collectModels, createDemoTool, createFileMemoryStore, createRuntimeProvider, credentialsPath, defaultApiKeyEnvName, DEFAULT_ANTHROPIC_BASE_URL, DEFAULT_OPENAI_BASE_URL, DEFAULT_OPENAI_MODEL, DEFAULT_STRATUS_AGENT, discoverActiveConfig, globalConfigPath, loadChannelCredentials, loadCredentials, installSkillsFromDirectory, loadOperatorSkills, loadRosterSouls, skillsDirPath, listAgentSummaries, loadSoulFile, memoryFilePath, migrateLegacyMemory, parseProviderName, readNonEmptyString, readProcessEnv, resolveConfiguredSoul, logsDirPath, readWorkingDirectory, resolveAgentApprovals, DEFAULT_CONFIG_FILENAME, loadConfigFile, resolveConfigLocation, resolveRuntimeConfig as resolveStateRuntimeConfig, saveChannelCredentials, saveConfigFile, readTrustedConfigBlock, saveCredentials, servedRuntimes, CREDENTIAL_PROVIDER_NAMES, verifyProviderKey, withLegacyDefaultMemories, workspacesDirPath, gatewayInfoPath, gatewayTokenPath, newerStateMessage, pendingStateMigrations, readStateStamp, runStateMigrations, STATE_SCHEMA_VERSION, } from '@stratusagent/state';
|
|
22
|
+
import { canonicalDestination, createForgetTool, createRecallTool, createRememberTool, defineAgent, describeCadence, describeSchedule, FORGET_TOOL_NAME, MEMORY_TOOL_NAME, formatSoul, generateAgentName, isLoadableSkillId, isValidAgentId, parseSoul, } from '@stratusagent/agents';
|
|
23
|
+
import { agentsDirPath, claimSoulFile, collectAvailableModels as collectModels, createDemoTool, createFileCredentialResolver, createFileMemoryStore, createRuntimeProvider, credentialsPath, defaultApiKeyEnvName, DEFAULT_ANTHROPIC_BASE_URL, DEFAULT_OPENAI_BASE_URL, DEFAULT_OPENAI_MODEL, DEFAULT_STRATUS_AGENT, discoverActiveConfig, globalConfigPath, loadChannelCredentials, loadCredentials, loadNamedCredentials, discoverSkillsInDirectory, installSkillsFromDirectory, loadOperatorSkills, loadRosterSouls, skillsDirPath, listAgentSummaries, loadSoulFile, memoryFilePath, migrateLegacyMemory, parseProviderName, readNonEmptyString, readProcessEnv, resolveConfiguredSoul, logsDirPath, readWorkingDirectory, resolveAgentApprovals, DEFAULT_CONFIG_FILENAME, loadConfigFile, resolveConfigLocation, resolveRuntimeConfig as resolveStateRuntimeConfig, saveChannelCredentials, saveConfigFile, readTrustedConfigBlock, saveCredentials, saveNamedCredentials, servedRuntimes, CREDENTIAL_PROVIDER_NAMES, verifyProviderKey, withLegacyDefaultMemories, workspacesDirPath, gatewayInfoPath, gatewayTokenPath, newerStateMessage, pendingStateMigrations, readStateStamp, runStateMigrations, STATE_SCHEMA_VERSION, } from '@stratusagent/state';
|
|
23
24
|
import { installService, readServiceCommand, readServiceStatus, servicePlatform, serviceUnitPath, startService, stopService, uninstallService, } from "./service.js";
|
|
24
25
|
export { installService, launchdPlist, readServiceCommand, readServiceStatus, servicePlatform, serviceUnitPath, startService, stopService, systemdUnit, uninstallService, SERVICE_LABEL, } from "./service.js";
|
|
25
26
|
import { createLogWriter, currentLogPosition, truncateRedirectLogs, formatLogRecord, readRecentRecords, tailLog, } from "./logs.js";
|
|
@@ -123,7 +124,16 @@ const defaultPackageInstaller = async (packages) => {
|
|
|
123
124
|
});
|
|
124
125
|
});
|
|
125
126
|
};
|
|
126
|
-
|
|
127
|
+
/**
|
|
128
|
+
* What a credential name may be: the two conventions in use, and nothing
|
|
129
|
+
* that would be awkward in a soul's `credentials:` list — `search.apiKey`
|
|
130
|
+
* and environment-style `SLACK_TOKEN`. Leading letter required, which also
|
|
131
|
+
* happens to exclude `__proto__`; the store does not *rely* on that (it
|
|
132
|
+
* keys prototype-free maps), because a credentials file can be written by
|
|
133
|
+
* something other than this command.
|
|
134
|
+
*/
|
|
135
|
+
export const CREDENTIAL_NAME_PATTERN = /^[A-Za-z][A-Za-z0-9._-]{0,127}$/;
|
|
136
|
+
export const CLI_VERSION = '0.11.0';
|
|
127
137
|
const DASHBOARD_TITLE = 'Stratus Agent Dashboard';
|
|
128
138
|
/**
|
|
129
139
|
* The range every package declares in its `engines` field. Two floors, not
|
|
@@ -162,6 +172,70 @@ export const unsupportedNodeMessage = (version) => {
|
|
|
162
172
|
+ "The gateway's session store uses node:sqlite, unflagged in 22.13.0 and, on the 23.x line, not until 23.4.0.\n"
|
|
163
173
|
+ 'Upgrade with `brew install node` on macOS, or your package manager or nvm on Linux.';
|
|
164
174
|
};
|
|
175
|
+
/**
|
|
176
|
+
* The warning listeners to install in place of Node's default one, so the
|
|
177
|
+
* `node:sqlite` ExperimentalWarning stops being the first thing the CLI
|
|
178
|
+
* prints.
|
|
179
|
+
*
|
|
180
|
+
* The session store imports `node:sqlite`, which is experimental on every
|
|
181
|
+
* Node release this supports — that is exactly why
|
|
182
|
+
* {@link SUPPORTED_NODE_RANGE} is written the way it is — so the warning
|
|
183
|
+
* announces a dependency the project chose deliberately and a user can do
|
|
184
|
+
* nothing about. It landed above `stratusd ready` on every `stratus serve`,
|
|
185
|
+
* and on `stratus schedules`.
|
|
186
|
+
*
|
|
187
|
+
* Filtered rather than silenced wholesale (`--no-warnings` would be the
|
|
188
|
+
* blunt version): every other warning still reaches stderr through the
|
|
189
|
+
* listeners passed in, because a deprecation in our own dependencies is
|
|
190
|
+
* ours to act on.
|
|
191
|
+
*/
|
|
192
|
+
export const withoutSqliteExperimentalWarning = (listeners) => (warning) => {
|
|
193
|
+
if (warning.name === 'ExperimentalWarning' && /\bSQLite\b/i.test(warning.message)) {
|
|
194
|
+
return;
|
|
195
|
+
}
|
|
196
|
+
for (const listener of listeners) {
|
|
197
|
+
listener(warning);
|
|
198
|
+
}
|
|
199
|
+
};
|
|
200
|
+
/**
|
|
201
|
+
* The name Node gives its own `warning` printer. Matched rather than assumed
|
|
202
|
+
* so that a Node which renames it simply stops being filtered — the warning
|
|
203
|
+
* comes back, which is noise, where guessing wrong would rewrite somebody
|
|
204
|
+
* else's listener.
|
|
205
|
+
*/
|
|
206
|
+
const NODE_DEFAULT_WARNING_LISTENER = 'onWarning';
|
|
207
|
+
/**
|
|
208
|
+
* Swap Node's default warning printer for the filtered one above.
|
|
209
|
+
*
|
|
210
|
+
* Node installs its printer as an ordinary `warning` listener, so adding one
|
|
211
|
+
* alongside it would print twice rather than filter; the default has to come
|
|
212
|
+
* off and be handed to the replacement.
|
|
213
|
+
*
|
|
214
|
+
* **Only ever Node's own printer.** Wrapping a listener somebody else
|
|
215
|
+
* registered changes it in two ways that are not ours to change: `listeners()`
|
|
216
|
+
* hands back the *unwrapped* function for a `once` listener, so re-registering
|
|
217
|
+
* it makes it permanent — it then runs for every later warning instead of one
|
|
218
|
+
* — and a caller that keeps a reference can no longer `process.off('warning',
|
|
219
|
+
* theirs)`, because what is registered is this wrapper. So when anything other
|
|
220
|
+
* than the default is present — a `--require` preload, an embedding host — this
|
|
221
|
+
* does nothing at all and the warning stays. A host that took an interest in
|
|
222
|
+
* warnings owns them.
|
|
223
|
+
*
|
|
224
|
+
* Called once from the binary, before anything imports the session store.
|
|
225
|
+
*/
|
|
226
|
+
export const filterSqliteExperimentalWarning = (process_ = process) => {
|
|
227
|
+
// rawListeners, not listeners: the wrapper is the registration, and only it
|
|
228
|
+
// carries the once-ness. It matters even here, where the guard below means
|
|
229
|
+
// the sole listener is Node's own — the next reader should not have to know
|
|
230
|
+
// that to see why this is safe.
|
|
231
|
+
const existing = process_.rawListeners('warning');
|
|
232
|
+
const [only] = existing;
|
|
233
|
+
if (existing.length !== 1 || only?.name !== NODE_DEFAULT_WARNING_LISTENER) {
|
|
234
|
+
return;
|
|
235
|
+
}
|
|
236
|
+
process_.removeAllListeners('warning');
|
|
237
|
+
process_.on('warning', withoutSqliteExperimentalWarning([only]));
|
|
238
|
+
};
|
|
165
239
|
const HELP_TEXT = `Stratus Agent CLI
|
|
166
240
|
|
|
167
241
|
Usage:
|
|
@@ -179,9 +253,14 @@ Usage:
|
|
|
179
253
|
stratus agents --gateway http://127.0.0.1:4123
|
|
180
254
|
stratus skill add stratuslabs/skill-code-review
|
|
181
255
|
stratus skill add ./my-skills --skill code-review --agent ava
|
|
256
|
+
stratus skill validate ./my-skill
|
|
182
257
|
stratus skills
|
|
258
|
+
stratus skill reload
|
|
259
|
+
stratus restart
|
|
183
260
|
stratus schedules
|
|
184
261
|
stratus schedules cancel <id>
|
|
262
|
+
printf %s "$BRAVE_KEY" | stratus credential set search.apiKey
|
|
263
|
+
stratus credentials
|
|
185
264
|
stratus doctor
|
|
186
265
|
stratus update
|
|
187
266
|
stratus update --check
|
|
@@ -205,7 +284,9 @@ Commands:
|
|
|
205
284
|
run Execute one local Stratus Agent session
|
|
206
285
|
serve Run stratusd, the always-on gateway: durable sessions, the
|
|
207
286
|
whole roster live at once (each agent on its own provider),
|
|
208
|
-
delegation, and a watchdog —
|
|
287
|
+
delegation, and a watchdog — one per home (it refuses to
|
|
288
|
+
start over a daemon already serving ~/.stratus), and
|
|
289
|
+
Ctrl+C / SIGTERM drains cleanly
|
|
209
290
|
(--idle-timeout <seconds>, --approvals <headless|remote>,
|
|
210
291
|
--no-events, --no-log-file, --config <path>); everything it
|
|
211
292
|
says is also written to ~/.stratus/logs, which
|
|
@@ -227,9 +308,39 @@ Commands:
|
|
|
227
308
|
(skills.sh-style repos). Installed is not enabled: a soul
|
|
228
309
|
opts in via skills:, or pass --agent <id> to enable now.
|
|
229
310
|
--skill <id> picks from a multi-skill repo (repeatable),
|
|
230
|
-
--force replaces an already-installed id
|
|
311
|
+
--force replaces an already-installed id. A skill that
|
|
312
|
+
does not conform to the Agent Skills spec is refused,
|
|
313
|
+
naming what is wrong; one that installs with caveats
|
|
314
|
+
(fields another host owns, a bundled scripts/) says so.
|
|
315
|
+
A running daemon reloads its skills afterwards, no
|
|
316
|
+
restart (--no-reload skips that)
|
|
317
|
+
skill validate Check a skill directory, a directory of skills, or an
|
|
318
|
+
installed skill id against the Agent Skills spec — the
|
|
319
|
+
same check "skill add" runs, so what validates installs.
|
|
320
|
+
Exit 1 if anything would be refused
|
|
231
321
|
skills List installed skills and which agents enable each
|
|
232
322
|
(also: stratus skill list)
|
|
323
|
+
skill reload Ask the running daemon to re-read ~/.stratus/skills — for a
|
|
324
|
+
skill edited or removed by hand. A skill that will not
|
|
325
|
+
load refuses the whole reload and the previous set keeps
|
|
326
|
+
serving; nothing becomes reachable to an agent whose soul
|
|
327
|
+
does not list it
|
|
328
|
+
restart Ask the running daemon for an announced restart — what a
|
|
329
|
+
plugin change needs: it refuses new turns, lets in-flight
|
|
330
|
+
ones finish for up to --drain-timeout <seconds> (default
|
|
331
|
+
30), then comes back with sessions, schedules, and
|
|
332
|
+
channels intact, under the service manager or not
|
|
333
|
+
credential set Store a named credential an agent can resolve — a search
|
|
334
|
+
backend asks for search.apiKey. The value is read from
|
|
335
|
+
stdin, never from a flag, so it stays out of your shell
|
|
336
|
+
history: printf %s "$KEY" | stratus credential set
|
|
337
|
+
search.apiKey. --agent <id> stores one agent's own key,
|
|
338
|
+
which outranks the shared entry for that agent. Storing
|
|
339
|
+
grants nothing: the soul still needs credentials: [name]
|
|
340
|
+
credentials List stored credential names and which agents have their
|
|
341
|
+
own — names only, never values (also: credential list)
|
|
342
|
+
credential remove
|
|
343
|
+
Forget one (--agent <id> for that agent's own entry)
|
|
233
344
|
schedules List every schedule the fleet has set — cadence, prompt,
|
|
234
345
|
pre-authorized destination, next firing — straight from the
|
|
235
346
|
daemon's database (--format json). "stratus schedules
|
|
@@ -281,9 +392,16 @@ Options:
|
|
|
281
392
|
--port dashboard: port for a daemon it starts (default: 4123)
|
|
282
393
|
--host dashboard: host for a daemon it starts (default: 127.0.0.1)
|
|
283
394
|
--no-open Do not open the browser automatically
|
|
284
|
-
--gateway agents
|
|
395
|
+
--gateway agents / skill reload / restart: a running daemon's control
|
|
396
|
+
API (skill reload and restart default to the daemon
|
|
397
|
+
~/.stratus/gateway.json names)
|
|
285
398
|
--token Bearer token for --gateway (default: ~/.stratus/gateway-token)
|
|
399
|
+
--no-reload skill add: install without reloading a running daemon
|
|
400
|
+
--reason restart: why, for the daemon's log
|
|
401
|
+
--drain-timeout restart: seconds the daemon lets in-flight turns finish
|
|
402
|
+
before aborting them (default: 30)
|
|
286
403
|
--no-api serve: do not serve the control API
|
|
404
|
+
--api serve: serve it even where the config says api.enabled: false
|
|
287
405
|
--api-host serve: control API interface (default: 127.0.0.1)
|
|
288
406
|
--api-port serve: control API port (default: 4123, 0 for any free port)
|
|
289
407
|
--help, -h Show this help message
|
|
@@ -337,6 +455,23 @@ const readPromptFromStdin = async (stdin) => {
|
|
|
337
455
|
}
|
|
338
456
|
return data.trim();
|
|
339
457
|
};
|
|
458
|
+
/**
|
|
459
|
+
* Stdin exactly as it arrived, for a value where whitespace may be part of
|
|
460
|
+
* it.
|
|
461
|
+
*
|
|
462
|
+
* `readPromptFromStdin` trims, which is right for a prompt and wrong for a
|
|
463
|
+
* secret: a key whose real value has a leading space would be stored as a
|
|
464
|
+
* *different* key, reported as stored, and then fail authentication with
|
|
465
|
+
* nothing to look at — this command never prints a value back.
|
|
466
|
+
*/
|
|
467
|
+
const readSecretFromStdin = async (stdin) => {
|
|
468
|
+
stdin.setEncoding('utf8');
|
|
469
|
+
let data = '';
|
|
470
|
+
for await (const chunk of stdin) {
|
|
471
|
+
data += chunk;
|
|
472
|
+
}
|
|
473
|
+
return data;
|
|
474
|
+
};
|
|
340
475
|
const readOptionValue = (tokens, index, flag) => {
|
|
341
476
|
const value = tokens[index + 1];
|
|
342
477
|
if (!value) {
|
|
@@ -433,6 +568,10 @@ export const parseCommand = (argv, env = {}) => {
|
|
|
433
568
|
parsed.api = false;
|
|
434
569
|
continue;
|
|
435
570
|
}
|
|
571
|
+
if (token === '--api') {
|
|
572
|
+
parsed.api = true;
|
|
573
|
+
continue;
|
|
574
|
+
}
|
|
436
575
|
if (token === '--api-port') {
|
|
437
576
|
const port = Number(readOptionValue(rest, index, '--api-port'));
|
|
438
577
|
if (!Number.isInteger(port) || port < 0 || port > 65_535) {
|
|
@@ -738,6 +877,63 @@ export const parseCommand = (argv, env = {}) => {
|
|
|
738
877
|
...(sessionId ? { sessionId } : {}),
|
|
739
878
|
};
|
|
740
879
|
}
|
|
880
|
+
if (command === 'credentials' || command === 'credential') {
|
|
881
|
+
const [subcommand, ...credentialRest] = command === 'credentials' ? ['list', ...rest] : rest;
|
|
882
|
+
if (subcommand === undefined || subcommand === '--help' || subcommand === '-h') {
|
|
883
|
+
return { command: 'help' };
|
|
884
|
+
}
|
|
885
|
+
if (subcommand !== 'set' && subcommand !== 'list' && subcommand !== 'remove') {
|
|
886
|
+
throw new Error(`No credential subcommand named ${JSON.stringify(subcommand)}. It is set, list, or remove.`);
|
|
887
|
+
}
|
|
888
|
+
let name;
|
|
889
|
+
let agentId;
|
|
890
|
+
for (let index = 0; index < credentialRest.length; index += 1) {
|
|
891
|
+
const token = credentialRest[index];
|
|
892
|
+
if (!token) {
|
|
893
|
+
continue;
|
|
894
|
+
}
|
|
895
|
+
if (token === '--help' || token === '-h') {
|
|
896
|
+
return { command: 'help' };
|
|
897
|
+
}
|
|
898
|
+
if (token === '--agent') {
|
|
899
|
+
agentId = readOptionValue(credentialRest, index, '--agent');
|
|
900
|
+
index += 1;
|
|
901
|
+
continue;
|
|
902
|
+
}
|
|
903
|
+
if (token.startsWith('--')) {
|
|
904
|
+
throw new Error(`Unknown option: ${token}`);
|
|
905
|
+
}
|
|
906
|
+
if (name !== undefined) {
|
|
907
|
+
throw new Error(`credential ${subcommand} takes one credential name; got both ${JSON.stringify(name)} and ${JSON.stringify(token)}.`);
|
|
908
|
+
}
|
|
909
|
+
name = token;
|
|
910
|
+
}
|
|
911
|
+
if (subcommand !== 'list' && name === undefined) {
|
|
912
|
+
throw new Error(`credential ${subcommand} needs a credential name — for a search backend that is search.apiKey.`);
|
|
913
|
+
}
|
|
914
|
+
// A name keys a map that a soul's `credentials:` list also names, so the
|
|
915
|
+
// two spellings have to be able to meet. The store itself is
|
|
916
|
+
// prototype-safe whatever lands in it; this rule is what stops a typo —
|
|
917
|
+
// or a name no soul could ever write — from being stored and then
|
|
918
|
+
// reported missing forever.
|
|
919
|
+
if (name !== undefined && !CREDENTIAL_NAME_PATTERN.test(name)) {
|
|
920
|
+
throw new Error(`${JSON.stringify(name)} is not a credential name. Use letters, digits, dots, dashes, or underscores, `
|
|
921
|
+
+ 'starting with a letter — search.apiKey, or an environment-style SLACK_TOKEN.');
|
|
922
|
+
}
|
|
923
|
+
// The agents package's own rule, not a second one: it is already the
|
|
924
|
+
// answer to "what may key a plain object", and it rejects the names —
|
|
925
|
+
// `__proto__`, `toString` — that would store a credential nothing could
|
|
926
|
+
// ever resolve.
|
|
927
|
+
if (agentId !== undefined && !isValidAgentId(agentId)) {
|
|
928
|
+
throw new Error(`${JSON.stringify(agentId)} cannot be an agent id, so a credential stored under it could never be resolved.`);
|
|
929
|
+
}
|
|
930
|
+
return {
|
|
931
|
+
command: 'credential',
|
|
932
|
+
action: subcommand,
|
|
933
|
+
...(name !== undefined ? { name } : {}),
|
|
934
|
+
...(agentId !== undefined ? { agentId } : {}),
|
|
935
|
+
};
|
|
936
|
+
}
|
|
741
937
|
if (command === 'skills' || (command === 'skill' && rest[0] === 'list')) {
|
|
742
938
|
const skillsRest = command === 'skills' ? rest : rest.slice(1);
|
|
743
939
|
for (const token of skillsRest) {
|
|
@@ -753,13 +949,57 @@ export const parseCommand = (argv, env = {}) => {
|
|
|
753
949
|
if (subcommand === '--help' || subcommand === '-h') {
|
|
754
950
|
return { command: 'help' };
|
|
755
951
|
}
|
|
952
|
+
if (subcommand === 'validate') {
|
|
953
|
+
let target;
|
|
954
|
+
for (const token of skillRest) {
|
|
955
|
+
if (token === '--help' || token === '-h') {
|
|
956
|
+
return { command: 'help' };
|
|
957
|
+
}
|
|
958
|
+
if (token.startsWith('--')) {
|
|
959
|
+
throw new Error(`Unknown option: ${token}`);
|
|
960
|
+
}
|
|
961
|
+
if (target !== undefined) {
|
|
962
|
+
throw new Error(`skill validate takes one target; got both ${JSON.stringify(target)} and ${JSON.stringify(token)}.`);
|
|
963
|
+
}
|
|
964
|
+
target = token;
|
|
965
|
+
}
|
|
966
|
+
if (target === undefined) {
|
|
967
|
+
throw new Error('skill validate needs a target: a skill directory, a directory of skills, or an installed skill id.');
|
|
968
|
+
}
|
|
969
|
+
return { command: 'skill-validate', target };
|
|
970
|
+
}
|
|
971
|
+
if (subcommand === 'reload') {
|
|
972
|
+
const parsed = { command: 'skill-reload' };
|
|
973
|
+
for (let index = 0; index < skillRest.length; index += 1) {
|
|
974
|
+
const token = skillRest[index];
|
|
975
|
+
if (!token) {
|
|
976
|
+
continue;
|
|
977
|
+
}
|
|
978
|
+
if (token === '--help' || token === '-h') {
|
|
979
|
+
return { command: 'help' };
|
|
980
|
+
}
|
|
981
|
+
if (token === '--gateway') {
|
|
982
|
+
parsed.gateway = readOptionValue(skillRest, index, '--gateway');
|
|
983
|
+
index += 1;
|
|
984
|
+
continue;
|
|
985
|
+
}
|
|
986
|
+
if (token === '--token') {
|
|
987
|
+
parsed.token = readOptionValue(skillRest, index, '--token');
|
|
988
|
+
index += 1;
|
|
989
|
+
continue;
|
|
990
|
+
}
|
|
991
|
+
throw new Error(`Unknown option: ${token}`);
|
|
992
|
+
}
|
|
993
|
+
return parsed;
|
|
994
|
+
}
|
|
756
995
|
if (subcommand !== 'add') {
|
|
757
|
-
throw new Error(`Unknown skill subcommand: ${subcommand ?? '(missing)'}. Try: stratus skill add <source>, stratus skills`);
|
|
996
|
+
throw new Error(`Unknown skill subcommand: ${subcommand ?? '(missing)'}. Try: stratus skill add <source>, stratus skill validate <path>, stratus skill reload, stratus skills`);
|
|
758
997
|
}
|
|
759
998
|
let source;
|
|
760
999
|
const skillIds = [];
|
|
761
1000
|
let force = false;
|
|
762
1001
|
let agentId;
|
|
1002
|
+
let reload = true;
|
|
763
1003
|
for (let index = 0; index < skillRest.length; index += 1) {
|
|
764
1004
|
const token = skillRest[index];
|
|
765
1005
|
if (!token) {
|
|
@@ -782,6 +1022,10 @@ export const parseCommand = (argv, env = {}) => {
|
|
|
782
1022
|
force = true;
|
|
783
1023
|
continue;
|
|
784
1024
|
}
|
|
1025
|
+
if (token === '--no-reload') {
|
|
1026
|
+
reload = false;
|
|
1027
|
+
continue;
|
|
1028
|
+
}
|
|
785
1029
|
if (token.startsWith('--')) {
|
|
786
1030
|
throw new Error(`Unknown option: ${token}`);
|
|
787
1031
|
}
|
|
@@ -799,8 +1043,47 @@ export const parseCommand = (argv, env = {}) => {
|
|
|
799
1043
|
...(skillIds.length > 0 ? { skillIds } : {}),
|
|
800
1044
|
...(force ? { force } : {}),
|
|
801
1045
|
...(agentId !== undefined ? { agentId } : {}),
|
|
1046
|
+
...(reload ? {} : { reload }),
|
|
802
1047
|
};
|
|
803
1048
|
}
|
|
1049
|
+
if (command === 'restart') {
|
|
1050
|
+
const parsed = { command: 'restart' };
|
|
1051
|
+
for (let index = 0; index < rest.length; index += 1) {
|
|
1052
|
+
const token = rest[index];
|
|
1053
|
+
if (!token) {
|
|
1054
|
+
continue;
|
|
1055
|
+
}
|
|
1056
|
+
if (token === '--help' || token === '-h') {
|
|
1057
|
+
return { command: 'help' };
|
|
1058
|
+
}
|
|
1059
|
+
if (token === '--reason') {
|
|
1060
|
+
parsed.reason = readOptionValue(rest, index, '--reason');
|
|
1061
|
+
index += 1;
|
|
1062
|
+
continue;
|
|
1063
|
+
}
|
|
1064
|
+
if (token === '--drain-timeout') {
|
|
1065
|
+
const seconds = Number(readOptionValue(rest, index, '--drain-timeout'));
|
|
1066
|
+
if (!Number.isFinite(seconds) || seconds < 0) {
|
|
1067
|
+
throw new Error(`Invalid value for --drain-timeout: ${rest[index + 1] ?? '(missing)'}`);
|
|
1068
|
+
}
|
|
1069
|
+
parsed.drainTimeoutMs = Math.round(seconds * 1000);
|
|
1070
|
+
index += 1;
|
|
1071
|
+
continue;
|
|
1072
|
+
}
|
|
1073
|
+
if (token === '--gateway') {
|
|
1074
|
+
parsed.gateway = readOptionValue(rest, index, '--gateway');
|
|
1075
|
+
index += 1;
|
|
1076
|
+
continue;
|
|
1077
|
+
}
|
|
1078
|
+
if (token === '--token') {
|
|
1079
|
+
parsed.token = readOptionValue(rest, index, '--token');
|
|
1080
|
+
index += 1;
|
|
1081
|
+
continue;
|
|
1082
|
+
}
|
|
1083
|
+
throw new Error(`Unknown option: ${token}`);
|
|
1084
|
+
}
|
|
1085
|
+
return parsed;
|
|
1086
|
+
}
|
|
804
1087
|
if (command === 'agent') {
|
|
805
1088
|
const [subcommand, ...agentRest] = rest;
|
|
806
1089
|
if (subcommand === '--help' || subcommand === '-h') {
|
|
@@ -1120,7 +1403,56 @@ export const warnOnCredentialOverride = async (runtime, streams, env = {}) => {
|
|
|
1120
1403
|
+ 'Unset it to use the subscription, or run `stratus doctor` to see everything that is being overridden.');
|
|
1121
1404
|
}
|
|
1122
1405
|
};
|
|
1123
|
-
|
|
1406
|
+
/**
|
|
1407
|
+
* Where a call would act, for a tool that answers that — read through
|
|
1408
|
+
* `originOf` so this and the daemon's engine compare the same thing.
|
|
1409
|
+
*/
|
|
1410
|
+
const approvalOrigin = (context) => {
|
|
1411
|
+
const reported = context.tool.originFor?.(context.session);
|
|
1412
|
+
return reported === undefined ? undefined : originOf(reported);
|
|
1413
|
+
};
|
|
1414
|
+
/**
|
|
1415
|
+
* What a local prompt asks about: the tool, where it would act when that is
|
|
1416
|
+
* a question at all, and the arguments.
|
|
1417
|
+
*
|
|
1418
|
+
* The origin is not in the arguments and cannot be — a `browser.act` call
|
|
1419
|
+
* carries a CSS selector, and `#submit` is equally "load more results" and
|
|
1420
|
+
* "confirm purchase". A prompt that showed only the selector would be
|
|
1421
|
+
* asking someone to authorize an effect whose location it withheld, which
|
|
1422
|
+
* is the same reason the daemon's prompt and the Slack request name it.
|
|
1423
|
+
*/
|
|
1424
|
+
export const describeApprovalCall = (context) => {
|
|
1425
|
+
const origin = approvalOrigin(context);
|
|
1426
|
+
return `${context.call.toolName}${origin ? ` on ${origin}` : ''}`
|
|
1427
|
+
+ ` with input ${JSON.stringify(context.call.input)}`;
|
|
1428
|
+
};
|
|
1429
|
+
/**
|
|
1430
|
+
* Whether the conversation is still on the page the prompt named.
|
|
1431
|
+
*
|
|
1432
|
+
* A y/N at a terminal takes as long as it takes, and the page is live for
|
|
1433
|
+
* all of it: a redirect between the question and the answer would have a
|
|
1434
|
+
* yes given for one site click on another, with the prompt still showing
|
|
1435
|
+
* the first. So the origin is read again and a page that moved refuses —
|
|
1436
|
+
* the same check the daemon's engine makes after its own approval wait.
|
|
1437
|
+
* This local policy needs its own because it is a separate policy sharing
|
|
1438
|
+
* none of that code: `--approvals ask` judges the call, not a scope.
|
|
1439
|
+
*
|
|
1440
|
+
* Said out loud rather than failing quietly: somebody typed `y` and is owed
|
|
1441
|
+
* an explanation for why nothing happened.
|
|
1442
|
+
*/
|
|
1443
|
+
const stillOnApprovedPage = (context, approved, streams) => {
|
|
1444
|
+
if (context.tool.originFor === undefined) {
|
|
1445
|
+
return true;
|
|
1446
|
+
}
|
|
1447
|
+
const now = approvalOrigin(context);
|
|
1448
|
+
if (now === approved) {
|
|
1449
|
+
return true;
|
|
1450
|
+
}
|
|
1451
|
+
writeLine(streams.stderr, `Not running ${context.call.toolName}: it was approved on ${approved ?? 'a page with no origin'}, `
|
|
1452
|
+
+ `and the conversation ${now === undefined ? 'no longer has that page open' : `is on ${now} now`}.`);
|
|
1453
|
+
return false;
|
|
1454
|
+
};
|
|
1455
|
+
export const createApprovalPolicy = (mode, streams, env, ask) => {
|
|
1124
1456
|
if (mode === 'always') {
|
|
1125
1457
|
return new AllowAllApprovalPolicy();
|
|
1126
1458
|
}
|
|
@@ -1135,25 +1467,27 @@ const createApprovalPolicy = (mode, streams, env, ask) => {
|
|
|
1135
1467
|
// asker — two readers on one stream would race for the same bytes.
|
|
1136
1468
|
if (ask) {
|
|
1137
1469
|
return {
|
|
1138
|
-
async approve(
|
|
1139
|
-
const
|
|
1140
|
-
|
|
1470
|
+
async approve(context) {
|
|
1471
|
+
const approved = approvalOrigin(context);
|
|
1472
|
+
const answer = await ask(`Approve tool call ${describeApprovalCall(context)}? [y/N] `);
|
|
1473
|
+
return /^y(es)?$/i.test(answer.trim()) && stillOnApprovedPage(context, approved, streams);
|
|
1141
1474
|
},
|
|
1142
1475
|
};
|
|
1143
1476
|
}
|
|
1144
1477
|
return {
|
|
1145
|
-
async approve(
|
|
1478
|
+
async approve(context) {
|
|
1146
1479
|
const input = env.approvalInput ?? process.stdin;
|
|
1147
1480
|
const readline = createInterface({ input, terminal: false });
|
|
1481
|
+
const approved = approvalOrigin(context);
|
|
1148
1482
|
// Prompt on stderr so stdout stays parseable (e.g. --format json).
|
|
1149
|
-
streams.stderr.write(`Approve tool call ${
|
|
1483
|
+
streams.stderr.write(`Approve tool call ${describeApprovalCall(context)}? [y/N] `);
|
|
1150
1484
|
try {
|
|
1151
1485
|
const answer = await new Promise((resolve) => {
|
|
1152
1486
|
readline.once('line', resolve);
|
|
1153
1487
|
readline.once('close', () => resolve(''));
|
|
1154
1488
|
});
|
|
1155
1489
|
writeLine(streams.stderr);
|
|
1156
|
-
return /^y(es)?$/i.test(answer.trim());
|
|
1490
|
+
return /^y(es)?$/i.test(answer.trim()) && stillOnApprovedPage(context, approved, streams);
|
|
1157
1491
|
}
|
|
1158
1492
|
finally {
|
|
1159
1493
|
readline.close();
|
|
@@ -1220,6 +1554,7 @@ const createAgentRuntime = async (streams, options) => {
|
|
|
1220
1554
|
tools,
|
|
1221
1555
|
skills,
|
|
1222
1556
|
bus,
|
|
1557
|
+
credentials: createFileCredentialResolver(runEnv),
|
|
1223
1558
|
workspaceRoot: workspacesDirPath(runEnv),
|
|
1224
1559
|
});
|
|
1225
1560
|
loadedPlugins.push(...result.loaded);
|
|
@@ -3086,9 +3421,10 @@ export const runSetup = async (command, streams, env = {}) => {
|
|
|
3086
3421
|
if (chosen.length > 0) {
|
|
3087
3422
|
const packages = chosen.flatMap((group) => group.packages);
|
|
3088
3423
|
writeLine(streams.stdout, `Running: npm install -g ${packages.join(' ')}`);
|
|
3089
|
-
// Never fails setup
|
|
3090
|
-
// the config and credentials are already written,
|
|
3091
|
-
// did not install is a warning at the next start,
|
|
3424
|
+
// Never fails setup — not even its exit code, unlike the always-on
|
|
3425
|
+
// service step below: the config and credentials are already written,
|
|
3426
|
+
// and a package that did not install is a warning at the next start,
|
|
3427
|
+
// not a broken machine.
|
|
3092
3428
|
const result = await (env.packageInstaller ?? defaultPackageInstaller)(packages)
|
|
3093
3429
|
.catch((error) => ({
|
|
3094
3430
|
ok: false,
|
|
@@ -3115,7 +3451,26 @@ export const runSetup = async (command, streams, env = {}) => {
|
|
|
3115
3451
|
: `Skipped. Install them yourself with: npm install -g ${rest}`);
|
|
3116
3452
|
}
|
|
3117
3453
|
};
|
|
3454
|
+
/**
|
|
3455
|
+
* Write everything the menu decided, then report whether the always-on
|
|
3456
|
+
* service step it performed — installing the service, or removing one the
|
|
3457
|
+
* user chose not to run — succeeded.
|
|
3458
|
+
*
|
|
3459
|
+
* A boolean rather than a throw: the service is the one optional part of
|
|
3460
|
+
* setup and everything else is already on disk by the time it runs, so
|
|
3461
|
+
* failing outright would lose the saved config to a service that can be
|
|
3462
|
+
* installed (or removed) later. The exit code carries it instead — the
|
|
3463
|
+
* same answer `stratus service install` and `stratus service uninstall`
|
|
3464
|
+
* give for the identical failure, which is what a script driving setup
|
|
3465
|
+
* has to be able to see.
|
|
3466
|
+
*/
|
|
3118
3467
|
const save = async () => {
|
|
3468
|
+
// The always-on service step is the only one here that can fail without
|
|
3469
|
+
// taking the rest of setup with it, so it is the only one the exit code
|
|
3470
|
+
// has to carry. Both signs count: a unit left in place after "do not run
|
|
3471
|
+
// it for me" starts a daemon at login the user asked not to have, which
|
|
3472
|
+
// is no more a successful setup than one that will not come up at all.
|
|
3473
|
+
let serviceStepFailed = false;
|
|
3119
3474
|
const config = { provider: state.provider };
|
|
3120
3475
|
if (state.provider !== 'demo') {
|
|
3121
3476
|
config.model = state.model ?? defaultModelFor(state.provider);
|
|
@@ -3208,6 +3563,9 @@ export const runSetup = async (command, streams, env = {}) => {
|
|
|
3208
3563
|
for (const message of removed.messages) {
|
|
3209
3564
|
writeLine(removed.ok ? streams.stdout : streams.stderr, message);
|
|
3210
3565
|
}
|
|
3566
|
+
if (!removed.ok) {
|
|
3567
|
+
serviceStepFailed = true;
|
|
3568
|
+
}
|
|
3211
3569
|
}
|
|
3212
3570
|
}
|
|
3213
3571
|
else if (state.service.install && servicePlatform(serviceEnvFor(env))) {
|
|
@@ -3233,6 +3591,7 @@ export const runSetup = async (command, streams, env = {}) => {
|
|
|
3233
3591
|
}
|
|
3234
3592
|
if (!result.ok) {
|
|
3235
3593
|
writeLine(streams.stderr, `Setup is saved either way — start the daemon yourself with \`${serveCommand()}\`.`);
|
|
3594
|
+
serviceStepFailed = true;
|
|
3236
3595
|
}
|
|
3237
3596
|
}
|
|
3238
3597
|
writeLine(streams.stdout);
|
|
@@ -3283,6 +3642,7 @@ export const runSetup = async (command, streams, env = {}) => {
|
|
|
3283
3642
|
if (dashboardReady) {
|
|
3284
3643
|
writeLine(streams.stdout, ' stratus dashboard');
|
|
3285
3644
|
}
|
|
3645
|
+
return !serviceStepFailed;
|
|
3286
3646
|
};
|
|
3287
3647
|
try {
|
|
3288
3648
|
if (!interactive) {
|
|
@@ -3326,8 +3686,13 @@ export const runSetup = async (command, streams, env = {}) => {
|
|
|
3326
3686
|
await testRun();
|
|
3327
3687
|
}
|
|
3328
3688
|
}
|
|
3329
|
-
|
|
3330
|
-
|
|
3689
|
+
// Non-zero when the always-on service step failed, matching `stratus
|
|
3690
|
+
// service install` and `stratus service uninstall`. Everything setup
|
|
3691
|
+
// saved is still saved, and the output above says so — but a daemon that
|
|
3692
|
+
// will not come up at login, or one that will after the user said not to
|
|
3693
|
+
// run it, is not a successful setup, and a script that only reads the
|
|
3694
|
+
// exit code had no way to tell.
|
|
3695
|
+
return (await save()) ? 0 : 1;
|
|
3331
3696
|
}
|
|
3332
3697
|
finally {
|
|
3333
3698
|
prompter.close();
|
|
@@ -4376,6 +4741,18 @@ export const runSkillAdd = async (command, streams, env = {}) => {
|
|
|
4376
4741
|
});
|
|
4377
4742
|
for (const skill of result.installed) {
|
|
4378
4743
|
writeLine(streams.stdout, `installed ${skill.id} — ${skill.description}`);
|
|
4744
|
+
// The spec's compatibility field is written for exactly this reader:
|
|
4745
|
+
// the person deciding whether this environment can carry the skill.
|
|
4746
|
+
if (skill.compatibility !== undefined) {
|
|
4747
|
+
writeLine(streams.stdout, ` compatibility: ${skill.compatibility}`);
|
|
4748
|
+
}
|
|
4749
|
+
}
|
|
4750
|
+
// What installed with a caveat — a field another host owns, the
|
|
4751
|
+
// legacy key form, a bundled scripts/ — is said next to the install,
|
|
4752
|
+
// per skill: the operator deciding to enable it is the one who needs
|
|
4753
|
+
// to hear it, and this is the moment they are looking.
|
|
4754
|
+
for (const warning of result.warnings) {
|
|
4755
|
+
writeLine(streams.stderr, `Warning: ${warning.id}: ${warning.message}`);
|
|
4379
4756
|
}
|
|
4380
4757
|
// Present already is a no-op, not a failure — and exactly what the
|
|
4381
4758
|
// "rerun with --agent" hint below produces, so these stay eligible
|
|
@@ -4392,6 +4769,13 @@ export const runSkillAdd = async (command, streams, env = {}) => {
|
|
|
4392
4769
|
: `Error: no skills found in ${command.source}. A skill is a directory with a SKILL.md.`);
|
|
4393
4770
|
return 1;
|
|
4394
4771
|
}
|
|
4772
|
+
// The ordinary path needs no second step: a daemon that is running
|
|
4773
|
+
// serves what was just installed from its next turn. Only when
|
|
4774
|
+
// something changed on disk — a re-run that installed nothing has
|
|
4775
|
+
// nothing to reload — and only where a daemon has said it is running.
|
|
4776
|
+
if (result.installed.length > 0 && command.reload !== false) {
|
|
4777
|
+
await reloadRunningDaemonSkills(streams, env);
|
|
4778
|
+
}
|
|
4395
4779
|
// Installed is not enabled: a soul opts in through its skills:
|
|
4396
4780
|
// allowlist, and that stays true when the install came through a
|
|
4397
4781
|
// command. --agent is the explicit way to say both at once.
|
|
@@ -4479,6 +4863,279 @@ export const runSchedules = async (command, streams, env = {}) => {
|
|
|
4479
4863
|
store.close();
|
|
4480
4864
|
}
|
|
4481
4865
|
};
|
|
4866
|
+
/**
|
|
4867
|
+
* `stratus credential set|list|remove`: the place a named credential goes.
|
|
4868
|
+
*
|
|
4869
|
+
* A credential nobody can add is a credential nobody has, which is why this
|
|
4870
|
+
* exists at all — a search backend asks for `search.apiKey` and until now
|
|
4871
|
+
* there was nowhere to put one. Three rules it keeps, all of them
|
|
4872
|
+
* deliberate:
|
|
4873
|
+
*
|
|
4874
|
+
* The value is read from **stdin, never from a flag**. A secret in argv is
|
|
4875
|
+
* a secret in shell history and in every `ps` on the machine.
|
|
4876
|
+
*
|
|
4877
|
+
* Nothing here ever prints a value back. `list` reports names and which
|
|
4878
|
+
* agents have their own, the same posture the control API's credential read
|
|
4879
|
+
* already keeps.
|
|
4880
|
+
*
|
|
4881
|
+
* And storing one grants no agent anything: the agent's soul still has to
|
|
4882
|
+
* list the name under `credentials:`, which is the per-identity gate.
|
|
4883
|
+
*/
|
|
4884
|
+
export const runCredential = async (command, streams, env = {}) => {
|
|
4885
|
+
const named = await loadNamedCredentials(env);
|
|
4886
|
+
if (command.action === 'list') {
|
|
4887
|
+
const shared = Object.keys(named.shared).sort();
|
|
4888
|
+
const agents = Object.entries(named.agents).sort(([a], [b]) => a.localeCompare(b));
|
|
4889
|
+
if (shared.length === 0 && agents.length === 0) {
|
|
4890
|
+
writeLine(streams.stdout, `No named credentials stored in ${credentialsPath(env)}.`);
|
|
4891
|
+
writeLine(streams.stdout, 'A search backend wants `stratus credential set search.apiKey`.');
|
|
4892
|
+
return 0;
|
|
4893
|
+
}
|
|
4894
|
+
if (shared.length > 0) {
|
|
4895
|
+
writeLine(streams.stdout, 'Shared with the whole fleet:');
|
|
4896
|
+
for (const name of shared) {
|
|
4897
|
+
writeLine(streams.stdout, ` ${name}`);
|
|
4898
|
+
}
|
|
4899
|
+
}
|
|
4900
|
+
for (const [agentId, entries] of agents) {
|
|
4901
|
+
writeLine(streams.stdout, `Only ${agentId}:`);
|
|
4902
|
+
for (const name of Object.keys(entries).sort()) {
|
|
4903
|
+
writeLine(streams.stdout, ` ${name}`);
|
|
4904
|
+
}
|
|
4905
|
+
}
|
|
4906
|
+
// Names, never values — and say so, so nobody goes looking for a flag
|
|
4907
|
+
// that prints one.
|
|
4908
|
+
writeLine(streams.stdout, '');
|
|
4909
|
+
writeLine(streams.stdout, `Values are never printed. They live in ${credentialsPath(env)}, readable only by you.`);
|
|
4910
|
+
return 0;
|
|
4911
|
+
}
|
|
4912
|
+
const name = command.name ?? '';
|
|
4913
|
+
if (command.action === 'remove') {
|
|
4914
|
+
const scope = command.agentId;
|
|
4915
|
+
const store = scope === undefined ? named.shared : named.agents[scope];
|
|
4916
|
+
if (!store || store[name] === undefined) {
|
|
4917
|
+
writeLine(streams.stderr, scope === undefined
|
|
4918
|
+
? `No shared credential named ${name}. \`stratus credentials\` lists what is stored.`
|
|
4919
|
+
: `Agent ${scope} has no credential of its own named ${name}. \`stratus credentials\` lists what is stored.`);
|
|
4920
|
+
return 1;
|
|
4921
|
+
}
|
|
4922
|
+
delete store[name];
|
|
4923
|
+
if (scope !== undefined && Object.keys(store).length === 0) {
|
|
4924
|
+
delete named.agents[scope];
|
|
4925
|
+
}
|
|
4926
|
+
await saveNamedCredentials(env, named);
|
|
4927
|
+
writeLine(streams.stdout, scope === undefined ? `Removed ${name}.` : `Removed ${name} for ${scope}.`);
|
|
4928
|
+
return 0;
|
|
4929
|
+
}
|
|
4930
|
+
writeLine(streams.stderr, `Reading the value for ${name} from stdin — it is never taken from the command line, where it would land in your shell history. Ctrl-D when done.`);
|
|
4931
|
+
const arrived = env.stdin ?? await readSecretFromStdin(env.stdinStream ?? process.stdin);
|
|
4932
|
+
// One trailing line terminator and nothing else. `echo "$KEY" |` appends a
|
|
4933
|
+
// newline that is not part of the key and `printf %s` appends nothing, so
|
|
4934
|
+
// both spellings have to work — while a key whose own value ends in a
|
|
4935
|
+
// space must survive either of them, which a blanket trim would not allow.
|
|
4936
|
+
const value = arrived.replace(/\r?\n$/, '');
|
|
4937
|
+
if (value.trim().length === 0) {
|
|
4938
|
+
writeLine(streams.stderr, `Nothing arrived on stdin, so ${name} was not stored. Pipe the value in: \`printf %s "$KEY" | stratus credential set ${name}\`.`);
|
|
4939
|
+
return 1;
|
|
4940
|
+
}
|
|
4941
|
+
if (command.agentId === undefined) {
|
|
4942
|
+
named.shared[name] = value;
|
|
4943
|
+
}
|
|
4944
|
+
else {
|
|
4945
|
+
named.agents[command.agentId] = { ...(named.agents[command.agentId] ?? {}), [name]: value };
|
|
4946
|
+
}
|
|
4947
|
+
await saveNamedCredentials(env, named);
|
|
4948
|
+
writeLine(streams.stdout, command.agentId === undefined
|
|
4949
|
+
? `Stored ${name} in ${credentialsPath(env)} (readable only by you).`
|
|
4950
|
+
: `Stored ${name} for ${command.agentId} in ${credentialsPath(env)} (readable only by you). It outranks the shared entry for that agent.`);
|
|
4951
|
+
// Storing grants nothing: the soul is the second gate, and an operator
|
|
4952
|
+
// who stops here gets "not allowed to access credential" on every call.
|
|
4953
|
+
writeLine(streams.stdout, `Each agent that may use it still needs \`credentials: [${name}]\` in its soul.`);
|
|
4954
|
+
return 0;
|
|
4955
|
+
};
|
|
4956
|
+
/**
|
|
4957
|
+
* `stratus skill validate <target>`: the install-time check, run without
|
|
4958
|
+
* installing — for an author about to publish a skill, or an operator
|
|
4959
|
+
* asking why one was refused. A local path is a skill directory or a
|
|
4960
|
+
* directory of skills, discovered exactly as `skill add` would; a bare id
|
|
4961
|
+
* names an installed skill under `~/.stratus/skills/`. Exit 1 when
|
|
4962
|
+
* anything would be refused, so a publish step can gate on it.
|
|
4963
|
+
*/
|
|
4964
|
+
export const runSkillValidate = async (command, streams, env = {}) => {
|
|
4965
|
+
let directory;
|
|
4966
|
+
let installed = false;
|
|
4967
|
+
const localPath = path.resolve(readWorkingDirectory(env), command.target);
|
|
4968
|
+
try {
|
|
4969
|
+
if ((await stat(localPath)).isDirectory()) {
|
|
4970
|
+
directory = localPath;
|
|
4971
|
+
}
|
|
4972
|
+
}
|
|
4973
|
+
catch {
|
|
4974
|
+
// Not a local directory; try it as an installed id.
|
|
4975
|
+
}
|
|
4976
|
+
// The loader's rule for the lookup, so a directory that loads with a
|
|
4977
|
+
// pre-spec id can be validated — and told what to rename.
|
|
4978
|
+
if (directory === undefined && isLoadableSkillId(command.target)) {
|
|
4979
|
+
const installedPath = path.join(skillsDirPath(env), command.target);
|
|
4980
|
+
try {
|
|
4981
|
+
if ((await stat(installedPath)).isDirectory()) {
|
|
4982
|
+
directory = installedPath;
|
|
4983
|
+
installed = true;
|
|
4984
|
+
}
|
|
4985
|
+
}
|
|
4986
|
+
catch {
|
|
4987
|
+
// Not installed either.
|
|
4988
|
+
}
|
|
4989
|
+
}
|
|
4990
|
+
if (directory === undefined) {
|
|
4991
|
+
writeLine(streams.stderr, `Error: ${JSON.stringify(command.target)} is neither a directory nor an installed skill id (stratus skills lists those).`);
|
|
4992
|
+
return 1;
|
|
4993
|
+
}
|
|
4994
|
+
// An installed directory IS the layout the spec's directory rule is
|
|
4995
|
+
// about, so its name is checked; a path the author typed is a checkout
|
|
4996
|
+
// or a container, judged as `skill add` would judge it.
|
|
4997
|
+
const { candidates, skipped } = await discoverSkillsInDirectory(directory, installed ? { checkRootDirectoryName: true } : {});
|
|
4998
|
+
if (candidates.length === 0 && skipped.length === 0) {
|
|
4999
|
+
writeLine(streams.stderr, `Error: no skills found in ${directory}. A skill is a directory with a SKILL.md.`);
|
|
5000
|
+
return 1;
|
|
5001
|
+
}
|
|
5002
|
+
for (const candidate of candidates) {
|
|
5003
|
+
const caveat = candidate.warnings.length > 0
|
|
5004
|
+
? ` — ${candidate.warnings.length} warning${candidate.warnings.length === 1 ? '' : 's'}`
|
|
5005
|
+
: '';
|
|
5006
|
+
writeLine(streams.stdout, `${candidate.id}: ok${caveat}`);
|
|
5007
|
+
for (const warning of candidate.warnings) {
|
|
5008
|
+
writeLine(streams.stdout, ` warning: ${warning}`);
|
|
5009
|
+
}
|
|
5010
|
+
}
|
|
5011
|
+
for (const skip of skipped) {
|
|
5012
|
+
writeLine(streams.stdout, `${skip.id}: refused`);
|
|
5013
|
+
writeLine(streams.stdout, ` error: ${skip.reason}`);
|
|
5014
|
+
}
|
|
5015
|
+
if (skipped.length > 0) {
|
|
5016
|
+
const noun = skipped.length === 1 ? 'skill' : 'skills';
|
|
5017
|
+
writeLine(streams.stderr, `Error: ${skipped.length} ${noun} would be refused at install.`);
|
|
5018
|
+
return 1;
|
|
5019
|
+
}
|
|
5020
|
+
return 0;
|
|
5021
|
+
};
|
|
5022
|
+
/**
|
|
5023
|
+
* The control API base a command should talk to: `--gateway` when given,
|
|
5024
|
+
* else whatever daemon `~/.stratus/gateway.json` says is serving, else
|
|
5025
|
+
* nothing — the file is written when the API binds and removed when it
|
|
5026
|
+
* stops, so its absence means no daemon has said where it is.
|
|
5027
|
+
*/
|
|
5028
|
+
const runningGatewayBase = async (env, target) => {
|
|
5029
|
+
if (target.gateway) {
|
|
5030
|
+
return target.gateway.replace(/\/+$/, '');
|
|
5031
|
+
}
|
|
5032
|
+
const info = await readGatewayInfo(env);
|
|
5033
|
+
return info?.url.replace(/\/+$/, '');
|
|
5034
|
+
};
|
|
5035
|
+
/** One authenticated call to a daemon's control API, with the network and auth failures said plainly. */
|
|
5036
|
+
const callRunningGateway = async (env, target, base, pathname, body) => {
|
|
5037
|
+
const token = await gatewayToken(env, target.token);
|
|
5038
|
+
const fetchImpl = env.fetch ?? globalThis.fetch;
|
|
5039
|
+
if (typeof fetchImpl !== 'function') {
|
|
5040
|
+
throw new Error('fetch is unavailable, so this runtime cannot reach a daemon.');
|
|
5041
|
+
}
|
|
5042
|
+
let response;
|
|
5043
|
+
try {
|
|
5044
|
+
response = await fetchImpl(`${base}${pathname}`, {
|
|
5045
|
+
method: 'POST',
|
|
5046
|
+
headers: {
|
|
5047
|
+
authorization: `Bearer ${token}`,
|
|
5048
|
+
...(body ? { 'content-type': 'application/json' } : {}),
|
|
5049
|
+
},
|
|
5050
|
+
...(body ? { body: JSON.stringify(body) } : {}),
|
|
5051
|
+
});
|
|
5052
|
+
}
|
|
5053
|
+
catch (error) {
|
|
5054
|
+
throw new Error(`Could not reach the gateway at ${base} (${error instanceof Error ? error.message : String(error)}). `
|
|
5055
|
+
+ 'Is stratusd running, and does it have @stratusagent/control-api installed?');
|
|
5056
|
+
}
|
|
5057
|
+
if (response.status === 401 || response.status === 403) {
|
|
5058
|
+
throw new Error(`The gateway at ${base} rejected this token. Check --token, STRATUS_GATEWAY_TOKEN, or ~/.stratus/gateway-token.`);
|
|
5059
|
+
}
|
|
5060
|
+
return response;
|
|
5061
|
+
};
|
|
5062
|
+
/** The API's own sentence for a failure, or the status when it sent none. */
|
|
5063
|
+
const gatewayErrorMessage = async (response) => {
|
|
5064
|
+
try {
|
|
5065
|
+
const payload = await response.json();
|
|
5066
|
+
if (payload.error?.message) {
|
|
5067
|
+
return payload.error.message;
|
|
5068
|
+
}
|
|
5069
|
+
}
|
|
5070
|
+
catch {
|
|
5071
|
+
// Not JSON; the status is all there is to say.
|
|
5072
|
+
}
|
|
5073
|
+
return `HTTP ${response.status}`;
|
|
5074
|
+
};
|
|
5075
|
+
const noRunningDaemonMessage = (env) => `no running daemon found — ${gatewayInfoPath(env)} does not exist, and no --gateway was given. `
|
|
5076
|
+
+ 'Start one with `stratus serve` or `stratus service start`; a daemon loads ~/.stratus/skills at start.';
|
|
5077
|
+
/**
|
|
5078
|
+
* After `skill add`: tell the daemon, if one says it is running. Silent
|
|
5079
|
+
* when none does — a `stratus run` user with no daemon should not read a
|
|
5080
|
+
* note about one — and a warning, never a failure, when the file names a
|
|
5081
|
+
* daemon that did not answer: the install itself succeeded.
|
|
5082
|
+
*/
|
|
5083
|
+
const reloadRunningDaemonSkills = async (streams, env) => {
|
|
5084
|
+
const info = await readGatewayInfo(env);
|
|
5085
|
+
if (!info) {
|
|
5086
|
+
return;
|
|
5087
|
+
}
|
|
5088
|
+
const base = info.url.replace(/\/+$/, '');
|
|
5089
|
+
try {
|
|
5090
|
+
const response = await callRunningGateway(env, {}, base, '/api/v1/skills/reload');
|
|
5091
|
+
if (!response.ok) {
|
|
5092
|
+
throw new Error(await gatewayErrorMessage(response));
|
|
5093
|
+
}
|
|
5094
|
+
writeLine(streams.stdout, `reloaded the running daemon's skills (${base}) — no restart needed`);
|
|
5095
|
+
}
|
|
5096
|
+
catch (error) {
|
|
5097
|
+
writeLine(streams.stderr, `Warning: ${gatewayInfoPath(env)} names a daemon at ${base}, but its skills were not reloaded: `
|
|
5098
|
+
+ `${error instanceof Error ? error.message : String(error)} If it is running, reload it with: stratus skill reload`);
|
|
5099
|
+
}
|
|
5100
|
+
};
|
|
5101
|
+
export const runSkillReload = async (command, streams, env = {}) => {
|
|
5102
|
+
const base = await runningGatewayBase(env, command);
|
|
5103
|
+
if (!base) {
|
|
5104
|
+
writeLine(streams.stderr, `Error: ${noRunningDaemonMessage(env)}`);
|
|
5105
|
+
return 1;
|
|
5106
|
+
}
|
|
5107
|
+
const response = await callRunningGateway(env, command, base, '/api/v1/skills/reload');
|
|
5108
|
+
if (!response.ok) {
|
|
5109
|
+
writeLine(streams.stderr, `Error: ${await gatewayErrorMessage(response)}`);
|
|
5110
|
+
return 1;
|
|
5111
|
+
}
|
|
5112
|
+
const { skills } = await response.json();
|
|
5113
|
+
const ids = (skills ?? []).map((skill) => skill.id);
|
|
5114
|
+
writeLine(streams.stdout, `reloaded skills in the daemon at ${base} — ${ids.length} skill(s) serving${ids.length > 0 ? `: ${ids.join(', ')}` : ''}`);
|
|
5115
|
+
return 0;
|
|
5116
|
+
};
|
|
5117
|
+
export const runRestart = async (command, streams, env = {}) => {
|
|
5118
|
+
const base = await runningGatewayBase(env, command);
|
|
5119
|
+
if (!base) {
|
|
5120
|
+
writeLine(streams.stderr, `Error: ${noRunningDaemonMessage(env)}`);
|
|
5121
|
+
return 1;
|
|
5122
|
+
}
|
|
5123
|
+
const response = await callRunningGateway(env, command, base, '/api/v1/restart', {
|
|
5124
|
+
reason: command.reason ?? 'stratus restart',
|
|
5125
|
+
...(command.drainTimeoutMs !== undefined ? { drainTimeoutMs: command.drainTimeoutMs } : {}),
|
|
5126
|
+
});
|
|
5127
|
+
if (!response.ok) {
|
|
5128
|
+
writeLine(streams.stderr, `Error: ${await gatewayErrorMessage(response)}`);
|
|
5129
|
+
return 1;
|
|
5130
|
+
}
|
|
5131
|
+
const status = await response.json();
|
|
5132
|
+
const inflight = status.inflight ?? 0;
|
|
5133
|
+
const window = Math.round((status.drainTimeoutMs ?? 0) / 1000);
|
|
5134
|
+
writeLine(streams.stdout, `restart announced to the daemon at ${base} — new turns are refused; ${inflight} turn(s) in flight `
|
|
5135
|
+
+ `get up to ${window}s to finish, then it comes back.`);
|
|
5136
|
+
writeLine(streams.stdout, 'Watch it come back: stratus logs -f');
|
|
5137
|
+
return 0;
|
|
5138
|
+
};
|
|
4482
5139
|
export const runSkills = async (streams, env = {}) => {
|
|
4483
5140
|
const registry = new SkillRegistry();
|
|
4484
5141
|
const skills = await loadOperatorSkills(env, registry, (line) => {
|
|
@@ -4723,6 +5380,85 @@ const gatewayAnswering = async (env, base, fetchImpl) => {
|
|
|
4723
5380
|
return false;
|
|
4724
5381
|
}
|
|
4725
5382
|
};
|
|
5383
|
+
/**
|
|
5384
|
+
* Whether a process with this pid exists. EPERM is an answer — the process
|
|
5385
|
+
* is there, it is just not ours to signal.
|
|
5386
|
+
*/
|
|
5387
|
+
const processAlive = (pid) => {
|
|
5388
|
+
try {
|
|
5389
|
+
process.kill(pid, 0);
|
|
5390
|
+
return true;
|
|
5391
|
+
}
|
|
5392
|
+
catch (error) {
|
|
5393
|
+
return error.code === 'EPERM';
|
|
5394
|
+
}
|
|
5395
|
+
};
|
|
5396
|
+
/**
|
|
5397
|
+
* The error for a home another daemon holds (see `claimHome` in
|
|
5398
|
+
* @stratusagent/gateway — the claim is what decides; this only says who).
|
|
5399
|
+
*
|
|
5400
|
+
* The discovery file names the holder when it has one to name: a daemon
|
|
5401
|
+
* that has bound its API. Read for the message alone, and only trusted as
|
|
5402
|
+
* far as a live pid — a daemon still starting has not written it, a
|
|
5403
|
+
* daemon draining its last turn has already removed it, and a SIGKILLed
|
|
5404
|
+
* one leaves it behind for the next pid to inherit. Not probed over HTTP:
|
|
5405
|
+
* a `STRATUS_GATEWAY_TOKEN` exported for some remote gateway would answer
|
|
5406
|
+
* 401 for the local one, and the claim needs no second opinion.
|
|
5407
|
+
*/
|
|
5408
|
+
/**
|
|
5409
|
+
* `stratus serve` refused because another daemon holds the home. Its own
|
|
5410
|
+
* type so `stratus dashboard` can tell "a daemon is there, wait for it to
|
|
5411
|
+
* publish" from "the daemon could not start".
|
|
5412
|
+
*/
|
|
5413
|
+
class HomeHeldError extends Error {
|
|
5414
|
+
constructor(message) {
|
|
5415
|
+
super(message);
|
|
5416
|
+
this.name = 'HomeHeldError';
|
|
5417
|
+
}
|
|
5418
|
+
}
|
|
5419
|
+
/**
|
|
5420
|
+
* Whether a daemon from before the lock existed is serving this home.
|
|
5421
|
+
*
|
|
5422
|
+
* Such a daemon — alive across an upgrade that did not stop it — never
|
|
5423
|
+
* took the claim, so the claim alone cannot see it. The discovery file it
|
|
5424
|
+
* published is its only trace, held to the two proofs the lock made
|
|
5425
|
+
* unnecessary for everything since: the pid it names is alive, and its
|
|
5426
|
+
* URL answers `/health` with this home's own token file — never the
|
|
5427
|
+
* `STRATUS_GATEWAY_TOKEN` override, which names some other gateway and
|
|
5428
|
+
* would make a live daemon read as absent. A stale file fails either
|
|
5429
|
+
* proof and refuses nothing.
|
|
5430
|
+
*/
|
|
5431
|
+
const legacyDaemonServing = async (env) => {
|
|
5432
|
+
const info = await readGatewayInfo(env);
|
|
5433
|
+
if (info?.pid === undefined || info.pid === process.pid || !processAlive(info.pid)) {
|
|
5434
|
+
return false;
|
|
5435
|
+
}
|
|
5436
|
+
const fetchImpl = env.fetch ?? globalThis.fetch;
|
|
5437
|
+
if (typeof fetchImpl !== 'function') {
|
|
5438
|
+
return false;
|
|
5439
|
+
}
|
|
5440
|
+
try {
|
|
5441
|
+
const token = (await readFile(gatewayTokenPath(env), 'utf8')).trim();
|
|
5442
|
+
const response = await fetchImpl(`${info.url}/api/v1/health`, {
|
|
5443
|
+
headers: { authorization: `Bearer ${token}` },
|
|
5444
|
+
signal: AbortSignal.timeout(2_000),
|
|
5445
|
+
});
|
|
5446
|
+
return response.ok;
|
|
5447
|
+
}
|
|
5448
|
+
catch {
|
|
5449
|
+
return false;
|
|
5450
|
+
}
|
|
5451
|
+
};
|
|
5452
|
+
const describeHeldHome = async (env) => {
|
|
5453
|
+
const info = await readGatewayInfo(env);
|
|
5454
|
+
const holder = info?.pid !== undefined && processAlive(info.pid)
|
|
5455
|
+
? ` (pid ${info.pid}, ${info.url})`
|
|
5456
|
+
: ' — one that is still starting, or still draining its last turns';
|
|
5457
|
+
return `stratusd is already running for this home${holder}. Two daemons on one ~/.stratus `
|
|
5458
|
+
+ 'would each fire the other\'s schedules and re-ask its approvals. Stop it first: `stratus service stop` '
|
|
5459
|
+
+ 'if it is the installed service, otherwise Ctrl+C where it runs — and let it finish; it holds the home until '
|
|
5460
|
+
+ 'its last turn is written.';
|
|
5461
|
+
};
|
|
4726
5462
|
/**
|
|
4727
5463
|
* `stratus dashboard` — open the web UI against a running daemon, starting
|
|
4728
5464
|
* one in the foreground when there is none.
|
|
@@ -4745,56 +5481,87 @@ export const runDashboard = async (command, streams, env = {}) => {
|
|
|
4745
5481
|
if (!base) {
|
|
4746
5482
|
writeLine(streams.stdout, 'No daemon is running — starting one. It stops when you do.');
|
|
4747
5483
|
writeLine(streams.stdout, 'Run `stratus service install` to keep one running instead.');
|
|
4748
|
-
|
|
4749
|
-
|
|
4750
|
-
|
|
4751
|
-
|
|
4752
|
-
|
|
4753
|
-
|
|
4754
|
-
|
|
4755
|
-
api: true,
|
|
4756
|
-
...(command.port !== undefined ? { apiPort: command.port } : {}),
|
|
4757
|
-
// Passed whenever it was asked for, including when it is the default.
|
|
4758
|
-
// `--host 127.0.0.1` against a config saying `0.0.0.0` is an operator
|
|
4759
|
-
// narrowing the bind, and dropping it because it matched the default
|
|
4760
|
-
// did the reverse of what they typed.
|
|
4761
|
-
...(command.host !== undefined ? { apiHost: command.host } : {}),
|
|
4762
|
-
},
|
|
4763
|
-
// The daemon's own chatter belongs on stderr here: stdout is where this
|
|
4764
|
-
// command says where to point a browser, and interleaving the two makes
|
|
4765
|
-
// the one line that matters hard to find.
|
|
4766
|
-
{ stdout: streams.stderr, stderr: streams.stderr }, { ...env, shutdownSignal: ownDaemon.signal });
|
|
4767
|
-
// Attached now, not at the end. `runServe` can reject before it publishes
|
|
4768
|
-
// anything — an unreadable roster, a session store that will not open —
|
|
4769
|
-
// and a rejected promise nobody is watching for fifteen seconds is an
|
|
4770
|
-
// unhandled rejection, which terminates the process instead of reaching
|
|
4771
|
-
// the message below. Captured rather than swallowed, so the reason the
|
|
4772
|
-
// daemon gave is the reason this command reports.
|
|
5484
|
+
// One attempt at a daemon of our own. `runServe` can reject before it
|
|
5485
|
+
// publishes anything — an unreadable roster, a session store that will
|
|
5486
|
+
// not open — and a rejected promise nobody is watching for fifteen
|
|
5487
|
+
// seconds is an unhandled rejection, which terminates the process
|
|
5488
|
+
// instead of reaching the message below. Captured rather than
|
|
5489
|
+
// swallowed, so the reason the daemon gave is the reason this command
|
|
5490
|
+
// reports.
|
|
4773
5491
|
let daemonFailure;
|
|
4774
|
-
|
|
4775
|
-
|
|
5492
|
+
let daemonSettled = false;
|
|
5493
|
+
let daemonExit = Promise.resolve();
|
|
5494
|
+
let attemptedAt = 0;
|
|
5495
|
+
const attempt = () => {
|
|
5496
|
+
daemonFailure = undefined;
|
|
5497
|
+
daemonSettled = false;
|
|
5498
|
+
attemptedAt = Date.now();
|
|
5499
|
+
serving = runServe({
|
|
5500
|
+
command: 'serve',
|
|
5501
|
+
events: false,
|
|
5502
|
+
// Explicitly on. A trusted config may set `api.enabled: false` —
|
|
5503
|
+
// a reasonable thing for a headless box — but this command exists
|
|
5504
|
+
// to open the dashboard, and honouring it here would start a
|
|
5505
|
+
// daemon with no API and then time out waiting for the one it
|
|
5506
|
+
// promised.
|
|
5507
|
+
api: true,
|
|
5508
|
+
...(command.port !== undefined ? { apiPort: command.port } : {}),
|
|
5509
|
+
// Passed whenever it was asked for, including when it is the
|
|
5510
|
+
// default. `--host 127.0.0.1` against a config saying `0.0.0.0`
|
|
5511
|
+
// is an operator narrowing the bind, and dropping it because it
|
|
5512
|
+
// matched the default did the reverse of what they typed.
|
|
5513
|
+
...(command.host !== undefined ? { apiHost: command.host } : {}),
|
|
5514
|
+
},
|
|
5515
|
+
// The daemon's own chatter belongs on stderr here: stdout is where
|
|
5516
|
+
// this command says where to point a browser, and interleaving the
|
|
5517
|
+
// two makes the one line that matters hard to find.
|
|
5518
|
+
{ stdout: streams.stderr, stderr: streams.stderr }, { ...env, shutdownSignal: ownDaemon.signal });
|
|
5519
|
+
daemonExit = serving.then(() => { daemonSettled = true; }, (error) => { daemonFailure = error; daemonSettled = true; });
|
|
5520
|
+
};
|
|
5521
|
+
attempt();
|
|
5522
|
+
// Gated on a daemon actually answering, not on a delay: one publishes
|
|
4776
5523
|
// where it bound the moment it binds, and anything less would be a race
|
|
4777
|
-
// dressed up as a timeout. It also stops the moment
|
|
4778
|
-
//
|
|
5524
|
+
// dressed up as a timeout. It also stops the moment our daemon gives up
|
|
5525
|
+
// — except when it gave up because another one holds the home. An
|
|
5526
|
+
// installed service still starting has not published its address yet
|
|
5527
|
+
// and is about to: that is the daemon to wait for. And a daemon still
|
|
5528
|
+
// draining may simply exit, with nothing replacing it — so while the
|
|
5529
|
+
// home is held, our own attempt is repeated about once a second, and
|
|
5530
|
+
// takes the home the moment it is free.
|
|
4779
5531
|
const startedBy = Date.now() + 15_000;
|
|
4780
|
-
|
|
4781
|
-
void daemonExit.then(() => { daemonStopped = true; });
|
|
4782
|
-
while (!base && !daemonStopped && Date.now() < startedBy) {
|
|
5532
|
+
while (!base && Date.now() < startedBy) {
|
|
4783
5533
|
const info = await readGatewayInfo(env);
|
|
4784
5534
|
if (info && await gatewayAnswering(env, info.url, fetchImpl)) {
|
|
4785
5535
|
base = info.url;
|
|
4786
5536
|
break;
|
|
4787
5537
|
}
|
|
5538
|
+
if (daemonSettled) {
|
|
5539
|
+
if (!(daemonFailure instanceof HomeHeldError)) {
|
|
5540
|
+
break;
|
|
5541
|
+
}
|
|
5542
|
+
if (Date.now() - attemptedAt >= 1_000) {
|
|
5543
|
+
attempt();
|
|
5544
|
+
}
|
|
5545
|
+
}
|
|
4788
5546
|
await new Promise((resolve) => setTimeout(resolve, 150));
|
|
4789
5547
|
}
|
|
4790
5548
|
if (!base) {
|
|
4791
5549
|
ownDaemon.abort();
|
|
4792
5550
|
await daemonExit;
|
|
4793
|
-
writeLine(streams.stderr, daemonFailure
|
|
4794
|
-
?
|
|
4795
|
-
|
|
5551
|
+
writeLine(streams.stderr, daemonFailure instanceof HomeHeldError
|
|
5552
|
+
? 'Error: another daemon holds this home but never published its address. It may still be starting or '
|
|
5553
|
+
+ 'draining — try again in a moment, or check `stratus service status`.'
|
|
5554
|
+
: daemonFailure
|
|
5555
|
+
? `Error: the daemon could not start: ${daemonFailure instanceof Error ? daemonFailure.message : String(daemonFailure)}`
|
|
5556
|
+
: 'Error: the daemon did not start serving its control API. Is @stratusagent/control-api installed?');
|
|
4796
5557
|
return 1;
|
|
4797
5558
|
}
|
|
5559
|
+
if (daemonFailure instanceof HomeHeldError) {
|
|
5560
|
+
// The daemon that answered is the one that held the home. Ours never
|
|
5561
|
+
// started, so there is nothing of ours to wait for or to stop: from
|
|
5562
|
+
// here this is the found-a-running-daemon case.
|
|
5563
|
+
serving = undefined;
|
|
5564
|
+
}
|
|
4798
5565
|
}
|
|
4799
5566
|
let url;
|
|
4800
5567
|
try {
|
|
@@ -4830,7 +5597,15 @@ export const runDashboard = async (command, streams, env = {}) => {
|
|
|
4830
5597
|
await new Promise((resolve) => setTimeout(resolve, env.dashboardAutoShutdownMs));
|
|
4831
5598
|
ownDaemon.abort();
|
|
4832
5599
|
}
|
|
4833
|
-
|
|
5600
|
+
// An attempt still in flight when the daemon answered may yet be refused
|
|
5601
|
+
// — the holder published while ours was claiming. The dashboard found a
|
|
5602
|
+
// daemon; that refusal is not its failure.
|
|
5603
|
+
return serving.catch((error) => {
|
|
5604
|
+
if (error instanceof HomeHeldError) {
|
|
5605
|
+
return 0;
|
|
5606
|
+
}
|
|
5607
|
+
throw error;
|
|
5608
|
+
});
|
|
4834
5609
|
};
|
|
4835
5610
|
/**
|
|
4836
5611
|
* Resolution and loading are separate questions, and only the first one
|
|
@@ -4933,6 +5708,214 @@ const loadServePlugins = async (env, configPath, warn) => {
|
|
|
4933
5708
|
* roster, accept dispatches, print events, and drain cleanly on SIGTERM,
|
|
4934
5709
|
* SIGINT, or the injected shutdown signal.
|
|
4935
5710
|
*/
|
|
5711
|
+
/**
|
|
5712
|
+
* What a daemon exits with to ask for a fresh process. Only ever seen by the
|
|
5713
|
+
* supervisor below, which is the only thing that starts a daemon with the
|
|
5714
|
+
* environment marker that makes it exit this way instead of supervising.
|
|
5715
|
+
*/
|
|
5716
|
+
export const RESTART_EXIT_CODE = 75;
|
|
5717
|
+
/**
|
|
5718
|
+
* What a daemon exits with when a restart could not drain — a turn that
|
|
5719
|
+
* ignored its abort, a plugin or channel that did not let go. Distinct from
|
|
5720
|
+
* the restart status on purpose: the supervisor answers this one by
|
|
5721
|
+
* exiting with it too, so the process the service manager started ends
|
|
5722
|
+
* and the manager restarts the whole unit — under systemd, cleaning the
|
|
5723
|
+
* cgroup of whatever the plugin left running. A supervisor that started
|
|
5724
|
+
* another daemon instead would keep that cgroup, and its leaks, alive.
|
|
5725
|
+
*/
|
|
5726
|
+
export const UNDRAINED_RESTART_EXIT_CODE = 76;
|
|
5727
|
+
/** Set in a daemon the supervisor started, so its own restart is an exit, not a second supervisor. */
|
|
5728
|
+
const SUPERVISED_ENV = 'STRATUS_SERVE_SUPERVISED';
|
|
5729
|
+
/**
|
|
5730
|
+
* The control API port the last daemon bound, handed to its replacement by
|
|
5731
|
+
* the supervisor. Honoured only where the replacement's own request — the
|
|
5732
|
+
* flag, else the config it reads afresh — is still for any free port: a
|
|
5733
|
+
* daemon that asked for port 0 comes back on the port it had, so a
|
|
5734
|
+
* dashboard page reconnects to it, while a config since edited to a fixed
|
|
5735
|
+
* port takes effect on that restart, as an `api` block change is meant to.
|
|
5736
|
+
* A hint, never a pin: passing the port back as `--api-port` would outrank
|
|
5737
|
+
* the config for every restart after.
|
|
5738
|
+
*/
|
|
5739
|
+
const BOUND_API_PORT_ENV = 'STRATUS_SERVE_BOUND_API_PORT';
|
|
5740
|
+
const isDashboardSession = (value) => typeof value === 'object' && value !== null
|
|
5741
|
+
&& typeof value.id === 'string'
|
|
5742
|
+
&& typeof value.expiresAt === 'number'
|
|
5743
|
+
&& typeof value.vouchedBy === 'string';
|
|
5744
|
+
/** Shape-checked, because the other end of an IPC channel is still another process. */
|
|
5745
|
+
const isSupervisorMessage = (value) => {
|
|
5746
|
+
if (typeof value !== 'object' || value === null) {
|
|
5747
|
+
return false;
|
|
5748
|
+
}
|
|
5749
|
+
const message = value;
|
|
5750
|
+
if (message.type === 'stratusd.bound-api-port') {
|
|
5751
|
+
return typeof message.port === 'number' && Number.isInteger(message.port) && message.port > 0;
|
|
5752
|
+
}
|
|
5753
|
+
if (message.type === 'stratusd.sessions') {
|
|
5754
|
+
return Array.isArray(message.sessions) && message.sessions.every(isDashboardSession);
|
|
5755
|
+
}
|
|
5756
|
+
return false;
|
|
5757
|
+
};
|
|
5758
|
+
/**
|
|
5759
|
+
* The IPC channel a supervisor opens when it spawns a daemon: what a
|
|
5760
|
+
* supervised daemon says up it (the port it bound, so the supervisor
|
|
5761
|
+
* carries *that* port to the next daemon rather than the first one's; the
|
|
5762
|
+
* dashboard sessions it hands on) and what comes down it. Where there is
|
|
5763
|
+
* no channel — the first daemon, or one a service manager started — there
|
|
5764
|
+
* is no one to say it to, and nothing is said.
|
|
5765
|
+
*/
|
|
5766
|
+
const defaultSupervisorLink = {
|
|
5767
|
+
send(message) {
|
|
5768
|
+
if (typeof process.send === 'function' && process.channel) {
|
|
5769
|
+
process.send(message);
|
|
5770
|
+
}
|
|
5771
|
+
},
|
|
5772
|
+
receive(handler) {
|
|
5773
|
+
const channel = process.channel;
|
|
5774
|
+
if (!channel) {
|
|
5775
|
+
return;
|
|
5776
|
+
}
|
|
5777
|
+
process.on('message', (message) => {
|
|
5778
|
+
if (isSupervisorMessage(message)) {
|
|
5779
|
+
handler(message);
|
|
5780
|
+
}
|
|
5781
|
+
});
|
|
5782
|
+
// A 'message' listener refs the channel, which would keep this process
|
|
5783
|
+
// alive after its drain; the daemon's own work is what holds it open.
|
|
5784
|
+
channel.unref?.();
|
|
5785
|
+
},
|
|
5786
|
+
};
|
|
5787
|
+
/**
|
|
5788
|
+
* The parsed serve command as arguments again — what the fresh daemon is
|
|
5789
|
+
* started with. From the command, not from `process.argv`: `stratus
|
|
5790
|
+
* dashboard` runs the daemon through `runServe` with a command it built,
|
|
5791
|
+
* and the process's own arguments would start a second dashboard.
|
|
5792
|
+
*/
|
|
5793
|
+
export const serveArgv = (command) => [
|
|
5794
|
+
'serve',
|
|
5795
|
+
...(command.configPath !== undefined ? ['--config', command.configPath] : []),
|
|
5796
|
+
...(command.idleTimeoutMs !== undefined ? ['--idle-timeout', String(command.idleTimeoutMs / 1000)] : []),
|
|
5797
|
+
...(command.approvals !== undefined ? ['--approvals', command.approvals] : []),
|
|
5798
|
+
...(command.events ? [] : ['--no-events']),
|
|
5799
|
+
...(command.logToFile === false ? ['--no-log-file'] : []),
|
|
5800
|
+
...(command.api === false ? ['--no-api'] : command.api === true ? ['--api'] : []),
|
|
5801
|
+
...(command.apiPort !== undefined ? ['--api-port', String(command.apiPort)] : []),
|
|
5802
|
+
...(command.apiHost !== undefined ? ['--api-host', command.apiHost] : []),
|
|
5803
|
+
];
|
|
5804
|
+
/**
|
|
5805
|
+
* The `bin` beside the module at `moduleUrl`, with that module's own
|
|
5806
|
+
* extension: `dist/index.js` respawns `dist/bin.js`, and a source checkout
|
|
5807
|
+
* running `src/index.ts` under type stripping respawns `src/bin.ts` — the
|
|
5808
|
+
* flags that made that possible travel in `process.execArgv`. Assuming
|
|
5809
|
+
* compiled output would hand a source checkout a file that does not exist,
|
|
5810
|
+
* and a daemon that never comes back.
|
|
5811
|
+
*/
|
|
5812
|
+
export const restartEntrypoint = (moduleUrl) => {
|
|
5813
|
+
const modulePath = fileURLToPath(moduleUrl);
|
|
5814
|
+
return path.join(path.dirname(modulePath), `bin${path.extname(modulePath)}`);
|
|
5815
|
+
};
|
|
5816
|
+
/**
|
|
5817
|
+
* Run this CLI's own entrypoint as a child with the daemon's streams and
|
|
5818
|
+
* exit code, forwarding the signals a supervisor would otherwise swallow.
|
|
5819
|
+
*
|
|
5820
|
+
* A child, and the parent waits, rather than a detached process and an
|
|
5821
|
+
* exit: under systemd and launchd the process the manager started is the
|
|
5822
|
+
* service, and its exit ends the job — cgroup and all, on Linux — so the
|
|
5823
|
+
* daemon that received the restart has to stay the manager's process and
|
|
5824
|
+
* become the supervisor of the next one. That is what makes the same path
|
|
5825
|
+
* hold in the foreground and under `--no-login`, where nothing else would
|
|
5826
|
+
* bring a clean exit back.
|
|
5827
|
+
*/
|
|
5828
|
+
const defaultServeRespawn = (env) => (argv, handoff) => new Promise((resolve, reject) => {
|
|
5829
|
+
const entrypoint = restartEntrypoint(import.meta.url);
|
|
5830
|
+
// The daemon's own streams, plus an IPC channel for what it and its
|
|
5831
|
+
// supervisor have to tell each other. See SupervisorLink.
|
|
5832
|
+
const child = spawn(process.execPath, [...process.execArgv, entrypoint, ...argv], {
|
|
5833
|
+
stdio: ['inherit', 'inherit', 'inherit', 'ipc'],
|
|
5834
|
+
env: {
|
|
5835
|
+
...process.env,
|
|
5836
|
+
[SUPERVISED_ENV]: '1',
|
|
5837
|
+
...(handoff.boundApiPort !== undefined ? { [BOUND_API_PORT_ENV]: String(handoff.boundApiPort) } : {}),
|
|
5838
|
+
},
|
|
5839
|
+
});
|
|
5840
|
+
if (handoff.sessions.length > 0 && child.connected) {
|
|
5841
|
+
// Queued in the channel until the daemon listens, which it does
|
|
5842
|
+
// before its API is up; the API holds them until it can adopt them.
|
|
5843
|
+
// A send that fails (the daemon died before reading) is not this
|
|
5844
|
+
// supervisor's failure: the exit below reports what happened, and a
|
|
5845
|
+
// daemon that never took them simply comes up signed out.
|
|
5846
|
+
const sessions = { type: 'stratusd.sessions', sessions: handoff.sessions };
|
|
5847
|
+
child.send(sessions, () => undefined);
|
|
5848
|
+
}
|
|
5849
|
+
const forward = (signal) => () => {
|
|
5850
|
+
child.kill(signal);
|
|
5851
|
+
};
|
|
5852
|
+
const onTerm = forward('SIGTERM');
|
|
5853
|
+
const onInt = forward('SIGINT');
|
|
5854
|
+
const onAbort = () => {
|
|
5855
|
+
child.kill('SIGTERM');
|
|
5856
|
+
};
|
|
5857
|
+
process.on('SIGTERM', onTerm);
|
|
5858
|
+
process.on('SIGINT', onInt);
|
|
5859
|
+
env.shutdownSignal?.addEventListener('abort', onAbort, { once: true });
|
|
5860
|
+
const done = () => {
|
|
5861
|
+
process.off('SIGTERM', onTerm);
|
|
5862
|
+
process.off('SIGINT', onInt);
|
|
5863
|
+
env.shutdownSignal?.removeEventListener('abort', onAbort);
|
|
5864
|
+
};
|
|
5865
|
+
let reportedPort;
|
|
5866
|
+
let handedBack;
|
|
5867
|
+
child.on('message', (message) => {
|
|
5868
|
+
if (!isSupervisorMessage(message)) {
|
|
5869
|
+
return;
|
|
5870
|
+
}
|
|
5871
|
+
if (message.type === 'stratusd.bound-api-port') {
|
|
5872
|
+
reportedPort = message.port;
|
|
5873
|
+
}
|
|
5874
|
+
else {
|
|
5875
|
+
handedBack = message.sessions;
|
|
5876
|
+
}
|
|
5877
|
+
});
|
|
5878
|
+
child.once('error', (error) => {
|
|
5879
|
+
done();
|
|
5880
|
+
reject(error);
|
|
5881
|
+
});
|
|
5882
|
+
child.once('exit', (code, signal) => {
|
|
5883
|
+
done();
|
|
5884
|
+
// Killed by a signal is a stop, not a request to come back.
|
|
5885
|
+
resolve({
|
|
5886
|
+
code: code ?? (signal ? 1 : 0),
|
|
5887
|
+
...(reportedPort !== undefined ? { boundApiPort: reportedPort } : {}),
|
|
5888
|
+
...(handedBack !== undefined ? { sessions: handedBack } : {}),
|
|
5889
|
+
});
|
|
5890
|
+
});
|
|
5891
|
+
});
|
|
5892
|
+
/**
|
|
5893
|
+
* Keep starting daemons for as long as they exit asking for another; the
|
|
5894
|
+
* first exit that does not is this process's own — an undrained restart's
|
|
5895
|
+
* status included, which ends the supervisor rather than starting a daemon
|
|
5896
|
+
* beside what the last one could not release.
|
|
5897
|
+
*/
|
|
5898
|
+
const superviseRestarts = async (command, first, streams, env) => {
|
|
5899
|
+
const respawn = env.serveRespawn ?? defaultServeRespawn(env);
|
|
5900
|
+
// The hand-off follows the daemons: the port each one said it bound is
|
|
5901
|
+
// what the next one is told (so a config edited to a fixed port and
|
|
5902
|
+
// back hands on the last address, not the first), and the sessions are
|
|
5903
|
+
// whatever the last one handed back — a daemon that handed nothing
|
|
5904
|
+
// hands nothing on.
|
|
5905
|
+
let handoff = first;
|
|
5906
|
+
let result;
|
|
5907
|
+
do {
|
|
5908
|
+
writeLine(streams.stdout, 'Restarting stratusd.');
|
|
5909
|
+
result = await respawn(serveArgv(command), handoff);
|
|
5910
|
+
handoff = {
|
|
5911
|
+
...(result.boundApiPort !== undefined || handoff.boundApiPort !== undefined
|
|
5912
|
+
? { boundApiPort: result.boundApiPort ?? handoff.boundApiPort }
|
|
5913
|
+
: {}),
|
|
5914
|
+
sessions: result.sessions ?? [],
|
|
5915
|
+
};
|
|
5916
|
+
} while (result.code === RESTART_EXIT_CODE);
|
|
5917
|
+
return result.code;
|
|
5918
|
+
};
|
|
4936
5919
|
export const runServe = async (command, streams, env = {}) => {
|
|
4937
5920
|
// First, before anything that can throw. A broken install makes the
|
|
4938
5921
|
// gateway import below fail, the manager restarts, and the CLI's error
|
|
@@ -4945,7 +5928,46 @@ export const runServe = async (command, streams, env = {}) => {
|
|
|
4945
5928
|
// Loaded lazily: the gateway pulls in node:sqlite, which every other CLI
|
|
4946
5929
|
// command neither needs nor should pay for (Node still prints an
|
|
4947
5930
|
// experimental warning for it).
|
|
4948
|
-
const { createGateway } = await import('@stratusagent/gateway');
|
|
5931
|
+
const { createGateway, claimHome, HomeClaimedError } = await import('@stratusagent/gateway');
|
|
5932
|
+
// Before anything of this daemon's is written or opened: a second daemon
|
|
5933
|
+
// on this home is refused here, with no log line, no store, and no sweep
|
|
5934
|
+
// or firing of its own.
|
|
5935
|
+
let claim;
|
|
5936
|
+
try {
|
|
5937
|
+
claim = claimHome(env);
|
|
5938
|
+
}
|
|
5939
|
+
catch (error) {
|
|
5940
|
+
if (error instanceof HomeClaimedError) {
|
|
5941
|
+
throw new HomeHeldError(await describeHeldHome(env));
|
|
5942
|
+
}
|
|
5943
|
+
throw error;
|
|
5944
|
+
}
|
|
5945
|
+
let outcome;
|
|
5946
|
+
try {
|
|
5947
|
+
// The one daemon the claim cannot see: one that predates it.
|
|
5948
|
+
if (await legacyDaemonServing(env)) {
|
|
5949
|
+
throw new HomeHeldError(await describeHeldHome(env));
|
|
5950
|
+
}
|
|
5951
|
+
outcome = await serveHeldHome(command, streams, env, createGateway);
|
|
5952
|
+
}
|
|
5953
|
+
finally {
|
|
5954
|
+
// After everything: the store closed by stop(), or whatever a throw
|
|
5955
|
+
// reached — a preflight that rejected before the gateway existed
|
|
5956
|
+
// included, so a host that calls runServe again is not refused for a
|
|
5957
|
+
// daemon that never started. And never sooner, so a replacement cannot
|
|
5958
|
+
// open the store while this one still writes.
|
|
5959
|
+
claim.release();
|
|
5960
|
+
}
|
|
5961
|
+
// An announced restart, once the home is let go — the replacement claims
|
|
5962
|
+
// it next. A daemon the supervisor started asks it for the next one by
|
|
5963
|
+
// exiting; the first daemon becomes the supervisor itself. Either way the
|
|
5964
|
+
// process the service manager (or the terminal) started is what stays.
|
|
5965
|
+
if (outcome.code === RESTART_EXIT_CODE && !readProcessEnv(env)[SUPERVISED_ENV]) {
|
|
5966
|
+
return superviseRestarts(command, { ...(outcome.boundApiPort !== undefined ? { boundApiPort: outcome.boundApiPort } : {}), sessions: outcome.sessions }, streams, env);
|
|
5967
|
+
}
|
|
5968
|
+
return outcome.code;
|
|
5969
|
+
};
|
|
5970
|
+
const serveHeldHome = async (command, streams, env, createGateway) => {
|
|
4949
5971
|
// Under a service manager the daemon's stdout is gone, so everything it
|
|
4950
5972
|
// says is also written to ~/.stratus/logs — that file is what `stratus
|
|
4951
5973
|
// logs` reads, and the only record of an overnight run.
|
|
@@ -4984,6 +6006,8 @@ export const runServe = async (command, streams, env = {}) => {
|
|
|
4984
6006
|
// Typed as the seam, not as whatever the first push happens to be: the
|
|
4985
6007
|
// list holds the control API and the Slack adapter alike.
|
|
4986
6008
|
const controlApiChannels = [];
|
|
6009
|
+
/** The API adapter itself, for what the seam does not carry: its address, and the sessions it hands across a restart. */
|
|
6010
|
+
let boundApi;
|
|
4987
6011
|
if (apiWanted) {
|
|
4988
6012
|
const createControlApi = await loadControlApi();
|
|
4989
6013
|
// Resolved into locals first. Inlined, `a ?? b !== undefined` parses as
|
|
@@ -4992,16 +6016,23 @@ export const runServe = async (command, streams, env = {}) => {
|
|
|
4992
6016
|
// Everything still bound and every test still passed, on whichever port
|
|
4993
6017
|
// nobody happened to be using.
|
|
4994
6018
|
const apiHost = command.apiHost ?? apiConfig.host;
|
|
4995
|
-
const
|
|
6019
|
+
const requestedPort = command.apiPort ?? apiConfig.port;
|
|
6020
|
+
// The port the last daemon bound, from the supervisor that started
|
|
6021
|
+
// this one — taken only where this daemon's own request is still for
|
|
6022
|
+
// any free port. See BOUND_API_PORT_ENV.
|
|
6023
|
+
const hinted = Number(readProcessEnv(env)[BOUND_API_PORT_ENV]);
|
|
6024
|
+
const apiPort = requestedPort === 0 && Number.isInteger(hinted) && hinted > 0 ? hinted : requestedPort;
|
|
4996
6025
|
if (createControlApi) {
|
|
4997
|
-
|
|
6026
|
+
const api = createControlApi({
|
|
4998
6027
|
env,
|
|
4999
6028
|
...(apiHost !== undefined ? { host: apiHost } : {}),
|
|
5000
6029
|
...(apiPort !== undefined ? { port: apiPort } : {}),
|
|
5001
6030
|
...(command.configPath ? { configPath: command.configPath } : {}),
|
|
5002
6031
|
log,
|
|
5003
6032
|
warn,
|
|
5004
|
-
})
|
|
6033
|
+
});
|
|
6034
|
+
boundApi = api;
|
|
6035
|
+
controlApiChannels.push(api);
|
|
5005
6036
|
}
|
|
5006
6037
|
else if (command.api === true || apiConfig.enabled === true) {
|
|
5007
6038
|
// Only when someone asked for it explicitly. A daemon that was never
|
|
@@ -5010,6 +6041,18 @@ export const runServe = async (command, streams, env = {}) => {
|
|
|
5010
6041
|
+ 'Run `npm install -g @stratusagent/control-api` to bring it online; starting without it.');
|
|
5011
6042
|
}
|
|
5012
6043
|
}
|
|
6044
|
+
// Listening before anything else is awaited: what the supervisor hands
|
|
6045
|
+
// this daemon is queued in the channel until then, and the API keeps it
|
|
6046
|
+
// until it is serving.
|
|
6047
|
+
const supervised = Boolean(readProcessEnv(env)[SUPERVISED_ENV]);
|
|
6048
|
+
const link = env.supervisorLink ?? defaultSupervisorLink;
|
|
6049
|
+
if (supervised) {
|
|
6050
|
+
link.receive((message) => {
|
|
6051
|
+
if (message.type === 'stratusd.sessions') {
|
|
6052
|
+
boundApi?.adoptSessions(message.sessions);
|
|
6053
|
+
}
|
|
6054
|
+
});
|
|
6055
|
+
}
|
|
5013
6056
|
const channelCredentials = await loadChannelCredentials(env);
|
|
5014
6057
|
const slackAgents = Object.entries(channelCredentials.slack ?? {});
|
|
5015
6058
|
const channels = [...controlApiChannels];
|
|
@@ -5107,7 +6150,10 @@ export const runServe = async (command, streams, env = {}) => {
|
|
|
5107
6150
|
// a tool that carries no command string is judged by its risk exactly as
|
|
5108
6151
|
// before. The whitelist lives beside the agent's soul, per agent.
|
|
5109
6152
|
const commands = {
|
|
5110
|
-
whitelist
|
|
6153
|
+
// A whitelist that exists and will not read is said here, once, and
|
|
6154
|
+
// never written over — the daemon's log is where a grant list going
|
|
6155
|
+
// quiet would otherwise go unnoticed.
|
|
6156
|
+
whitelist: createFileCommandWhitelist({ directory: agentsDirPath(env), warn }),
|
|
5111
6157
|
onScopeRemembered: ({ agentId, scope }) => {
|
|
5112
6158
|
// An approval that widens what runs unattended, for every future
|
|
5113
6159
|
// session, is precisely the decision that must not be the one leaving
|
|
@@ -5115,14 +6161,24 @@ export const runServe = async (command, streams, env = {}) => {
|
|
|
5115
6161
|
log(`${agentId}: "${describeCommandScope(scope)}" now runs without asking`);
|
|
5116
6162
|
},
|
|
5117
6163
|
};
|
|
6164
|
+
// The origin-scope engine, whose grants live in the same file as the
|
|
6165
|
+
// command scopes: one place an operator looks to see what an agent may
|
|
6166
|
+
// do unattended. Wired unconditionally for the same reason — a tool that
|
|
6167
|
+
// names no origin is judged by its risk exactly as before.
|
|
6168
|
+
const origins = {
|
|
6169
|
+
whitelist: commands.whitelist,
|
|
6170
|
+
onScopeRemembered: ({ agentId, scope }) => {
|
|
6171
|
+
log(`${agentId}: ${describeOriginScope(scope)} is now acted on without asking`);
|
|
6172
|
+
},
|
|
6173
|
+
};
|
|
5118
6174
|
const approvals = (transport) => createPermissionPolicy(
|
|
5119
6175
|
// The destination scope rides along in BOTH modes — it is what lets a
|
|
5120
6176
|
// scheduled turn report to the channel a human approved with the
|
|
5121
6177
|
// schedule, and headless (where every other gated call is refused) is
|
|
5122
6178
|
// exactly the deployment it exists for.
|
|
5123
6179
|
approvalMode === 'remote'
|
|
5124
|
-
? { mode: 'remote', request: transport.request, onDecision, commands, destinations: transport.destinations }
|
|
5125
|
-
: { mode: 'headless', onDecision, commands, destinations: transport.destinations });
|
|
6180
|
+
? { mode: 'remote', request: transport.request, onDecision, commands, origins, destinations: transport.destinations }
|
|
6181
|
+
: { mode: 'headless', onDecision, commands, origins, destinations: transport.destinations });
|
|
5126
6182
|
if (approvalMode === 'remote') {
|
|
5127
6183
|
// Only agents whose channel actually came up can be asked: tokens on
|
|
5128
6184
|
// disk with the Slack package missing means nothing renders the
|
|
@@ -5130,9 +6186,30 @@ export const runServe = async (command, streams, env = {}) => {
|
|
|
5130
6186
|
const askable = slackAdapterUp ? slackAgents.map(([agentId]) => agentId) : [];
|
|
5131
6187
|
log(`approvals: remote — gated calls are parked and asked in Slack (${describeApprovers(approvalsConfig, askable)})`);
|
|
5132
6188
|
}
|
|
6189
|
+
/**
|
|
6190
|
+
* The one signal handler this daemon has, for the whole of its life —
|
|
6191
|
+
* installed by the wait below, removed in the finally after the drain.
|
|
6192
|
+
* See the wait for why it is never `once` and never removed sooner.
|
|
6193
|
+
*/
|
|
6194
|
+
let stopSignal;
|
|
6195
|
+
let repeatWarned = false;
|
|
6196
|
+
// The gateway's restart hands the process back here once it has stopped:
|
|
6197
|
+
// the outcome is kept and the wait below released, and the tail of this
|
|
6198
|
+
// function decides what "come back" means for this process.
|
|
6199
|
+
let restart;
|
|
6200
|
+
/** A signal or the host's abort asked for a stop — set when the wait ends for that reason. */
|
|
6201
|
+
let stopRequested = false;
|
|
6202
|
+
let requestShutdown = () => { };
|
|
6203
|
+
const shutdownRequested = new Promise((resolve) => {
|
|
6204
|
+
requestShutdown = resolve;
|
|
6205
|
+
});
|
|
5133
6206
|
const gateway = createGateway({
|
|
5134
6207
|
env,
|
|
5135
6208
|
approvals,
|
|
6209
|
+
onRestart: (outcome) => {
|
|
6210
|
+
restart = outcome;
|
|
6211
|
+
requestShutdown();
|
|
6212
|
+
},
|
|
5136
6213
|
...(Object.keys(pluginsConfig).length > 0
|
|
5137
6214
|
? {
|
|
5138
6215
|
plugins: pluginsConfig,
|
|
@@ -5214,36 +6291,46 @@ export const runServe = async (command, streams, env = {}) => {
|
|
|
5214
6291
|
});
|
|
5215
6292
|
}
|
|
5216
6293
|
await gateway.start();
|
|
5217
|
-
//
|
|
5218
|
-
//
|
|
5219
|
-
|
|
5220
|
-
|
|
5221
|
-
|
|
5222
|
-
|
|
5223
|
-
//
|
|
5224
|
-
|
|
5225
|
-
|
|
5226
|
-
|
|
5227
|
-
if (unreachable.length > 0) {
|
|
5228
|
-
warn(`no channel can ask for ${unreachable.join(', ')}, so their gated calls will wait out the `
|
|
5229
|
-
+ 'approval timeout and then be denied. Connect a Slack app for them, or run with --approvals headless.');
|
|
5230
|
-
}
|
|
5231
|
-
}
|
|
5232
|
-
writeLine(streams.stdout, 'Press Ctrl+C to stop.');
|
|
5233
|
-
// And periodically, for a long-running daemon that warns steadily
|
|
5234
|
-
// without ever writing enough records to rotate. Unref'd, so it never
|
|
5235
|
-
// holds the process open. Armed only once the daemon is actually
|
|
5236
|
-
// serving: every step above can throw, and runServe is an exported
|
|
5237
|
-
// function as much as a process entry point — in a host that survives
|
|
5238
|
-
// the failure, a timer armed before the throw would outlive the call
|
|
5239
|
-
// and go on truncating that environment's redirect logs. The finally
|
|
5240
|
-
// below takes it down on every other exit path.
|
|
6294
|
+
// Read now, while the API is bound: its stop forgets the address, and
|
|
6295
|
+
// a restart's respawn needs it (see ServeOutcome).
|
|
6296
|
+
const boundApiPort = boundApi?.url !== undefined ? Number(new URL(boundApi.url).port) : undefined;
|
|
6297
|
+
if (supervised && boundApiPort !== undefined && Number.isInteger(boundApiPort) && boundApiPort > 0) {
|
|
6298
|
+
link.send({ type: 'stratusd.bound-api-port', port: boundApiPort });
|
|
6299
|
+
}
|
|
6300
|
+
// Under one finally from here: a throw anywhere after the start — a
|
|
6301
|
+
// host's writable refusing a line, say — must still stop the gateway
|
|
6302
|
+
// before the claim on the home is released, or a live gateway would be
|
|
6303
|
+
// left behind a lock the next daemon can take.
|
|
5241
6304
|
let redirectTimer;
|
|
5242
|
-
if (logWriter) {
|
|
5243
|
-
redirectTimer = setInterval(() => void truncateRedirectLogs(logsDirPath(env)), 5 * 60_000);
|
|
5244
|
-
redirectTimer.unref?.();
|
|
5245
|
-
}
|
|
5246
6305
|
try {
|
|
6306
|
+
// The roster is only known once the gateway has loaded it, and in remote
|
|
6307
|
+
// mode an agent no channel can ask for is the quietest failure this
|
|
6308
|
+
// feature has: its gated calls park with nobody rendering them and wait
|
|
6309
|
+
// out the whole timeout before being denied. No channel can detect this
|
|
6310
|
+
// on its own — a request is a broadcast, and no adapter knows whether
|
|
6311
|
+
// another one is about to answer — so it is reported here, where the
|
|
6312
|
+
// roster and the channel list are both in view.
|
|
6313
|
+
if (approvalMode === 'remote') {
|
|
6314
|
+
const askable = new Set(slackAdapterUp ? slackAgents.map(([agentId]) => agentId) : []);
|
|
6315
|
+
const unreachable = gateway.agents().map((agent) => agent.id).filter((id) => !askable.has(id));
|
|
6316
|
+
if (unreachable.length > 0) {
|
|
6317
|
+
warn(`no channel can ask for ${unreachable.join(', ')}, so their gated calls will wait out the `
|
|
6318
|
+
+ 'approval timeout and then be denied. Connect a Slack app for them, or run with --approvals headless.');
|
|
6319
|
+
}
|
|
6320
|
+
}
|
|
6321
|
+
writeLine(streams.stdout, 'Press Ctrl+C to stop.');
|
|
6322
|
+
// And periodically, for a long-running daemon that warns steadily
|
|
6323
|
+
// without ever writing enough records to rotate. Unref'd, so it never
|
|
6324
|
+
// holds the process open. Armed only once the daemon is actually
|
|
6325
|
+
// serving: every step above can throw, and runServe is an exported
|
|
6326
|
+
// function as much as a process entry point — in a host that survives
|
|
6327
|
+
// the failure, a timer armed before the throw would outlive the call
|
|
6328
|
+
// and go on truncating that environment's redirect logs. The finally
|
|
6329
|
+
// below takes it down on every other exit path.
|
|
6330
|
+
if (logWriter) {
|
|
6331
|
+
redirectTimer = setInterval(() => void truncateRedirectLogs(logsDirPath(env)), 5 * 60_000);
|
|
6332
|
+
redirectTimer.unref?.();
|
|
6333
|
+
}
|
|
5247
6334
|
// Hold the event loop open until a shutdown request, then drain: new
|
|
5248
6335
|
// dispatches are refused while in-flight turns finish.
|
|
5249
6336
|
await new Promise((resolve) => {
|
|
@@ -5255,12 +6342,46 @@ export const runServe = async (command, streams, env = {}) => {
|
|
|
5255
6342
|
}
|
|
5256
6343
|
settled = true;
|
|
5257
6344
|
clearInterval(keepAlive);
|
|
5258
|
-
process.off('SIGTERM', shutdown);
|
|
5259
|
-
process.off('SIGINT', shutdown);
|
|
5260
6345
|
resolve();
|
|
5261
6346
|
};
|
|
5262
|
-
|
|
5263
|
-
|
|
6347
|
+
stopSignal = () => {
|
|
6348
|
+
if (!settled) {
|
|
6349
|
+
shutdown();
|
|
6350
|
+
return;
|
|
6351
|
+
}
|
|
6352
|
+
// A signal while a drain is already running — a repeat, or a stop
|
|
6353
|
+
// arriving during a restart's drain. Either way the drain goes on,
|
|
6354
|
+
// and a daemon told to stop does not come back.
|
|
6355
|
+
stopRequested = true;
|
|
6356
|
+
if (!repeatWarned) {
|
|
6357
|
+
repeatWarned = true;
|
|
6358
|
+
// Said from signal dispatch, outside the promise and its finally:
|
|
6359
|
+
// a stream that throws on write (a host's injected stderr) would
|
|
6360
|
+
// otherwise end the process from here — mid-drain, the very
|
|
6361
|
+
// thing this handler exists to prevent. The word is best effort.
|
|
6362
|
+
try {
|
|
6363
|
+
warn('stop signal received while draining; still draining, and this daemon will not come back (SIGKILL ends it at once)');
|
|
6364
|
+
}
|
|
6365
|
+
catch {
|
|
6366
|
+
// Nothing to say it to; the drain is what matters.
|
|
6367
|
+
}
|
|
6368
|
+
}
|
|
6369
|
+
};
|
|
6370
|
+
// `on`, never `once`, and not removed until the drain is over (the
|
|
6371
|
+
// finally below). `once` drops the listener before running it, Node
|
|
6372
|
+
// uninstalls its native handler the moment no listener is left, and
|
|
6373
|
+
// a second SIGTERM landing in that window ends the process by the
|
|
6374
|
+
// default action — mid-drain, with nothing said. The second signal
|
|
6375
|
+
// is the normal case, not an edge: a stop delivered to the process
|
|
6376
|
+
// group, as systemd's KillMode=control-group and a terminal's Ctrl+C
|
|
6377
|
+
// deliver it, reaches a supervised daemon once from the kernel and
|
|
6378
|
+
// once more forwarded by its supervisor. Reproduced two runs in
|
|
6379
|
+
// three: the replacement died and the supervisor exited 1 on a
|
|
6380
|
+
// `stratus service stop` after a restart. SIGKILL still ends the
|
|
6381
|
+
// process at once.
|
|
6382
|
+
process.on('SIGTERM', stopSignal);
|
|
6383
|
+
process.on('SIGINT', stopSignal);
|
|
6384
|
+
void shutdownRequested.then(shutdown);
|
|
5264
6385
|
if (env.shutdownSignal?.aborted) {
|
|
5265
6386
|
shutdown();
|
|
5266
6387
|
}
|
|
@@ -5268,20 +6389,85 @@ export const runServe = async (command, streams, env = {}) => {
|
|
|
5268
6389
|
env.shutdownSignal?.addEventListener('abort', shutdown, { once: true });
|
|
5269
6390
|
}
|
|
5270
6391
|
});
|
|
5271
|
-
|
|
5272
|
-
|
|
5273
|
-
|
|
6392
|
+
// Decided now, before the stop below: a restart's drain can still be
|
|
6393
|
+
// running when a stop signal releases this wait, and `onRestart` then
|
|
6394
|
+
// fires during that stop. A daemon told to stop does not come back,
|
|
6395
|
+
// whatever it was told before.
|
|
6396
|
+
if (!restart) {
|
|
6397
|
+
stopRequested = true;
|
|
6398
|
+
}
|
|
6399
|
+
if (restart) {
|
|
6400
|
+
// Already stopped: the gateway drained and closed before handing the
|
|
6401
|
+
// process over, and the stop() below finds nothing to do. Said here,
|
|
6402
|
+
// while the structured log still takes writes — the next lines
|
|
6403
|
+
// belong to a process that has none.
|
|
6404
|
+
if (!restart.drained) {
|
|
6405
|
+
// A turn that ignored its abort is still running in this process,
|
|
6406
|
+
// possibly still writing sessions. Starting a fresh daemon beside
|
|
6407
|
+
// it would be two processes on one store; exiting instead leaves
|
|
6408
|
+
// the service manager, where there is one, to bring it back.
|
|
6409
|
+
warn(`restart: something did not let go; exiting with status ${UNDRAINED_RESTART_EXIT_CODE} for the service manager to restart, instead of starting a new daemon beside it`);
|
|
6410
|
+
}
|
|
6411
|
+
else {
|
|
6412
|
+
log(`restarting stratusd${restart.reason ? ` (${restart.reason})` : ''}`);
|
|
6413
|
+
}
|
|
6414
|
+
}
|
|
6415
|
+
else {
|
|
6416
|
+
writeLine(streams.stdout, 'Stopping — draining in-flight turns.');
|
|
6417
|
+
}
|
|
5274
6418
|
}
|
|
5275
6419
|
finally {
|
|
5276
|
-
|
|
5277
|
-
|
|
6420
|
+
try {
|
|
6421
|
+
await gateway.stop();
|
|
6422
|
+
// Writes are queued and dropped on the hot path so logging never sits
|
|
6423
|
+
// on a streamed token. That makes the tail of the log the part most
|
|
6424
|
+
// likely to be lost — and the tail is the shutdown reason, the last
|
|
6425
|
+
// warning, the line explaining a restart.
|
|
6426
|
+
await logWriter?.flush();
|
|
5278
6427
|
}
|
|
5279
|
-
|
|
5280
|
-
|
|
5281
|
-
|
|
5282
|
-
|
|
5283
|
-
|
|
5284
|
-
|
|
6428
|
+
finally {
|
|
6429
|
+
// The shutdown is over, the flush included: a stop signal from here
|
|
6430
|
+
// on is the process's to handle by default again — and a host that
|
|
6431
|
+
// calls runServe repeatedly (the tests) must not accumulate handlers.
|
|
6432
|
+
// Last, and under its own finally, because the listener is on the
|
|
6433
|
+
// global process: taken off before the flush, a repeated signal
|
|
6434
|
+
// landing during it would end the process by the default action
|
|
6435
|
+
// (which a supervisor reports as a failure, and a service manager
|
|
6436
|
+
// set to restart on failure would then bring back a daemon an
|
|
6437
|
+
// operator just stopped); left on by a stop() that rejects (its last
|
|
6438
|
+
// log line can throw through an injected stream), a settled handler
|
|
6439
|
+
// would swallow the host's next signal.
|
|
6440
|
+
if (stopSignal) {
|
|
6441
|
+
process.off('SIGTERM', stopSignal);
|
|
6442
|
+
process.off('SIGINT', stopSignal);
|
|
6443
|
+
}
|
|
6444
|
+
if (redirectTimer) {
|
|
6445
|
+
clearInterval(redirectTimer);
|
|
6446
|
+
}
|
|
6447
|
+
}
|
|
6448
|
+
}
|
|
6449
|
+
const bound = boundApiPort !== undefined && Number.isInteger(boundApiPort) && boundApiPort > 0
|
|
6450
|
+
? { boundApiPort }
|
|
6451
|
+
: {};
|
|
6452
|
+
if (!restart || stopRequested) {
|
|
6453
|
+
return { code: 0, ...bound, sessions: [] };
|
|
6454
|
+
}
|
|
6455
|
+
if (!restart.drained) {
|
|
6456
|
+
(env.exitProcess ?? process.exit)(UNDRAINED_RESTART_EXIT_CODE);
|
|
6457
|
+
return { code: UNDRAINED_RESTART_EXIT_CODE, ...bound, sessions: [] };
|
|
6458
|
+
}
|
|
6459
|
+
// Coming back: the browser sessions live at the stop go to the
|
|
6460
|
+
// replacement — up the channel to the supervisor when this daemon has
|
|
6461
|
+
// one, else with the outcome to the supervisor this process becomes.
|
|
6462
|
+
const sessions = boundApi?.sessionsAtStop() ?? [];
|
|
6463
|
+
if (supervised && sessions.length > 0) {
|
|
6464
|
+
link.send({ type: 'stratusd.sessions', sessions });
|
|
6465
|
+
}
|
|
6466
|
+
// Asked to come back. Whether this process starts the next daemon or
|
|
6467
|
+
// exits for its supervisor to is `runServe`'s decision, after the home
|
|
6468
|
+
// is released: the claim is held until this returns, and a daemon
|
|
6469
|
+
// started before that would be refused as a second one on the home.
|
|
6470
|
+
return { code: RESTART_EXIT_CODE, ...bound, sessions };
|
|
5285
6471
|
};
|
|
5286
6472
|
export const runCli = async ({ argv, streams = process, env = {} }) => {
|
|
5287
6473
|
try {
|
|
@@ -5320,6 +6506,7 @@ export const runCli = async ({ argv, streams = process, env = {} }) => {
|
|
|
5320
6506
|
|| command.command === 'run'
|
|
5321
6507
|
|| command.command === 'skill-add'
|
|
5322
6508
|
|| command.command === 'dashboard'
|
|
6509
|
+
|| (command.command === 'credential' && command.action !== 'list')
|
|
5323
6510
|
|| (command.command === 'schedules' && command.action === 'cancel')
|
|
5324
6511
|
|| (command.command === 'service' && (command.action === 'install' || command.action === 'start'));
|
|
5325
6512
|
if (writesState) {
|
|
@@ -5359,9 +6546,21 @@ export const runCli = async ({ argv, streams = process, env = {} }) => {
|
|
|
5359
6546
|
if (command.command === 'skill-add') {
|
|
5360
6547
|
return await runSkillAdd(command, streams, resolvedEnv);
|
|
5361
6548
|
}
|
|
6549
|
+
if (command.command === 'skill-validate') {
|
|
6550
|
+
return await runSkillValidate(command, streams, resolvedEnv);
|
|
6551
|
+
}
|
|
5362
6552
|
if (command.command === 'skills') {
|
|
5363
6553
|
return await runSkills(streams, resolvedEnv);
|
|
5364
6554
|
}
|
|
6555
|
+
if (command.command === 'credential') {
|
|
6556
|
+
return await runCredential(command, streams, resolvedEnv);
|
|
6557
|
+
}
|
|
6558
|
+
if (command.command === 'skill-reload') {
|
|
6559
|
+
return await runSkillReload(command, streams, resolvedEnv);
|
|
6560
|
+
}
|
|
6561
|
+
if (command.command === 'restart') {
|
|
6562
|
+
return await runRestart(command, streams, resolvedEnv);
|
|
6563
|
+
}
|
|
5365
6564
|
if (command.command === 'schedules') {
|
|
5366
6565
|
return await runSchedules(command, streams, resolvedEnv);
|
|
5367
6566
|
}
|