@gaia-ai/conductor 0.9.2 → 0.11.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.
@@ -43,11 +43,60 @@ export declare const RENDERER_PLUGIN_ENTRY = "{ plugin: '@gaia-ai/addon-essentia
43
43
  * hand-reshaped file) is left alone rather than guessed at.
44
44
  */
45
45
  export declare function addRendererPluginEntry(text: string): string;
46
+ /** The dropsh plugin package whose entries carry a connection's OAuth identity. */
47
+ export declare const OAUTH2_PLUGIN_PACKAGE = "@dropsh/plugin-oauth2";
48
+ /**
49
+ * The id of the ONE oauth2 provider a GAIA connection declares (GAIA-391 D1).
50
+ *
51
+ * `gaia`, not `session`: the entry names the CONNECTION, not a capability. Until
52
+ * v3 a config declared two providers that differed only by scope — `session`
53
+ * (`gaia:session`) and `pm` (`gaia:project_manager`) — and every write had to
54
+ * pick one. What that choice cost is what GAIA-391 removes; the id therefore
55
+ * names the connection, and the surviving scope carries the capability.
56
+ */
57
+ export declare const OAUTH2_PROVIDER_ID = "gaia";
58
+ /**
59
+ * The ONE OAuth2 scope a GAIA connection requests (GAIA-391 D1, as revised).
60
+ *
61
+ * There is exactly one, and there is not zero. `gaia:project_manager` is
62
+ * retired; `gaia:session` survives and its role carries the ceiling of what a
63
+ * machine identity may do.
64
+ *
65
+ * Zero was the original design, and a live control plane refuted it:
66
+ * `simple_oauth`'s Oauth2AccessPolicy rewrites a token account's permissions
67
+ * with `overwrite: TRUE`, and for a `client_credentials` token — every GAIA
68
+ * machine identity — the set becomes EXACTLY the scope's permissions, with
69
+ * neither user_role nor OG-rank permissions surviving. A request naming no scope
70
+ * against a consumer holding no default scopes does not even mint a token.
71
+ */
72
+ export declare const OAUTH2_SCOPE = "gaia:session";
73
+ /**
74
+ * GAIA-391 (v2 → v3): collapse a connection's oauth2 entries into ONE `gaia`
75
+ * provider on ONE scope — the whole of the one-identity change, as a pure text
76
+ * transform.
77
+ *
78
+ * A MIGRATION rather than a cutover (spec D3): operators have hand-edited these
79
+ * files — a pinned literal `baseUrl`, a per-install `workspace_id`, an extra
80
+ * `addons[]` entry — so every value outside the two elements being merged is
81
+ * carried over verbatim, and the runner never EXECUTES a possibly
82
+ * side-effecting config module.
83
+ *
84
+ * The FIRST oauth2 element survives (it is the one v2 marked `default: true`);
85
+ * the rest are removed line-and-all. A config with no oauth2 element is left
86
+ * exactly as it is — that is the one silent no-op, and it is silent because
87
+ * there is nothing to collapse. An element that IS there but cannot be given the
88
+ * `gaia` identity throws (see {@link ensureProviderId}) rather than passing
89
+ * through unchanged.
90
+ */
91
+ export declare function collapseOauth2Providers(text: string): string;
46
92
  /**
47
93
  * The ordered, contiguous migration chain. v1 was the pre-GAIA-226 shape (auth
48
- * plugins only); v2 adds the markdown renderer entry. Nothing below v1 is a
49
- * versioned config (v0 = seed / hand-authored / untouched). `GAIA_CONFIG_SCHEMA_VERSION`
50
- * derives from the chain's last `to` never hardcode it.
94
+ * plugins only); v2 adds the markdown renderer entry; v3 collapses the two
95
+ * differently-scoped oauth2 providers into ONE identity on ONE scope (GAIA-391)
96
+ * one, not zero: see {@link OAUTH2_SCOPE} for why the scope survives.
97
+ * Nothing below v1 is a versioned config (v0 = seed / hand-authored / untouched).
98
+ * `GAIA_CONFIG_SCHEMA_VERSION` derives from the chain's last `to` — never
99
+ * hardcode it.
51
100
  */
52
101
  export declare const CONNECTION_MIGRATIONS: ConnectionConfigMigration[];
53
102
  /** The current connection-config schema version — single source of truth. */
@@ -27,6 +27,19 @@ export const RENDERER_PLUGIN_ENTRY = `{ plugin: '${RENDERER_PLUGIN_PACKAGE}' },`
27
27
  const PLUGINS_ARRAY_OPEN_RE = /^([ \t]*)plugins:\s*\[[ \t]*$/m;
28
28
  /** Whole-line `//` comments — stripped before the presence probe (see below). */
29
29
  const LINE_COMMENT_RE = /^[ \t]*\/\/.*$/gm;
30
+ /**
31
+ * Blank out whole-line `//` comments WITHOUT moving a single byte.
32
+ *
33
+ * `addRendererPluginEntry` can simply delete them, because it only asks a
34
+ * yes/no question. The v2→v3 collapse below needs OFFSETS into the real text,
35
+ * so a comment has to become the same number of spaces rather than disappear:
36
+ * the shipped connection config explains itself in prose that names both
37
+ * `@dropsh/plugin-oauth2` and a `{ site, plugins }` shape, and a scan that read
38
+ * those as code would splice a comment fragment.
39
+ */
40
+ function maskLineComments(text) {
41
+ return text.replace(LINE_COMMENT_RE, (line) => ' '.repeat(line.length));
42
+ }
30
43
  /**
31
44
  * GAIA-226 (v1 → v2): insert the renderer aggregator as the FIRST `plugins[]`
32
45
  * element, so `gaia dropsh --format md` works instead of failing with
@@ -49,11 +62,219 @@ export function addRendererPluginEntry(text) {
49
62
  const indent = `${m[1]} `;
50
63
  return text.replace(PLUGINS_ARRAY_OPEN_RE, (line) => `${line}\n${indent}${RENDERER_PLUGIN_ENTRY}`);
51
64
  }
65
+ /** The dropsh plugin package whose entries carry a connection's OAuth identity. */
66
+ export const OAUTH2_PLUGIN_PACKAGE = '@dropsh/plugin-oauth2';
67
+ /**
68
+ * The id of the ONE oauth2 provider a GAIA connection declares (GAIA-391 D1).
69
+ *
70
+ * `gaia`, not `session`: the entry names the CONNECTION, not a capability. Until
71
+ * v3 a config declared two providers that differed only by scope — `session`
72
+ * (`gaia:session`) and `pm` (`gaia:project_manager`) — and every write had to
73
+ * pick one. What that choice cost is what GAIA-391 removes; the id therefore
74
+ * names the connection, and the surviving scope carries the capability.
75
+ */
76
+ export const OAUTH2_PROVIDER_ID = 'gaia';
77
+ /**
78
+ * The ONE OAuth2 scope a GAIA connection requests (GAIA-391 D1, as revised).
79
+ *
80
+ * There is exactly one, and there is not zero. `gaia:project_manager` is
81
+ * retired; `gaia:session` survives and its role carries the ceiling of what a
82
+ * machine identity may do.
83
+ *
84
+ * Zero was the original design, and a live control plane refuted it:
85
+ * `simple_oauth`'s Oauth2AccessPolicy rewrites a token account's permissions
86
+ * with `overwrite: TRUE`, and for a `client_credentials` token — every GAIA
87
+ * machine identity — the set becomes EXACTLY the scope's permissions, with
88
+ * neither user_role nor OG-rank permissions surviving. A request naming no scope
89
+ * against a consumer holding no default scopes does not even mint a token.
90
+ */
91
+ export const OAUTH2_SCOPE = 'gaia:session';
92
+ /**
93
+ * Every `plugins[]` element that names {@link OAUTH2_PLUGIN_PACKAGE}, in order.
94
+ *
95
+ * Brace-balanced from the `{` that opens the element (the package name is the
96
+ * first key of every entry the scaffold and the migrations write) to its match,
97
+ * absorbing the trailing comma so an element can be removed whole. Comments are
98
+ * MASKED, not stripped, so these offsets index the real text.
99
+ *
100
+ * A `${…}` inside a template literal is brace-balanced too, which is why
101
+ * `token_url: \`${baseUrl}/oauth/token\`` needs no special case. An UNBALANCED
102
+ * file yields nothing at all rather than a guessed span — the migration then
103
+ * leaves it untouched, which is the same refusal `addRendererPluginEntry` makes
104
+ * for a config with no `plugins: [` literal.
105
+ */
106
+ function oauth2EntrySpans(text) {
107
+ const probe = maskLineComments(text);
108
+ const spans = [];
109
+ let from = 0;
110
+ for (;;) {
111
+ const hit = probe.indexOf(OAUTH2_PLUGIN_PACKAGE, from);
112
+ if (hit === -1)
113
+ return spans;
114
+ const start = probe.lastIndexOf('{', hit);
115
+ if (start === -1) {
116
+ from = hit + OAUTH2_PLUGIN_PACKAGE.length;
117
+ continue;
118
+ }
119
+ let depth = 0;
120
+ let i = start;
121
+ for (; i < probe.length; i += 1) {
122
+ if (probe[i] === '{')
123
+ depth += 1;
124
+ else if (probe[i] === '}') {
125
+ depth -= 1;
126
+ if (depth === 0) {
127
+ i += 1;
128
+ break;
129
+ }
130
+ }
131
+ }
132
+ if (depth !== 0)
133
+ return [];
134
+ let end = i;
135
+ while (probe[end] === ' ' || probe[end] === '\t')
136
+ end += 1;
137
+ if (probe[end] === ',')
138
+ end += 1;
139
+ spans.push({ start, end });
140
+ from = end;
141
+ }
142
+ }
143
+ /** Grow a span to its whole line, when the line holds nothing else. */
144
+ function wholeLineSpan(text, span) {
145
+ const lineStart = text.lastIndexOf('\n', span.start) + 1;
146
+ const start = text.slice(lineStart, span.start).trim() === '' ? lineStart : span.start;
147
+ const newline = text.indexOf('\n', span.end);
148
+ const end = newline !== -1 && text.slice(span.end, newline).trim() === ''
149
+ ? newline + 1
150
+ : span.end;
151
+ return { start, end };
152
+ }
153
+ const SCOPE_VALUE = /(?:'[^']*'|"[^"]*")/.source;
154
+ /** Every `scope: '…'`, for rewriting the VALUE in place. */
155
+ const SCOPE_KEY_RE = new RegExp(`(\\bscope:\\s*)${SCOPE_VALUE}`, 'g');
156
+ /** Whether the element names a scope at all. Non-global: `.test` must not seek. */
157
+ const SCOPE_PRESENT_RE = new RegExp(`\\bscope:\\s*${SCOPE_VALUE}`);
158
+ const ID_LINE_RE = /^([ \t]*)id:\s*'[^']*',[ \t]*\r?\n/m;
159
+ const ID_INLINE_RE = /\bid:\s*'[^']*',/;
160
+ /** Whether the element names an id at all — no trailing comma required. */
161
+ const ID_PRESENT_RE = /\bid:\s*'[^']*'/;
162
+ /** `with: {` as the last thing on its line — the fallback insertion anchor. */
163
+ const WITH_LINE_RE = /^([ \t]*)with:\s*\{[ \t]*\r?\n/m;
164
+ /** `with: {` anywhere — the same anchor for a single-line `with: { … }`. */
165
+ const WITH_INLINE_RE = /\bwith:\s*\{/;
166
+ /**
167
+ * Ensure the element names {@link OAUTH2_PROVIDER_ID} as its provider id.
168
+ *
169
+ * The id is also the ANCHOR the two other v3 edits insert against, so resolving
170
+ * it first is what makes them reachable. An element that already names an id has
171
+ * that value rewritten; one that names none gets the key inserted at the head of
172
+ * its `with: { … }` block.
173
+ *
174
+ * An element with neither THROWS. That is the GAIA-391 round-2 correction: the
175
+ * first round used the `id:` key as its only anchor and simply returned such an
176
+ * element verbatim, so `gaia upgrade` re-stamped the file to v3 and reported
177
+ * success while leaving a provider dropsh cannot resolve — no id to select it by
178
+ * and no `default: true` to make it the fallback, i.e. a config that mints
179
+ * nothing. Silent success on a config that was not migrated is worse than a
180
+ * refusal the operator can act on, and the runner has already written the
181
+ * `.v<N>.bak` before this point, so the original is never lost.
182
+ */
183
+ function ensureProviderId(entry) {
184
+ if (ID_PRESENT_RE.test(entry)) {
185
+ return entry.replace(/(\bid:\s*)'[^']*'/, `$1'${OAUTH2_PROVIDER_ID}'`);
186
+ }
187
+ const lineForm = entry.replace(WITH_LINE_RE, (line, indent) => `${line}${indent} id: '${OAUTH2_PROVIDER_ID}',\n`);
188
+ if (lineForm !== entry)
189
+ return lineForm;
190
+ const inlineForm = entry.replace(WITH_INLINE_RE, (open) => `${open} id: '${OAUTH2_PROVIDER_ID}',`);
191
+ if (inlineForm !== entry)
192
+ return inlineForm;
193
+ throw new Error(`cannot migrate a ${OAUTH2_PLUGIN_PACKAGE} entry that names neither an ` +
194
+ `\`id:\` key nor a \`with: { … }\` block: there is nowhere to put the ` +
195
+ `single '${OAUTH2_PROVIDER_ID}' identity. Give the entry a ` +
196
+ `\`with: { id: '${OAUTH2_PROVIDER_ID}', scope: '${OAUTH2_SCOPE}', … }\` ` +
197
+ `block by hand, then re-run \`gaia upgrade\`.`);
198
+ }
199
+ /**
200
+ * Rewrite ONE oauth2 element into the single `gaia` provider.
201
+ *
202
+ * Three edits, each idempotent on already-collapsed text: the id becomes
203
+ * {@link OAUTH2_PROVIDER_ID} (inserted when the element names none — see
204
+ * {@link ensureProviderId}, which refuses loudly when it cannot be placed); the
205
+ * scope is normalised to the one surviving scope {@link OAUTH2_SCOPE}, and ADDED
206
+ * when the element names none; and `default: true` is asserted, because with one
207
+ * provider the resolver must be able to pick it with no profile argument.
208
+ *
209
+ * The scope is kept rather than deleted, and that is the correction GAIA-391
210
+ * measured rather than assumed. `simple_oauth`'s Oauth2AccessPolicy rewrites a
211
+ * token account's permissions with `overwrite: TRUE`, and for a
212
+ * `client_credentials` token the result is EXACTLY the scope's permissions — so
213
+ * a scopeless config either fails to mint at all (`invalid_request`, hint
214
+ * `Check the scope parameter`) or silently inherits whatever the consumer's
215
+ * default scopes happen to be. The rank still decides WHICH rows are reachable;
216
+ * the scope decides which operations exist at all.
217
+ */
218
+ function collapseOauth2Entry(entry) {
219
+ let out = ensureProviderId(entry);
220
+ if (SCOPE_PRESENT_RE.test(out)) {
221
+ out = out.replace(SCOPE_KEY_RE, `$1'${OAUTH2_SCOPE}'`);
222
+ }
223
+ else {
224
+ const lineForm = out.replace(ID_LINE_RE, (line, indent) => `${line}${indent}scope: '${OAUTH2_SCOPE}',\n`);
225
+ out =
226
+ lineForm !== out
227
+ ? lineForm
228
+ : out.replace(ID_INLINE_RE, (id) => `${id} scope: '${OAUTH2_SCOPE}',`);
229
+ }
230
+ if (/\bdefault:\s*true\b/.test(out))
231
+ return out;
232
+ const lineForm = out.replace(ID_LINE_RE, (line, indent) => `${line}${indent}default: true,\n`);
233
+ if (lineForm !== out)
234
+ return lineForm;
235
+ return out.replace(ID_INLINE_RE, (id) => `${id} default: true,`);
236
+ }
237
+ /**
238
+ * GAIA-391 (v2 → v3): collapse a connection's oauth2 entries into ONE `gaia`
239
+ * provider on ONE scope — the whole of the one-identity change, as a pure text
240
+ * transform.
241
+ *
242
+ * A MIGRATION rather than a cutover (spec D3): operators have hand-edited these
243
+ * files — a pinned literal `baseUrl`, a per-install `workspace_id`, an extra
244
+ * `addons[]` entry — so every value outside the two elements being merged is
245
+ * carried over verbatim, and the runner never EXECUTES a possibly
246
+ * side-effecting config module.
247
+ *
248
+ * The FIRST oauth2 element survives (it is the one v2 marked `default: true`);
249
+ * the rest are removed line-and-all. A config with no oauth2 element is left
250
+ * exactly as it is — that is the one silent no-op, and it is silent because
251
+ * there is nothing to collapse. An element that IS there but cannot be given the
252
+ * `gaia` identity throws (see {@link ensureProviderId}) rather than passing
253
+ * through unchanged.
254
+ */
255
+ export function collapseOauth2Providers(text) {
256
+ const spans = oauth2EntrySpans(text);
257
+ const first = spans[0];
258
+ if (first === undefined)
259
+ return text;
260
+ let out = text;
261
+ // From the END, so the surviving element's offsets stay valid.
262
+ for (const span of spans.slice(1).reverse()) {
263
+ const line = wholeLineSpan(out, span);
264
+ out = out.slice(0, line.start) + out.slice(line.end);
265
+ }
266
+ return (out.slice(0, first.start) +
267
+ collapseOauth2Entry(out.slice(first.start, first.end)) +
268
+ out.slice(first.end));
269
+ }
52
270
  /**
53
271
  * The ordered, contiguous migration chain. v1 was the pre-GAIA-226 shape (auth
54
- * plugins only); v2 adds the markdown renderer entry. Nothing below v1 is a
55
- * versioned config (v0 = seed / hand-authored / untouched). `GAIA_CONFIG_SCHEMA_VERSION`
56
- * derives from the chain's last `to` never hardcode it.
272
+ * plugins only); v2 adds the markdown renderer entry; v3 collapses the two
273
+ * differently-scoped oauth2 providers into ONE identity on ONE scope (GAIA-391)
274
+ * one, not zero: see {@link OAUTH2_SCOPE} for why the scope survives.
275
+ * Nothing below v1 is a versioned config (v0 = seed / hand-authored / untouched).
276
+ * `GAIA_CONFIG_SCHEMA_VERSION` derives from the chain's last `to` — never
277
+ * hardcode it.
57
278
  */
58
279
  export const CONNECTION_MIGRATIONS = [
59
280
  {
@@ -62,6 +283,13 @@ export const CONNECTION_MIGRATIONS = [
62
283
  description: `add the ${RENDERER_PLUGIN_PACKAGE} renderer entry to plugins[]`,
63
284
  apply: addRendererPluginEntry,
64
285
  },
286
+ {
287
+ from: 2,
288
+ to: 3,
289
+ description: `collapse the two ${OAUTH2_PLUGIN_PACKAGE} entries into one ` +
290
+ `'${OAUTH2_PROVIDER_ID}' provider on scope '${OAUTH2_SCOPE}'`,
291
+ apply: collapseOauth2Providers,
292
+ },
65
293
  ];
66
294
  /** The current connection-config schema version — single source of truth. */
67
295
  export const GAIA_CONFIG_SCHEMA_VERSION = CONNECTION_MIGRATIONS.at(-1)?.to ?? 1;
@@ -50,8 +50,10 @@ export declare function renderCommittedConfig(inputs: Pick<InitInputs, 'project'
50
50
  * The committed CONNECTION config `./.gaia/gaia.config.js` (GAIA-201). A
51
51
  * dropsh-shaped `{ site, plugins }` read by `gaia ui`, `gaia dropsh`, and the
52
52
  * conductor's auth. Reads base_url + client credentials from the user-global
53
- * machine context (`~/.gaia/machine.config.js`) and declares the session
54
- * (default) + pm oauth2 profiles.
53
+ * machine context (`~/.gaia/machine.config.js`) and declares the ONE default
54
+ * oauth2 provider, `gaia`, on the one scope `gaia:session` (GAIA-391). The
55
+ * scope is the capability ceiling for a client_credentials token; the rank an
56
+ * identity holds in a workspace decides which rows it reaches.
55
57
  */
56
58
  export declare function renderGaiaConfig(): string;
57
59
  /** The user-global machine context module: identity + connection (incl. secret). */
@@ -2,7 +2,7 @@ import { chmodSync, existsSync, mkdirSync, writeFileSync } from 'node:fs';
2
2
  import { hostname } from 'node:os';
3
3
  import { dirname, join } from 'node:path';
4
4
  import { machineContextPath, readMachineContext, } from '@gaia-ai/core';
5
- import { GAIA_CONFIG_SCHEMA_VERSION, RENDERER_PLUGIN_ENTRY, } from './config-schema.js';
5
+ import { GAIA_CONFIG_SCHEMA_VERSION, OAUTH2_PROVIDER_ID, OAUTH2_SCOPE, RENDERER_PLUGIN_ENTRY, } from './config-schema.js';
6
6
  export { machineContextPath, readMachineContext };
7
7
  /** JS single-quoted string literal for a trusted, simple value. */
8
8
  function q(value) {
@@ -56,16 +56,17 @@ async function loadLocal() {
56
56
  const machine = await loadMachine();
57
57
  const local = await loadLocal();
58
58
  const project = local.project ?? ${q(inputs.project)};
59
- const composedMachineId =
59
+ const composedConductorId =
60
60
  machine.user_id && machine.machine_id
61
61
  ? \`\${machine.user_id}-\${machine.machine_id}-\${project}\`
62
62
  : undefined;
63
63
 
64
64
  export default {
65
65
  project,
66
- machine_id: local.machine_id ?? composedMachineId,
66
+ // Registration key (GAIA-353 renamed from machine_id). Host capacity lives on
67
+ // machine.config.js as max_parallel — do not set max_parallel here.
68
+ conductor_id: local.conductor_id ?? local.machine_id ?? composedConductorId,
67
69
  states: ['spec', 'diagnose', 'coding', 'review'],
68
- max_parallel: 5,
69
70
  // Lifecycle hooks are executor-owned (GAIA-84): they live at the config top
70
71
  // level — NOT on a plugin descriptor's \`with.hooks\`.
71
72
  hooks: { after_create: 'ddev init-worktree', after_done: 'ddev delete -Oy' },
@@ -88,8 +89,10 @@ export default {
88
89
  * The committed CONNECTION config `./.gaia/gaia.config.js` (GAIA-201). A
89
90
  * dropsh-shaped `{ site, plugins }` read by `gaia ui`, `gaia dropsh`, and the
90
91
  * conductor's auth. Reads base_url + client credentials from the user-global
91
- * machine context (`~/.gaia/machine.config.js`) and declares the session
92
- * (default) + pm oauth2 profiles.
92
+ * machine context (`~/.gaia/machine.config.js`) and declares the ONE default
93
+ * oauth2 provider, `gaia`, on the one scope `gaia:session` (GAIA-391). The
94
+ * scope is the capability ceiling for a client_credentials token; the rank an
95
+ * identity holds in a workspace decides which rows it reaches.
93
96
  */
94
97
  export function renderGaiaConfig() {
95
98
  return `// @gaia-schema-version ${GAIA_CONFIG_SCHEMA_VERSION}
@@ -121,37 +124,45 @@ const local = await loadLocal();
121
124
  const baseUrl = local.base_url ?? machine.base_url;
122
125
  const clientId = local.oauth?.client_id ?? machine.client_id ?? 'gaia-agent';
123
126
  const clientSecret = local.oauth?.client_secret ?? machine.client_secret;
127
+ // GAIA-351: the ambient workspace every create is bound to. REQUIRED — see the
128
+ // top-level \`workspace_id\` below.
129
+ const workspaceId = local.workspace_id ?? machine.workspace_id;
124
130
 
125
131
  export default {
126
132
  // GAIA-216: the connection-config schema version — kept in sync with the
127
133
  // header marker above so \`gaia upgrade\` can migrate a stale-shape config.
128
134
  schema_version: ${GAIA_CONFIG_SCHEMA_VERSION},
129
135
  site: { base_url: baseUrl, jsonapi_prefix: local.jsonapi_prefix ?? '/jsonapi' },
136
+ // GAIA-351: REQUIRED, and top-level — a sibling of \`site\`, never a member of
137
+ // it and never an addon's \`with\`. The filler that consumes it ships inside
138
+ // \`@gaia-ai/addon-essentials\`, which takes no options, so this is the only
139
+ // place a connection ever names its workspace. \`loadGaiaConfig\` refuses a
140
+ // config without it, so set \`workspace_id\` in ~/.gaia/machine.config.js (or
141
+ // this repo's conductor.config.local.js) to the uuid of the gaia_workspace
142
+ // this checkout creates into.
143
+ workspace_id: workspaceId,
130
144
  plugins: [
131
145
  ${RENDERER_PLUGIN_ENTRY}
146
+ // GAIA-391: ONE identity, on ONE scope. There used to be two entries here
147
+ // that differed only by the scope they requested, and every write had to
148
+ // pick one — that choice is what went away. The id names the CONNECTION,
149
+ // not a capability, which is why it is not named after a job.
150
+ //
151
+ // The scope is not ceremony: for a client_credentials token simple_oauth
152
+ // overwrites the account's permissions with EXACTLY the scope's, so this is
153
+ // the ceiling of what this identity may do. The rank it holds in a workspace
154
+ // still decides WHICH rows it reaches.
132
155
  {
133
156
  plugin: '@dropsh/plugin-oauth2',
134
157
  export: 'oauth2Plugin',
135
158
  with: {
136
- id: 'session',
159
+ id: '${OAUTH2_PROVIDER_ID}',
137
160
  default: true,
138
161
  type: 'oauth2_client_credentials',
139
162
  client_id: clientId,
140
163
  client_secret: clientSecret,
141
164
  token_url: \`\${baseUrl}/oauth/token\`,
142
- scope: 'gaia:session',
143
- },
144
- },
145
- {
146
- plugin: '@dropsh/plugin-oauth2',
147
- export: 'oauth2Plugin',
148
- with: {
149
- id: 'pm',
150
- type: 'oauth2_client_credentials',
151
- client_id: clientId,
152
- client_secret: clientSecret,
153
- token_url: \`\${baseUrl}/oauth/token\`,
154
- scope: 'gaia:project_manager',
165
+ scope: '${OAUTH2_SCOPE}',
155
166
  },
156
167
  },
157
168
  ],
@@ -160,18 +171,22 @@ export default {
160
171
  }
161
172
  /** The user-global machine context module: identity + connection (incl. secret). */
162
173
  export function renderMachineContext(ctx) {
174
+ const maxParallel = ctx.max_parallel ?? 1;
163
175
  return `// User-global GAIA machine context — gitignored, user-only (chmod 0600), never
164
176
  // committed. A plain importable module holding your machine identity +
165
177
  // connection, incl. the OAuth client secret. The engine conductor.config.js
166
- // composes machine_id (\`\${user_id}-\${machine_id}-\${project}\`) from it; the
167
- // connection gaia.config.js reads base_url / client_id / client_secret. Created
168
- // and gap-filled by \`gaia conductor init\`; existing values are never overwritten.
178
+ // composes conductor_id (\`\${user_id}-\${machine_id}-\${project}\`) from it; the
179
+ // connection gaia.config.js reads base_url / client_id / client_secret.
180
+ // max_parallel is the host concurrent-run budget (GAIA-353) not an engine
181
+ // config field. Created and gap-filled by \`gaia conductor init\`; existing
182
+ // values are never overwritten.
169
183
  export default {
170
184
  machine_id: ${q(ctx.machine_id)},
171
185
  user_id: ${q(ctx.user_id)},
172
186
  base_url: ${q(ctx.base_url)},
173
187
  client_id: ${q(ctx.client_id)},
174
188
  client_secret: ${q(ctx.client_secret)},
189
+ max_parallel: ${maxParallel},
175
190
  };
176
191
  `;
177
192
  }
@@ -189,6 +204,7 @@ export async function scaffoldMachineContext(opts) {
189
204
  base_url: opts.baseUrl,
190
205
  client_id: opts.clientId,
191
206
  client_secret: opts.secret,
207
+ max_parallel: 1,
192
208
  };
193
209
  const filledKeys = [];
194
210
  const merged = { ...derived, ...existing };
@@ -205,6 +221,12 @@ export async function scaffoldMachineContext(opts) {
205
221
  filledKeys.push(key);
206
222
  }
207
223
  }
224
+ if (typeof existing.max_parallel !== 'number' ||
225
+ !Number.isFinite(existing.max_parallel) ||
226
+ (existing.max_parallel ?? 0) < 1) {
227
+ merged.max_parallel = 1;
228
+ filledKeys.push('max_parallel');
229
+ }
208
230
  const created = !existsSync(opts.path);
209
231
  if (filledKeys.length > 0 || created) {
210
232
  mkdirSync(dirname(opts.path), { recursive: true });
@@ -4,6 +4,7 @@ import { createInterface } from 'node:readline';
4
4
  import { CommandRunner, createLogger, deriveConductorLiveness, exec, fetchUpdateNotice, loadGaiaConfig, machineContextPath, printVersionLine, readMachineContext, resolveConfigPath, setDefaultCommandRunner, signalVerifiedProcess, } from '@gaia-ai/core';
5
5
  import { Command } from 'commander';
6
6
  import { authStatus } from 'dropsh';
7
+ import { OAUTH2_PROVIDER_ID } from '../cli/config-schema.js';
7
8
  import { scaffold } from '../cli/init.js';
8
9
  import { runUpgrade } from '../cli/upgrade.js';
9
10
  import { composeConductorConfig, loadConductorConfig } from '../config.js';
@@ -36,14 +37,14 @@ function checkoutRootOf(config) {
36
37
  return dirname(config.config_path);
37
38
  }
38
39
  /**
39
- * The conductor's stable identity (gaia_conductor.machine_id). machine_id is
40
- * required — the loader throws without it — so lifecycle commands read it here,
41
- * never re-derive.
40
+ * The conductor's stable identity (gaia_conductor.conductor_id). Required
41
+ * the loader throws without it — so lifecycle commands read it here, never
42
+ * re-derive. Soft-reads legacy machine_id during the GAIA-353 cutover.
42
43
  */
43
44
  function conductorIdOf(config) {
44
- const id = config.machine_id;
45
+ const id = config.conductor_id ?? config.machine_id;
45
46
  if (id === undefined || id.trim() === '') {
46
- throw new Error('conductor config has no machine_id');
47
+ throw new Error('conductor config has no conductor_id');
47
48
  }
48
49
  return id;
49
50
  }
@@ -575,10 +576,15 @@ function registerInit(conductor) {
575
576
  ? `updated ${m.path} (filled: ${m.filledKeys.join(', ')})`
576
577
  : `kept ${m.path} (already complete)`);
577
578
  }
579
+ // GAIA-391: ONE login, because the scaffolded connection declares ONE
580
+ // oauth2 provider. The id is interpolated from OAUTH2_PROVIDER_ID — the
581
+ // same constant `renderGaiaConfig` and the v2→v3 migration emit — so
582
+ // this first-impression block can never name a provider the config does
583
+ // not have. It used to print two logins and `# both profiles present`;
584
+ // neither id resolved and that status line could not become true.
578
585
  console.log('\nNext steps:\n' +
579
- ' gaia dropsh auth login --provider session\n' +
580
- ' gaia dropsh auth login --provider pm\n' +
581
- ' gaia dropsh auth status # both profiles present');
586
+ ` gaia dropsh auth login --provider ${OAUTH2_PROVIDER_ID}\n` +
587
+ ' gaia dropsh auth status');
582
588
  });
583
589
  }
584
590
  /** The `conductor` command plugin the host mounts (GAIA-201). GAIA-224
@@ -1,7 +1,7 @@
1
1
  import { readdirSync } from 'node:fs';
2
2
  import { resolve } from 'node:path';
3
3
  import { pathToFileURL } from 'node:url';
4
- import { discoverAddons, emptyContributions, findDefaultExportObject, matchDelimiter, resolveConductorSlots, resolveModuleEslintStyle, scanTopLevelProperties, } from '@gaia-ai/core';
4
+ import { discoverAddons, emptyContributions, findDefaultExportObject, matchDelimiter, readMachineContext, resolveConductorSlots, resolveModuleEslintStyle, scanTopLevelProperties, } from '@gaia-ai/core';
5
5
  import { narrowConductorContributions } from './plugins/preset.js';
6
6
  // GAIA-201: the `.gaia/` walk-up + connection resolution moved to `@gaia-ai/core`
7
7
  // (`resolveConfigPath` / `resolveGaiaConfigPath` / `findGaiaDir`). Re-export the
@@ -187,18 +187,45 @@ export async function loadConductorConfig(configFile) {
187
187
  const states = Array.isArray(config.states)
188
188
  ? config.states.map((state) => requireNonEmptyString(state, 'states'))
189
189
  : [];
190
- // machine_id is required the config MUST set it; there is no derived
191
- // fallback. The id is defined in a single place (config.machine_id), which
192
- // the CLI lifecycle commands and registration all read, never re-derive. The
193
- // conductor label defaults to it (a conductor is identified by it). A repo's
194
- // committed config composes it from the machine context
195
- // (`${user_id}-${machine_id}-${project}`); with several named configs in one
196
- // .gaia/ (GAIA-126) each composes its own from its own project, so co-located
197
- // conductors get distinct identities with no filename-based fallback.
198
- const machineId = requireNonEmptyString(config.machine_id, 'machine_id');
190
+ // conductor_id is required (GAIA-353 renamed from machine_id). Prefer the new
191
+ // key; accept legacy machine_id with a one-shot warning. No derived fallback.
192
+ // A repo's committed config composes it from the machine context
193
+ // (`${user_id}-${host}-${project}`); co-located configs each compose their
194
+ // own from their project.
195
+ let conductorId;
196
+ if (typeof config.conductor_id === 'string' &&
197
+ config.conductor_id.trim() !== '') {
198
+ conductorId = config.conductor_id.trim();
199
+ }
200
+ else if (typeof config.machine_id === 'string' &&
201
+ config.machine_id.trim() !== '') {
202
+ console.warn(`[gaia] ${configPath}: engine key "machine_id" is deprecated; use "conductor_id" (GAIA-353)`);
203
+ conductorId = config.machine_id.trim();
204
+ }
205
+ else {
206
+ throw new Error(`conductor config requires non-empty conductor_id (legacy machine_id accepted)`);
207
+ }
199
208
  const label = typeof config.label === 'string' && config.label.trim() !== ''
200
209
  ? config.label
201
- : machineId;
210
+ : conductorId;
211
+ // Capacity SoT is the machine context (GAIA-353). Engine max_parallel is
212
+ // ignored with a warning so leftover configs do not reintroduce a per-process
213
+ // private quota.
214
+ if (config.max_parallel !== undefined) {
215
+ console.warn(`[gaia] ${configPath}: engine "max_parallel" is ignored; set max_parallel on ~/.gaia/machine.config.js (GAIA-353)`);
216
+ }
217
+ const machineCtx = await readMachineContext();
218
+ const maxParallel = typeof machineCtx.max_parallel === 'number' &&
219
+ Number.isFinite(machineCtx.max_parallel) &&
220
+ machineCtx.max_parallel >= 1
221
+ ? Math.floor(machineCtx.max_parallel)
222
+ : 1;
223
+ // Host stem for gaia_machine / registration (GAIA-353) — prefer the machine
224
+ // context id over hyphen-splitting the composed conductor_id.
225
+ const hostKey = typeof machineCtx.machine_id === 'string' &&
226
+ machineCtx.machine_id.trim() !== ''
227
+ ? machineCtx.machine_id.trim()
228
+ : undefined;
202
229
  // GAIA-215: discover the conductor `addons: []` surface (last-wins singletons,
203
230
  // agent candidate list). A pre-existing per-slot descriptor still loads and
204
231
  // WINS over a discovered contributor of the same kind (back-compat additive).
@@ -245,13 +272,16 @@ export async function loadConductorConfig(configFile) {
245
272
  agent,
246
273
  workspace,
247
274
  label,
248
- machine_id: machineId,
275
+ conductor_id: conductorId,
276
+ // Soft alias so callers that still read machine_id keep working this cut.
277
+ machine_id: conductorId,
249
278
  project,
250
279
  states,
251
280
  prompt: typeof config.prompt === 'string' && config.prompt.trim() !== ''
252
281
  ? config.prompt
253
282
  : DEFAULT_AGENT_PROMPT,
254
- max_parallel: optionalPositiveInteger(config.max_parallel, 1, 'max_parallel'),
283
+ max_parallel: maxParallel,
284
+ ...(hostKey !== undefined ? { host_key: hostKey } : {}),
255
285
  poll_interval_ms: optionalPositiveInteger(config.poll_interval_ms, 5000, 'poll_interval_ms'),
256
286
  lease_seconds: optionalPositiveInteger(config.lease_seconds, 300, 'lease_seconds'),
257
287
  ...(config.hooks !== undefined ? { hooks: config.hooks } : {}),
@@ -25,6 +25,12 @@ export declare class Conductor {
25
25
  private uuid;
26
26
  constructor(config: ConductorFileConfig, remote: GaiaRemote, executor: GaiaExecutor, workspace: GaiaWorkspace, agents: ResolvedAgent[], logger: ConductorLogger, checkoutRoot?: string);
27
27
  get id(): string;
28
+ /**
29
+ * Host stem for gaia_machine.host_key. Prefer config.host_key from
30
+ * MachineContext.machine_id; fall back to a best-effort parse of the composed
31
+ * conductor_id only when the loader could not supply the stem.
32
+ */
33
+ private get hostKey();
28
34
  /**
29
35
  * Whether this instance is the serve-loop process itself (GAIA-232).
30
36
  *
@@ -103,7 +103,27 @@ export class Conductor {
103
103
  this.checkoutRoot = checkoutRoot;
104
104
  }
105
105
  get id() {
106
- return this.config.machine_id ?? conductorId(this.checkoutRoot);
106
+ return (this.config.conductor_id ??
107
+ this.config.machine_id ??
108
+ conductorId(this.checkoutRoot));
109
+ }
110
+ /**
111
+ * Host stem for gaia_machine.host_key. Prefer config.host_key from
112
+ * MachineContext.machine_id; fall back to a best-effort parse of the composed
113
+ * conductor_id only when the loader could not supply the stem.
114
+ */
115
+ get hostKey() {
116
+ const fromConfig = this.config.host_key?.trim();
117
+ if (fromConfig)
118
+ return fromConfig;
119
+ const id = this.id;
120
+ const parts = id.split('-');
121
+ if (parts.length >= 3 && parts[0] && parts[1]) {
122
+ const middle = parts.slice(1, -1).join('-');
123
+ if (middle)
124
+ return middle;
125
+ }
126
+ return id;
107
127
  }
108
128
  /**
109
129
  * Whether this instance is the serve-loop process itself (GAIA-232).
@@ -130,6 +150,7 @@ export class Conductor {
130
150
  registration() {
131
151
  const base = {
132
152
  id: this.id,
153
+ host_key: this.hostKey,
133
154
  project: this.config.project,
134
155
  states: this.config.states,
135
156
  workspace: this.checkoutRoot,
@@ -163,15 +184,28 @@ export class Conductor {
163
184
  if (!this.uuid)
164
185
  throw new Error('not started');
165
186
  let count = await this.remote.activeRunCount(this.id);
166
- // A heartbeat is the server-side self-heal: it upserts by machine_id (so it
167
- // never 404s and recreates a vanished registration) and refreshes the lease
168
- // — no client-side re-register, no separate status read.
187
+ // A heartbeat is the server-side self-heal: it upserts by conductor_id (so
188
+ // it never 404s and recreates a vanished registration) and refreshes the
189
+ // lease — no client-side re-register, no separate status read. It also
190
+ // recomputes gaia_machine.current_load (peers + this process's load).
169
191
  await this.remote.heartbeat(this.registration(), count, this.config.lease_seconds);
170
- this.logger.info({ conductorId: this.id, active: count }, 'conductor tick');
192
+ // GAIA-353 D5: gate claims on the shared host budget, not only this
193
+ // process's active runs. After the heartbeat above, machine.current_load
194
+ // includes peers + self; each claim below increments the local view so
195
+ // pending dispatches count without a second round-trip.
196
+ const capacity = await this.remote.machineCapacity(this.hostKey);
197
+ let machineLoad = capacity?.currentLoad ?? count;
198
+ const maxParallel = this.config.max_parallel;
199
+ this.logger.info({
200
+ conductorId: this.id,
201
+ active: count,
202
+ machineLoad,
203
+ capacity: maxParallel,
204
+ }, 'conductor tick');
171
205
  await this.finalizeDoneRuns();
172
206
  await this.reap();
173
207
  let claimed = 0;
174
- while (count < this.config.max_parallel) {
208
+ while (machineLoad < maxParallel) {
175
209
  const run = await this.remote.claimNext({
176
210
  leaseSeconds: this.config.lease_seconds,
177
211
  conductorId: this.id,
@@ -203,14 +237,16 @@ export class Conductor {
203
237
  }
204
238
  }
205
239
  count += 1;
240
+ machineLoad += 1;
206
241
  claimed += 1;
207
242
  }
208
243
  if (claimed === 0) {
209
244
  this.logger.info({
210
245
  conductorId: this.id,
211
246
  active: count,
212
- capacity: this.config.max_parallel,
213
- }, count >= this.config.max_parallel
247
+ machineLoad,
248
+ capacity: maxParallel,
249
+ }, machineLoad >= maxParallel
214
250
  ? 'at capacity, nothing claimed'
215
251
  : 'idle, nothing to claim');
216
252
  }
@@ -533,6 +569,7 @@ export class Conductor {
533
569
  await this.workspace.applyOpenLayout?.(ws.path, {
534
570
  identifier: t.identifier,
535
571
  branch: t.branchName,
572
+ title: t.title,
536
573
  });
537
574
  }
538
575
  await this.executor.runHook('before_run', ws.path, { ticket: t.identifier }, env);
@@ -1,5 +1,8 @@
1
1
  export interface ConductorRegistration {
2
+ /** Conductor process registration key (gaia_conductor.conductor_id). */
2
3
  id: string;
4
+ /** Host identity stem (gaia_machine.host_key / MachineContext.machine_id). */
5
+ host_key?: string;
3
6
  project: string;
4
7
  /** Empty = serve all claimable states in the project (GAIA-207). */
5
8
  states: string[];
@@ -163,6 +166,16 @@ export interface GaiaRemote {
163
166
  setConductorStatus(conductorId: string, status: 'offline' | 'online'): Promise<void>;
164
167
  listConductors(owner?: 'me'): Promise<ConductorStatus[]>;
165
168
  activeRunCount(conductorId: string): Promise<number>;
169
+ /**
170
+ * Shared host capacity for this `host_key` (gaia_machine, GAIA-353 D5).
171
+ * After a heartbeat that reported this process's load, `currentLoad` is the
172
+ * sum of online conductors on the machine (peers + self). Null when the
173
+ * control plane has no machine row yet — callers fall back to local count.
174
+ */
175
+ machineCapacity(hostKey: string): Promise<{
176
+ maxParallel: number;
177
+ currentLoad: number;
178
+ } | null>;
166
179
  fetchActiveRuns(conductorId: string): Promise<ActiveRun[]>;
167
180
  claimNext(claim: ClaimOptions): Promise<ClaimedRun | null>;
168
181
  getTicket(ticketUuid: string): Promise<Ticket>;
@@ -61,5 +61,6 @@ export interface GaiaWorkspace {
61
61
  applyOpenLayout?(path: string, ctx: {
62
62
  identifier: string;
63
63
  branch: string;
64
+ title: string;
64
65
  }): Promise<void>;
65
66
  }
@@ -5,10 +5,14 @@ export interface ConductorSettings {
5
5
  /** Default: `${project} @ ${checkoutRoot}`. */
6
6
  label: string;
7
7
  /**
8
- * Stable node identity (gaia_conductor.machine_id) this process registers as.
9
- * Defaults to a hash of hostname + checkout path. Set it to pin a conductor to
10
- * a known identity — e.g. so a ticket can be pre-assigned to it (conductor
11
- * assignment is the run-start trigger), which the e2e fixtures rely on.
8
+ * Stable conductor registration key (gaia_conductor.conductor_id). Composed
9
+ * as `${user_id}-${host}-${project}` from the machine context. Set it to pin
10
+ * a conductor to a known identity — e.g. so a ticket can be pre-assigned to
11
+ * it (conductor assignment is the run-start trigger).
12
+ */
13
+ conductor_id?: string;
14
+ /**
15
+ * @deprecated GAIA-353 — alias for `conductor_id` during cutover.
12
16
  */
13
17
  machine_id?: string;
14
18
  /** Project name (gaia_project.name); resolved at registration. */
@@ -26,8 +30,17 @@ export interface ConductorSettings {
26
30
  * `{state}`, `{runUuid}`. Defaults to `DEFAULT_AGENT_PROMPT`.
27
31
  */
28
32
  prompt: string;
29
- /** Default: 1. */
33
+ /**
34
+ * Host concurrent-run budget. Sourced from machine context max_parallel
35
+ * (GAIA-353); engine-config leftovers are ignored with a warning.
36
+ */
30
37
  max_parallel: number;
38
+ /**
39
+ * Host identity stem (gaia_machine.host_key). Sourced from
40
+ * MachineContext.machine_id — not derived by hyphen-splitting conductor_id
41
+ * (that mis-parses when project or host segments contain hyphens).
42
+ */
43
+ host_key?: string;
31
44
  poll_interval_ms: number;
32
45
  lease_seconds: number;
33
46
  hooks?: {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gaia-ai/conductor",
3
- "version": "0.9.2",
3
+ "version": "0.11.0",
4
4
  "description": "GAIA conductor engine + CLI: registers, claims tickets via JSON:API, dispatches agents.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -28,10 +28,10 @@
28
28
  "directory": "gaia-cli/conductor"
29
29
  },
30
30
  "dependencies": {
31
- "@gaia-ai/core": "^0.9.2",
32
- "@dropsh/plugin-oauth2": "^0.5.7",
33
- "@dropsh/plugin-jsonapi-schema": "^0.5.8",
31
+ "@gaia-ai/core": "^0.11.0",
32
+ "@dropsh/plugin-oauth2": "^0.6.1",
33
+ "@dropsh/plugin-jsonapi-schema": "^0.6.1",
34
34
  "commander": "^12.1.0",
35
- "dropsh": "^0.5.8"
35
+ "dropsh": "^0.6.1"
36
36
  }
37
37
  }