@stage-labs/metro 0.1.0-beta.172 → 0.1.0-beta.173
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/package.json +1 -1
- package/runtime/marketplace/plugin/.claude-plugin/plugin.json +1 -1
- package/runtime/node_modules/@metro-labs/core/package.json +4 -1
- package/runtime/node_modules/@metro-labs/core/src/events.ts +1 -3
- package/runtime/node_modules/@metro-labs/core/src/ids.ts +3 -0
- package/runtime/node_modules/@metro-labs/core/src/secure-fs.ts +17 -0
- package/runtime/node_modules/@metro-labs/core/src/station-names.ts +0 -1
- package/runtime/node_modules/@metro-labs/core/src/stations/station-runtime.ts +2 -5
- package/runtime/node_modules/@metro-labs/core/src/stations/train-boot.ts +42 -0
- package/runtime/node_modules/@metro-labs/core/src/stations/train-events.ts +89 -0
- package/runtime/node_modules/@metro-labs/core/src/tickets.ts +37 -0
- package/runtime/node_modules/@metro-labs/daemon/src/agents/accounts-api.ts +11 -28
- package/runtime/node_modules/@metro-labs/daemon/src/agents/admin.ts +0 -9
- package/runtime/node_modules/@metro-labs/daemon/src/agents/api.ts +11 -91
- package/runtime/node_modules/@metro-labs/daemon/src/agents/bundle.ts +7 -8
- package/runtime/node_modules/@metro-labs/daemon/src/agents/file-admin.ts +14 -90
- package/runtime/node_modules/@metro-labs/daemon/src/agents/files.ts +1 -34
- package/runtime/node_modules/@metro-labs/daemon/src/agents/keys.ts +0 -5
- package/runtime/node_modules/@metro-labs/daemon/src/boot/boot.ts +4 -7
- package/runtime/node_modules/@metro-labs/daemon/src/channels/dedupe.ts +1 -13
- package/runtime/node_modules/@metro-labs/daemon/src/channels/relay.ts +1 -1
- package/runtime/node_modules/@metro-labs/daemon/src/claude/api.ts +5 -9
- package/runtime/node_modules/@metro-labs/daemon/src/claude/files.ts +2 -7
- package/runtime/node_modules/@metro-labs/daemon/src/claude/session-files.ts +1 -16
- package/runtime/node_modules/@metro-labs/daemon/src/claude/settings.ts +3 -20
- package/runtime/node_modules/@metro-labs/daemon/src/claude/setup.ts +15 -26
- package/runtime/node_modules/@metro-labs/daemon/src/claude/skills.ts +4 -10
- package/runtime/node_modules/@metro-labs/daemon/src/connectors/api.ts +26 -55
- package/runtime/node_modules/@metro-labs/daemon/src/connectors/oauth-client.ts +1 -8
- package/runtime/node_modules/@metro-labs/daemon/src/connectors/oauth-discovery.ts +1 -5
- package/runtime/node_modules/@metro-labs/daemon/src/connectors/oauth-routes.ts +8 -21
- package/runtime/node_modules/@metro-labs/daemon/src/connectors/oauth.ts +20 -9
- package/runtime/node_modules/@metro-labs/daemon/src/connectors/relay-target.ts +4 -14
- package/runtime/node_modules/@metro-labs/daemon/src/connectors/store.ts +75 -64
- package/runtime/node_modules/@metro-labs/daemon/src/connectors/tools.ts +4 -32
- package/runtime/node_modules/@metro-labs/daemon/src/connectors/url.ts +2 -24
- package/runtime/node_modules/@metro-labs/daemon/src/connectors/verify.ts +25 -20
- package/runtime/node_modules/@metro-labs/daemon/src/files/attach-serve.ts +3 -1
- package/runtime/node_modules/@metro-labs/daemon/src/files/upload-api.ts +3 -19
- package/runtime/node_modules/@metro-labs/daemon/src/gateway/bedrock.ts +15 -41
- package/runtime/node_modules/@metro-labs/daemon/src/gateway/codex-auth.ts +17 -40
- package/runtime/node_modules/@metro-labs/daemon/src/gateway/codex-device.ts +5 -5
- package/runtime/node_modules/@metro-labs/daemon/src/gateway/codex-stream.ts +17 -132
- package/runtime/node_modules/@metro-labs/daemon/src/gateway/codex-translate.ts +9 -18
- package/runtime/node_modules/@metro-labs/daemon/src/gateway/codex.ts +35 -140
- package/runtime/node_modules/@metro-labs/daemon/src/gateway/forward.ts +43 -2
- package/runtime/node_modules/@metro-labs/daemon/src/gateway/frames.ts +118 -0
- package/runtime/node_modules/@metro-labs/daemon/src/gateway/gateway.ts +5 -8
- package/runtime/node_modules/@metro-labs/daemon/src/gateway/gemini-auth.ts +18 -40
- package/runtime/node_modules/@metro-labs/daemon/src/gateway/gemini-setup.ts +15 -15
- package/runtime/node_modules/@metro-labs/daemon/src/gateway/gemini-stream.ts +14 -26
- package/runtime/node_modules/@metro-labs/daemon/src/gateway/gemini-translate.ts +12 -21
- package/runtime/node_modules/@metro-labs/daemon/src/gateway/gemini.ts +30 -133
- package/runtime/node_modules/@metro-labs/daemon/src/gateway/model-api.ts +0 -1
- package/runtime/node_modules/@metro-labs/daemon/src/gateway/model-store.ts +0 -1
- package/runtime/node_modules/@metro-labs/daemon/src/gateway/openrouter.ts +4 -4
- package/runtime/node_modules/@metro-labs/daemon/src/gateway/pkce.ts +36 -0
- package/runtime/node_modules/@metro-labs/daemon/src/gateway/provider-models.ts +5 -5
- package/runtime/node_modules/@metro-labs/daemon/src/gateway/subscription.ts +120 -0
- package/runtime/node_modules/@metro-labs/daemon/src/gateway/text.ts +15 -0
- package/runtime/node_modules/@metro-labs/daemon/src/mcp/accounts.ts +1 -1
- package/runtime/node_modules/@metro-labs/daemon/src/mcp/call-tools.ts +5 -0
- package/runtime/node_modules/@metro-labs/daemon/src/mcp/event-store.ts +14 -15
- package/runtime/node_modules/@metro-labs/daemon/src/mcp/index.ts +22 -71
- package/runtime/node_modules/@metro-labs/daemon/src/mcp/permission-relay.ts +4 -5
- package/runtime/node_modules/@metro-labs/daemon/src/mcp/request-identity.ts +5 -6
- package/runtime/node_modules/@metro-labs/daemon/src/mcp/session-slot.ts +48 -0
- package/runtime/node_modules/@metro-labs/daemon/src/mcp/session.ts +12 -31
- package/runtime/node_modules/@metro-labs/daemon/src/mcp/upload-tool.ts +1 -6
- package/runtime/node_modules/@metro-labs/daemon/src/net/tunnel.ts +2 -75
- package/runtime/node_modules/@metro-labs/daemon/src/routes/http.ts +3 -8
- package/runtime/node_modules/@metro-labs/daemon/src/routes/local-mode.ts +19 -29
- package/runtime/node_modules/@metro-labs/daemon/src/routes/threema-callback.ts +1 -1
- package/runtime/node_modules/@metro-labs/daemon/src/server/control.ts +15 -41
- package/runtime/node_modules/@metro-labs/daemon/src/server/machine.ts +3 -25
- package/runtime/node_modules/@metro-labs/daemon/src/server/owner.ts +3 -25
- package/runtime/node_modules/@metro-labs/daemon/src/server/update.ts +7 -34
- package/runtime/node_modules/@metro-labs/daemon/src/stations/attach-session.ts +1 -6
- package/runtime/node_modules/@metro-labs/daemon/src/stations/materialize.ts +1 -13
- package/runtime/node_modules/@metro-labs/daemon/src/stations/webhook-endpoints.ts +51 -0
- package/runtime/node_modules/@metro-labs/daemon/src/terminal/api.ts +8 -31
- package/runtime/node_modules/@metro-labs/daemon/src/terminal/tickets.ts +6 -31
- package/runtime/node_modules/@metro-labs/discord-bot/package.json +1 -3
- package/runtime/node_modules/@metro-labs/discord-bot/src/accounts.ts +0 -1
- package/runtime/node_modules/@metro-labs/discord-bot/src/actions.ts +0 -8
- package/runtime/node_modules/@metro-labs/discord-bot/src/format.ts +7 -66
- package/runtime/node_modules/@metro-labs/discord-bot/src/index.ts +5 -10
- package/runtime/node_modules/@metro-labs/discord-bot/src/wire.ts +0 -4
- package/runtime/node_modules/@metro-labs/http/src/api-http.ts +39 -7
- package/runtime/node_modules/@metro-labs/telegram/src/actions.ts +0 -1
- package/runtime/node_modules/@metro-labs/telegram/src/format.ts +0 -48
- package/runtime/node_modules/@metro-labs/telegram/src/inbound.ts +11 -33
- package/runtime/node_modules/@metro-labs/telegram/src/index.ts +4 -43
- package/runtime/node_modules/@metro-labs/telegram/src/types.ts +0 -1
- package/runtime/node_modules/@metro-labs/telegram-bot/src/accounts.ts +0 -1
- package/runtime/node_modules/@metro-labs/telegram-bot/src/actions.ts +0 -8
- package/runtime/node_modules/@metro-labs/telegram-bot/src/format.ts +9 -50
- package/runtime/node_modules/@metro-labs/telegram-bot/src/index.ts +7 -13
- package/runtime/node_modules/@metro-labs/telegram-bot/src/media-actions.ts +3 -2
- package/runtime/node_modules/@metro-labs/telegram-bot/src/wire.ts +0 -2
- package/runtime/node_modules/@metro-labs/threema/src/accounts.ts +0 -1
- package/runtime/node_modules/@metro-labs/threema/src/actions.ts +0 -1
- package/runtime/node_modules/@metro-labs/threema/src/files.ts +8 -43
- package/runtime/node_modules/@metro-labs/threema/src/format.ts +3 -16
- package/runtime/node_modules/@metro-labs/threema/src/inbound.ts +5 -6
- package/runtime/node_modules/@metro-labs/threema/src/index.ts +2 -7
- package/runtime/node_modules/@metro-labs/whatsapp/src/actions.ts +0 -1
- package/runtime/node_modules/@metro-labs/whatsapp/src/format.ts +1 -56
- package/runtime/node_modules/@metro-labs/whatsapp/src/inbound.ts +10 -27
- package/runtime/node_modules/@metro-labs/whatsapp/src/index.ts +4 -37
- package/runtime/node_modules/@metro-labs/whatsapp/src/types.ts +0 -1
- package/runtime/node_modules/@metro-labs/whatsapp/src/wire.ts +0 -4
- package/runtime/node_modules/@metro-labs/xmtp/src/accounts.ts +1 -2
- package/runtime/node_modules/@metro-labs/xmtp/src/actions-conv.ts +0 -27
- package/runtime/node_modules/@metro-labs/xmtp/src/actions.ts +0 -10
- package/runtime/node_modules/@metro-labs/xmtp/src/emit-payloads.ts +4 -23
- package/runtime/node_modules/@metro-labs/xmtp/src/emit.ts +3 -5
- package/runtime/node_modules/@metro-labs/xmtp/src/index.ts +4 -8
- package/runtime/node_modules/@metro-labs/xmtp/src/tools-handlers.ts +0 -47
- package/runtime/node_modules/@metro-labs/xmtp/src/tools.ts +23 -43
- package/runtime/node_modules/@metro-labs/xmtp/src/transcribe.ts +3 -5
- package/runtime/node_modules/@metro-labs/xmtp/src/wire.ts +0 -2
- package/runtime/runtime.json +1 -1
- package/runtime/stations.json +1 -3
- package/runtime/node_modules/@metro-labs/daemon/src/connectors/model.ts +0 -69
- package/runtime/node_modules/@metro-labs/daemon/src/connectors/oauth-pending.ts +0 -65
- package/runtime/node_modules/@metro-labs/daemon/src/mcp/channel-owner.ts +0 -29
- package/runtime/node_modules/@metro-labs/daemon/src/mcp/frame-scope.ts +0 -21
- package/runtime/node_modules/@metro-labs/daemon/src/mcp/session-registry.ts +0 -172
- package/runtime/node_modules/@metro-labs/daemon/src/mcp/session-route.ts +0 -43
- package/runtime/node_modules/@metro-labs/discord-bot/src/voice-speak.ts +0 -195
- package/runtime/node_modules/@metro-labs/discord-bot/src/voice-transcribe.ts +0 -292
- package/runtime/node_modules/@metro-labs/discord-bot/src/voice.ts +0 -197
- package/runtime/node_modules/@metro-labs/xmtp/src/emit-core.ts +0 -63
|
@@ -23,7 +23,6 @@ export const loadedAgentOf = (bundle: AgentBundle): LoadedAgent => ({
|
|
|
23
23
|
name: bundle.agent.name,
|
|
24
24
|
key: null,
|
|
25
25
|
accounts: bundle.agent.stations,
|
|
26
|
-
connectors: [],
|
|
27
26
|
});
|
|
28
27
|
|
|
29
28
|
export interface RestoredAgent {
|
|
@@ -36,8 +35,8 @@ export interface RestoredAgent {
|
|
|
36
35
|
export type ImportMode = 'append' | 'overwrite';
|
|
37
36
|
|
|
38
37
|
export interface BundleApiDeps {
|
|
39
|
-
bundle: (
|
|
40
|
-
restore: (
|
|
38
|
+
bundle: (agentId: string) => Promise<AgentBundle>;
|
|
39
|
+
restore: (bundle: AgentBundle, mode: ImportMode) => Promise<RestoredAgent>;
|
|
41
40
|
}
|
|
42
41
|
|
|
43
42
|
export function parseMode(raw: unknown): ImportMode {
|
|
@@ -62,7 +61,7 @@ function connectorOf(raw: unknown): LoadedConnector {
|
|
|
62
61
|
if (!isRecord(raw) || typeof raw.id !== 'string' || typeof raw.name !== 'string' || typeof raw.url !== 'string')
|
|
63
62
|
throw bad('a connector is missing its id, name or url');
|
|
64
63
|
if (!isRecord(raw.config)) throw bad('a connector has no config');
|
|
65
|
-
return { id: raw.id, name: raw.name, url: raw.url,
|
|
64
|
+
return { id: raw.id, name: raw.name, url: raw.url, config: raw.config };
|
|
66
65
|
}
|
|
67
66
|
|
|
68
67
|
function agentOf(raw: unknown): AgentBundle['agent'] {
|
|
@@ -79,14 +78,14 @@ export function parseBundle(raw: unknown): AgentBundle {
|
|
|
79
78
|
return { version: 1, agent: agentOf(raw.agent), connectors: connectors.map(connectorOf) };
|
|
80
79
|
}
|
|
81
80
|
|
|
82
|
-
async function answer(req: IncomingMessage, deps: BundleApiDeps,
|
|
81
|
+
async function answer(req: IncomingMessage, deps: BundleApiDeps, path: string): Promise<unknown> {
|
|
83
82
|
if (path === RESTORE_PATH) {
|
|
84
83
|
const body: unknown = await readJsonBody(req, BUNDLE_MAX);
|
|
85
|
-
return deps.restore(
|
|
84
|
+
return deps.restore(parseBundle(body), parseMode(body));
|
|
86
85
|
}
|
|
87
86
|
const id = parseId(path.slice(AGENTS.length + 1).split('/')[0] ?? '');
|
|
88
87
|
if (id === null) throw new ApiError('no such agent', 404);
|
|
89
|
-
return deps.bundle(
|
|
88
|
+
return deps.bundle(id);
|
|
90
89
|
}
|
|
91
90
|
|
|
92
91
|
export function handleBundleRequest(req: IncomingMessage, res: ServerResponse, deps: BundleApiDeps): boolean {
|
|
@@ -106,7 +105,7 @@ export function handleBundleRequest(req: IncomingMessage, res: ServerResponse, d
|
|
|
106
105
|
.then((session) => {
|
|
107
106
|
if (!session) throw new ApiError('unauthorized', 401);
|
|
108
107
|
requireAdmin(session);
|
|
109
|
-
return answer(req, deps,
|
|
108
|
+
return answer(req, deps, path);
|
|
110
109
|
})
|
|
111
110
|
.then((body) => {
|
|
112
111
|
sendJson(req, res, isRestore ? 201 : 200, body);
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { readFileSync
|
|
1
|
+
import { readFileSync } from 'node:fs';
|
|
2
2
|
import { join } from 'node:path';
|
|
3
3
|
import { ApiError } from '@metro-labs/http/api-error';
|
|
4
4
|
import { isOrganizationId } from '@metro-labs/http/workos-token';
|
|
@@ -9,8 +9,6 @@ import {
|
|
|
9
9
|
normalizeAgentName,
|
|
10
10
|
type AgentSummary,
|
|
11
11
|
type CreatedAgent,
|
|
12
|
-
type DeletedAgent,
|
|
13
|
-
type OwnedAgent,
|
|
14
12
|
} from './admin.js';
|
|
15
13
|
import type { AccountRef } from './account-attach.js';
|
|
16
14
|
import {
|
|
@@ -23,12 +21,11 @@ import {
|
|
|
23
21
|
type AgentFile,
|
|
24
22
|
} from './files.js';
|
|
25
23
|
import { newId } from '@metro-labs/core/ids';
|
|
26
|
-
import { registerKey
|
|
24
|
+
import { registerKey } from './keys.js';
|
|
27
25
|
import { MOVABLE_STATIONS, type LoadedAgent } from '../stations/materialize.js';
|
|
28
26
|
import type { StationName } from '@metro-labs/core/station-names';
|
|
29
27
|
import { normalizeAddress } from '@metro-labs/core/address';
|
|
30
28
|
|
|
31
|
-
export const LOCAL_PROJECT_ID = 'localdaemon';
|
|
32
29
|
const OWNER_FILE = '.owner';
|
|
33
30
|
|
|
34
31
|
interface Stored {
|
|
@@ -59,12 +56,6 @@ export function setLocalOwner(raw: string, dir = agentsDir()): string {
|
|
|
59
56
|
return owner;
|
|
60
57
|
}
|
|
61
58
|
|
|
62
|
-
|
|
63
|
-
function isOwner(subject: string, dir: string): boolean {
|
|
64
|
-
const owner = localOwner(dir);
|
|
65
|
-
return owner !== null && owner === parseOwner(subject);
|
|
66
|
-
}
|
|
67
|
-
|
|
68
59
|
export function storedAgents(dir: string): Stored[] {
|
|
69
60
|
return listAgentFiles(dir).map((path) => ({ path, file: readAgentFile(path) }));
|
|
70
61
|
}
|
|
@@ -73,34 +64,14 @@ function save(stored: Stored): void {
|
|
|
73
64
|
writeSecure(stored.path, `${JSON.stringify(stored.file, null, 2)}\n`);
|
|
74
65
|
}
|
|
75
66
|
|
|
76
|
-
function
|
|
77
|
-
if (!isOwner(subject, dir)) throw missing();
|
|
67
|
+
function agentOrThrow(id: string, dir: string): Stored {
|
|
78
68
|
const found = storedAgents(dir).find((s) => s.file.id === id);
|
|
79
69
|
if (found === undefined) throw missing();
|
|
80
70
|
return found;
|
|
81
71
|
}
|
|
82
72
|
|
|
83
|
-
export async function
|
|
84
|
-
|
|
85
|
-
id: string,
|
|
86
|
-
dir = agentsDir(),
|
|
87
|
-
): Promise<{ agent: OwnedAgent }> {
|
|
88
|
-
const { file } = ownedOrThrow(subject, id, dir);
|
|
89
|
-
return Promise.resolve({ agent: { id: file.id, name: file.name } });
|
|
90
|
-
}
|
|
91
|
-
|
|
92
|
-
export async function localListAgents(
|
|
93
|
-
subject: string,
|
|
94
|
-
project: string,
|
|
95
|
-
dir = agentsDir(),
|
|
96
|
-
): Promise<AgentSummary[]> {
|
|
97
|
-
if (project !== LOCAL_PROJECT_ID || !isOwner(subject, dir))
|
|
98
|
-
throw new AgentAdminError('no such project', 404);
|
|
99
|
-
return Promise.resolve(
|
|
100
|
-
storedAgents(dir)
|
|
101
|
-
.map(({ file }) => ({ id: file.id, name: file.name, owned: true, key: file.key }))
|
|
102
|
-
.sort((a, b) => a.id.localeCompare(b.id)),
|
|
103
|
-
);
|
|
73
|
+
export async function localListAgents(dir = agentsDir()): Promise<AgentSummary[]> {
|
|
74
|
+
return Promise.resolve(storedAgents(dir).map(({ file }) => ({ id: file.id, name: file.name })));
|
|
104
75
|
}
|
|
105
76
|
|
|
106
77
|
function freshId(taken: Set<string>): string {
|
|
@@ -111,14 +82,7 @@ function freshId(taken: Set<string>): string {
|
|
|
111
82
|
throw new AgentAdminError('could not allocate a free id', 500);
|
|
112
83
|
}
|
|
113
84
|
|
|
114
|
-
export async function localCreateAgent(
|
|
115
|
-
subject: string,
|
|
116
|
-
project: string,
|
|
117
|
-
rawName?: string,
|
|
118
|
-
dir = agentsDir(),
|
|
119
|
-
): Promise<CreatedAgent> {
|
|
120
|
-
if (project !== LOCAL_PROJECT_ID || !isOwner(subject, dir))
|
|
121
|
-
throw new AgentAdminError('no such project', 404);
|
|
85
|
+
export async function localCreateAgent(rawName?: string, dir = agentsDir()): Promise<CreatedAgent> {
|
|
122
86
|
const name = rawName === undefined ? null : normalizeAgentName(rawName);
|
|
123
87
|
const existing = storedAgents(dir);
|
|
124
88
|
if (existing.length > 0) throw new AgentAdminError('this box already has its agent', 409);
|
|
@@ -127,7 +91,7 @@ export async function localCreateAgent(
|
|
|
127
91
|
ensureSecureDir(dir);
|
|
128
92
|
save({
|
|
129
93
|
path: agentFilePath(dir),
|
|
130
|
-
file: { version: 1, id, name, key,
|
|
94
|
+
file: { version: 1, id, name, key, stations: [] },
|
|
131
95
|
});
|
|
132
96
|
registerKey(key, id);
|
|
133
97
|
return Promise.resolve({ id, name, key });
|
|
@@ -137,30 +101,11 @@ export type Ensured = 'created' | 'present' | 'no-owner';
|
|
|
137
101
|
|
|
138
102
|
export async function ensureLocalAgent(dir = agentsDir()): Promise<Ensured> {
|
|
139
103
|
if (storedAgents(dir).length > 0) return 'present';
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
await localCreateAgent(owner, LOCAL_PROJECT_ID, undefined, dir);
|
|
104
|
+
if (localOwner(dir) === null) return 'no-owner';
|
|
105
|
+
await localCreateAgent(undefined, dir);
|
|
143
106
|
return 'created';
|
|
144
107
|
}
|
|
145
108
|
|
|
146
|
-
export async function localDeleteAgent(
|
|
147
|
-
subject: string,
|
|
148
|
-
id: string,
|
|
149
|
-
dir = agentsDir(),
|
|
150
|
-
): Promise<DeletedAgent> {
|
|
151
|
-
const stored = ownedOrThrow(subject, id, dir);
|
|
152
|
-
const attached = stored.file.stations.length;
|
|
153
|
-
if (attached > 0)
|
|
154
|
-
throw new AgentAdminError(
|
|
155
|
-
`the agent still has ${String(attached)} station account(s) attached — detach them first`,
|
|
156
|
-
409,
|
|
157
|
-
);
|
|
158
|
-
rmSync(stored.path);
|
|
159
|
-
removeIfEmpty(join(stored.path, '..'));
|
|
160
|
-
unregisterAgentKey(id);
|
|
161
|
-
return Promise.resolve({ id, name: stored.file.name });
|
|
162
|
-
}
|
|
163
|
-
|
|
164
109
|
function assertUnclaimed(existing: Stored[], agent: LoadedAgent): void {
|
|
165
110
|
for (const { file } of existing) {
|
|
166
111
|
if (file.id === agent.id)
|
|
@@ -170,10 +115,6 @@ function assertUnclaimed(existing: Stored[], agent: LoadedAgent): void {
|
|
|
170
115
|
}
|
|
171
116
|
}
|
|
172
117
|
|
|
173
|
-
export function assertLocalOwner(subject: string, dir = agentsDir()): void {
|
|
174
|
-
if (!isOwner(subject, dir)) throw new AgentAdminError('no such project', 404);
|
|
175
|
-
}
|
|
176
|
-
|
|
177
118
|
function assertImportable(agent: LoadedAgent): void {
|
|
178
119
|
const immovable = agent.accounts.find((a) => !MOVABLE_STATIONS.has(a.station));
|
|
179
120
|
if (immovable !== undefined)
|
|
@@ -200,16 +141,14 @@ function importTarget(dir: string, agent: LoadedAgent): ImportTarget {
|
|
|
200
141
|
export type ImportMode = 'append' | 'overwrite';
|
|
201
142
|
|
|
202
143
|
export async function localImportAgent(
|
|
203
|
-
subject: string,
|
|
204
144
|
agent: LoadedAgent,
|
|
205
145
|
dir = agentsDir(),
|
|
206
146
|
mode: ImportMode = 'overwrite',
|
|
207
147
|
): Promise<{ id: string; name: string; key: string; stations: number }> {
|
|
208
|
-
assertLocalOwner(subject, dir);
|
|
209
148
|
assertImportable(agent);
|
|
210
149
|
const { path, previous } = importTarget(dir, agent);
|
|
211
150
|
const key = previous?.key ?? agent.key ?? newApiKey();
|
|
212
|
-
const file = fileFor({ ...agent, key },
|
|
151
|
+
const file = fileFor({ ...agent, key }, path, previous, mode);
|
|
213
152
|
ensureSecureDir(join(path, '..'));
|
|
214
153
|
save({ path, file });
|
|
215
154
|
registerKey(key, agent.id);
|
|
@@ -237,7 +176,6 @@ function mergedStations(
|
|
|
237
176
|
|
|
238
177
|
function fileFor(
|
|
239
178
|
agent: LoadedAgent,
|
|
240
|
-
owner: string | null,
|
|
241
179
|
path: string,
|
|
242
180
|
previous: AgentFile | undefined,
|
|
243
181
|
mode: ImportMode,
|
|
@@ -249,9 +187,7 @@ function fileFor(
|
|
|
249
187
|
id: agent.id,
|
|
250
188
|
name: previous?.name ?? null,
|
|
251
189
|
key: agent.key,
|
|
252
|
-
owner,
|
|
253
190
|
stations: mergedStations(agent, previous, mode),
|
|
254
|
-
connectors: previous?.connectors ?? [],
|
|
255
191
|
}),
|
|
256
192
|
path,
|
|
257
193
|
);
|
|
@@ -268,14 +204,6 @@ export function readLocalAgentFile(agentId: string, dir = agentsDir()): AgentFil
|
|
|
268
204
|
return found.file;
|
|
269
205
|
}
|
|
270
206
|
|
|
271
|
-
function removeIfEmpty(folder: string): void {
|
|
272
|
-
try {
|
|
273
|
-
rmdirSync(folder);
|
|
274
|
-
} catch {
|
|
275
|
-
return;
|
|
276
|
-
}
|
|
277
|
-
}
|
|
278
|
-
|
|
279
207
|
function assertTokenFree(all: Stored[], station: StationName, token: string): void {
|
|
280
208
|
const taken = all.some((s) =>
|
|
281
209
|
s.file.stations.some(
|
|
@@ -290,13 +218,12 @@ function assertTokenFree(all: Stored[], station: StationName, token: string): vo
|
|
|
290
218
|
}
|
|
291
219
|
|
|
292
220
|
export async function localAttachAccount(
|
|
293
|
-
subject: string,
|
|
294
221
|
agentId: string,
|
|
295
222
|
station: StationName,
|
|
296
223
|
config: Record<string, unknown>,
|
|
297
224
|
dir = agentsDir(),
|
|
298
225
|
): Promise<AccountRef> {
|
|
299
|
-
const stored =
|
|
226
|
+
const stored = agentOrThrow(agentId, dir);
|
|
300
227
|
if (!MOVABLE_STATIONS.has(station))
|
|
301
228
|
throw new AgentAdminError(
|
|
302
229
|
`a ${station} endpoint needs a public url and cannot live on a local daemon`,
|
|
@@ -312,14 +239,13 @@ export async function localAttachAccount(
|
|
|
312
239
|
}
|
|
313
240
|
|
|
314
241
|
export async function localSetAllowlist(
|
|
315
|
-
subject: string,
|
|
316
242
|
agentId: string,
|
|
317
243
|
station: StationName,
|
|
318
244
|
accountId: string,
|
|
319
245
|
allowlist: string[],
|
|
320
246
|
dir = agentsDir(),
|
|
321
247
|
): Promise<string[]> {
|
|
322
|
-
const stored =
|
|
248
|
+
const stored = agentOrThrow(agentId, dir);
|
|
323
249
|
const account = stored.file.stations.find((a) => a.station === station && a.id === accountId);
|
|
324
250
|
if (account === undefined) throw new AgentAdminError('no such account on this agent', 404);
|
|
325
251
|
account.allowlist = allowlist;
|
|
@@ -328,14 +254,13 @@ export async function localSetAllowlist(
|
|
|
328
254
|
}
|
|
329
255
|
|
|
330
256
|
export async function localSetAccountEnabled(
|
|
331
|
-
subject: string,
|
|
332
257
|
agentId: string,
|
|
333
258
|
station: StationName,
|
|
334
259
|
accountId: string,
|
|
335
260
|
enabled: boolean,
|
|
336
261
|
dir = agentsDir(),
|
|
337
262
|
): Promise<boolean> {
|
|
338
|
-
const stored =
|
|
263
|
+
const stored = agentOrThrow(agentId, dir);
|
|
339
264
|
const account = stored.file.stations.find((a) => a.station === station && a.id === accountId);
|
|
340
265
|
if (account === undefined) throw new AgentAdminError('no such account on this agent', 404);
|
|
341
266
|
account.enabled = enabled;
|
|
@@ -344,13 +269,12 @@ export async function localSetAccountEnabled(
|
|
|
344
269
|
}
|
|
345
270
|
|
|
346
271
|
export async function localDetachAccount(
|
|
347
|
-
subject: string,
|
|
348
272
|
agentId: string,
|
|
349
273
|
station: StationName,
|
|
350
274
|
accountId: string,
|
|
351
275
|
dir = agentsDir(),
|
|
352
276
|
): Promise<AccountRef> {
|
|
353
|
-
const stored =
|
|
277
|
+
const stored = agentOrThrow(agentId, dir);
|
|
354
278
|
const before = stored.file.stations.length;
|
|
355
279
|
stored.file.stations = stored.file.stations.filter(
|
|
356
280
|
(a) => !(a.station === station && a.id === accountId),
|
|
@@ -19,9 +19,7 @@ export interface AgentFile {
|
|
|
19
19
|
id: string;
|
|
20
20
|
name: string | null;
|
|
21
21
|
key: string | null;
|
|
22
|
-
owner: string | null;
|
|
23
22
|
stations: LoadedAccount[];
|
|
24
|
-
connectors: string[];
|
|
25
23
|
}
|
|
26
24
|
|
|
27
25
|
export const agentFilePath = (dir = agentsDir()): string => join(dir, AGENT_FILE);
|
|
@@ -33,8 +31,6 @@ export function agentsDir(): string {
|
|
|
33
31
|
: join(homedir(), '.metro', 'agents');
|
|
34
32
|
}
|
|
35
33
|
|
|
36
|
-
const OWNER_RE = /^(0x[0-9a-f]{40}|org_[A-Za-z0-9]{10,64})$/;
|
|
37
|
-
|
|
38
34
|
function fail(path: string, reason: string): never {
|
|
39
35
|
throw new AgentFileError(`${path}: ${reason}`);
|
|
40
36
|
}
|
|
@@ -74,9 +70,6 @@ function optionalMatch(
|
|
|
74
70
|
return value;
|
|
75
71
|
}
|
|
76
72
|
|
|
77
|
-
const connectorsOf = (raw: unknown): string[] =>
|
|
78
|
-
Array.isArray(raw) ? raw.filter((id): id is string => typeof id === 'string' && ID_RE.test(id)) : [];
|
|
79
|
-
|
|
80
73
|
function nameOf(raw: unknown, path: string): string | null {
|
|
81
74
|
if (raw === undefined || raw === null) return null;
|
|
82
75
|
if (typeof raw !== 'string' || !AGENT_NAME_RE.test(raw)) fail(path, 'name is not a valid agent name');
|
|
@@ -97,21 +90,13 @@ export function parseAgentFile(raw: string, path: string): AgentFile {
|
|
|
97
90
|
fail(path, 'id is not an 11-character id');
|
|
98
91
|
const label = nameOf(name, path);
|
|
99
92
|
const key = optionalMatch(parsed.key, KEY_RE, path, 'key is not an agent key');
|
|
100
|
-
const owner = optionalMatch(
|
|
101
|
-
parsed.owner,
|
|
102
|
-
OWNER_RE,
|
|
103
|
-
path,
|
|
104
|
-
'owner is neither an organization id nor a lowercase Ethereum address',
|
|
105
|
-
);
|
|
106
93
|
if (!Array.isArray(stations)) fail(path, 'stations is not a list');
|
|
107
94
|
return {
|
|
108
95
|
version: 1,
|
|
109
96
|
id,
|
|
110
97
|
name: label,
|
|
111
98
|
key,
|
|
112
|
-
owner,
|
|
113
99
|
stations: stations.map((s, i) => stationOf(s, path, i)),
|
|
114
|
-
connectors: connectorsOf(parsed.connectors),
|
|
115
100
|
};
|
|
116
101
|
}
|
|
117
102
|
|
|
@@ -155,26 +140,8 @@ export function migrateAgentLayout(dir = agentsDir()): 'moved' | 'kept' | 'none'
|
|
|
155
140
|
return 'moved';
|
|
156
141
|
}
|
|
157
142
|
|
|
158
|
-
function assertUnique(agents: AgentFile[], paths: string[]): void {
|
|
159
|
-
const ids = new Map<string, string>();
|
|
160
|
-
const keys = new Map<string, string>();
|
|
161
|
-
agents.forEach((agent, i) => {
|
|
162
|
-
const path = paths[i] ?? '';
|
|
163
|
-
const sameId = ids.get(agent.id);
|
|
164
|
-
if (sameId !== undefined) fail(path, `id ${agent.id} is already used by ${sameId}`);
|
|
165
|
-
ids.set(agent.id, path);
|
|
166
|
-
if (agent.key === null) return;
|
|
167
|
-
const sameKey = keys.get(agent.key);
|
|
168
|
-
if (sameKey !== undefined) fail(path, `its key is already used by ${sameKey}`);
|
|
169
|
-
keys.set(agent.key, path);
|
|
170
|
-
});
|
|
171
|
-
}
|
|
172
|
-
|
|
173
143
|
export function loadFileAgents(dir = agentsDir()): LoadedAgent[] {
|
|
174
|
-
|
|
175
|
-
const agents = paths.map(readAgentFile);
|
|
176
|
-
assertUnique(agents, paths);
|
|
177
|
-
return agents.map((agent) => ({
|
|
144
|
+
return listAgentFiles(dir).map(readAgentFile).map((agent) => ({
|
|
178
145
|
id: agent.id,
|
|
179
146
|
name: agent.name ?? agent.id,
|
|
180
147
|
key: agent.key,
|
|
@@ -25,11 +25,6 @@ export function registerKey(key: string, agentId: string): void {
|
|
|
25
25
|
agentIdByKeyHash.set(digest(key), agentId);
|
|
26
26
|
}
|
|
27
27
|
|
|
28
|
-
export function unregisterAgentKey(agentId: string): void {
|
|
29
|
-
for (const [hash, id] of agentIdByKeyHash)
|
|
30
|
-
if (id === agentId) agentIdByKeyHash.delete(hash);
|
|
31
|
-
}
|
|
32
|
-
|
|
33
28
|
export function agentIdForKey(token: string): string | undefined {
|
|
34
29
|
if (token === '') return undefined;
|
|
35
30
|
return agentIdByKeyHash.get(digest(token));
|
|
@@ -6,7 +6,8 @@ import { errMsg, log, logFatalSync } from '@metro-labs/core/log';
|
|
|
6
6
|
import { acquireLock, STATE_DIR, trainsDir } from './paths.js';
|
|
7
7
|
import { installCrashGuard, markDaemonReady } from './crash-guard.js';
|
|
8
8
|
import { METRO_VERSION } from '@metro-labs/core/version';
|
|
9
|
-
import { funnelDriver, Tunnel, tunnelWanted
|
|
9
|
+
import { funnelDriver, Tunnel, tunnelWanted } from '../net/tunnel.js';
|
|
10
|
+
import { webhookPort } from '../files/attach-serve.js';
|
|
10
11
|
import {
|
|
11
12
|
localConnectHint,
|
|
12
13
|
publicConnectHint,
|
|
@@ -31,10 +32,7 @@ import { migrateAgentLayout } from '../agents/files.js';
|
|
|
31
32
|
import { ensureStationDeps } from '../stations/runtime-deps.js';
|
|
32
33
|
import { localSessionApis } from '../routes/local-mode.js';
|
|
33
34
|
import type { SessionApis } from '../routes/session-apis.js';
|
|
34
|
-
import {
|
|
35
|
-
agentLiveness,
|
|
36
|
-
createMetroMcp,
|
|
37
|
-
} from '../mcp/index.js';
|
|
35
|
+
import { createMetroMcp } from '../mcp/index.js';
|
|
38
36
|
import { gatherAccountsForAgents } from '../mcp/accounts.js';
|
|
39
37
|
import {
|
|
40
38
|
accountStationCapabilities,
|
|
@@ -106,7 +104,6 @@ function sessionApis(): SessionApis {
|
|
|
106
104
|
},
|
|
107
105
|
gatherAccounts: gatherAccountsForAgents,
|
|
108
106
|
capabilities: accountStationCapabilities,
|
|
109
|
-
liveness: agentLiveness,
|
|
110
107
|
prepareAccount,
|
|
111
108
|
});
|
|
112
109
|
}
|
|
@@ -117,7 +114,7 @@ async function main(): Promise<void> {
|
|
|
117
114
|
installBearerSessions(agentsDir(), localOwner);
|
|
118
115
|
migrateAgentLayout();
|
|
119
116
|
log.info({ agent: await ensureLocalAgent() }, 'local daemon: agent');
|
|
120
|
-
await materializeFrom(fileSource
|
|
117
|
+
await materializeFrom(fileSource);
|
|
121
118
|
supervisor.start();
|
|
122
119
|
const metroMcp = await createMetroMcp();
|
|
123
120
|
webhookServer = await startWebhookServer(
|
|
@@ -1,24 +1,12 @@
|
|
|
1
|
-
import { accountFromLine, agentIdForAccount } from '../agents/map.js';
|
|
2
|
-
|
|
3
1
|
const accountStrippedLine = (line: string): string => {
|
|
4
2
|
const parts = line.split('/');
|
|
5
3
|
if (parts.length < 5) return line;
|
|
6
4
|
return [parts[0], parts[1], parts[2], ...parts.slice(4)].join('/');
|
|
7
5
|
};
|
|
8
6
|
|
|
9
|
-
export const dedupeOwner = (line: string): string => {
|
|
10
|
-
const acct = accountFromLine(line);
|
|
11
|
-
if (!acct) return 'unowned';
|
|
12
|
-
const agentId = agentIdForAccount(acct.station, acct.accountId);
|
|
13
|
-
return agentId === undefined
|
|
14
|
-
? `account:${acct.accountId}`
|
|
15
|
-
: `agent:${agentId}`;
|
|
16
|
-
};
|
|
17
|
-
|
|
18
7
|
export const dedupeKey = (
|
|
19
8
|
station: string,
|
|
20
9
|
line: string,
|
|
21
10
|
kind: string,
|
|
22
11
|
messageId: string,
|
|
23
|
-
): string =>
|
|
24
|
-
`${dedupeOwner(line)} ${station} ${accountStrippedLine(line)} ${kind} ${messageId}`;
|
|
12
|
+
): string => `${station} ${accountStrippedLine(line)} ${kind} ${messageId}`;
|
|
@@ -83,7 +83,7 @@ export class ChannelRelay {
|
|
|
83
83
|
private withhold(event: MetroEvent, busSeq: number): void {
|
|
84
84
|
this.ledger.delivered.delete(busSeq);
|
|
85
85
|
this.deps.log(
|
|
86
|
-
'relay: withheld (line outside the
|
|
86
|
+
'relay: withheld (no stream, or line outside the agent scope)',
|
|
87
87
|
'busSeq',
|
|
88
88
|
busSeq,
|
|
89
89
|
'line',
|
|
@@ -43,7 +43,7 @@ import {
|
|
|
43
43
|
type SessionDeps,
|
|
44
44
|
} from './session.js';
|
|
45
45
|
import { claudeVersion, updateClaude, type VersionDeps } from './version.js';
|
|
46
|
-
import {
|
|
46
|
+
import { receiveSessionFile, sessionFilePath } from './session-files.js';
|
|
47
47
|
import { createReadStream } from 'node:fs';
|
|
48
48
|
import { pipeline } from 'node:stream/promises';
|
|
49
49
|
import {
|
|
@@ -60,13 +60,11 @@ import {
|
|
|
60
60
|
|
|
61
61
|
const PREFIX = '/api/claude';
|
|
62
62
|
const BODY_MAX = SETTINGS_MAX + 4096;
|
|
63
|
-
const SESSION_BODY_MAX = SESSION_FILE_MAX + 4096;
|
|
64
63
|
const WRITABLE = new Set(['GET', 'DELETE', 'PUT', 'POST']);
|
|
65
64
|
const PAGE = 100;
|
|
66
65
|
const PAGE_MAX = 500;
|
|
67
66
|
|
|
68
67
|
export interface ClaudeApiDeps {
|
|
69
|
-
authorize: (subject: string) => void;
|
|
70
68
|
dir?: () => string;
|
|
71
69
|
login?: LoginDeps;
|
|
72
70
|
session?: SessionDeps;
|
|
@@ -98,7 +96,6 @@ const COLLECTIONS: Record<string, Handler> = {
|
|
|
98
96
|
|
|
99
97
|
const ITEMS: Record<string, Handler> = {
|
|
100
98
|
sessions: (query, dir, id) => {
|
|
101
|
-
if (query.get('raw') === '1') return { id, text: readSessionFile(projectOf(query), id, dir) };
|
|
102
99
|
const { offset, limit } = pageOf(query);
|
|
103
100
|
return readTranscript(projectOf(query), id, offset, limit, dir);
|
|
104
101
|
},
|
|
@@ -111,7 +108,7 @@ const parts = (path: string): string[] => path.slice(PREFIX.length + 1).split('/
|
|
|
111
108
|
const seenIn = (body: Record<string, unknown>): string | null | undefined =>
|
|
112
109
|
'seenAt' in body ? (typeof body.seenAt === 'string' ? body.seenAt : null) : undefined;
|
|
113
110
|
|
|
114
|
-
const WRITE_HEADS = new Set(['settings', 'skills', 'memory'
|
|
111
|
+
const WRITE_HEADS = new Set(['settings', 'skills', 'memory']);
|
|
115
112
|
|
|
116
113
|
interface Write {
|
|
117
114
|
text: string;
|
|
@@ -121,7 +118,6 @@ interface Write {
|
|
|
121
118
|
|
|
122
119
|
function writeItem(head: string, item: string, write: Write, search: string, dir: string): unknown {
|
|
123
120
|
if (head === 'skills') return writeClaudeSkill(decodeURIComponent(item), write.text, write.seen, dir);
|
|
124
|
-
if (head === 'sessions') return writeSessionFile(projectOf(new URLSearchParams(search)), item, write.text, dir);
|
|
125
121
|
if (head === 'memory') return writeMemoryFile(projectOf(new URLSearchParams(search)), decodeURIComponent(item), write.text, dir, write.modifiedAt);
|
|
126
122
|
return writeClaudeSettings(item, write.text, write.seen, dir);
|
|
127
123
|
}
|
|
@@ -129,7 +125,7 @@ function writeItem(head: string, item: string, write: Write, search: string, dir
|
|
|
129
125
|
async function writeAnswer(req: IncomingMessage, path: string, search: string, dir: string): Promise<unknown> {
|
|
130
126
|
const [head = '', item = ''] = parts(path);
|
|
131
127
|
if (!WRITE_HEADS.has(head) || item === '') throw new ApiError('method not allowed', 405);
|
|
132
|
-
const body = await readJsonBody(req,
|
|
128
|
+
const body = await readJsonBody(req, BODY_MAX);
|
|
133
129
|
if (!isRecord(body) || typeof body.text !== 'string') throw new ApiError('text is required', 400);
|
|
134
130
|
const modifiedAt = typeof body.modifiedAt === 'string' ? body.modifiedAt : undefined;
|
|
135
131
|
return writeItem(head, item, { text: body.text, seen: seenIn(body), modifiedAt }, search, dir);
|
|
@@ -315,7 +311,8 @@ async function streamed(req: IncomingMessage, res: ServerResponse, path: string,
|
|
|
315
311
|
});
|
|
316
312
|
return true;
|
|
317
313
|
}
|
|
318
|
-
if (req.method === 'PUT'
|
|
314
|
+
if (req.method === 'PUT') {
|
|
315
|
+
if ((req.headers['content-type'] ?? '').includes('json')) throw new ApiError('send the session file as text/plain', 415);
|
|
319
316
|
sendJson(req, res, 200, await receiveSessionFile(projectOf(query), item, req, dir));
|
|
320
317
|
return true;
|
|
321
318
|
}
|
|
@@ -340,7 +337,6 @@ export function handleClaudeRequest(
|
|
|
340
337
|
apiSession(req)
|
|
341
338
|
.then(async (session) => {
|
|
342
339
|
if (!session) throw new ApiError('unauthorized', 401);
|
|
343
|
-
deps.authorize(session.subject);
|
|
344
340
|
if (req.method !== 'GET' && ADMIN_ONLY.test(path)) requireAdmin(session);
|
|
345
341
|
if (await streamed(req, res, path, search, (deps.dir ?? claudeDir)())) return;
|
|
346
342
|
sendJson(req, res, 200, await routed(req, path, search, deps));
|
|
@@ -2,21 +2,19 @@ import {
|
|
|
2
2
|
closeSync,
|
|
3
3
|
createReadStream,
|
|
4
4
|
existsSync,
|
|
5
|
-
mkdirSync,
|
|
6
5
|
openSync,
|
|
7
6
|
readdirSync,
|
|
8
7
|
readSync,
|
|
9
|
-
renameSync,
|
|
10
8
|
rmSync,
|
|
11
9
|
statSync,
|
|
12
10
|
utimesSync,
|
|
13
|
-
writeFileSync,
|
|
14
11
|
} from 'node:fs';
|
|
15
12
|
import { homedir } from 'node:os';
|
|
16
13
|
import { join } from 'node:path';
|
|
17
14
|
import { createInterface } from 'node:readline';
|
|
18
15
|
import { ApiError } from '@metro-labs/http/api-error';
|
|
19
16
|
import { isRecord } from '@metro-labs/core/is-record';
|
|
17
|
+
import { writeAtomic } from '@metro-labs/core/secure-fs';
|
|
20
18
|
|
|
21
19
|
export const PROJECT_RE = /^[A-Za-z0-9._-]{1,200}$/;
|
|
22
20
|
export const SESSION_RE = /^[A-Za-z0-9][A-Za-z0-9-]{7,63}$/;
|
|
@@ -340,11 +338,8 @@ export function writeMemoryFile(project: string, name: string, text: string, dir
|
|
|
340
338
|
throw new ApiError(`a memory file is at most ${String(MEMORY_MAX)} bytes`, 400);
|
|
341
339
|
const folder = memoryDir(project, dir);
|
|
342
340
|
const file = safeName(name, MEMORY_RE, 'memory file name');
|
|
343
|
-
mkdirSync(folder, { recursive: true });
|
|
344
341
|
const path = join(folder, file);
|
|
345
|
-
|
|
346
|
-
writeFileSync(tmp, text, { mode: MEMORY_MODE });
|
|
347
|
-
renameSync(tmp, path);
|
|
342
|
+
writeAtomic(path, text, MEMORY_MODE);
|
|
348
343
|
const stamp = stampOf(modifiedAt);
|
|
349
344
|
if (stamp !== null) utimesSync(path, stamp, stamp);
|
|
350
345
|
const stat = statSync(path);
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { createWriteStream, existsSync, mkdirSync,
|
|
1
|
+
import { createWriteStream, existsSync, mkdirSync, renameSync, rmSync, statSync } from 'node:fs';
|
|
2
2
|
import type { Readable } from 'node:stream';
|
|
3
3
|
import { join } from 'node:path';
|
|
4
4
|
import { ApiError } from '@metro-labs/http/api-error';
|
|
@@ -23,10 +23,6 @@ export function sessionFilePath(project: string, session: string, dir = claudeDi
|
|
|
23
23
|
return path;
|
|
24
24
|
}
|
|
25
25
|
|
|
26
|
-
export function readSessionFile(project: string, session: string, dir = claudeDir()): string {
|
|
27
|
-
return readFileSync(sessionFilePath(project, session, dir), 'utf8');
|
|
28
|
-
}
|
|
29
|
-
|
|
30
26
|
const HEAD_BYTES = 64 * 1024;
|
|
31
27
|
|
|
32
28
|
async function spool(body: Readable, tmp: string): Promise<string> {
|
|
@@ -77,14 +73,3 @@ function firstLineIsJson(text: string): boolean {
|
|
|
77
73
|
return false;
|
|
78
74
|
}
|
|
79
75
|
}
|
|
80
|
-
|
|
81
|
-
export function writeSessionFile(project: string, session: string, text: string, dir = claudeDir()): SessionFile {
|
|
82
|
-
if (!firstLineIsJson(text)) throw new ApiError('a session file is JSON lines, one Claude Code entry per line', 400);
|
|
83
|
-
if (Buffer.byteLength(text, 'utf8') > SESSION_FILE_MAX) throw new ApiError(`a session file is at most ${String(SESSION_FILE_MAX)} bytes`, 400);
|
|
84
|
-
const path = transcriptPath(project, session, dir);
|
|
85
|
-
mkdirSync(join(path, '..'), { recursive: true });
|
|
86
|
-
const tmp = `${path}.metro-${String(process.pid)}`;
|
|
87
|
-
writeFileSync(tmp, text, { mode: FILE_MODE });
|
|
88
|
-
renameSync(tmp, path);
|
|
89
|
-
return { id: safeName(session, SESSION_RE, 'session id'), bytes: statSync(path).size };
|
|
90
|
-
}
|
|
@@ -1,14 +1,14 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
1
|
+
import { existsSync, readFileSync, realpathSync, statSync } from 'node:fs';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
3
|
import { ApiError } from '@metro-labs/http/api-error';
|
|
4
4
|
import { claudeDir, listClaudeProjects } from './files.js';
|
|
5
5
|
import { isRecord } from '@metro-labs/core/is-record';
|
|
6
6
|
import { errMsg } from '@metro-labs/core/log';
|
|
7
|
+
import { writeAtomic } from '@metro-labs/core/secure-fs';
|
|
7
8
|
|
|
8
9
|
export const SETTINGS_MAX = 256 * 1024;
|
|
9
10
|
const USER_ID = 'user';
|
|
10
11
|
const LOCAL_SUFFIX = '.local';
|
|
11
|
-
const DEFAULT_MODE = 0o644;
|
|
12
12
|
|
|
13
13
|
export type SettingsScope = 'user' | 'project' | 'local';
|
|
14
14
|
|
|
@@ -92,23 +92,6 @@ function assertSettingsJson(text: string): void {
|
|
|
92
92
|
if (!isRecord(parsed)) throw new ApiError('Claude Code settings must be a JSON object', 400);
|
|
93
93
|
}
|
|
94
94
|
|
|
95
|
-
function modeOf(path: string): number {
|
|
96
|
-
try {
|
|
97
|
-
return statSync(path).mode & 0o777;
|
|
98
|
-
} catch {
|
|
99
|
-
return DEFAULT_MODE;
|
|
100
|
-
}
|
|
101
|
-
}
|
|
102
|
-
|
|
103
|
-
function writeAtomic(path: string, text: string): void {
|
|
104
|
-
const mode = existsSync(path) ? modeOf(path) : DEFAULT_MODE;
|
|
105
|
-
mkdirSync(dirname(path), { recursive: true });
|
|
106
|
-
const tmp = `${path}.metro-${String(process.pid)}`;
|
|
107
|
-
writeFileSync(tmp, text, { mode });
|
|
108
|
-
chmodSync(tmp, mode);
|
|
109
|
-
renameSync(tmp, path);
|
|
110
|
-
}
|
|
111
|
-
|
|
112
95
|
export function writeClaudeSettings(
|
|
113
96
|
id: string,
|
|
114
97
|
text: string,
|