@stage-labs/metro 0.1.0-beta.201 → 0.1.0-beta.202
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/claude.js +4 -15
- package/dist/cli.js +5 -3
- package/dist/hold.js +1 -2
- package/dist/runtime-install.js +4 -5
- package/dist/runtime.js +1 -4
- package/dist/serve.js +4 -5
- package/dist/service.js +82 -21
- package/dist/update.js +1 -1
- package/package.json +2 -2
- package/runtime/marketplace/plugin/.claude-plugin/plugin.json +1 -1
- package/runtime/node_modules/@metro-labs/core/src/events.ts +4 -2
- package/runtime/node_modules/@metro-labs/core/src/lines.ts +12 -25
- package/runtime/node_modules/@metro-labs/core/src/stations/account-store.ts +45 -22
- package/runtime/node_modules/@metro-labs/core/src/stations/attachments.ts +0 -1
- package/runtime/node_modules/@metro-labs/core/src/stations/messaging-normalize.ts +8 -80
- package/runtime/node_modules/@metro-labs/core/src/stations/station-runtime.ts +3 -5
- package/runtime/node_modules/@metro-labs/core/src/stations/train-events.ts +4 -9
- package/runtime/node_modules/@metro-labs/daemon/src/agent-user/files.ts +9 -39
- package/runtime/node_modules/@metro-labs/daemon/src/agent-user/job-env.ts +1 -1
- package/runtime/node_modules/@metro-labs/daemon/src/agent-user/provision.ts +9 -28
- package/runtime/node_modules/@metro-labs/daemon/src/agent-user/schedule-detail.ts +0 -1
- package/runtime/node_modules/@metro-labs/daemon/src/agent-user/schedules-api.ts +3 -3
- package/runtime/node_modules/@metro-labs/daemon/src/agent-user/schedules.ts +8 -116
- package/runtime/node_modules/@metro-labs/daemon/src/agent-user/unit-files.ts +11 -35
- package/runtime/node_modules/@metro-labs/daemon/src/agent-user/user.ts +7 -9
- package/runtime/node_modules/@metro-labs/daemon/src/agent-user/view.ts +1 -1
- package/runtime/node_modules/@metro-labs/daemon/src/agents/account-lookups.ts +1 -1
- package/runtime/node_modules/@metro-labs/daemon/src/agents/accounts-api.ts +1 -1
- package/runtime/node_modules/@metro-labs/daemon/src/agents/allowlist.ts +1 -1
- package/runtime/node_modules/@metro-labs/daemon/src/agents/bundle.ts +3 -3
- package/runtime/node_modules/@metro-labs/daemon/src/agents/file-admin.ts +2 -2
- package/runtime/node_modules/@metro-labs/daemon/src/agents/files.ts +3 -29
- package/runtime/node_modules/@metro-labs/daemon/src/approvals/pending.ts +5 -8
- package/runtime/node_modules/@metro-labs/daemon/src/approvals/preview.ts +1 -1
- package/runtime/node_modules/@metro-labs/daemon/src/boot/boot.ts +5 -8
- package/runtime/node_modules/@metro-labs/daemon/src/boot/connect-hint.ts +3 -6
- package/runtime/node_modules/@metro-labs/daemon/src/channels/inbound.ts +25 -3
- package/runtime/node_modules/@metro-labs/daemon/src/channels/media-note.ts +2 -3
- package/runtime/node_modules/@metro-labs/daemon/src/claude/files.ts +2 -2
- package/runtime/node_modules/@metro-labs/daemon/src/claude/login.ts +1 -1
- package/runtime/node_modules/@metro-labs/daemon/src/claude/memory.ts +6 -56
- package/runtime/node_modules/@metro-labs/daemon/src/claude/plugin-install.ts +3 -3
- package/runtime/node_modules/@metro-labs/daemon/src/claude/session-files.ts +1 -1
- package/runtime/node_modules/@metro-labs/daemon/src/claude/session.ts +7 -6
- package/runtime/node_modules/@metro-labs/daemon/src/claude/settings.ts +1 -1
- package/runtime/node_modules/@metro-labs/daemon/src/claude/setup.ts +7 -7
- package/runtime/node_modules/@metro-labs/daemon/src/claude/skills.ts +4 -4
- package/runtime/node_modules/@metro-labs/daemon/src/claude/version.ts +1 -1
- package/runtime/node_modules/@metro-labs/daemon/src/connectors/api.ts +2 -4
- package/runtime/node_modules/@metro-labs/daemon/src/connectors/config.ts +19 -22
- package/runtime/node_modules/@metro-labs/daemon/src/connectors/gates.ts +1 -2
- package/runtime/node_modules/@metro-labs/daemon/src/connectors/oauth-discovery.ts +6 -8
- package/runtime/node_modules/@metro-labs/daemon/src/connectors/oauth-routes.ts +3 -6
- package/runtime/node_modules/@metro-labs/daemon/src/connectors/plugin-sync.ts +1 -1
- package/runtime/node_modules/@metro-labs/daemon/src/connectors/relay.ts +2 -3
- package/runtime/node_modules/@metro-labs/daemon/src/connectors/store.ts +28 -1
- package/runtime/node_modules/@metro-labs/daemon/src/files/attach-serve.ts +1 -1
- package/runtime/node_modules/@metro-labs/daemon/src/gateway/bedrock.ts +4 -4
- package/runtime/node_modules/@metro-labs/daemon/src/gateway/codex-auth.ts +1 -1
- package/runtime/node_modules/@metro-labs/daemon/src/gateway/codex-device.ts +1 -1
- package/runtime/node_modules/@metro-labs/daemon/src/gateway/codex-stream.ts +1 -1
- package/runtime/node_modules/@metro-labs/daemon/src/gateway/codex-translate.ts +3 -2
- package/runtime/node_modules/@metro-labs/daemon/src/gateway/codex.ts +1 -1
- package/runtime/node_modules/@metro-labs/daemon/src/gateway/effort.ts +2 -2
- package/runtime/node_modules/@metro-labs/daemon/src/gateway/eventstream.ts +1 -1
- package/runtime/node_modules/@metro-labs/daemon/src/gateway/forward.ts +3 -3
- package/runtime/node_modules/@metro-labs/daemon/src/gateway/frames.ts +1 -1
- package/runtime/node_modules/@metro-labs/daemon/src/gateway/gateway.ts +3 -3
- package/runtime/node_modules/@metro-labs/daemon/src/gateway/gemini-auth.ts +6 -6
- package/runtime/node_modules/@metro-labs/daemon/src/gateway/gemini-client.ts +7 -7
- package/runtime/node_modules/@metro-labs/daemon/src/gateway/gemini-stream.ts +1 -1
- package/runtime/node_modules/@metro-labs/daemon/src/gateway/gemini-translate.ts +3 -2
- package/runtime/node_modules/@metro-labs/daemon/src/gateway/gemini.ts +1 -1
- package/runtime/node_modules/@metro-labs/daemon/src/gateway/model-config.ts +10 -31
- package/runtime/node_modules/@metro-labs/daemon/src/gateway/model-signin.ts +1 -1
- package/runtime/node_modules/@metro-labs/daemon/src/gateway/model-store.ts +1 -1
- package/runtime/node_modules/@metro-labs/daemon/src/gateway/openrouter.ts +2 -2
- package/runtime/node_modules/@metro-labs/daemon/src/gateway/provider-models.ts +3 -3
- package/runtime/node_modules/@metro-labs/daemon/src/gateway/text.ts +1 -2
- package/runtime/node_modules/@metro-labs/daemon/src/gateway/usage.ts +2 -2
- package/runtime/node_modules/@metro-labs/daemon/src/mcp/policy-gate.ts +5 -6
- package/runtime/node_modules/@metro-labs/daemon/src/mcp/policy-snapshot.ts +3 -3
- package/runtime/node_modules/@metro-labs/daemon/src/mcp/profile-lookup.ts +2 -2
- package/runtime/node_modules/@metro-labs/daemon/src/mcp/read-tool.ts +2 -2
- package/runtime/node_modules/@metro-labs/daemon/src/metro-user/helper-script.ts +2 -2
- package/runtime/node_modules/@metro-labs/daemon/src/metro-user/install.ts +37 -0
- package/runtime/node_modules/@metro-labs/daemon/src/net/tunnel.ts +2 -2
- package/runtime/node_modules/@metro-labs/daemon/src/policy/policy.ts +1 -1
- package/runtime/node_modules/@metro-labs/daemon/src/routes/local-mode.ts +1 -4
- package/runtime/node_modules/@metro-labs/daemon/src/routes/session-apis.ts +3 -8
- package/runtime/node_modules/@metro-labs/daemon/src/server/machine.ts +2 -2
- package/runtime/node_modules/@metro-labs/daemon/src/server/update.ts +2 -2
- package/runtime/node_modules/@metro-labs/daemon/src/stations/attach-session.ts +2 -2
- package/runtime/node_modules/@metro-labs/daemon/src/stations/attach-xmtp.ts +6 -7
- package/runtime/node_modules/@metro-labs/daemon/src/stations/supervisor-io.ts +1 -1
- package/runtime/node_modules/@metro-labs/daemon/src/stations/supervisor.ts +2 -2
- package/runtime/node_modules/@metro-labs/daemon/src/terminal/api.ts +2 -2
- package/runtime/node_modules/@metro-labs/daemon/src/terminal/socket.ts +1 -1
- package/runtime/node_modules/@metro-labs/daemon/src/vault/firewall.ts +5 -44
- package/runtime/node_modules/@metro-labs/daemon/src/vault/index.ts +4 -4
- package/runtime/node_modules/@metro-labs/daemon/src/vault/proxy.ts +6 -7
- package/runtime/node_modules/@metro-labs/daemon/src/vault/store.ts +12 -13
- package/runtime/node_modules/@metro-labs/daemon/src/vault/trust.ts +5 -22
- package/runtime/node_modules/@metro-labs/discord-bot/src/accounts.ts +1 -19
- package/runtime/node_modules/@metro-labs/discord-bot/src/actions.ts +7 -35
- package/runtime/node_modules/@metro-labs/discord-bot/src/format.ts +5 -10
- package/runtime/node_modules/@metro-labs/discord-bot/src/group-actions.ts +1 -1
- package/runtime/node_modules/@metro-labs/discord-bot/src/index.ts +1 -2
- package/runtime/node_modules/@metro-labs/discord-bot/src/profile.ts +1 -1
- package/runtime/node_modules/@metro-labs/discord-bot/src/send-files.ts +11 -12
- package/runtime/node_modules/@metro-labs/http/src/api-http.ts +2 -5
- package/runtime/node_modules/@metro-labs/http/src/mode-api.ts +0 -1
- package/runtime/node_modules/@metro-labs/outlook/src/accounts.ts +1 -15
- package/runtime/node_modules/@metro-labs/outlook/src/actions.ts +0 -6
- package/runtime/node_modules/@metro-labs/outlook/src/format.ts +0 -1
- package/runtime/node_modules/@metro-labs/outlook/src/state.ts +1 -1
- package/runtime/node_modules/@metro-labs/telegram/src/accounts.ts +4 -22
- package/runtime/node_modules/@metro-labs/telegram/src/actions.ts +2 -2
- package/runtime/node_modules/@metro-labs/telegram/src/format.ts +1 -3
- package/runtime/node_modules/@metro-labs/telegram/src/inbound.ts +1 -1
- package/runtime/node_modules/@metro-labs/telegram/src/media-actions.ts +1 -2
- package/runtime/node_modules/@metro-labs/telegram/src/reactions.ts +1 -1
- package/runtime/node_modules/@metro-labs/telegram-bot/src/accounts.ts +4 -37
- package/runtime/node_modules/@metro-labs/telegram-bot/src/actions.ts +7 -23
- package/runtime/node_modules/@metro-labs/telegram-bot/src/format.ts +1 -4
- package/runtime/node_modules/@metro-labs/telegram-bot/src/media-actions.ts +4 -9
- package/runtime/node_modules/@metro-labs/telegram-bot/src/profile.ts +3 -3
- package/runtime/node_modules/@metro-labs/threema/src/accounts.ts +0 -11
- package/runtime/node_modules/@metro-labs/threema/src/actions.ts +2 -12
- package/runtime/node_modules/@metro-labs/threema/src/files.ts +4 -15
- package/runtime/node_modules/@metro-labs/threema/src/format.ts +2 -7
- package/runtime/node_modules/@metro-labs/whatsapp/package.json +1 -2
- package/runtime/node_modules/@metro-labs/whatsapp/src/accounts.ts +5 -26
- package/runtime/node_modules/@metro-labs/whatsapp/src/actions.ts +5 -6
- package/runtime/node_modules/@metro-labs/whatsapp/src/format.ts +1 -3
- package/runtime/node_modules/@metro-labs/whatsapp/src/inbound.ts +1 -1
- package/runtime/node_modules/@metro-labs/xmtp/src/accounts.ts +1 -12
- package/runtime/node_modules/@metro-labs/xmtp/src/actions-close.ts +1 -1
- package/runtime/node_modules/@metro-labs/xmtp/src/actions-conv.ts +14 -5
- package/runtime/node_modules/@metro-labs/xmtp/src/actions-meta.ts +1 -1
- package/runtime/node_modules/@metro-labs/xmtp/src/actions.ts +31 -78
- package/runtime/node_modules/@metro-labs/xmtp/src/emit.ts +3 -4
- package/runtime/node_modules/@metro-labs/xmtp/src/index.ts +1 -6
- package/runtime/node_modules/@metro-labs/xmtp/src/profile.ts +1 -1
- package/runtime/node_modules/@metro-labs/xmtp/src/sender.ts +1 -1
- package/runtime/node_modules/@metro-labs/xmtp/src/tools-handlers.ts +1 -1
- package/runtime/node_modules/@metro-labs/xmtp/src/transcribe.ts +2 -2
- package/runtime/node_modules/@metro-labs/xmtp/src/wire.ts +0 -2
- package/runtime/runtime.json +1 -1
- package/runtime/node_modules/@metro-labs/core/src/identity.ts +0 -7
- package/runtime/node_modules/@metro-labs/daemon/src/approvals/needs.ts +0 -2
- package/runtime/node_modules/@metro-labs/daemon/src/channels/dedupe.ts +0 -12
- package/runtime/node_modules/@metro-labs/daemon/src/channels/react-note.ts +0 -13
- package/runtime/node_modules/@metro-labs/daemon/src/channels/reply-id.ts +0 -1
- package/runtime/node_modules/@metro-labs/daemon/src/connectors/relay-target.ts +0 -29
- package/runtime/node_modules/@metro-labs/daemon/src/metro-user/api.ts +0 -43
- package/runtime/node_modules/@metro-labs/daemon/src/metro-user/move.ts +0 -165
- package/runtime/node_modules/@metro-labs/discord-bot/src/wire.ts +0 -1
- package/runtime/node_modules/@metro-labs/telegram/src/normalize.ts +0 -22
- package/runtime/node_modules/@metro-labs/telegram/src/wire.ts +0 -1
- package/runtime/node_modules/@metro-labs/telegram-bot/src/wire.ts +0 -1
- package/runtime/node_modules/@metro-labs/whatsapp/src/normalize.ts +0 -22
- package/runtime/node_modules/@metro-labs/whatsapp/src/wire.ts +0 -1
|
@@ -1,88 +1,16 @@
|
|
|
1
1
|
type Args = Record<string, unknown>;
|
|
2
|
+
|
|
2
3
|
export interface Normalized {
|
|
3
4
|
action: string;
|
|
4
5
|
args: Args;
|
|
5
6
|
}
|
|
6
7
|
|
|
7
|
-
|
|
8
|
-
kind?: string;
|
|
9
|
-
path?: string;
|
|
10
|
-
url?: string;
|
|
11
|
-
data?: string;
|
|
12
|
-
mime?: string;
|
|
13
|
-
name?: string;
|
|
14
|
-
}
|
|
15
|
-
|
|
16
|
-
interface DiscordFile {
|
|
17
|
-
file: string;
|
|
18
|
-
kind: string;
|
|
19
|
-
name: string;
|
|
20
|
-
}
|
|
21
|
-
|
|
22
|
-
const discordFile = (a: Attachment): DiscordFile | null => {
|
|
23
|
-
const file = a.path ?? a.url;
|
|
24
|
-
if (typeof file !== 'string') return null;
|
|
25
|
-
return { file, kind: a.kind ?? 'file', name: a.name ?? '' };
|
|
26
|
-
};
|
|
8
|
+
export type Normalize = (action: string, args: Args) => Normalized;
|
|
27
9
|
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
files: usable.map((f) => f.file),
|
|
35
|
-
attachmentKinds: usable.map((f) => f.kind),
|
|
36
|
-
attachmentNames: usable.map((f) => f.name),
|
|
10
|
+
export const messagingAliases =
|
|
11
|
+
(removal: Args = { emoji: '' }): Normalize =>
|
|
12
|
+
(action, args) => {
|
|
13
|
+
if (action === 'reply') return { action: 'send', args };
|
|
14
|
+
if (action === 'unreact') return { action: 'react', args: { ...args, ...removal } };
|
|
15
|
+
return { action, args };
|
|
37
16
|
};
|
|
38
|
-
}
|
|
39
|
-
|
|
40
|
-
export function normalizeDiscord(action: string, env: Args): Normalized {
|
|
41
|
-
const att = (env.attachments as Attachment[] | undefined) ?? [];
|
|
42
|
-
if (action === 'send') {
|
|
43
|
-
return { action: 'send', args: { ...env, ...discordAttachments(att) } };
|
|
44
|
-
}
|
|
45
|
-
if (action === 'reply') {
|
|
46
|
-
const replyTo = env.replyTo ?? env.messageId;
|
|
47
|
-
return {
|
|
48
|
-
action: 'send',
|
|
49
|
-
args: { ...env, replyTo, ...discordAttachments(att) },
|
|
50
|
-
};
|
|
51
|
-
}
|
|
52
|
-
if (action === 'unreact')
|
|
53
|
-
return { action: 'react', args: { ...env, emoji: '' } };
|
|
54
|
-
if (action === 'read') {
|
|
55
|
-
return {
|
|
56
|
-
action: 'fetch',
|
|
57
|
-
args: { line: env.line, limit: env.limit, before: env.before },
|
|
58
|
-
};
|
|
59
|
-
}
|
|
60
|
-
return { action, args: env };
|
|
61
|
-
}
|
|
62
|
-
|
|
63
|
-
export function normalizeTelegram(action: string, env: Args): Normalized {
|
|
64
|
-
if (action === 'reply') {
|
|
65
|
-
return {
|
|
66
|
-
action: 'send',
|
|
67
|
-
args: {
|
|
68
|
-
line: env.line,
|
|
69
|
-
text: env.text,
|
|
70
|
-
replyTo: env.replyTo,
|
|
71
|
-
attachments: env.attachments,
|
|
72
|
-
},
|
|
73
|
-
};
|
|
74
|
-
}
|
|
75
|
-
if (action === 'unreact')
|
|
76
|
-
return { action: 'react', args: { ...env, emoji: '' } };
|
|
77
|
-
return { action, args: env };
|
|
78
|
-
}
|
|
79
|
-
|
|
80
|
-
export function normalizeXmtp(action: string, env: Args): Normalized {
|
|
81
|
-
if (action === 'unreact') {
|
|
82
|
-
return { action: 'react', args: { ...env, action: 'removed' } };
|
|
83
|
-
}
|
|
84
|
-
if (action === 'read') {
|
|
85
|
-
return { action: 'query', args: { line: env.line, limit: env.limit } };
|
|
86
|
-
}
|
|
87
|
-
return { action, args: env };
|
|
88
|
-
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { serializeTrainError, type TrainErrorInfo } from '../train-error.js';
|
|
2
|
-
import type {
|
|
2
|
+
import type { Normalize } from './messaging-normalize.js';
|
|
3
3
|
|
|
4
4
|
export const emit = (e: unknown): void =>
|
|
5
5
|
void process.stdout.write(JSON.stringify(e) + '\n');
|
|
@@ -27,7 +27,7 @@ export type StationHandler = (id: string, args: Args) => void | Promise<void>;
|
|
|
27
27
|
|
|
28
28
|
export interface StationConfig {
|
|
29
29
|
handlers: Record<string, StationHandler>;
|
|
30
|
-
normalize
|
|
30
|
+
normalize?: Normalize;
|
|
31
31
|
}
|
|
32
32
|
|
|
33
33
|
const lineTag = (args: Args): string =>
|
|
@@ -40,15 +40,13 @@ export function makeStation({ handlers, normalize }: StationConfig) {
|
|
|
40
40
|
let action = msg.action;
|
|
41
41
|
let args: Args = msg.args;
|
|
42
42
|
try {
|
|
43
|
-
({ action, args } = normalize(
|
|
44
|
-
emit({ op: 'log', text: `call ${action} recv (line=${lineTag(args)})` });
|
|
43
|
+
if (normalize && !handlers[action]) ({ action, args } = normalize(action, args));
|
|
45
44
|
const handler = handlers[action];
|
|
46
45
|
if (!handler) {
|
|
47
46
|
respond(id, { error: `unknown action '${action}' (have: ${known})` });
|
|
48
47
|
return;
|
|
49
48
|
}
|
|
50
49
|
await handler(id, args);
|
|
51
|
-
emit({ op: 'log', text: `call ${action} done (line=${lineTag(args)})` });
|
|
52
50
|
} catch (err) {
|
|
53
51
|
const info = serializeTrainError(err);
|
|
54
52
|
emit({
|
|
@@ -4,15 +4,11 @@ import { emit } from './station-runtime.js';
|
|
|
4
4
|
type Fields = Record<string, unknown>;
|
|
5
5
|
type Out = (e: unknown) => void;
|
|
6
6
|
|
|
7
|
-
export const selfUri = (
|
|
8
|
-
process.env.METRO_SELF_URI ??
|
|
9
|
-
(account === undefined ? `metro://${station}/self` : `metro://${station}/${account}/self`);
|
|
7
|
+
export const selfUri = (): string => process.env.METRO_SELF_URI ?? 'metro://user';
|
|
10
8
|
|
|
11
9
|
export function emitInbound(account: string, e: Fields, out: Out = emit): void {
|
|
12
10
|
out({
|
|
13
|
-
kind: 'inbound',
|
|
14
11
|
...e,
|
|
15
|
-
account,
|
|
16
12
|
payload: { ...(e.payload as Fields | undefined), account },
|
|
17
13
|
});
|
|
18
14
|
}
|
|
@@ -30,18 +26,17 @@ export interface SavedFile {
|
|
|
30
26
|
path: string;
|
|
31
27
|
mime?: string;
|
|
32
28
|
name?: string;
|
|
29
|
+
bytes?: number;
|
|
33
30
|
}
|
|
34
31
|
|
|
35
32
|
function attachmentEvent(at: AttachmentAt, text: string, payload: Fields): Fields {
|
|
36
33
|
return {
|
|
37
|
-
kind: 'inbound',
|
|
38
34
|
id: mintId(),
|
|
39
35
|
ts: new Date().toISOString(),
|
|
40
36
|
station: at.station,
|
|
41
37
|
line: at.line,
|
|
42
|
-
from: at.from ?? selfUri(
|
|
38
|
+
from: at.from ?? selfUri(),
|
|
43
39
|
text,
|
|
44
|
-
account: at.account,
|
|
45
40
|
payload: { account: at.account, attachmentFor: at.forId, index: at.index, ...payload },
|
|
46
41
|
};
|
|
47
42
|
}
|
|
@@ -49,9 +44,9 @@ function attachmentEvent(at: AttachmentAt, text: string, payload: Fields): Field
|
|
|
49
44
|
export const attachmentSavedEvent = (at: AttachmentAt & { saved: SavedFile; extra?: Fields }): Fields =>
|
|
50
45
|
attachmentEvent(at, `📎 saved: ${at.saved.path}`, {
|
|
51
46
|
contentType: 'attachmentSaved',
|
|
47
|
+
...(at.saved.bytes === undefined ? {} : { size: at.saved.bytes }),
|
|
52
48
|
...at.extra,
|
|
53
49
|
attachmentPath: at.saved.path,
|
|
54
|
-
localPath: at.saved.path,
|
|
55
50
|
mime: at.saved.mime,
|
|
56
51
|
name: at.saved.name,
|
|
57
52
|
});
|
|
@@ -1,12 +1,11 @@
|
|
|
1
1
|
import { spawnSync } from 'node:child_process';
|
|
2
|
-
import { closeSync, openSync, readdirSync, readSync, statSync } from 'node:fs';
|
|
3
2
|
import { join } from 'node:path';
|
|
4
3
|
import { ApiError } from '@metro-labs/http/api-error';
|
|
5
4
|
import { asUser, type AgentUser } from './user.js';
|
|
6
5
|
|
|
7
|
-
|
|
6
|
+
type EntryKind = 'folder' | 'file' | 'other';
|
|
8
7
|
|
|
9
|
-
|
|
8
|
+
interface FileEntry {
|
|
10
9
|
name: string;
|
|
11
10
|
kind: EntryKind;
|
|
12
11
|
bytes: number;
|
|
@@ -76,44 +75,15 @@ function readAsAgent(user: AgentUser, path: string): FilesAnswer {
|
|
|
76
75
|
const run = spawnSync(file, argv, { stdio: ['ignore', 'pipe', 'pipe'], maxBuffer: 64 * 1024 * 1024, timeout: 20_000 });
|
|
77
76
|
if (run.error !== undefined) throw new ApiError(`could not read that path: ${run.error.message}`, 500);
|
|
78
77
|
if (run.status !== 0) throw new ApiError(run.stderr.toString('utf8').trim() || 'the agent cannot read that path', 404);
|
|
79
|
-
|
|
80
|
-
const body = out.subarray(2);
|
|
81
|
-
return out.subarray(0, 1).toString() === 'D' ? folderAnswer(user.home, path, parseListing(body)) : parseFile(user.home, path, body);
|
|
82
|
-
}
|
|
83
|
-
|
|
84
|
-
function readHead(target: string): Buffer {
|
|
85
|
-
const fd = openSync(target, 'r');
|
|
86
|
-
try {
|
|
87
|
-
const buffer = Buffer.alloc(PREVIEW_BYTES + 1);
|
|
88
|
-
return buffer.subarray(0, readSync(fd, buffer, 0, buffer.length, 0));
|
|
89
|
-
} finally {
|
|
90
|
-
closeSync(fd);
|
|
91
|
-
}
|
|
78
|
+
return answerOf(user.home, path, run.stdout);
|
|
92
79
|
}
|
|
93
80
|
|
|
94
|
-
function
|
|
95
|
-
const
|
|
96
|
-
|
|
97
|
-
try {
|
|
98
|
-
stat = statSync(target);
|
|
99
|
-
} catch {
|
|
100
|
-
throw new ApiError('no such file or folder', 404);
|
|
101
|
-
}
|
|
102
|
-
if (stat.isFile()) return { root, path, kind: 'file', bytes: stat.size, modifiedAt: stat.mtime.toISOString(), ...preview(readHead(target), stat.size) };
|
|
103
|
-
const entries = readdirSync(target, { withFileTypes: true }).map((entry): FileEntry => {
|
|
104
|
-
const inner = join(target, entry.name);
|
|
105
|
-
try {
|
|
106
|
-
const s = statSync(inner);
|
|
107
|
-
return { name: entry.name, kind: s.isDirectory() ? 'folder' : s.isFile() ? 'file' : 'other', bytes: s.size, modifiedAt: s.mtime.toISOString() };
|
|
108
|
-
} catch {
|
|
109
|
-
return { name: entry.name, kind: 'other', bytes: 0, modifiedAt: '' };
|
|
110
|
-
}
|
|
111
|
-
});
|
|
112
|
-
return folderAnswer(root, path, entries);
|
|
81
|
+
export function answerOf(root: string, path: string, out: Buffer): FilesAnswer {
|
|
82
|
+
const body = out.subarray(2);
|
|
83
|
+
return out.subarray(0, 1).toString() === 'D' ? folderAnswer(root, path, parseListing(body)) : parseFile(root, path, body);
|
|
113
84
|
}
|
|
114
85
|
|
|
115
|
-
export function readAgentPath(user: AgentUser | null, path: string
|
|
116
|
-
if (user
|
|
117
|
-
|
|
118
|
-
return readLocal(localRoot, path);
|
|
86
|
+
export function readAgentPath(user: AgentUser | null, path: string): FilesAnswer {
|
|
87
|
+
if (user === null) throw new ApiError('Claude Code does not run as its own user on this machine, so there is no agent folder to show', 409);
|
|
88
|
+
return readAsAgent(user, path);
|
|
119
89
|
}
|
|
@@ -9,7 +9,7 @@ const DROP_IN = '11-metro-vault.conf';
|
|
|
9
9
|
const BEGIN = '# metro vault: begin';
|
|
10
10
|
const END = '# metro vault: end';
|
|
11
11
|
|
|
12
|
-
|
|
12
|
+
const jobEnvFile = (user: AgentUser): string => join(user.home, '.metro', 'vault.env');
|
|
13
13
|
|
|
14
14
|
export function withCronEnv(lines: string[], env: Record<string, string>): string[] {
|
|
15
15
|
const start = lines.indexOf(BEGIN);
|
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
import { spawn, spawnSync } from 'node:child_process';
|
|
2
|
-
import { existsSync
|
|
2
|
+
import { existsSync } from './agent-fs.js';
|
|
3
3
|
import { join } from 'node:path';
|
|
4
4
|
import { chmodSync } from 'node:fs';
|
|
5
5
|
import { homedir } from 'node:os';
|
|
6
6
|
import { errMsg, log } from '@metro-labs/core/log';
|
|
7
|
-
import { isRecord } from '@metro-labs/core/is-record';
|
|
8
7
|
import { copyIntoHome } from './home-fs.js';
|
|
9
8
|
import { AGENT_NAME, agentMarketplaceDir, agentUser, agentUserExpected, asUser, claudeBin, forgetAgentUser, type AgentUser } from './user.js';
|
|
10
9
|
import { watchAgentView } from './view.js';
|
|
11
|
-
import { mustHelper
|
|
10
|
+
import { mustHelper } from '../metro-user/privilege.js';
|
|
11
|
+
import { stagedPluginVersion } from '../claude/plugin-install.js';
|
|
12
12
|
|
|
13
13
|
const INSTALL_MS = 10 * 60_000;
|
|
14
14
|
const INSTALLER = 'curl -fsSL https://claude.ai/install.sh | bash';
|
|
@@ -27,21 +27,11 @@ function openMetroHome(): void {
|
|
|
27
27
|
}
|
|
28
28
|
|
|
29
29
|
function ensureUser(): AgentUser | null {
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
forgetAgentUser();
|
|
34
|
-
const user = agentUser();
|
|
35
|
-
if (user !== null) run(...asUser(user, 'chmod', ['711', user.home]));
|
|
36
|
-
return user;
|
|
37
|
-
}
|
|
38
|
-
if (agentUser() === null) {
|
|
39
|
-
run('useradd', ['--create-home', '--shell', '/bin/bash', AGENT_NAME]);
|
|
40
|
-
log.info({ user: AGENT_NAME }, 'agent-user: created the user Claude Code runs as');
|
|
41
|
-
forgetAgentUser();
|
|
42
|
-
}
|
|
30
|
+
mustHelper(['ensure-agent']);
|
|
31
|
+
openMetroHome();
|
|
32
|
+
forgetAgentUser();
|
|
43
33
|
const user = agentUser();
|
|
44
|
-
if (user !== null) run('chmod', ['
|
|
34
|
+
if (user !== null) run(...asUser(user, 'chmod', ['711', user.home]));
|
|
45
35
|
return user;
|
|
46
36
|
}
|
|
47
37
|
|
|
@@ -64,23 +54,14 @@ function installClaude(user: AgentUser): Promise<void> {
|
|
|
64
54
|
});
|
|
65
55
|
}
|
|
66
56
|
|
|
67
|
-
const pluginVersion = (dir: string): string | null => {
|
|
68
|
-
try {
|
|
69
|
-
const parsed: unknown = JSON.parse(readFileSync(join(dir, 'plugin', '.claude-plugin', 'plugin.json'), 'utf8'));
|
|
70
|
-
return isRecord(parsed) && typeof parsed.version === 'string' ? parsed.version : null;
|
|
71
|
-
} catch {
|
|
72
|
-
return null;
|
|
73
|
-
}
|
|
74
|
-
};
|
|
75
|
-
|
|
76
57
|
function copyMarketplace(user: AgentUser, env: NodeJS.ProcessEnv): void {
|
|
77
58
|
const store = env.METRO_RUNTIME_STORE?.trim() ?? '';
|
|
78
59
|
const from = join(store, 'marketplace');
|
|
79
60
|
if (store === '' || !existsSync(join(from, '.claude-plugin', 'marketplace.json'))) return;
|
|
80
61
|
const to = agentMarketplaceDir(user);
|
|
81
|
-
if (
|
|
62
|
+
if (stagedPluginVersion(from) !== null && stagedPluginVersion(from) === stagedPluginVersion(to)) return;
|
|
82
63
|
const files = copyIntoHome(from, to, user);
|
|
83
|
-
log.info({ files, version:
|
|
64
|
+
log.info({ files, version: stagedPluginVersion(from) }, 'agent-user: copied the metro plugin where the agent user can load it');
|
|
84
65
|
}
|
|
85
66
|
|
|
86
67
|
export async function provisionAgentUser(env: NodeJS.ProcessEnv = process.env): Promise<Provisioned> {
|
|
@@ -87,7 +87,6 @@ export function scheduleDetail(id: string, user: AgentUser | null, runner: Runne
|
|
|
87
87
|
|
|
88
88
|
export function runNow(id: string, user: AgentUser | null, runner: Runner = realRunner): JobDetail {
|
|
89
89
|
const job = findJob(id, user, runner);
|
|
90
|
-
if (job.runsAs === 'root') throw new ApiError('this job still runs as root; switch it to the agent first', 409);
|
|
91
90
|
if (job.kind === 'timer') runner.run('systemctl', ['start', '--no-block', serviceOf(job.id.slice('timer:'.length), runner)]);
|
|
92
91
|
else {
|
|
93
92
|
if (user === null) throw new ApiError('Claude Code does not run as its own user on this machine', 409);
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import type { IncomingMessage, ServerResponse } from 'node:http';
|
|
2
2
|
import { ApiError } from '@metro-labs/http/api-error';
|
|
3
3
|
import { bodyField, readJsonBody, sessionRoute } from '@metro-labs/http/api-http';
|
|
4
|
-
import { listSchedules
|
|
4
|
+
import { listSchedules } from './schedules.js';
|
|
5
5
|
import { runNow, scheduleDetail } from './schedule-detail.js';
|
|
6
6
|
import { agentUser } from './user.js';
|
|
7
7
|
|
|
@@ -18,8 +18,8 @@ async function answer(req: IncomingMessage): Promise<unknown> {
|
|
|
18
18
|
const body = await readJsonBody(req);
|
|
19
19
|
const id = bodyField(body, 'id');
|
|
20
20
|
if (typeof id !== 'string' || id === '') throw new ApiError('id is required', 400);
|
|
21
|
-
if (bodyField(body, 'action')
|
|
22
|
-
return {
|
|
21
|
+
if (bodyField(body, 'action') !== 'run') throw new ApiError('action must be run', 400);
|
|
22
|
+
return { job: runNow(id, user) };
|
|
23
23
|
}
|
|
24
24
|
|
|
25
25
|
export function handleSchedulesRequest(req: IncomingMessage, res: ServerResponse): boolean {
|
|
@@ -1,20 +1,13 @@
|
|
|
1
1
|
import { spawnSync } from 'node:child_process';
|
|
2
2
|
import { createHash } from 'node:crypto';
|
|
3
|
-
import { existsSync, mkdirSync, writeFileSync } from 'node:fs';
|
|
4
|
-
import { homedir } from 'node:os';
|
|
5
|
-
import { join } from 'node:path';
|
|
6
|
-
import { ApiError } from '@metro-labs/http/api-error';
|
|
7
3
|
import { isRecord } from '@metro-labs/core/is-record';
|
|
8
|
-
import {
|
|
9
|
-
import {
|
|
10
|
-
import { metroRun, writeDropIn } from './unit-files.js';
|
|
4
|
+
import type { AgentUser } from './user.js';
|
|
5
|
+
import { helperRun } from './unit-files.js';
|
|
11
6
|
|
|
12
7
|
const UNIT_DIR = '/etc/systemd/system';
|
|
13
|
-
const defaultBackups = (): string => join(homedir(), '.metro', 'agents');
|
|
14
|
-
const DROP_IN = '10-metro-agent.conf';
|
|
15
8
|
const OWN_UNITS = /^metro(-claude-\d+)?\.(service|scope)$/;
|
|
16
9
|
|
|
17
|
-
|
|
10
|
+
type JobKind = 'timer' | 'cron-agent';
|
|
18
11
|
|
|
19
12
|
export interface ScheduledJob {
|
|
20
13
|
id: string;
|
|
@@ -26,9 +19,6 @@ export interface ScheduledJob {
|
|
|
26
19
|
next: string | null;
|
|
27
20
|
last: string | null;
|
|
28
21
|
lastResult: string | null;
|
|
29
|
-
usesRoot: boolean;
|
|
30
|
-
converted: boolean;
|
|
31
|
-
problem?: string | null;
|
|
32
22
|
}
|
|
33
23
|
|
|
34
24
|
export interface Runner {
|
|
@@ -37,17 +27,13 @@ export interface Runner {
|
|
|
37
27
|
|
|
38
28
|
export const realRunner: Runner = {
|
|
39
29
|
run: (file, args, input) => {
|
|
40
|
-
const viaHelper =
|
|
30
|
+
const viaHelper = helperRun(file, args, input);
|
|
41
31
|
if (viaHelper !== null) return viaHelper;
|
|
42
32
|
const done = spawnSync(file, args, { encoding: 'utf8', input, stdio: ['pipe', 'pipe', 'ignore'], timeout: 20_000 });
|
|
43
33
|
return { status: done.error === undefined ? done.status : null, stdout: done.stdout ?? '' };
|
|
44
34
|
},
|
|
45
35
|
};
|
|
46
36
|
|
|
47
|
-
const ROOT_RE = /(^|[\s="'=:])\/root(?=\/|$|[\s"';:])/;
|
|
48
|
-
export const usesRootHome = (text: string): boolean => ROOT_RE.test(text);
|
|
49
|
-
export const rehome = (text: string, home: string): string => text.replace(/(^|[\s="'=:])\/root(?=\/|$|[\s"';:])/g, `$1${home}`);
|
|
50
|
-
|
|
51
37
|
const stamp = (micros: unknown): string | null =>
|
|
52
38
|
typeof micros === 'number' && micros > 0 ? new Date(micros / 1000).toISOString() : null;
|
|
53
39
|
|
|
@@ -61,7 +47,7 @@ export function showProps(runner: Runner, unit: string, props: string[]): Record
|
|
|
61
47
|
return found;
|
|
62
48
|
}
|
|
63
49
|
|
|
64
|
-
|
|
50
|
+
function execArgv(execStart: string): string[] {
|
|
65
51
|
const m = /argv\[\]=(.*?) ;/.exec(execStart);
|
|
66
52
|
return m?.[1] === undefined ? [] : m[1].split(' ').filter((a) => a !== '');
|
|
67
53
|
}
|
|
@@ -87,7 +73,7 @@ function timerJob(runner: Runner, t: Record<string, unknown>): ScheduledJob | nu
|
|
|
87
73
|
if (unit === '' || service === '' || OWN_UNITS.test(service)) return null;
|
|
88
74
|
const timer = showProps(runner, unit, ['FragmentPath', 'TimersCalendar', 'TimersMonotonic']);
|
|
89
75
|
if (!text(timer.FragmentPath).startsWith(`${UNIT_DIR}/`)) return null;
|
|
90
|
-
const svc = showProps(runner, service, ['ExecStart', 'User', 'Result'
|
|
76
|
+
const svc = showProps(runner, service, ['ExecStart', 'User', 'Result']);
|
|
91
77
|
const command = execArgv(text(svc.ExecStart)).join(' ');
|
|
92
78
|
return {
|
|
93
79
|
id: `timer:${unit}`,
|
|
@@ -99,8 +85,6 @@ function timerJob(runner: Runner, t: Record<string, unknown>): ScheduledJob | nu
|
|
|
99
85
|
next: stamp(t.next),
|
|
100
86
|
last: stamp(t.last),
|
|
101
87
|
lastResult: svc.Result ?? null,
|
|
102
|
-
usesRoot: usesRootHome(`${command} ${text(svc.Environment)} ${text(svc.WorkingDirectory)}`),
|
|
103
|
-
converted: text(svc.DropInPaths).includes(DROP_IN),
|
|
104
88
|
};
|
|
105
89
|
}
|
|
106
90
|
|
|
@@ -148,105 +132,13 @@ function cronJobs(runner: Runner, user: string, kind: JobKind): ScheduledJob[] {
|
|
|
148
132
|
next: null,
|
|
149
133
|
last: null,
|
|
150
134
|
lastResult: null,
|
|
151
|
-
usesRoot: usesRootHome(parsed.command),
|
|
152
|
-
converted: false,
|
|
153
135
|
}];
|
|
154
136
|
});
|
|
155
137
|
}
|
|
156
138
|
|
|
157
139
|
function allJobs(user: AgentUser | null, runner: Runner): ScheduledJob[] {
|
|
158
|
-
return [...timerJobs(runner), ...
|
|
159
|
-
}
|
|
160
|
-
|
|
161
|
-
const quote = (arg: string): string => `"${arg.replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"`;
|
|
162
|
-
|
|
163
|
-
export function dropInText(user: AgentUser, argv: string[], environment: string): string {
|
|
164
|
-
const env = environment.split(' ').filter((pair) => pair.includes('=') && !pair.startsWith('HOME=') && !pair.startsWith('PATH='));
|
|
165
|
-
return [
|
|
166
|
-
'[Service]',
|
|
167
|
-
`User=${user.name}`,
|
|
168
|
-
`Group=${String(user.gid)}`,
|
|
169
|
-
`WorkingDirectory=${user.home}`,
|
|
170
|
-
`Environment=HOME=${user.home}`,
|
|
171
|
-
`Environment=PATH=${user.home}/.local/bin:/usr/local/bin:/usr/bin:/bin`,
|
|
172
|
-
...env.map((pair) => `Environment=${quote(rehome(pair, user.home))}`),
|
|
173
|
-
'ExecStart=',
|
|
174
|
-
`ExecStart=${argv.map((a) => quote(rehome(a, user.home))).join(' ')}`,
|
|
175
|
-
'',
|
|
176
|
-
].join('\n');
|
|
177
|
-
}
|
|
178
|
-
|
|
179
|
-
function agentSees(user: AgentUser, path: string): boolean {
|
|
180
|
-
if (existsSync(path)) return true;
|
|
181
|
-
const [file, args] = asUser(user, 'test', ['-e', path]);
|
|
182
|
-
return spawnSync(file, args, { stdio: 'ignore' }).status === 0;
|
|
183
|
-
}
|
|
184
|
-
|
|
185
|
-
function convertTimer(job: ScheduledJob, user: AgentUser, runner: Runner): void {
|
|
186
|
-
const unit = job.id.slice('timer:'.length);
|
|
187
|
-
const service = showProps(runner, unit, ['Unit']).Unit ?? unit.replace(/\.timer$/, '.service');
|
|
188
|
-
const svc = showProps(runner, service, ['ExecStart', 'Environment']);
|
|
189
|
-
const argv = execArgv(svc.ExecStart ?? '');
|
|
190
|
-
if (argv.length === 0) throw new ApiError('that timer runs no command metro can read', 400);
|
|
191
|
-
const missing = argv.map((a) => rehome(a, user.home)).find((a) => a.startsWith(`${user.home}/`) && !agentSees(user, a));
|
|
192
|
-
if (missing !== undefined) throw new ApiError(`${missing} does not exist; move it to the agent first`, 409);
|
|
193
|
-
writeDropIn(service, DROP_IN, dropInText(user, argv, svc.Environment ?? ''));
|
|
194
|
-
runner.run('systemctl', ['daemon-reload']);
|
|
195
|
-
log.info({ unit, service, user: user.name }, 'schedules: a timer now runs as the agent user');
|
|
196
|
-
}
|
|
197
|
-
|
|
198
|
-
function convertCron(job: ScheduledJob, user: AgentUser, backups: string, runner: Runner): void {
|
|
199
|
-
const lines = crontabOf(runner, 'root');
|
|
200
|
-
const line = lines.find((l) => lineId('cron-root', l) === job.id);
|
|
201
|
-
if (line === undefined) throw new ApiError('that cron line is gone', 404);
|
|
202
|
-
mkdirSync(backups, { recursive: true });
|
|
203
|
-
writeFileSync(join(backups, `crontab-root.${new Date().toISOString().replace(/[:.]/g, '-')}.bak`), lines.join('\n'), { mode: 0o600 });
|
|
204
|
-
const agentLines = crontabOf(runner, user.name).filter((l) => l !== '');
|
|
205
|
-
const moved = runner.run('crontab', ['-u', user.name, '-'], `${[...agentLines, rehome(line, user.home)].join('\n')}\n`);
|
|
206
|
-
if (moved.status !== 0) throw new ApiError("the agent's crontab refused the line", 500);
|
|
207
|
-
const kept = lines.filter((l) => l !== line && l !== '');
|
|
208
|
-
runner.run('crontab', ['-u', 'root', '-'], `${kept.join('\n')}\n`);
|
|
209
|
-
log.info({ user: user.name }, "schedules: a root cron line moved to the agent's crontab");
|
|
210
|
-
}
|
|
211
|
-
|
|
212
|
-
function toAgent(job: ScheduledJob, user: AgentUser, backups: string, runner: Runner): void {
|
|
213
|
-
if (job.kind === 'timer') convertTimer(job, user, runner);
|
|
214
|
-
else if (job.kind === 'cron-root') convertCron(job, user, backups, runner);
|
|
215
|
-
else throw new ApiError('that job already runs as the agent', 400);
|
|
216
|
-
}
|
|
217
|
-
|
|
218
|
-
const problems = new Map<string, string>();
|
|
219
|
-
|
|
220
|
-
const needsAgent = (job: ScheduledJob): boolean => job.kind !== 'cron-agent' && job.runsAs === 'root' && job.usesRoot;
|
|
221
|
-
|
|
222
|
-
function tryAgent(job: ScheduledJob, user: AgentUser, backups: string, runner: Runner): boolean {
|
|
223
|
-
try {
|
|
224
|
-
toAgent(job, user, backups, runner);
|
|
225
|
-
problems.delete(job.id);
|
|
226
|
-
return true;
|
|
227
|
-
} catch (err) {
|
|
228
|
-
problems.set(job.id, errMsg(err));
|
|
229
|
-
log.warn({ job: job.name, err: errMsg(err) }, 'schedules: a job still pointing into /root could not be switched to the agent user');
|
|
230
|
-
return false;
|
|
231
|
-
}
|
|
232
|
-
}
|
|
233
|
-
|
|
234
|
-
export function convertRootJobs(user: AgentUser | null, backups = defaultBackups(), runner: Runner = realRunner): number {
|
|
235
|
-
if (user === null) return 0;
|
|
236
|
-
const pending = allJobs(user, runner).filter(needsAgent);
|
|
237
|
-
return pending.filter((job) => tryAgent(job, user, backups, runner)).length;
|
|
140
|
+
return [...timerJobs(runner), ...(user === null ? [] : cronJobs(runner, user.name, 'cron-agent'))];
|
|
238
141
|
}
|
|
239
142
|
|
|
240
143
|
export const listSchedules = (user: AgentUser | null, runner: Runner = realRunner): ScheduledJob[] =>
|
|
241
|
-
allJobs(user, runner)
|
|
242
|
-
.filter((job) => job.runsAs !== 'root' || job.usesRoot)
|
|
243
|
-
.map((job) => ({ ...job, problem: problems.get(job.id) ?? null }));
|
|
244
|
-
|
|
245
|
-
export function retrySchedule(id: string, user: AgentUser | null, backups = defaultBackups(), runner: Runner = realRunner): ScheduledJob[] {
|
|
246
|
-
if (user === null) throw new ApiError('Claude Code does not run as its own user on this machine', 409);
|
|
247
|
-
const job = allJobs(user, runner).find((j) => j.id === id);
|
|
248
|
-
if (job === undefined) throw new ApiError('no such scheduled job', 404);
|
|
249
|
-
if (needsAgent(job)) toAgent(job, user, backups, runner);
|
|
250
|
-
problems.delete(id);
|
|
251
|
-
return listSchedules(user, runner);
|
|
252
|
-
}
|
|
144
|
+
allJobs(user, runner).filter((job) => job.runsAs !== 'root');
|
|
@@ -1,61 +1,37 @@
|
|
|
1
1
|
import { spawnSync } from 'node:child_process';
|
|
2
|
-
import {
|
|
3
|
-
import { existsSync, mkdirSync, rmSync, writeFileSync } from 'node:fs';
|
|
2
|
+
import { existsSync } from 'node:fs';
|
|
4
3
|
import { join } from 'node:path';
|
|
5
|
-
import { mustHelper, rootHelper
|
|
4
|
+
import { mustHelper, rootHelper } from '../metro-user/privilege.js';
|
|
6
5
|
import { agentUser, asUser } from './user.js';
|
|
7
6
|
|
|
8
7
|
const UNIT_DIR = '/etc/systemd/system';
|
|
9
8
|
|
|
10
9
|
export function writeDropIn(service: string, name: string, text: string): void {
|
|
11
|
-
|
|
12
|
-
mustHelper(['dropin-write', service, name], text);
|
|
13
|
-
return;
|
|
14
|
-
}
|
|
15
|
-
mkdirSync(join(UNIT_DIR, `${service}.d`), { recursive: true });
|
|
16
|
-
writeFileSync(join(UNIT_DIR, `${service}.d`, name), text, { mode: 0o644 });
|
|
10
|
+
mustHelper(['dropin-write', service, name], text);
|
|
17
11
|
}
|
|
18
12
|
|
|
19
13
|
export function removeDropIn(service: string, name: string): boolean {
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
if (runningAsMetro()) mustHelper(['dropin-remove', service, name]);
|
|
23
|
-
else rmSync(file, { force: true });
|
|
14
|
+
if (!existsSync(join(UNIT_DIR, `${service}.d`, name))) return false;
|
|
15
|
+
mustHelper(['dropin-remove', service, name]);
|
|
24
16
|
return true;
|
|
25
17
|
}
|
|
26
18
|
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
const [file, argv] = asUser(agentUser(), 'crontab',
|
|
19
|
+
function agentCrontab(args: string[], input?: string): { status: number | null; stdout: string } {
|
|
20
|
+
const at = args.indexOf('-u');
|
|
21
|
+
const rest = at < 0 ? args : args.filter((_, i) => i !== at && i !== at + 1);
|
|
22
|
+
const [file, argv] = asUser(agentUser(), 'crontab', rest);
|
|
31
23
|
const run = spawnSync(file, argv, { encoding: 'utf8', input, stdio: ['pipe', 'pipe', 'ignore'], timeout: 20_000 });
|
|
32
24
|
return { status: run.error === undefined ? run.status : null, stdout: run.stdout ?? '' };
|
|
33
25
|
}
|
|
34
26
|
|
|
35
|
-
function rootCrontabWrite(input: string): { status: number | null; stdout: string } {
|
|
36
|
-
const wanted = new Set(input.split('\n').filter((l) => l.trim() !== '').map(lineHash));
|
|
37
|
-
const current = rootHelper(['root-crontab']).stdout.split('\n').filter((l) => l.trim() !== '');
|
|
38
|
-
for (const line of current) if (!wanted.has(lineHash(line))) mustHelper(['root-crontab-drop', lineHash(line)]);
|
|
39
|
-
return { status: 0, stdout: '' };
|
|
40
|
-
}
|
|
41
|
-
|
|
42
|
-
function crontab(args: string[], input?: string): { status: number | null; stdout: string } {
|
|
43
|
-
const at = args.indexOf('-u');
|
|
44
|
-
const who = at >= 0 ? args[at + 1] : undefined;
|
|
45
|
-
const rest = args.filter((_, i) => i !== at && i !== at + 1);
|
|
46
|
-
if (who === 'root') return rest[0] === '-l' ? { status: 0, stdout: rootHelper(['root-crontab']).stdout } : rootCrontabWrite(input ?? '');
|
|
47
|
-
return asAgentRun(rest, input);
|
|
48
|
-
}
|
|
49
|
-
|
|
50
27
|
function systemctl(args: string[]): { status: number | null; stdout: string } | null {
|
|
51
28
|
if (args[0] === 'daemon-reload') return { status: rootHelper(['daemon-reload']).status, stdout: '' };
|
|
52
29
|
if (args[0] === 'start') return { status: rootHelper(['start-job', args[args.length - 1] ?? '']).status, stdout: '' };
|
|
53
30
|
return null;
|
|
54
31
|
}
|
|
55
32
|
|
|
56
|
-
export function
|
|
57
|
-
if (
|
|
58
|
-
if (file === 'crontab') return crontab(args, input);
|
|
33
|
+
export function helperRun(file: string, args: string[], input?: string): { status: number | null; stdout: string } | null {
|
|
34
|
+
if (file === 'crontab') return agentCrontab(args, input);
|
|
59
35
|
if (file === 'systemctl') return systemctl(args);
|
|
60
36
|
return null;
|
|
61
37
|
}
|