@yagni-app/code-staging 1.2.4-staging.1803.1 → 1.2.4-staging.1821.1

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.
@@ -0,0 +1,78 @@
1
+ /**
2
+ * A cloud run's skill sources (YAG-784): the mission file `sources.json` the
3
+ * backend writes beside `ticket.md`, and the compat options it turns into.
4
+ *
5
+ * The backend decides everything here: whether the working repo is trusted
6
+ * for agent runs (an admin's per-repo flag, standing in for pi's trust.json),
7
+ * and which org marketplace and personal skills snapshots it fetched and
8
+ * extracted under AGENT_SOURCES_ROOT. The CLI only reads the file, so it
9
+ * carries its own small validator rather than importing @yagni/shared (this
10
+ * package never takes a build-time dependency on a private workspace package).
11
+ * The contract is `AgentRunSourcesFile` in packages/shared/src/agentSources.ts.
12
+ *
13
+ * Everything is fail-soft: a missing, oversized, or garbled file means "no
14
+ * cloud sources" (the caller falls back to local behavior), and one bad
15
+ * source is dropped without touching the others.
16
+ */
17
+ import type { CompatExtraRoot, CompatMarketplace } from "./claudeCompat.js";
18
+ /** Where the backend extracts the org and personal snapshots in the sandbox. */
19
+ export declare const AGENT_SOURCES_ROOT = "/opt/yagni/sources";
20
+ export declare const AGENT_RUN_SOURCES_FILE_NAME = "sources.json";
21
+ /** pi-harness.sh's default when YAGNI_MISSION_DIR is unset. */
22
+ export declare const DEFAULT_MISSION_DIR = "/opt/yagni/mission";
23
+ export interface AgentRunSources {
24
+ repo: {
25
+ fullName: string;
26
+ sha: string;
27
+ trusted: boolean;
28
+ };
29
+ org: {
30
+ fullName: string;
31
+ ref: string | null;
32
+ sha: string;
33
+ dir: string;
34
+ enabledPlugins: string[];
35
+ } | null;
36
+ personal: {
37
+ fullName: string;
38
+ ref: string | null;
39
+ sha: string;
40
+ dir: string;
41
+ userId: string;
42
+ } | null;
43
+ }
44
+ /** Validate a parsed sources.json (version 1). Null when it isn't one. */
45
+ export declare function parseAgentRunSources(raw: unknown): AgentRunSources | null;
46
+ /**
47
+ * Read `<missionDir>/sources.json`. Null when absent, too large, or invalid.
48
+ * Never throws. A file that is there but unusable is not the same as no file:
49
+ * the backend already recorded its sources as loaded, so `warn` says so (one
50
+ * stderr line in a headless run) rather than degrade to local mode silently.
51
+ */
52
+ export declare function readAgentRunSources(missionDir: string, warn?: (line: string) => void): AgentRunSources | null;
53
+ /** The mission dir the harness wrote into (`YAGNI_MISSION_DIR`, else its default). */
54
+ export declare function missionDirFromEnv(env: NodeJS.ProcessEnv): string;
55
+ /**
56
+ * `dir` as a realpath when it is absolute, an existing directory, and inside
57
+ * `sourcesRoot` after symlink resolution; else the reason it was refused.
58
+ */
59
+ export declare function checkSourceDir(dir: string, sourcesRoot: string): {
60
+ dir: string;
61
+ } | {
62
+ refused: string;
63
+ };
64
+ /** What a valid sources file asks `claudeCompatArgs` to load. */
65
+ export interface CloudCompatOptions {
66
+ trustOverride: boolean;
67
+ extraRoots: CompatExtraRoot[];
68
+ marketplaces: CompatMarketplace[];
69
+ /** `label: reason` for each source dir that was refused. */
70
+ refused: string[];
71
+ }
72
+ /**
73
+ * Turn a sources file into compat options. Snapshot dirs must sit under
74
+ * `sourcesRoot` (AGENT_SOURCES_ROOT in the sandbox); any other dir is refused
75
+ * and reported, never loaded.
76
+ */
77
+ export declare function cloudCompatOptions(sources: AgentRunSources, sourcesRoot?: string): CloudCompatOptions;
78
+ //# sourceMappingURL=agentSources.d.ts.map
@@ -0,0 +1,194 @@
1
+ /**
2
+ * A cloud run's skill sources (YAG-784): the mission file `sources.json` the
3
+ * backend writes beside `ticket.md`, and the compat options it turns into.
4
+ *
5
+ * The backend decides everything here: whether the working repo is trusted
6
+ * for agent runs (an admin's per-repo flag, standing in for pi's trust.json),
7
+ * and which org marketplace and personal skills snapshots it fetched and
8
+ * extracted under AGENT_SOURCES_ROOT. The CLI only reads the file, so it
9
+ * carries its own small validator rather than importing @yagni/shared (this
10
+ * package never takes a build-time dependency on a private workspace package).
11
+ * The contract is `AgentRunSourcesFile` in packages/shared/src/agentSources.ts.
12
+ *
13
+ * Everything is fail-soft: a missing, oversized, or garbled file means "no
14
+ * cloud sources" (the caller falls back to local behavior), and one bad
15
+ * source is dropped without touching the others.
16
+ */
17
+ import { closeSync, fstatSync, openSync, readSync, realpathSync, statSync } from "node:fs";
18
+ import { isAbsolute, join, sep } from "node:path";
19
+ /** Where the backend extracts the org and personal snapshots in the sandbox. */
20
+ export const AGENT_SOURCES_ROOT = "/opt/yagni/sources";
21
+ export const AGENT_RUN_SOURCES_FILE_NAME = "sources.json";
22
+ /** pi-harness.sh's default when YAGNI_MISSION_DIR is unset. */
23
+ export const DEFAULT_MISSION_DIR = "/opt/yagni/mission";
24
+ /** A sources file is a few hundred bytes; anything past this is not one. */
25
+ const MAX_SOURCES_FILE_BYTES = 64 * 1024;
26
+ /** Mirrors MAX_ENABLED_PLUGINS in the shared contract. */
27
+ const MAX_ENABLED_PLUGINS = 50;
28
+ /**
29
+ * A label, never a path. The owner allows `_` because a working repo can
30
+ * belong to an Enterprise Managed User (`jane_acme`); rejecting it would drop
31
+ * the whole file and silently ignore the repo's trust flag.
32
+ */
33
+ const FULL_NAME = /^[A-Za-z0-9_-]{1,39}\/[A-Za-z0-9._-]{1,100}$/;
34
+ const SHA = /^[0-9a-f]{40}$/;
35
+ /** Same shape as claudePlugins' SAFE_SEGMENT: plugin names are path segments. */
36
+ const PLUGIN_NAME = /^[A-Za-z0-9][A-Za-z0-9._-]{0,99}$/;
37
+ function isRecord(value) {
38
+ return !!value && typeof value === "object" && !Array.isArray(value);
39
+ }
40
+ function refOf(value) {
41
+ if (value === undefined || value === null)
42
+ return null;
43
+ return typeof value === "string" && value.length > 0 && value.length <= 255 ? value : undefined;
44
+ }
45
+ function snapshotBase(value) {
46
+ if (!isRecord(value))
47
+ return null;
48
+ const { fullName, sha, dir } = value;
49
+ const ref = refOf(value.ref);
50
+ if (typeof fullName !== "string" || !FULL_NAME.test(fullName))
51
+ return null;
52
+ if (typeof sha !== "string" || !SHA.test(sha))
53
+ return null;
54
+ if (typeof dir !== "string" || dir.length === 0)
55
+ return null;
56
+ if (ref === undefined)
57
+ return null;
58
+ return { fullName, ref, sha, dir };
59
+ }
60
+ /** Validate a parsed sources.json (version 1). Null when it isn't one. */
61
+ export function parseAgentRunSources(raw) {
62
+ if (!isRecord(raw) || raw.version !== 1 || !isRecord(raw.repo))
63
+ return null;
64
+ const { fullName, sha, trusted } = raw.repo;
65
+ if (typeof fullName !== "string" || !FULL_NAME.test(fullName))
66
+ return null;
67
+ if (typeof sha !== "string" || !SHA.test(sha))
68
+ return null;
69
+ if (typeof trusted !== "boolean")
70
+ return null;
71
+ let org = null;
72
+ const orgBase = snapshotBase(raw.org);
73
+ if (orgBase && isRecord(raw.org) && Array.isArray(raw.org.enabledPlugins)) {
74
+ const enabledPlugins = [
75
+ ...new Set(raw.org.enabledPlugins.filter((p) => typeof p === "string" && PLUGIN_NAME.test(p))),
76
+ ].slice(0, MAX_ENABLED_PLUGINS);
77
+ org = { ...orgBase, enabledPlugins };
78
+ }
79
+ let personal = null;
80
+ const personalBase = snapshotBase(raw.personal);
81
+ if (personalBase && isRecord(raw.personal) && typeof raw.personal.userId === "string") {
82
+ personal = { ...personalBase, userId: raw.personal.userId };
83
+ }
84
+ return { repo: { fullName, sha, trusted }, org, personal };
85
+ }
86
+ /**
87
+ * Read `<missionDir>/sources.json`. Null when absent, too large, or invalid.
88
+ * Never throws. A file that is there but unusable is not the same as no file:
89
+ * the backend already recorded its sources as loaded, so `warn` says so (one
90
+ * stderr line in a headless run) rather than degrade to local mode silently.
91
+ */
92
+ export function readAgentRunSources(missionDir, warn = () => { }) {
93
+ const path = join(missionDir, AGENT_RUN_SOURCES_FILE_NAME);
94
+ let fd = null;
95
+ try {
96
+ try {
97
+ fd = openSync(path, "r");
98
+ }
99
+ catch (err) {
100
+ if (err?.code === "ENOENT")
101
+ return null;
102
+ throw err;
103
+ }
104
+ const size = fstatSync(fd).size;
105
+ if (size === 0 || size > MAX_SOURCES_FILE_BYTES) {
106
+ warn(`Skills: ${path} ignored (${size === 0 ? "empty" : `larger than ${MAX_SOURCES_FILE_BYTES} bytes`}); no skills sources loaded`);
107
+ return null;
108
+ }
109
+ const buffer = Buffer.alloc(size);
110
+ readSync(fd, buffer, 0, size, 0);
111
+ const parsed = parseAgentRunSources(JSON.parse(buffer.toString("utf8")));
112
+ if (!parsed)
113
+ warn(`Skills: ${path} ignored (not a valid version 1 sources file); no skills sources loaded`);
114
+ return parsed;
115
+ }
116
+ catch (err) {
117
+ const why = err instanceof SyntaxError ? "not valid JSON" : err instanceof Error ? err.message : String(err);
118
+ warn(`Skills: ${path} ignored (${why}); no skills sources loaded`);
119
+ return null;
120
+ }
121
+ finally {
122
+ if (fd !== null) {
123
+ try {
124
+ closeSync(fd);
125
+ }
126
+ catch {
127
+ // Already closed.
128
+ }
129
+ }
130
+ }
131
+ }
132
+ /** The mission dir the harness wrote into (`YAGNI_MISSION_DIR`, else its default). */
133
+ export function missionDirFromEnv(env) {
134
+ const dir = env.YAGNI_MISSION_DIR?.trim();
135
+ return dir ? dir : DEFAULT_MISSION_DIR;
136
+ }
137
+ /**
138
+ * `dir` as a realpath when it is absolute, an existing directory, and inside
139
+ * `sourcesRoot` after symlink resolution; else the reason it was refused.
140
+ */
141
+ export function checkSourceDir(dir, sourcesRoot) {
142
+ if (!isAbsolute(dir))
143
+ return { refused: "not an absolute path" };
144
+ let real;
145
+ let realRoot;
146
+ try {
147
+ real = realpathSync(dir);
148
+ realRoot = realpathSync(sourcesRoot);
149
+ }
150
+ catch {
151
+ return { refused: "not present in the sandbox" };
152
+ }
153
+ if (!real.startsWith(realRoot + sep))
154
+ return { refused: `outside ${sourcesRoot}` };
155
+ try {
156
+ if (!statSync(real).isDirectory())
157
+ return { refused: "not a directory" };
158
+ }
159
+ catch {
160
+ return { refused: "not present in the sandbox" };
161
+ }
162
+ return { dir: real };
163
+ }
164
+ /**
165
+ * Turn a sources file into compat options. Snapshot dirs must sit under
166
+ * `sourcesRoot` (AGENT_SOURCES_ROOT in the sandbox); any other dir is refused
167
+ * and reported, never loaded.
168
+ */
169
+ export function cloudCompatOptions(sources, sourcesRoot = AGENT_SOURCES_ROOT) {
170
+ const out = {
171
+ trustOverride: sources.repo.trusted,
172
+ extraRoots: [],
173
+ marketplaces: [],
174
+ refused: [],
175
+ };
176
+ if (sources.org) {
177
+ const label = `${sources.org.fullName} @ ${sources.org.sha.slice(0, 7)}`;
178
+ const checked = checkSourceDir(sources.org.dir, sourcesRoot);
179
+ if ("dir" in checked)
180
+ out.marketplaces.push({ root: checked.dir, enabled: sources.org.enabledPlugins, label });
181
+ else
182
+ out.refused.push(`${label}: ${checked.refused}`);
183
+ }
184
+ if (sources.personal) {
185
+ const label = `${sources.personal.fullName} @ ${sources.personal.sha.slice(0, 7)}`;
186
+ const checked = checkSourceDir(sources.personal.dir, sourcesRoot);
187
+ if ("dir" in checked)
188
+ out.extraRoots.push({ root: checked.dir, label });
189
+ else
190
+ out.refused.push(`${label}: ${checked.refused}`);
191
+ }
192
+ return out;
193
+ }
194
+ //# sourceMappingURL=agentSources.js.map
@@ -107,6 +107,60 @@ export declare function readTrustDecision(agentDir: string, cwd: string): boolea
107
107
  * asked again next launch.
108
108
  */
109
109
  export declare function writeTrustDecision(agentDir: string, cwd: string, decision: boolean): void;
110
+ /**
111
+ * A personal skills root (a person's own skills repository, extracted into the
112
+ * sandbox). Any of the layouts in EXTRA_ROOT_LAYOUT that exist load,
113
+ * regardless of the working repo's trust: the backend only hands over a root
114
+ * it authorized.
115
+ */
116
+ export interface CompatExtraRoot {
117
+ root: string;
118
+ /** Human label for logs, e.g. `jane/skills`. */
119
+ label: string;
120
+ }
121
+ /**
122
+ * A plugin marketplace checkout (`<root>/.claude-plugin/marketplace.json`) of
123
+ * which only the `enabled` plugins load. Only relative-path plugin sources are
124
+ * supported; object sources (github, url, npm) are reported, never fetched.
125
+ */
126
+ export interface CompatMarketplace {
127
+ root: string;
128
+ enabled: string[];
129
+ label: string;
130
+ }
131
+ /** Personal root layouts, in load order. */
132
+ export declare const EXTRA_ROOT_LAYOUT: {
133
+ readonly skills: readonly ["skills", ".claude/skills", ".agents/skills"];
134
+ readonly commands: readonly ["commands", ".claude/commands"];
135
+ readonly rules: readonly ["rules", ".claude/rules"];
136
+ };
137
+ /** Bound on the symlink scan of one asset path, so a huge tree can't stall a launch. */
138
+ export declare const SYMLINK_SCAN_LIMIT = 20000;
139
+ /**
140
+ * `<root>/<rel>` as a realpath when it exists, is a directory, stays inside
141
+ * `root` after symlink resolution, and holds no symlink pointing outside it.
142
+ * Every source the interactive launcher never read goes through this, so a
143
+ * hostile link in a repo or snapshot can't hand pi a directory outside it.
144
+ * `scanLimit` exists for tests: a tree with more entries than it is refused.
145
+ */
146
+ export declare function safeSourceDir(root: string, rel: string, scanLimit?: number): string | null;
147
+ /** One source's contribution, summarized for the launcher's log line. */
148
+ export interface CompatSourceSummary {
149
+ label: string;
150
+ kind: "user" | "project" | "personal" | "org-marketplace";
151
+ skills: string[];
152
+ commands: string[];
153
+ rulesDirs: string[];
154
+ /** `name@marketplace` keys of the plugins it contributed. */
155
+ plugins: string[];
156
+ /** Things present but not loaded (unsupported plugin parts, missing plugins). */
157
+ unsupported: string[];
158
+ }
159
+ /** The enabled plugins of one marketplace checkout, plus what could not load. Never throws. */
160
+ export declare function marketplacePlugins(mp: CompatMarketplace): {
161
+ plugins: PluginAssets[];
162
+ unsupported: string[];
163
+ };
110
164
  export interface ClaudeCompatDeps {
111
165
  cwd: string;
112
166
  /** The hermetic pi agent dir for the active profile (holds trust.json). */
@@ -116,6 +170,26 @@ export interface ClaudeCompatDeps {
116
170
  interactive?: boolean;
117
171
  /** The one-time trust question; injectable for tests. */
118
172
  confirm?: (question: string) => Promise<boolean>;
173
+ /**
174
+ * A trust decision made elsewhere (a cloud run's per-repo admin flag). It
175
+ * replaces the trust.json lookup and the prompt, and is never written back.
176
+ */
177
+ trustOverride?: boolean;
178
+ /** False skips `~/.claude`, home plugins and the YAGNI ledger. Default true. */
179
+ userScope?: boolean;
180
+ /** False never emits YAGNI_CLAUDE_PLUGIN_MCP (headless runs). Default true. */
181
+ pluginMcp?: boolean;
182
+ /**
183
+ * Once the folder is trusted, also wire `<cwd>/.agents/skills`, `.pi/skills`
184
+ * (`--skill`) and `.pi/prompts` (`--prompt-template`) explicitly, so a
185
+ * headless run never needs pi's `--approve` (which would also run the repo's
186
+ * `.pi/extensions` code). Default false.
187
+ */
188
+ projectNativeSkills?: boolean;
189
+ /** Personal skills roots; load regardless of the folder's trust. */
190
+ extraRoots?: CompatExtraRoot[];
191
+ /** Plugin marketplaces with an explicit enabled list; load regardless of trust. */
192
+ marketplaces?: CompatMarketplace[];
119
193
  }
120
194
  export declare function compatDisabled(env: NodeJS.ProcessEnv): boolean;
121
195
  export interface ClaudeCompatLaunch {
@@ -123,12 +197,21 @@ export interface ClaudeCompatLaunch {
123
197
  argv: string[];
124
198
  /** Extra child env (plugin agent dirs, rules dirs) for the extension. */
125
199
  env: Record<string, string>;
200
+ /**
201
+ * What each source contributed, for logging. Absent when nothing loaded and
202
+ * no extra source was asked for.
203
+ */
204
+ sources?: CompatSourceSummary[];
126
205
  }
127
206
  /**
128
207
  * The extra pi argv + child env for this launch: user-scope dirs and plugins
129
208
  * unconditionally, project-sourced dirs/plugins/rules once trusted, asking
130
209
  * (and persisting) the trust decision when needed. Everything is fail-soft —
131
210
  * compat can only add flags/env, never break a launch.
211
+ *
212
+ * The optional deps (`trustOverride`, `userScope`, `pluginMcp`,
213
+ * `projectNativeSkills`, `extraRoots`, `marketplaces`) serve headless cloud
214
+ * runs; left unset, the argv and env are exactly the interactive behavior.
132
215
  */
133
216
  export declare function claudeCompatArgs(deps: ClaudeCompatDeps): Promise<ClaudeCompatLaunch>;
134
217
  /** One plugin as the in-session `/plugin` panel sees it. */