@aifabrix/builder 2.57.0 → 2.59.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.
Files changed (106) hide show
  1. package/lib/agent-kit/codex-app-server.js +44 -0
  2. package/lib/agent-kit/init.js +4 -17
  3. package/lib/agent-kit/session-report.js +15 -10
  4. package/lib/agent-kit/setup-auth.js +131 -0
  5. package/lib/agent-kit/setup-context.js +118 -0
  6. package/lib/agent-kit/setup-github.js +70 -0
  7. package/lib/agent-kit/setup-managed.js +79 -0
  8. package/lib/agent-kit/setup-process.js +42 -0
  9. package/lib/agent-kit/setup-session.js +53 -0
  10. package/lib/agent-kit/setup-state.js +52 -0
  11. package/lib/agent-kit/setup-workspace.js +113 -0
  12. package/lib/agent-kit/setup.js +84 -0
  13. package/lib/agent-kit/start.js +4 -3
  14. package/lib/agent-kit/tmux-sessions.js +20 -3
  15. package/lib/api/dev.api.js +1 -1
  16. package/lib/api/miso-health.api.js +26 -0
  17. package/lib/api/types/dev.types.js +3 -3
  18. package/lib/app/deploy.js +0 -102
  19. package/lib/app/push.js +98 -41
  20. package/lib/build/standard-docker-build.js +13 -11
  21. package/lib/cli/index.js +4 -0
  22. package/lib/cli/setup-app.js +16 -5
  23. package/lib/cli/setup-dev-path-commands.js +53 -2
  24. package/lib/cli/setup-dev.js +7 -4
  25. package/lib/cli/setup-environment.js +17 -4
  26. package/lib/cli/setup-help.js +86 -0
  27. package/lib/cli/setup-onboarding.js +37 -0
  28. package/lib/cli/setup-utility-repair.js +7 -5
  29. package/lib/commands/agent-kit-help.js +21 -7
  30. package/lib/commands/agent-kit-sessions.js +126 -0
  31. package/lib/commands/agent-kit-setup.js +44 -0
  32. package/lib/commands/agent-kit.js +5 -79
  33. package/lib/commands/dev-init.js +21 -23
  34. package/lib/commands/dev-refresh.js +2 -2
  35. package/lib/commands/dev-registry.js +155 -0
  36. package/lib/commands/dev-show-display.js +10 -2
  37. package/lib/commands/governance-verify-external.js +12 -5
  38. package/lib/commands/repair-datasource.js +9 -9
  39. package/lib/commands/repair.js +45 -8
  40. package/lib/commands/setup-image-refresh.js +26 -0
  41. package/lib/commands/upload-qualification-client.js +56 -11
  42. package/lib/commands/upload-qualification-poll.js +51 -22
  43. package/lib/commands/wizard-dataplane.js +16 -52
  44. package/lib/core/config-attach-extensions.js +4 -1
  45. package/lib/datasource/datasource-validate-summary.js +8 -5
  46. package/lib/datasource/sync-defaults.js +53 -0
  47. package/lib/deployment/installation/azure-infra-stage.js +50 -9
  48. package/lib/deployment/installation/azure-preflight.js +63 -7
  49. package/lib/deployment/installation/azure-readiness.js +59 -7
  50. package/lib/deployment/installation/environment-auth.js +60 -20
  51. package/lib/deployment/installation/environment-stage.js +9 -5
  52. package/lib/deployment/installation/infra-catalog.js +57 -5
  53. package/lib/deployment/installation/infra-manifest.js +22 -2
  54. package/lib/deployment/installation/infra-projection.js +14 -3
  55. package/lib/deployment/installation/registry-auth-mode.js +121 -0
  56. package/lib/deployment/installation/stage-input.js +21 -6
  57. package/lib/deployment/marketplace-environment.js +26 -1
  58. package/lib/generator/builders.js +8 -9
  59. package/lib/generator/index.js +23 -12
  60. package/lib/integration-definition/apply.js +2 -2
  61. package/lib/internal/fs-real-sync.js +4 -1
  62. package/lib/programmatic/bearer-auth.js +3 -62
  63. package/lib/programmatic/builder-api-startup.js +51 -0
  64. package/lib/programmatic/builder-miso-runtime.js +167 -0
  65. package/lib/programmatic/openapi-descriptions.js +3 -3
  66. package/lib/programmatic/openapi-schema-descriptions-core.js +3 -3
  67. package/lib/programmatic/openapi-spec.js +2 -1
  68. package/lib/programmatic/route-handler-map.js +3 -2
  69. package/lib/schema/environment-deploy-request.schema.json +1 -1
  70. package/lib/schema/external-datasource.schema.json +20 -5
  71. package/lib/schema/infra-parameter.schema.json +1 -1
  72. package/lib/schema/infra.parameter.yaml +2 -1
  73. package/lib/schema/infrastructure-schema.json +200 -3
  74. package/lib/schema/installation-environment.schema.json +1 -1
  75. package/lib/schema/wizard-config.schema.json +1 -1
  76. package/lib/utils/app-register-config.js +2 -4
  77. package/lib/utils/build-resolve-image.js +2 -21
  78. package/lib/utils/cli-utils.js +8 -1
  79. package/lib/utils/config-paths.js +24 -29
  80. package/lib/utils/config-registry-preference.js +113 -0
  81. package/lib/utils/dev-init-ssh-merge.js +5 -4
  82. package/lib/utils/dev-user-groups.js +2 -1
  83. package/lib/utils/docker-build.js +53 -19
  84. package/lib/utils/docker-daemon-tls-ca.js +2 -2
  85. package/lib/utils/env-map.js +23 -6
  86. package/lib/utils/error-formatters/validation-errors.js +25 -5
  87. package/lib/utils/help-builder.js +4 -3
  88. package/lib/utils/image-tags.js +88 -0
  89. package/lib/utils/promote-dev-docker-image.js +6 -0
  90. package/lib/utils/registry-credentials.js +236 -0
  91. package/lib/utils/resolve-docker-image-ref.js +78 -9
  92. package/lib/utils/secrets-helpers.js +9 -1
  93. package/lib/utils/token-manager.js +29 -36
  94. package/lib/utils/variable-transformer.js +2 -4
  95. package/package.json +4 -2
  96. package/templates/agent-kit/instructions/AGENTKIT.md +39 -2
  97. package/templates/agent-kit/instructions/root.AGENTS.md +40 -1
  98. package/templates/agent-kit/skills/shared/hosts.md +23 -5
  99. package/templates/agent-kit/workspace/README.md +14 -1
  100. package/templates/applications/builder-api/application.yaml +1 -1
  101. package/templates/applications/dataplane/application.yaml +1 -1
  102. package/templates/applications/dataplane/env.template +1 -1
  103. package/templates/applications/miso-controller/application.yaml +1 -1
  104. package/templates/applications/miso-controller/env.template +1 -1
  105. package/templates/external-system/external-datasource.yaml.hbs +7 -6
  106. package/templates/marketplace/main.json +440 -82
@@ -0,0 +1,88 @@
1
+ /**
2
+ * Image tag lists for build and push: comma-separated tags, exact `{version}` expansion from the
3
+ * application's `app.version`, Docker tag validation, and order-preserving de-duplication.
4
+ * All validation happens before any Docker call.
5
+ *
6
+ * @fileoverview Shared image tag resolution
7
+ * @author AI Fabrix Team
8
+ * @version 1.0.0
9
+ */
10
+
11
+ 'use strict';
12
+
13
+ const { assertSafeTag } = require('./resolve-docker-image-ref');
14
+
15
+ const DEFAULT_TAG = 'latest';
16
+ const VERSION_PLACEHOLDER = '{version}';
17
+ /** Tags that are floating aliases themselves (a single explicit one gets no extra `latest`). */
18
+ const FLOATING_TAGS = Object.freeze(['latest', 'main']);
19
+
20
+ /**
21
+ * @param {Object} [appConfig] - Application manifest (possibly wrapped in `variables`)
22
+ * @returns {string} Trimmed `app.version` or ''
23
+ */
24
+ function readAppVersion(appConfig) {
25
+ const cfg = appConfig?.variables?.app ? appConfig.variables : appConfig;
26
+ const raw = cfg?.app?.version;
27
+ return raw === undefined || raw === null ? '' : String(raw).trim();
28
+ }
29
+
30
+ function expandTag(entry, version) {
31
+ if (entry === VERSION_PLACEHOLDER) {
32
+ if (!version) {
33
+ throw new Error('Tag {version} requires a non-empty app.version in the application manifest');
34
+ }
35
+ return version;
36
+ }
37
+ if (entry.includes('{') || entry.includes('}')) {
38
+ throw new Error(`Unsupported tag placeholder "${entry}"; only the exact token {version} is supported`);
39
+ }
40
+ return entry;
41
+ }
42
+
43
+ /**
44
+ * Resolves a tag option into a validated, de-duplicated list (first occurrence wins).
45
+ * @param {string|undefined} tagInput - `--tag` value (omitted means `latest`)
46
+ * @param {string} appVersion - Application `app.version`
47
+ * @returns {string[]}
48
+ * @throws {Error} On blank entries, unsupported placeholders, missing version or invalid tags
49
+ */
50
+ function resolveImageTags(tagInput, appVersion) {
51
+ if (tagInput === undefined || tagInput === null || String(tagInput).trim() === '') {
52
+ return [DEFAULT_TAG];
53
+ }
54
+ const entries = String(tagInput).split(',').map((entry) => entry.trim());
55
+ if (entries.some((entry) => entry === '')) {
56
+ throw new Error(`Invalid tag list "${tagInput}": remove empty entries between commas`);
57
+ }
58
+ const resolved = [];
59
+ for (const entry of entries) {
60
+ const tag = assertSafeTag(expandTag(entry, String(appVersion || '').trim()));
61
+ if (!resolved.includes(tag)) resolved.push(tag);
62
+ }
63
+ return resolved;
64
+ }
65
+
66
+ /**
67
+ * Build plan: the first tag is the Docker build target; the rest are aliases of the same image.
68
+ * A single explicit non-floating tag keeps its compatibility `latest` alias.
69
+ * @param {string|undefined} tagInput - `--tag` value
70
+ * @param {string} appVersion - Application `app.version`
71
+ * @returns {{ primary: string, aliases: string[], tags: string[] }}
72
+ */
73
+ function resolveBuildTagPlan(tagInput, appVersion) {
74
+ const tags = resolveImageTags(tagInput, appVersion);
75
+ const explicitSingle = tags.length === 1 && tagInput !== undefined && tagInput !== null && String(tagInput).trim() !== '';
76
+ if (explicitSingle && !FLOATING_TAGS.includes(tags[0])) {
77
+ tags.push(DEFAULT_TAG);
78
+ }
79
+ return { primary: tags[0], aliases: tags.slice(1), tags };
80
+ }
81
+
82
+ module.exports = {
83
+ DEFAULT_TAG,
84
+ VERSION_PLACEHOLDER,
85
+ readAppVersion,
86
+ resolveImageTags,
87
+ resolveBuildTagPlan
88
+ };
@@ -88,6 +88,12 @@ function resolvePromoteVersionTag(options = {}) {
88
88
  * @returns {string[]}
89
89
  */
90
90
  function resolvePromoteBaseTags(options = {}) {
91
+ if (options.tagPlan && Array.isArray(options.tagPlan.tags) && options.tagPlan.tags.length > 0) {
92
+ // Shared build tag plan (comma list / {version}); every tag becomes a base alias.
93
+ const tags = [...options.tagPlan.tags];
94
+ if (!tags.includes(PROMOTE_FLOATING_TAG)) tags.push(PROMOTE_FLOATING_TAG);
95
+ return tags;
96
+ }
91
97
  const versionTag = resolvePromoteVersionTag(options);
92
98
  const tags = [versionTag];
93
99
  if (versionTag !== PROMOTE_FLOATING_TAG) {
@@ -0,0 +1,236 @@
1
+ /**
2
+ * Host-scoped container registry credentials in the primary user secrets file.
3
+ *
4
+ * Keys are exactly `<registry>-username` and `<registry>-password` in the user's
5
+ * `secrets.local.yaml` (never the shared store). Values are encrypted through the existing
6
+ * local-secret writer and reach Docker only via `docker login --password-stdin`.
7
+ *
8
+ * @fileoverview Registry credential storage, lookup, removal, and per-command Docker login
9
+ * @author AI Fabrix Team
10
+ * @version 1.0.0
11
+ */
12
+
13
+ 'use strict';
14
+
15
+ const fs = require('fs');
16
+ const yaml = require('js-yaml');
17
+ const config = require('../core/config');
18
+ const pathsUtil = require('./paths');
19
+ const { loadExistingSecrets, mergeSecretsIntoFile } = require('./secrets-generator');
20
+ const { saveLocalSecret } = require('./local-secrets');
21
+ const { decryptSecret, isEncrypted } = require('./secrets-encryption');
22
+ const { normalizeRegistryHost } = require('./config-registry-preference');
23
+
24
+ /** Hosts already authenticated during this CLI process. */
25
+ const authenticatedHosts = new Set();
26
+
27
+ /**
28
+ * @param {string} registry - Registry host
29
+ * @returns {{ host: string, usernameKey: string, passwordKey: string }}
30
+ */
31
+ function registryCredentialKeys(registry) {
32
+ const host = normalizeRegistryHost(registry);
33
+ return { host, usernameKey: `${host}-username`, passwordKey: `${host}-password` };
34
+ }
35
+
36
+ function hasValue(value) {
37
+ return typeof value === 'string' ? value.trim() !== '' : value !== undefined && value !== null;
38
+ }
39
+
40
+ function readRawPair(keys) {
41
+ const file = pathsUtil.getPrimaryUserSecretsLocalPath();
42
+ const raw = loadExistingSecrets(file);
43
+ return { file, username: raw[keys.usernameKey], password: raw[keys.passwordKey] };
44
+ }
45
+
46
+ function partialPairError(keys) {
47
+ return new Error(
48
+ `Registry credentials for ${keys.host} are incomplete: both ${keys.usernameKey} and ${keys.passwordKey} ` +
49
+ `must be set. Run "aifabrix dev set-registry ${keys.host} --username <username> --password-stdin" ` +
50
+ `or "aifabrix dev set-registry ${keys.host} --clear-credentials".`
51
+ );
52
+ }
53
+
54
+ /**
55
+ * Credential presence for a registry without decrypting anything.
56
+ * @param {string} registry - Registry host
57
+ * @returns {'complete'|'absent'|'partial'}
58
+ */
59
+ function getRegistryCredentialStatus(registry) {
60
+ const keys = registryCredentialKeys(registry);
61
+ const pair = readRawPair(keys);
62
+ const count = [pair.username, pair.password].filter(hasValue).length;
63
+ if (count === 2) return 'complete';
64
+ return count === 0 ? 'absent' : 'partial';
65
+ }
66
+
67
+ async function decryptStoredValue(value, label) {
68
+ const text = String(value);
69
+ if (!isEncrypted(text)) return text;
70
+ const key = await config.getSecretsEncryptionKey();
71
+ if (!key) {
72
+ throw new Error(`Cannot decrypt the stored ${label}: the local secrets encryption key is not configured`);
73
+ }
74
+ return decryptSecret(text, key);
75
+ }
76
+
77
+ /**
78
+ * Loads a complete credential pair for the registry.
79
+ * @param {string} registry - Registry host
80
+ * @returns {Promise<{ username: string, password: string }|null>} null when no pair is stored
81
+ * @throws {Error} When only one of the two keys is stored
82
+ */
83
+ async function loadRegistryCredentials(registry) {
84
+ const keys = registryCredentialKeys(registry);
85
+ const pair = readRawPair(keys);
86
+ const present = [pair.username, pair.password].filter(hasValue).length;
87
+ if (present === 0) return null;
88
+ if (present === 1) throw partialPairError(keys);
89
+ return {
90
+ username: await decryptStoredValue(pair.username, `${keys.host} username`),
91
+ password: await decryptStoredValue(pair.password, `${keys.host} password`)
92
+ };
93
+ }
94
+
95
+ function removeKeysFromFile(file, keyNames) {
96
+ if (!fs.existsSync(file)) return false;
97
+ const data = loadExistingSecrets(file);
98
+ const present = keyNames.filter((key) => Object.prototype.hasOwnProperty.call(data, key));
99
+ if (present.length === 0) return false;
100
+ for (const key of present) delete data[key];
101
+ const { assertWritableSecretsPathForTests } = require('./aifabrix-test-runtime-guard');
102
+ assertWritableSecretsPathForTests(file);
103
+ fs.writeFileSync(file, yaml.dump(data, { indent: 2, lineWidth: -1, noRefs: true, sortKeys: false }), { mode: 0o600 });
104
+ // `mode` applies only on creation; keep an existing file owner-only too.
105
+ fs.chmodSync(file, 0o600);
106
+ return true;
107
+ }
108
+
109
+ function restorePair(keys, snapshot) {
110
+ const restore = {};
111
+ const remove = [];
112
+ for (const [key, value] of [[keys.usernameKey, snapshot.username], [keys.passwordKey, snapshot.password]]) {
113
+ if (value === undefined) remove.push(key);
114
+ else restore[key] = value;
115
+ }
116
+ removeKeysFromFile(snapshot.file, remove);
117
+ mergeSecretsIntoFile(snapshot.file, restore);
118
+ }
119
+
120
+ /**
121
+ * Saves a complete pair. On any write failure the previous entries are restored.
122
+ * @param {string} registry - Registry host
123
+ * @param {string} username - Registry username
124
+ * @param {string} password - Registry password or token
125
+ * @returns {Promise<{ host: string, rollback: Function }>} rollback restores the prior entries
126
+ */
127
+ async function saveRegistryCredentials(registry, username, password) {
128
+ const keys = registryCredentialKeys(registry);
129
+ if (!hasValue(username) || !hasValue(password)) {
130
+ throw new Error('Registry username and password must both be non-empty');
131
+ }
132
+ const snapshot = readRawPair(keys);
133
+ await config.ensureSecretsEncryptionKey();
134
+ try {
135
+ await saveLocalSecret(keys.usernameKey, String(username));
136
+ await saveLocalSecret(keys.passwordKey, String(password));
137
+ } catch (error) {
138
+ restorePair(keys, snapshot);
139
+ throw new Error(`Could not save registry credentials for ${keys.host}; previous values were restored (${error.message})`);
140
+ }
141
+ authenticatedHosts.delete(keys.host);
142
+ return { host: keys.host, rollback: () => restorePair(keys, snapshot) };
143
+ }
144
+
145
+ /**
146
+ * Removes both credential keys for the registry (idempotent).
147
+ * @param {string} registry - Registry host
148
+ * @returns {boolean} true when at least one key was removed
149
+ */
150
+ function removeRegistryCredentials(registry) {
151
+ const keys = registryCredentialKeys(registry);
152
+ authenticatedHosts.delete(keys.host);
153
+ return removeKeysFromFile(pathsUtil.getPrimaryUserSecretsLocalPath(), [keys.usernameKey, keys.passwordKey]);
154
+ }
155
+
156
+ /**
157
+ * Authenticates Docker to the registry once per process: stored pair first, then the optional
158
+ * fallback (for example Azure CLI for ACR). The password is sent only on stdin.
159
+ * @param {string} registry - Registry host
160
+ * @param {Object} [options] - Options
161
+ * @param {Function} [options.fallback] - async (host) => void when no pair is stored
162
+ * @param {Function} [options.login] - async (host, username, password) => void (tests)
163
+ * @returns {Promise<'cached'|'credentials'|'fallback'|'none'>}
164
+ */
165
+ async function ensureRegistryLogin(registry, options = {}) {
166
+ const host = normalizeRegistryHost(registry);
167
+ if (authenticatedHosts.has(host)) return 'cached';
168
+ const credentials = await loadRegistryCredentials(host);
169
+ if (credentials) {
170
+ const login = options.login || require('../deployment/push').authenticateExternalRegistry;
171
+ await login(host, credentials.username, credentials.password);
172
+ authenticatedHosts.add(host);
173
+ return 'credentials';
174
+ }
175
+ if (typeof options.fallback === 'function') {
176
+ await options.fallback(host);
177
+ authenticatedHosts.add(host);
178
+ return 'fallback';
179
+ }
180
+ return 'none';
181
+ }
182
+
183
+ /**
184
+ * Registry host of an image reference, or '' for Docker Hub style refs.
185
+ * @param {string} imageRef - Image reference (`host/repo:tag`, `repo:tag`, ...)
186
+ * @returns {string}
187
+ */
188
+ function registryHostFromImageRef(imageRef) {
189
+ const ref = String(imageRef || '').trim();
190
+ const slash = ref.indexOf('/');
191
+ if (slash <= 0) return '';
192
+ const first = ref.slice(0, slash);
193
+ if (!(first.includes('.') || first.includes(':') || first === 'localhost')) return '';
194
+ try {
195
+ return normalizeRegistryHost(first);
196
+ } catch {
197
+ return '';
198
+ }
199
+ }
200
+
201
+ /**
202
+ * Logs in once per distinct registry host of the given image refs when a stored pair exists.
203
+ * Hosts without stored credentials are pulled anonymously (or with the user's own Docker login).
204
+ * @param {string[]} imageRefs - Image references
205
+ * @returns {Promise<string[]>} Hosts logged in with stored credentials
206
+ */
207
+ async function ensureLoginForImageRefs(imageRefs) {
208
+ const hosts = [...new Set((imageRefs || []).map(registryHostFromImageRef).filter(Boolean))];
209
+ const used = [];
210
+ for (const host of hosts) {
211
+ const outcome = await ensureRegistryLogin(host);
212
+ if (outcome === 'credentials') used.push(host);
213
+ }
214
+ return used;
215
+ }
216
+
217
+ /**
218
+ * Forgets cached logins (used before re-authentication and in tests).
219
+ * @param {string} [registry] - Host to forget; all when omitted
220
+ */
221
+ function resetRegistryLoginCache(registry) {
222
+ if (registry) authenticatedHosts.delete(normalizeRegistryHost(registry));
223
+ else authenticatedHosts.clear();
224
+ }
225
+
226
+ module.exports = {
227
+ registryCredentialKeys,
228
+ getRegistryCredentialStatus,
229
+ loadRegistryCredentials,
230
+ saveRegistryCredentials,
231
+ removeRegistryCredentials,
232
+ ensureRegistryLogin,
233
+ ensureLoginForImageRefs,
234
+ registryHostFromImageRef,
235
+ resetRegistryLoginCache
236
+ };
@@ -1,6 +1,7 @@
1
1
  /**
2
2
  * Resolve Docker repository path and tag from application config and optional CLI overrides.
3
- * Precedence: --image (full ref), --registry CLI, image.registry in manifest, else unqualified name.
3
+ * Precedence: --image (full ref), --registry CLI, installation `registry` in config.yaml,
4
+ * image.registry in manifest, else unqualified name.
4
5
  *
5
6
  * For refs like localhost:5000/repo without an explicit tag, prefer --image with :tag (parse ambiguity).
6
7
  *
@@ -12,6 +13,7 @@
12
13
  'use strict';
13
14
 
14
15
  const { parseImageOverride } = require('./parse-image-ref');
16
+ const { readInstallationRegistrySync } = require('./config-registry-preference');
15
17
  const DOCKER_REPOSITORY_RE = /^[a-z0-9][a-z0-9._/-]*$/i;
16
18
  const DOCKER_TAG_RE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/;
17
19
 
@@ -92,6 +94,73 @@ function normalizeDockerRegistryPrefix(registry) {
92
94
  return t.slice(0, end);
93
95
  }
94
96
 
97
+ /**
98
+ * Remove a registry host prefix from a Docker repository path.
99
+ * The first segment is a host when it contains `.` or `:` or equals `localhost`.
100
+ * @param {string} repository
101
+ * @returns {string}
102
+ */
103
+ function stripRegistryHostFromRepository(repository) {
104
+ if (!repository || typeof repository !== 'string') {
105
+ return repository;
106
+ }
107
+ const parts = repository.split('/');
108
+ if (parts.length <= 1) {
109
+ return repository;
110
+ }
111
+ const host = parts[0];
112
+ if (host.includes('.') || host.includes(':') || host === 'localhost') {
113
+ return parts.slice(1).join('/');
114
+ }
115
+ return repository;
116
+ }
117
+
118
+ /**
119
+ * Effective registry for an application image.
120
+ * Precedence: `options.registry` (CLI) > installation `registry` > manifest `image.registry`.
121
+ * @param {Object} [appConfig] - Application manifest
122
+ * @param {Object} [options] - `registry` (CLI) and optional `installationRegistry` (preloaded; '' = none)
123
+ * @returns {{ registry: string, source: 'cli'|'installation'|'manifest'|'none' }}
124
+ */
125
+ function resolveEffectiveRegistry(appConfig, options = {}) {
126
+ const opts = options || {};
127
+ const cli = normalizeDockerRegistryPrefix(opts.registry);
128
+ if (cli) return { registry: cli, source: 'cli' };
129
+ const installation = opts.installationRegistry !== undefined
130
+ ? normalizeDockerRegistryPrefix(opts.installationRegistry)
131
+ : readInstallationRegistrySync();
132
+ if (installation) return { registry: installation, source: 'installation' };
133
+ const manifest = normalizeDockerRegistryPrefix(appConfig?.image?.registry ?? '');
134
+ if (manifest) return { registry: manifest, source: 'manifest' };
135
+ return { registry: '', source: 'none' };
136
+ }
137
+
138
+ /**
139
+ * Prefix a repository with a registry exactly once (replacing any existing host).
140
+ * @param {string} registry - Registry prefix ('' keeps the repository unchanged)
141
+ * @param {string} repository - Repository path, possibly already qualified
142
+ * @returns {string}
143
+ */
144
+ function qualifyRepository(registry, repository) {
145
+ const prefix = normalizeDockerRegistryPrefix(registry);
146
+ if (!prefix) return repository;
147
+ return `${prefix}/${stripRegistryHostFromRepository(repository)}`;
148
+ }
149
+
150
+ /**
151
+ * Deployment/registration image reference (`registry/name:tag`) using the shared precedence.
152
+ * @param {Object} variables - Application manifest
153
+ * @param {string} fallbackName - Name when the manifest has no image name or app key
154
+ * @param {Object} [options] - Same options as {@link resolveEffectiveRegistry}
155
+ * @returns {string}
156
+ */
157
+ function buildManifestImageReference(variables, fallbackName, options = {}) {
158
+ const imageName = variables?.image?.name || variables?.app?.key || fallbackName;
159
+ const tag = variables?.image?.tag || 'latest';
160
+ const { registry } = resolveEffectiveRegistry(variables, options);
161
+ return `${qualifyRepository(registry, imageName)}:${tag}`;
162
+ }
163
+
95
164
  /**
96
165
  * Effective image repository (may include registry prefix) and tag for Docker.
97
166
  * @param {string} appName - Application name
@@ -118,13 +187,8 @@ function resolveDockerImageRef(appName, appConfig, runOptions = {}) {
118
187
 
119
188
  const baseRepo = assertSafeRepository(getRepositoryPathFromConfig(appConfig, appName));
120
189
  const imageTag = assertSafeTag(tagFromOpts || imageTagFromConfig(appConfig));
121
- const prefix =
122
- normalizeDockerRegistryPrefix(opts.registry) ||
123
- normalizeDockerRegistryPrefix(appConfig?.image?.registry ?? '');
124
- if (prefix) {
125
- return { imageName: assertSafeRepository(`${prefix}/${baseRepo}`), imageTag };
126
- }
127
- return { imageName: baseRepo, imageTag };
190
+ const { registry } = resolveEffectiveRegistry(appConfig, opts);
191
+ return { imageName: assertSafeRepository(qualifyRepository(registry, baseRepo)), imageTag };
128
192
  }
129
193
 
130
194
  /**
@@ -137,7 +201,7 @@ function resolveDockerImageRef(appName, appConfig, runOptions = {}) {
137
201
  function resolveComposeImageOverrideString(appName, appConfig, options = {}) {
138
202
  if (options.image) return options.image;
139
203
  if (options.imageOverride) return options.imageOverride;
140
- const runOpts = { registry: options.registry, image: undefined };
204
+ const runOpts = { registry: options.registry, installationRegistry: options.installationRegistry, image: undefined };
141
205
  if (options.tag) {
142
206
  const { imageName } = resolveDockerImageRef(appName, appConfig, runOpts);
143
207
  return `${imageName}:${options.tag}`;
@@ -154,6 +218,11 @@ function resolveComposeImageOverrideString(appName, appConfig, options = {}) {
154
218
  module.exports = {
155
219
  resolveDockerImageRef,
156
220
  resolveComposeImageOverrideString,
221
+ resolveEffectiveRegistry,
222
+ qualifyRepository,
223
+ buildManifestImageReference,
224
+ stripRegistryHostFromRepository,
225
+ assertSafeTag,
157
226
  normalizeDockerRegistryPrefix,
158
227
  getRepositoryPathFromConfig,
159
228
  imageTagFromConfig
@@ -29,7 +29,15 @@ const { collectUniqueKvPathStrings, isCommentOrEmptyLine, activeEnvLineForKvScan
29
29
  * @returns {string} Interpolated content
30
30
  */
31
31
  function interpolateEnvVars(content, envVars) {
32
- return content.replace(/\$\{([A-Z_]+)\}/g, (match, envVar) => {
32
+ const hasOwn = (key) => Object.prototype.hasOwnProperty.call(envVars, key);
33
+ const interpolated = content.replace(
34
+ /\$\{DEV_USERNAME\}\.\$\{REMOTE_HOST\}/g,
35
+ (match) => {
36
+ if (!hasOwn('DEV_USERNAME') || !hasOwn('REMOTE_HOST')) return match;
37
+ return [envVars.DEV_USERNAME, envVars.REMOTE_HOST].filter(Boolean).join('.');
38
+ }
39
+ );
40
+ return interpolated.replace(/\$\{([A-Z_]+)\}/g, (match, envVar) => {
33
41
  return envVars[envVar] || match;
34
42
  });
35
43
  }
@@ -11,6 +11,7 @@
11
11
 
12
12
  const config = require('../core/config');
13
13
  const logger = require('./logger');
14
+ const { maskSensitiveData } = require('./log-redaction');
14
15
  const {
15
16
  refreshClientToken,
16
17
  refreshDeviceToken
@@ -99,55 +100,47 @@ async function getOrRefreshClientToken(environment, appName, controllerUrl) {
99
100
  * @returns {Promise<{token: string, controller: string}|null>} Token and controller URL, or null if not available
100
101
  */
101
102
  async function getOrRefreshDeviceToken(controllerUrl) {
102
- // Try to get existing token
103
103
  const tokenInfo = await getDeviceToken(controllerUrl);
104
-
105
- if (!tokenInfo) {
106
- return null;
104
+ if (!tokenInfo) return null;
105
+ if (reusableDeviceToken(tokenInfo) && !shouldRefreshToken(tokenInfo.expiresAt)) {
106
+ return reusableDeviceToken(tokenInfo);
107
107
  }
108
-
109
- // Check if token should be refreshed proactively (within 15 minutes of expiry)
110
- // This ensures we refresh before Keycloak's SSO Session Idle timeout (30 minutes)
111
- const needsRefresh = shouldRefreshToken(tokenInfo.expiresAt);
112
-
113
- if (!needsRefresh) {
114
- // Token is valid and doesn't need refresh yet
115
- return {
116
- token: tokenInfo.token,
117
- controller: tokenInfo.controller
118
- };
119
- }
120
-
121
- // Token needs refresh (expired or within 15 minutes of expiry)
122
- // Try to refresh if refresh token exists
123
108
  if (!tokenInfo.refreshToken) {
124
- const { isSetupPlatformFlow } = require('../commands/setup-run-context');
125
- if (!isSetupPlatformFlow()) {
126
- logger.warn(
127
- 'Access token expired and no refresh token available. Please login again using: aifabrix login'
128
- );
109
+ const usable = reusableDeviceToken(tokenInfo);
110
+ if (!usable) {
111
+ warnRefreshFailureOnce(controllerUrl, 'No usable access token and no refresh token available. Please login again using: aifabrix login');
129
112
  }
130
- return null;
113
+ return usable;
131
114
  }
132
-
133
115
  try {
134
116
  const refreshed = await refreshDeviceToken(controllerUrl, tokenInfo.refreshToken);
135
- return {
136
- token: refreshed.token,
137
- controller: controllerUrl
138
- };
117
+ return { token: refreshed.token, controller: controllerUrl };
139
118
  } catch (error) {
140
- // Refresh failed - check if it's a refresh token expiry
141
- const errorMessage = error.message || String(error);
142
- if (errorMessage.includes('Refresh token has expired')) {
143
- warnRefreshTokenExpiredOnce(controllerUrl, errorMessage);
119
+ let message = error.message || String(error);
120
+ for (const secret of [tokenInfo.token, tokenInfo.refreshToken]) {
121
+ if (secret) message = message.split(secret).join('***');
122
+ }
123
+ message = maskSensitiveData(message);
124
+ if (message.includes('Refresh token has expired')) {
125
+ warnRefreshTokenExpiredOnce(controllerUrl, message);
144
126
  } else {
145
- warnRefreshFailureOnce(controllerUrl, errorMessage);
127
+ warnRefreshFailureOnce(controllerUrl, message);
146
128
  }
147
- return null;
129
+ return reusableDeviceToken(tokenInfo);
148
130
  }
149
131
  }
150
132
 
133
+ /**
134
+ * Reuse only a token whose actual expiry is known and still in the future.
135
+ * @param {Object} tokenInfo - Stored device token
136
+ * @returns {Object|null} Usable token, without refresh credentials
137
+ */
138
+ function reusableDeviceToken(tokenInfo) {
139
+ const expiry = typeof tokenInfo.expiresAt === 'string' ? Date.parse(tokenInfo.expiresAt) : NaN;
140
+ return tokenInfo.token && Number.isFinite(expiry) && expiry > Date.now()
141
+ ? { token: tokenInfo.token, controller: tokenInfo.controller } : null;
142
+ }
143
+
151
144
  /**
152
145
  * Validates deployment auth parameters
153
146
  * @function validateDeploymentAuthParams
@@ -126,10 +126,8 @@ function transformFlatStructure(variables, appName) {
126
126
  * @returns {string} Image reference string
127
127
  */
128
128
  function buildImageReference(variables, appName) {
129
- const imageName = variables.image?.name || variables.app?.key || appName;
130
- const registry = variables.image?.registry;
131
- const tag = variables.image?.tag || 'latest';
132
- return registry ? `${registry}/${imageName}:${tag}` : `${imageName}:${tag}`;
129
+ const { buildManifestImageReference } = require('./resolve-docker-image-ref');
130
+ return buildManifestImageReference(variables, appName);
133
131
  }
134
132
 
135
133
  /**
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aifabrix/builder",
3
- "version": "2.57.0",
3
+ "version": "2.59.0",
4
4
  "description": "AI Fabrix Builder — CLI and developer scripts (pnpm + af)",
5
5
  "main": "lib/index.js",
6
6
  "bin": {
@@ -28,6 +28,7 @@
28
28
  "agent-skills:check": "node scripts/pnpm/agent-skills-sync.mjs --check",
29
29
  "agent-skills:sync:codex": "node scripts/pnpm/agent-skills-sync.mjs --codex-home",
30
30
  "agent-skills:test": "node --test scripts/pnpm/agent-skills-sync.test.mjs",
31
+ "dev-deploy:test": "node --test scripts/pnpm/dev-deploy.test.mjs",
31
32
  "check": "npm run lint && npm run test",
32
33
  "lint": "eslint . --ext .js",
33
34
  "lint:fix": "eslint . --ext .js --fix",
@@ -130,7 +131,8 @@
130
131
  }
131
132
  },
132
133
  "dependencies": {
133
- "@aifabrix/miso-client": "4.22.4",
134
+ "@aifabrix/miso-client": "4.23.1",
135
+ "@azure/identity": "4.11.1",
134
136
  "adm-zip": "^0.6.0",
135
137
  "ajv": "^8.20.0",
136
138
  "ajv-formats": "^3.0.1",
@@ -2,6 +2,35 @@
2
2
 
3
3
  Builder-owned. `aifabrix agent-kit update` refreshes this file. If workspace `README.md` is missing, install/update creates it. An existing README is never overwritten.
4
4
 
5
+ ## Onboard
6
+
7
+ On an organisation-managed SSH workspace with a provisioned `config.yaml`:
8
+
9
+ ```bash
10
+ af onboarding
11
+ af onboarding --coding claude # or codex or cursor for this run
12
+ ```
13
+
14
+ For Claude, onboarding starts or reuses a detached tmux session and prints
15
+ its attach command. For Codex or Cursor, open the printed workspace in the
16
+ selected coding client over SSH, then say “Hi”. `--coding` selects a platform
17
+ for one run without changing the provisioned default.
18
+ The agent reads this workspace's `AGENTS.md` and `README.md`, checks which
19
+ packages already exist, and guides you to a first delivery task.
20
+
21
+ On a Linux or macOS terminal with Builder, Git, and tmux:
22
+
23
+ ```bash
24
+ aifabrix agent-kit setup https://controller.example.com
25
+ ```
26
+
27
+ This prepares a local workspace under the configured AI Fabrix work directory and starts
28
+ Codex. Select Claude with `--code claude`. GitHub is optional: add `--repo owner/name`
29
+ to reuse a matching local checkout, clone a remote, or create a private repository.
30
+ The repository name becomes the folder name; unresolved access stops safely.
31
+ Setup preserves existing agent permissions. Change environment afterward with
32
+ `aifabrix auth --set-environment <environment>`.
33
+
5
34
  ## Install
6
35
 
7
36
  From this git repository (Builder CLI on PATH):
@@ -57,7 +86,12 @@ curl -fsSL https://claude.ai/install.sh | bash
57
86
  claude auth login
58
87
  ```
59
88
 
60
- **Run in tmux** from this workspace so closing the terminal does not kill Claude. The command enables Remote Control and this workspace's Claude auto-approval by default. Remote Control is the **same** session on this PC, [claude.ai/code](https://claude.ai/code), and the Claude mobile app (Code). That is not `claude --cloud` (a separate cloud VM). Claude `--tmux` is for worktrees only.
89
+ **Optional terminal session in tmux:** run from this workspace so closing the
90
+ terminal does not kill Claude. The command enables Remote Control and this
91
+ workspace's Claude auto-approval by default. Remote Control is the **same**
92
+ session on this PC, [claude.ai/code](https://claude.ai/code), and the Claude
93
+ mobile app (Code). That is not `claude --cloud` (a separate cloud VM). Claude
94
+ `--tmux` is for worktrees only.
61
95
 
62
96
  ```bash
63
97
  aifabrix agent-kit start claude
@@ -76,7 +110,10 @@ npm install -g @openai/codex
76
110
  codex login
77
111
  ```
78
112
 
79
- **Run in tmux** from this workspace so the session stays if you close the terminal. The command starts native Codex Remote Control and writes this workspace's Codex auto-approval by default:
113
+ **Optional terminal session in tmux:** run from this workspace so a terminal
114
+ Codex session stays after SSH disconnects. This session is separate from a
115
+ Codex app chat opened over SSH. The command requests native Codex Remote
116
+ Control and writes workspace auto-approval by default:
80
117
 
81
118
  ```bash
82
119
  aifabrix agent-kit start codex