@asc-agent/runtime 0.1.0 → 0.2.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.
- package/README.md +4 -1
- package/dist/cli/asc.d.ts +0 -7
- package/dist/cli/asc.js +308 -148
- package/dist/core/attach/adopt.d.ts +38 -0
- package/dist/core/attach/adopt.js +104 -0
- package/dist/core/attach/bootstrap.d.ts +13 -3
- package/dist/core/attach/bootstrap.js +14 -17
- package/dist/core/attach/setup-plan.d.ts +1 -1
- package/dist/core/attach/setup-plan.js +20 -3
- package/dist/core/attach/setup.d.ts +10 -0
- package/dist/core/attach/setup.js +10 -0
- package/dist/core/distribution/release.d.ts +3 -3
- package/dist/core/distribution/release.js +1 -1
- package/dist/core/monitor/observation.d.ts +2 -2
- package/dist/core/resolver/load.d.ts +12 -1
- package/dist/core/resolver/load.js +23 -2
- package/dist/core/resolver/profile-source.d.ts +29 -0
- package/dist/core/resolver/profile-source.js +139 -0
- package/dist/core/workspace/identity.d.ts +2 -2
- package/dist/core/workspace/index-store.d.ts +10 -10
- package/dist/schemas/profile.d.ts +21 -21
- package/package.json +55 -55
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
/** git이 부르는 이름과 URL. 이름을 버리지 않는 이유는 `origin` 을 알아보기 위해서다. */
|
|
2
|
+
export type RemoteEntry = {
|
|
3
|
+
name: string;
|
|
4
|
+
url: string;
|
|
5
|
+
};
|
|
6
|
+
export type AdoptInput = {
|
|
7
|
+
/** 프로젝트 뿌리의 디렉터리 이름. remote가 없을 때 id와 repository의 근거가 된다. */
|
|
8
|
+
dirName: string;
|
|
9
|
+
remotes: readonly RemoteEntry[];
|
|
10
|
+
/** 사람이 `--id` 로 준 이름. 있으면 추론보다 앞선다. */
|
|
11
|
+
requestedId?: string;
|
|
12
|
+
/**
|
|
13
|
+
* 이 host를 어느 SCM 이름으로 적을 것인가. 모르면 `'git'` 이면 된다 — 그것이 사실이다.
|
|
14
|
+
* provider를 아는 쪽(조립부)이 넘긴다.
|
|
15
|
+
*/
|
|
16
|
+
scmForHost?: (host: string) => string;
|
|
17
|
+
};
|
|
18
|
+
export type AdoptedProfile = {
|
|
19
|
+
id: string;
|
|
20
|
+
/** `ProjectProfile` 로 파싱되는 객체. 파일에 그대로 쓴다. */
|
|
21
|
+
profile: Record<string, unknown>;
|
|
22
|
+
/** 추론하지 못해 비워 둔 것. 숨기지 않고 사람에게 보인다. */
|
|
23
|
+
warnings: string[];
|
|
24
|
+
};
|
|
25
|
+
export declare class AdoptError extends Error {
|
|
26
|
+
readonly code: 'NO_USABLE_ID';
|
|
27
|
+
constructor(code: 'NO_USABLE_ID', message: string);
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* 이름 하나를 id 문법으로 깎는다. 못 깎으면 `null` — 조용히 아무 이름이나 만들지 않는다.
|
|
31
|
+
* `.` 과 `..` 은 문법은 통과하지만 디렉터리로서 뜻이 다르므로 여기서 뺀다.
|
|
32
|
+
*/
|
|
33
|
+
export declare function toProfileId(name: string): string | null;
|
|
34
|
+
/**
|
|
35
|
+
* 이 저장소를 설명하는 최소 Profile. **파일을 읽지도 쓰지도 않는다** — 사실은 호출자가
|
|
36
|
+
* 관측해 넘기고, 여기서는 판단만 한다. 그래야 같은 판단을 테스트가 그대로 돌린다.
|
|
37
|
+
*/
|
|
38
|
+
export declare function buildAdoptedProfile(input: AdoptInput): AdoptedProfile;
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
// 지금 이 저장소를 설명하는 Profile을 만든다 (P0 — Two-URL agent bootstrap).
|
|
2
|
+
//
|
|
3
|
+
// **왜 필요한가**: 배포본에는 예시 Profile 둘뿐이고 둘 다 실 프로젝트용이 아니다. URL만
|
|
4
|
+
// 받은 agent는 `ASC_PROFILE_SELECTION_REQUIRED` 앞에서 고를 것이 없어 사람에게 되물었다.
|
|
5
|
+
// 되물음을 없애려면 "고른다"가 아니라 "만든다"가 있어야 한다.
|
|
6
|
+
//
|
|
7
|
+
// **무엇을 만드는가 — 추론 가능한 최소치뿐이다.** git remote에서 읽히는 사실
|
|
8
|
+
// (id · scm · repository)만 적는다. branch 정본·role 경계·정책은 이 저장소를 봐서
|
|
9
|
+
// 알 수 없다. 모르는 것을 그럴듯하게 채우면 두 가지가 같이 무너진다:
|
|
10
|
+
//
|
|
11
|
+
// canonical source를 지어내면 세션 발급이 그 정본을 실제로 읽으려 하고, 자격이 없는
|
|
12
|
+
// 기계에서 issue 자체가 실패한다 (session.ts §readBaselines)
|
|
13
|
+
// role 경계를 지어내면 지어낸 경계가 곧 사람이 겪는 SCOPE_ESCALATION이 된다 —
|
|
14
|
+
// FAIL 회차에서 번들 Profile이 정확히 그렇게 막았다
|
|
15
|
+
//
|
|
16
|
+
// 그래서 adopt의 산출물은 **시작점**이다. 팀의 정책은 팀이 이 파일을 키워서 정한다.
|
|
17
|
+
//
|
|
18
|
+
// **provider 어휘는 여기 없다** (C-09 §6.1). 어느 host가 어느 provider인지는 Adapter를
|
|
19
|
+
// 아는 쪽이 정하고, 여기서는 `scmForHost` 로 받는다 — core가 특정 provider 이름으로
|
|
20
|
+
// 갈라지기 시작하면 그 다음 provider는 반드시 이 파일을 고쳐야 들어온다.
|
|
21
|
+
import { normalizeRemote } from "../workspace/identity.js";
|
|
22
|
+
export class AdoptError extends Error {
|
|
23
|
+
code;
|
|
24
|
+
constructor(code, message) {
|
|
25
|
+
super(message);
|
|
26
|
+
this.name = 'AdoptError';
|
|
27
|
+
this.code = code;
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
/** Profile id는 디렉터리 이름 한 칸이다 (profile-source.ts SAFE_ID와 같은 문법). */
|
|
31
|
+
const SAFE_ID = /^[A-Za-z0-9][A-Za-z0-9._-]*$/;
|
|
32
|
+
/**
|
|
33
|
+
* 이름 하나를 id 문법으로 깎는다. 못 깎으면 `null` — 조용히 아무 이름이나 만들지 않는다.
|
|
34
|
+
* `.` 과 `..` 은 문법은 통과하지만 디렉터리로서 뜻이 다르므로 여기서 뺀다.
|
|
35
|
+
*/
|
|
36
|
+
export function toProfileId(name) {
|
|
37
|
+
const cut = name
|
|
38
|
+
.trim()
|
|
39
|
+
.replace(/\.git$/, '')
|
|
40
|
+
.replace(/[^A-Za-z0-9._-]+/g, '-')
|
|
41
|
+
.replace(/^[^A-Za-z0-9]+/, '')
|
|
42
|
+
.replace(/-+$/, '');
|
|
43
|
+
if (!cut || cut === '.' || cut === '..' || !SAFE_ID.test(cut))
|
|
44
|
+
return null;
|
|
45
|
+
return cut;
|
|
46
|
+
}
|
|
47
|
+
/** `origin` 이 있으면 그것이다. 없으면 첫 번째 — 다만 그 사실을 경고로 남긴다. */
|
|
48
|
+
function primaryRemote(remotes) {
|
|
49
|
+
return remotes.find((r) => r.name === 'origin') ?? remotes[0];
|
|
50
|
+
}
|
|
51
|
+
/**
|
|
52
|
+
* 이 저장소를 설명하는 최소 Profile. **파일을 읽지도 쓰지도 않는다** — 사실은 호출자가
|
|
53
|
+
* 관측해 넘기고, 여기서는 판단만 한다. 그래야 같은 판단을 테스트가 그대로 돌린다.
|
|
54
|
+
*/
|
|
55
|
+
export function buildAdoptedProfile(input) {
|
|
56
|
+
const warnings = [];
|
|
57
|
+
const remote = primaryRemote(input.remotes);
|
|
58
|
+
const distinctUrls = new Set(input.remotes.map((r) => r.url));
|
|
59
|
+
if (remote && remote.name !== 'origin' && distinctUrls.size > 1) {
|
|
60
|
+
warnings.push(`No 'origin' remote — used '${remote.name}' (${remote.url}) to identify this project.`);
|
|
61
|
+
}
|
|
62
|
+
// `host/group/project` 로 정규화된다. 자격·포트·질의는 여기 들어오지 않는다.
|
|
63
|
+
const alias = remote ? normalizeRemote(remote.url) : null;
|
|
64
|
+
const slash = alias?.indexOf('/') ?? -1;
|
|
65
|
+
const host = alias && slash > 0 ? alias.slice(0, slash) : null;
|
|
66
|
+
// repository는 host를 뺀 `owner/repo` 다 — Adapter가 API에 그대로 넘기는 형태가 그것이다.
|
|
67
|
+
const repository = alias && slash > 0 ? alias.slice(slash + 1) : null;
|
|
68
|
+
const id = input.requestedId ?? (repository ? toProfileId(repository.split('/').pop()) : null) ?? toProfileId(input.dirName);
|
|
69
|
+
if (id === null) {
|
|
70
|
+
throw new AdoptError('NO_USABLE_ID', `Could not make a profile id out of '${input.dirName}' — pass one with --id <name> (letters, digits, '.', '_', '-').`);
|
|
71
|
+
}
|
|
72
|
+
if (input.requestedId !== undefined && !SAFE_ID.test(input.requestedId)) {
|
|
73
|
+
throw new AdoptError('NO_USABLE_ID', `'${input.requestedId}' is not a profile id — use a single name made of letters, digits, '.', '_' or '-'.`);
|
|
74
|
+
}
|
|
75
|
+
if (!repository) {
|
|
76
|
+
warnings.push(remote
|
|
77
|
+
? `Remote '${remote.url}' is not a shared repository address — this profile describes a local project.`
|
|
78
|
+
: 'This repository has no remote — this profile describes a local project.');
|
|
79
|
+
}
|
|
80
|
+
warnings.push('canonical.sources is empty: no branch is treated as the source of truth yet. Declare one before' +
|
|
81
|
+
' relying on canonical verification.');
|
|
82
|
+
warnings.push('policy.roleScopes is empty: no role is narrowed yet. Declare scopes when the team agrees on them.');
|
|
83
|
+
return {
|
|
84
|
+
id,
|
|
85
|
+
warnings,
|
|
86
|
+
profile: {
|
|
87
|
+
schemaVersion: 1,
|
|
88
|
+
$comment: 'Adopted from this repository by `asc profile adopt`. It carries only what a git remote can prove —' +
|
|
89
|
+
' the project identity. Canonical branches, role boundaries and policy are decisions this tool cannot' +
|
|
90
|
+
' make for you: add them here as the team agrees on them.',
|
|
91
|
+
id,
|
|
92
|
+
project: {
|
|
93
|
+
// 공유 주소가 없으면 로컬 프로젝트다. 있으면 host를 아는 쪽이 이름을 준다.
|
|
94
|
+
scm: host === null ? 'local' : (input.scmForHost?.(host) ?? 'git'),
|
|
95
|
+
repository: repository ?? `local/${toProfileId(input.dirName) ?? id}`,
|
|
96
|
+
},
|
|
97
|
+
canonical: {
|
|
98
|
+
$comment: 'Empty means canonical verification does not apply. Add a source (id, provider, remote, ref) to' +
|
|
99
|
+
' have sessions check the project against a real baseline.',
|
|
100
|
+
sources: [],
|
|
101
|
+
},
|
|
102
|
+
},
|
|
103
|
+
};
|
|
104
|
+
}
|
|
@@ -66,8 +66,13 @@ export type BootstrapPlan = {
|
|
|
66
66
|
};
|
|
67
67
|
export type BootstrapInput = {
|
|
68
68
|
cwd: string;
|
|
69
|
-
/** ASC 설치 경로. `profiles/` 를 여기서 읽는다. */
|
|
69
|
+
/** ASC 설치 경로. 내장 `profiles/` 를 여기서 읽는다. */
|
|
70
70
|
installRoot: string;
|
|
71
|
+
/**
|
|
72
|
+
* 사용자 소유 Profile 디렉터리(보통 `ASC_HOME/profiles`). **Surface가 정해서 넘긴다** —
|
|
73
|
+
* Core가 홈 경로를 스스로 알면 그 순간 저장소·사용자 공간의 경계를 Core가 쥐게 된다.
|
|
74
|
+
*/
|
|
75
|
+
externalProfileRoot?: string;
|
|
71
76
|
/**
|
|
72
77
|
* 이 위치가 붙어 있다면 그 runtime 뿌리. **Surface가 정해서 넘긴다** — Core가 여기서
|
|
73
78
|
* 다시 찾으면 저장소 안의 `.asc` 만 보게 되고, 그것이 기본이 아닌 지금은
|
|
@@ -94,8 +99,13 @@ export type BootstrapInput = {
|
|
|
94
99
|
/** 정책 질문 자체를 생략할지. 계획만 보려는 호출용이다. */
|
|
95
100
|
askPolicy?: boolean;
|
|
96
101
|
};
|
|
97
|
-
/**
|
|
98
|
-
|
|
102
|
+
/**
|
|
103
|
+
* 지금 고를 수 있는 Profile. 설치된 배포본 안과 **사용자 소유 공간** 둘 다 본다 —
|
|
104
|
+
* 팀이 나눠 갖는 실 Profile은 배포본에 들어가지 않으므로 후자가 없으면 후보가 없다.
|
|
105
|
+
*
|
|
106
|
+
* 없으면 빈 목록이다 — 그것도 사람이 알아야 할 사실이다.
|
|
107
|
+
*/
|
|
108
|
+
export declare function availableProfiles(installRoot: string, externalProfileRoot?: string): Promise<string[]>;
|
|
99
109
|
/** 감지하고 계획을 세운다. 파일은 하나도 건드리지 않는다. */
|
|
100
110
|
export declare function planBootstrap(input: BootstrapInput): Promise<BootstrapPlan>;
|
|
101
111
|
/** 사람이 읽는 계획. 무엇이 정해지지 않았는지가 먼저 오도록 짠다. */
|
|
@@ -13,6 +13,7 @@
|
|
|
13
13
|
import { readdir, readFile } from 'node:fs/promises';
|
|
14
14
|
import { join } from 'node:path';
|
|
15
15
|
import { availableCapabilities } from "../binding/types.js";
|
|
16
|
+
import { listProfileLocations } from "../resolver/profile-source.js";
|
|
16
17
|
import { discoverProjectRoot, exists } from "./init.js";
|
|
17
18
|
/**
|
|
18
19
|
* 정책은 사람이 정한다 (C-09 §7.1). adapter가 찾아 주는 것과 그것이 무슨 뜻인지 정하는 것은
|
|
@@ -25,22 +26,18 @@ export const POLICY_QUESTIONS = [
|
|
|
25
26
|
{ id: 'authority', question: 'decision authority는 누구인가' },
|
|
26
27
|
{ id: 'digest-channel', question: '기본 전달 채널은 무엇인가' },
|
|
27
28
|
];
|
|
28
|
-
/**
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
}
|
|
41
|
-
catch {
|
|
42
|
-
return [];
|
|
43
|
-
}
|
|
29
|
+
/**
|
|
30
|
+
* 지금 고를 수 있는 Profile. 설치된 배포본 안과 **사용자 소유 공간** 둘 다 본다 —
|
|
31
|
+
* 팀이 나눠 갖는 실 Profile은 배포본에 들어가지 않으므로 후자가 없으면 후보가 없다.
|
|
32
|
+
*
|
|
33
|
+
* 없으면 빈 목록이다 — 그것도 사람이 알아야 할 사실이다.
|
|
34
|
+
*/
|
|
35
|
+
export async function availableProfiles(installRoot, externalProfileRoot) {
|
|
36
|
+
const found = await listProfileLocations({
|
|
37
|
+
installRoot,
|
|
38
|
+
...(externalProfileRoot ? { externalRoot: externalProfileRoot } : {}),
|
|
39
|
+
});
|
|
40
|
+
return [...new Set(found.map((location) => location.id))].sort();
|
|
44
41
|
}
|
|
45
42
|
/** 붙어 있다면 무엇으로 붙었는가. lock을 읽지 못하면 붙이다 만 상태로 본다. */
|
|
46
43
|
async function attachedProfile(ascRoot) {
|
|
@@ -58,7 +55,7 @@ export async function planBootstrap(input) {
|
|
|
58
55
|
// 넘겨받은 것이 없을 때만 저장소 안을 본다 — 옛 경로를 계속 지원하되 기본으로 두지 않는다.
|
|
59
56
|
const ascRoot = input.ascRoot ?? join(projectRoot, '.asc');
|
|
60
57
|
const attached = await exists(ascRoot);
|
|
61
|
-
const candidates = await availableProfiles(input.installRoot);
|
|
58
|
+
const candidates = await availableProfiles(input.installRoot, input.externalProfileRoot);
|
|
62
59
|
const hosts = [...(input.hosts ?? [])];
|
|
63
60
|
const plan = input.bindings ?? { bindings: [] };
|
|
64
61
|
const locked = attached ? await attachedProfile(ascRoot) : null;
|
|
@@ -67,7 +67,7 @@ export type SetupCode =
|
|
|
67
67
|
* agent는 `portable` 만 실행하면 되고, 산문을 읽을 필요가 없다.
|
|
68
68
|
*/
|
|
69
69
|
export type NextAction = {
|
|
70
|
-
type: 'select_profile' | 'install_runtime' | 'apply_setup' | 'proceed' | 'force_host_install';
|
|
70
|
+
type: 'select_profile' | 'adopt_profile' | 'install_runtime' | 'apply_setup' | 'proceed' | 'force_host_install';
|
|
71
71
|
display: string;
|
|
72
72
|
portable: string;
|
|
73
73
|
};
|
|
@@ -66,6 +66,11 @@ export function computeSetupPlan(state) {
|
|
|
66
66
|
}
|
|
67
67
|
}
|
|
68
68
|
if (state.ascRoot) {
|
|
69
|
+
// 붙어 있어도 **무엇을 고를 수 있었는지**는 사실이다. 사용자 소유 Profile을 새로 놓고
|
|
70
|
+
// 계획을 물었을 때 그것이 어디에도 안 보이면, 놓은 사람은 경로를 의심하게 된다.
|
|
71
|
+
if (state.profileCandidates.length > 0) {
|
|
72
|
+
evidence.push(`profile candidates=${state.profileCandidates.join(', ')}`);
|
|
73
|
+
}
|
|
69
74
|
return finish(changes, evidence, state, mode, command);
|
|
70
75
|
}
|
|
71
76
|
// 아직 안 붙었다. 무엇으로 붙을지는 사람이 정한다.
|
|
@@ -78,9 +83,21 @@ export function computeSetupPlan(state) {
|
|
|
78
83
|
changes,
|
|
79
84
|
requiresUserAction: true,
|
|
80
85
|
profiles: state.profileCandidates,
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
86
|
+
// 고를 것이 **없을** 수도 있다 — 배포본이 들고 있는 것은 예시뿐이고, 이 프로젝트를
|
|
87
|
+
// 설명하는 Profile은 아직 아무도 만들지 않았다. 그때 "골라라"만 주면 막다른 길이
|
|
88
|
+
// 된다(FAIL 회차에서 agent가 사람에게 되물은 자리). 만드는 길을 함께 든다.
|
|
89
|
+
//
|
|
90
|
+
// 순서는 **지금 무엇이 실제로 길을 여는가**로 정한다. 첫 action이 늘 같으면
|
|
91
|
+
// agent는 첫 줄만 보고 막힌 길로 간다.
|
|
92
|
+
...actions(mode, evidence, state.profileCandidates.length > 0
|
|
93
|
+
? [
|
|
94
|
+
{ type: 'select_profile', ...command(['setup', 'apply', '--profile', '<id>']) },
|
|
95
|
+
{ type: 'adopt_profile', ...command(['profile', 'adopt', '--json']) },
|
|
96
|
+
]
|
|
97
|
+
: [
|
|
98
|
+
{ type: 'adopt_profile', ...command(['profile', 'adopt', '--json']) },
|
|
99
|
+
{ type: 'select_profile', ...command(['setup', 'apply', '--profile', '<id>']) },
|
|
100
|
+
]),
|
|
84
101
|
};
|
|
85
102
|
}
|
|
86
103
|
if (state.scope === 'project') {
|
|
@@ -20,12 +20,22 @@ export type SetupGate = {
|
|
|
20
20
|
};
|
|
21
21
|
export type SetupStatus = {
|
|
22
22
|
attachment: AttachmentState;
|
|
23
|
+
/** 지금 붙어 있는 Profile과 그 출처. 붙지 않았으면 없다. */
|
|
24
|
+
profile?: {
|
|
25
|
+
id: string;
|
|
26
|
+
origin: 'built-in' | 'external';
|
|
27
|
+
};
|
|
23
28
|
/** 설정과 무관하게 지금 되는 것. */
|
|
24
29
|
ready: string[];
|
|
25
30
|
gates: SetupGate[];
|
|
26
31
|
};
|
|
27
32
|
export type SetupInput = {
|
|
28
33
|
attachment: AttachmentState;
|
|
34
|
+
/** 붙어 있는 Profile의 id와 출처. **Surface가 읽어 넘긴다** — Core는 경로를 모른다. */
|
|
35
|
+
profile?: {
|
|
36
|
+
id: string;
|
|
37
|
+
origin: 'built-in' | 'external';
|
|
38
|
+
};
|
|
29
39
|
/** 승인 권한자 매핑이 하나라도 있는가. */
|
|
30
40
|
hasApprovers: boolean;
|
|
31
41
|
/** override의 controller.identities가 채워졌는가. */
|
|
@@ -26,6 +26,7 @@ const RESOLVE_AGAIN = 'after editing, re-lock with `asc profile resolve --write`
|
|
|
26
26
|
export function assessSetup(input) {
|
|
27
27
|
return {
|
|
28
28
|
attachment: input.attachment,
|
|
29
|
+
...(input.profile ? { profile: input.profile } : {}),
|
|
29
30
|
ready: [...ALWAYS_READY],
|
|
30
31
|
gates: [approvalGate(input), monitorGate(input), externalWriteGate(input)],
|
|
31
32
|
};
|
|
@@ -116,6 +117,15 @@ export function renderSetup(status) {
|
|
|
116
117
|
case 'READY':
|
|
117
118
|
break;
|
|
118
119
|
}
|
|
120
|
+
// 어떤 Profile로 도는지, 그리고 그것이 **어디서 왔는지**. 배포본에 딸려 온 예시로
|
|
121
|
+
// 실 프로젝트를 돌리고 있는 상태를 사람이 모르고 지나가지 않게 한다.
|
|
122
|
+
if (status.profile) {
|
|
123
|
+
const origin = status.profile.origin === 'external'
|
|
124
|
+
? 'your own profile directory'
|
|
125
|
+
: 'bundled with the installed package';
|
|
126
|
+
lines.push(`profile: ${status.profile.id} — ${origin}`);
|
|
127
|
+
lines.push('');
|
|
128
|
+
}
|
|
119
129
|
lines.push('Working now:');
|
|
120
130
|
for (const item of status.ready)
|
|
121
131
|
lines.push(` ${item}`);
|
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
export declare const RUNTIME_PACKAGE = "@asc-agent/runtime";
|
|
2
2
|
export declare const BOOTSTRAP_PACKAGE = "@asc-agent/bootstrap";
|
|
3
3
|
/** runtime과 bootstrap은 초기 release에서 lockstep이다. */
|
|
4
|
-
export declare const RELEASE_VERSION = "0.
|
|
5
|
-
export declare const RUNTIME_SPEC = "@asc-agent/runtime@0.
|
|
6
|
-
export declare const BOOTSTRAP_SPEC = "@asc-agent/bootstrap@0.
|
|
4
|
+
export declare const RELEASE_VERSION = "0.2.0";
|
|
5
|
+
export declare const RUNTIME_SPEC = "@asc-agent/runtime@0.2.0";
|
|
6
|
+
export declare const BOOTSTRAP_SPEC = "@asc-agent/bootstrap@0.2.0";
|
|
7
7
|
/**
|
|
8
8
|
* 아직 설치되지 않은 machine에서 그대로 실행되는 형태 (C-14 §3.4).
|
|
9
9
|
*
|
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
export const RUNTIME_PACKAGE = '@asc-agent/runtime';
|
|
10
10
|
export const BOOTSTRAP_PACKAGE = '@asc-agent/bootstrap';
|
|
11
11
|
/** runtime과 bootstrap은 초기 release에서 lockstep이다. */
|
|
12
|
-
export const RELEASE_VERSION = '0.
|
|
12
|
+
export const RELEASE_VERSION = '0.2.0';
|
|
13
13
|
export const RUNTIME_SPEC = `${RUNTIME_PACKAGE}@${RELEASE_VERSION}`;
|
|
14
14
|
export const BOOTSTRAP_SPEC = `${BOOTSTRAP_PACKAGE}@${RELEASE_VERSION}`;
|
|
15
15
|
/**
|
|
@@ -25,16 +25,16 @@ export declare const Observation: z.ZodObject<{
|
|
|
25
25
|
reference: string;
|
|
26
26
|
disposition: "INBOX" | "SHADOW";
|
|
27
27
|
evidence: string[];
|
|
28
|
+
lastSeenAt: string;
|
|
28
29
|
revisionMarker: string;
|
|
29
30
|
firstSeenAt: string;
|
|
30
|
-
lastSeenAt: string;
|
|
31
31
|
reason?: string | undefined;
|
|
32
32
|
}, {
|
|
33
33
|
reference: string;
|
|
34
34
|
disposition: "INBOX" | "SHADOW";
|
|
35
|
+
lastSeenAt: string;
|
|
35
36
|
revisionMarker: string;
|
|
36
37
|
firstSeenAt: string;
|
|
37
|
-
lastSeenAt: string;
|
|
38
38
|
evidence?: string[] | undefined;
|
|
39
39
|
reason?: string | undefined;
|
|
40
40
|
}>;
|
|
@@ -1,10 +1,13 @@
|
|
|
1
1
|
import { OperationalPreset, ProfileLock, ProjectProfile, UserOverride } from '../../schemas/profile.ts';
|
|
2
2
|
import type { MonitorConfig } from '../monitor/signals.ts';
|
|
3
3
|
import type { OwnershipMap } from '../policy/ownership.ts';
|
|
4
|
+
import { type ProfileOrigin } from './profile-source.ts';
|
|
4
5
|
import { type ConfigLayer, type ResolvedProfile } from './resolve.ts';
|
|
5
6
|
export type LoadedLayers = {
|
|
6
7
|
profile: ProjectProfile;
|
|
7
8
|
profileSource: string;
|
|
9
|
+
/** 그 Profile이 어디서 왔는가 — 배포본 안인가, 사용자 소유 공간인가. */
|
|
10
|
+
profileOrigin: ProfileOrigin;
|
|
8
11
|
preset?: OperationalPreset;
|
|
9
12
|
presetSource?: string;
|
|
10
13
|
override?: UserOverride;
|
|
@@ -23,12 +26,18 @@ export type ResolvedRuntime = {
|
|
|
23
26
|
};
|
|
24
27
|
export declare const digest: (value: unknown) => string;
|
|
25
28
|
/**
|
|
26
|
-
*
|
|
29
|
+
* Profile과 Preset을 읽고, 프로젝트에서 Override를 읽는다.
|
|
27
30
|
* Override가 없는 것은 정상이다 — 팀 설정만으로도 돌아가야 한다.
|
|
31
|
+
*
|
|
32
|
+
* Profile은 설치 경로 안일 수도, 사용자 소유 공간일 수도 있다. **어디서 고를지는
|
|
33
|
+
* 여기서 정하지 않는다** — profile-source.ts가 정하고 여기는 그 결과를 읽는다.
|
|
34
|
+
* Preset은 아직 배포본 안에만 있다(팀이 나눠 갖는 것은 Profile 쪽이다).
|
|
28
35
|
*/
|
|
29
36
|
export declare function loadLayers(input: {
|
|
30
37
|
installRoot: string;
|
|
31
38
|
profileId: string;
|
|
39
|
+
/** 사용자 소유 Profile 디렉터리. 보통 `ASC_HOME/profiles`. */
|
|
40
|
+
externalProfileRoot?: string;
|
|
32
41
|
presetId?: string;
|
|
33
42
|
overridePath?: string;
|
|
34
43
|
}): Promise<LoadedLayers>;
|
|
@@ -113,6 +122,8 @@ export type BootstrapOutcome = {
|
|
|
113
122
|
export declare function bootstrapGuard(input: {
|
|
114
123
|
ascRoot: string;
|
|
115
124
|
installRoot: string;
|
|
125
|
+
/** 사용자 소유 Profile 디렉터리. 붙을 때 쓴 것과 같은 자리를 봐야 한다. */
|
|
126
|
+
externalProfileRoot?: string;
|
|
116
127
|
profileId?: string;
|
|
117
128
|
presetId?: string;
|
|
118
129
|
capabilities: readonly string[];
|
|
@@ -13,6 +13,7 @@ import { constants } from 'node:fs';
|
|
|
13
13
|
import { access, mkdir, readFile, rename } from 'node:fs/promises';
|
|
14
14
|
import { join } from 'node:path';
|
|
15
15
|
import { OperationalPreset, ProfileLock, ProjectProfile, UserOverride } from "../../schemas/profile.js";
|
|
16
|
+
import { ProfileSourceError, resolveProfileLocation } from "./profile-source.js";
|
|
16
17
|
import { resolveProfile } from "./resolve.js";
|
|
17
18
|
import { satisfies } from "./version.js";
|
|
18
19
|
async function pathExists(path) {
|
|
@@ -29,12 +30,30 @@ async function readJson(path) {
|
|
|
29
30
|
return JSON.parse(await readFile(path, 'utf8'));
|
|
30
31
|
}
|
|
31
32
|
/**
|
|
32
|
-
*
|
|
33
|
+
* Profile과 Preset을 읽고, 프로젝트에서 Override를 읽는다.
|
|
33
34
|
* Override가 없는 것은 정상이다 — 팀 설정만으로도 돌아가야 한다.
|
|
35
|
+
*
|
|
36
|
+
* Profile은 설치 경로 안일 수도, 사용자 소유 공간일 수도 있다. **어디서 고를지는
|
|
37
|
+
* 여기서 정하지 않는다** — profile-source.ts가 정하고 여기는 그 결과를 읽는다.
|
|
38
|
+
* Preset은 아직 배포본 안에만 있다(팀이 나눠 갖는 것은 Profile 쪽이다).
|
|
34
39
|
*/
|
|
35
40
|
export async function loadLayers(input) {
|
|
36
|
-
const
|
|
41
|
+
const location = await resolveProfileLocation(input.profileId, {
|
|
42
|
+
installRoot: input.installRoot,
|
|
43
|
+
...(input.externalProfileRoot ? { externalRoot: input.externalProfileRoot } : {}),
|
|
44
|
+
});
|
|
45
|
+
const profileSource = location.path;
|
|
37
46
|
const profile = ProjectProfile.parse(await readJson(profileSource));
|
|
47
|
+
// 디렉터리 이름과 선언된 id가 다르면 여기서 멈춘다.
|
|
48
|
+
//
|
|
49
|
+
// 둘이 다르면 lock에는 **선언된 id**가 박히고 다음 실행은 그 id로 파일을 찾는다 —
|
|
50
|
+
// 그런 디렉터리는 없으므로 붙자마자 아무 명령도 돌지 않는 workspace가 된다.
|
|
51
|
+
// 독립 검증이 실물로 보인 결함이고, 되돌리려면 `.asc/` 를 손으로 지워야 했다.
|
|
52
|
+
if (profile.id !== input.profileId) {
|
|
53
|
+
throw new ProfileSourceError('PROFILE_ID_MISMATCH', `${profileSource} declares id '${profile.id}' but lives in a directory named ` +
|
|
54
|
+
`'${input.profileId}'. Make the two the same — everything after this point looks ` +
|
|
55
|
+
'the profile up by the id it declares.');
|
|
56
|
+
}
|
|
38
57
|
let preset;
|
|
39
58
|
let presetSource;
|
|
40
59
|
if (input.presetId) {
|
|
@@ -54,6 +73,7 @@ export async function loadLayers(input) {
|
|
|
54
73
|
return {
|
|
55
74
|
profile,
|
|
56
75
|
profileSource,
|
|
76
|
+
profileOrigin: location.origin,
|
|
57
77
|
...(preset ? { preset } : {}),
|
|
58
78
|
...(presetSource ? { presetSource } : {}),
|
|
59
79
|
...(override ? { override } : {}),
|
|
@@ -220,6 +240,7 @@ export async function bootstrapGuard(input) {
|
|
|
220
240
|
const locked = ProfileLock.parse(JSON.parse(lockedRaw));
|
|
221
241
|
const layers = await loadLayers({
|
|
222
242
|
installRoot: input.installRoot,
|
|
243
|
+
...(input.externalProfileRoot ? { externalProfileRoot: input.externalProfileRoot } : {}),
|
|
223
244
|
profileId: input.profileId ?? locked.profile.id,
|
|
224
245
|
...(input.presetId ?? locked.preset?.id ? { presetId: input.presetId ?? locked.preset.id } : {}),
|
|
225
246
|
overridePath: join(input.ascRoot, 'override.json'),
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
export type ProfileOrigin = 'built-in' | 'external';
|
|
2
|
+
export type ProfileLocation = {
|
|
3
|
+
id: string;
|
|
4
|
+
origin: ProfileOrigin;
|
|
5
|
+
/** `profile.json` 의 절대 경로. 파일이 아직 없을 수도 있다 — 읽는 쪽이 그 사실을 만난다. */
|
|
6
|
+
path: string;
|
|
7
|
+
};
|
|
8
|
+
export type ProfileRoots = {
|
|
9
|
+
/** 설치된 배포본의 뿌리. `profiles/` 가 그 아래 있다. */
|
|
10
|
+
installRoot: string;
|
|
11
|
+
/** 사용자 소유 Profile 디렉터리. 보통 `ASC_HOME/profiles`. 없으면 external은 없는 것이다. */
|
|
12
|
+
externalRoot?: string;
|
|
13
|
+
};
|
|
14
|
+
/** 왜 Profile을 고르지 못했는가. 문장이 아니라 코드로 든다 — Surface가 다르게 말해야 한다. */
|
|
15
|
+
export type ProfileSourceCode = 'INVALID_PROFILE_ID' | 'PROFILE_COLLISION' | 'PROFILE_ESCAPES_ROOT' | 'PROFILE_ID_MISMATCH';
|
|
16
|
+
export declare class ProfileSourceError extends Error {
|
|
17
|
+
code: ProfileSourceCode;
|
|
18
|
+
constructor(code: ProfileSourceCode, message: string);
|
|
19
|
+
}
|
|
20
|
+
export declare function assertProfileId(id: string): void;
|
|
21
|
+
/** 이 뿌리들 아래에 실제로 있는 Profile 전부. external이 먼저 온다. */
|
|
22
|
+
export declare function listProfileLocations(roots: ProfileRoots): Promise<ProfileLocation[]>;
|
|
23
|
+
/**
|
|
24
|
+
* 이 id의 Profile은 어디 있는가.
|
|
25
|
+
*
|
|
26
|
+
* 아무 데도 없으면 **built-in 자리를 돌려준다** — 파일이 없다는 사실은 읽는 쪽에서
|
|
27
|
+
* 그대로 드러나야 하고, 그 오류 모양은 v0.1과 같아야 한다.
|
|
28
|
+
*/
|
|
29
|
+
export declare function resolveProfileLocation(id: string, roots: ProfileRoots): Promise<ProfileLocation>;
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
// Profile이 어디서 오는가 — 설치된 배포본 안, 그리고 사용자 소유 공간.
|
|
2
|
+
//
|
|
3
|
+
// v0.1까지 Profile은 **설치 경로 안에만** 있었다. 그래서 공개 패키지를 설치한 사람은
|
|
4
|
+
// 자기 프로젝트의 Profile을 쓸 방법이 없었다 — 팀에 배포할 수 없다는 뜻이다.
|
|
5
|
+
// 여기서 여는 것은 그 한 갈래다: `ASC_HOME/profiles/<id>/profile.json`.
|
|
6
|
+
//
|
|
7
|
+
// 자리를 고르는 규칙이 이 파일의 전부다:
|
|
8
|
+
// 1. 사람이 명시한 것 Surface가 이미 id로 넘긴다
|
|
9
|
+
// 2. 사용자 소유 external ASC_HOME — 팀이 나눠 갖는 실 Profile이 여기 온다
|
|
10
|
+
// 3. 배포본 내장 built-in 예시·fixture. 아무것도 없을 때의 바닥
|
|
11
|
+
//
|
|
12
|
+
// **같은 id가 양쪽에 있으면 멈춘다.** 조용히 하나를 고르면 "무엇을 읽었는가"를 사람이
|
|
13
|
+
// 알 수 없고, 그 답이 정책이 되므로 틀린 쪽으로 조용히 도는 것보다 서는 편이 낫다.
|
|
14
|
+
//
|
|
15
|
+
// Profile은 코드가 아니라 데이터다. 여기서 하는 일은 **경로를 고르는 것뿐**이고,
|
|
16
|
+
// 읽기·검증·digest는 기존 resolver 계약(load.ts)이 그대로 한다.
|
|
17
|
+
import { constants } from 'node:fs';
|
|
18
|
+
import { access, readdir, realpath } from 'node:fs/promises';
|
|
19
|
+
import { isAbsolute, join, relative, resolve, sep } from 'node:path';
|
|
20
|
+
export class ProfileSourceError extends Error {
|
|
21
|
+
code;
|
|
22
|
+
constructor(code, message) {
|
|
23
|
+
super(message);
|
|
24
|
+
this.name = 'ProfileSourceError';
|
|
25
|
+
this.code = code;
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* Profile id는 **디렉터리 이름 한 칸**이다.
|
|
30
|
+
*
|
|
31
|
+
* 경로 조각을 허용하면 id 하나로 `profiles/` 밖의 파일을 읽게 된다 — `../../secrets`
|
|
32
|
+
* 같은 것. 그래서 여기서 문법으로 막고, 아래에서 실제 경로가 뿌리 안에 있는지 한 번 더 본다
|
|
33
|
+
* (문법만 믿지 않는다 — symlink는 문법을 통과한다).
|
|
34
|
+
*/
|
|
35
|
+
const SAFE_ID = /^[A-Za-z0-9][A-Za-z0-9._-]*$/;
|
|
36
|
+
export function assertProfileId(id) {
|
|
37
|
+
if (!SAFE_ID.test(id) || id === '.' || id === '..') {
|
|
38
|
+
throw new ProfileSourceError('INVALID_PROFILE_ID', `'${id}' is not a profile id — use a single name made of letters, digits, '.', '_' or '-'`);
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
/** 글자만 보고 판단한다. 첫 번째 자물쇠 — symlink·junction은 여기를 그냥 통과한다. */
|
|
42
|
+
function withinLexically(root, target) {
|
|
43
|
+
const rel = relative(resolve(root), resolve(target));
|
|
44
|
+
return rel !== '' && !rel.startsWith(`..${sep}`) && rel !== '..' && !isAbsolute(rel);
|
|
45
|
+
}
|
|
46
|
+
/**
|
|
47
|
+
* **실제로** 그 뿌리 안인가. 두 번째 자물쇠다.
|
|
48
|
+
*
|
|
49
|
+
* 글자 검사만으로는 부족하다는 것을 독립 검증이 실물로 보였다 — Windows junction
|
|
50
|
+
* (`mklink /J`)이나 symlink를 `profiles/` 안에 만들면 이름은 한 칸이고 경로도 뿌리 안처럼
|
|
51
|
+
* 보이는데, 실제 파일은 바깥에 있다. 그 파일이 프로젝트의 정책이 되면 lock에 적히는
|
|
52
|
+
* 출처마저 link 경로라 사람이 진짜 자리를 알 수 없다.
|
|
53
|
+
*
|
|
54
|
+
* 그래서 존재하는 것은 realpath로 한 번 더 본다. 없는 경로는 판정 대상이 아니다.
|
|
55
|
+
*/
|
|
56
|
+
async function withinReally(root, target) {
|
|
57
|
+
try {
|
|
58
|
+
const [realRoot, realTarget] = await Promise.all([realpath(root), realpath(target)]);
|
|
59
|
+
return withinLexically(realRoot, realTarget);
|
|
60
|
+
}
|
|
61
|
+
catch {
|
|
62
|
+
// 아직 없는 파일이면 글자 판단만으로 충분하다 — 읽는 쪽에서 없다는 사실을 만난다.
|
|
63
|
+
return withinLexically(root, target);
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
const profilePath = (root, id) => join(root, id, 'profile.json');
|
|
67
|
+
const exists = async (path) => {
|
|
68
|
+
try {
|
|
69
|
+
await access(path, constants.F_OK);
|
|
70
|
+
return true;
|
|
71
|
+
}
|
|
72
|
+
catch {
|
|
73
|
+
return false;
|
|
74
|
+
}
|
|
75
|
+
};
|
|
76
|
+
/** 이 뿌리들 아래에 실제로 있는 Profile 전부. external이 먼저 온다. */
|
|
77
|
+
export async function listProfileLocations(roots) {
|
|
78
|
+
const found = [];
|
|
79
|
+
const dirs = [
|
|
80
|
+
['external', roots.externalRoot],
|
|
81
|
+
['built-in', join(roots.installRoot, 'profiles')],
|
|
82
|
+
];
|
|
83
|
+
for (const [origin, dir] of dirs) {
|
|
84
|
+
if (!dir)
|
|
85
|
+
continue;
|
|
86
|
+
let entries;
|
|
87
|
+
try {
|
|
88
|
+
entries = await readdir(dir, { withFileTypes: true });
|
|
89
|
+
}
|
|
90
|
+
catch {
|
|
91
|
+
continue; // 없는 디렉터리는 "Profile 0개"다. 오류가 아니다.
|
|
92
|
+
}
|
|
93
|
+
for (const entry of entries) {
|
|
94
|
+
// junction·symlink는 Windows에서 `isDirectory()` 가 아니다. 목록에서만 빼면
|
|
95
|
+
// "후보에는 없는데 --profile 로는 붙는" 상태가 되므로 여기서도 같이 본다 —
|
|
96
|
+
// 발견과 해석이 다른 답을 하면 사람은 둘 다 못 믿는다.
|
|
97
|
+
if (!entry.isDirectory() && !entry.isSymbolicLink())
|
|
98
|
+
continue;
|
|
99
|
+
if (!SAFE_ID.test(entry.name))
|
|
100
|
+
continue; // 이름이 문법 밖이면 고를 수 없는 것이다
|
|
101
|
+
const path = profilePath(dir, entry.name);
|
|
102
|
+
if (!(await withinReally(dir, path)))
|
|
103
|
+
continue;
|
|
104
|
+
if (await exists(path))
|
|
105
|
+
found.push({ id: entry.name, origin, path });
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
return found;
|
|
109
|
+
}
|
|
110
|
+
/**
|
|
111
|
+
* 이 id의 Profile은 어디 있는가.
|
|
112
|
+
*
|
|
113
|
+
* 아무 데도 없으면 **built-in 자리를 돌려준다** — 파일이 없다는 사실은 읽는 쪽에서
|
|
114
|
+
* 그대로 드러나야 하고, 그 오류 모양은 v0.1과 같아야 한다.
|
|
115
|
+
*/
|
|
116
|
+
export async function resolveProfileLocation(id, roots) {
|
|
117
|
+
assertProfileId(id);
|
|
118
|
+
const builtInDir = join(roots.installRoot, 'profiles');
|
|
119
|
+
const builtIn = profilePath(builtInDir, id);
|
|
120
|
+
const externalDir = roots.externalRoot;
|
|
121
|
+
const external = externalDir ? profilePath(externalDir, id) : undefined;
|
|
122
|
+
if (external && !withinLexically(externalDir, external)) {
|
|
123
|
+
throw new ProfileSourceError('INVALID_PROFILE_ID', `'${id}' escapes the profile directory`);
|
|
124
|
+
}
|
|
125
|
+
const hasExternal = external ? await exists(external) : false;
|
|
126
|
+
if (external && hasExternal && !(await withinReally(externalDir, external))) {
|
|
127
|
+
throw new ProfileSourceError('PROFILE_ESCAPES_ROOT', `'${id}' points outside ${externalDir} — a link leads to another place on disk. ` +
|
|
128
|
+
'Put the profile itself in the directory: policy must not come from somewhere the ' +
|
|
129
|
+
'recorded source does not name.');
|
|
130
|
+
}
|
|
131
|
+
const hasBuiltIn = await exists(builtIn);
|
|
132
|
+
if (hasExternal && hasBuiltIn) {
|
|
133
|
+
throw new ProfileSourceError('PROFILE_COLLISION', `two profiles claim the id '${id}' — ${external} and ${builtIn}. ` +
|
|
134
|
+
'Rename one of them: which policy applies must not depend on lookup order.');
|
|
135
|
+
}
|
|
136
|
+
if (hasExternal)
|
|
137
|
+
return { id, origin: 'external', path: external };
|
|
138
|
+
return { id, origin: 'built-in', path: builtIn };
|
|
139
|
+
}
|