@stratusagent/cli 0.10.0 → 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/index.d.ts +176 -4
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1234 -120
- 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
|
|
@@ -243,9 +253,14 @@ Usage:
|
|
|
243
253
|
stratus agents --gateway http://127.0.0.1:4123
|
|
244
254
|
stratus skill add stratuslabs/skill-code-review
|
|
245
255
|
stratus skill add ./my-skills --skill code-review --agent ava
|
|
256
|
+
stratus skill validate ./my-skill
|
|
246
257
|
stratus skills
|
|
258
|
+
stratus skill reload
|
|
259
|
+
stratus restart
|
|
247
260
|
stratus schedules
|
|
248
261
|
stratus schedules cancel <id>
|
|
262
|
+
printf %s "$BRAVE_KEY" | stratus credential set search.apiKey
|
|
263
|
+
stratus credentials
|
|
249
264
|
stratus doctor
|
|
250
265
|
stratus update
|
|
251
266
|
stratus update --check
|
|
@@ -269,7 +284,9 @@ Commands:
|
|
|
269
284
|
run Execute one local Stratus Agent session
|
|
270
285
|
serve Run stratusd, the always-on gateway: durable sessions, the
|
|
271
286
|
whole roster live at once (each agent on its own provider),
|
|
272
|
-
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
|
|
273
290
|
(--idle-timeout <seconds>, --approvals <headless|remote>,
|
|
274
291
|
--no-events, --no-log-file, --config <path>); everything it
|
|
275
292
|
says is also written to ~/.stratus/logs, which
|
|
@@ -291,9 +308,39 @@ Commands:
|
|
|
291
308
|
(skills.sh-style repos). Installed is not enabled: a soul
|
|
292
309
|
opts in via skills:, or pass --agent <id> to enable now.
|
|
293
310
|
--skill <id> picks from a multi-skill repo (repeatable),
|
|
294
|
-
--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
|
|
295
321
|
skills List installed skills and which agents enable each
|
|
296
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)
|
|
297
344
|
schedules List every schedule the fleet has set — cadence, prompt,
|
|
298
345
|
pre-authorized destination, next firing — straight from the
|
|
299
346
|
daemon's database (--format json). "stratus schedules
|
|
@@ -345,9 +392,16 @@ Options:
|
|
|
345
392
|
--port dashboard: port for a daemon it starts (default: 4123)
|
|
346
393
|
--host dashboard: host for a daemon it starts (default: 127.0.0.1)
|
|
347
394
|
--no-open Do not open the browser automatically
|
|
348
|
-
--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)
|
|
349
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)
|
|
350
403
|
--no-api serve: do not serve the control API
|
|
404
|
+
--api serve: serve it even where the config says api.enabled: false
|
|
351
405
|
--api-host serve: control API interface (default: 127.0.0.1)
|
|
352
406
|
--api-port serve: control API port (default: 4123, 0 for any free port)
|
|
353
407
|
--help, -h Show this help message
|
|
@@ -401,6 +455,23 @@ const readPromptFromStdin = async (stdin) => {
|
|
|
401
455
|
}
|
|
402
456
|
return data.trim();
|
|
403
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
|
+
};
|
|
404
475
|
const readOptionValue = (tokens, index, flag) => {
|
|
405
476
|
const value = tokens[index + 1];
|
|
406
477
|
if (!value) {
|
|
@@ -497,6 +568,10 @@ export const parseCommand = (argv, env = {}) => {
|
|
|
497
568
|
parsed.api = false;
|
|
498
569
|
continue;
|
|
499
570
|
}
|
|
571
|
+
if (token === '--api') {
|
|
572
|
+
parsed.api = true;
|
|
573
|
+
continue;
|
|
574
|
+
}
|
|
500
575
|
if (token === '--api-port') {
|
|
501
576
|
const port = Number(readOptionValue(rest, index, '--api-port'));
|
|
502
577
|
if (!Number.isInteger(port) || port < 0 || port > 65_535) {
|
|
@@ -802,6 +877,63 @@ export const parseCommand = (argv, env = {}) => {
|
|
|
802
877
|
...(sessionId ? { sessionId } : {}),
|
|
803
878
|
};
|
|
804
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
|
+
}
|
|
805
937
|
if (command === 'skills' || (command === 'skill' && rest[0] === 'list')) {
|
|
806
938
|
const skillsRest = command === 'skills' ? rest : rest.slice(1);
|
|
807
939
|
for (const token of skillsRest) {
|
|
@@ -817,13 +949,57 @@ export const parseCommand = (argv, env = {}) => {
|
|
|
817
949
|
if (subcommand === '--help' || subcommand === '-h') {
|
|
818
950
|
return { command: 'help' };
|
|
819
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
|
+
}
|
|
820
995
|
if (subcommand !== 'add') {
|
|
821
|
-
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`);
|
|
822
997
|
}
|
|
823
998
|
let source;
|
|
824
999
|
const skillIds = [];
|
|
825
1000
|
let force = false;
|
|
826
1001
|
let agentId;
|
|
1002
|
+
let reload = true;
|
|
827
1003
|
for (let index = 0; index < skillRest.length; index += 1) {
|
|
828
1004
|
const token = skillRest[index];
|
|
829
1005
|
if (!token) {
|
|
@@ -846,6 +1022,10 @@ export const parseCommand = (argv, env = {}) => {
|
|
|
846
1022
|
force = true;
|
|
847
1023
|
continue;
|
|
848
1024
|
}
|
|
1025
|
+
if (token === '--no-reload') {
|
|
1026
|
+
reload = false;
|
|
1027
|
+
continue;
|
|
1028
|
+
}
|
|
849
1029
|
if (token.startsWith('--')) {
|
|
850
1030
|
throw new Error(`Unknown option: ${token}`);
|
|
851
1031
|
}
|
|
@@ -863,8 +1043,47 @@ export const parseCommand = (argv, env = {}) => {
|
|
|
863
1043
|
...(skillIds.length > 0 ? { skillIds } : {}),
|
|
864
1044
|
...(force ? { force } : {}),
|
|
865
1045
|
...(agentId !== undefined ? { agentId } : {}),
|
|
1046
|
+
...(reload ? {} : { reload }),
|
|
866
1047
|
};
|
|
867
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
|
+
}
|
|
868
1087
|
if (command === 'agent') {
|
|
869
1088
|
const [subcommand, ...agentRest] = rest;
|
|
870
1089
|
if (subcommand === '--help' || subcommand === '-h') {
|
|
@@ -1184,7 +1403,56 @@ export const warnOnCredentialOverride = async (runtime, streams, env = {}) => {
|
|
|
1184
1403
|
+ 'Unset it to use the subscription, or run `stratus doctor` to see everything that is being overridden.');
|
|
1185
1404
|
}
|
|
1186
1405
|
};
|
|
1187
|
-
|
|
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) => {
|
|
1188
1456
|
if (mode === 'always') {
|
|
1189
1457
|
return new AllowAllApprovalPolicy();
|
|
1190
1458
|
}
|
|
@@ -1199,25 +1467,27 @@ const createApprovalPolicy = (mode, streams, env, ask) => {
|
|
|
1199
1467
|
// asker — two readers on one stream would race for the same bytes.
|
|
1200
1468
|
if (ask) {
|
|
1201
1469
|
return {
|
|
1202
|
-
async approve(
|
|
1203
|
-
const
|
|
1204
|
-
|
|
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);
|
|
1205
1474
|
},
|
|
1206
1475
|
};
|
|
1207
1476
|
}
|
|
1208
1477
|
return {
|
|
1209
|
-
async approve(
|
|
1478
|
+
async approve(context) {
|
|
1210
1479
|
const input = env.approvalInput ?? process.stdin;
|
|
1211
1480
|
const readline = createInterface({ input, terminal: false });
|
|
1481
|
+
const approved = approvalOrigin(context);
|
|
1212
1482
|
// Prompt on stderr so stdout stays parseable (e.g. --format json).
|
|
1213
|
-
streams.stderr.write(`Approve tool call ${
|
|
1483
|
+
streams.stderr.write(`Approve tool call ${describeApprovalCall(context)}? [y/N] `);
|
|
1214
1484
|
try {
|
|
1215
1485
|
const answer = await new Promise((resolve) => {
|
|
1216
1486
|
readline.once('line', resolve);
|
|
1217
1487
|
readline.once('close', () => resolve(''));
|
|
1218
1488
|
});
|
|
1219
1489
|
writeLine(streams.stderr);
|
|
1220
|
-
return /^y(es)?$/i.test(answer.trim());
|
|
1490
|
+
return /^y(es)?$/i.test(answer.trim()) && stillOnApprovedPage(context, approved, streams);
|
|
1221
1491
|
}
|
|
1222
1492
|
finally {
|
|
1223
1493
|
readline.close();
|
|
@@ -1284,6 +1554,7 @@ const createAgentRuntime = async (streams, options) => {
|
|
|
1284
1554
|
tools,
|
|
1285
1555
|
skills,
|
|
1286
1556
|
bus,
|
|
1557
|
+
credentials: createFileCredentialResolver(runEnv),
|
|
1287
1558
|
workspaceRoot: workspacesDirPath(runEnv),
|
|
1288
1559
|
});
|
|
1289
1560
|
loadedPlugins.push(...result.loaded);
|
|
@@ -3150,9 +3421,10 @@ export const runSetup = async (command, streams, env = {}) => {
|
|
|
3150
3421
|
if (chosen.length > 0) {
|
|
3151
3422
|
const packages = chosen.flatMap((group) => group.packages);
|
|
3152
3423
|
writeLine(streams.stdout, `Running: npm install -g ${packages.join(' ')}`);
|
|
3153
|
-
// Never fails setup
|
|
3154
|
-
// the config and credentials are already written,
|
|
3155
|
-
// 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.
|
|
3156
3428
|
const result = await (env.packageInstaller ?? defaultPackageInstaller)(packages)
|
|
3157
3429
|
.catch((error) => ({
|
|
3158
3430
|
ok: false,
|
|
@@ -3181,20 +3453,24 @@ export const runSetup = async (command, streams, env = {}) => {
|
|
|
3181
3453
|
};
|
|
3182
3454
|
/**
|
|
3183
3455
|
* Write everything the menu decided, then report whether the always-on
|
|
3184
|
-
*
|
|
3456
|
+
* service step it performed — installing the service, or removing one the
|
|
3457
|
+
* user chose not to run — succeeded.
|
|
3185
3458
|
*
|
|
3186
|
-
* A boolean rather than a throw: the
|
|
3459
|
+
* A boolean rather than a throw: the service is the one optional part of
|
|
3187
3460
|
* setup and everything else is already on disk by the time it runs, so
|
|
3188
3461
|
* failing outright would lose the saved config to a service that can be
|
|
3189
|
-
* installed later. The exit code carries it instead — the
|
|
3190
|
-
* `stratus service install`
|
|
3191
|
-
*
|
|
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.
|
|
3192
3466
|
*/
|
|
3193
3467
|
const save = async () => {
|
|
3194
|
-
// The always-on
|
|
3468
|
+
// The always-on service step is the only one here that can fail without
|
|
3195
3469
|
// taking the rest of setup with it, so it is the only one the exit code
|
|
3196
|
-
// has to carry.
|
|
3197
|
-
|
|
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;
|
|
3198
3474
|
const config = { provider: state.provider };
|
|
3199
3475
|
if (state.provider !== 'demo') {
|
|
3200
3476
|
config.model = state.model ?? defaultModelFor(state.provider);
|
|
@@ -3287,6 +3563,9 @@ export const runSetup = async (command, streams, env = {}) => {
|
|
|
3287
3563
|
for (const message of removed.messages) {
|
|
3288
3564
|
writeLine(removed.ok ? streams.stdout : streams.stderr, message);
|
|
3289
3565
|
}
|
|
3566
|
+
if (!removed.ok) {
|
|
3567
|
+
serviceStepFailed = true;
|
|
3568
|
+
}
|
|
3290
3569
|
}
|
|
3291
3570
|
}
|
|
3292
3571
|
else if (state.service.install && servicePlatform(serviceEnvFor(env))) {
|
|
@@ -3312,7 +3591,7 @@ export const runSetup = async (command, streams, env = {}) => {
|
|
|
3312
3591
|
}
|
|
3313
3592
|
if (!result.ok) {
|
|
3314
3593
|
writeLine(streams.stderr, `Setup is saved either way — start the daemon yourself with \`${serveCommand()}\`.`);
|
|
3315
|
-
|
|
3594
|
+
serviceStepFailed = true;
|
|
3316
3595
|
}
|
|
3317
3596
|
}
|
|
3318
3597
|
writeLine(streams.stdout);
|
|
@@ -3363,7 +3642,7 @@ export const runSetup = async (command, streams, env = {}) => {
|
|
|
3363
3642
|
if (dashboardReady) {
|
|
3364
3643
|
writeLine(streams.stdout, ' stratus dashboard');
|
|
3365
3644
|
}
|
|
3366
|
-
return !
|
|
3645
|
+
return !serviceStepFailed;
|
|
3367
3646
|
};
|
|
3368
3647
|
try {
|
|
3369
3648
|
if (!interactive) {
|
|
@@ -3407,11 +3686,12 @@ export const runSetup = async (command, streams, env = {}) => {
|
|
|
3407
3686
|
await testRun();
|
|
3408
3687
|
}
|
|
3409
3688
|
}
|
|
3410
|
-
// Non-zero when the always-on
|
|
3411
|
-
// install
|
|
3412
|
-
//
|
|
3413
|
-
//
|
|
3414
|
-
//
|
|
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.
|
|
3415
3695
|
return (await save()) ? 0 : 1;
|
|
3416
3696
|
}
|
|
3417
3697
|
finally {
|
|
@@ -4461,6 +4741,18 @@ export const runSkillAdd = async (command, streams, env = {}) => {
|
|
|
4461
4741
|
});
|
|
4462
4742
|
for (const skill of result.installed) {
|
|
4463
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}`);
|
|
4464
4756
|
}
|
|
4465
4757
|
// Present already is a no-op, not a failure — and exactly what the
|
|
4466
4758
|
// "rerun with --agent" hint below produces, so these stay eligible
|
|
@@ -4477,6 +4769,13 @@ export const runSkillAdd = async (command, streams, env = {}) => {
|
|
|
4477
4769
|
: `Error: no skills found in ${command.source}. A skill is a directory with a SKILL.md.`);
|
|
4478
4770
|
return 1;
|
|
4479
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
|
+
}
|
|
4480
4779
|
// Installed is not enabled: a soul opts in through its skills:
|
|
4481
4780
|
// allowlist, and that stays true when the install came through a
|
|
4482
4781
|
// command. --agent is the explicit way to say both at once.
|
|
@@ -4564,6 +4863,279 @@ export const runSchedules = async (command, streams, env = {}) => {
|
|
|
4564
4863
|
store.close();
|
|
4565
4864
|
}
|
|
4566
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
|
+
};
|
|
4567
5139
|
export const runSkills = async (streams, env = {}) => {
|
|
4568
5140
|
const registry = new SkillRegistry();
|
|
4569
5141
|
const skills = await loadOperatorSkills(env, registry, (line) => {
|
|
@@ -4808,6 +5380,85 @@ const gatewayAnswering = async (env, base, fetchImpl) => {
|
|
|
4808
5380
|
return false;
|
|
4809
5381
|
}
|
|
4810
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
|
+
};
|
|
4811
5462
|
/**
|
|
4812
5463
|
* `stratus dashboard` — open the web UI against a running daemon, starting
|
|
4813
5464
|
* one in the foreground when there is none.
|
|
@@ -4830,56 +5481,87 @@ export const runDashboard = async (command, streams, env = {}) => {
|
|
|
4830
5481
|
if (!base) {
|
|
4831
5482
|
writeLine(streams.stdout, 'No daemon is running — starting one. It stops when you do.');
|
|
4832
5483
|
writeLine(streams.stdout, 'Run `stratus service install` to keep one running instead.');
|
|
4833
|
-
|
|
4834
|
-
|
|
4835
|
-
|
|
4836
|
-
|
|
4837
|
-
|
|
4838
|
-
|
|
4839
|
-
|
|
4840
|
-
api: true,
|
|
4841
|
-
...(command.port !== undefined ? { apiPort: command.port } : {}),
|
|
4842
|
-
// Passed whenever it was asked for, including when it is the default.
|
|
4843
|
-
// `--host 127.0.0.1` against a config saying `0.0.0.0` is an operator
|
|
4844
|
-
// narrowing the bind, and dropping it because it matched the default
|
|
4845
|
-
// did the reverse of what they typed.
|
|
4846
|
-
...(command.host !== undefined ? { apiHost: command.host } : {}),
|
|
4847
|
-
},
|
|
4848
|
-
// The daemon's own chatter belongs on stderr here: stdout is where this
|
|
4849
|
-
// command says where to point a browser, and interleaving the two makes
|
|
4850
|
-
// the one line that matters hard to find.
|
|
4851
|
-
{ stdout: streams.stderr, stderr: streams.stderr }, { ...env, shutdownSignal: ownDaemon.signal });
|
|
4852
|
-
// Attached now, not at the end. `runServe` can reject before it publishes
|
|
4853
|
-
// anything — an unreadable roster, a session store that will not open —
|
|
4854
|
-
// and a rejected promise nobody is watching for fifteen seconds is an
|
|
4855
|
-
// unhandled rejection, which terminates the process instead of reaching
|
|
4856
|
-
// the message below. Captured rather than swallowed, so the reason the
|
|
4857
|
-
// 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.
|
|
4858
5491
|
let daemonFailure;
|
|
4859
|
-
|
|
4860
|
-
|
|
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
|
|
4861
5523
|
// where it bound the moment it binds, and anything less would be a race
|
|
4862
|
-
// dressed up as a timeout. It also stops the moment
|
|
4863
|
-
//
|
|
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.
|
|
4864
5531
|
const startedBy = Date.now() + 15_000;
|
|
4865
|
-
|
|
4866
|
-
void daemonExit.then(() => { daemonStopped = true; });
|
|
4867
|
-
while (!base && !daemonStopped && Date.now() < startedBy) {
|
|
5532
|
+
while (!base && Date.now() < startedBy) {
|
|
4868
5533
|
const info = await readGatewayInfo(env);
|
|
4869
5534
|
if (info && await gatewayAnswering(env, info.url, fetchImpl)) {
|
|
4870
5535
|
base = info.url;
|
|
4871
5536
|
break;
|
|
4872
5537
|
}
|
|
5538
|
+
if (daemonSettled) {
|
|
5539
|
+
if (!(daemonFailure instanceof HomeHeldError)) {
|
|
5540
|
+
break;
|
|
5541
|
+
}
|
|
5542
|
+
if (Date.now() - attemptedAt >= 1_000) {
|
|
5543
|
+
attempt();
|
|
5544
|
+
}
|
|
5545
|
+
}
|
|
4873
5546
|
await new Promise((resolve) => setTimeout(resolve, 150));
|
|
4874
5547
|
}
|
|
4875
5548
|
if (!base) {
|
|
4876
5549
|
ownDaemon.abort();
|
|
4877
5550
|
await daemonExit;
|
|
4878
|
-
writeLine(streams.stderr, daemonFailure
|
|
4879
|
-
?
|
|
4880
|
-
|
|
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?');
|
|
4881
5557
|
return 1;
|
|
4882
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
|
+
}
|
|
4883
5565
|
}
|
|
4884
5566
|
let url;
|
|
4885
5567
|
try {
|
|
@@ -4915,7 +5597,15 @@ export const runDashboard = async (command, streams, env = {}) => {
|
|
|
4915
5597
|
await new Promise((resolve) => setTimeout(resolve, env.dashboardAutoShutdownMs));
|
|
4916
5598
|
ownDaemon.abort();
|
|
4917
5599
|
}
|
|
4918
|
-
|
|
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
|
+
});
|
|
4919
5609
|
};
|
|
4920
5610
|
/**
|
|
4921
5611
|
* Resolution and loading are separate questions, and only the first one
|
|
@@ -5018,6 +5708,214 @@ const loadServePlugins = async (env, configPath, warn) => {
|
|
|
5018
5708
|
* roster, accept dispatches, print events, and drain cleanly on SIGTERM,
|
|
5019
5709
|
* SIGINT, or the injected shutdown signal.
|
|
5020
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
|
+
};
|
|
5021
5919
|
export const runServe = async (command, streams, env = {}) => {
|
|
5022
5920
|
// First, before anything that can throw. A broken install makes the
|
|
5023
5921
|
// gateway import below fail, the manager restarts, and the CLI's error
|
|
@@ -5030,7 +5928,46 @@ export const runServe = async (command, streams, env = {}) => {
|
|
|
5030
5928
|
// Loaded lazily: the gateway pulls in node:sqlite, which every other CLI
|
|
5031
5929
|
// command neither needs nor should pay for (Node still prints an
|
|
5032
5930
|
// experimental warning for it).
|
|
5033
|
-
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) => {
|
|
5034
5971
|
// Under a service manager the daemon's stdout is gone, so everything it
|
|
5035
5972
|
// says is also written to ~/.stratus/logs — that file is what `stratus
|
|
5036
5973
|
// logs` reads, and the only record of an overnight run.
|
|
@@ -5069,6 +6006,8 @@ export const runServe = async (command, streams, env = {}) => {
|
|
|
5069
6006
|
// Typed as the seam, not as whatever the first push happens to be: the
|
|
5070
6007
|
// list holds the control API and the Slack adapter alike.
|
|
5071
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;
|
|
5072
6011
|
if (apiWanted) {
|
|
5073
6012
|
const createControlApi = await loadControlApi();
|
|
5074
6013
|
// Resolved into locals first. Inlined, `a ?? b !== undefined` parses as
|
|
@@ -5077,16 +6016,23 @@ export const runServe = async (command, streams, env = {}) => {
|
|
|
5077
6016
|
// Everything still bound and every test still passed, on whichever port
|
|
5078
6017
|
// nobody happened to be using.
|
|
5079
6018
|
const apiHost = command.apiHost ?? apiConfig.host;
|
|
5080
|
-
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;
|
|
5081
6025
|
if (createControlApi) {
|
|
5082
|
-
|
|
6026
|
+
const api = createControlApi({
|
|
5083
6027
|
env,
|
|
5084
6028
|
...(apiHost !== undefined ? { host: apiHost } : {}),
|
|
5085
6029
|
...(apiPort !== undefined ? { port: apiPort } : {}),
|
|
5086
6030
|
...(command.configPath ? { configPath: command.configPath } : {}),
|
|
5087
6031
|
log,
|
|
5088
6032
|
warn,
|
|
5089
|
-
})
|
|
6033
|
+
});
|
|
6034
|
+
boundApi = api;
|
|
6035
|
+
controlApiChannels.push(api);
|
|
5090
6036
|
}
|
|
5091
6037
|
else if (command.api === true || apiConfig.enabled === true) {
|
|
5092
6038
|
// Only when someone asked for it explicitly. A daemon that was never
|
|
@@ -5095,6 +6041,18 @@ export const runServe = async (command, streams, env = {}) => {
|
|
|
5095
6041
|
+ 'Run `npm install -g @stratusagent/control-api` to bring it online; starting without it.');
|
|
5096
6042
|
}
|
|
5097
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
|
+
}
|
|
5098
6056
|
const channelCredentials = await loadChannelCredentials(env);
|
|
5099
6057
|
const slackAgents = Object.entries(channelCredentials.slack ?? {});
|
|
5100
6058
|
const channels = [...controlApiChannels];
|
|
@@ -5192,7 +6150,10 @@ export const runServe = async (command, streams, env = {}) => {
|
|
|
5192
6150
|
// a tool that carries no command string is judged by its risk exactly as
|
|
5193
6151
|
// before. The whitelist lives beside the agent's soul, per agent.
|
|
5194
6152
|
const commands = {
|
|
5195
|
-
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 }),
|
|
5196
6157
|
onScopeRemembered: ({ agentId, scope }) => {
|
|
5197
6158
|
// An approval that widens what runs unattended, for every future
|
|
5198
6159
|
// session, is precisely the decision that must not be the one leaving
|
|
@@ -5200,14 +6161,24 @@ export const runServe = async (command, streams, env = {}) => {
|
|
|
5200
6161
|
log(`${agentId}: "${describeCommandScope(scope)}" now runs without asking`);
|
|
5201
6162
|
},
|
|
5202
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
|
+
};
|
|
5203
6174
|
const approvals = (transport) => createPermissionPolicy(
|
|
5204
6175
|
// The destination scope rides along in BOTH modes — it is what lets a
|
|
5205
6176
|
// scheduled turn report to the channel a human approved with the
|
|
5206
6177
|
// schedule, and headless (where every other gated call is refused) is
|
|
5207
6178
|
// exactly the deployment it exists for.
|
|
5208
6179
|
approvalMode === 'remote'
|
|
5209
|
-
? { mode: 'remote', request: transport.request, onDecision, commands, destinations: transport.destinations }
|
|
5210
|
-
: { 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 });
|
|
5211
6182
|
if (approvalMode === 'remote') {
|
|
5212
6183
|
// Only agents whose channel actually came up can be asked: tokens on
|
|
5213
6184
|
// disk with the Slack package missing means nothing renders the
|
|
@@ -5215,9 +6186,30 @@ export const runServe = async (command, streams, env = {}) => {
|
|
|
5215
6186
|
const askable = slackAdapterUp ? slackAgents.map(([agentId]) => agentId) : [];
|
|
5216
6187
|
log(`approvals: remote — gated calls are parked and asked in Slack (${describeApprovers(approvalsConfig, askable)})`);
|
|
5217
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
|
+
});
|
|
5218
6206
|
const gateway = createGateway({
|
|
5219
6207
|
env,
|
|
5220
6208
|
approvals,
|
|
6209
|
+
onRestart: (outcome) => {
|
|
6210
|
+
restart = outcome;
|
|
6211
|
+
requestShutdown();
|
|
6212
|
+
},
|
|
5221
6213
|
...(Object.keys(pluginsConfig).length > 0
|
|
5222
6214
|
? {
|
|
5223
6215
|
plugins: pluginsConfig,
|
|
@@ -5299,36 +6291,46 @@ export const runServe = async (command, streams, env = {}) => {
|
|
|
5299
6291
|
});
|
|
5300
6292
|
}
|
|
5301
6293
|
await gateway.start();
|
|
5302
|
-
//
|
|
5303
|
-
//
|
|
5304
|
-
|
|
5305
|
-
|
|
5306
|
-
|
|
5307
|
-
|
|
5308
|
-
//
|
|
5309
|
-
|
|
5310
|
-
|
|
5311
|
-
|
|
5312
|
-
if (unreachable.length > 0) {
|
|
5313
|
-
warn(`no channel can ask for ${unreachable.join(', ')}, so their gated calls will wait out the `
|
|
5314
|
-
+ 'approval timeout and then be denied. Connect a Slack app for them, or run with --approvals headless.');
|
|
5315
|
-
}
|
|
5316
|
-
}
|
|
5317
|
-
writeLine(streams.stdout, 'Press Ctrl+C to stop.');
|
|
5318
|
-
// And periodically, for a long-running daemon that warns steadily
|
|
5319
|
-
// without ever writing enough records to rotate. Unref'd, so it never
|
|
5320
|
-
// holds the process open. Armed only once the daemon is actually
|
|
5321
|
-
// serving: every step above can throw, and runServe is an exported
|
|
5322
|
-
// function as much as a process entry point — in a host that survives
|
|
5323
|
-
// the failure, a timer armed before the throw would outlive the call
|
|
5324
|
-
// and go on truncating that environment's redirect logs. The finally
|
|
5325
|
-
// 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.
|
|
5326
6304
|
let redirectTimer;
|
|
5327
|
-
if (logWriter) {
|
|
5328
|
-
redirectTimer = setInterval(() => void truncateRedirectLogs(logsDirPath(env)), 5 * 60_000);
|
|
5329
|
-
redirectTimer.unref?.();
|
|
5330
|
-
}
|
|
5331
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
|
+
}
|
|
5332
6334
|
// Hold the event loop open until a shutdown request, then drain: new
|
|
5333
6335
|
// dispatches are refused while in-flight turns finish.
|
|
5334
6336
|
await new Promise((resolve) => {
|
|
@@ -5340,12 +6342,46 @@ export const runServe = async (command, streams, env = {}) => {
|
|
|
5340
6342
|
}
|
|
5341
6343
|
settled = true;
|
|
5342
6344
|
clearInterval(keepAlive);
|
|
5343
|
-
process.off('SIGTERM', shutdown);
|
|
5344
|
-
process.off('SIGINT', shutdown);
|
|
5345
6345
|
resolve();
|
|
5346
6346
|
};
|
|
5347
|
-
|
|
5348
|
-
|
|
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);
|
|
5349
6385
|
if (env.shutdownSignal?.aborted) {
|
|
5350
6386
|
shutdown();
|
|
5351
6387
|
}
|
|
@@ -5353,20 +6389,85 @@ export const runServe = async (command, streams, env = {}) => {
|
|
|
5353
6389
|
env.shutdownSignal?.addEventListener('abort', shutdown, { once: true });
|
|
5354
6390
|
}
|
|
5355
6391
|
});
|
|
5356
|
-
|
|
5357
|
-
|
|
5358
|
-
|
|
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
|
+
}
|
|
5359
6418
|
}
|
|
5360
6419
|
finally {
|
|
5361
|
-
|
|
5362
|
-
|
|
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();
|
|
5363
6427
|
}
|
|
5364
|
-
|
|
5365
|
-
|
|
5366
|
-
|
|
5367
|
-
|
|
5368
|
-
|
|
5369
|
-
|
|
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 };
|
|
5370
6471
|
};
|
|
5371
6472
|
export const runCli = async ({ argv, streams = process, env = {} }) => {
|
|
5372
6473
|
try {
|
|
@@ -5405,6 +6506,7 @@ export const runCli = async ({ argv, streams = process, env = {} }) => {
|
|
|
5405
6506
|
|| command.command === 'run'
|
|
5406
6507
|
|| command.command === 'skill-add'
|
|
5407
6508
|
|| command.command === 'dashboard'
|
|
6509
|
+
|| (command.command === 'credential' && command.action !== 'list')
|
|
5408
6510
|
|| (command.command === 'schedules' && command.action === 'cancel')
|
|
5409
6511
|
|| (command.command === 'service' && (command.action === 'install' || command.action === 'start'));
|
|
5410
6512
|
if (writesState) {
|
|
@@ -5444,9 +6546,21 @@ export const runCli = async ({ argv, streams = process, env = {} }) => {
|
|
|
5444
6546
|
if (command.command === 'skill-add') {
|
|
5445
6547
|
return await runSkillAdd(command, streams, resolvedEnv);
|
|
5446
6548
|
}
|
|
6549
|
+
if (command.command === 'skill-validate') {
|
|
6550
|
+
return await runSkillValidate(command, streams, resolvedEnv);
|
|
6551
|
+
}
|
|
5447
6552
|
if (command.command === 'skills') {
|
|
5448
6553
|
return await runSkills(streams, resolvedEnv);
|
|
5449
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
|
+
}
|
|
5450
6564
|
if (command.command === 'schedules') {
|
|
5451
6565
|
return await runSchedules(command, streams, resolvedEnv);
|
|
5452
6566
|
}
|