@monster-orange/den 0.3.0 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +184 -0
- package/docs/distributions.md +229 -8
- package/package.json +4 -1
- package/src/shared/agents/default-pack/be-dev.md +20 -0
- package/src/shared/agents/default-pack/devops.md +14 -0
- package/src/shared/agents/default-pack/fe-dev.md +20 -0
- package/src/shared/agents/default-pack/orchestrator.md +16 -0
- package/src/shared/agents/default-pack/planner.md +17 -0
- package/src/shared/agents/default-pack/reviewer.md +14 -0
- package/src/shared/agents/default-pack/ux-designer.md +15 -0
- package/src/shared/agents/den-claude.ts +173 -0
- package/src/shared/agents/generic.ts +52 -0
- package/src/shared/agents/personas.ts +218 -0
- package/src/shared/agents/records.ts +118 -0
- package/src/shared/agents/runtime.ts +192 -0
- package/src/shared/agents/runtimes.ts +47 -0
- package/src/shared/den-config.ts +116 -0
- package/src/shared/engine.ts +23 -0
- package/src/shared/environment-manager/workspaces.ts +19 -0
- package/src/shared/secret-env.ts +70 -0
- package/src/shared/sessions/client.ts +277 -0
- package/src/shared/sessions/den-pty.ts +97 -0
- package/src/shared/sessions/env.ts +79 -0
- package/src/shared/sessions/host.ts +667 -0
- package/src/shared/sessions/names.ts +85 -0
- package/src/shared/sessions/paths.ts +27 -0
- package/src/shared/sessions/protocol.ts +72 -0
- package/src/shared/sessions/pty.ts +141 -0
- package/src/shared/sessions/ring-buffer.ts +160 -0
- package/src/shared/sessions/types.ts +81 -0
- package/src/ui/cli/agents-command.ts +812 -0
- package/src/ui/cli/doctor-command.ts +138 -9
- package/src/ui/cli/env-probe.ts +266 -0
- package/src/ui/cli/flag-parser.ts +11 -3
- package/src/ui/cli/index.ts +34 -2
- package/src/ui/cli/sessions-command.ts +718 -0
- package/src/ui/mcp/scope.ts +123 -19
- package/src/ui/mcp/server.ts +27 -2
- package/src/ui/mcp/tools.ts +44 -5
- package/src/ui/server/audit.ts +35 -0
- package/src/ui/server/index.ts +7 -0
- package/src/ui/server/readonly-routes.ts +25 -2
- package/src/ui/server/workspace-routes.ts +3 -15
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The runtime contract (strategy kind `runtime`, AG spec §4): which CLI an
|
|
3
|
+
* agent runs. A runtime turns a persona and a workspace into argv, env and
|
|
4
|
+
* files (a per-agent MCP config), and says how to resume. It knows nothing
|
|
5
|
+
* about sessions, tmux or the browser; Den joins it to a session backend.
|
|
6
|
+
*
|
|
7
|
+
* Built-ins register here exactly as a runtime plugin would (AG-8a):
|
|
8
|
+
* den-claude and generic.
|
|
9
|
+
*/
|
|
10
|
+
import * as fs from "fs";
|
|
11
|
+
import * as path from "path";
|
|
12
|
+
import {
|
|
13
|
+
getStrategy,
|
|
14
|
+
registerStrategy,
|
|
15
|
+
strategyIds,
|
|
16
|
+
type StrategyEntry,
|
|
17
|
+
} from "../strategies/registry.js";
|
|
18
|
+
import type { Persona } from "./personas.js";
|
|
19
|
+
|
|
20
|
+
export const RUNTIME_KIND = "runtime";
|
|
21
|
+
|
|
22
|
+
/** The runtime an agent gets when neither it nor its persona names one. */
|
|
23
|
+
export const DEFAULT_RUNTIME_ID = "den-claude";
|
|
24
|
+
|
|
25
|
+
export interface RuntimeDetect {
|
|
26
|
+
available: boolean;
|
|
27
|
+
version?: string;
|
|
28
|
+
/** Why it is unavailable. */
|
|
29
|
+
why?: string;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** The MCP server an agent acts through: Den's, scoped to its workspace. */
|
|
33
|
+
export interface AgentMcp {
|
|
34
|
+
/** The server's name in an MCP config (the distribution's CLI). */
|
|
35
|
+
name: string;
|
|
36
|
+
/** `<cli> mcp --workspace <ws> --actor agent:<id> [--grant <ws>…]`. */
|
|
37
|
+
command: string[];
|
|
38
|
+
/** The server's own environment (PATH pinned), never a secret. */
|
|
39
|
+
env: Record<string, string>;
|
|
40
|
+
/** The workspace it is pinned to. */
|
|
41
|
+
scope: string;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export interface LaunchInput {
|
|
45
|
+
persona: Persona;
|
|
46
|
+
workspace: { name: string; dir: string };
|
|
47
|
+
agentId: string;
|
|
48
|
+
/** Where the agent works: the workspace's dir (a worktree from AG-9a). */
|
|
49
|
+
worktree: string;
|
|
50
|
+
model?: string;
|
|
51
|
+
mcp: AgentMcp;
|
|
52
|
+
/** A directory of this agent's own (0700) for the files a runtime writes. */
|
|
53
|
+
filesDir: string;
|
|
54
|
+
/**
|
|
55
|
+
* The caller's environment, for finding the runtime's CLI on PATH. The
|
|
56
|
+
* agent does not get it: it starts from an allowlist (sessions/env.ts),
|
|
57
|
+
* plus envPassthrough and LaunchOutput.env.
|
|
58
|
+
*/
|
|
59
|
+
env: NodeJS.ProcessEnv;
|
|
60
|
+
/**
|
|
61
|
+
* A conversation id to start under instead of a new one: a resume of a
|
|
62
|
+
* run that was never prompted starts afresh with the id it had.
|
|
63
|
+
*/
|
|
64
|
+
resumeToken?: string;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export interface LaunchOutput {
|
|
68
|
+
argv: string[];
|
|
69
|
+
/** Added to the session's environment (after the allowlist). */
|
|
70
|
+
env: Record<string, string>;
|
|
71
|
+
/** Absolute path (inside filesDir) → content; Den writes each 0600. */
|
|
72
|
+
files?: Record<string, string>;
|
|
73
|
+
/** What resume needs to continue this run (kept in the agent record). */
|
|
74
|
+
resumeToken?: string;
|
|
75
|
+
/** One line each, printed when the agent starts (never secret). */
|
|
76
|
+
warnings?: string[];
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export type ResumeInput = LaunchInput;
|
|
80
|
+
|
|
81
|
+
export interface Runtime {
|
|
82
|
+
/** "den-claude", "generic". */
|
|
83
|
+
id: string;
|
|
84
|
+
detect(env?: NodeJS.ProcessEnv): RuntimeDetect;
|
|
85
|
+
/** Throws LaunchError when this persona or this machine can't run it. */
|
|
86
|
+
launch(input: LaunchInput): LaunchOutput;
|
|
87
|
+
/** A new run that continues the last one; null when there is nothing to resume. */
|
|
88
|
+
resume?(input: ResumeInput): Omit<LaunchOutput, "resumeToken"> | null;
|
|
89
|
+
/** Where permission prompts surface (Q-AG-2). */
|
|
90
|
+
approvals?: "terminal" | "hook";
|
|
91
|
+
/**
|
|
92
|
+
* Caller variables this runtime needs beyond the allowlist (PATH, HOME,
|
|
93
|
+
* USER, LOGNAME, SHELL, TERM, COLORTERM, LANG, LC_*, TZ, TMPDIR). Named
|
|
94
|
+
* one by one; Claude Code's session plumbing never passes.
|
|
95
|
+
*/
|
|
96
|
+
envPassthrough?: readonly string[];
|
|
97
|
+
/**
|
|
98
|
+
* Names this runtime owns that must never reach a session Den starts
|
|
99
|
+
* (an exact name, or a prefix ending in `*`): den-claude's CLAUDECODE and
|
|
100
|
+
* CLAUDE_CODE_*. Den removes them after building any session's env,
|
|
101
|
+
* whatever put them there (sessions/env.ts).
|
|
102
|
+
*/
|
|
103
|
+
scrubEnv: readonly string[];
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** A runtime cannot launch this agent here: a refusal, not a crash. */
|
|
107
|
+
export class LaunchError extends Error {
|
|
108
|
+
constructor(message: string) {
|
|
109
|
+
super(message);
|
|
110
|
+
this.name = "LaunchError";
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** Register a runtime as strategy kind `runtime` (doctor lists it). */
|
|
115
|
+
export function registerRuntime(rt: Runtime): void {
|
|
116
|
+
const entry: StrategyEntry<typeof RUNTIME_KIND, Runtime> = {
|
|
117
|
+
id: rt.id,
|
|
118
|
+
kind: RUNTIME_KIND,
|
|
119
|
+
detect(env) {
|
|
120
|
+
const d = rt.detect(env);
|
|
121
|
+
if (!d.available)
|
|
122
|
+
return { available: false, reason: d.why ?? "unavailable" };
|
|
123
|
+
return d.version
|
|
124
|
+
? { available: true, reason: `version ${d.version}` }
|
|
125
|
+
: { available: true };
|
|
126
|
+
},
|
|
127
|
+
capabilities: rt,
|
|
128
|
+
};
|
|
129
|
+
registerStrategy(entry);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
export function getRuntime(id: string): Runtime | undefined {
|
|
133
|
+
return getStrategy<typeof RUNTIME_KIND, Runtime>(RUNTIME_KIND, id)
|
|
134
|
+
?.capabilities;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
export function runtimeIds(): string[] {
|
|
138
|
+
return strategyIds(RUNTIME_KIND);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/** Every registered runtime's scrubEnv: applied to every session Den starts. */
|
|
142
|
+
export function allScrubEnv(): string[] {
|
|
143
|
+
return [
|
|
144
|
+
...new Set(runtimeIds().flatMap((id) => getRuntime(id)?.scrubEnv ?? [])),
|
|
145
|
+
];
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/** The first executable `bin` on `env.PATH`, or null. Runs nothing. */
|
|
149
|
+
export function whichBin(
|
|
150
|
+
bin: string,
|
|
151
|
+
env: NodeJS.ProcessEnv = process.env,
|
|
152
|
+
): string | null {
|
|
153
|
+
for (const dir of (env.PATH ?? "").split(path.delimiter)) {
|
|
154
|
+
if (!dir) continue;
|
|
155
|
+
const file = path.join(dir, bin);
|
|
156
|
+
try {
|
|
157
|
+
fs.accessSync(file, fs.constants.X_OK);
|
|
158
|
+
if (fs.statSync(file).isFile()) return file;
|
|
159
|
+
} catch {
|
|
160
|
+
/* not here */
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
return null;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* A standard MCP config (`mcpServers`, the shape most MCP clients read)
|
|
168
|
+
* with Den's server as the only entry.
|
|
169
|
+
*/
|
|
170
|
+
export function mcpConfigJson(mcp: AgentMcp): string {
|
|
171
|
+
const [command, ...args] = mcp.command;
|
|
172
|
+
return (
|
|
173
|
+
JSON.stringify(
|
|
174
|
+
{
|
|
175
|
+
mcpServers: {
|
|
176
|
+
[mcp.name]: {
|
|
177
|
+
command,
|
|
178
|
+
args,
|
|
179
|
+
...(Object.keys(mcp.env).length ? { env: mcp.env } : {}),
|
|
180
|
+
},
|
|
181
|
+
},
|
|
182
|
+
},
|
|
183
|
+
null,
|
|
184
|
+
2,
|
|
185
|
+
) + "\n"
|
|
186
|
+
);
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/** Where a runtime puts an agent's MCP config. */
|
|
190
|
+
export function mcpConfigPath(input: Pick<LaunchInput, "filesDir">): string {
|
|
191
|
+
return path.join(input.filesDir, "mcp.json");
|
|
192
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Den's built-in runtimes, registered as strategy kind `runtime`: import
|
|
3
|
+
* this module to have them. den-claude first: it is the default.
|
|
4
|
+
*/
|
|
5
|
+
import { denClaude } from "./den-claude.js";
|
|
6
|
+
import { generic } from "./generic.js";
|
|
7
|
+
import { allScrubEnv, registerRuntime, type Runtime } from "./runtime.js";
|
|
8
|
+
import { AGENT_ACTOR_ENV, buildSessionEnv } from "../sessions/env.js";
|
|
9
|
+
|
|
10
|
+
registerRuntime(denClaude);
|
|
11
|
+
registerRuntime(generic);
|
|
12
|
+
|
|
13
|
+
export {
|
|
14
|
+
allScrubEnv,
|
|
15
|
+
DEFAULT_RUNTIME_ID,
|
|
16
|
+
getRuntime,
|
|
17
|
+
LaunchError,
|
|
18
|
+
RUNTIME_KIND,
|
|
19
|
+
runtimeIds,
|
|
20
|
+
type AgentMcp,
|
|
21
|
+
type LaunchInput,
|
|
22
|
+
type LaunchOutput,
|
|
23
|
+
type ResumeInput,
|
|
24
|
+
type Runtime,
|
|
25
|
+
} from "./runtime.js";
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* An agent's session env, as `agents start` builds it and `doctor --probe`
|
|
29
|
+
* checks it: the allowlist, the runtime's passthrough and launch env, Den's
|
|
30
|
+
* marker (DEN_AGENT=agent:<id>), then every runtime's scrubEnv.
|
|
31
|
+
*/
|
|
32
|
+
export function agentSessionEnv(
|
|
33
|
+
caller: NodeJS.ProcessEnv,
|
|
34
|
+
rt: Pick<Runtime, "envPassthrough">,
|
|
35
|
+
launch: Record<string, string>,
|
|
36
|
+
agentId: string,
|
|
37
|
+
): Record<string, string> {
|
|
38
|
+
return buildSessionEnv({
|
|
39
|
+
caller,
|
|
40
|
+
passthrough: rt.envPassthrough,
|
|
41
|
+
launch,
|
|
42
|
+
// The seam for a persona's or runtime's declared env: none yet.
|
|
43
|
+
extra: {},
|
|
44
|
+
den: { [AGENT_ACTOR_ENV]: `agent:${agentId}` },
|
|
45
|
+
scrub: allScrubEnv(),
|
|
46
|
+
});
|
|
47
|
+
}
|
package/src/shared/den-config.ts
CHANGED
|
@@ -243,6 +243,11 @@ export interface DenConfig {
|
|
|
243
243
|
* only; `actions` opts in to named actions. See readMcpConfig.
|
|
244
244
|
*/
|
|
245
245
|
mcp?: McpConfig;
|
|
246
|
+
/**
|
|
247
|
+
* Agents: `max_concurrent` caps the sessions running at once in this
|
|
248
|
+
* distribution (default 4). See readAgentsConfig.
|
|
249
|
+
*/
|
|
250
|
+
agents?: AgentsConfig;
|
|
246
251
|
/**
|
|
247
252
|
* The web UI: `port` (read by edge/config.ts readWebPort) and `service`,
|
|
248
253
|
* the launchd service `<cli> service` installs. See readWebServiceConfig.
|
|
@@ -552,6 +557,7 @@ export function readDenConfig(): DenConfig {
|
|
|
552
557
|
env_health_urls: parsed?.env_health_urls,
|
|
553
558
|
security: parsed?.security,
|
|
554
559
|
mcp: parsed?.mcp,
|
|
560
|
+
agents: parsed?.agents,
|
|
555
561
|
web: parsed?.web,
|
|
556
562
|
test_accounts: parsed?.test_accounts,
|
|
557
563
|
defaults: parsed?.defaults,
|
|
@@ -729,6 +735,116 @@ export function readMcpConfig(
|
|
|
729
735
|
};
|
|
730
736
|
}
|
|
731
737
|
|
|
738
|
+
// ── Agents (`<cli> sessions`, `<cli> agents`) ─────────────────────────────────
|
|
739
|
+
|
|
740
|
+
/** `agents:` in the org config. */
|
|
741
|
+
export interface AgentsConfig {
|
|
742
|
+
/** Sessions that may run at once (default 4). */
|
|
743
|
+
max_concurrent?: number;
|
|
744
|
+
/**
|
|
745
|
+
* Persona packs (AG-2a): directories of persona files, relative to the
|
|
746
|
+
* distribution root. Den's default pack is always read first; a pack's
|
|
747
|
+
* persona replaces one of the same id.
|
|
748
|
+
*/
|
|
749
|
+
persona_packs?: string[];
|
|
750
|
+
/**
|
|
751
|
+
* Any other key is an agent id: `agents.<id>.grants` widens that agent's
|
|
752
|
+
* MCP scope to other workspaces (AG spec §5b). Config only: an agent
|
|
753
|
+
* never grants itself one.
|
|
754
|
+
*/
|
|
755
|
+
[agentId: string]: unknown;
|
|
756
|
+
}
|
|
757
|
+
|
|
758
|
+
export const DEFAULT_AGENTS_MAX_CONCURRENT = 4;
|
|
759
|
+
|
|
760
|
+
/** The keys of `agents:` that are not an agent id. */
|
|
761
|
+
export const AGENTS_RESERVED_KEYS = ["max_concurrent", "persona_packs"];
|
|
762
|
+
|
|
763
|
+
/** One agent's own block, `agents.<id>`. */
|
|
764
|
+
export interface AgentGrantsConfig {
|
|
765
|
+
/** Workspaces beyond its own that its MCP serves. */
|
|
766
|
+
grants: string[];
|
|
767
|
+
}
|
|
768
|
+
|
|
769
|
+
/** `agents:` with its defaults applied. */
|
|
770
|
+
export interface ResolvedAgentsConfig {
|
|
771
|
+
maxConcurrent: number;
|
|
772
|
+
/** As configured (relative to the distribution root). */
|
|
773
|
+
personaPacks: string[];
|
|
774
|
+
/** `agents.<id>` blocks, by agent id. */
|
|
775
|
+
agents: Record<string, AgentGrantsConfig>;
|
|
776
|
+
}
|
|
777
|
+
|
|
778
|
+
/** An agent id or workspace name: what WORKSPACE_NAME_RE accepts. */
|
|
779
|
+
const AGENT_KEY_RE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/;
|
|
780
|
+
|
|
781
|
+
const isMapping = (v: unknown): v is Record<string, unknown> =>
|
|
782
|
+
v !== null && typeof v === "object" && !Array.isArray(v);
|
|
783
|
+
|
|
784
|
+
/**
|
|
785
|
+
* The effective agents config. Anything malformed throws, naming the key:
|
|
786
|
+
* a typo must not lift the cap, drop a pack or widen a grant.
|
|
787
|
+
*/
|
|
788
|
+
export function readAgentsConfig(
|
|
789
|
+
config: DenConfig = readDenConfig(),
|
|
790
|
+
): ResolvedAgentsConfig {
|
|
791
|
+
const raw = (config.agents ?? {}) as unknown;
|
|
792
|
+
if (!isMapping(raw))
|
|
793
|
+
throw new Error(`agents: must be a mapping (got ${JSON.stringify(raw)})`);
|
|
794
|
+
const out: ResolvedAgentsConfig = {
|
|
795
|
+
maxConcurrent: DEFAULT_AGENTS_MAX_CONCURRENT,
|
|
796
|
+
personaPacks: [],
|
|
797
|
+
agents: {},
|
|
798
|
+
};
|
|
799
|
+
const max = raw.max_concurrent;
|
|
800
|
+
if (max !== undefined) {
|
|
801
|
+
if (typeof max !== "number" || !Number.isInteger(max) || max < 1)
|
|
802
|
+
throw new Error(
|
|
803
|
+
`agents.max_concurrent: must be a whole number of at least 1 (got ${JSON.stringify(max)})`,
|
|
804
|
+
);
|
|
805
|
+
out.maxConcurrent = max;
|
|
806
|
+
}
|
|
807
|
+
const packs = raw.persona_packs;
|
|
808
|
+
if (packs !== undefined && packs !== null) {
|
|
809
|
+
if (!Array.isArray(packs))
|
|
810
|
+
throw new Error(
|
|
811
|
+
`agents.persona_packs: must be a list of paths (got ${JSON.stringify(packs)})`,
|
|
812
|
+
);
|
|
813
|
+
packs.forEach((p, i) => {
|
|
814
|
+
if (typeof p !== "string" || !p.trim())
|
|
815
|
+
throw new Error(
|
|
816
|
+
`agents.persona_packs[${i}]: must be a non-empty path (got ${JSON.stringify(p)})`,
|
|
817
|
+
);
|
|
818
|
+
});
|
|
819
|
+
out.personaPacks = packs as string[];
|
|
820
|
+
}
|
|
821
|
+
for (const [id, block] of Object.entries(raw)) {
|
|
822
|
+
if (AGENTS_RESERVED_KEYS.includes(id)) continue;
|
|
823
|
+
const where = `agents.${id}`;
|
|
824
|
+
if (!AGENT_KEY_RE.test(id) || !isMapping(block))
|
|
825
|
+
throw new Error(
|
|
826
|
+
`${where}: unknown key (agents: takes max_concurrent, persona_packs, ` +
|
|
827
|
+
`or an agent id mapping to { grants: [...] })`,
|
|
828
|
+
);
|
|
829
|
+
for (const k of Object.keys(block))
|
|
830
|
+
if (k !== "grants")
|
|
831
|
+
throw new Error(`${where}.${k}: unknown key (an agent takes grants)`);
|
|
832
|
+
const grants = block.grants ?? [];
|
|
833
|
+
if (!Array.isArray(grants))
|
|
834
|
+
throw new Error(
|
|
835
|
+
`${where}.grants: must be a list of workspace names (got ${JSON.stringify(grants)})`,
|
|
836
|
+
);
|
|
837
|
+
grants.forEach((g, i) => {
|
|
838
|
+
if (typeof g !== "string" || !AGENT_KEY_RE.test(g))
|
|
839
|
+
throw new Error(
|
|
840
|
+
`${where}.grants[${i}]: ${JSON.stringify(g)} is not a workspace name`,
|
|
841
|
+
);
|
|
842
|
+
});
|
|
843
|
+
out.agents[id] = { grants: [...new Set(grants as string[])] };
|
|
844
|
+
}
|
|
845
|
+
return out;
|
|
846
|
+
}
|
|
847
|
+
|
|
732
848
|
// ── Web UI service (`<cli> service`) ──────────────────────────────────────────
|
|
733
849
|
|
|
734
850
|
/** `web:` in the org config. */
|
package/src/shared/engine.ts
CHANGED
|
@@ -13,6 +13,7 @@
|
|
|
13
13
|
*/
|
|
14
14
|
import * as fs from "node:fs";
|
|
15
15
|
import * as path from "node:path";
|
|
16
|
+
import { pathToFileURL } from "node:url";
|
|
16
17
|
import { getDistRoot, getEngineRoot } from "./paths.js";
|
|
17
18
|
|
|
18
19
|
/** An absolute path inside Den's own package. */
|
|
@@ -83,3 +84,25 @@ export function distPath(...parts: string[]): string {
|
|
|
83
84
|
export function playwrightCommand(...args: string[]): EngineCommand {
|
|
84
85
|
return { command: engineBin("playwright"), args };
|
|
85
86
|
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* How to run Den's CLI as a background process of its own, whatever process
|
|
90
|
+
* asks (the CLI, the web server): one node process, never the tsx CLI (a
|
|
91
|
+
* parent that relays signals). Live TypeScript source with tsx's loader in
|
|
92
|
+
* a checkout or a package install; the compiled build when tsx is absent,
|
|
93
|
+
* or with DEN_USE_DIST=1. Mirrors the web server's resolveServerEntrypoint.
|
|
94
|
+
*/
|
|
95
|
+
export function cliEntrypoint(engine: string = getEngineRoot()): EngineCommand {
|
|
96
|
+
const built = path.join(engine, "dist/src/ui/cli/index.js");
|
|
97
|
+
if (process.env.DEN_USE_DIST === "1" && fs.existsSync(built))
|
|
98
|
+
return { command: process.execPath, args: [built] };
|
|
99
|
+
const loader = engineModuleFile("tsx/dist/loader.mjs", engine);
|
|
100
|
+
const src = path.join(engine, "src/ui/cli/index.ts");
|
|
101
|
+
if (loader && fs.existsSync(src))
|
|
102
|
+
return {
|
|
103
|
+
command: process.execPath,
|
|
104
|
+
args: ["--import", pathToFileURL(loader).href, src],
|
|
105
|
+
};
|
|
106
|
+
if (fs.existsSync(built)) return { command: process.execPath, args: [built] };
|
|
107
|
+
return { command: engineBin("tsx", engine), args: [src] };
|
|
108
|
+
}
|
|
@@ -325,6 +325,25 @@ export function writeWorkspacesFile(file: WorkspacesFile): void {
|
|
|
325
325
|
}
|
|
326
326
|
}
|
|
327
327
|
|
|
328
|
+
/**
|
|
329
|
+
* A workspace's checkout directory: its `directory` (relative to the
|
|
330
|
+
* distribution root), else den-workspaces/<name>, else the legacy
|
|
331
|
+
* <reposRoot>/<name>, else the repos root itself. What /api/workspaces
|
|
332
|
+
* reports as resolvedDirs, and where an agent of the workspace runs.
|
|
333
|
+
*/
|
|
334
|
+
export function resolveWorkspaceDir(
|
|
335
|
+
name: string,
|
|
336
|
+
ws: Pick<WorkspaceConfig, "directory">,
|
|
337
|
+
reposRoot: string,
|
|
338
|
+
): string {
|
|
339
|
+
if (ws.directory) return path.resolve(ROOT, ws.directory);
|
|
340
|
+
const gwDir = path.join(DEN_WORKSPACES_DIR, name);
|
|
341
|
+
if (fs.existsSync(gwDir)) return gwDir;
|
|
342
|
+
const legacyDir = path.join(reposRoot, name);
|
|
343
|
+
if (fs.existsSync(legacyDir)) return legacyDir;
|
|
344
|
+
return reposRoot;
|
|
345
|
+
}
|
|
346
|
+
|
|
328
347
|
export function getWorkspaceByName(name: string): {
|
|
329
348
|
name: string;
|
|
330
349
|
config: WorkspaceConfig;
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Which environment variables are credentials, by name or by value. One
|
|
3
|
+
* set of patterns for everything that checks an environment: the test
|
|
4
|
+
* workers (tests/scrub-env.ts), the audit of `sessions start --keep-env`,
|
|
5
|
+
* and `<cli> doctor --probe` (with the text redactor's own patterns,
|
|
6
|
+
* redact.ts). They err on the side of flagging: a harmless variable called
|
|
7
|
+
* a credential costs less than a token called harmless.
|
|
8
|
+
*
|
|
9
|
+
* Nothing here returns or logs a value.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { redactSecrets, SECRET_PATTERNS } from "./redact.js";
|
|
13
|
+
|
|
14
|
+
/** Variable names that hold credentials. */
|
|
15
|
+
const SECRET_NAME =
|
|
16
|
+
/(TOKEN|SECRET|PASSWORD|PASSWD|CREDENTIAL|API_?KEY|PRIVATE_KEY|ACCESS_KEY|AUTH)/i;
|
|
17
|
+
|
|
18
|
+
/** Whole families of credential variables. */
|
|
19
|
+
const SECRET_PREFIX = /^(OP_|DOPPLER_|AWS_|GH_|GITHUB_|NPM_|ANTHROPIC_)/i;
|
|
20
|
+
|
|
21
|
+
/** Values shaped like well-known tokens, whatever the variable is called. */
|
|
22
|
+
const SECRET_VALUE =
|
|
23
|
+
/(gh[pousr]_[A-Za-z0-9]{20,}|github_pat_[A-Za-z0-9_]{20,}|ops_[A-Za-z0-9]{20,}|sk-ant-[A-Za-z0-9_-]{20,}|dp\.(st|ct|sa|scim)\.[A-Za-z0-9]{20,}|npm_[A-Za-z0-9]{20,}|xox[abpr]-[A-Za-z0-9-]{10,})/;
|
|
24
|
+
|
|
25
|
+
/** Whether a variable looks like a credential, by name or by value. */
|
|
26
|
+
export function isCredentialEnv(
|
|
27
|
+
name: string,
|
|
28
|
+
value: string | undefined,
|
|
29
|
+
): boolean {
|
|
30
|
+
return (
|
|
31
|
+
SECRET_NAME.test(name) ||
|
|
32
|
+
SECRET_PREFIX.test(name) ||
|
|
33
|
+
(value !== undefined && SECRET_VALUE.test(value))
|
|
34
|
+
);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Credential-shaped: by name or value (isCredentialEnv), or a value the
|
|
39
|
+
* text redactor would redact.
|
|
40
|
+
*/
|
|
41
|
+
export function isCredentialShaped(
|
|
42
|
+
name: string,
|
|
43
|
+
value: string | undefined,
|
|
44
|
+
): boolean {
|
|
45
|
+
return (
|
|
46
|
+
isCredentialEnv(name, value) ||
|
|
47
|
+
(value !== undefined && value !== "" && redactSecrets(value) !== value)
|
|
48
|
+
);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Every pattern isCredentialShaped uses, as plain data (source and flags,
|
|
53
|
+
* global flag dropped), for a check run in another process (the probe).
|
|
54
|
+
*/
|
|
55
|
+
export function credentialPatternData(): {
|
|
56
|
+
name: { source: string; flags: string }[];
|
|
57
|
+
value: { source: string; flags: string }[];
|
|
58
|
+
} {
|
|
59
|
+
const data = (re: RegExp) => ({
|
|
60
|
+
source: re.source,
|
|
61
|
+
flags: re.flags.replace("g", ""),
|
|
62
|
+
});
|
|
63
|
+
return {
|
|
64
|
+
name: [SECRET_NAME, SECRET_PREFIX].map(data),
|
|
65
|
+
value: [SECRET_VALUE, ...SECRET_PATTERNS.map((p) => p.re)].map(data),
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** An environment variable name (`--keep-env NAME`). */
|
|
70
|
+
export const ENV_NAME_RE = /^[A-Za-z_][A-Za-z0-9_]*$/;
|