@stratusagent/cli 0.11.0 → 0.11.2
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 +6 -1
- package/dist/index.d.ts +46 -4
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +383 -20
- package/dist/index.js.map +1 -1
- package/package.json +22 -22
package/dist/index.js
CHANGED
|
@@ -13,14 +13,14 @@ import { mkdtemp, readdir, readFile, realpath, rm, stat, writeFile } from 'node:
|
|
|
13
13
|
import os from 'node:os';
|
|
14
14
|
import path from 'node:path';
|
|
15
15
|
import { createInterface } from 'node:readline';
|
|
16
|
-
import { AgentRunner, AllowAllApprovalPolicy, EventBus, SkillRegistry, ToolRegistry, matchesSkillAllowlist, missingSkillRequirements, originOf, } from '@stratusagent/core';
|
|
16
|
+
import { AgentRunner, AllowAllApprovalPolicy, EventBus, SkillRegistry, ToolRegistry, isTrustLevel, matchesSkillAllowlist, escapeControlCharacters, memoryEntryTrust, missingSkillRequirements, describeToolAllowlistFinding, unmatchedToolAllowlist, originOf, TRUST_LEVELS, } from '@stratusagent/core';
|
|
17
17
|
import { createLocalCommandExecutor, } from '@stratusagent/executor-local';
|
|
18
18
|
import { loadPlugins } from '@stratusagent/plugins';
|
|
19
19
|
import { createFileCommandWhitelist, createPermissionPolicy, describeCommandScope, describeOriginScope, } from '@stratusagent/permissions';
|
|
20
20
|
import { DEFAULT_ANTHROPIC_MODEL, redactAnthropicRawTurns, } from '@stratusagent/provider-anthropic';
|
|
21
21
|
import { DEFAULT_CODEX_MODEL } from '@stratusagent/provider-codex';
|
|
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';
|
|
22
|
+
import { canonicalDestination, createForgetTool, createRecallTool, createRememberTool, defineAgent, describeCadence, describeSchedule, FORGET_TOOL_NAME, MEMORY_TOOL_NAME, formatSoul, generateAgentName, isLoadableSkillId, isValidAgentId, parseSoul, GATEWAY_ONLY_TOOL_NAMES, } 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, resolveAgentPrincipals, 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';
|
|
24
24
|
import { installService, readServiceCommand, readServiceStatus, servicePlatform, serviceUnitPath, startService, stopService, uninstallService, } from "./service.js";
|
|
25
25
|
export { installService, launchdPlist, readServiceCommand, readServiceStatus, servicePlatform, serviceUnitPath, startService, stopService, systemdUnit, uninstallService, SERVICE_LABEL, } from "./service.js";
|
|
26
26
|
import { createLogWriter, currentLogPosition, truncateRedirectLogs, formatLogRecord, readRecentRecords, tailLog, } from "./logs.js";
|
|
@@ -133,7 +133,7 @@ const defaultPackageInstaller = async (packages) => {
|
|
|
133
133
|
* something other than this command.
|
|
134
134
|
*/
|
|
135
135
|
export const CREDENTIAL_NAME_PATTERN = /^[A-Za-z][A-Za-z0-9._-]{0,127}$/;
|
|
136
|
-
export const CLI_VERSION = '0.11.
|
|
136
|
+
export const CLI_VERSION = '0.11.2';
|
|
137
137
|
const DASHBOARD_TITLE = 'Stratus Agent Dashboard';
|
|
138
138
|
/**
|
|
139
139
|
* The range every package declares in its `engines` field. Two floors, not
|
|
@@ -259,6 +259,9 @@ Usage:
|
|
|
259
259
|
stratus restart
|
|
260
260
|
stratus schedules
|
|
261
261
|
stratus schedules cancel <id>
|
|
262
|
+
stratus memory list ava
|
|
263
|
+
stratus memory reassert ava --trust user --all-unknown
|
|
264
|
+
stratus session rollover slack:ava:T01ABCDEF:D07GHIJKL
|
|
262
265
|
printf %s "$BRAVE_KEY" | stratus credential set search.apiKey
|
|
263
266
|
stratus credentials
|
|
264
267
|
stratus doctor
|
|
@@ -347,6 +350,24 @@ Commands:
|
|
|
347
350
|
cancel <id>" stops the next firing and revokes the
|
|
348
351
|
destination that was approved with it
|
|
349
352
|
(also: stratus schedule list / schedule cancel <id>)
|
|
353
|
+
memory list Show an agent's live memory with the trust label each
|
|
354
|
+
entry carries — user, agent, unknown (no recorded origin,
|
|
355
|
+
or written in a conversation with someone not configured
|
|
356
|
+
as a principal), external (recorded after reading content
|
|
357
|
+
from outside). --trust <level> filters; --format json
|
|
358
|
+
memory reassert Re-label entries as an operator: stratus memory reassert
|
|
359
|
+
<agent> --trust user <id>..., or --all-unknown for every
|
|
360
|
+
entry with no recorded origin (the upgrade case; an
|
|
361
|
+
entry recorded unknown — a stranger's message, a shell
|
|
362
|
+
command — is left for you to name by id). Appends
|
|
363
|
+
a record to ~/.stratus/memory.jsonl; a running daemon
|
|
364
|
+
sees it on its next turn. The only way a label ever
|
|
365
|
+
rises — no tool can do this
|
|
366
|
+
session rollover Start a conversation over under the same session id,
|
|
367
|
+
archiving its transcript so far — for a long-lived
|
|
368
|
+
session (a Slack DM) that predates provenance tracking
|
|
369
|
+
and would otherwise write unknown forever. Asks the
|
|
370
|
+
running daemon (--gateway, --token)
|
|
350
371
|
agent new Create an agent identity (generates a human-ish name + avatar theme)
|
|
351
372
|
agents List your agents: who they are, where their souls live, what
|
|
352
373
|
they run on, what they remember (also: stratus agent list).
|
|
@@ -392,9 +413,12 @@ Options:
|
|
|
392
413
|
--port dashboard: port for a daemon it starts (default: 4123)
|
|
393
414
|
--host dashboard: host for a daemon it starts (default: 127.0.0.1)
|
|
394
415
|
--no-open Do not open the browser automatically
|
|
395
|
-
--gateway agents / skill reload / restart: a running
|
|
396
|
-
API (
|
|
416
|
+
--gateway agents / skill reload / restart / session rollover: a running
|
|
417
|
+
daemon's control API (all but agents default to the daemon
|
|
397
418
|
~/.stratus/gateway.json names)
|
|
419
|
+
--trust memory list: show only this label; memory reassert: the
|
|
420
|
+
label to record (user, agent, unknown, external)
|
|
421
|
+
--all-unknown memory reassert: every live entry with no recorded origin
|
|
398
422
|
--token Bearer token for --gateway (default: ~/.stratus/gateway-token)
|
|
399
423
|
--no-reload skill add: install without reloading a running daemon
|
|
400
424
|
--reason restart: why, for the daemon's log
|
|
@@ -1046,6 +1070,123 @@ export const parseCommand = (argv, env = {}) => {
|
|
|
1046
1070
|
...(reload ? {} : { reload }),
|
|
1047
1071
|
};
|
|
1048
1072
|
}
|
|
1073
|
+
if (command === 'memory') {
|
|
1074
|
+
const [action, ...memoryRest] = rest;
|
|
1075
|
+
if (action === undefined || action === '--help' || action === '-h') {
|
|
1076
|
+
return { command: 'help' };
|
|
1077
|
+
}
|
|
1078
|
+
if (action !== 'list' && action !== 'reassert') {
|
|
1079
|
+
throw new Error(`Unknown memory subcommand: ${action}. Try: stratus memory list <agent>, stratus memory reassert <agent> --trust user <id>...`);
|
|
1080
|
+
}
|
|
1081
|
+
let agentId;
|
|
1082
|
+
let trust;
|
|
1083
|
+
let format = 'text';
|
|
1084
|
+
let allUnknown = false;
|
|
1085
|
+
const ids = [];
|
|
1086
|
+
for (let index = 0; index < memoryRest.length; index += 1) {
|
|
1087
|
+
const token = memoryRest[index];
|
|
1088
|
+
if (!token) {
|
|
1089
|
+
continue;
|
|
1090
|
+
}
|
|
1091
|
+
if (token === '--help' || token === '-h') {
|
|
1092
|
+
return { command: 'help' };
|
|
1093
|
+
}
|
|
1094
|
+
if (token === '--trust') {
|
|
1095
|
+
const value = readOptionValue(memoryRest, index, '--trust');
|
|
1096
|
+
if (!isTrustLevel(value)) {
|
|
1097
|
+
throw new Error(`Unsupported trust level: ${value}. Use one of ${TRUST_LEVELS.join(', ')}.`);
|
|
1098
|
+
}
|
|
1099
|
+
trust = value;
|
|
1100
|
+
index += 1;
|
|
1101
|
+
continue;
|
|
1102
|
+
}
|
|
1103
|
+
if (token === '--format') {
|
|
1104
|
+
const value = readOptionValue(memoryRest, index, '--format');
|
|
1105
|
+
if (value !== 'text' && value !== 'json') {
|
|
1106
|
+
throw new Error(`Unsupported format: ${value}`);
|
|
1107
|
+
}
|
|
1108
|
+
format = value;
|
|
1109
|
+
index += 1;
|
|
1110
|
+
continue;
|
|
1111
|
+
}
|
|
1112
|
+
if (token === '--all-unknown' && action === 'reassert') {
|
|
1113
|
+
allUnknown = true;
|
|
1114
|
+
continue;
|
|
1115
|
+
}
|
|
1116
|
+
if (token.startsWith('--')) {
|
|
1117
|
+
throw new Error(`Unknown option: ${token}`);
|
|
1118
|
+
}
|
|
1119
|
+
if (agentId === undefined) {
|
|
1120
|
+
agentId = token;
|
|
1121
|
+
continue;
|
|
1122
|
+
}
|
|
1123
|
+
if (action === 'reassert') {
|
|
1124
|
+
ids.push(token);
|
|
1125
|
+
continue;
|
|
1126
|
+
}
|
|
1127
|
+
throw new Error(`Unexpected argument: ${token}. Try: stratus memory list <agent>`);
|
|
1128
|
+
}
|
|
1129
|
+
if (!agentId) {
|
|
1130
|
+
throw new Error(`memory ${action} needs the agent id: stratus memory ${action} <agent>${action === 'reassert' ? ' --trust user <id>...' : ''}.`);
|
|
1131
|
+
}
|
|
1132
|
+
if (action === 'reassert') {
|
|
1133
|
+
if (trust === undefined) {
|
|
1134
|
+
throw new Error('memory reassert needs --trust <user|agent|external|unknown>: the label you are vouching for.');
|
|
1135
|
+
}
|
|
1136
|
+
if (ids.length === 0 && !allUnknown) {
|
|
1137
|
+
throw new Error('memory reassert needs entry ids, or --all-unknown to re-label every entry that has no recorded origin.');
|
|
1138
|
+
}
|
|
1139
|
+
}
|
|
1140
|
+
return {
|
|
1141
|
+
command: 'memory',
|
|
1142
|
+
action,
|
|
1143
|
+
agentId,
|
|
1144
|
+
...(trust !== undefined ? { trust } : {}),
|
|
1145
|
+
ids,
|
|
1146
|
+
allUnknown,
|
|
1147
|
+
format,
|
|
1148
|
+
};
|
|
1149
|
+
}
|
|
1150
|
+
if (command === 'session') {
|
|
1151
|
+
const [action, ...sessionRest] = rest;
|
|
1152
|
+
if (action === undefined || action === '--help' || action === '-h') {
|
|
1153
|
+
return { command: 'help' };
|
|
1154
|
+
}
|
|
1155
|
+
if (action !== 'rollover') {
|
|
1156
|
+
throw new Error(`Unknown session subcommand: ${action}. Try: stratus session rollover <session-id>`);
|
|
1157
|
+
}
|
|
1158
|
+
const parsed = { command: 'session', action: 'rollover', sessionId: '' };
|
|
1159
|
+
for (let index = 0; index < sessionRest.length; index += 1) {
|
|
1160
|
+
const token = sessionRest[index];
|
|
1161
|
+
if (!token) {
|
|
1162
|
+
continue;
|
|
1163
|
+
}
|
|
1164
|
+
if (token === '--help' || token === '-h') {
|
|
1165
|
+
return { command: 'help' };
|
|
1166
|
+
}
|
|
1167
|
+
if (token === '--gateway') {
|
|
1168
|
+
parsed.gateway = readOptionValue(sessionRest, index, '--gateway');
|
|
1169
|
+
index += 1;
|
|
1170
|
+
continue;
|
|
1171
|
+
}
|
|
1172
|
+
if (token === '--token') {
|
|
1173
|
+
parsed.token = readOptionValue(sessionRest, index, '--token');
|
|
1174
|
+
index += 1;
|
|
1175
|
+
continue;
|
|
1176
|
+
}
|
|
1177
|
+
if (token.startsWith('--')) {
|
|
1178
|
+
throw new Error(`Unknown option: ${token}`);
|
|
1179
|
+
}
|
|
1180
|
+
if (parsed.sessionId.length > 0) {
|
|
1181
|
+
throw new Error(`Unexpected argument: ${token}. Try: stratus session rollover <session-id>`);
|
|
1182
|
+
}
|
|
1183
|
+
parsed.sessionId = token;
|
|
1184
|
+
}
|
|
1185
|
+
if (parsed.sessionId.length === 0) {
|
|
1186
|
+
throw new Error('session rollover needs the session id: stratus session rollover <session-id>. `stratus logs` shows ids in its last column.');
|
|
1187
|
+
}
|
|
1188
|
+
return parsed;
|
|
1189
|
+
}
|
|
1049
1190
|
if (command === 'restart') {
|
|
1050
1191
|
const parsed = { command: 'restart' };
|
|
1051
1192
|
for (let index = 0; index < rest.length; index += 1) {
|
|
@@ -1291,6 +1432,8 @@ export const formatEvent = (event) => {
|
|
|
1291
1432
|
return `• session.completed ${event.sessionId}`;
|
|
1292
1433
|
case 'session.failed':
|
|
1293
1434
|
return `• session.failed ${event.error}`;
|
|
1435
|
+
case 'session.tainted':
|
|
1436
|
+
return `• session.tainted ${event.trust} (${event.source})`;
|
|
1294
1437
|
default:
|
|
1295
1438
|
return null;
|
|
1296
1439
|
}
|
|
@@ -1341,6 +1484,11 @@ export const eventDetail = (event) => {
|
|
|
1341
1484
|
};
|
|
1342
1485
|
case 'session.failed':
|
|
1343
1486
|
return { error: event.error };
|
|
1487
|
+
case 'session.tainted':
|
|
1488
|
+
// The label and what lowered it — a tool's name, or `memory`,
|
|
1489
|
+
// `sender`, `legacy`. Never the content that did: same rule as every
|
|
1490
|
+
// other event here.
|
|
1491
|
+
return { trust: event.trust, source: event.source };
|
|
1344
1492
|
default:
|
|
1345
1493
|
return undefined;
|
|
1346
1494
|
}
|
|
@@ -1597,6 +1745,26 @@ const createAgentRuntime = async (streams, options) => {
|
|
|
1597
1745
|
for (const { skill, missing } of missingSkillRequirements(agent, skills)) {
|
|
1598
1746
|
writeLine(streams.stderr, `Warning: agent ${agent.id} enables skill ${skill.id}, which expects tools the agent is not allowed: ${missing.join(', ')}`);
|
|
1599
1747
|
}
|
|
1748
|
+
// And the same for the tools themselves, after `initialize()` so the
|
|
1749
|
+
// plugins have registered theirs: an allowlist entry naming a tool
|
|
1750
|
+
// nothing provides grants nothing and says nothing, and a soul made only
|
|
1751
|
+
// of those runs with no tools while its persona still talks about them.
|
|
1752
|
+
const finding = unmatchedToolAllowlist(agent, tools.describe().map((tool) => tool.name), {
|
|
1753
|
+
mayRegister: loadedPlugins.flatMap((plugin) => plugin.manifest.contributes.toolsDiscovered.map((declared) => declared.namespace)),
|
|
1754
|
+
// A local run has no dispatcher, store, or channels, so it registers
|
|
1755
|
+
// none of the daemon's tools. A soul written for `stratus serve` is
|
|
1756
|
+
// not wrong for naming them here — it is in the wrong process.
|
|
1757
|
+
elsewhere: GATEWAY_ONLY_TOOL_NAMES,
|
|
1758
|
+
});
|
|
1759
|
+
for (const line of finding ? describeToolAllowlistFinding(agent.id, finding) : []) {
|
|
1760
|
+
writeLine(streams.stderr, `Warning: ${line}`);
|
|
1761
|
+
}
|
|
1762
|
+
// The kernel leaves this one to the host, because only the host knows
|
|
1763
|
+
// which process it is not.
|
|
1764
|
+
if (finding && finding.elsewhere.length > 0) {
|
|
1765
|
+
writeLine(streams.stderr, `Warning: agent ${agent.id} lists ${finding.elsewhere.join(', ')}, which only the daemon provides`
|
|
1766
|
+
+ ' — the names are right, but stratus run cannot call them; stratus serve can');
|
|
1767
|
+
}
|
|
1600
1768
|
const metadata = options.runtime.provider === 'demo'
|
|
1601
1769
|
? { provider: 'demo', executor: 'local-command' }
|
|
1602
1770
|
: {
|
|
@@ -2259,20 +2427,28 @@ const verifySlackAppToken = async (token, fetchImpl) => {
|
|
|
2259
2427
|
// package being installed.
|
|
2260
2428
|
const SLACK_BOT_SCOPES = [
|
|
2261
2429
|
'app_mentions:read',
|
|
2430
|
+
// The history family is what lets an agent stay in a conversation it was
|
|
2431
|
+
// brought into: without it Slack delivers only mentions, and a reply in
|
|
2432
|
+
// the agent's own thread never reaches it. It is the workspace's switch
|
|
2433
|
+
// for that — an app installed without these scopes answers mentions and
|
|
2434
|
+
// nothing else, exactly as it always did.
|
|
2435
|
+
'channels:history',
|
|
2262
2436
|
// The conversations read family backs outbound destination validation
|
|
2263
2437
|
// (conversations.info): whether a channel a schedule wants to report to
|
|
2264
2438
|
// exists, and whether this app is a member of it.
|
|
2265
2439
|
'channels:read',
|
|
2266
2440
|
'chat:write',
|
|
2267
2441
|
'files:write',
|
|
2442
|
+
'groups:history',
|
|
2268
2443
|
'groups:read',
|
|
2269
2444
|
'im:history',
|
|
2270
2445
|
'im:read',
|
|
2271
2446
|
'im:write',
|
|
2447
|
+
'mpim:history',
|
|
2272
2448
|
'mpim:read',
|
|
2273
2449
|
'users:read',
|
|
2274
2450
|
];
|
|
2275
|
-
const SLACK_BOT_EVENTS = ['app_mention', 'message.im'];
|
|
2451
|
+
const SLACK_BOT_EVENTS = ['app_mention', 'message.channels', 'message.groups', 'message.im', 'message.mpim'];
|
|
2276
2452
|
export const slackAppManifest = (agentName) => JSON.stringify({
|
|
2277
2453
|
display_information: {
|
|
2278
2454
|
name: agentName,
|
|
@@ -4273,6 +4449,37 @@ const loadServeApprovals = async (env, configPath, warn) => {
|
|
|
4273
4449
|
* `agentIds` is therefore the agents that can actually be *asked*, not
|
|
4274
4450
|
* every agent with tokens on disk.
|
|
4275
4451
|
*/
|
|
4452
|
+
/**
|
|
4453
|
+
* The `principals` block, read under the same trust rule as `approvals` and
|
|
4454
|
+
* for a sharper reason: it names the people whose messages an agent takes
|
|
4455
|
+
* as its operator's, which is the trust root everything downstream lowers
|
|
4456
|
+
* from. A cloned repo appointing itself the principal would arrive as
|
|
4457
|
+
* `user` in every Slack thread the daemon serves.
|
|
4458
|
+
*/
|
|
4459
|
+
const loadServePrincipals = async (env, configPath, warn) => {
|
|
4460
|
+
const block = await readTrustedConfigBlock('principals', env, configPath);
|
|
4461
|
+
if (block.status === 'untrusted') {
|
|
4462
|
+
warn(`ignoring the principals config in ${block.path}: a project-local config cannot decide whose messages `
|
|
4463
|
+
+ 'this daemon\'s agents treat as their operator\'s. Move it to ~/.stratus/config.json, or pass it with --config.');
|
|
4464
|
+
return {};
|
|
4465
|
+
}
|
|
4466
|
+
if (block.status === 'unreadable') {
|
|
4467
|
+
warn(`ignoring the principals config (${block.error instanceof Error ? block.error.message : String(block.error)}); every Slack sender is unknown`);
|
|
4468
|
+
return {};
|
|
4469
|
+
}
|
|
4470
|
+
return block.status === 'present' ? block.value : {};
|
|
4471
|
+
};
|
|
4472
|
+
/** One line saying whose messages arrive as the operator's, per Slack agent. */
|
|
4473
|
+
const describePrincipals = (principals, agentIds) => {
|
|
4474
|
+
const covered = agentIds.filter((agentId) => (resolveAgentPrincipals(principals, agentId).slackUsers ?? []).length > 0);
|
|
4475
|
+
if (covered.length === 0) {
|
|
4476
|
+
return 'no principals configured, so every Slack sender is unknown and every fact written in Slack carries that label — set principals.slackUsers in ~/.stratus/config.json';
|
|
4477
|
+
}
|
|
4478
|
+
const uncovered = agentIds.filter((agentId) => !covered.includes(agentId));
|
|
4479
|
+
return uncovered.length === 0
|
|
4480
|
+
? `principals set for ${covered.join(', ')}`
|
|
4481
|
+
: `principals set for ${covered.join(', ')}; none for ${uncovered.join(', ')}, whose Slack senders are all unknown`;
|
|
4482
|
+
};
|
|
4276
4483
|
const describeApprovers = (approvals, agentIds) => {
|
|
4277
4484
|
if (agentIds.length === 0) {
|
|
4278
4485
|
return 'but no channel is running to ask through, so gated calls will wait out the approval timeout and then be denied';
|
|
@@ -5114,6 +5321,134 @@ export const runSkillReload = async (command, streams, env = {}) => {
|
|
|
5114
5321
|
writeLine(streams.stdout, `reloaded skills in the daemon at ${base} — ${ids.length} skill(s) serving${ids.length > 0 ? `: ${ids.join(', ')}` : ''}`);
|
|
5115
5322
|
return 0;
|
|
5116
5323
|
};
|
|
5324
|
+
/**
|
|
5325
|
+
* `stratus memory list|reassert` — the operator's half of provenance.
|
|
5326
|
+
*
|
|
5327
|
+
* `list` shows what an agent's store holds with the label each entry
|
|
5328
|
+
* carries, because an operator cannot re-assert what they cannot see.
|
|
5329
|
+
* `reassert` is the one way a label ever rises: it appends a re-assertion
|
|
5330
|
+
* record to the JSONL (never rewrites a line), so a running daemon — which
|
|
5331
|
+
* re-reads the file on every read — sees it at once. Operator only, on
|
|
5332
|
+
* purpose: no tool exposes this, because an agent re-labelling its own
|
|
5333
|
+
* memory as trusted is the attack writing its own permission slip.
|
|
5334
|
+
*
|
|
5335
|
+
* `--all-unknown` exists for the upgrade: every entry written before labels
|
|
5336
|
+
* existed reads `unknown`, and the injected slice of such a store makes
|
|
5337
|
+
* every session `unknown` on its first turn. Re-asserting the entries the
|
|
5338
|
+
* agent actually surfaces, once, is the bounded work that drains it.
|
|
5339
|
+
*/
|
|
5340
|
+
export const runMemory = async (command, streams, env = {}) => {
|
|
5341
|
+
// Only the writer folds a legacy per-directory store in: `list` is the
|
|
5342
|
+
// read-only command a downgraded build is allowed to run against newer
|
|
5343
|
+
// state, and a migration is a write.
|
|
5344
|
+
if (command.action === 'reassert') {
|
|
5345
|
+
await migrateLegacyMemory(env);
|
|
5346
|
+
}
|
|
5347
|
+
const store = withLegacyDefaultMemories(createFileMemoryStore(memoryFilePath(env)));
|
|
5348
|
+
// Two ways an entry reads `unknown`, and only one is the upgrade case:
|
|
5349
|
+
// no label at all (written before labels existed, or added by hand), or a
|
|
5350
|
+
// recorded `unknown` — a session that heard from a sender nobody vouched
|
|
5351
|
+
// for, or ran a shell command. The bulk re-assertion is for the first;
|
|
5352
|
+
// the second may hold a stranger's text and is named by id, once read.
|
|
5353
|
+
const unlabelled = (entry) => entry.trust === undefined;
|
|
5354
|
+
const describeEntry = (entry) => ({
|
|
5355
|
+
id: entry.id,
|
|
5356
|
+
trust: memoryEntryTrust(entry),
|
|
5357
|
+
content: entry.content,
|
|
5358
|
+
createdAt: entry.createdAt,
|
|
5359
|
+
...(entry.origin ? { origin: entry.origin } : {}),
|
|
5360
|
+
});
|
|
5361
|
+
if (command.action === 'list') {
|
|
5362
|
+
const live = (await store.list(command.agentId)).entries
|
|
5363
|
+
.filter((entry) => command.trust === undefined || memoryEntryTrust(entry) === command.trust);
|
|
5364
|
+
if (command.format === 'json') {
|
|
5365
|
+
writeLine(streams.stdout, JSON.stringify({ agentId: command.agentId, entries: live.map(describeEntry) }, null, 2));
|
|
5366
|
+
return 0;
|
|
5367
|
+
}
|
|
5368
|
+
if (live.length === 0) {
|
|
5369
|
+
writeLine(streams.stdout, command.trust === undefined
|
|
5370
|
+
? `${command.agentId} has no live memory entries in ${memoryFilePath(env)}.`
|
|
5371
|
+
: `${command.agentId} has no live memory entries at ${command.trust}.`);
|
|
5372
|
+
return 0;
|
|
5373
|
+
}
|
|
5374
|
+
for (const entry of live) {
|
|
5375
|
+
const taintedBy = entry.origin?.taintedBy ? ` (tainted by ${entry.origin.taintedBy})` : '';
|
|
5376
|
+
const unrecorded = unlabelled(entry) ? ' (no recorded origin)' : '';
|
|
5377
|
+
writeLine(streams.stdout, `${entry.id} [${memoryEntryTrust(entry)}]${taintedBy}${unrecorded}`);
|
|
5378
|
+
writeLine(streams.stdout, ` ${escapeControlCharacters(entry.content)}`);
|
|
5379
|
+
}
|
|
5380
|
+
if (command.trust === undefined) {
|
|
5381
|
+
const unlabelledCount = live.filter(unlabelled).length;
|
|
5382
|
+
const recordedUnknown = live.filter((entry) => !unlabelled(entry) && memoryEntryTrust(entry) === 'unknown').length;
|
|
5383
|
+
if (unlabelledCount > 0) {
|
|
5384
|
+
writeLine(streams.stdout, '');
|
|
5385
|
+
writeLine(streams.stdout, `${unlabelledCount} entr${unlabelledCount === 1 ? 'y has' : 'ies have'} no recorded origin, so any session that reads one writes unknown. `
|
|
5386
|
+
+ `Review them, then: stratus memory reassert ${command.agentId} --trust user --all-unknown (or name ids).`);
|
|
5387
|
+
}
|
|
5388
|
+
if (recordedUnknown > 0) {
|
|
5389
|
+
writeLine(streams.stdout, '');
|
|
5390
|
+
writeLine(streams.stdout, `${recordedUnknown} entr${recordedUnknown === 1 ? 'y was' : 'ies were'} recorded unknown — written after a message from someone not configured as a principal, or after a shell command — `
|
|
5391
|
+
+ 'and may repeat what a stranger said. --all-unknown leaves these alone; re-assert one by id once you have read it.');
|
|
5392
|
+
}
|
|
5393
|
+
}
|
|
5394
|
+
return 0;
|
|
5395
|
+
}
|
|
5396
|
+
if (!store.reassertTrust) {
|
|
5397
|
+
writeLine(streams.stderr, 'Error: this memory store cannot re-assert trust.');
|
|
5398
|
+
return 1;
|
|
5399
|
+
}
|
|
5400
|
+
const trust = command.trust ?? 'user';
|
|
5401
|
+
const live = (await store.list(command.agentId)).entries;
|
|
5402
|
+
const targets = command.allUnknown
|
|
5403
|
+
? [...new Set([...live.filter(unlabelled).map((entry) => entry.id), ...command.ids])]
|
|
5404
|
+
: command.ids;
|
|
5405
|
+
if (targets.length === 0) {
|
|
5406
|
+
writeLine(streams.stdout, `${command.agentId} has no live entries with no recorded origin; nothing to re-assert. An entry recorded unknown is named by id.`);
|
|
5407
|
+
return 0;
|
|
5408
|
+
}
|
|
5409
|
+
const missing = [];
|
|
5410
|
+
let changed = 0;
|
|
5411
|
+
for (const id of targets) {
|
|
5412
|
+
if (await store.reassertTrust(command.agentId, id, trust)) {
|
|
5413
|
+
changed += 1;
|
|
5414
|
+
}
|
|
5415
|
+
else {
|
|
5416
|
+
missing.push(id);
|
|
5417
|
+
}
|
|
5418
|
+
}
|
|
5419
|
+
if (command.format === 'json') {
|
|
5420
|
+
writeLine(streams.stdout, JSON.stringify({ agentId: command.agentId, trust, reasserted: changed, missing }, null, 2));
|
|
5421
|
+
}
|
|
5422
|
+
else {
|
|
5423
|
+
writeLine(streams.stdout, `Re-asserted ${changed} entr${changed === 1 ? 'y' : 'ies'} of ${command.agentId} as ${trust}. A running daemon reads the change on its next turn.`);
|
|
5424
|
+
}
|
|
5425
|
+
for (const id of missing) {
|
|
5426
|
+
writeLine(streams.stderr, `No live memory entry with id ${id} belongs to ${command.agentId}; nothing was re-asserted for it.`);
|
|
5427
|
+
}
|
|
5428
|
+
return missing.length === 0 ? 0 : 1;
|
|
5429
|
+
};
|
|
5430
|
+
/**
|
|
5431
|
+
* `stratus session rollover <id>` — start a conversation over under the
|
|
5432
|
+
* same id, archiving its transcript so far. The daemon does the work, on
|
|
5433
|
+
* the session's own chain, so a turn cannot interleave; this only asks.
|
|
5434
|
+
*/
|
|
5435
|
+
export const runSessionRollover = async (command, streams, env = {}) => {
|
|
5436
|
+
const base = await runningGatewayBase(env, command);
|
|
5437
|
+
if (!base) {
|
|
5438
|
+
writeLine(streams.stderr, `Error: ${noRunningDaemonMessage(env)}`);
|
|
5439
|
+
return 1;
|
|
5440
|
+
}
|
|
5441
|
+
const response = await callRunningGateway(env, command, base, `/api/v1/sessions/${encodeURIComponent(command.sessionId)}/rollover`);
|
|
5442
|
+
if (!response.ok) {
|
|
5443
|
+
writeLine(streams.stderr, `Error: ${await gatewayErrorMessage(response)}`);
|
|
5444
|
+
return 1;
|
|
5445
|
+
}
|
|
5446
|
+
const outcome = await response.json();
|
|
5447
|
+
writeLine(streams.stdout, `Rolled over ${outcome.sessionId ?? command.sessionId}: the next message starts a fresh conversation, `
|
|
5448
|
+
+ `and the transcript so far is archived as ${outcome.archivedAs ?? '(unknown)'}.`);
|
|
5449
|
+
writeLine(streams.stdout, 'A fresh session still reads the memory it injects — if that slice holds entries with no recorded origin, `stratus memory list <agent>` shows them and `stratus memory reassert` re-labels them.');
|
|
5450
|
+
return 0;
|
|
5451
|
+
};
|
|
5117
5452
|
export const runRestart = async (command, streams, env = {}) => {
|
|
5118
5453
|
const base = await runningGatewayBase(env, command);
|
|
5119
5454
|
if (!base) {
|
|
@@ -5994,6 +6329,7 @@ const serveHeldHome = async (command, streams, env, createGateway) => {
|
|
|
5994
6329
|
// approve this" differently from "is anyone being asked at all".
|
|
5995
6330
|
const approvalsConfig = await loadServeApprovals(env, command.configPath, warn);
|
|
5996
6331
|
const approvalMode = command.approvals ?? approvalsConfig.mode ?? 'headless';
|
|
6332
|
+
const principalsConfig = await loadServePrincipals(env, command.configPath, warn);
|
|
5997
6333
|
// Read here rather than inside the gateway for the same reason as the two
|
|
5998
6334
|
// blocks above: the trust boundary is a property of *which file* said it,
|
|
5999
6335
|
// and this is where the file precedence is already understood.
|
|
@@ -6074,12 +6410,14 @@ const serveHeldHome = async (command, streams, env, createGateway) => {
|
|
|
6074
6410
|
channels.push(adapter({
|
|
6075
6411
|
agents: slackAgents.map(([agentId, tokens]) => {
|
|
6076
6412
|
const route = resolveAgentApprovals(approvalsConfig, agentId);
|
|
6413
|
+
const principals = resolveAgentPrincipals(principalsConfig, agentId);
|
|
6077
6414
|
return {
|
|
6078
6415
|
agentId,
|
|
6079
6416
|
appToken: tokens.appToken,
|
|
6080
6417
|
botToken: tokens.botToken,
|
|
6081
6418
|
...(route.slackApprovers ? { approvers: route.slackApprovers } : {}),
|
|
6082
6419
|
...(route.slackChannel ? { approvalChannel: route.slackChannel } : {}),
|
|
6420
|
+
...(principals.slackUsers ? { principals: principals.slackUsers } : {}),
|
|
6083
6421
|
};
|
|
6084
6422
|
}),
|
|
6085
6423
|
log,
|
|
@@ -6186,6 +6524,12 @@ const serveHeldHome = async (command, streams, env, createGateway) => {
|
|
|
6186
6524
|
const askable = slackAdapterUp ? slackAgents.map(([agentId]) => agentId) : [];
|
|
6187
6525
|
log(`approvals: remote — gated calls are parked and asked in Slack (${describeApprovers(approvalsConfig, askable)})`);
|
|
6188
6526
|
}
|
|
6527
|
+
if (slackAdapterUp) {
|
|
6528
|
+
// Said at startup, because the failure it names is silent otherwise: a
|
|
6529
|
+
// daemon with no principals serves every Slack message as `unknown`,
|
|
6530
|
+
// and the operator finds out when their agent's memory reads that way.
|
|
6531
|
+
log(`provenance: ${describePrincipals(principalsConfig, slackAgents.map(([agentId]) => agentId))}`);
|
|
6532
|
+
}
|
|
6189
6533
|
/**
|
|
6190
6534
|
* The one signal handler this daemon has, for the whole of its life —
|
|
6191
6535
|
* installed by the wait below, removed in the finally after the drain.
|
|
@@ -6491,6 +6835,17 @@ export const runCli = async ({ argv, streams = process, env = {} }) => {
|
|
|
6491
6835
|
// to be able to report what is pending rather than having just done it.
|
|
6492
6836
|
if (command.command !== 'update') {
|
|
6493
6837
|
const stamp = await readStateStamp(resolvedEnv);
|
|
6838
|
+
const writesState = command.command === 'serve'
|
|
6839
|
+
|| command.command === 'setup'
|
|
6840
|
+
|| command.command === 'chat'
|
|
6841
|
+
|| command.command === 'run'
|
|
6842
|
+
|| command.command === 'skill-add'
|
|
6843
|
+
|| command.command === 'dashboard'
|
|
6844
|
+
|| (command.command === 'credential' && command.action !== 'list')
|
|
6845
|
+
|| (command.command === 'schedules' && command.action === 'cancel')
|
|
6846
|
+
|| (command.command === 'memory' && command.action === 'reassert')
|
|
6847
|
+
|| command.command === 'session'
|
|
6848
|
+
|| (command.command === 'service' && (command.action === 'install' || command.action === 'start'));
|
|
6494
6849
|
if (stamp.schemaVersion > STATE_SCHEMA_VERSION) {
|
|
6495
6850
|
// Anything that writes under ~/.stratus refuses, not only the
|
|
6496
6851
|
// daemon: a downgraded build's setup, chat, or run can discard
|
|
@@ -6500,15 +6855,6 @@ export const runCli = async ({ argv, streams = process, env = {} }) => {
|
|
|
6500
6855
|
// diagnoses their way OUT of this state; so do `service stop`,
|
|
6501
6856
|
// `status`, and `uninstall`, for the same reason. (`agent new`
|
|
6502
6857
|
// only prints an identity — it writes nothing.)
|
|
6503
|
-
const writesState = command.command === 'serve'
|
|
6504
|
-
|| command.command === 'setup'
|
|
6505
|
-
|| command.command === 'chat'
|
|
6506
|
-
|| command.command === 'run'
|
|
6507
|
-
|| command.command === 'skill-add'
|
|
6508
|
-
|| command.command === 'dashboard'
|
|
6509
|
-
|| (command.command === 'credential' && command.action !== 'list')
|
|
6510
|
-
|| (command.command === 'schedules' && command.action === 'cancel')
|
|
6511
|
-
|| (command.command === 'service' && (command.action === 'install' || command.action === 'start'));
|
|
6512
6858
|
if (writesState) {
|
|
6513
6859
|
writeLine(streams.stderr, newerStateMessage(stamp.schemaVersion));
|
|
6514
6860
|
writeLine(streams.stderr, `Refusing \`stratus ${command.command}\` — it writes state the newer format owns. Read-only commands (logs, agents, doctor, service status/stop) still work.`);
|
|
@@ -6525,10 +6871,21 @@ export const runCli = async ({ argv, streams = process, env = {} }) => {
|
|
|
6525
6871
|
}
|
|
6526
6872
|
}
|
|
6527
6873
|
catch (error) {
|
|
6528
|
-
|
|
6529
|
-
//
|
|
6530
|
-
//
|
|
6531
|
-
|
|
6874
|
+
const reason = error instanceof Error ? error.message : String(error);
|
|
6875
|
+
// A command that writes state must not run on a home whose stamp
|
|
6876
|
+
// could not be written: the labels it would persist are what the
|
|
6877
|
+
// stamp protects, and a stamp still reading the old schema lets a
|
|
6878
|
+
// downgraded build in afterwards to read an `external` fact as
|
|
6879
|
+
// ordinary memory — the exact hole the stamp closes. A read-only
|
|
6880
|
+
// command warns and continues, because reading is how someone
|
|
6881
|
+
// diagnoses their way out; silence here is how the migrated and
|
|
6882
|
+
// unmigrated populations diverge.
|
|
6883
|
+
if (writesState) {
|
|
6884
|
+
writeLine(streams.stderr, `State migration failed (${reason}).`);
|
|
6885
|
+
writeLine(streams.stderr, `Refusing \`stratus ${command.command}\` — it writes state the migration stamps. Fix ~/.stratus (is state.json writable?) or run \`stratus update\`; read-only commands (logs, agents, doctor, memory list, service status/stop) still work.`);
|
|
6886
|
+
return 1;
|
|
6887
|
+
}
|
|
6888
|
+
writeLine(streams.stderr, `Warning: state migration failed (${reason}). Continuing on unmigrated state — \`stratus update\` retries it.`);
|
|
6532
6889
|
}
|
|
6533
6890
|
}
|
|
6534
6891
|
}
|
|
@@ -6564,6 +6921,12 @@ export const runCli = async ({ argv, streams = process, env = {} }) => {
|
|
|
6564
6921
|
if (command.command === 'schedules') {
|
|
6565
6922
|
return await runSchedules(command, streams, resolvedEnv);
|
|
6566
6923
|
}
|
|
6924
|
+
if (command.command === 'memory') {
|
|
6925
|
+
return await runMemory(command, streams, resolvedEnv);
|
|
6926
|
+
}
|
|
6927
|
+
if (command.command === 'session') {
|
|
6928
|
+
return await runSessionRollover(command, streams, resolvedEnv);
|
|
6929
|
+
}
|
|
6567
6930
|
if (command.command === 'doctor') {
|
|
6568
6931
|
return await runDoctor(command, streams, resolvedEnv);
|
|
6569
6932
|
}
|