@tagma/sdk 0.6.9 → 0.6.11

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.
Files changed (43) hide show
  1. package/README.md +58 -10
  2. package/dist/config-ops.d.ts.map +1 -1
  3. package/dist/config-ops.js +14 -0
  4. package/dist/config-ops.js.map +1 -1
  5. package/dist/pipeline-runner.d.ts +1 -1
  6. package/dist/pipeline-runner.d.ts.map +1 -1
  7. package/dist/pipeline-runner.js +20 -0
  8. package/dist/pipeline-runner.js.map +1 -1
  9. package/dist/registry.d.ts.map +1 -1
  10. package/dist/registry.js +20 -1
  11. package/dist/registry.js.map +1 -1
  12. package/dist/schema.d.ts.map +1 -1
  13. package/dist/schema.js +40 -8
  14. package/dist/schema.js.map +1 -1
  15. package/dist/task-ref.d.ts.map +1 -1
  16. package/dist/task-ref.js +2 -0
  17. package/dist/task-ref.js.map +1 -1
  18. package/dist/utils.js +3 -3
  19. package/dist/utils.js.map +1 -1
  20. package/dist/validate-raw.d.ts +1 -0
  21. package/dist/validate-raw.d.ts.map +1 -1
  22. package/dist/validate-raw.js +74 -3
  23. package/dist/validate-raw.js.map +1 -1
  24. package/dist/yaml-compiler.d.ts.map +1 -1
  25. package/dist/yaml-compiler.js +23 -5
  26. package/dist/yaml-compiler.js.map +1 -1
  27. package/package.json +2 -2
  28. package/src/completions/output-check.test.ts +50 -0
  29. package/src/config-ops.test.ts +70 -0
  30. package/src/config-ops.ts +11 -0
  31. package/src/pipeline-runner.test.ts +95 -0
  32. package/src/pipeline-runner.ts +18 -1
  33. package/src/plugin-registry.test.ts +18 -0
  34. package/src/registry.ts +25 -1
  35. package/src/schema.test.ts +113 -1
  36. package/src/schema.ts +45 -10
  37. package/src/task-ref.ts +1 -0
  38. package/src/utils.test.ts +28 -0
  39. package/src/utils.ts +3 -3
  40. package/src/validate-raw-plugin-types.test.ts +60 -0
  41. package/src/validate-raw.ts +78 -4
  42. package/src/yaml-compiler.test.ts +108 -0
  43. package/src/yaml-compiler.ts +32 -5
@@ -0,0 +1,95 @@
1
+ import { describe, expect, test } from 'bun:test';
2
+ import { mkdtempSync, rmSync, writeFileSync } from 'node:fs';
3
+ import { tmpdir } from 'node:os';
4
+ import { join } from 'node:path';
5
+ import { bootstrapBuiltins } from './bootstrap';
6
+ import { PipelineRunner } from './pipeline-runner';
7
+ import { PluginRegistry } from './registry';
8
+ import type { PipelineConfig } from './types';
9
+
10
+ function makeDir(): string {
11
+ return mkdtempSync(join(tmpdir(), 'tagma-pipeline-runner-'));
12
+ }
13
+
14
+ function portsPipeline(dir: string): PipelineConfig {
15
+ const emit = join(dir, 'emit.js');
16
+ writeFileSync(
17
+ emit,
18
+ 'process.stdout.write(JSON.stringify({ city: "Shanghai" }) + "\\n");\n',
19
+ );
20
+ const echo = join(dir, 'echo.js');
21
+ writeFileSync(echo, 'process.stdout.write(process.argv[2] + "\\n");\n');
22
+
23
+ return {
24
+ name: 'runner-snapshot',
25
+ tracks: [
26
+ {
27
+ id: 't',
28
+ name: 'T',
29
+ tasks: [
30
+ {
31
+ id: 'up',
32
+ name: 'up',
33
+ command: `node "${emit}"`,
34
+ ports: {
35
+ outputs: [{ name: 'city', type: 'string' }],
36
+ },
37
+ },
38
+ {
39
+ id: 'down',
40
+ name: 'down',
41
+ depends_on: ['up'],
42
+ command: `node "${echo}" "{{inputs.city}}"`,
43
+ ports: {
44
+ inputs: [{ name: 'city', type: 'string', required: true }],
45
+ },
46
+ },
47
+ ],
48
+ },
49
+ ],
50
+ };
51
+ }
52
+
53
+ async function run(config: PipelineConfig, dir: string): Promise<PipelineRunner> {
54
+ const registry = new PluginRegistry();
55
+ bootstrapBuiltins(registry);
56
+ const runner = new PipelineRunner(config, dir, {
57
+ registry,
58
+ skipPluginLoading: true,
59
+ });
60
+
61
+ const result = await runner.start();
62
+ expect(result.success).toBe(true);
63
+ return runner;
64
+ }
65
+
66
+ describe('PipelineRunner task snapshot', () => {
67
+ test('getTasks reflects task_update inputs and outputs', async () => {
68
+ const dir = makeDir();
69
+ try {
70
+ const runner = await run(portsPipeline(dir), dir);
71
+
72
+ const tasks = runner.getTasks();
73
+ const up = tasks.get('t.up');
74
+ const down = tasks.get('t.down');
75
+ expect(up?.outputs).toEqual({ city: 'Shanghai' });
76
+ expect(down?.inputs).toEqual({ city: 'Shanghai' });
77
+ } finally {
78
+ rmSync(dir, { recursive: true, force: true });
79
+ }
80
+ });
81
+
82
+ test('getTasks folds streamed task logs into the task snapshot', async () => {
83
+ const dir = makeDir();
84
+ try {
85
+ const runner = await run(portsPipeline(dir), dir);
86
+
87
+ const tasks = runner.getTasks();
88
+ const up = tasks.get('t.up');
89
+ expect(up?.logs.length).toBeGreaterThan(0);
90
+ expect(up?.totalLogCount).toBeGreaterThan(0);
91
+ } finally {
92
+ rmSync(dir, { recursive: true, force: true });
93
+ }
94
+ });
95
+ });
@@ -23,7 +23,7 @@
23
23
 
24
24
  import { runPipeline } from './engine';
25
25
  import type { EngineResult, RunPipelineOptions } from './engine';
26
- import type { PipelineConfig, RunEventPayload, RunTaskState } from './types';
26
+ import { TASK_LOG_CAP, type PipelineConfig, type RunEventPayload, type RunTaskState } from './types';
27
27
  import { generateRunId } from './utils';
28
28
 
29
29
  export type { EngineResult };
@@ -132,12 +132,29 @@ export class PipelineRunner {
132
132
  stderrBytes: pick(event.stderrBytes, prev.stderrBytes),
133
133
  sessionId: pick(event.sessionId, prev.sessionId),
134
134
  normalizedOutput: pick(event.normalizedOutput, prev.normalizedOutput),
135
+ outputs: pick(event.outputs, prev.outputs),
136
+ inputs: pick(event.inputs, prev.inputs),
135
137
  resolvedDriver: pick(event.resolvedDriver, prev.resolvedDriver),
136
138
  resolvedModel: pick(event.resolvedModel, prev.resolvedModel),
137
139
  resolvedPermissions: pick(event.resolvedPermissions, prev.resolvedPermissions),
138
140
  });
139
141
  return;
140
142
  }
143
+ case 'task_log': {
144
+ if (event.taskId === null) return;
145
+ const prev = this._tasks.get(event.taskId);
146
+ if (!prev) return;
147
+ const logs = [
148
+ ...prev.logs,
149
+ { level: event.level, timestamp: event.timestamp, text: event.text },
150
+ ];
151
+ this._tasks.set(event.taskId, {
152
+ ...prev,
153
+ logs: logs.slice(-TASK_LOG_CAP),
154
+ totalLogCount: prev.totalLogCount + 1,
155
+ });
156
+ return;
157
+ }
141
158
  case 'run_end':
142
159
  this._status = this._abortController.signal.aborted ? 'aborted' : 'done';
143
160
  return;
@@ -135,6 +135,24 @@ describe('PluginRegistry — validation', () => {
135
135
  ),
136
136
  ).toThrow(/non-empty "name"/);
137
137
  });
138
+
139
+ test('rejects plugin type identifiers that are not YAML-safe ids', () => {
140
+ const reg = new PluginRegistry();
141
+ expect(() =>
142
+ reg.registerPlugin(
143
+ 'drivers',
144
+ '../evil',
145
+ makeDriver('evil', []),
146
+ ),
147
+ ).toThrow(/Plugin type .* must match/);
148
+ });
149
+
150
+ test('middleware install hint uses singular middleware package name', () => {
151
+ const reg = new PluginRegistry();
152
+ expect(() => reg.getHandler('middlewares', 'audit')).toThrow(
153
+ /bun add @tagma\/middleware-audit/,
154
+ );
155
+ });
138
156
  });
139
157
 
140
158
  describe('runPipeline — options.registry isolation', () => {
package/src/registry.ts CHANGED
@@ -17,6 +17,20 @@ const VALID_CATEGORIES: ReadonlySet<PluginCategory> = new Set([
17
17
  'completions',
18
18
  'middlewares',
19
19
  ]);
20
+ const PLUGIN_TYPE_RE = /^[A-Za-z_][A-Za-z0-9_-]*$/;
21
+
22
+ function singularCategory(category: PluginCategory): string {
23
+ switch (category) {
24
+ case 'drivers':
25
+ return 'driver';
26
+ case 'triggers':
27
+ return 'trigger';
28
+ case 'completions':
29
+ return 'completion';
30
+ case 'middlewares':
31
+ return 'middleware';
32
+ }
33
+ }
20
34
 
21
35
  /**
22
36
  * Minimal contract enforcement so a malformed plugin fails fast at
@@ -145,6 +159,11 @@ export function readPluginManifest(pkgJson: unknown): PluginManifest | null {
145
159
  if (typeof type !== 'string' || type.length === 0) {
146
160
  throw new Error(`tagmaPlugin.type must be a non-empty string, got ${JSON.stringify(type)}`);
147
161
  }
162
+ if (!PLUGIN_TYPE_RE.test(type)) {
163
+ throw new Error(
164
+ `tagmaPlugin.type must match ${PLUGIN_TYPE_RE} (letters, digits, underscores, hyphens; no paths or dots), got ${JSON.stringify(type)}`,
165
+ );
166
+ }
148
167
  return { category: category as PluginCategory, type };
149
168
  }
150
169
 
@@ -183,6 +202,11 @@ export class PluginRegistry {
183
202
  if (typeof type !== 'string' || type.length === 0) {
184
203
  throw new Error(`Plugin type must be a non-empty string (category="${category}")`);
185
204
  }
205
+ if (!PLUGIN_TYPE_RE.test(type)) {
206
+ throw new Error(
207
+ `Plugin type "${type}" must match ${PLUGIN_TYPE_RE} (letters, digits, underscores, hyphens; no paths or dots)`,
208
+ );
209
+ }
186
210
  validateContract(category, handler);
187
211
  const registry = this.registries[category] as Map<string, T>;
188
212
  const existing = registry.get(type);
@@ -220,7 +244,7 @@ export class PluginRegistry {
220
244
  if (!handler) {
221
245
  throw new Error(
222
246
  `${category} type "${type}" not registered.\n` +
223
- `Install the plugin: bun add @tagma/${category.replace(/s$/, '')}-${type}`,
247
+ `Install the plugin: bun add @tagma/${singularCategory(category)}-${type}`,
224
248
  );
225
249
  }
226
250
  return handler as T;
@@ -1,7 +1,7 @@
1
1
  import { describe, expect, test } from 'bun:test';
2
2
  import yaml from 'js-yaml';
3
3
  import type { PipelineConfig, RawPipelineConfig } from './types';
4
- import { deresolvePipeline, serializePipeline } from './schema';
4
+ import { deresolvePipeline, parseYaml, resolveConfig, serializePipeline } from './schema';
5
5
 
6
6
  function parsePipelineYaml(content: string): RawPipelineConfig {
7
7
  const doc = yaml.load(content) as { pipeline: RawPipelineConfig };
@@ -56,6 +56,37 @@ describe('completion default serialization', () => {
56
56
  });
57
57
  });
58
58
 
59
+ test('serializePipeline drops continue_from from command tasks (prompt-only field)', () => {
60
+ const raw: RawPipelineConfig = {
61
+ name: 'Strip Continue From',
62
+ tracks: [
63
+ {
64
+ id: 'track_a',
65
+ name: 'Track A',
66
+ tasks: [
67
+ { id: 'upstream', prompt: 'generate something' },
68
+ // Simulates a task the user authored as `prompt` with a
69
+ // continue_from, then toggled to `command` in the editor panel.
70
+ // The field should not survive serialization.
71
+ {
72
+ id: 'downstream',
73
+ command: 'bun run build',
74
+ continue_from: 'upstream',
75
+ depends_on: ['upstream'],
76
+ },
77
+ // A prompt task keeps its continue_from as-is.
78
+ { id: 'threaded', prompt: 'refine', continue_from: 'upstream' },
79
+ ],
80
+ },
81
+ ],
82
+ };
83
+
84
+ const parsed = parsePipelineYaml(serializePipeline(raw));
85
+ expect(parsed.tracks[0].tasks[1].continue_from).toBeUndefined();
86
+ expect(parsed.tracks[0].tasks[1].depends_on).toEqual(['upstream']);
87
+ expect(parsed.tracks[0].tasks[2].continue_from).toBe('upstream');
88
+ });
89
+
59
90
  test('deresolvePipeline also omits the default exit_code completion', () => {
60
91
  const resolved: PipelineConfig = {
61
92
  name: 'Deresolve Defaults',
@@ -99,3 +130,84 @@ describe('completion default serialization', () => {
99
130
  });
100
131
  });
101
132
  });
133
+
134
+ describe('parseYaml structural validation', () => {
135
+ test('rejects non-array pipeline.tracks with a clear error', () => {
136
+ expect(() =>
137
+ parseYaml(`
138
+ pipeline:
139
+ name: Bad
140
+ tracks:
141
+ id: not-an-array
142
+ `),
143
+ ).toThrow(/pipeline\.tracks must be an array/);
144
+ });
145
+
146
+ test('rejects non-array track.tasks with a clear error', () => {
147
+ expect(() =>
148
+ parseYaml(`
149
+ pipeline:
150
+ name: Bad
151
+ tracks:
152
+ - id: t
153
+ name: T
154
+ tasks:
155
+ id: not-an-array
156
+ `),
157
+ ).toThrow(/track "t": tasks must be an array/);
158
+ });
159
+ });
160
+
161
+ describe('permissions inheritance', () => {
162
+ test('resolveConfig applies pipeline-level permissions to tracks and tasks', () => {
163
+ const raw: RawPipelineConfig = {
164
+ name: 'Pipeline Permissions',
165
+ permissions: { read: true, write: true, execute: false },
166
+ tracks: [
167
+ {
168
+ id: 'track_a',
169
+ name: 'Track A',
170
+ tasks: [{ id: 'task_1', prompt: 'hello' }],
171
+ },
172
+ ],
173
+ };
174
+
175
+ const resolved = resolveConfig(raw, 'D:/workspace');
176
+ expect(resolved.tracks[0].permissions).toEqual({ read: true, write: true, execute: false });
177
+ expect(resolved.tracks[0].tasks[0].permissions).toEqual({
178
+ read: true,
179
+ write: true,
180
+ execute: false,
181
+ });
182
+ });
183
+
184
+ test('deresolvePipeline preserves pipeline-level permissions without repeating inherited values', () => {
185
+ const resolved: PipelineConfig = {
186
+ name: 'Deresolve Permissions',
187
+ permissions: { read: true, write: true, execute: false },
188
+ tracks: [
189
+ {
190
+ id: 'track_a',
191
+ name: 'Track A',
192
+ permissions: { read: true, write: true, execute: false },
193
+ cwd: 'D:/workspace',
194
+ tasks: [
195
+ {
196
+ id: 'task_1',
197
+ name: 'Task 1',
198
+ prompt: 'hello',
199
+ permissions: { read: true, write: true, execute: false },
200
+ cwd: 'D:/workspace',
201
+ },
202
+ ],
203
+ },
204
+ ],
205
+ };
206
+
207
+ const raw = deresolvePipeline(resolved, 'D:/workspace');
208
+
209
+ expect(raw.permissions).toEqual({ read: true, write: true, execute: false });
210
+ expect(raw.tracks[0].permissions).toBeUndefined();
211
+ expect(raw.tracks[0].tasks[0].permissions).toBeUndefined();
212
+ });
213
+ });
package/src/schema.ts CHANGED
@@ -17,13 +17,17 @@ import { buildDag } from './dag';
17
17
  // ═══ YAML Parsing ═══
18
18
 
19
19
  export function parseYaml(content: string): RawPipelineConfig {
20
- const doc = yaml.load(content) as { pipeline?: RawPipelineConfig };
20
+ const doc = yaml.load(content) as { pipeline?: unknown };
21
21
  if (!doc?.pipeline) {
22
22
  throw new Error('YAML must contain a top-level "pipeline" key');
23
23
  }
24
- const p = doc.pipeline;
24
+ if (typeof doc.pipeline !== 'object' || Array.isArray(doc.pipeline)) {
25
+ throw new Error('pipeline must be an object');
26
+ }
27
+ const p = doc.pipeline as RawPipelineConfig;
25
28
  if (!p.name) throw new Error('pipeline.name is required');
26
- if (!p.tracks || p.tracks.length === 0) throw new Error('pipeline.tracks must be non-empty');
29
+ if (!Array.isArray(p.tracks)) throw new Error('pipeline.tracks must be an array');
30
+ if (p.tracks.length === 0) throw new Error('pipeline.tracks must be non-empty');
27
31
 
28
32
  // D14: Detect duplicate track IDs before per-track validation so the error
29
33
  // message is clear ("Duplicate track id") rather than a confusing DAG error
@@ -60,10 +64,16 @@ function assertValidId(id: string, label: string): void {
60
64
  }
61
65
 
62
66
  function validateRawTrack(track: RawTrackConfig): void {
67
+ if (!track || typeof track !== 'object' || Array.isArray(track)) {
68
+ throw new Error('track must be an object');
69
+ }
63
70
  if (!track.id) throw new Error('track.id is required');
64
71
  assertValidId(track.id, `track "${track.id}"`);
65
72
  if (!track.name) throw new Error(`track "${track.id}": name is required`);
66
- if (!track.tasks || track.tasks.length === 0) {
73
+ if (!Array.isArray(track.tasks)) {
74
+ throw new Error(`track "${track.id}": tasks must be an array`);
75
+ }
76
+ if (track.tasks.length === 0) {
67
77
  throw new Error(`track "${track.id}": tasks must be non-empty`);
68
78
  }
69
79
  for (const task of track.tasks) {
@@ -72,6 +82,9 @@ function validateRawTrack(track: RawTrackConfig): void {
72
82
  }
73
83
 
74
84
  function validateRawTask(task: RawTaskConfig, trackId: string): void {
85
+ if (!task || typeof task !== 'object' || Array.isArray(task)) {
86
+ throw new Error(`track "${trackId}": task must be an object`);
87
+ }
75
88
  if (!task.id) throw new Error(`track "${trackId}": task.id is required`);
76
89
  assertValidId(task.id, `task "${task.id}" in track "${trackId}"`);
77
90
 
@@ -140,7 +153,7 @@ export function resolveConfig(raw: RawPipelineConfig, workDir: string): Pipeline
140
153
  model: rawTask.model ?? rawTrack.model ?? raw.model,
141
154
  reasoning_effort:
142
155
  rawTask.reasoning_effort ?? rawTrack.reasoning_effort ?? raw.reasoning_effort,
143
- permissions: rawTask.permissions ?? rawTrack.permissions ?? DEFAULT_PERMISSIONS,
156
+ permissions: rawTask.permissions ?? rawTrack.permissions ?? raw.permissions ?? DEFAULT_PERMISSIONS,
144
157
  driver: rawTask.driver ?? trackDriver ?? 'opencode',
145
158
  timeout: rawTask.timeout,
146
159
  // Middleware: Task-level overrides Track (including [] to disable)
@@ -161,7 +174,7 @@ export function resolveConfig(raw: RawPipelineConfig, workDir: string): Pipeline
161
174
  agent_profile: rawTrack.agent_profile,
162
175
  model: rawTrack.model ?? raw.model,
163
176
  reasoning_effort: rawTrack.reasoning_effort ?? raw.reasoning_effort,
164
- permissions: rawTrack.permissions ?? DEFAULT_PERMISSIONS,
177
+ permissions: rawTrack.permissions ?? raw.permissions ?? DEFAULT_PERMISSIONS,
165
178
  driver: trackDriver ?? 'opencode',
166
179
  cwd: trackCwd,
167
180
  middlewares: rawTrack.middlewares,
@@ -175,6 +188,7 @@ export function resolveConfig(raw: RawPipelineConfig, workDir: string): Pipeline
175
188
  driver: raw.driver,
176
189
  model: raw.model,
177
190
  reasoning_effort: raw.reasoning_effort,
191
+ permissions: raw.permissions,
178
192
  timeout: raw.timeout,
179
193
  plugins: raw.plugins,
180
194
  hooks: raw.hooks,
@@ -208,14 +222,32 @@ function stripDefaultTaskCompletion<T extends { completion?: CompletionConfig }>
208
222
  return rest as T;
209
223
  }
210
224
 
211
- function stripDefaultCompletionsForSerialization<T extends PipelineConfig | RawPipelineConfig>(
225
+ // `continue_from` is a prompt-only field — it tells AI drivers with
226
+ // session-resume capability to thread off an upstream prompt task's context.
227
+ // A command task runs as a plain shell subprocess and has no session to
228
+ // resume, so any `continue_from` on a command task is dead weight. Drop it
229
+ // at serialization time so YAML on disk never carries the stale field after
230
+ // a user toggles task mode from prompt → command. The tagma-yaml agent's
231
+ // system prompt (apps/editor/server/opencode-seed.ts) documents this
232
+ // stripping — keep them in sync.
233
+ function stripPromptOnlyFieldsFromCommandTask<
234
+ T extends { command?: string; continue_from?: string },
235
+ >(task: T): T {
236
+ if (task.command === undefined || task.continue_from === undefined) return task;
237
+ const { continue_from: _cf, ...rest } = task;
238
+ return rest as T;
239
+ }
240
+
241
+ function stripForSerialization<T extends PipelineConfig | RawPipelineConfig>(
212
242
  config: T,
213
243
  ): T {
214
244
  return {
215
245
  ...config,
216
246
  tracks: config.tracks.map((track) => ({
217
247
  ...track,
218
- tasks: track.tasks.map((task) => stripDefaultTaskCompletion(task)),
248
+ tasks: track.tasks.map((task) =>
249
+ stripPromptOnlyFieldsFromCommandTask(stripDefaultTaskCompletion(task)),
250
+ ),
219
251
  })),
220
252
  } as T;
221
253
  }
@@ -228,7 +260,7 @@ function stripDefaultCompletionsForSerialization<T extends PipelineConfig | RawP
228
260
  */
229
261
  export function serializePipeline(config: PipelineConfig | RawPipelineConfig): string {
230
262
  return yaml.dump(
231
- { pipeline: stripDefaultCompletionsForSerialization(config) },
263
+ { pipeline: stripForSerialization(config) },
232
264
  { lineWidth: 120, indent: 2 },
233
265
  );
234
266
  }
@@ -302,7 +334,7 @@ export function deresolvePipeline(config: PipelineConfig, workDir: string): RawP
302
334
  ...(track.on_failure && track.on_failure !== 'skip_downstream'
303
335
  ? { on_failure: track.on_failure }
304
336
  : {}),
305
- ...(track.permissions && !permissionsEqual(track.permissions, DEFAULT_PERMISSIONS)
337
+ ...(track.permissions && !permissionsEqual(track.permissions, config.permissions ?? DEFAULT_PERMISSIONS)
306
338
  ? { permissions: track.permissions }
307
339
  : {}),
308
340
  tasks,
@@ -314,6 +346,9 @@ export function deresolvePipeline(config: PipelineConfig, workDir: string): RawP
314
346
  ...(config.driver ? { driver: config.driver } : {}),
315
347
  ...(config.model ? { model: config.model } : {}),
316
348
  ...(config.reasoning_effort ? { reasoning_effort: config.reasoning_effort } : {}),
349
+ ...(config.permissions && !permissionsEqual(config.permissions, DEFAULT_PERMISSIONS)
350
+ ? { permissions: config.permissions }
351
+ : {}),
317
352
  ...(config.timeout ? { timeout: config.timeout } : {}),
318
353
  ...(config.plugins?.length ? { plugins: config.plugins } : {}),
319
354
  ...(config.hooks ? { hooks: config.hooks } : {}),
package/src/task-ref.ts CHANGED
@@ -68,6 +68,7 @@ export function buildTaskIndex(config: RawPipelineConfig | PipelineConfig): Task
68
68
  const bareToQualified = new Map<string, string>();
69
69
  for (const track of config.tracks ?? []) {
70
70
  if (!track?.id) continue;
71
+ if (!Array.isArray(track.tasks)) continue;
71
72
  for (const task of track.tasks ?? []) {
72
73
  if (!task?.id) continue;
73
74
  const qid = qualifyTaskId(track.id, task.id);
@@ -0,0 +1,28 @@
1
+ import { describe, expect, test } from 'bun:test';
2
+ import { mkdirSync, mkdtempSync, rmSync } from 'node:fs';
3
+ import { tmpdir } from 'node:os';
4
+ import { join } from 'node:path';
5
+ import { validatePath } from './utils';
6
+
7
+ describe('validatePath', () => {
8
+ test('rejects real parent traversal outside the project root', () => {
9
+ const root = mkdtempSync(join(tmpdir(), 'tagma-validate-path-'));
10
+ try {
11
+ expect(() => validatePath('../outside', root)).toThrow(/escapes project root/);
12
+ } finally {
13
+ rmSync(root, { recursive: true, force: true });
14
+ }
15
+ });
16
+
17
+ test('allows project-local names that merely start with two dots', () => {
18
+ const root = mkdtempSync(join(tmpdir(), 'tagma-validate-path-'));
19
+ try {
20
+ const inside = join(root, '..inside');
21
+ mkdirSync(inside);
22
+
23
+ expect(validatePath('..inside', root)).toBe(inside);
24
+ } finally {
25
+ rmSync(root, { recursive: true, force: true });
26
+ }
27
+ });
28
+ });
package/src/utils.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { resolve, relative, parse as parsePath } from 'path';
1
+ import { isAbsolute, resolve, relative, parse as parsePath, sep } from 'path';
2
2
  import { realpathSync, lstatSync, existsSync } from 'fs';
3
3
  import { randomBytes } from 'crypto';
4
4
 
@@ -38,7 +38,7 @@ export function validatePath(filePath: string, projectRoot: string): string {
38
38
  }
39
39
 
40
40
  const rel = relative(projectRoot, resolved);
41
- if (rel.startsWith('..') || rel.startsWith('/')) {
41
+ if (rel === '..' || rel.startsWith(`..${sep}`) || isAbsolute(rel)) {
42
42
  throw new Error(
43
43
  `Security: path "${filePath}" escapes project root. ` +
44
44
  `All file references must be within "${projectRoot}".`,
@@ -83,7 +83,7 @@ export function validatePath(filePath: string, projectRoot: string): string {
83
83
  );
84
84
  }
85
85
  const realRel = relative(realRoot, real);
86
- if (realRel.startsWith('..') || realRel.startsWith('/')) {
86
+ if (realRel === '..' || realRel.startsWith(`..${sep}`) || isAbsolute(realRel)) {
87
87
  throw new Error(
88
88
  `Security: path "${filePath}" resolves via symlink to "${real}" which escapes project root "${realRoot}".`,
89
89
  );
@@ -0,0 +1,60 @@
1
+ import { describe, expect, test } from 'bun:test';
2
+ import { compileYamlContent } from './yaml-compiler';
3
+ import { validateRaw } from './validate-raw';
4
+ import type { RawPipelineConfig } from './types';
5
+
6
+ describe('validateRaw known plugin types', () => {
7
+ test('warns when pipeline, track, or prompt task references an unknown driver', () => {
8
+ const config: RawPipelineConfig = {
9
+ name: 'driver checks',
10
+ driver: 'missing-pipeline',
11
+ tracks: [
12
+ {
13
+ id: 'main',
14
+ name: 'Main',
15
+ driver: 'missing-track',
16
+ tasks: [
17
+ {
18
+ id: 'prompt',
19
+ name: 'Prompt',
20
+ prompt: 'hello',
21
+ driver: 'missing-task',
22
+ },
23
+ ],
24
+ },
25
+ ],
26
+ };
27
+
28
+ const diagnostics = validateRaw(config, { drivers: ['opencode'] });
29
+ expect(diagnostics.map((d) => d.message)).toEqual(
30
+ expect.arrayContaining([
31
+ 'Unknown driver type "missing-pipeline"',
32
+ 'Unknown driver type "missing-track"',
33
+ 'Unknown driver type "missing-task"',
34
+ ]),
35
+ );
36
+ });
37
+
38
+ test('compileYamlContent includes unknown driver warnings from knownTypes.drivers', () => {
39
+ const result = compileYamlContent(
40
+ [
41
+ 'pipeline:',
42
+ ' name: Unknown Driver',
43
+ ' driver: ghost',
44
+ ' tracks:',
45
+ ' - id: main',
46
+ ' name: Main',
47
+ ' tasks:',
48
+ ' - id: prompt',
49
+ ' name: Prompt',
50
+ ' prompt: Hello',
51
+ '',
52
+ ].join('\n'),
53
+ { knownTypes: { drivers: ['opencode'] } },
54
+ );
55
+
56
+ expect(result.validation.warnings.map((w) => w.message)).toContain(
57
+ 'Unknown driver type "ghost"',
58
+ );
59
+ });
60
+ });