@runuai/host 0.9.1 → 0.9.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/images/standard/container/uai-init +38 -19
- package/lib/agent.ts +42 -16
- package/lib/git-identity.ts +349 -70
- package/lib/github-git-auth.ts +207 -0
- package/lib/github-tokens.ts +756 -110
- package/lib/orchestrator.ts +70 -28
- package/lib/repo-clone.ts +12 -101
- package/lib/ssh.ts +11 -8
- package/package.json +1 -1
- package/scripts/agent/_common.sh +214 -0
- package/scripts/agent/task-down.sh +35 -59
- package/scripts/agent/task-up.sh +746 -72
- package/src/index.ts +78 -5
- package/src/main.ts +100 -31
- package/src/protocol.ts +4 -0
package/lib/orchestrator.ts
CHANGED
|
@@ -35,7 +35,7 @@ import {
|
|
|
35
35
|
} from "./agents/types";
|
|
36
36
|
import { ACTIVE_STATUSES } from "./task-status";
|
|
37
37
|
import { getHostTask, upsertHostTask } from "./runtime-state";
|
|
38
|
-
import { clearRefresh,
|
|
38
|
+
import { clearRefresh, reconcileTaskGitAuth } from "./github-tokens";
|
|
39
39
|
import { ensureTaskSshIdentity, setupTaskGitIdentity } from "./git-identity";
|
|
40
40
|
import { dockerCli } from "./docker-exec";
|
|
41
41
|
import {
|
|
@@ -1362,27 +1362,31 @@ export class Orchestrator {
|
|
|
1362
1362
|
const initialRoster = [...channel.roster];
|
|
1363
1363
|
|
|
1364
1364
|
// Set the task creator's git author identity in the container (ADR-029).
|
|
1365
|
-
// The SSH key
|
|
1366
|
-
//
|
|
1365
|
+
// The optional SSH signing/fallback key is installed earlier by
|
|
1366
|
+
// task-up.sh using the creator's per-user key. Best-effort. Awaited but
|
|
1367
1367
|
// async (docker exec via dockerCli) so it doesn't block the event loop —
|
|
1368
1368
|
// this runs on every channel ensure, including each post-restart reconnect.
|
|
1369
1369
|
await setupTaskGitIdentity(channel.taskId, task.ownerName, task.ownerEmail);
|
|
1370
1370
|
if (!this.isActiveChannel(channel)) return false;
|
|
1371
1371
|
|
|
1372
|
-
// GitHub auth for `gh` (ADR-027):
|
|
1373
|
-
// (re)start
|
|
1374
|
-
//
|
|
1375
|
-
//
|
|
1376
|
-
// task-up is gone. Guarded + best-effort + non-blocking; the token lands
|
|
1377
|
-
// before the agents (spawned just below) make their first `gh` call.
|
|
1372
|
+
// GitHub auth for both `gh` and HTTPS Git (ADR-027): inject the token,
|
|
1373
|
+
// configure gh's credential helper, and (re)start refresh. Await the
|
|
1374
|
+
// serialized reconcile so agents cannot race their first fetch/push after
|
|
1375
|
+
// task creation, reconnect, or host recovery.
|
|
1378
1376
|
if (task.ownerUserId) {
|
|
1379
|
-
|
|
1377
|
+
await reconcileTaskGitAuth(channel.taskId, task.ownerUserId);
|
|
1380
1378
|
}
|
|
1381
1379
|
|
|
1382
|
-
// SSH
|
|
1383
|
-
//
|
|
1384
|
-
//
|
|
1385
|
-
void ensureTaskSshIdentity(channel.taskId, task.ownerUserId)
|
|
1380
|
+
// SSH signing identity, re-asserted independently of transport. Connected
|
|
1381
|
+
// users still push through gh over HTTPS; disconnected users select the
|
|
1382
|
+
// SSH transport fallback inside the reconcile above.
|
|
1383
|
+
void ensureTaskSshIdentity(channel.taskId, task.ownerUserId).catch((err) =>
|
|
1384
|
+
console.warn(
|
|
1385
|
+
`[ssh] task ${channel.taskId}: signing identity reconciliation failed: ${
|
|
1386
|
+
err instanceof Error ? err.message : String(err)
|
|
1387
|
+
}`,
|
|
1388
|
+
),
|
|
1389
|
+
);
|
|
1386
1390
|
|
|
1387
1391
|
// ADR-047: install any package skills (native Claude Agent Skills) into the
|
|
1388
1392
|
// container BEFORE spawning agents, so they're discoverable on the first
|
|
@@ -1579,7 +1583,7 @@ export class Orchestrator {
|
|
|
1579
1583
|
// Cancel any armed auto-retry chain so this manual attempt runs fresh
|
|
1580
1584
|
// (setup's duplicate-chain guard would otherwise no-op it).
|
|
1581
1585
|
clearRefresh(taskId);
|
|
1582
|
-
const ok = await
|
|
1586
|
+
const ok = await reconcileTaskGitAuth(taskId, owner);
|
|
1583
1587
|
this.emitSystemNote(
|
|
1584
1588
|
taskId,
|
|
1585
1589
|
ok
|
|
@@ -2772,8 +2776,9 @@ export function buildSystemPreamble(
|
|
|
2772
2776
|
? [
|
|
2773
2777
|
"## Commit policy",
|
|
2774
2778
|
"",
|
|
2775
|
-
"
|
|
2776
|
-
"not disable or override
|
|
2779
|
+
"When this task has an optional SSH signing key configured, Git signs",
|
|
2780
|
+
"commits automatically; do not disable or override it. Do NOT add any",
|
|
2781
|
+
"`Co-Authored-By:`",
|
|
2777
2782
|
"trailers to commit messages, and do NOT add 'Generated with …' or any",
|
|
2778
2783
|
"tool/agent attribution footer to commit messages or PR/issue bodies.",
|
|
2779
2784
|
"Write commit messages and PR descriptions plainly, as the author, with",
|
|
@@ -3128,9 +3133,21 @@ async function recoverOneTask(
|
|
|
3128
3133
|
// ~8h later. Cheap with the per-user access-token cache — one exchange
|
|
3129
3134
|
// per user, every task re-injects from it. Best-effort.
|
|
3130
3135
|
if (task.ownerUserId) {
|
|
3131
|
-
void
|
|
3136
|
+
void reconcileTaskGitAuth(task.taskId, task.ownerUserId).catch((err) =>
|
|
3137
|
+
console.warn(
|
|
3138
|
+
`[github] task ${task.taskId}: running recovery reconciliation failed: ${
|
|
3139
|
+
err instanceof Error ? err.message : String(err)
|
|
3140
|
+
}`,
|
|
3141
|
+
),
|
|
3142
|
+
);
|
|
3132
3143
|
}
|
|
3133
|
-
void ensureTaskSshIdentity(task.taskId, task.ownerUserId)
|
|
3144
|
+
void ensureTaskSshIdentity(task.taskId, task.ownerUserId).catch((err) =>
|
|
3145
|
+
console.warn(
|
|
3146
|
+
`[ssh] task ${task.taskId}: running recovery reconciliation failed: ${
|
|
3147
|
+
err instanceof Error ? err.message : String(err)
|
|
3148
|
+
}`,
|
|
3149
|
+
),
|
|
3150
|
+
);
|
|
3134
3151
|
return true;
|
|
3135
3152
|
}
|
|
3136
3153
|
|
|
@@ -3152,11 +3169,35 @@ async function recoverOneTask(
|
|
|
3152
3169
|
// possibly host-uid-owned 0600 files Codex can't read. Failure is surfaced
|
|
3153
3170
|
// by the inject itself and must not abort the task's recovery.
|
|
3154
3171
|
await injectCodexIntoContainer(containerName);
|
|
3172
|
+
// Establish the task owner's GitHub transport BEFORE uai-init performs any
|
|
3173
|
+
// dependency-network work. This matters for package manifests that refer to
|
|
3174
|
+
// private GitHub repositories: gh's credential helper must already be live
|
|
3175
|
+
// when pnpm/npm/uv/cargo resolves them. Reconciliation is best-effort, just
|
|
3176
|
+
// like the prior post-init call, but it is now ordered rather than raced.
|
|
3177
|
+
if (task.ownerUserId) {
|
|
3178
|
+
try {
|
|
3179
|
+
await reconcileTaskGitAuth(task.taskId, task.ownerUserId);
|
|
3180
|
+
} catch (err) {
|
|
3181
|
+
console.warn(
|
|
3182
|
+
`[orchestrator] recovery: ${task.taskId} GitHub auth setup failed before uai-init: ${
|
|
3183
|
+
err instanceof Error ? err.message : String(err)
|
|
3184
|
+
}`,
|
|
3185
|
+
);
|
|
3186
|
+
}
|
|
3187
|
+
}
|
|
3155
3188
|
// uai-init reinstalls workspace deps (pnpm/npm install) — minutes on a big
|
|
3156
3189
|
// repo. dockerCli's 30s default would SIGKILL it mid-install.
|
|
3157
|
-
|
|
3158
|
-
|
|
3159
|
-
|
|
3190
|
+
const initResult = await dockerCli(
|
|
3191
|
+
[
|
|
3192
|
+
"exec",
|
|
3193
|
+
"-e",
|
|
3194
|
+
"UAI_SKIP_GIT_TRANSPORT=1",
|
|
3195
|
+
containerName,
|
|
3196
|
+
"/usr/local/bin/uai-init",
|
|
3197
|
+
],
|
|
3198
|
+
{ timeoutMs: 10 * 60_000 },
|
|
3199
|
+
);
|
|
3200
|
+
if (initResult.status !== 0) {
|
|
3160
3201
|
console.warn(
|
|
3161
3202
|
`[orchestrator] recovery: ${task.taskId} uai-init failed; container is up but Editor may be down`,
|
|
3162
3203
|
);
|
|
@@ -3165,12 +3206,13 @@ async function recoverOneTask(
|
|
|
3165
3206
|
db_setRuntime(task.taskId, {
|
|
3166
3207
|
codeServerPort: port,
|
|
3167
3208
|
});
|
|
3168
|
-
|
|
3169
|
-
|
|
3170
|
-
|
|
3171
|
-
|
|
3172
|
-
|
|
3173
|
-
|
|
3209
|
+
void ensureTaskSshIdentity(task.taskId, task.ownerUserId).catch((err) =>
|
|
3210
|
+
console.warn(
|
|
3211
|
+
`[ssh] task ${task.taskId}: resumed signing identity reconciliation failed: ${
|
|
3212
|
+
err instanceof Error ? err.message : String(err)
|
|
3213
|
+
}`,
|
|
3214
|
+
),
|
|
3215
|
+
);
|
|
3174
3216
|
console.log(
|
|
3175
3217
|
`[orchestrator] recovery: ${task.taskId} resumed (port ${port ?? "?"})`,
|
|
3176
3218
|
);
|
package/lib/repo-clone.ts
CHANGED
|
@@ -1,33 +1,18 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
2
|
+
* Retired bare-mirror host command.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
4
|
+
* Project creation stores metadata only. Repository access happens at
|
|
5
|
+
* task-up, after the task creator and host are known, using that user's
|
|
6
|
+
* connected GitHub credential or their explicit SSH fallback. This legacy
|
|
7
|
+
* protocol command cannot express either principal, so running Git here would
|
|
8
|
+
* necessarily risk borrowing the host operator's ambient credential.
|
|
7
9
|
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
* origin/<defaultBranch>` would fail with "invalid reference". `task-up`
|
|
13
|
-
* self-heals an existing mirror's refspec on subsequent runs, but only
|
|
14
|
-
* if the first task-up runs online — initialising the mirror correctly
|
|
15
|
-
* here closes the offline-first-task gap.
|
|
16
|
-
*
|
|
17
|
-
* Sync in v0.1 — the spec doesn't pin sync-vs-async, and async pipelines
|
|
18
|
-
* add a state machine we don't need until the UI shows progress.
|
|
19
|
-
*
|
|
20
|
-
* Idempotent: if the bare mirror already exists, (re-)assert the refspec
|
|
21
|
-
* and treat the repo as ready without re-fetching. Healing an existing
|
|
22
|
-
* mirror's refspec is cheap and protects against a mirror created before
|
|
23
|
-
* this helper learned the right refspec.
|
|
10
|
+
* Keep the command-shaped response for rolling-version compatibility, but
|
|
11
|
+
* fail closed without touching the filesystem or network. A future preflight
|
|
12
|
+
* needs a user/host-aware protocol of its own; see
|
|
13
|
+
* docs/repo-access-preflight.md.
|
|
24
14
|
*/
|
|
25
15
|
|
|
26
|
-
import { spawnSync } from "node:child_process";
|
|
27
|
-
import { existsSync, rmSync, statSync } from "node:fs";
|
|
28
|
-
import { mkdirSync } from "node:fs";
|
|
29
|
-
import { dirname } from "node:path";
|
|
30
|
-
|
|
31
16
|
import { projectRepoMirror } from "./env";
|
|
32
17
|
|
|
33
18
|
export interface CloneRepoInput {
|
|
@@ -42,86 +27,12 @@ export interface CloneRepoResult {
|
|
|
42
27
|
absolutePath: string;
|
|
43
28
|
}
|
|
44
29
|
|
|
45
|
-
const REMOTE_REFSPEC = "+refs/heads/*:refs/remotes/origin/*";
|
|
46
|
-
|
|
47
30
|
export function cloneRepo(input: CloneRepoInput): CloneRepoResult {
|
|
48
31
|
const target = projectRepoMirror(input.projectId);
|
|
49
|
-
|
|
50
|
-
if (existsSync(target) && statSync(target).isDirectory()) {
|
|
51
|
-
// Heal an existing mirror's refspec — no-op when already correct.
|
|
52
|
-
const setRefspec = git(target, [
|
|
53
|
-
"config",
|
|
54
|
-
"remote.origin.fetch",
|
|
55
|
-
REMOTE_REFSPEC,
|
|
56
|
-
]);
|
|
57
|
-
if (setRefspec.status !== 0) {
|
|
58
|
-
return errorResult(target, setRefspec, "git config remote.origin.fetch");
|
|
59
|
-
}
|
|
60
|
-
return { status: "ready", absolutePath: target };
|
|
61
|
-
}
|
|
62
|
-
|
|
63
|
-
mkdirSync(dirname(target), { recursive: true });
|
|
64
|
-
|
|
65
|
-
const init = spawnSync("git", ["init", "--bare", target], { encoding: "utf8" });
|
|
66
|
-
if (init.status !== 0) {
|
|
67
|
-
return errorResult(target, init, "git init --bare");
|
|
68
|
-
}
|
|
69
|
-
|
|
70
|
-
const remote = git(target, ["remote", "add", "origin", input.url]);
|
|
71
|
-
if (remote.status !== 0) {
|
|
72
|
-
cleanup(target);
|
|
73
|
-
return errorResult(target, remote, "git remote add origin");
|
|
74
|
-
}
|
|
75
|
-
|
|
76
|
-
const refspec = git(target, [
|
|
77
|
-
"config",
|
|
78
|
-
"remote.origin.fetch",
|
|
79
|
-
REMOTE_REFSPEC,
|
|
80
|
-
]);
|
|
81
|
-
if (refspec.status !== 0) {
|
|
82
|
-
cleanup(target);
|
|
83
|
-
return errorResult(target, refspec, "git config remote.origin.fetch");
|
|
84
|
-
}
|
|
85
|
-
|
|
86
|
-
const fetch = git(target, ["fetch", "origin"]);
|
|
87
|
-
if (fetch.status !== 0) {
|
|
88
|
-
// A failed initial fetch leaves a half-built mirror that task-up
|
|
89
|
-
// would later mistake for a healthy one. Tear it down so the next
|
|
90
|
-
// retry starts clean.
|
|
91
|
-
cleanup(target);
|
|
92
|
-
return errorResult(target, fetch, "git fetch origin");
|
|
93
|
-
}
|
|
94
|
-
|
|
95
|
-
return { status: "ready", absolutePath: target };
|
|
96
|
-
}
|
|
97
|
-
|
|
98
|
-
function git(
|
|
99
|
-
cwd: string,
|
|
100
|
-
args: string[],
|
|
101
|
-
): ReturnType<typeof spawnSync> {
|
|
102
|
-
return spawnSync("git", ["-C", cwd, ...args], { encoding: "utf8" });
|
|
103
|
-
}
|
|
104
|
-
|
|
105
|
-
function cleanup(target: string): void {
|
|
106
|
-
try {
|
|
107
|
-
rmSync(target, { recursive: true, force: true });
|
|
108
|
-
} catch {
|
|
109
|
-
// Best-effort: the caller will surface the original error.
|
|
110
|
-
}
|
|
111
|
-
}
|
|
112
|
-
|
|
113
|
-
function errorResult(
|
|
114
|
-
target: string,
|
|
115
|
-
result: ReturnType<typeof spawnSync>,
|
|
116
|
-
step: string,
|
|
117
|
-
): CloneRepoResult {
|
|
118
32
|
return {
|
|
119
33
|
status: "error",
|
|
120
34
|
absolutePath: target,
|
|
121
|
-
error:
|
|
122
|
-
|
|
123
|
-
)
|
|
124
|
-
.trim()
|
|
125
|
-
.slice(0, 1024),
|
|
35
|
+
error:
|
|
36
|
+
"repo.clone is retired because it has no task creator credential; start a task to perform the user-scoped repository fetch",
|
|
126
37
|
};
|
|
127
38
|
}
|
package/lib/ssh.ts
CHANGED
|
@@ -143,11 +143,12 @@ export function keyPairForUser(
|
|
|
143
143
|
};
|
|
144
144
|
}
|
|
145
145
|
|
|
146
|
-
// --- per-task on-disk identity (
|
|
146
|
+
// --- per-task on-disk identity (optional SSH fallback + signing, ADR-029) -----
|
|
147
147
|
//
|
|
148
|
-
// task-up.sh
|
|
149
|
-
//
|
|
150
|
-
//
|
|
148
|
+
// task-up.sh uses the creator's key for host Git only when GitHub is not
|
|
149
|
+
// connected, and installs it in the container for signing (plus that same
|
|
150
|
+
// fallback). The cloud relays ssh.key.get/ensure/delete for the Account card
|
|
151
|
+
// via the store functions above.
|
|
151
152
|
|
|
152
153
|
function taskIdentityDir(taskId: string): string {
|
|
153
154
|
return resolve(env.dataDir, "identity", "tasks", taskId);
|
|
@@ -155,10 +156,12 @@ function taskIdentityDir(taskId: string): string {
|
|
|
155
156
|
|
|
156
157
|
/**
|
|
157
158
|
* Materialize the task creator's keypair to a per-task on-disk identity that
|
|
158
|
-
* task-up.sh uses for
|
|
159
|
-
* (ADR-029)
|
|
160
|
-
*
|
|
161
|
-
*
|
|
159
|
+
* task-up.sh uses for the disconnected user's host-side SSH fallback and the
|
|
160
|
+
* container's optional signing/fallback identity (ADR-029). Connected users
|
|
161
|
+
* clone + push with their GitHub App user token over HTTPS instead.
|
|
162
|
+
* Returns the dir, or null when the creator has no key on this host. A task
|
|
163
|
+
* never borrows the host operator's identity: without a connected GitHub
|
|
164
|
+
* credential, SSH transport is available only through this owner's key. The
|
|
162
165
|
* decrypted key lives only on the trusted host (ADR-015) and is removed right
|
|
163
166
|
* after task-up — see {@link removeTaskIdentity}.
|
|
164
167
|
*/
|
package/package.json
CHANGED
package/scripts/agent/_common.sh
CHANGED
|
@@ -204,6 +204,69 @@ UAI_CURRENT_STEP=""
|
|
|
204
204
|
UAI_ERROR_CODE="UNKNOWN"
|
|
205
205
|
UAI_TASK_ID=""
|
|
206
206
|
UAI_LOCK_HELD=""
|
|
207
|
+
UAI_PROJECT_LOCK_PATH=""
|
|
208
|
+
UAI_PROJECT_LOCK_TOKEN=""
|
|
209
|
+
|
|
210
|
+
# Shared project mirrors are mutated by task-up and task-down across different
|
|
211
|
+
# task processes. A task-row lock cannot serialize those operations, so use a
|
|
212
|
+
# portable atomic directory lock (macOS does not ship `flock`). Never reclaim
|
|
213
|
+
# a stale lock automatically: checking a PID and unlinking a fixed path creates
|
|
214
|
+
# a TOCTOU window where a new owner's lock can be deleted. A stale directory is
|
|
215
|
+
# deliberately surfaced for an operator to inspect and remove.
|
|
216
|
+
acquire_project_lock() {
|
|
217
|
+
local projects_root="$1" project_id="$2"
|
|
218
|
+
local project_parent="$projects_root/$project_id"
|
|
219
|
+
local lock_path="$project_parent/.uai-repo.lock"
|
|
220
|
+
local owner_pid attempts=0
|
|
221
|
+
mkdir -p "$project_parent"
|
|
222
|
+
while ! mkdir "$lock_path" 2>/dev/null; do
|
|
223
|
+
if [ ! -d "$lock_path" ]; then
|
|
224
|
+
emit_err "FETCH_FAILED" \
|
|
225
|
+
"project $project_id has an obsolete or invalid repository lock at $lock_path. Confirm no task-up/task-down process is active, remove that lock manually, then retry." \
|
|
226
|
+
"acquire project cache lock"
|
|
227
|
+
fi
|
|
228
|
+
owner_pid=$(cat "$lock_path/pid" 2>/dev/null || true)
|
|
229
|
+
case "$owner_pid" in
|
|
230
|
+
''|*[!0-9]*) ;;
|
|
231
|
+
*)
|
|
232
|
+
if ! kill -0 "$owner_pid" 2>/dev/null; then
|
|
233
|
+
emit_err "FETCH_FAILED" \
|
|
234
|
+
"project $project_id has a stale repository lock at $lock_path (owner pid $owner_pid). Confirm no task-up/task-down process is active, remove that lock directory manually, then retry." \
|
|
235
|
+
"acquire project cache lock"
|
|
236
|
+
fi
|
|
237
|
+
;;
|
|
238
|
+
esac
|
|
239
|
+
attempts=$((attempts + 1))
|
|
240
|
+
if [ "$attempts" -ge 300 ]; then
|
|
241
|
+
emit_err "FETCH_FAILED" \
|
|
242
|
+
"project $project_id is busy in another host operation, or has an incomplete lock at $lock_path. Confirm no task-up/task-down process is active; if none is, remove that lock directory manually, then retry." \
|
|
243
|
+
"acquire project cache lock"
|
|
244
|
+
fi
|
|
245
|
+
sleep 0.1
|
|
246
|
+
done
|
|
247
|
+
UAI_PROJECT_LOCK_TOKEN="$$:${RANDOM:-0}"
|
|
248
|
+
printf '%s\n' "$$" > "$lock_path/pid"
|
|
249
|
+
printf '%s\n' "$UAI_PROJECT_LOCK_TOKEN" > "$lock_path/owner"
|
|
250
|
+
UAI_PROJECT_LOCK_PATH="$lock_path"
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
release_project_lock() {
|
|
254
|
+
[ -n "$UAI_PROJECT_LOCK_PATH" ] || return 0
|
|
255
|
+
local owner
|
|
256
|
+
owner=$(cat "$UAI_PROJECT_LOCK_PATH/owner" 2>/dev/null || true)
|
|
257
|
+
if [ -n "$UAI_PROJECT_LOCK_TOKEN" ] \
|
|
258
|
+
&& [ "$owner" = "$UAI_PROJECT_LOCK_TOKEN" ]; then
|
|
259
|
+
rm -f "$UAI_PROJECT_LOCK_PATH/pid" "$UAI_PROJECT_LOCK_PATH/owner" \
|
|
260
|
+
2>/dev/null || true
|
|
261
|
+
rmdir "$UAI_PROJECT_LOCK_PATH" 2>/dev/null || true
|
|
262
|
+
fi
|
|
263
|
+
UAI_PROJECT_LOCK_PATH=""
|
|
264
|
+
UAI_PROJECT_LOCK_TOKEN=""
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
_uai_on_exit() {
|
|
268
|
+
release_project_lock || true
|
|
269
|
+
}
|
|
207
270
|
|
|
208
271
|
step() {
|
|
209
272
|
UAI_ERROR_CODE="$1"
|
|
@@ -230,6 +293,7 @@ _uai_on_err() {
|
|
|
230
293
|
|
|
231
294
|
setup_err_trap() {
|
|
232
295
|
trap _uai_on_err ERR
|
|
296
|
+
trap _uai_on_exit EXIT
|
|
233
297
|
}
|
|
234
298
|
|
|
235
299
|
# ---------------------------------------------------------------------------
|
|
@@ -246,3 +310,153 @@ compose_project_for_task() { printf 'task-%s' "$(_uai_lower "$1")"; }
|
|
|
246
310
|
# ever do scale, swap to discovering the container name via the
|
|
247
311
|
# `com.docker.compose.project` + `com.docker.compose.service` labels.
|
|
248
312
|
app_container_for_task() { printf 'task-%s-app-1' "$(_uai_lower "$1")"; }
|
|
313
|
+
|
|
314
|
+
# Canonicalise every GitHub URL shape to credential-free HTTPS. task-up selects
|
|
315
|
+
# the credential per task: the creator's connected GitHub token is primary;
|
|
316
|
+
# SSH is a fallback only when that user has no GitHub connection on this host.
|
|
317
|
+
# Keeping the stored/shared origin transport-neutral avoids binding a project
|
|
318
|
+
# to whichever user's credential happened to touch its mirror first.
|
|
319
|
+
#
|
|
320
|
+
# Mirrors normalizeRepoUrl() in lib/github/repo-url.ts — keep the two in step.
|
|
321
|
+
# Non-GitHub remotes are returned untouched: we only know how to speak for
|
|
322
|
+
# GitHub, and Uai supports any git remote.
|
|
323
|
+
#
|
|
324
|
+
# Bash 3.2 (macOS /bin/bash): no ${var,,}, no associative arrays.
|
|
325
|
+
normalize_github_repo_url() {
|
|
326
|
+
local input="$1" url after_scheme authority host_port host host_lc
|
|
327
|
+
local path owner rest repo repo_lc left
|
|
328
|
+
url=$(printf '%s' "$input" | sed \
|
|
329
|
+
-e 's/^[[:space:]]*//' -e 's/[[:space:]]*$//')
|
|
330
|
+
if [ "${url#*://}" != "$url" ]; then
|
|
331
|
+
after_scheme=${url#*://}
|
|
332
|
+
authority=${after_scheme%%/*}
|
|
333
|
+
[ "$after_scheme" != "$authority" ] || {
|
|
334
|
+
printf '%s' "$url"
|
|
335
|
+
return
|
|
336
|
+
}
|
|
337
|
+
host_port=${authority##*@}
|
|
338
|
+
host=${host_port%%:*}
|
|
339
|
+
host_lc=$(printf '%s' "$host" | tr '[:upper:]' '[:lower:]')
|
|
340
|
+
case "$host_lc" in
|
|
341
|
+
github.com|www.github.com) path=${after_scheme#*/} ;;
|
|
342
|
+
*)
|
|
343
|
+
printf '%s' "$url"
|
|
344
|
+
return
|
|
345
|
+
;;
|
|
346
|
+
esac
|
|
347
|
+
elif [ "${url#*:}" != "$url" ]; then
|
|
348
|
+
left=${url%%:*}
|
|
349
|
+
host=${left##*@}
|
|
350
|
+
host_lc=$(printf '%s' "$host" | tr '[:upper:]' '[:lower:]')
|
|
351
|
+
case "$host_lc" in
|
|
352
|
+
github.com|www.github.com) path=${url#*:} ;;
|
|
353
|
+
*)
|
|
354
|
+
printf '%s' "$url"
|
|
355
|
+
return
|
|
356
|
+
;;
|
|
357
|
+
esac
|
|
358
|
+
else
|
|
359
|
+
case "$url" in
|
|
360
|
+
*/*/*|/*)
|
|
361
|
+
printf '%s' "$url"
|
|
362
|
+
return
|
|
363
|
+
;;
|
|
364
|
+
*/*) path=$url ;;
|
|
365
|
+
*)
|
|
366
|
+
printf '%s' "$url"
|
|
367
|
+
return
|
|
368
|
+
;;
|
|
369
|
+
esac
|
|
370
|
+
fi
|
|
371
|
+
|
|
372
|
+
# Keep only owner/repo. Browser URLs can include /tree/branch/path, and a
|
|
373
|
+
# pasted clone URL can carry both `.git` and a trailing slash.
|
|
374
|
+
path=${path%%\?*}
|
|
375
|
+
path=${path%%\#*}
|
|
376
|
+
path=$(printf '%s' "$path" | sed -e 's#/*$##')
|
|
377
|
+
owner=${path%%/*}
|
|
378
|
+
rest=${path#*/}
|
|
379
|
+
if [ -z "$owner" ] || [ "$rest" = "$path" ]; then
|
|
380
|
+
printf '%s' "$url"
|
|
381
|
+
return
|
|
382
|
+
fi
|
|
383
|
+
repo=${rest%%/*}
|
|
384
|
+
repo=${repo%%\?*}
|
|
385
|
+
repo=${repo%%\#*}
|
|
386
|
+
repo_lc=$(printf '%s' "$repo" | tr '[:upper:]' '[:lower:]')
|
|
387
|
+
case "$repo_lc" in
|
|
388
|
+
*.git) repo=${repo%????} ;;
|
|
389
|
+
esac
|
|
390
|
+
if [ -z "$repo" ]; then
|
|
391
|
+
printf '%s' "$url"
|
|
392
|
+
return
|
|
393
|
+
fi
|
|
394
|
+
printf 'https://github.com/%s/%s.git' "$owner" "$repo"
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
# True for a reference whose explicit authority is github.com, even if its
|
|
398
|
+
# path is malformed. Callers use this to fail closed rather than handing a
|
|
399
|
+
# GitHub-looking URL to a generic Git command with ambient credentials.
|
|
400
|
+
is_github_hosted_url() {
|
|
401
|
+
local input="$1" url after_scheme authority host_port host host_lc left
|
|
402
|
+
url=$(printf '%s' "$input" | sed \
|
|
403
|
+
-e 's/^[[:space:]]*//' -e 's/[[:space:]]*$//')
|
|
404
|
+
if [ "${url#*://}" != "$url" ]; then
|
|
405
|
+
after_scheme=${url#*://}
|
|
406
|
+
authority=${after_scheme%%/*}
|
|
407
|
+
host_port=${authority##*@}
|
|
408
|
+
host=${host_port%%:*}
|
|
409
|
+
elif [ "${url#*:}" != "$url" ]; then
|
|
410
|
+
left=${url%%:*}
|
|
411
|
+
host=${left##*@}
|
|
412
|
+
else
|
|
413
|
+
case "$url" in
|
|
414
|
+
*/*/*|/*|*://*) return 1 ;;
|
|
415
|
+
*/*) return 0 ;;
|
|
416
|
+
*) return 1 ;;
|
|
417
|
+
esac
|
|
418
|
+
fi
|
|
419
|
+
host_lc=$(printf '%s' "$host" | tr '[:upper:]' '[:lower:]')
|
|
420
|
+
case "$host_lc" in
|
|
421
|
+
github.com|www.github.com) return 0 ;;
|
|
422
|
+
*) return 1 ;;
|
|
423
|
+
esac
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
# HTTPS spelling of a github.com repository. Empty for non-GitHub remotes.
|
|
427
|
+
github_https_repo_url() {
|
|
428
|
+
local https_url rest owner repo
|
|
429
|
+
https_url=$(normalize_github_repo_url "$1")
|
|
430
|
+
case "$https_url" in
|
|
431
|
+
https://github.com/*)
|
|
432
|
+
rest=${https_url#https://github.com/}
|
|
433
|
+
owner=${rest%%/*}
|
|
434
|
+
repo=${rest#*/}
|
|
435
|
+
if [ "$repo" = "$rest" ]; then
|
|
436
|
+
printf ''
|
|
437
|
+
else
|
|
438
|
+
case "$owner/$repo" in
|
|
439
|
+
/*|*/|*/*/*) printf '' ;;
|
|
440
|
+
*) printf '%s' "$https_url" ;;
|
|
441
|
+
esac
|
|
442
|
+
fi
|
|
443
|
+
;;
|
|
444
|
+
*)
|
|
445
|
+
printf ''
|
|
446
|
+
;;
|
|
447
|
+
esac
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
# SSH spelling used only when the task owner has not connected GitHub.
|
|
451
|
+
github_ssh_repo_url() {
|
|
452
|
+
local https_url
|
|
453
|
+
https_url=$(github_https_repo_url "$1")
|
|
454
|
+
case "$https_url" in
|
|
455
|
+
https://github.com/*)
|
|
456
|
+
printf 'git@github.com:%s' "${https_url#https://github.com/}"
|
|
457
|
+
;;
|
|
458
|
+
*)
|
|
459
|
+
printf ''
|
|
460
|
+
;;
|
|
461
|
+
esac
|
|
462
|
+
}
|